Kubernetesの「ブランチデプロイ」で誰もがハッピーなDev環境を作る

こんにちは。HRBrainでインフラエンジニアをしている間野(@mano_0307)です。

今年の5月にインフラエンジニアとして入社しました。Kubernetesを使っている弊社で、Kubernetesをまったく触ったことのない私のような人間がインフラエンジニアになれるというのが弊社の素晴らしいところです。合言葉は「トライドリブン」。日々トライができる素晴らしい環境です。

Dev環境という各社共通の悩み

多くの会社で何かと困っているのがdev環境なのではないかと思います。

  • dev環境今日も空いてないよ・・・フルリモートでどうせバレないし、寝ちゃお
  • あれ?久々に使ったdev5環境がうまく動かないよ。・・・(数時間後)あー、最新のmasterがrebaseされてないからAPIのinterface変わってんじゃん!うわー寝よ・・・
  • そろそろdev環境増やしたいな・・・でも、あの設定も複製しなきゃいけないしあれも作らなきゃいけないし、しかも増やしたら増やしただけコストかかるし・・・めんどくさい、よし寝よ!

こんなことが日々繰り広げられていることでしょう。私には見えているのですよ。

序論その1: 弊社サービスの構成

弊社の サービスはマイクロサービス化されており、アプリケーションだけで10個のGitHubリポジトリ(アプリケーション用GitHubリポジトリを以降CodeRepositoryとします)で管理されています。 Kubernetesが導入されており、それらのマイクロサービスがGKE Cluster上で動作しています。 余談ですが、以前はAWS上でモノリシックで稼働していた弊社のサービスが、マイクロサービス化と同時にKubernetes化・GCP移行という非常におもしろい話が語られているので、こちらも是非ご覧いただきたいと思います。

cloudonair.withgoogle.com

序論その2: 従来のDev環境

これまで、弊社には下記のような環境がありました。

  • Dev環境
    • メインのDev環境(通称Dev無印環境)と、Dev1~Dev15からなる派生的なDev環境(通称DevX環境)
    • Dev無印環境はdevというリモートブランチpush時、DevX環境はdev1~dev15というリモートブランチpush時にデプロイされる
  • Staging環境
    • 各CodeRepositoryのmasterブランチ内容が反映されている環境
  • QA環境
    • 本番リリース前確認を行う環境
  • LoadTest環境
    • 本番と同じ構成の負荷テスト用環境
  • Production環境
    • 本番環境

従来のDevX環境Kubernetes構成

HRBrainのdev環境は下図のような構成です。

f:id:jumpeim37:20201010210809p:plain
従来のDevX環境Kubernetes構成

この現在の構成についての特徴を挙げてみます。

  1. 同一のGKE Cluster内で、namespaceによってdev1 dev2 ~ devN環境を分けている
  2. Istioを導入しており、VirtualServiceでHostを下記のように指定してルーティングしている
    • dev1環境: dev1.{ルートドメイン}
    • dev2環境: dev2.{ルートドメイン}

各CodeRepositoryにgit pushされてから各環境に反映されるまでの流れ

ここでは、弊社マイクロサービスの構成員である「認証機能バックエンド」のCodeRepositoryにdev1ブランチがgit pushされた後、どのようなルートを辿ってDev1環境に反映されるかを見ていきます。

f:id:jumpeim37:20201010164550p:plain
dev1ブランチを認証機能バックエンドリポジトリにgit pushしてからデプロイまでの流れ

  1. Cloud Buildに設定したtriggerが、認証機能バックエンドリポジトリへのgit pushを検知しdocker buildを実施・GCRにpush
    このとき、Docker image tagには{GIT_SHORT_SHA}-{ブランチ名}が付きます。今回の例だとxxxxxxx-dev1タグが付きます。
  2. GCRにpushがあったことがCloud Pub/Subを経由しCloud Functionsに伝わる
  3. Cloud Functionsが、Pub/Subから受け取った情報を元にConfigRepository(Kubernetesのmanifestが管理されているGitHubリポジトリ)の該当ファイルを書き換え、commit & git push & create PR & mergeする
  4. fluxがConfigRepositoryを定期的にpollingし、変更があったらKubernetesにapply

従来のDev環境にあった問題点

さて、上で書いたような従来のdev環境には、下記のような問題点がありました。

1. dev環境の枯渇・空き待ちの発生

エンジニアのチームが増え、並行で走るプロジェクトが増えると、残存するdev環境の取り合いが発生します。 限られたパイを巡っての根回し、策略、騙し合い、奪取、怨恨、復讐・・・というようなことは(僕の見る限り)発生しませんでしたが、時として環境が枯渇していることによるキュー詰まりの時間が発生していました。 弊社のエンジニアは大人しめの人が多いので、「あの環境使ってるんだか使ってないんだかわからないね!」「さっさと環境貸しなさいよ!」などということがはっきり言えません。 さらにコロナによるフルリモート化です。コミュニケーションコストの上昇はさらに高まっています。

2. Kubernetes manifestの重複

これまでは、各環境ごとに全てフルフルのmanifest(deployment.yaml・service.yaml・virtualservice.yamlなど)を書いて管理をしていました。 そのため、変更点の全体反映に労力を要したり、抜け漏れリスクがあったりということがありました。

3. DevX環境の複製が大変

僕が入社した当初は、DevX環境はDev1~5の5つでしたが、そこから数ヶ月で増え続けて最終的にDev15にまで増えました。 manifestの複製自体はshell scriptで行っていましたが、下記の作業が大変でした。

a. Secretの複製

DevXは、同一のGKE Cluster内でnamespaceを分けることによって構築しています(例: Dev1はnamespacedev1、Dev15はdev15)。また、各アプリケーションに定義された環境変数は、Secretから値を取っているものも少なくありませんでした。 Secretに関してはGitHubでバージョン管理をしておらず、DevX環境(=namespace)が増えるたびに権限保有者が手動でkubectl create secret genericコマンドを実行することでSecretを作成していました。これは大変だし、「あれ、このnamespaceにSecretがなくてPodが起動しない!!」ということも起きていました。

b. DBデータの初期化

弊社ではDBにPostgreSQL、Firestoreを使用しています。DevX環境以外ではそれぞれCloud SQL・Cloud Firestoreを使っていますが、DevX環境ではPodでそれぞれのサービスを立てて使っています。 このPodで動くFirestore emulatorについて、初期データを登録するGoツールを手動で実行していく必要がありました。これも環境が増えれば増えるほど運用負担になります。

また、上記のPodで動くDBについて、Volumeが永続化されておらず、ClusterのバージョンアップなどでPodが再起動するとデータが消滅してしまっており、その度にまた全環境に初期データ登録ツールを手動実行していかねばなりませんでした。

4. Dev環境の各アプリケーションが更新されず、置いてけぼりになる

従来のDev環境は、前述の通り各CodeRepositoryのリモートブランチpush時にデプロイされる仕組みでした。 さらにmulti-repository構成のため、dev,dev1~15という16ブランチを、10個のCodeRepository全てについて常に最新化させるということは並大抵ではありません。 そのためCodeRepository間でバージョンの不整合が日常的に発生していました。依存関係の不整合も発生するため、アプリケーションエンジニアが想定しないエラーが発生し、原因究明に長時間を要するようなこともありました。

本題: これらの問題点を全て解決!

1. 環境枯渇・manifest重複・環境複製の大変さを全て解決する「ブランチデプロイ」

環境の数が固定化されてしまうと、どうしても繁忙期は環境枯渇となり、タイミングによっては空きが生じて無駄が生まれます。 だったら、任意のブランチをgit pushしたら勝手にそれを反映した環境が誕生し、使い終わったら消滅するのがよいのでは? ということで、「ブランチデプロイ」構想が生まれました(ちなみに構想を生み出したのは僕ではなく、一人でAWSからGKEへの移行を達成したスーパーエンジニアのゆきちさんです。)。

ブランチデプロイ構想の基本方針は下記の通りです。

  1. CodeRepositoryへ dev という文字列から始まるリモートブランチがpushされたとき、Cloud BuildによってDockerビルドを行いGCRにimageをpushする
    *わざわざ dev プレフィックスのブランチに限定したのは、全てのブランチを許すとまったく使われないブランチ環境が多発してしまうということがわかったからでした。
  2. GCRへのimage pushをトリガーとしてCloud Functionsを走らせる。このCloud Functionsが、ConfigRepositoryに必要なファイルをgit commitし、Pull Requestを作成、さらにはマージまで行う
  3. fluxがConfigRepositoryの変化を検知し、GKE環境に自動的にapplyしてくれる

一見すると既存のdevX環境と変わりませんが、大きく異なる点はKustomizeを導入したという点です。

a. ブランチデプロイの根幹をなす、「Kustomize & flux」を用いたGitOps

ブランチデプロイの主役は、Kustomizefluxです。

kustomize.io

fluxcd.io

Kustomize

Kustomizeを使うことにより、Cloud Functionsが作成・commitするファイルを最小限の量に限定することができます。 もしKustomizeを使っていない場合、作成するブランチ環境用のnamespaceに全て、 Service VirtualService DestinationRule ConfigMapなど、必要なmanifestを全てcommitしなければならないということになります。 しかしKustomizeのpatchesStrategicMergeを使うと、baseに全環境共通のmanifestを書いておけば、差分の最小限のファイル(例:Deploymentのimage)をcommitするだけでよいということになります。 また、Kustomizeにはvarsという便利な変数機能もあり、base manifestにこのvarsを忍ばせておけば、各ブランチ環境に適した値に自動的に変えてくれます。(ただしデフォルトではvarsの適用範囲は限定的なので、CRDのプロパティなどにも適用したいときは自分でvarreferenceを定義する必要があります。詳しくはこちら)

flux

fluxのManifest Generation機能を使うことで、こちら側でkustomize buildを行う必要がなくなります。 .flux.yamlというファイルをoverlays直下に配置し、下記のように書いておくと、ConfigRepositoryの変更をfluxが検知する度にこのコマンドを勝手に実行し、GKE環境に反映してくれます。

version: 1 # must be `1`
patchUpdated:
  generators:
  - command: ls -d */ | grep -E '^dev' | xargs -I % sh -c 'echo ---; kustomize build %;'

このcommandが、fluxがConfigRepositoryの変化を検知した際に実行されるコマンドです。
見るとちょっと黒魔術っぽいのですが、要するにoverlaysディレクトリ配下のdevプレフィックスを持つディレクトリ一覧を取得してループさせ、kustomize buildを実行しています。 overlays直下にkustomization.yamlを配置すれば通常はこんなことをせずにkustomize build .だけでいいのですが、varsを各namespaceで全く同じキーで定義しているためそれではエラーが出てしまい、しかたなくxargsでループさせています。

ブランチデプロイ導入後の、CodeRepositoryへのリモートブランチpushから環境が新規作成されるまでの流れを図にしてみました。

f:id:jumpeim37:20201010213651p:plain
dev-hrbrainブランチをgit pushしてからブランチ環境が誕生するまで
このように、Cloud Functionsが生成するファイルも最小限でよいというのが利点です。 このoverlaysにパッチファイルをおかないプロダクトについては、base manifestの内容、つまりCodeRepositoryのmasterブランチ内容を反映した形で環境が誕生することになります。

余談ですが、fluxにはAutomated Deploymentという一見とても便利そうな機能があります。
これは、正規表現やglob等でimage tagのパターンをannotationに設定しておくと、GCR等のcontainer registryにそのパターンに該当するimage tagがpushされたのを検知し、勝手にデプロイまでしてくれるというものです。
当初、この機能を採用していました。base manifestにregexでGITHASH-masterという形でannotationを書いておけば、各CodeRepositoryのmaster docker imageがGCRにpushされさえすれば(masterのimage tagはGITHASH-masterという形式)勝手に現存するブランチ環境にデプロイが走るということになります。夢のよう。
しかし今はこの機能の採用を見送っています。理由はとにかくデプロイに時間がかかるということです。例えば複数のCodeRepositoryのmasterブランチに更新が入ると検証時に3時間程度かかることもありました。しかもこの間、ConfigRepositoryの反映も止まるので、「あれ!?pushしたのにブランチ環境全然更新されないよ!?おいおい!!!」という怒号を私が浴びまくることは必然でした。
コードを見ていただくとよくわかりますが、ConfigRepositoryの反映と同一のgoroutineにいて、しかもこの処理が動作中は一切合切のログも出ないため、「flux、完全に沈黙しました。」という状態になります。私は「あ、fluxぶっ壊れたわ」と何度も思いました。
でもおそらくこの機能が走っているときに並行して別の反映処理が走る、というのは難しいのだろうと思います。

fluxのAutomated Deploymentを使わない中で、各CodeRepositoryのデフォルトブランチに変更があったとき、現在はbase manifestのDeploymentに記載しているdocker image tagをCloud Functionsによって書き換えています。

f:id:jumpeim37:20201020004923p:plain
CodeRepositoryのmasterブランチ更新時のリリースフロー
なお、ここは残課題であると考えています。現状弊社のConfigRepositoryは環境ごとに別々なのですが、せっかくKustomizeを使っているのであれば、本番環境を含む全環境のbase manifestを統一させたいものです。
でも、このようにbase manifestを頻繁に書き換えているようでは、本番にいきなりリリースされる可能性もあるため危なっかしくてリポジトリの統一などできません。悔しいなぁ、何か一つのことができるようになっても目の前には分厚い壁が立ち塞がっているんだ(私は鬼滅の刃の大ファン )。

2. もう使わなくなったブランチ環境は自動的に消滅

devプレフィックスのリモートブランチだけがブランチ環境作成に繋がるとはいえ、このままだと無尽蔵に増えてしまいコストがとんでもないことになってしまうので、使用後のブランチ環境は消滅するようにする必要がありました。

fluxの「ガベージコレクション」

fluxには便利なGarbage Collectionという機能があります。 これは、「fluxによって生成されたリソースについて、対応するmanifestがConfigRepository上から削除された場合はそのリソースも削除する」というものです。 つまり、この機能を使えば、ConfigRepository上から該当のmanifestを削除するだけで勝手に環境が消滅します。この機能を利用しています。

manifestを削除するのはCloud Functionsで行っています。 manifest削除のCloud Functionsのトリガーとなっているのは現在2つあります。

  1. CodeRepositoryのリモートブランチが削除されたとき
    GitHub Actionsを各CodeRepositoryに実装し、リモートブランチの削除トリガーでPub/Sub topicにpublishします。それを受けてCloud Functionsが作動、manifestを削除してConfigRepositoryにcommit、Pull Requestを作成しmergeするという流れです。

    f:id:jumpeim37:20201010214816p:plain
    dev-hrbrainブランチを削除してからブランチ環境が消滅するまで

  2. ConfigRepository上で一定期間更新がないmanifestを、日次バッチで検知して削除
    overlaysディレクトリ配下のmanifestをループし、git log --since <一定期間前>のコマンドを実行、もしlogがまったくないものはもはや更新されていない環境とみなしmanifestを削除しています。

    f:id:jumpeim37:20201010221219p:plain
    一定期間更新のないブランチ環境を自動的に削除

これにより、GKEクラスタ内にはさかんに更新されている必要最小限のブランチ環境だけが残るということになります。

3. Secretの複製について

勝手にnamespaceが増減すると、namespaceに属しているSecretもそれに付き合って自動設定されてくれないと困ります。この問題を解消するのに、reflectorを採用しています。

github.com

Secretに指定のannotationを付けておくと、全namespaceまたは指定の一部namespaceに勝手に複製してくれます。また、各namespaceに複製した状態であっても、Secretの変更は複製されている全namespaceに一括反映され、削除も一括削除されます。便利。

4. DBボリュームの永続化について

これはブランチデプロイとは関係ありませんが、PersistentVolumeClaimを使っています。
弊社ではfirestore emulatorをpodで動かしているのですが、弊社メンバーである高橋さんのこちらのブログを参考に擬似的な永続化を行っています。

medium.com

また、PostgreSQL DBの初期データインポートは下記のようにやっています。

  1. PostgreSQL podのInit Containerで、GitHubのprivateリポジトリをclone。そこにサンプルのSQLが管理されている。
  2. 取得したSQLファイルをemptyDirでPostgreSQLのコンテナに共有する。
    このとき、/docker-entrypoint-initdb.d配下に置くと、Initialization Script機能によって勝手に実行される。

まとめ

そんなわけで、効率的な、生まれては滅し、また生まれては滅するという素敵な開発環境ができたという話でした。Kubernetes触ったことなかったので正直相当大変でしたが、いろいろわかって楽しかったです。トライドリブン。私は今日も明日もトライドライバー(?)。

HRBrainでは常にトライし続けたいエンジニアを募集しています。ご興味のある方、是非ご応募ください!

www.wantedly.com