🥋

Cloud Build トリガーの不要な同時実行を避ける方法を考える

2024/03/18に公開

はじめに

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を使う

https://mixi-developers.mixi.co.jp/strongest-terraform-terragrunt-ci-e4c350d627e6#3d1e

この記事[2]の「ジョブのキャンセルをしよう」で紹介されている cancelot.sh を使います。これを最初のステップで呼び出すことで、実行されているトリガーと同じトリガーがすでに実行中であれば、それをキャンセルすることができます。ちなみに、おおもとのcloud-builders-community[3]の方はメンテナンスされておらず、docker build すらできませんでした 😐

使い方

cancelot.sh をダウンロードしてきて、Cloud Build の最初のステップで呼び出すだけです。ただ global で動かす場合は問題があるので後述の「スクリプトの修正」を参照してください。簡単ですね👍

  1. Shell スクリプトをダウンロードする
  2. スクリプトを修正する
  3. Cloud Build 構成ファイルにステップを追加して、スクリプトと共にリポジトリへ push する
  4. トリガーを実行する
# ダウンロードコマンド
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として取得できます。

gcloud builds describe

## 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

gcloud builds list

## 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

gcloud builds cancel

## 3. ビルドIDを指定してキャンセルする
## リージョンが未設定の場合は、global として設定されます
gcloud builds cancel "$ID"
## e.g.)
gcloud builds cancel 6ab72514-a23f-4c38-9123-d53b914b1bd2

waiting.sh をつくってみる

cancelot.sh はビルドをキャンセルするので、terraform などを実行していた場合はロックしたまま落ちてしまったりします。あるいは、キャンセルしたあと、またキャンセルが続いてしばらくビルドが完了しないといった問題が発生します。そのためcancelot.sh を参考に、キャンセルせずに終わるまで待機するスクリプトを作成してみます。

https://github.com/sikeda107/tech-blog/blob/main/CloudBuildsTips/waiting.sh

gcloud builds list で取得したビルド ID の件数をチェックして、INTERVAL で指定した秒数のあいだ待機します。待機中のものがなくなったら、次のステップへ移行します。ステップは待機しないと意味がないので、かならず waitFor を指定するようにしましょう。

https://github.com/sikeda107/tech-blog/blob/main/CloudBuildsTips/cloudbuild.yaml

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配下に限定して行います。

https://github.com/sikeda107/tech-blog/blob/246abaf881778954eb43f948ed0c7432e748d2d0/CloudBuildsTips/cloud_build_lock_manager.sh#L4-L14

削除

各ビルドの最後にはかならずロックファイルを削除する処理をいれるようにします。ロックファイルが残った状態で待機処理が走ってしまうと、ずっと待機してしまう可能性があります。

https://github.com/sikeda107/tech-blog/blob/246abaf881778954eb43f948ed0c7432e748d2d0/CloudBuildsTips/cloud_build_lock_manager.sh#L15-L19

待機処理 - wait_build関数

  1. ロックファイルの取得: CloudStorageからロックファイル一覧を取得し、各ファイルのタイムスタンプとビルド ID を抽出します
  2. ビルドのカウント: 現在のビルドよりも古い作成時刻を持ち、現在のビルドIDと異なるビルドをカウントします
  3. 処理の待機: ロックファイルの数が 1 以上であれば、指定したINTERVALで待機します
  4. 繰り返し: 条件に合致するロックファイルがなくなるまで、上記のステップを繰り返します。

https://github.com/sikeda107/tech-blog/blob/246abaf881778954eb43f948ed0c7432e748d2d0/CloudBuildsTips/cloud_build_lock_manager.sh#L20-L43

キャンセル処理 - cancel_build関数

  1. ロックファイルの取得: CloudStorageからロックファイル一覧を取得し、各ファイルのタイムスタンプとビルドIDを抽出します
  2. ビルドのキャンセル: 現在のビルドよりも古い作成時刻を持ち、現在のビルドIDと異なるビルドをキャンセルします
  3. ロックファイルの削除: キャンセルしたビルドに合致するロックファイルを削除します

https://github.com/sikeda107/tech-blog/blob/246abaf881778954eb43f948ed0c7432e748d2d0/CloudBuildsTips/cloud_build_lock_manager.sh#L44-L58

待機処理 - 動作確認

テスト用の構成ファイルです。
https://github.com/sikeda107/tech-blog/blob/main/CloudBuildsTips/cloudbuild_submit_waiting.yaml

# 連続で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

キャンセル処理 - 動作確認

テスト用の構成ファイルです。
https://github.com/sikeda107/tech-blog/blob/main/CloudBuildsTips/cloudbuild_submit_cancel.yaml

# 連続で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からお試していただければと思います!!

その他参考資料

脚注
  1. Cloud Build リポジトリ ↩︎

  2. CloudBuild で最強の Terraform & Terragrunt CI 環境を作る MIXI DEVELOPERS ↩︎

  3. cloud-builders-community/cancelot at master · GoogleCloudPlatform/cloud-builders-community · GitHub ↩︎

  4. 変数値の置換 ↩︎

  5. CLI と API を使用してビルドを送信する    Cloud Build のドキュメント    Google Cloud ↩︎

  6. ビルド構成ファイルのスキーマ - timeout ↩︎

コミューン株式会社

Discussion