オブジェクトの広場はオージス総研グループのエンジニアによる技術発表サイトです

コンテナ・マイクロサービス

Kubernetesオペレーターパターンの活用

GitLabのマージリクエストと連動したレビュー用テスト環境の自動構築
オージス総研 技術部 アドバンストテクノロジセンター
山中 克容
2023年3月30日

カスタムリソース、カスタムコントローラーを活用したユースケースとして、GitLabのマージリクエストと連動したレビュー用テスト環境の自動構築について検証します。

1. はじめに

前回GitLab環境を導入して、プロジェクト開始時にGitLabへのプロジェクト設定を自動構築しました。今回はその環境を引き続き利用して、Kubernetes内にマージリクエスト時のレビュー用テスト環境の自動構築について検証してみたいと思います。

なお、本記事内のコードは抜粋となります。コード全体についてはGithubを参照してください。

1.1 レビュー用テスト環境とは?

Gitを使用して開発する場合、通常はブランチを切って開発を行い単体テスト完了時など開発がひと段落したタイミングでマージリクエスト(GitHubではプルリクエスト)を投げてレビュー依頼をすると思います。通常はコードの差分やCIの結果を元にレビューをすると思いますが、コードだけでは実際仕様通りの挙動になっているか確認が難しいこともあると思います。そこで、マージリクエスト時にレビュー専用の動作確認環境を自動構築することで実際の動作も確認できるようにしてみます。またレビューが完了しマージをしたタイミングで環境は自動的に破棄します。

2. アーキテクチャ

2.1 オペレーター

本題に入る前にKubernetesの自動化について少しだけ触れておきます。

Kubernetesの特長はReconciliation loopによる自動運用にあります。 リソースファイルにあるべき姿を定義することで、コントローラーがその状態になるようにKubernetesに変更を加えますが、それを Reconciliation loopによって常に維持するように動作し続けます。

ReconciliationLoop

このReconciliation loopによる自動化の仕組みはKubernetes利用者も独自にリソースおよびコントローラーを作成することで利用することが可能であり、Kubernetesにおける標準的な自動化手法になります。(これをオペレーターパターンと呼びます)

公式ドキュメント

2.2 全体構成

前回作成したMinikubeおよびMinikube上のGitLab、ArgoCDの環境を引き続き使用します。今回はマージリクエストを作成した際にGitLabCIでビルド及びWebhookの送信を行います。Webhookを受信したらカスタムリソースを作成し、マージリクエストを行ったタイミングのアプリケーションをデプロイする流れになります。全体像は以下のようになります。

アーキテクチャ図

2.3 処理の流れ

今回開発者がマージリクエストを作成してからレビュー担当者がレビュー環境で動作確認するまでには以下の手順が必要になります。

  • マージリクエストの作成をトリガーにWebhookを送信
    • Webhookを受けてカスタムリソースを作成
  • カスタムリソースの内容を元にカスタムコントローラーがArgoCDのApplicationリソース、IstioのVirtualSeriviceリソースを作成
  • ArgoCDがアプリケーションをデプロイ
  • レビュー担当者はレビュー環境用のURLにアクセスして動作確認

それぞれの処理についてもう少し細かく見ていきます。

(1) マージリクエストの作成をトリガーにWebhookを送信

GitLabは各種トリガーでWebhookを送信することができます。今回はマージリクエストをトリガーとしたWebhookを作成します。 Webhookは通常のHTTP通信となるため、Minikube側ではHTTP通信を受信して処理を行うためのWebサーバーを用意します。(Webhookレシーバー)

このWebサーバーでは主に以下の2つの処理を行います。

  • リクエストの送信元が正しいかチェック(トークンチェック)
  • カスタムリソース(今回作成するMergeRequestリソース)の作成

mergerequest

(2) カスタムリソースの処理

WebhookレシーバーがMergeRequestリソースを作成すると、カスタムコントローラーがArgoCDのApplicationリソース、IstioのVirtualServiceリソースを作成します。Applicationリソースはアプリケーションのデプロイに使用します。VirtualServiceリソースは動作確認時にレビュー環境へアクセスする際に適切なPodへ振り分けるためのルーティング情報になります。

customresource

(3) アプリケーションのデプロイ

ArgoCDがGitLabにコミットされているマニフェストにアクセスすることでアプリケーションをデプロイします。

customresource

(4) 動作確認

レビュー担当者はテスト用URLにアクセスして動作確認します。アプリケーションへの振り分けはistio-ingressGatewayがGatewayリソース、VirtualServiveリソースをの設定を元に行います。

customresource

※ Gatewayリソースは事前に作成しておきます

なお、istio-ingressgatewayへのアクセスはGitLabへのアクセス時と同じくminikube tunnelで作成したトンネルを利用します。そのため、GitLabとポートが重複しないように設定しておく必要があります。(後ほど手順は記載します)

2.4 開発するリソース

本記事で実装するリソースは下記2つになります。

(1) Webhookレシーバー

Kubernetes内にWebhookを受信してカスタムリソースを作成するサービスを作成します。これは通常のWebアプリケーションになるので標準のDeploymentおよびServiceリソースでデプロイします。

(2) MergeRequestリソース

マージリクエストに対応してリソースを操作するためのカスタムリソース、カスタムコントローラーを作成します。

3. 環境準備

3.1 オペレーター開発環境

今回作成するアプリケーションはGoで実装します。またカスタムリソース・カスタムコントローラーはOperator-SDKを使用します。今回利用しているバージョンは以下の通りです。

なお、開発環境はWindowsのWSL2(Ubuntu 20.04.3 LTS)になります。

3.2 Istioの導入

クラスタ外からのリクエストをブランチごとのアプリケーションにルーティングするためGatewayリソースを導入します。今回はIstioを利用します。

curl -L https://istio.io/downloadIstio | sh -
cd istio-1.17.1/bin
./istioctl install

※バージョンは執筆時点のものです。

(1) LoadBalancerの設定変更

Istioを導入すると、istio-ingressgatewayサービスが導入されます。デフォルトでは80,443でのリクエストを受け付けるため、GitLabのIngressとポートが被らないようにポート番号は変更しておきます。(GitLabが80番ポートを使用しているため)

kubectl edit svc -n istio-system istio-ingressgateway
  - name: http2
    nodePort: 30947
    port: 18080      # 80 -> 18080
    protocol: TCP
    targetPort: 8080
  - name: https
    nodePort: 32641
    port: 18443       # 443 -> 18443
    protocol: TCP
    targetPort: 8443

(2) Gatewayの導入

Gatewayリソースを導入します。クラスタ外部からの18080ポートアクセスをistio-ingressgatewayに流すように設定します。

apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: application-gateway
  namespace: demo1
spec:
  selector:
    istio: ingressgateway
  servers:
  - port:
      number: 18080
      name: http
      protocol: HTTP
    hosts:
    - "*"

4. オペレーターの実装

事前準備ができましたので、カスタムリソース、カスタムコントローラーを作成していきます。

4.1 カスタムリソースの仕様

今回作成するカスタムリソースの定義は下記の通りです。spec.nameがGitLabのグループに相当し、この範囲でネームスペースを作成します。また、spec.applicationがGitLabのプロジェクトに相当し、targetRevisionでブランチを指定します。つまりマージリクエスト時にデプロイするアプリケーションはspec.name + spec.application + spec.targetRevisionで一意になる仕様となります。

apiVersion: review.nautible.com/v1alpha1
kind: MergeRequest
metadata:
  name: sample-mr
  namespace: operator-system
spec:
  name: demo1
  application: demo1pj1
  baseUrl: "http://gitlab-webservice-default.gitlab.svc.cluster.local:8181"
  manifestPath: "/"
  targetRevision: "HEAD"

4.2 OperatorSDKの初期化処理

オペレーターの初期化処理を行います。この作業によってリソースとコントローラーのスケルトンが生成されます。

operator-sdk init --domain nautible.com --repo github.com/nautible/review-env-operator
operator-sdk create api --group review --version v1alpha1 --kind MergeRequest --resource --controller

いくつかファイルやフォルダが作成されますが、ポイントになるのはapiフォルダとcontrollersフォルダの2つになります。また、コントローラーをビルドするためのMakefileやDockerfileもこの時自動生成されています。

4.3 カスタムリソースの実装

まずはカスタムリソースの定義を作成します。定義は(1) カスタムリソースの仕様で記述したYAMLの内容をGoのStructで定義していくことになります。

カスタムリソースはapiフォルダの下に–versionで指定したv1alpha1がフォルダとして出来上がっています。その中にあるmergerequest_types.goを(1) カスタムリソースの仕様の内容に従い実装します。

mergerequest_types.go(抜粋)

type MergeRequestSpec struct {
    Name             string   `json:"name"`                     // GitLab group
    Application      string   `json:"application"`              // GitLab project
    BaseUrl          string   `json:"baseUrl"`                  // GitLab Base URL
    ManifestPath     string   `json:"manifestPath,omitempty"`   // manifests root path
    TargetRevision   string   `json:"targetRevision,omitempty"` // Application TargetRevision
}

※ 本来はStatusも定義すべきですが、今回は省略します。
※ ManifestPath,TargetRevisionは未入力可(omitempty)として、未入力値はコードでデフォルト値をセットします。

(1) 定義を記載したら関連コードの自動生成を行います。

apis配下にzz_generated.deepcopy.goを生成

make generate

config/crd/bases/配下にYAMLを生成

make manifests

作成されたYAMLに下記のようにSpecが作成されていればカスタムリソースは完成です。

...
    spec:
    description: MergeRequestSpec defines the desired state of MergeRequest
    properties:
        application:
        type: string
        baseUrl:
        type: string
        manifestPath:
        type: string
        name:
        type: string
        targetRevision:
        type: string
    required:
    - application
    - baseUrl
    - name

4.4 カスタムコントローラーの実装

続いてカスタムコントローラーの実装になります。controllersフォルダにあるmergerequest_controller.goを見てみましょう。

自動生成によりReconcileメソッドが作成されています。ここが処理の起点になるので基本的には※この中を実装していきます。

func (r *MergeRequestReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    _ = log.FromContext(ctx)

    // TODO(user): your logic here

    return ctrl.Result{}, nil
}

※ 処理が長くなる場合はpkgフォルダなどを作成してその中に実装を分けていくなど必要に応じてパッケージ分割は行います。

(1) カスタムコントローラーの仕様

カスタムリソースでは以下の処理を実装します。

  • 定義したカスタムリソースの取得
  • ネームスペース作成
  • Applicationリソース作成(ArgoCDリソース)
  • VirtualService作成(Istioリソース)

(2) カスタムリソースの取得

まずは定義したカスタムリソースを取得します。取得にはClientのReaderインターフェースが持つGetメソッドを使用します。

    mr := &reviewv1alpha1.MergeRequest{}
    err := r.Get(ctx, req.NamespacedName, mr)
    if apierrors.IsNotFound(err) {
        return ctrl.Result{}, nil
    } else if err != nil {
        return ctrl.Result{}, err
    }

(3) Namespace作成

NamespaceはKubernetes標準のリソースなのでk8s.io/api/core/v1のAPIを使用して作成します。

...
    ns := &corev1.Namespace{
        ObjectMeta: metav1.ObjectMeta{
            Name: name,
        },
    }
    var namespaceFound corev1.Namespace
    err := r.Get(ctx, client.ObjectKey{Name: name, Namespace: ""}, &namespaceFound)
    if apierrors.IsNotFound(err) {
        if err := r.Create(ctx, ns); err != nil {
            return err
        }
        return nil
    }
...

(4) Applicationリソースの作成

Applicationはアプリケーションのデプロイを管理するArgoCDリソースになります。 今回はGitLabリポジトリのmanifestsフォルダに置いているDeployment,Serviceを使用し、マージリクエストを送信したブランチをターゲットとして指定してデプロイします。

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  finalizers:
  - resources-finalizer.argocd.argoproj.io
  name: demo1-demo1pj1-20230306            # グループ名-プロジェクト名-ブランチ名でリソースを作成
  namespace: argocd
spec:
  destination:
    namespace: demo1
    server: https://kubernetes.default.svc
  project: default
  source:
    path: manifests
    repoURL: http://gitlab-webservice-default.gitlab.svc.cluster.local:8181/demo1/demo1pj1.git
    targetRevision: "20230306"
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    retry: {}

Applicationの実装はArgoCDのAPI(argocdv1alpha1 “github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1")を使用して作成します。

    app := &argocdv1alpha1.Application{
        ObjectMeta: metav1.ObjectMeta{
            Name:      name,
            Namespace: "argocd",
        },
        Spec: argocdv1alpha1.ApplicationSpec{
            Source:               source(p.Spec.BaseUrl, groupName, applicationName, p.Spec.ManifestPath, p.Spec.TargetRevision),
            Destination:          *destination(groupName, "https://kubernetes.default.svc", ""),
            Project:              "default",
            SyncPolicy:           syncPolicy(true, true, false), 
            IgnoreDifferences:    nil,
            Info:                 nil,
            RevisionHistoryLimit: nil,
        },
    }
    err := client.Create(ctx, app)
...
// spec.source部分の生成
func source(baseUrl string, groupName string, applicationName string, manifestPath string, targetRevision string) *argocdv1alpha1.ApplicationSource {
    repoURL := fmt.Sprintf("%s/%s/%s.git", baseUrl, groupName, applicationName)
    if manifestPath == "" {
        manifestPath = "/manifests/overlays/dev/" // default
    }
    if targetRevision == "" {
        targetRevision = "HEAD" // default
    }
    res := &argocdv1alpha1.ApplicationSource{
        RepoURL:        repoURL,
        Path:           manifestPath,
        TargetRevision: targetRevision,
        Helm:           nil,
        Kustomize:      nil,
        Directory:      nil,
        Plugin:         nil,
        Chart:          "",
    }
    return res
}
// spec.destination部分の生成
func destination(namespace string, server string, name string) *argocdv1alpha1.ApplicationDestination {
    res := &argocdv1alpha1.ApplicationDestination{
        Namespace: namespace,
        Server:    server,
        Name:      name,
    }
    return res
}
// spec.syncpolicy部分の生成
func syncPolicy(selfHeal bool, prune bool, allowEmpty bool) *argocdv1alpha1.SyncPolicy {
    res := &argocdv1alpha1.SyncPolicy{
        Automated: &argocdv1alpha1.SyncPolicyAutomated{
            SelfHeal:   selfHeal,
            Prune:      prune,
            AllowEmpty: allowEmpty,
        },
        SyncOptions: argocdv1alpha1.SyncOptions{},
        Retry:       &argocdv1alpha1.RetryStrategy{},
    }
    return res
}

(5) VirtualServiceリソースの作成

VirtualServiceはトラフィック管理を行うためのIstioリソースになります。パスやクエリパラメータ、ヘッダなどL7の情報を利用してルーティングを制御することができます。 今回はクエリパラメータbranchに指定されたブランチ名に基づいて対象ブランチのアプリケーションにルーティングしていきます。

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: demo1-demo1pj1-20230306 # グループ名-プロジェクト名-ブランチ名でリソースを作成
  namespace: demo1
spec:
  gateways:
  - application-gateway
  hosts:
  - '*'
  http:
  - match:
    - queryParams:
        branch:
          exact: "20230306"     # クエリパラメータにbranch=20230306があるか
    name: "20230306"
    route:
    - destination:
        host: demo1pj1-20230306 # ルーティング先のサービス
        port:
          number: 8080

VirtualServiceの実装はIstioのAPI(networkingv1beta1 "istio.io/api/networking/v1beta1")を使用して作成します。

...
    hosts := []string{"*"}
    gateways := []string{"application-gateway"}
    app := &istioclient.VirtualService{
        ObjectMeta: metav1.ObjectMeta{
            Name:      name,
            Namespace: groupName,
        },
        Spec: networkingv1beta1.VirtualService{
            Gateways: gateways,
            Hosts:    hosts,
            Http:     httpRoute(groupName, applicationName, branch),
        },
    }
    err := client.Create(ctx, app)
...
// spec.http部分の生成
func httpRoute(groupName string, applicationName string, branch string) []*networkingv1beta1.HTTPRoute {
    res := &networkingv1beta1.HTTPRoute{
        Name:  branch,
        Match: match(branch),
        Route: route(fmt.Sprintf("%s-%s", applicationName, branch)),
    }
    return []*networkingv1beta1.HTTPRoute{res}
}
// spec.http.match部分の生成
func match(branch string) []*networkingv1beta1.HTTPMatchRequest {
    param := &networkingv1beta1.StringMatch{
        MatchType: &networkingv1beta1.StringMatch_Exact{
            Exact: branch,
        },
    }
    res := &networkingv1beta1.HTTPMatchRequest{
        QueryParams: map[string]*networkingv1beta1.StringMatch{
            "branch": param,
        },
    }
    return []*networkingv1beta1.HTTPMatchRequest{res}
}
// spec.http.route部分の生成
func route(name string) []*networkingv1beta1.HTTPRouteDestination {
    res := &networkingv1beta1.HTTPRouteDestination{
        Destination: &networkingv1beta1.Destination{
            Host: name,
            Port: &networkingv1beta1.PortSelector{
                Number: 8080,
            },
        },
    }
    return []*networkingv1beta1.HTTPRouteDestination{res}
}

(6) スキームの追加

デフォルトでは標準リソースと今回作成するカスタムリソースしか扱えないので、ArgoCDとIstioのカスタムリソースも扱えるようにスキームを追加します。

main.go

import (
    ...
    argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1"  // 追加
    istioclient "istio.io/client-go/pkg/apis/networking/v1beta1"                   // 追加
    ...
)

func init() {
    utilruntime.Must(clientgoscheme.AddToScheme(scheme))

    utilruntime.Must(reviewv1alpha1.AddToScheme(scheme))
    //+kubebuilder:scaffold:scheme

    utilruntime.Must(argocdv1alpha1.AddToScheme(scheme)) // 追加
    utilruntime.Must(istioclient.AddToScheme(scheme))    // 追加
}

(7) 削除処理

削除処理はMergeRequestカスタムリソースの他にArgoCDアプリケーションリソースとIstioリソースを削除する必要があります。そのため、カスタムリソースにはfinalizerを付与しておき、削除時はまず関連リソースを削除した上でカスタムリソースの削除を行います。

※ finalizerを付与しておくと、リソース削除時に物理的な削除は行わず削除日付が付与(論理削除)されるため独自の削除処理を実装することが可能になります

    // finalizer付与
    if !controllerutil.ContainsFinalizer(mr, finalizerName) {
        controllerutil.AddFinalizer(mr, finalizerName)
        err = r.Update(ctx, mr)
        if err != nil {
            return ctrl.Result{}, err
        }
    }

    // 削除日付があれば関連リソースをすべて削除
    if !mr.ObjectMeta.DeletionTimestamp.IsZero() {
        if controllerutil.ContainsFinalizer(mr, finalizerName) {
            // 独自の削除処理
            r.delete(ctx, mr, project)
        }
        // 関連リソース削除後にFinalizerを削除して更新
        controllerutil.RemoveFinalizer(mr, finalizerName)
        err = r.Update(ctx, mr)
        if err != nil {
            return ctrl.Result{}, err
        }
        return ctrl.Result{}, nil
    }

5. カスタムコントローラーをMinikubeへデプロイ

5.1 operatorのイメージを作成

make docker-buildコマンドでイメージを作成します。 標準のフォルダ以外にpkgなどフォルダを追加している場合は事前にDockerfileに追記してイメージ内にコピーしておくようにします。

# Copy the go source
COPY main.go main.go
COPY api/ api/
COPY controllers/ controllers/
# 追加しているフォルダがあれば追記
COPY pkg/ pkg/

今回はローカルのMinikubeにデプロイするためMinikubeに直接イメージを作成します。

eval $(minikube docker-env)
make docker-build IMG="review-env-operator:v0.0.1"

5.2 serviceaccountに権限付与

kubectl create clusterrolebinding serviceaccounts-cluster-admin \
  --clusterrole=cluster-admin \
  --group=system:serviceaccounts

5.3 minikubeにoperatorを導入

make deployでコントローラーをデプロイします。 デフォルトだとoperator-systemネームスペースに作成されるので、正常に起動しているか確認します。

make deploy IMG=review-env-operator:v0.0.1
kubectl get po -n operator-system

NAME                                           READY   STATUS    RESTARTS   AGE
operator-controller-manager-6d79bffd89-66xq4   2/2     Running   0          2m47s

カスタムリソースが認識されているかも確認します。

kubectl api-resources

NAME                              SHORTNAMES         APIVERSION                             NAMESPACED   KIND
...
mergerequests                                        review.nautible.com/v1alpha1           true         MergeRequest
...

6. Webhookからの呼び出し処理を実装

次に、GitLabのマージリクエストから呼び出せるようにWebhookの設定と、Webhookを受信してカスタムリソースを作成するレシーバーを実装します。

6.1 Webhook設定

WebhookはGitLabのプロジェクト(今回の場合demo1/demo1pj1)のSettings>Webhooksから設定します。

項目
URL http://webhook-receiver.gitlab-webhook.svc.cluster.local/webhook?project=<プロジェクト名>
Secret token 任意のトークン(同じ値を後ほど作成するレシーバーの環境変数にも設定します)
Trigger Merge request events

※ WebhookはGitLabのコンテナ(Kubernetes内)からの通信になるため、レシーバーのServiceエンドポイントを指定します。

6.2 レシーバーの実装

レシーバーは通常のWebアプリケーションになります。今回はGoで実装します。

処理の大まかな手順は以下の通りです。

  • トークンの検証
  • リクエストの内容を構造体にパース
  • リクエストの内容に応じてカスタムリソースの作成や削除

手順に沿ってポイントとなるソースを抜粋して記載します。

(1) トークンの検証

GitLabの画面で設定したトークンはヘッダのX-Gitlab-Tokenに設定されています。 今回は簡易的にコンテナの環境変数にも同じ値をセットして値の検証を行います。

token := r.Header.Get("X-Gitlab-Token")
if !validToken(token) {
    w.WriteHeader(http.StatusForbidden)
    fmt.Fprintf(w, "Forbidden")
    return
}
...
func validToken(token string) bool {
    expect := os.Getenv("WEBHOOK_TOKEN")
    if expect == "" {
        return false
    }
    return token == expect
}

(2) リクエストの内容を構造体にパース

GitLabからのリクエストを受け取る構造体(必要なものだけ抜粋)

※APIの詳細仕様については公式ドキュメントを参照してください

type ObjectAttributes struct {
    Title        string `json:"title"`
    Description  string `description:"description"`
    MergeStatus  string `json:"merge_status"`
    SourceBranch string `json:"source_branch"`
    TargetBranch string `json:"target_branch"`
    State        string `json:"state"`
    Action       string `json:"action"`
    CreatedAt    string `json:"created_at"`
    UpdatedAt    string `json:"updated_at"`
}
type MergeRequest struct {
    ObjectKind       string           `json:"object_kind"`
    EventType        string           `json:"event_type"`
    User             User             `json:"user"`
    Project          Project          `json:"project"`
    ObjectAttributes ObjectAttributes `json:"object_attributes"`
}

マージリクエストの内容をパース

body, _ := io.ReadAll(r.Body)
defer r.Body.Close()
var mergeRequest MergeRequest
if err := json.Unmarshal(body, &mergeRequest); err != nil {
    w.WriteHeader(http.StatusInternalServerError)
    fmt.Fprintf(w, "InternalServerError")
    return
}

(3) カスタムリソースの作成・削除

Kubernetesのクライアントライブラリであるclient-goには標準リソースを扱うClientの他に任意のリソースを扱うためのDynamicClientがあります。 今回は独自のカスタムリソース操作したいためDynamicClientを使用します。

DynamicClientの作成

func NewClient() (client *Client, err error) {
    if config == nil {
        var kubeconfig string

        pathToConfig := filepath.Join(homedir.HomeDir(), ".kube", "config")

        if exists(pathToConfig) {
            kubeconfig = pathToConfig
            config, err = clientcmd.BuildConfigFromFlags("", kubeconfig)
        } else {
            config, err = rest.InClusterConfig()
        }
        if err != nil {
            return nil, err
        }
    }
    clientset, err := dynamic.NewForConfig(config)
    if err != nil {
        return nil, err
    }

    return &Client{
        clientset: clientset,
    }, nil
}

Group、Version、Resoruceを指定してカスタムリソースを作成

// Git操作のステータス
state := mergeRequest.ObjectAttributes.State
action := mergeRequest.ObjectAttributes.Action

target := mergeRequest.ObjectAttributes.SourceBranch
application := mergeRequest.Project.Name
group := mergeRequest.Project.Namespace
// マージリクエスト作成時
if state == "opened" && action == "open" {
    resource := schema.GroupVersionResource{Group: "review.nautible.com", Version: "v1alpha1", Resource: "mergerequests"}
    manifest := createManifest(groupName, applicationName, target)
    result, err := c.clientset.Resource(resource).Namespace("operator-system").Create(ctx, manifest, metav1.CreateOptions{})
    if err != nil {
        return err
    }
    fmt.Printf("Created MergeRequest %q.\n", result.GetName())
    return nil
}

マニフェストはunstructured.Unstructured型で定義して利用します。

func createManifest(group string, project string, target string) *unstructured.Unstructured {
    name := fmt.Sprintf("%s-%s-%s", group, project, strings.Replace(target, "/", "-", -1))
    projectResource := &unstructured.Unstructured{
        Object: map[string]interface{}{
            "apiVersion": "review.nautible.com/v1alpha1",
            "kind":       "MergeRequest",
            "metadata": map[string]interface{}{
                "name":       name,
                "namespace":  "operator-system",
                "finalizers": [1]string{"mergerequest.review.nautible.com"},
            },
            "spec": map[string]interface{}{
                "name":           group,
                "application":    project,
                "baseUrl":        "http://gitlab-webservice-default.gitlab.svc.cluster.local:8181",
                "manifestPath":   "/manifests",
                "targetRevision": target,
            },
        },
    }
    return projectResource
}

6.3 動作確認

基本的な実装の流れは以上のようになります。それでは実際に動かしてみましょう。

(1) 権限

gitlab-webhookネームスペース内にある以下のリソースの全操作権限のみ付与

  • Group: review.nautible.com
  • Resource: mergerequests

(2) ビルド

eval $(minikube docker-env)
docker build -t webhook-receiver:v0.0.1 .

(3) デプロイ

Webhookレシーバーをデプロイします。必要なマニフェストについてはGithubのコードを参照してください。

kubectl create ns gitlab-webhook
kubectl apply -f manifests/
kubextl get po -n gitlab-webhook

NAME                                READY   STATUS    RESTARTS   AGE
webhook-receiver-7969f7cbf5-fp6hd   1/1     Running   0          11s

7. マージリクエストからの動作確認

準備がすべて整いましたので、実際にコードを修正してマージリクエストを投げてみます。

7.1 .gitlabci.ymlの修正

前回作成した環境のdeployment.yamlとservice.yamlはリソースのnameを固定で書いているため、そのままではブランチごとに別のアプリをデプロイすることができません。(リソース名が同じだと更新されてしまう)そのため、ブランチごとに異なる名前でDeployment、Serviceをデプロイするようにマージリクエスト作成前にsedでnameを書き換えます。(最後に課題として記載しますが、この手順は暫定です)

また、マニフェスト修正のコミットは新規にブランチを作成せずにコード修正時のブランチと同じ所にコミットするようにしておきます。

      git checkout -b $CI_COMMIT_BRANCH
      sed -i 's/image: ${ecr_uri}\/${group}\/${project}:\(.*\)/image: ${ecr_uri}\/${group}\/${project}:'$CI_COMMIT_SHORT_SHA'/' ./manifests/deployment.yaml 
      sed -i 's/name: ${project}\(.*\)/name: ${project}-'$CI_COMMIT_BRANCH'/' ./manifests/deployment.yaml 
      sed -i 's/name: ${project}\(.*\)/name: ${project}-'$CI_COMMIT_BRANCH'/' ./manifests/service.yaml 
      git add -A
      git commit -m '[ci skip] image update'
      git push http://group_${ci_user}_bot:$PAT@gitlab-webservice-default.gitlab.svc.cluster.local/${group}/${project} $CI_COMMIT_BRANCH
      apk update
      apk add curl
      curl -X POST --header "PRIVATE-TOKEN: ${gitlab_token}" --header "Content-Type: application/json" --data "{\"source_branch\": \"$CI_COMMIT_BRANCH\", \"target_branch\": \"main\", \"title\": \"auto merge request\"}" ${gitlab_url}${gitlab_api}projects/${project_path}/merge_requests

7.2 ブランチ作成

mainからブランチを作成します。ブランチ名は「20230306」とします。

mergerequest1

7.3 マージリクエストの作成

コードを適当に編集します。(ここではlabelsにtest: mergetestを追加しています。)

mergerequest1

現在のブランチ上でコミットします。(マージリクエストはこの後CIから投げるため、Start a new merge requestのチェックは外します)

mergerequest2

パイプラインが正常に完了していることを確認します。

mergerequest3

7.4 リソースの確認

ArgoCDの画面を見ると、アプリケーションが自動的にデプロイされていることが確認できます。

mergerequest2

Istioのリソースも確認します。

kubectl get virtualservice -n demo1

NAME                      GATEWAYS                  HOSTS   AGE
demo1-demo1pj1-20230306   ["application-gateway"]   ["*"]   3m32s

7.5 ブラウザから動作確認

ブラウザからアクセスしてみます。アプリケーションのエンドポイントにクエリパラメータでブランチ名を付与してリクエストします。

http://localhost:18080/?branch=20230306

confirm1

7.6 複数環境の確認

ブランチが異なれば別の環境が構築されることを確認するため、同じ手順でもう1つブランチを作成してマージリクエストしてみましょう。 今度はブランチ名を「20230306-2」、コードは確認しやすいようにDockerfileを修正し、NginxをApacheに変更してみます。

Dockerfile

FROM httpd:alpine

ArgoCDの画面を確認するともう一つアプリケーションがデプロイされたことが確認できます。

confirm2

今度はブランチ名に20230306-2を指定してアクセスしてみましょう。Apacheのトップページが表示されます。

http://localhost:18080/?branch=20230306-2

confirm3

7.7 環境の削除

マージを実行(もしくはマージリクエストをクローズ)するとリソースが削除されることが確認できます。

8. おわりに

今回はGitLabのマージリクエストと連動したアプリケーションのデプロイ/削除の自動化について検証しました。これまでは事前に用意した環境にテストアプリケーションをデプロイしていたため環境のスケールが難しかったのですが、作成/破棄が容易なコンテナの特徴とCIやカスタムコントローラーといった自動化の仕組みを組み合わせることで、必要に応じて簡単に環境を準備することが可能なことがわかりました。

ただ、今回は簡易的な仕様でしたのでいくつか改善点も残っています。

  • 複数のブランチからアプリをデプロイするためリソースの名前が重複しないようDeploymentリソースやSerivceリソースをCI上で書き換えているが、このやり方だとブランチ名の入ったマニフェストがマージされてしまうので変数化などの工夫が必要
  • MergeRequestリソースのステータス管理等の機能も必要
  • レビュー時にリクエストの向き先を簡単に変えれるようにフロント側の考慮も必要
  • 実際の運用ではアプリケーションだけでなくデータベースの準備など他にも検討事項がある

実際に運用する際はこれらの課題も解決できるようにブラッシュアップが必要になります。ですが比較的簡単な実装で自動化していくことができ、運用をスケールすることが可能なカスタムコントローラーはKubernetesを運用する上で非常に重要な仕組みになります。Goの基本的な知識があり、KubernetesのクライアントAPIを使うことができれば実装自体はそれほど難しくはありませんので、これからKubernetesを運用されるという方は是非一度取り組んでみていただければと思います。