GitLabとKubernetesで作る、自動で起動・停止できるブランチ別環境

SREのたっち(@TatchNicolas)です 本記事は、先日の弊社主催のTechトークイベントで発表した内容について、もうすこし詳細に書いてみます。

jxpress.connpass.com

TL;DR;

  • GitLabのenvironmentとHelmのreleaseを対応させることで動的な環境の作成・削除を実現した
  • CIOpsとGitOpsを使い分けて、「ちょうどいい」使用感を目指した
  • 環境の順番待ちがなくなった!

背景

JX通信社のサーバサイド開発では、ECSによるコンテナベースでの開発・デプロイが主流です。*1

環境としては

  • ローカル環境: docker-composeやgo run yarn run などで起動する、文字通り手元のマシンのローカル上で動かす環境
  • 開発版環境: SQSやFirestoreなどのマネージドサービスや、ローカルで立てるのが大変な別のAPI*2との繋ぎ込み等に利用する環境
  • ステージング環境: 本番デプロイ前のチェックに使う環境
  • 本番環境: エンドユーザが実際に利用する環境

という構成になっています。

しかし、開発チームの規模が大きくなってくると開発版環境を使った繋ぎ込みの確認のために順番待ちが発生するようになり、Slack上でも「@here どなたか開発版環境つかってます?借りていいですか?」のようなやりとりが散見されるようになりました。結果、思うように開発スピードが上がらなくなってしまいました。

そこで、開発が進行している複数のトピックブランチごとに独立してコンテナのアプリケーションをデプロイできると、開発版環境の順番待ち問題を解決 できるのではないか?と考えました。

どうやって実現しているか

この問題を解決するには、以下の要件が満たされている必要があります。

  1. 環境が自動で作成され、独立したURLが動的に払い出される
  2. Merge requestが閉じられたら、その環境も閉じられる

そのために、最近社内でも導入の進んでいるKubernetesをベースにすれば、比較的簡単に実現可能と考え、以下の技術選定をしました

  1. URLの払い出し: Istio
  2. 自動的な環境作成・削除: GitLab CI & Helm

構成について

全体の構成を図に表すと以下のようになります。登場人物についてもう少し詳しく解説します。

f:id:TatchNicolas:20210521212304p:plain

Ingress

トラフィックの制御はIstioに任せるため、全てistio-ingressgatewayに流すようにしています。 そのため、図からは省略しました。

「クライアントからのリクエストのTLSを終端して、k8sクラスタの中へリクエストを連れて行く」ことを担当します。

GKEの場合は、GCPから発行される証明書がワイルドカードに対応していないため、cert-managerで発行したものをIngressで指定してLoadBalancerに持たせます。

EKSの場合は、AWS Load Balancer Controllerを利用しました。

Istio

「k8sクラスタの中に入ってきたリクエストを、Hostヘッダをみて環境ごとのk8s Serviceに振り分ける」を担当します。Ingressのルールでも同じことは可能ですが、Istioを使うことで以下のメリットを享受できます。

  • ホスト名やパスなどによるリクエスト振り分けをVirtualServiceで設定することで、Ingressの中央集権ではなく個々のアプリケーション単位で行えるようになる*3
  • 認証周りをアプリケーションから剥がすことができる

GitLab CI

「トピックブランチごとの環境の作成・削除」を担当します。

GitLabにもKubernetesクラスタと連携する機能はあるのですが、GitLabのお作法に従う必要が出てくるためあまり使っていません。

JX通信社では、プロダクト単位でGitLabのグループを分けており、その単位でクラスターを作成しているので、開発版環境のクラスタの情報を登録して、GitLab CIからkubectlhelm のコマンドを叩けるようにしました。*4

ArgoCD

「各クラスタに対するメインブランチの反映」を担当します。詳しくは後述

実際のワークフロー

(1) 開発者が、ある機能の実装のためにfeatureブランチを切ってpush

各アプリケーションに対応するmanifestsリポジトリを用意して、そこでブランチを切ります。

(2) GitLab CIがfeatureブランチに対応する環境を作成する

ここで、下記のCI Jobが発火します。

setup_mr_env:
  stage: mr_env
  image: docker-image-with-helm
  script:
    - helm upgrade -i -n kurimonaca-api ${CI_COMMIT_REF_SLUG} ./chart --set url=${CI_ENVIRONMENT_URL}
  environment:
    name: ${CI_COMMIT_REF_SLUG}
    url: https://${CI_COMMIT_REF_SLUG}.your-domain.com
    on_stop: teardown_mr_env
  only:
    - branches
  except:
    - main
    - tags

利用するHelmテンプレートは以下のようになっています。*5

chart
├── Chart.yaml
├── values.yaml
└── templates
    ├── NOTES.txt
    ├── _helpers.tpl
    └── main.yaml

chart/values.yamlhelm upgrade -i ... --set xxx=yyy で値を渡していきます。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}
spec:
  replicas: 1
  selector:
    matchLabels:
      app: {{  .Release.Name  }}
  template:
    metadata:
      labels:
        app: {{  .Release.Name  }}
    spec:
      serviceAccountName: your-app-serviceaccount
      containers:
      - name: api
        image: {{ .Values.image }}
        ports:
        - containerPort: 8000
---
apiVersion: v1
kind: Service
metadata:
  name: {{ .Release.Name }}
spec:
  type: ClusterIP
  selector:
    app: {{ .Release.Name }}
  ports:
    - name: http
      protocol: TCP
      targetPort: 8000
      port: 80
---
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: {{ .Release.Name }}
spec:
  gateways:
  - istio-system/gateway
  hosts:
    - {{ (urlParse .Values.url).host }}
  http:
  - route:
    - destination:
        host: {{  .Release.Name  }}.your-app-ns.svc.cluster.local

(3) レビュー担当者は、コードを確認しつつ2.でできた環境を触って動作確認したりする

前述のマニフェストが反映されたら、 https://${CI_COMMIT_REF_SLUG}.your-domain.com というURLでアプリケーションにアクセスできるようになります。

また、正式なレビューに至る前のちょこっと書き換えて挙動を確認して、のようなことをするなら逐一CI越しのデプロイをせずにTelepresenceを使います。

(4) LGTMをもらってMergeすると、トピックブランチ用の環境は自動で削除される

GitLab上で、

  • Merge Requestに表示される停止ボタンをクリックする
  • Pipeline上で表示されるmanualなJobを実行する
  • Merge Requestが閉じられる

のいずれかの操作を行うと、下記のCI Jobが発火します。これにより、2. で作成されたhelm releaseがアンインストールされます。*6

teardown_mr_env:
  stage: mr_env
  variables:
    GIT_STRATEGY: none
  image: docker-image-with-helm
  script:
    - helm uninstall -n kurimonaca-api ${CI_COMMIT_REF_SLUG}
  when: manual
  environment:
    name: ${CI_COMMIT_REF_SLUG}
    action: stop
  except:
    - main
    - tags

(5) (6) ArgoCDがステージング・本番用ブランチの内容を同期する

ここだけ、GitOpsな方針になっています。詳しくは後述します。

もう少し細かい話

リポジトリ戦略について

JX通信社では、1つのアプリケーション(≒マイクロサービス)を1つのGitLabプロジェクト(=リポジトリ)に対応させ、その中でソースコードとデプロイの両方を管理しているケースがほとんどでした。

今回、Kubernetesを導入するにあたって、アプリケーションとデプロイの関係を疎にするために

  • アプリケーションリポジトリ:
    • CIではlint、テスト、コンテナイメージの作成までを担当する
  • マニフェストリポジトリ:
    • 開発版クラスタのトピックブランチ別環境へのデプロイのデプロイ
    • ArgoCDがこのリポジトリのmainブランチをクラスタに同期する

という構成にしました。マニフェストリポジトリのほうはモノレポではなく、デプロイしたい単位に分けています。するとアプリケーションとマニフェストを一対一に対応させたり、複数のAPIのセットをまとめて一つのデプロイの単位としても扱えたり、柔軟な運用が可能になりました。

CIOps v. GitOps

CIOpsとGitOpsの特徴などについては、すでに良い記事が世の中にたくさんあるので、ここでは説明を割愛しますが、「CIとCD」。

  • プロダクト開発チームとしては、どんどんコードを書いてデプロイしたい
  • でも本番環境はバージョン管理もセキュリティもしっかりしたい

そこで、JX通信社では

  • 開発版クラスタの各トピックブランチに対応する環境にGitLab CIからデプロイ(=CIOps)
  • 開発版クラスタおよび本番クラスタのmainブランチは、ArgoCDでデプロイ(=GitOps)

のように、環境に応じてデプロイ方式を変える方式を採用しました。すると、

  • 開発版トピックブランチへの作業はpushするたびに随時反映されていき、グイグイと開発を進められる
  • 本番環境クラスタに対する権限をGitLab CIに渡していないので、よりセキュアに保てるようになる

という環境ごとの目的にマッチした利用が可能になりました。

Namespace・ServiceAccountについて

EKS/GKE上で動くアプリケーションは、マネージドサービスを利用するためにWorkload Identity/IRSAを利用します。そのためには、AWS/GCPのIAMがKSAのあるNamespaceとServiceAccountを知っている必要があります。

そのため、動的に追加/削除されるHelmのtemplateからNamespaceとServiceAccountは除外して、クラスタ自体を管理してるリポジトリで、新しいアプリケーションの追加時にArgoCD Applicationの定義と一緒にNamespaceを定義・作成しています。つまり、各トピックブランチの環境はIAMを共有しています。ここは権限の変更も含めてトピックブランチ環境間で独立させられると理想的なのですが、複雑になってしまうため今回の仕組みからは外しました。

apiVersion: v1
kind: Namespace
metadata:
  name: your-app-ns
  labels:
    istio-injection: enabled
---
apiVersion: v1
kind: ServiceAccount
metadata:
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::1234567890:role/your-app-role
  name: your-app-serviceaccount
  namespace: your-app-ns
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: your-app
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://your-gitlab.com/your-group/your-project.git
    path: path/to/chart/
    targetRevision: HEAD
    helm:
      releaseName: main
  destination:
    server: https://kubernetes.default.svc
    namespace: your-app-ns

まとめ

  • 環境の順番待ちがなくなって、複数の施策を並行して進めやすくなった
  • CIOpsとGitOpsのいいとこ取りをして、「ちょうどいい」ワークフローを実現できた
  • それぞれの環境はあくまで helm install / helm uninstall しかしてないので、中でどんなKubernetesリソースを利用しているのかは関係なくなり、CI/CDの仕組みが標準化できるようになった

以上、先日行ったTechトークの詳細をブログにしてみました。どなたかの参考になれば幸いです。

JX通信社では、2021/06/23(Wed) に二回目のTechトークイベントを予定しています。次回はPythonにまつわる色々な話をする予定ですので、ぜひ参加してみてください。

jxpress.connpass.com

*1:Serverless FrameworkやSAMを使ったLambdaへのデプロイもよく使われます Serverless Framework+mangum+FastAPIで、より快適なPython API開発環境を作る - JX通信社エンジニアブログ

*2:たとえば、VPCに閉じていて外部から触れない内部向けのAPIなどです

*3:AWS Load Balancer Controllerでは振り分けルールをバラして書くことができますが、GKEでもできるだけ近い方法でやりたかったのと、他にも重み付けやリトライ制御などServiceMeshの機能として使いたいことがあったのでL7ルーティングもIstioに任せました

*4:https://docs.gitlab.com/ee/user/group/clusters/

*5:実際には、IstioのAuthorizationPolicyの設定なども含めていますが省略しています

*6: GIT_STRATEGY: noneを指定しないと、GitLabでマージと同時に元ブランチを削除する設定にしていたときに失敗してしまいます。