
こんにちは、バックエンド基盤チームの藤井脩紀です。
バックエンド基盤チームは、バックエンドエンジニアの生産性向上やコスト削減を目的に、エンジニア主導で課題を発見・解決している部署です。
その業務の一環として、コスト削減を目的にGoogle CloudのCloud Buildへ自動キャンセルを導入しました。本記事では、その取り組みについて紹介します。
自動キャンセルとは
はじめに、自動キャンセルとは何かを説明します。例えばgit pushをトリガーに発火するビルドがある場合に、同一ブランチでpushを複数回行うと、最新のpushに対応するビルド以外を自動的にキャンセルする仕組みのことです。
ミラティブのバックエンドでは、プルリクエストのCIにCloud Buildを利用しています。pushした直後にミスに気づき、すぐに修正を再pushするようなケースでは、古いビルドが走り続けることが無駄になります。このようなビルドをキャンセルしたい、というのが導入の動機です。
自動キャンセルは、GitHub Actionsであれば以下のような設定で実現可能です。しかし、Cloud Buildでは標準機能として提供されていないため、自前で対応する必要があります。
concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true
標準機能で実現できないとしても、OSSなどで対応できないかと調べたところ、cancelotを見つけました。しかし、キャンセル対象をグルーピングする単位が「ブランチ」または「ブランチとトリガーのペア」の2種類に限定されており、要件に一致しませんでした。
その要件とは、gcloud builds submit コマンドによって実行した、トリガーやブランチに紐づかないビルドも対象にするというものです。ミラティブのバックエンドでは、諸事情によりビルドの中で gcloud builds submit を実行して子ビルドを開始しています。その子ビルドも含めてキャンセルする必要があったため、要件を満たすものを自作することにしました。
利用方法
自作したものを理解しやすくするため、先に完成した成果物とその利用方法を紹介します。
今回作成したのが、Dockerイメージのnoih/cloudbuild-cancel-in-progressです。(本記事用に改変したものであり、実際に運用しているものとは異なります。)
利用例
以下のように、ビルドのステップの1つとして実行します。
steps: - id: 'cancel-in-progress' name: 'noih/cloudbuild-cancel-in-progress:0.1.1' args: - '--project-id=$PROJECT_ID' - '--location=$LOCATION' - '--build-id=$BUILD_ID' - '--filter=tags="cancel-group-1"' waitFor: ['-'] - id: 'main' # ビルドで本来行いたい処理のステップ name: 'debian:bookworm-slim' args: ['bash', '-c', 'sleep 120'] waitFor: ['-'] tags: - tag-1 - cancel-group-1 # ここでトリガーのタグを指定
中心となるのは引数の --filter で、これを用いてキャンセル対象のビルドを絞り込みます。この例では tags="cancel-group-1" を指定し、トリガーのタグ(Gitのタグではない)でグルーピングしています。tags は配列ですが、いずれかのタグが一致すれば真と判定されます。(後述しますが、絞り込みの条件は内部でいくつか追加されます。)
トリガーのタグであれば、前述の gcloud builds submit コマンドによって実行した、トリガーやブランチに紐づかないビルドも対象にできます。cancelotと同様に、ブランチとトリガーのペアでグルーピングしたい場合は、以下のような引数を指定します。(ブランチと言いつつ REF_NAME であるため、タグが入ることもあります。どちらかに限定したい場合は、BRANCH_NAME や TAG_NAME に置き換えてください。)
--filter=substitutions.REF_NAME="$REF_NAME" AND substitutions.TRIGGER_NAME="$TRIGGER_NAME"
--filter 以外の引数は現在のビルド情報の取得に使用されますが、常に決まった値を渡すため特に説明することはありません。また、処理の完了を待機する必要はないため、waitFor: ['-'] でステップを並列実行しています。
実装
続いて、内部実装について解説します。
コードを提示しつつ説明しますが、前提としてCloud Build APIのクライアントにcloud.google.com/go/cloudbuildパッケージを利用しています。
client, err := cloudbuild.NewClient(ctx)
処理内容は単純で、3つのステップで構成されています。順番に説明します。
ステップ1: 現在のビルド情報を取得
まず、現在のビルドが作成された時刻を取得します。この時刻は自身より古いビルドのみを対象としてキャンセルするために使用します。
var createTime string if build, err := client.GetBuild(ctx, &cloudbuildpb.GetBuildRequest{ Name: "projects/" + projectID + "/locations/" + location + "/builds/" + buildID, }); err != nil { return err } else { createTime = build.GetCreateTime().AsTime().Format(time.RFC3339Nano) }
ステップ2: キャンセル対象のビルド一覧を取得
キャンセル対象のビルド一覧を取得します。ただし、正確にはイテレータを作成しているだけで、この段階ではまだ取得していません。
filter = fmt.Sprintf(`(status="QUEUED" OR status="WORKING" OR status="PENDING") AND create_time<%q AND (%s)`, createTime, filter) it := client.ListBuilds(ctx, &cloudbuildpb.ListBuildsRequest{ Parent: "projects/" + projectID + "/locations/" + location, Filter: filter, })
フィルタは以下のような構成です。
(status="QUEUED" OR status="WORKING" OR status="PENDING")- 実行前・実行中のビルドが対象
create_time<"{createTime}"- 現在のビルドよりも作成時刻が古いビルドが対象
({filter})--filterフラグを適用
ステップ3: キャンセル実行
最後に、作成したイテレータでループを回しながら、実際にキャンセルを実行します。注意点として、キャンセルはリクエストするのみで、完了までは待機しません。
for { inProgressBuild, err := it.Next() if err != nil { if errors.Is(err, iterator.Done) { break } if st, ok := status.FromError(err); ok && st.Code() == codes.InvalidArgument { // リクエストパラメータが不正(おそらく `--filter` が不正) return cli.Exit("error: filter is probably invalid: "+filter, 1) } return err } if _, err := client.CancelBuild(ctx, &cloudbuildpb.CancelBuildRequest{ Name: "projects/" + projectID + "/locations/" + location + "/builds/" + inProgressBuild.GetId(), }); err != nil { if st, ok := status.FromError(err); ok && st.Code() == codes.FailedPrecondition { // すでに終了しているなどの理由でキャンセル不要 continue } return err } fmt.Fprintf(os.Stderr, "cancel: %s\n", inProgressBuild.GetLogUrl()) }
特別なことはしていませんが、ビルド一覧の取得からキャンセルまでの間にビルドが終了すると、キャンセルがエラーになります。そのため、codes.FailedPrecondition のエラーは許容しています。
以上が実装内容です。
振り返り
次に、自動キャンセルを自前で実装する上で困った点や、カバーしなかった機能について説明します。
フィルタ
実装する上で最も困ったのは、ビルド一覧を絞り込むためのフィルタの仕様が不透明な点でした。
具体的には、Cloud Build APIのprojects.builds.listのクエリパラメータ filter のことで、今回紹介したGoのコード上では ListBuildsRequest.Filter が該当します。しかし、これに関する詳細なドキュメントが見当たらず、手探りで実装を進めることになりました。
AIに質問もしてみましたが、正確な回答は得られませんでした。そのため、存在しないフィールド名の指定や構文の誤りがあるとリクエストが失敗することを頼りに、実際に実行して成功するかどうかを確認しながら進めました。
なお、gcloud builds list コマンドの --filter フラグは、内部リクエストのクエリパラメータとしてそのまま渡される訳ではないようで、等価ではありません。(内部リクエストの内容は、--log-http フラグを渡すことで確認可能です。)
ただ、これは単に見つけられなかっただけで、実装後に参考になるドキュメントを発見しました。こちらの「クエリを使用してビルド結果をフィルタする」に、利用できるフィールド名や構文がある程度記載されています。これはCloud Build APIに関するものではなく、ブラウザ上でビルド履歴を絞り込むためのガイドであったため見落としていましたが、内部的には同じAPIを使用していると思われます。ただし、substitutions.* などの記載がないため、完全なものではありません。
同時実行制御
冒頭でGitHub Actionsの concurrency を自動キャンセルの例として挙げましたが、こちらは名前の通り、並列実行を制御するための設定項目です。
対して、今回作成したものはキャンセルを行うのみで、並列実行を制御する機能は持っていません。client.CancelBuild はリクエストするのみで、実際にキャンセルが完了するまで待機しないためです。厳密にガードできるかは疑わしいものの、client.ListBuilds をポーリングして待機すれば実現自体は可能と考えられます。しかし、待機中も課金対象になるため、好ましくないと判断してサポートしませんでした。
このことから、Cloud Buildは同時実行制御が必要なケースにはあまり適していないと考えています。
同一時刻のキャンセル
自分より古いビルドを対象とするための条件式を create_time<"{createTime}" としており、<= ではないため、時刻が一致した場合にはキャンセルが発生しないというのが現状の仕様です。
<= としなかったのは、作成時刻が重複したときに、理論上は全てのビルドがキャンセルされてしまう可能性があるためです。作成時刻以外の情報も使って優先度を決めれば回避できる可能性はありますが、適切な条件を設計できませんでした。
ビルドの作成時刻はマイクロ秒精度まで保持されているようなので、重複する可能性は低いと考えられます。また、同時実行制御ではないためそこまで厳密である必要はないと判断し、この仕様を許容しました。
// create_time: 2026-05-27T10:05:32.512979Z (seconds:1779876332 nanos:512979000) fmt.Fprintf(os.Stderr, "create_time: %s (%s)\n", build.GetCreateTime().AsTime().Format(time.RFC3339Nano), build.GetCreateTime().String())
ロケーション跨ぎ
キャンセル対象のビルド一覧取得時のパラメータに projects/{projectID}/locations/{location} を渡しているため、ロケーションを跨いだキャンセルは現状できません。ロケーションごとにリクエストすれば実現は可能ですが、現時点で必要なユースケースがないため、カバーしませんでした。
効果
自動キャンセルを導入した効果について、約1ヶ月分のビルドを対象に確認したところ、約16%のビルドがキャンセルされていました。
また、キャンセルされなかった場合の実行時間を「成功したビルドの実行時間の中央値」と仮定して計算すると、約13%の費用削減となっていました。
ただし、キャンセルの回数やタイミングは一定ではなく、実行時間にもブレがあるため、この概算の正確性は保証できず、今後も同様の効果が得られるとは限りません。
まとめ
ここまでお読みいただきありがとうございます。今回はCloud Buildの自動キャンセルについて紹介しました。
前述の通り、Cloud Buildで自動キャンセルを行うには一手間必要です。GitHub Actionsのように標準機能として提供されることが望ましいですが、ビルドがメインの用途であることを考えると、必須機能とは言えないため妥当な仕様だと考えています。
また、今回紹介したものは以下で公開しているので、興味があればご自由にご利用ください。
- Dockerイメージ
- ソースコード
さいごに
ミラティブではバックエンドエンジニアを含め複数のポジションで力を貸してくださる方々を募集しています!