はじめに
コミューンではこれまでCI/CDのツールにCircleCIを使っていましたが、最近Cloud Buildへ切り替えました。
結論から言うと、切り替えにあたってパイプラインの中身とプロセスを今一度見直したところ、以下のように改善しました。
- ビルド回数:2回 -> 1回
- 本番環境のリリース完了時間:約13分~24分 -> 約3分
今回の記事では切り替えるきっかけとなった出来事やCloud Buildの設定、注意点について書きます。
タフな仕事始め
きっかけは2023年はじめのCircleCIのセキュリティインシデントでした。
詳細は省略しますが、不正な第三者がCircleCIに保存された環境変数やトークン、キーなどのシークレット情報へアクセス可能な状態だったようで、CircleCIから提示された対応策はシークレット情報をすぐにローテーションしてくださいというものでした。
コミューンはビルド・デプロイの仕組み上CircleCIへシークレットを保存していました。具体的にはプロダクトの一部で使っているCompute EngineへSSHアクセスができる秘密鍵やGoogle Cloudに対する操作が可能な権限を付与したサービスアカウントのキーなどです。
応急処置としてまずCircleCIからシークレットを削除、プロダクトのリポジトリとCircleCIのプロジェクトを切り離しました。そして不正アクセスがなかったかログなどから確認しました。(幸い不正アクセスはありませんでした)
その場限りの対応ではなくあるべきを考える
CircleCIからリポジトリを切り離したのでリリースを停止せざるを得ませんでした。 応急処置が済んだことや不正アクセスがないことを確認できたので、わたしは再発行したシークレットをCircleCIへ登録して使用を再開しようと思ってました。しかしSREチームのメンバーが「いっそのことCI/CDを移行しませんか?」と提案、議論の結果このままだと外部サービスへシークレットを置くことのリスクはいつまでも払拭できず、Google Cloudの中でCI/CDを完結したほうがセキュリティリスクが小さいのではという理由にみんな納得し、Cloud Buildへ乗り換えることをインシデント対応の真っ只中に決めました。
その場限りの対応にせずあるべきを考えること、意思決定の速さがコミューンらしくて良いです。
あるべきを設計する
移行を決めたとはいえリリースを止めるわけにはいかないので、手動でもリリースできるようまずは簡単なシェルスクリプトを作ってから移行を進めました。 CircleCIでやっていたことをそのままCloud Buildへ移行するだけならやる意義がないので、CI/CD周りであがっていた課題をこのタイミングで解消することにしました。
課題の1つにパイプラインの効率の悪さがありました。 従来は、developブランチへマージすると開発環境へビルド・デプロイし、本番環境へリリースする時はdevelopブランチをmasterブランチへマージしてビルド・デプロイしていました。developとmasterのコードは基本同じなので、同じコードでビルド・デプロイするというステップが2回ありました。 この他、不要なnpm installをしている箇所もありました。
このようにプロセスを見直した結果、最終的なパイプラインは以下のようになりました。
パイプラインのステップ
大きく2つのステップに分けました。
ステップ1
developブランチへコードがpushされると、開発環境はビルド > デプロイ > リリースをして、そのビルドイメージを使い本番環境のCloud Runもデプロイする(この時点では本番環境の新しいコンテナへエンドユーザーからのトラフィックを流さない)
ステップ2
ステップ1でデプロイした本番環境のCloud Runのリビジョンへエンドユーザーからのトラフィックをルーティングする(=リリース)
これにより、ビルド回数を2回から1回へ減らすことができ、本番環境のリリースはビルドとデプロイが不要でトラフィックを切り替えるだけで良くなりました。
このステップをCloud Buildで実装します。
Cloud Buildの設定
コミューンのアーキテクチャを見ていただくとよりご理解いただけると思いますので構成図を貼ります。
構成を詳しく知りたい方はこちらの記事がおすすめです。
構成ファイルの作成
公式ドキュメントを参照し、Cloud Buildの構成ファイル(yaml)をビルド、デプロイ、リリースの各ステップ分作成しました。
ファイル構成は正確には少し異なるのですが以下のイメージです。
repository root └── cloudbuild/ ├── build.cloudbuild.yaml ├── deploy.cloudbuild.yaml ├── release.cloudbuild.yaml ├── frontend/ │ ├── cloudbuild.yaml │ └── Dockerfile ├── backend/ │ ├── cloudbuild.yaml │ └── Dockerfile └── openapi/ ├── cloudbuild.yaml └── Dockerfile
保守のしやすさを考え、ビルド、デプロイなど各ステップのyamlとフロントエンドなど各サービスのyamlを分けて作りました。
cloudbuild/build.cloudbuild.yaml
へフロントエンド、バックエンド、公開APIサーバのビルド方法を定義します。以下はバックエンドの定義箇所の抜粋です。
steps: - id: build-backend name: gcr.io/cloud-builders/gcloud entrypoint: gcloud args: - 'builds' - 'submit' - '--project=project-name' - '--config=cloudbuild/backend/cloudbuild.yaml' - '--substitutions=_IMAGE_TAG=${SHORT_SHA}' waitFor: - '-' options: machineType: 'N1_HIGHCPU_32'
--config
オプションでバックエンド用のyamlを指定しています。
バックエンドのビルド手順をcloudbuild/backend/cloudbuild.yaml
で定義します。(詳細なビルドの流れはDockerfileで定義しています)
steps: - id: build-backend name: 'gcr.io/kaniko-project/executor:latest' args: - --dockerfile=cloudbuild/backend/Dockerfile - --destination=asia-northeast1-docker.pkg.dev/${PROJECT_ID}/product/backend:${_IMAGE_TAG} - --cache=true - --cache-ttl=336h - --snapshot-mode=redo - --use-new-run options: machineType: 'N1_HIGHCPU_32'
ビルド時間を短くするようkanikoキャッシュを使い、machineType
でハイスペックなマシンを使っています。なおフロントエンドはNext.jsのビルド時のキャッシュをCloud Storageへ保存するようにしています。
--substitutions
を使ってビルド時に変数を置き換えることができます。
ここではArtifact Registryへ保存するDockerイメージへタグをつけるため_IMAGE_TAG
へ${SHORT_SHA}
(ショートコミットハッシュの値)を渡しています。
変数は自前で定義することも、定義せずともデフォルトで利用できるものもあります。
上のyamlでいうと_IMAGE_TAG
は自前で定義、${SHORT_SHA}
や${PROJECT_ID}
はデフォルトで利用できる変数です。
この辺りはこちらのドキュメントを見ると良いです。デフォルトで利用できる変数だけで足りなければ自前で定義すると良いでしょう。
この他にデプロイ用とリリース用のyamlをそれぞれ作成しました。ビルドのyamlと同じような構成なので詳しく書きませんが、デプロイはgcloud run deploy
、リリースはgcloud run service update-traffic
と使っているコマンドやオプションが違うくらいです。
サービスアカウントの設定
各ステップのタスクの実行の主体はCloud Buildなので、Cloud Build用のサービスアカウントへ以下の権限を付与しました。
- Artifact Registryのイメージを参照する
- 開発環境のサービスアカウントで本番環境にもCloud Runのリビジョンを作成する
- Google Cloud Storageを参照する
コミューンは環境ごとにGCPのプロジェクトを分けているので、プロジェクトをまたいだ権限設定が必要です。 ここは根気良くトライアンドエラーを繰り返してミニマムな権限を設定しました。
トリガーの設定
次はトリガーを使って先ほどの2ステップを実装しました。 ビルド > デプロイ > リリースと前のステップが成功したら次のステップへ進むためにCloud BuildのPub/Subトリガーを使いました。
トリガーが成功するとPub/Subのトピックへメッセージをパブリッシュします。別のトリガーはメッセージの内容次第でトリガーを発動するか定義できます。
ビルドの一意の ID とビルドのステータスは message.attributes フィールドで確認できます。
とあるためこれを使ってトリガーの発動条件を設定します。
ビルドのステータスはbody.message.attributes.status
なので、以下のようにこれを_BUILD_STATUS
という変数へ代入して使いました。
そして、フィルターを使って前のトリガーのIDかつ結果が成功であれば発動するという条件を設定しました。
_BUILD_TRIGGER_ID
の正規表現値
には例えばデプロイ用のトリガーであればその前のステップのビルドのトリガーIDを入力します。
これによって、ビルド > デプロイ > リリースのステップを実装しました。
なお、本番環境のリリーストリガーは、Slackからコマンドを実行するとCloud Buildのwebhook URLへリクエストして発動するようにしています。
最終的にGoogle Cloudのプロジェクトごとに以下のトリガーを作りました。
開発環境用のプロジェクト
- developブランチへコードをpushするとビルドしてArtifact RegistryへDockerイメージを保存する
- そのDockerイメージから開発環境のCloud Runのコンテナをデプロイしてトラフィックをルーティングする
本番環境用のプロジェクト
- ビルドで作成したDockerイメージから本番環境のCloud Runのコンテナをデプロイする
- Cloud Buildのwebhook URLへリクエストが飛ぶとコンテナへトラフィックをルーティングする
新CI/CDパイプラインが完成しました。 二人のSREチームのメンバーがおもに手を動かし、他のタスクもやりつつリリースまで約1ヶ月かかりました。 リリースまでのあいだ手動でリリースしてくれたり、あとから移行しようと考えていたテストやコードのエラーチェックをGitHub Actionsでさくっと実装してくれたりと開発チームのメンバーにも感謝です。
注意点
Cloud Buildを使う際の注意点、ハマったポイントを書きます。
cloudbuild.yamlの多段構成は避ける
プロダクトの一部で使っているCompute Engineへデプロイする際に、当初はルートディレクトリのyaml > バックエンド用のyaml > Compute Engine用のyamlという3段構成にしていたのですが、トリガーの終了時間がかなりかかったため2階層にすることで時間を短縮しました。 多段にすればするほどタスクを実行するマシンの起動回数が増えるので階層は少なくした方がよいです。
自前のタグをつけたリビジョンはこの世に生きている
これはCloud BuildではなくCloud Runの話ですが、Cloud Runのリビジョンがどのリリースを含むのか判別しやすくすることを目的として、リリース当初はリビジョンへショートコミットハッシュ(コミットハッシュの頭7桁)を含むタグをつけていました。 リリース後にCloud Runのコストが急増しているという報告があり調べたところ、CPUを常に割り当てている自前のタグをつけたリビジョンはトラフィックがルーティングされていなくても課金されることがわかりました。 タグの利用をやめてリビジョン名へショートコミットハッシュをつけることでこの問題を回避しました。
CPUを常に割り当てるコンテナで以下の画像のようにルーティングしていないリビジョンに緑のチェックアイコンがついていると課金対象になるので注意してください。
画像はドキュメントから引用
おわりに
いかがでしたでしょうか。パイプラインの改善やCloud Buildを設定する際の参考になれば幸いです。
今回の移行に限ったことではないですが、プロセスの中に無駄を見つけ改善すると開発チームの生産性を向上することができ、より多くの価値をユーザーへ提供することができます。
その結果がビジネス上の成果に繋がるので、何かトラブルがあっても暫定対応で終わらせることなく本質を考えて仕事をしていきたいです。
もしコミューンへ少しでも興味をもってくださった方はぜひカジュアル面談へお申し込みください!会社や技術の話をありのままにお伝えします!
現在募集中の職種はこちらです!