Cloud Build トリガーの不要な同時実行を避ける方法を考える
はじめに
Google Cloud で CI/CD といえば Cloud Build です。無料枠もあり、とても便利なサービスです。ただ、Cloud Build は同一トリガーでさえも同時実行を許容します。
そのため、時折意図したとおりの順番に実行されなかったり、ステップ同士で処理が衝突して失敗してしまうことがあります。今回は不要な同時実行を避ける方法を考えてみました。
準備
以下の YAML ファイルをcloudbuild.yaml
として作成します。60 秒間待機するだけのスクリプトです。
steps:
- id: 'wait-60-seconds'
name: 'bash'
script: |
#!/usr/bin/env bash
# 60秒間、毎秒カウントダウン
for i in {60..1}; do
echo "待機中... 残り${i}秒"
sleep 1
done
echo "完了しました!"
第 2 世代のリポジトリでは、非リージョン トリガー(global)を作成することができない[1]ので、Github のリポジトリを第 1 世代として接続しておきます。第 1 世代は残念ながらコンソールからしか設定できません。
リポジトリ設定画面
リポジトリを作成したので、Cloud Build トリガーを作成して、実行してみます。実行できたら準備はできました🎉
# 1. 「手動呼び出し」のトリガーの作成
gcloud builds triggers create manual \
--name=my-manual-trigger \
--build-config="CloudBuildsTips/cloudbuild.yaml" \
--repo=https://www.github.com/sikeda107/tech-blog \
--repo-type=GITHUB \
--branch="main"
# 2. 作成したトリガーを実行する ※ブランチ名は適切に設定してください
gcloud builds triggers run my-manual-trigger \
--branch="feature/cloudbuildtips"
cancelot.sh
を使う
この記事[2]の「ジョブのキャンセルをしよう」で紹介されている cancelot.sh
を使います。これを最初のステップで呼び出すことで、実行されているトリガーと同じトリガーがすでに実行中であれば、それをキャンセルすることができます。ちなみに、おおもとのcloud-builders-community[3]の方はメンテナンスされておらず、docker build すらできませんでした 😐
使い方
cancelot.sh
をダウンロードしてきて、Cloud Build の最初のステップで呼び出すだけです。ただ global
で動かす場合は問題があるので後述の「スクリプトの修正」を参照してください。簡単ですね👍
- Shell スクリプトをダウンロードする
- スクリプトを修正する
- Cloud Build 構成ファイルにステップを追加して、スクリプトと共にリポジトリへ push する
- トリガーを実行する
# ダウンロードコマンド
curl -OL https://raw.githubusercontent.com/siberex/cancelot/main/cancelot.sh
スクリプトの修正
このスクリプトですがそのままでは Cloud Build トリガーが global
の場合は動きません 😞
ERROR: (gcloud.builds.cancel) INVALID_ARGUMENT: Request contains an invalid argument.
なのでちょっと修正する必要があります。一番最後で gcloud builds cancel で、実行中のビルドをキャンセルしています。global の場合に --region が (unset)
に設定されるようになっているのですが、ここでエラーが起きます。そのため、以下のように--region="$GOOGLE_CLOUD_REGION"
のオプションを削除します。
for build in "${CANCEL_BUILDS[@]}"; do
echo "$build"
ID=$(echo "$build" | awk '{print $1;}')
- gcloud builds cancel "$ID" --project="$GOOGLE_CLOUD_PROJECT" --region="$GOOGLE_CLOUD_REGION" >/dev/null || true
+ gcloud builds cancel "$ID" --project="$GOOGLE_CLOUD_PROJECT" >/dev/null || true
done
とりあえず、これで動くようにはなるのですが region の指定ができないので、指定がない場合は --region="$GOOGLE_CLOUD_REGION"
を設定しないようにしたほうがいいかもしれないです。
- GOOGLE_CLOUD_REGION=${REGION:-"(unset)"}
+ if [[ -n $REGION ]]; then
+ GOOGLE_CLOUD_REGION="--region=$REGION"
+ fi
解説
スクリプトでは3つのステップが行われています。各ステップごとのコマンドと実行例を記載します。ちなみに、自分自身のIDは「デフォルトの置換の使用[4]」で$BUILD_ID
として取得できます。
## 1. フィルターする条件として、自分自身の「トリガーID」と「作成時刻」を取得
gcloud builds describe "$CURRENT_BUILD_ID" \
--format="csv[no-heading](createTime, buildTriggerId, substitutions.BRANCH_NAME)"
## e.g.)
gcloud builds describe 5ff2368e-ea1d-4e82-91f3-afd9b5fe28cd \
--format="csv[no-heading](createTime, buildTriggerId, substitutions.BRANCH_NAME)"
2024-02-23T14:24:30.462018Z,81e8e381-ddd4-49a1-86e6-fefd248e588a,feature/cloudbuildtips
## 2. 条件で古いビルドIDを取得
## --ongoing をつけることで、QUEUED または WORKING に絞り込みます
gcloud builds list --ongoing \
--filter="id!=$CURRENT_BUILD_ID AND createTime<$BUILD_CREATE_TIME AND buildTriggerId=$BUILD_TRIGGER_ID" \
--format="value(id, status)"
## e.g.) 結果確認のため、--ongoing を外しています
gcloud builds list \
--filter="id!=5ff2368e-ea1d-4e82-91f3-afd9b5fe28cd AND createTime<2024-02-23T14:24:30.462018Z AND buildTriggerId=81e8e381-ddd4-49a1-86e6-fefd248e588a" \
--format="value(id, status)"
6ab72514-a23f-4c38-9123-d53b914b1bd2 CANCELLED
4146d5e2-56f0-4664-9676-873c4d1f2c08 SUCCESS
## 3. ビルドIDを指定してキャンセルする
## リージョンが未設定の場合は、global として設定されます
gcloud builds cancel "$ID"
## e.g.)
gcloud builds cancel 6ab72514-a23f-4c38-9123-d53b914b1bd2
waiting.sh
をつくってみる
cancelot.sh
はビルドをキャンセルするので、terraform などを実行していた場合はロックしたまま落ちてしまったりします。あるいは、キャンセルしたあと、またキャンセルが続いてしばらくビルドが完了しないといった問題が発生します。そのためcancelot.sh
を参考に、キャンセルせずに終わるまで待機するスクリプトを作成してみます。
gcloud builds list で取得したビルド ID の件数をチェックして、INTERVAL
で指定した秒数のあいだ待機します。待機中のものがなくなったら、次のステップへ移行します。ステップは待機しないと意味がないので、かならず waitFor
を指定するようにしましょう。
gcloud builds submit でも同じことがしたい
これまで説明してきたものは、トリガーIDでフィルタしているため gcloud builds submit
によるビルドの実行では動作しません。そのため、トリガーID相当になるものを自前で用意する必要があり、待機やキャンセルの動作をしないといけません。
具体的には、ビルドの開始時と終了時にロックファイルを作成・削除をし、ビルドの実行中に他のビルドが開始されないように待機する、あるいは他のビルドをキャンセルする機能を提供するスクリプトを作成します。
共通処理 - ロックファイルの作成と削除
作成
待機する場合もキャンセルする場合もはじめにロックファイルを作成します。ロックファイルを作成して、Cloud Storage バケットに格納します。submit を実行したときに、デフォルト設定では、ソースコードは プロジェクトID_cloudbuilds
というバケットにソースファイルを格納します。
Google Cloud プロジェクトで初めて gcloud builds submit を実行すると、Cloud Build はそのプロジェクトに [YOUR_PROJECT_NAME]_cloudbuild という名前の Cloud Storage バケットを作成します。Cloud Build はこのバケットを使用して、ビルドに使用するソースコードを保存
します。[5]
このバケットを利用してロックファイルを各ビルドで共有します。ロックファイルは「作成時刻」と「ビルドID」を”_”で繋いだファイル名として作成し、このロックファイルでどのビルドが待機中または実行中なのかを取得します。また、このファイルはバケットのTRIGGER_NAME
に指定されたパスに保存し、トリガーIDのような役割を果たすことで他のビルドと区別します。
e.g.) gs://プロジェクトID_cloudbuilds/TRIGGER_NAME/作成時刻_ビルドID.lock
そのため、ロックファイルのチェックはTRIGGER_NAME
配下に限定して行います。
削除
各ビルドの最後にはかならずロックファイルを削除する処理をいれるようにします。ロックファイルが残った状態で待機処理が走ってしまうと、ずっと待機してしまう可能性があります。
wait_build
関数
待機処理 - - ロックファイルの取得: CloudStorageからロックファイル一覧を取得し、各ファイルのタイムスタンプとビルド ID を抽出します
- ビルドのカウント: 現在のビルドよりも古い作成時刻を持ち、現在のビルドIDと異なるビルドをカウントします
-
処理の待機: ロックファイルの数が 1 以上であれば、指定した
INTERVAL
で待機します - 繰り返し: 条件に合致するロックファイルがなくなるまで、上記のステップを繰り返します。
cancel_build
関数
キャンセル処理 - - ロックファイルの取得: CloudStorageからロックファイル一覧を取得し、各ファイルのタイムスタンプとビルドIDを抽出します
- ビルドのキャンセル: 現在のビルドよりも古い作成時刻を持ち、現在のビルドIDと異なるビルドをキャンセルします
- ロックファイルの削除: キャンセルしたビルドに合致するロックファイルを削除します
待機処理 - 動作確認
テスト用の構成ファイルです。
# 連続で4回実行します
gcloud builds submit --project=$PROJECT_ID --async --config=cloudbuild_submit_waiting.yaml && \
gcloud builds submit --project=$PROJECT_ID --async --config=cloudbuild_submit_waiting.yaml && \
gcloud builds submit --project=$PROJECT_ID --async --config=cloudbuild_submit_waiting.yaml && \
gcloud builds submit --project=$PROJECT_ID --async --config=cloudbuild_submit_waiting.yaml
ID CREATE_TIME
910bbe9b-dc17-4303-98e0-868aed2441e9 2024-02-25T09:12:03+00:00
7c43d196-d18b-46a0-8848-972c5eadebdc 2024-02-25T09:12:06+00:00
22454714-5b1b-429e-aa04-1094625b60d1 2024-02-25T09:12:09+00:00
9bd5dcac-97ed-4042-81aa-2053bf8a4135 2024-02-25T09:12:11+00:00
すべて成功しているので問題なさそうです。
一覧
最後のビルドを確認します。待機待ちのビルドが3個から1つずつ減っていっているので想定どおりの動きになっていることがわかります。
最後の 9bd5dcac-97ed-4042-81aa-2053bf8a4135
# 可能な限り同時に4回実行します
gcloud builds submit --project=$PROJECT_ID --async --config=cloudbuild_submit_waiting.yaml & \
gcloud builds submit --project=$PROJECT_ID --async --config=cloudbuild_submit_waiting.yaml & \
gcloud builds submit --project=$PROJECT_ID --async --config=cloudbuild_submit_waiting.yaml & \
gcloud builds submit --project=$PROJECT_ID --async --config=cloudbuild_submit_waiting.yaml
ID CREATE_TIME
afa03da3-e3df-4bd2-bc2f-7e416cc78425 2024-02-25T08:53:47+00:00
46c9ef31-f23d-4431-9a71-d9fd26ce4ed8 2024-02-25T08:53:47+00:00
a297340c-964b-48c6-9d4e-91b0a7dfd885 2024-02-25T08:53:48+00:00
17ab9a7f-f4d0-4d98-a130-eeed20fb68e9 2024-02-25T08:53:48+00:00
すべて成功しているので問題なさそうに見えますが…。
一覧
2個目に実行されていますが、どれも待っていないようです。
1個目のafa03da3-e3df-4bd2-bc2f-7e416cc78425
1個目に実行されていますが、1個待っているようです。
2個目の46c9ef31-f23d-4431-9a71-d9fd26ce4ed8
最後に実行されていますが、どれも待っていないようです。
3個目のa297340c-964b-48c6-9d4e-91b0a7dfd885
3個目に実行されていて3個待っていますが、2個を飛ばして1個待ちになっています。2個目と最後が同時に実行されているためと思われます。
最後の17ab9a7f-f4d0-4d98-a130-eeed20fb68e9
キャンセル処理 - 動作確認
テスト用の構成ファイルです。
# 連続で4回実行します
gcloud builds submit --project=$PROJECT_ID --async --config=cloudbuild_submit_cancel.yaml && \
gcloud builds submit --project=$PROJECT_ID --async --config=cloudbuild_submit_cancel.yaml && \
gcloud builds submit --project=$PROJECT_ID --async --config=cloudbuild_submit_cancel.yaml && \
gcloud builds submit --project=$PROJECT_ID --async --config=cloudbuild_submit_cancel.yaml
ID CREATE_TIME
d06c3d2b-2d13-40cb-86ba-c39c0066ad57 2024-02-25T13:37:12+00:00
5936e33e-aea5-4eb2-a90a-45b05080d242 2024-02-25T13:37:15+00:00
381bb874-1ea9-4291-9ea1-eefbe4e788f8 2024-02-25T13:37:18+00:00
1ffd889e-5562-4137-8f78-f263028c96ee 2024-02-25T13:37:22+00:00
はじめの3個をキャンセルして、最後の1個が成功しているので問題なさそうです。
一覧
1個目d06c3d2b
をキャンセルしているので、想定通りです。
2個目5936e33e-aea5-4eb2-a90a-45b05080d242
1個目d06c3d2b
をキャンセルしてますが、どうせキャンセルするので問題ないです。2個目5936e33e
もキャンセルしているので、想定通りです。
3個目381bb874-1ea9-4291-9ea1-eefbe4e788f8
3個目381bb874
をキャンセルしています。想定通りです。
最後の1ffd889e-5562-4137-8f78-f263028c96ee
# 可能な限り同時に4回実行します
gcloud builds submit --project=$PROJECT_ID --async --config=cloudbuild_submit_cancel.yaml & \
gcloud builds submit --project=$PROJECT_ID --async --config=cloudbuild_submit_cancel.yaml & \
gcloud builds submit --project=$PROJECT_ID --async --config=cloudbuild_submit_cancel.yaml & \
gcloud builds submit --project=$PROJECT_ID --async --config=cloudbuild_submit_cancel.yaml
ID CREATE_TIME
4aa8d7e4-e1ef-4d87-b268-d18a591c7587 2024-02-25T09:24:43+00:00
b4e967f4-d8d9-4d21-a934-76f03b95be94 2024-02-25T09:24:43+00:00
6086f27f-4d09-4b49-a53e-252436a4335f 2024-02-25T09:24:44+00:00
9db704b4-33d9-4a55-88ad-7ac486c972cc 2024-02-25T09:24:44+00:00
1個目が成功して、残りの3個がキャンセルされてしまっています。
一覧
最後のものが、2個目b4e967f4
と3個目6086f27f
をキャンセルしています。
最後の9db704b4-33d9-4a55-88ad-7ac486c972cc
さらに3個目6086f27f
が2個目b4e967f4
と最後9db704b4
をキャンセルしています。
3個目の6086f27f-4d09-4b49-a53e-252436a4335f
所感
複数回ジョブが数秒レベルの誤差で実行されると想定通りにいかないケースがあるので、厳密なロックに使いたい場合は適していないかもしれません。そもそも数秒レベルの誤差で1つ目をどれと扱うべきかはなかなか難しいです😓
ただ、実際の運用ではそこまで同時に実行されることは少ないと思います。さらに、もともとが同時実行していたと考えるとそこまで気にするものでもないと思いますので、それなりにやりたいことはできている印象です。
ちなみに、各gcloud コマンドは、失敗してもエラーを出力するのみで処理を停止させないように実装しています🫣コマンド失敗時の挙動については改善の余地がありそうです。
おわりに
Cloud Build の同時実行に悩まされてるひとがこの記事を通して少しでも減れば嬉しいです😀
お財布にも少し優しくなるかもしれないので、ぜひcancelot.sh
からお試していただければと思います!!
その他参考資料
- Bashの変数スコープについてのメモ #Bash - Qiita
- bash で do...while する #Bash - Qiita
- オプション引数を指定してgcloudコマンドの出力をフォーマット, フィルタする #googlecloud - Qiita
Discussion