🦊

GitLab CIのReview Appsでブランチごとにレビュー用の環境をデプロイする

2023/05/07に公開

Review Apps

https://docs.gitlab.com/ee/ci/review_apps/

Review AppsはGitLab CIの機能の1つで、
アプリケーションの変更をレビューする環境を提供してくれます。

何が嬉しいか?

複数人で開発しているケースにおいて、レビューイ(コードを見てもらう側)は以下の流れになると思います。

  1. トピックブランチを作成する
  2. 変更をコミット・プッシュする
  3. MR(PR)を作成してレビュー依頼を出す

レビュワー側は以下です。

  1. レビュー依頼のあったブランチをローカルに取得する
  2. 環境を立ち上げて動作を確認する
  3. レビュー結果をコメントをする・マージする

プロジェクトの規模によりますが、1・2で手元に環境を用意しないといけないのが結構手間だったりします。
複数人から同時に依頼をもらっている場合だと、頻繁に環境を切り替えていると、
何のレビューをしているのか、環境が意図通りのものなのかわからなくなるかもしれません。

Review Appsでレビュー用の環境を自動で用意することで、こういったわずらわしさから解放されます。


素敵やん!とすぐに飛びつきたい機能ですが、レビュー用の環境を自動で用意するインターフェースが提供されているのみで、仕組みは自前で用意する必要があります。
公式のサンプルではrsyncで実現しているものがあったりしますが、
今回はKubernetesを用いて試してみることにします。

この記事の流れ

  1. Kubernetes構築
  2. Helmのインストール
  3. GitLab Agentの登録
  4. デプロイ用のNamespaceを作成
  5. サンプルアプリの準備
  6. Istioインストール
  7. Helmチャートのファイルを書く
  8. CIでデプロイしてみる
  9. Review Apps

レビュー環境を用意するのがメインコンテンツなので、Review Appsまでが長いです。

なお、実行した環境は以下です。

mac 13.3.1(Intel)
Docker 20.10.14
GitLab https://gitlab.com/

1. Kubernetes構築

検証なので、サクッとローカルに構築します。
手段はいろいろありますが、今回はKind(Kubernetes in Docker)でKubernetesクラスターを作成します。

https://kind.sigs.k8s.io/

> brew install kind

今回は以下の設定ファイルを用意しました。

config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
    extraPortMappings:
    - containerPort: 30950
      hostPort: 80
  - role: worker
  - role: worker
  - role: worker
> kind create cluster --name review-apps --config config.yaml
> kubectl get nodes

NAME                        STATUS   ROLES           AGE   VERSION
review-apps-control-plane   Ready    control-plane   11m   v1.26.3
review-apps-worker          Ready    <none>          10m   v1.26.3
review-apps-worker2         Ready    <none>          10m   v1.26.3
review-apps-worker3         Ready    <none>          10m   v1.26.3

Dockerさえ入っている環境であれば、このようにサクッとKubernetesクラスターを作成することができます。
類似のminikube等と比較すると、マルチノードクラスターを作成できる点が差別化ポイントでしょうか。
※今回はマルチノードクラスターにした意味は特にありません。

2. Helmのインストール

> brew install helm

後述するGitLab Agentや、レビュー用アプリケーションのデプロイで使用します。

3. GitLab Agentの登録

https://docs.gitlab.com/ee/user/clusters/agent/install/index.html

CIが動作するGitLabとデプロイ先のKubernetesクラスターを接続するためには、
クラスターにGitLab Agentをインストールする必要があります。
無くても接続することは可能ですが、ローカルにお試しで構築したクラスターであれば外部公開していないと思うので必須になるでしょう。
GitLab Runnerをセルフホストするのと同じイメージになります。

以下の記事にとてもわかりやすくまとめられていました。
https://qiita.com/ynott/items/35e9492d0681ea8ac60a

  1. GitLabでプロジェクトを作成します。
    今回はreview-apps-sampleという名前で作成しています。
  2. .gitlab/agents/<agent_name>/config.yamlというパスにファイルを作成します。
    config.yamlは空でOKです。
  3. GitLab AgentをHelmでインストールします。
    コマンドは 「Connect a cluster」 ボタンからコピペ可能です。
helm repo add gitlab https://charts.gitlab.io
helm repo update
helm upgrade --install review-apps gitlab/gitlab-agent \
             --namespace gitlab-agent-review-apps \
             --create-namespace \
             --set image.tag=v16.0.0-rc1 \
             --set config.token=XXXXXXXX \
             --set config.kasAddress=wss://kas.gitlab.com

画像のようにAgentが登録されればOKです。

4. デプロイ用のNamespaceを作成

defaultでもいいのですが、せっかくなのでデプロイ環境となるNamespaceを準備します。

> kubectl create namespace review-apps-sample

5. サンプルアプリの準備

今回は、この手の検証でおなじみのNginxでやっていきます。
ディレクトリ構成は以下です。

├── .gitlab
│   └── agents
│       └── review-apps
│           └── config.yaml
├── .gitlab-ci.yml
├── Dockerfile
├── README.md
├── helm
│   ├── Chart.yaml
│   ├── templates
│   │   ├── deployment.yaml
│   │   ├── gateway.yaml
│   │   ├── service.yaml
│   │   └── virtualService.yaml
│   └── values.yaml
└── index.html

表示されるページファイルはこちらです。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Review Apps Demo</title>
</head>
<body>
    <h1>Review Apps 1</h1>
</body>
</html>

6. Istioインストール

公式ドキュメントに記載の方法でインストールします。

> curl -L https://istio.io/downloadIstio | sh -

完了後、istioctlでクラスターにインストールします。

> istioctl install -y

NamespaceにLabelを付与することで、Istio(Proxy)がサイドカーとして各Pod内に自動で起動するようになります。

> kubectl label namespace review-apps-sample istio-injection=enabled

以下のコマンドでWarningが出なければOKです。

> istioctl analyze -n review-apps-sample 

✔ No validation issues found when analyzing namespace: review-apps-sample.

Type: LoadBalancerをNodePortに変更する(Option)

基本的にはインストールは上記で完了ですが、
istio-ingressgatewayType: LoadBalancerとしてデプロイされています。

Kindにはロードバランサーがなく、ドキュメントにもmetallbで頑張って、と書いてあります。
https://kind.sigs.k8s.io/docs/user/loadbalancer/

が、今回はサクッとやりたいのでType: NodePortで動かすことにします。

以下コマンドで設定を書き換えます。

> kubectl -n istio-system edit svc istio-ingressgateway
spec:
  allocateLoadBalancerNodePorts: true
  clusterIP: 10.96.146.220
  clusterIPs:
  - 10.96.146.220
  externalTrafficPolicy: Cluster
  internalTrafficPolicy: Cluster
  ipFamilies:
  - IPv4
  ipFamilyPolicy: SingleStack
  ports:
  - name: status-port
    nodePort: 31406
    port: 15021
    protocol: TCP
    targetPort: 15021
  - name: http2
    # kindのextraPortMappingsのcontainerPortに合わせる
    nodePort: 30950
    port: 80
    protocol: TCP
    targetPort: 8080
  - name: https
    nodePort: 32490
    port: 443
    protocol: TCP
    targetPort: 8443
  selector:
    app: istio-ingressgateway
    istio: ingressgateway
  sessionAffinity: None
  # Loadbalancer -> NodePort
  type: NodePort
status:
  loadBalancer: {}

7. Helmチャートのファイルを書く

├── helm
│   ├── Chart.yaml
│   ├── templates
│   │   ├── deployment.yaml
│   │   ├── gateway.yaml
│   │   ├── service.yaml
│   │   └── virtualService.yaml
│   └── values.yaml

Chart.yamlvalues.yamlはあまり関係ないので省略します。
deployment.yaml, service.yamlもいつものやつ、って感じなので特に説明はなしです。

deployment.yaml
deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}
spec:
  replicas: 1
  selector:
    matchLabels:
      app: {{ .Release.Name }}
  template:
    metadata:
      labels:
        app: {{ .Release.Name }}
    spec:
      containers:
        - name: review-app
          image: doncool340/review-apps-sample:{{ .Values.tag }}
service.yaml
service.yaml
apiVersion: v1
kind: Service
metadata:
  name: {{ .Release.Name }}
spec:
  type: ClusterIP
  selector:
    app: {{ .Release.Name }}
  ports:
    - name: http
      protocol: TCP
      targetPort: 80
      port: 80

gateway.yaml

Ingressを担うやつです。
helm installで指定されるhostに対応するGatewayになります。

gateway.yaml
apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: {{ .Release.Name }}-gateway
spec:
  selector:
    istio: ingressgateway
  servers:
  - port:
      number: 80
      name: http
      protocol: HTTP
    hosts:
      - {{ .Values.host }}

virtualService.yaml

先ほどのgatewayをgatewaysに指定することで紐付けができます。
これで、hostが.Values.hostのリクエストがServiceに流れるようになります。

virtualService.yaml
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: {{ .Release.Name }}
spec:
  gateways:
    - {{ .Release.Name }}-gateway
  hosts:
    - {{ .Values.host }}
  http:
    - route:
      - destination:
          host: {{ .Release.Name }}

8. CIでデプロイしてみる

CIの流れは、DockerイメージをビルドしてDockerHubにPush、その後にHelmインストールするようにしています。
今回はサンプルなのでhostは適当な値です。

.gitlab-ci.yml
stages:
  - build
  - deploy

build:
  stage: build
  image: docker:23.0.5
  services:
    - docker:23.0.5-dind
  variables:
    DOCKER_HOST: tcp://docker:2375
  before_script:
    - echo ${DOCKERHUB_TOKEN} | docker login -u ${DOCKERHUB_USER} --password-stdin
  script:
    - docker build -t <docker_hub_repository>:${CI_COMMIT_REF_SLUG} .
    - docker push <docker_hub_repository>:${CI_COMMIT_REF_SLUG}

deploy:
  stage: deploy
  image: alpine/k8s:1.24.13
  script:
    - kubectl config use-context <group>/review-apps-sample:review-apps
    - helm upgrade -i -n review-apps-sample review-apps-sample-${CI_COMMIT_REF_SLUG} ./helm --set tag=${CI_COMMIT_REF_SLUG} --set host=review-${CI_COMMIT_REF_SLUG}.sample-app.local

<docker_hub_repository>は環境に応じて適切な値を入れます。

deployのジョブにてkubectlを実行していますが、
エージェントが正常に登録されていれば問題なく実行できると思います。
<group>/<project>:<agent>というコンテキストで操作する必要がある点がハマりポイントです。


CIが成功すると、ローカルPC上のクラスターにデプロイされています。
コンテナが2/2になっているのはIstioがPodに注入されているためです。

> kubectl -n review-apps-sample get pod                                                                    
NAME                                       READY   STATUS    RESTARTS   AGE
review-apps-sample-main-65745549c8-bnjg4   2/2     Running   0          78s

上記はmainブランチでのCIのため、CI_COMMIT_REF_SLUGにmainが入ってこのような名前になっています。

確認する

では、リクエストを送信してみましょう。

> curl -H "Host:review-main.sample-app.local" localhost -v

*   Trying 127.0.0.1:80...
* Connected to localhost (127.0.0.1) port 80 (#0)
> GET / HTTP/1.1
> Host:review-main.sample-app.local
> User-Agent: curl/7.87.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< server: istio-envoy
< date: Sat, 06 May 2023 12:34:10 GMT
< content-type: text/html
< content-length: 293
< last-modified: Fri, 05 May 2023 23:24:03 GMT
< etag: "64559013-125"
< accept-ranges: bytes
< x-envoy-upstream-service-time: 0
<
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Review Apps Demo</title>
</head>
<body>
    <h1>Review Apps 1</h1>
</body>
</html>

無事につながりました!
ちなみに、hostを書き換えないリクエストだと、、、

>  curl localhost -v
*   Trying 127.0.0.1:80...
* Connected to localhost (127.0.0.1) port 80 (#0)
> GET / HTTP/1.1
> Host: localhost
> User-Agent: curl/7.87.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 404 Not Found
< date: Sat, 06 May 2023 12:33:23 GMT
< server: istio-envoy
< content-length: 0
< 
* Connection #0 to host localhost left intact

しっかりNotFoundになっていることが確認できますね。

9. Review Apps

CIからクラスターにデプロイできることが確認できたので、ようやくReview Appsの出番です。
.gitlab-ci.ymlにReviewAppsとしての設定を加えていきます。

.gitlab-ci.yml
stages:
  - build
  - deploy

build:
  stage: build
  image: docker:23.0.5
  services:
    - docker:23.0.5-dind
  variables:
    DOCKER_HOST: tcp://docker:2375
  before_script:
    - echo ${DOCKERHUB_TOKEN} | docker login -u ${DOCKERHUB_USER} --password-stdin
  script:
    - docker build -t <docker_hub_repository>:${CI_COMMIT_REF_SLUG} .
    - docker push <docker_hub_repository>:${CI_COMMIT_REF_SLUG}

setup_review:
  stage: deploy
  image: alpine/k8s:1.24.13
  script:
    - kubectl config use-context <group>/review-apps-sample:review-apps
    - helm upgrade -i -n review-apps-sample review-apps-sample-${CI_COMMIT_REF_SLUG} ./helm --set tag=${CI_COMMIT_REF_SLUG} --set host=review-${CI_COMMIT_REF_SLUG}.sample-app.local
  environment:
    name: review/$CI_COMMIT_REF_NAME
    url: http://review-${CI_COMMIT_REF_SLUG}.sample-app.local/
    on_stop: teardown_review
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

teardown_review:
  stage: deploy
  variables:
    GIT_STRATEGY: none
  image: alpine/k8s:1.24.13
  script:
    - helm uninstall -n review-apps-sample review-apps-sample-${CI_COMMIT_REF_SLUG}
  when: manual
  environment:
    name: review/$CI_COMMIT_REF_NAME
    action: stop

setup_review がMR作成時に実行されるジョブになります。
environmentにデプロイ先のURL等の情報を与えることで、GitLab上でenvironmentが作成されるようになります。

では、test_2ブランチを作成してMRを作ってみます。
わかりやすいようにhtmlを変更しておきましょう。

index.html
<body>
    <h1>Review Apps 2</h1>
</body>

CIが成功すると、MR画面上にリンクボタンが作成されます。


curlで確認してみると、先ほどのtest_2ブランチの内容が返ってきました。成功ですね。

> curl -H "Host:review-test-2.sample-app.local" localhost

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Review Apps Demo</title>
</head>
<body>
    <h1>Review Apps 2</h1>
</body>
</html>

不要な環境の整理

今回デプロイした環境は、MRがクローズされる=レビューが終わると不要になります。
いつまでもクラスター上に残り続けるとリソースが圧迫されますし、手動で消して回るのは面倒ですね。

.gitlab-ci.yamlteardown_reviewというジョブに注目してください。

.gitlab-ci.yaml
setup_review:
  stage: deploy
  image: alpine/k8s:1.24.13
  script:
    - kubectl config use-context <group>/review-apps-sample:review-apps
    - helm upgrade -i -n review-apps-sample review-apps-sample-${CI_COMMIT_REF_SLUG} ./helm --set tag=${CI_COMMIT_REF_SLUG} --set host=review-${CI_COMMIT_REF_SLUG}.sample-app.local
  environment:
    name: review/$CI_COMMIT_REF_NAME
    url: http://review-${CI_COMMIT_REF_SLUG}.sample-app.local/
    on_stop: teardown_review
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"

teardown_review:
  stage: deploy
  variables:
    GIT_STRATEGY: none
  image: alpine/k8s:1.24.13
  script:
    - kubectl config use-context <group>/review-apps-sample:review-apps
    - helm uninstall -n review-apps-sample review-apps-sample-${CI_COMMIT_REF_SLUG}
  when: manual
  environment:
    name: review/$CI_COMMIT_REF_NAME
    action: stop

teardown_review は環境を破棄するためのジョブです。
setup_reviewenvironment.on_stopと紐づけることで、MRのクローズや画面上の停止ボタンをトリガーにジョブが動きます。

では、停止ボタンを押してみましょう。

helm uninstall が実行され、クラスターからリソースが削除されました。

まとめ

KubernetesクラスターとReview Appsを用いて、レビュー用の環境をいい感じに整備する検証をやってみました。
アプリケーションのコンテナ化対応だったりKubernetesクラスターの構築だったり、
実現できるまでのハードルは少しあるのですが、使いこなせると非常に便利な機能だと思います。

Discussion