水底

ScalaとかC#とかk8sとか

GitOpsで絶対に避けて通れないブランチ戦略

Kubernetes Advent Calendar 2019 その3 の 2日目です。

WeaveworksによってGitOpsが提案されてから2年ほどが経ち、僅かですが本番導入事例も耳にするようになりました。とはいえ案外まとまったドキュメントは作られていません。特にGitOpsで複数の環境 (e.g., 開発環境、本番環境、etc.) をハンドリングするためには欠かせないブランチ戦略については殆ど語られていないようです。これではたとえGitOpsの概要 (Single Source of Truthの概念等) を知っていても本番導入には大きなハードルが残ったままで、本番導入事例がまだまだ少ないことにも納得できてしまいます。そこでこの記事ではブランチ戦略に焦点を当て、サンプルプロジェクトを交えながら紹介していこうと思います。k8s/GitOps中級者向けです、多分。

以前GitOpsについて発表した際に端折ってしまった分 の補完にもなっています。

前提

本記事では以下のことを前提として解説せずに進めます。必要に応じて外部資料を参照してください。

  • k8sの概要
  • GitHub-flowの概要
  • GitOpsの概要

なお、本記事の内容は技術書典7にて頒布した『Kubernetes-Native Development&Deployment』の一部を抜粋・結構改変したものです。全体像やサンプルプロジェクトをもっと知りたいといった場合は↓からどうぞ。GitOpsだけでなくk8s向け開発環境についても解説しています (ただの宣伝です!!!)。

概要

GitOpsでは全てがGitで管理されますが、そのブランチとアプリ実行環境への反映をどう対応付けるかを考えなければなりません。サービスによってはサービスのバージョンごとに本番環境が必要であったり、逆に本番環境は常に1つで最新にしたり、はたまたユーザごとに個別の環境を払い出すようなサービスであれば似たような環境が大量に必要になったり、と形態は様々です。更に開発のための環境や本番デプロイ前の最終確認としてステージング (=プレビュー) 環境が必要にもなるはずです。動かしたいサービスのモデルにブランチ戦略をうまく合わせない限りいい感じにデプロイサイクルを回すことはできないのです。

用語

以下は適切な用語が定義されていないため、分かりやすくするため筆者が勝手に定義した用語です。

  • デプロイ要求: GitOpsの文脈において、アプリリポジトリの更新にCIがフックしSingle Source of Truthであるmanifestsリポジトリに対してその更新を反映させるPRを発行すること。具体的には、manifestsリポジトリに含まれる対象アプリのマニフェストファイルのDockerイメージタグを最新のものに変更するPRを発行する

サンプルプロジェクト

比較的汎用的と思われる形のサンプルで紹介していきます。今回は以下のような場面を想定したものとなります。サービスの要求によっては調整する必要があります。

  • 本番環境 (prd) は1つで、常に全ユーザに同じバージョンで提供するようなサービス
  • 開発環境 (dev) は1つで、常にアプリリポジトリの最新状態がデプロイされている
  • 必ず先に開発環境にデプロイし、後々本番環境にデプロイする
    • 出来る限りの自動化を行う
  • 使用ツール

Gitリポジトリ

サンプルプロジェクトは5つのGitリポジトリから構成されています。

manifests

アプリのSingle Source of Truthとなるリポジトリです。app-aとapp-bをデプロイするためのManifestを含んでいます。app-aはhelmを、app-bはkustomizeをテンプレートエンジンとして利用し dev・prd 環境にパラメータ違いのデプロイを実現しています。

例示のためにhelmとkustomizeを併用していますが、実際にはどちらかに寄せたほうが良いでしょう。

system-manifests

ドメインを実現するアプリ 以外の ミドルウェアのSingle Source of Truthとなるリポジトリです。今回のサンプルではsealed-secretsをデプロイするためのManifestを含んでいます。IstioやPrometheusといったミドルウェアを利用する場合もここに含まれることになります。パラメータファイルだけを保持し、HelmのChart自体は外部を参照する構成となっています。

manifestsリポジトリとsystem-manifestsリポジトリを分けている理由ですが、これはそれぞれのリポジトリで扱っているもののライフサイクルが異なるためです。デプロイの粒度やタイミング、デプロイ先Namespaceも全く異なりますし、ブートストラップ時にはミドルウェア→アプリという順序が必要になることもしばしばあります。「Single Source of Truthといいつつ複数あるじゃねーか!」とツッコミたくなるかもしれませんが、筆者的には常に分けたほうが何かと便利という結論に達しました。

cd-manifests

CDツールであるargo-cdの設定のSingle Source of Truthとなるリポジトリです。各環境にインストールしたargo-cdからはこのリポジトリを参照させます。(argo-cdをデプロイするためのManifestではなく、) ApplicationをデプロイするためのManifestを含んでいます。ここの Application とはデプロイ設定を表す Custom Resource Definitionです。"Application of Applications" と呼ばれる、幾つかのApplicationをデプロイするためのApplicationを取る構成となっています。これは例えば、「dev 環境のアプリ」・「prd 環境のミドルウェア」といった単位でのデプロイを可能にするための手法です。リポジトリルートにApplication of Applicationsがあり、個々のApplicationは環境別・アプリ/ミドルウェア別に配置されています。

app-a

サービス実現のために動かすアプリのリポジトリです。Pythonで簡易的なAPIサーバを実装をしています。 (環境変数でDB接続情報等の開発環境/本番環境で変更が必要なパラメータを渡せるようにすることで、同一のDockerイメージで開発環境と本番環境どちらにも対応できるという前提があります。)

app-b

サービス実現のために動かすアプリのリポジトリです。Golangで簡易的なAPIサーバを実装をしています。 (環境変数でDB接続情報等の開発環境/本番環境で変更が必要なパラメータを渡せるようにすることで、同一のDockerイメージで開発環境と本番環境どちらにも対応できるという前提があります。)

ブランチ

各リポジトリのブランチは以下のようになっています。なお、Dockerイメージタグにはバージョン番号ではなくGitコミットハッシュ値を利用しています。

manifests

  • dev: 開発環境のアプリの状態を表すブランチ (開発環境・本番環境両方の設定ファイルを含む)
  • prd: 本番環境のアプリの状態を表すブランチ (開発環境・本番環境両方の設定ファイルを含む)
  • feat/*: アプリの構成 (テンプレート) を更新するときに手動で作成するブランチ

system-manifests

  • dev: 開発環境のミドルウェアの状態を表すブランチ (開発環境・本番環境両方の設定ファイルを含む)
  • prd: 本番環境のミドルウェアの状態を表すブランチ (開発環境・本番環境両方の設定ファイルを含む)
  • feat/*: ミドルウェアの構成 (テンプレート) を更新するときに手動で作成するブランチ

cd-manifests

  • master: 全環境のデプロイ状態を表すブランチ
  • feat/*: デプロイ状態を更新するときに手動で作成するブランチ

GitHub-flowで管理されている。

app-a

  • master: 最新のapp-aを表すブランチ (各環境のデプロイ状態とは無関係)
  • feat/*: app-aを更新するときに手動で作成するブランチ

GitHub-flowで管理されている。

app-b

  • master: 最新のapp-bを表すブランチ (各環境のデプロイ状態とは無関係)
  • feat/*: app-bを更新するときに手動で作成するブランチ

GitHub-flowで管理されている。

詳細

アプリリポジトリとmanifestsリポジトリ

CIによる自動的なデプロイ要求への対応

最初にアプリのリポジトリですが、今回はGitHub-flowを前提とし master ブランチ (とfeatureブランチ) のみとなっています。GitHub-flowで master ブランチは常にProduction-Readyであることを示しますので、master が進んだ時点でCIから開発環境を更新するデプロイ要求を自動で行います。

f:id:amaya382:20191202145920p:plain
ブランチ戦略全体像

manifestsリポジトリでは、各環境を表すブランチは正にその環境の状態を表しています。つまり、開発環境のデプロイ状態は常に dev ブランチの状態と、本番環境のデプロイ状態は常に prd ブランチの状態と等しくなることが期待されています。ここで注意していただきたいのが、dev ブランチも prd 環境の設定を含んでいる という点です。ディレクトリ構造的には下記のように保持しています。

manifests/
 ├ app-a/ # app-aに関するマニフェスト
 │ ├ templates/ # <- テンプレートファイル
 │ └ values/ # <- 環境固有設定値
 │   ├ dev-app-a.yaml
 │   └ prd-app-a.yaml
 └ app-b/ # app-bに関するマニフェスト
   ├ templates/ # <- テンプレートファイル
   └ values/ # <- 環境固有設定値
     ├ dev-app-b.yaml
     └ prd-app-b.yaml

manifests/app-a/values at dev · gitops-demo/manifests · GitHub

一見変に感じるかもしれませんが、これは manifestsリポジトリ内の dev 環境の設定と prd 環境の設定は常に同時に更新される ということを暗に要求しています。

分かりづらいと思うのでアプリリポジトリ視点でも考えてみましょう。アプリリポジトリが更新されたとき、まず開発環境に反映するため対応するブランチである dev ブランチに対してデプロイ要求を行います。

f:id:amaya382:20191202180033p:plain
ブランチ戦略詳細 (ブランチの関係とアプリの更新。赤文字はコミットハッシュ値)

図のようにapp-aやapp-bの更新が同時に走った場合Git的にコンフリクトしてしまいそうですが、上で紹介したようにアプリごとに設定ファイルが分けられているため心配ありません。また、同時に同じアプリに更新が走った場合 (=先に来たデプロイ要求PRをマージせずに次のデプロイ要求が発行されてしまった場合)、到着順にマージすれば全てのログを、最新のものだけマージしそれ以外を破棄すればアプリの最新状態だけをデプロイ可能です。なお、前者の場合は手動でコンフリクトを解決する必要があります。

このとき、必ず 開発環境向けの設定だけでなく本番環境向けの設定も更新します。具体例を挙げると、manifestsリポジトリの dev ブランチに含まれる開発環境向けの設定ファイル (dev-app-a.yaml) と本番環境向け設定ファイル (prd-app-a.yaml) に共通するDockerイメージタグを app-a:aaa から app-a:bbb に同時に書き換える、というような感じです。PRの差分的には次のようになるはずです。

--- a/dev-app-a.yaml
+++ b/dev-app-a.yaml
@@ -1,2 +1,2 @@
-image: app-a:aaa
+image: app-b:bbb
replicas: 1
--- a/prd-app-a.yaml
+++ b/prd-app-a.yaml
@@ -1,2 +1,2 @@
-image: app-a:aaa
+image: app-b:bbb
replicas: 10

また、サンプルでいうと以下のCIから生成されたデプロイ要求PRです (コミット内容、コミットハッシュ値等は異なります)。

以下が 実際にその操作を行っているCIのスクリプト の抜粋です。

git clone "https://github.com/gitops-demo/manifests.git" manifests
cd manifests
git checkout dev # 常にデプロイ要求はdevブランチ (=開発環境) に向ける
git checkout -b "ci-build/app-a/${TRAVIS_COMMIT}" # 更新用のブランチを作成
cd app-a/values
for f in *-app-a.yaml # 開発環境向け設定ファイル (dev-app-a.yaml) と本番環境向け設定ファイル (prd-app-a.yaml) どちらにも同じ処理を行う
do
  PREV_COMMIT=$(grep -oP "(?<=repository: gitopsdemo/app-a:).+$" "${f}")
  sed -i -e "s!repository: gitopsdemo/app-a:.\+\$!repository: gitopsdemo/app-a:${TRAVIS_COMMIT}!" "${f}" # Dockerイメージタグを最新のGitコミットハッシュ値に更新
done

manifestsリポジトリの dev ブランチをデプロイ要求の対象としつつ、開発環境向け設定ファイルと本番環境向け設定ファイルを同時に同じように更新していることがわかると思います。なお、今回のサンプルでは開発環境と本番環境の2つだけですが、例えばステージング環境がある場合はステージング環境向け設定ファイルも同様に処理することになります。

ちなみに、開発環境の設定は dev ブランチのみ、本番環境の設定は prd ブランチのみ持たせた場合も考えてみましょう。簡単なことですが、dev ブランチと prd ブランチはそれぞれ独立したコミットログを重ねてしまうためGitの恩恵を受けられません。新しいパラメータを増やそうと思ったら、全く独立した2つのブランチの全く独立した2つのファイルを書き換えることになってしまいます。また、テンプレートも活用できません。
f:id:amaya382:20191202170313p:plain
ブランチ戦略の失敗例

アプリ構成変更を伴う手動デプロイ要求への対応

ここまでアプリごとのDockerイメージを更新するだけで済むケースのみを考えてきました。もう1パターン、manifestsリポジトリ内のアプリ構成を変更するケースを考えてみましょう。サンプルプロジェクトにはありませんが、app-cという第3のアプリの追加を仮定します。

f:id:amaya382:20191202183757p:plain
ブランチ戦略詳細 (アプリ構成の更新)

この場合GitOpsではmanifestsリポジトリにapp-c用のマニフェストを追加するため、手動で feat ブランチを作成する必要があります。feat/add-app-c は以下の緑色の部分を追加するPRになるはずです。もちろん開発環境だけでなく本番環境の設定も含まれています。

manifests/
 ├ app-a/ # app-aに関するマニフェスト
 │ ├ templates/ # <- テンプレートファイル
 │ └ values/ # <- 環境固有設定値
 │   ├ dev-app-a.yaml
 │   └ prd-app-a.yaml
 ├ app-b/ # app-bに関するマニフェスト
 │ ├ templates/ # <- テンプレートファイル
 │ └ values/ # <- 環境固有設定値
 │   ├ dev-app-b.yaml
 │   └ prd-app-b.yaml
 └ app-c/ # app-cに関するマニフェスト
   ├ templates/ # <- テンプレートファイル
   └ values/ # <- 環境固有設定値
     ├ dev-app-b.yaml
     └ prd-app-b.yaml

そしてマージ先ですが、これを必ず dev ブランチにします。これにより、自動化されたデプロイ要求と整合性の取れたブランチ運用を実現します。

開発環境から本番環境への反映

度々になりますが、アプリの変更は必ず最初に dev 環境に対して行うため、CIによるデプロイ要求はmanifestsリポジトリの dev ブランチに行います。これだけでは本番環境 (prd ブランチ) が一生更新されませんので、こちらの更新方法についても紹介していきます。方法としては、prd ブランチに対するCIからの自動的な処理は一切なく、任意のタイミングで手動でmanifestsリポジトリの dev ブランチを prd ブランチにマージ します。

f:id:amaya382:20191202180056p:plain
ブランチ戦略詳細 (devからprdへ、app-bのfeat/xとapp-aのfeat/bをまとめて反映)

サンプルでは以下の手で作成されたPRがこれにあたります。

なぜ自動化されていないのかは2つの理由から成ります。1つ目の理由は、一般的に本番環境は開発環境ほど細かい単位ではデプロイを行わないから、というものです。サービスの更新によっては「複数のアプリの更新を同時に取り込む必要がある」というケースも容易に考えられます。この場合、dev ブランチに必要な幾つかのアプリが更新が反映された段階で、まとまった更新が prd ブランチに行われるべきです。2つ目の理由は、一旦開発環境で確認してから本番環境へ上げるというフローの強制がGit的に困難だからで。もしCIフックの度に同じデプロイ要求を dev ブランチと prd ブランチに投げてしまうとブランチが独立してしまいます。また、CIによる自動的なデプロイ要求以外に行われるサービス構成の変更もまず dev ブランチへ行うようにすることで、変更を自然にGit上で伝播させることが可能になります。特殊なケースとして、「dev ブランチにコミットとして溜まった変更の一部をピックアップして本番環境に反映したい」ということもあるかと思います。この場合はGitのCherry-Pickで自然に解決できます。必要な変更のコミットだけをCherry-Pickで prd ブランチに拾い上げれば良いのです。dev ブランチに残った差分をまとめて prd ブランチに反映させたくなったら dev ブランチを prd ブランチにマージするだけです。この辺りはGitを最大限に活用していくことになりますが、運用が煩雑になりすぎないように注意が必要です。

system-manifestsリポジトリ

ここまでmanifestsリポジトリ(=アプリのSingle Source of Truth)についてでしたが、system-manifestsリポジトリについてもフローは全く同じです。(アプリとは異なり、ミドルウェア自体の開発を行うリポジトリは外部にありあくまで利用するだけという仮定のもと、) manifestsリポジトリとは異なり基本的にCIによるデプロイ要求はありません。system-manifestsに対する直接の変更はアプリ構成変更を伴うデプロイ要求と同じように feat ブランチを作成し、それをまずは dev ブランチにPR・マージします。そして必要に応じて dev ブランチを prd ブランチにマージすることで最終的に変更を本番環境へ反映できます。

サンプルプロジェクト > Gitリポジトリ でも軽く触れましたがmanifestsとsystem-manifestsはライフサイクルが大きく異なります。もし分けていないと、例えば本番環境のアラート設定のマニフェストを少しだけ変える必要が出た場合、「既に dev ブランチに溜まっていたアプリの変更差分をprd ブランチへのマージに巻き込まないようにアラート設定コミットだけをCherry-Pickする」といった煩雑なCherry-Pickが多発して面倒なことになってしまうでしょう。

cd-manifestsリポジトリ

最後にcd-manifestsですが、基本的に master ブランチのみです。manifestsやsystem-manifestsと比較して、一旦デプロイしてしまえばcd-manifestsのレイヤでの変更 (e.g., アプリ自体の追加・削除等) は少ないはずなので、余計な複雑性を排除するためにこのようにしています。

まとめ

あくまで一例ですが、サンプルプロジェクトを交えながらGitOpsのブランチ戦略を紹介しました。実際に導入するにはどうしてもサービスの要件に合わせたカスタマイズが必要ですが、基本となる部分だけでも、雰囲気だけでも掴んでいただけたのであれば。