Language: 日本語
「Terraform と gcloud で同じリソースを触っていませんか?」 Cloud Run Job の二重所有による Etag 競合と属性ごとの所有権分離
クラウドインフラを運用していると、**「Terraform で定義したリソースを、デプロイ用スクリプトや CLI(`gcloud` 等)からも直接更新している」** という構成によく遭遇します。
クラウドインフラを運用していると、「Terraform で定義したリソースを、デプロイ用スクリプトや CLI(gcloud 等)からも直接更新している」 という構成によく遭遇します。
データベースのマイグレーションを実行する Cloud Run Job において、この 「2 つの書き手が 1 つのリソースを取り合う二重所有(Dual Ownership)」 が原因で、本番運用を揺るがす 2 つの深刻な問題が発生していました。
- 消えない Plan 差分:
gcloudのメタデータや環境変数の順序差分により、Terraform apply のたびに同じ差分が再生産され続ける(flip-flop 現象) - Etag 競合による CD 停止: 同一の push から DB デプロイと Terraform apply が並走した際、Cloud Run の楽観ロック(Etag)が衝突して
ABORTED: Conflict for resourceでデプロイが落ちる
Cloud Run CD のリビジョン反映トラップ(『「デプロイは Green なのにコードが変わっていない?」 Cloud Run CD で最新リビジョンが反映されない罠』)の裏側に潜んでいた、この二重所有問題。
この記事では、一時的な競合を吸収するリトライ設計から、Terraform の lifecycle ignore_changes による属性ごとの所有権分離と契約テスト で根本解決に至ったプロセスを解説します。
得られること
- Terraform と CLI/CD が同じリソースを更新することで生じる Etag 競合と差分ループの構造がわかる
- 一時的な楽観ロック競合のみを安全にリトライする指数バックオフ wrapper の設計が学べる
lifecycle ignore_changesを使ってリソースの属性ごとに「誰が正か」を明確に分担する IaC 設計が身につく- 属性分担の崩壊を防ぐ契約テスト(Contract Test)の実装方法がわかる!
1. 消えない Plan 差分と Etag 競合の正体
DB マイグレーション用の Cloud Run Job(migrate-job)には、2 つの更新経路が存在していました。
- DB deploy ワークフロー:
gcloud run jobs updateで最新のコンテナイメージと環境変数を注入して実行 - Terraform apply ワークフロー: インフラ全体の構成定義(CPU、メモリ、VPC、接続設定等)を適用
【 二重所有の衝突構造 】
[ DB deploy (gcloud) ] ───┐
├─→ 💥 [ Cloud Run Job ] ──→ Etag 競合で ABORTED & 差分ループ!
[ Terraform apply ] ───┘
差分がループする原因
gcloud run jobs update を叩くと、Google Cloud 側で client="gcloud", client_version="568.0.0" といった管理用メタデータが自動付与されます。
しかし、Terraform コードにはこの属性が定義されていないため、Terraform plan はこれらを null に戻そうとする差分を毎回吐き出します。
さらに、gcloud は CLI 引数で渡された順序のまま環境変数を保存するのに対し、Terraform は map をアルファベット順にソートして適用します。値は 1 つも変わっていないのに、「順序の差分」と「メタデータの有無」だけで毎回の apply が差分を再生産し続ける 状態に陥っていました。
並走時の Etag 競合
差分ループだけなら見た目の問題で済みますが、CI/CD パイプラインで両ワークフローが同時に並走した瞬間に本番障害へと発展します。
ERROR: ABORTED: Conflict for resource '<service>-migrate':
version '12345' was specified but current version is '12346'.
Cloud Run のリソース更新は Etag による楽観ロック で保護されているため、一方の更新が完了した直後に他方が古い Etag で上書きしようとすると ABORTED で即座に弾かれ、CD ワークフローが失敗していました。
2. 競合だけを拾うピンポイント・リトライ(対症療法)
ワークフロー全体を concurrency で直列化するとデプロイ待ち時間が無駄に伸びてしまうため、まずは 「Etag 競合が発生したコマンドだけをやり直す」リトライ wrapper を作成しました。
# gcloud run jobs update のリトライ制御ロジック(イメージ)
MAX_RETRIES=3
WAIT_SECONDS=(5 15 30) # 指数バックオフ
for attempt in $(seq 0 $MAX_RETRIES); do
# コマンドを実行し、stderr をキャプチャしつつリアルタイム出力
OUTPUT=$(gcloud run jobs update "$JOB_NAME" ... 2>&1 | tee /dev/stderr) && exit 0
# ABORTED かつ Conflict の場合のみリトライ
if echo "$OUTPUT" | grep -q "ABORTED" && echo "$OUTPUT" | grep -q "Conflict for resource"; then
echo "⚠️ Etag 競合を検知しました。再試行します(attempt: $attempt)..."
sleep "${WAIT_SECONDS[$attempt]}"
else
# 権限エラーや構文エラーなど、競合以外の失敗は即座に落とす(包括リトライ禁止)
exit 1
fi
done
リトライ設計のポイント
- 競合エラーのみを判定:
ABORTEDかつConflict for resourceにマッチした場合のみ再試行し、IAM 権限不足やイメージ不正などの本質的なエラーは即座に fail させる - ログを握りつぶさない:
stderrをキャプチャしつつ、呼び出し元の CI ログにもそのまま透過して流す - 単体テストで振る舞いを固定: 「正常時は 1 回で通過」「2 回競合後に成功」「異常エラー時は即終了」をテストで検証
これにより、Etag 衝突によるデプロイ失敗は大幅に減少しました。しかし、これは一時的な対症療法であり、Plan に出続ける差分ループは何も解決していませんでした。
3. lifecycle ignore_changes による属性ごとの所有権分離
根本対応として、「Terraform 側で特定の属性の管理権限を明示的に放棄し、属性ごとに唯一の所有者を決める」 設計へと移行しました。
Terraform の Cloud Run Job モジュールに lifecycle { ignore_changes = [ ... ] } を追加し、デプロイパイプライン側が書き換える属性を Terraform の管理対象から完全に切り離しました。
# Cloud Run Job の属性所有権の分離
resource "google_cloud_run_v2_job" "migrate" {
name = "${var.service_name}-migrate"
location = var.region
# Terraform が所有するインフラ構成
template {
task_count = 1
max_retries = 1
timeout = "600s"
template {
vpc_access {
connector = var.vpc_connector
}
# ...
}
}
lifecycle {
# CD ワークフロー(gcloud)が所有する属性を Terraform の監視から除外
ignore_changes = [
client,
client_version,
template[0].template[0].containers[0].image,
template[0].template[0].containers[0].env,
]
}
}
| 属性 | 所有者(正とする場所) | 理由・役割 |
|---|---|---|
client / client_version | DB deploy (gcloud) | gcloud CLI が自動管理するメタデータ |
containers[0].image / env | DB deploy (gcloud) | アプリケーションのリリースごとに動的に更新される |
| リソースの存在、CPU/メモリ、タイムアウト、VPC、Cloud SQL 接続 | Terraform | インフラの基本構造であり、変更頻度が低い |
所有権分離がもたらした効果
- Plan 差分の完全消滅: Terraform はデプロイごとに変わるイメージや環境変数を無視するため、apply のたびに出ていた flip-flop 差分が全環境で完全に消滅した。
- Etag 競合の構造的排除: Terraform が Job を更新するのは CPU やタイムアウトなど「インフラの構造変更」を行うときだけになり、通常のデプロイで両者が同じ Job を取り合うことがなくなった。
- 対症療法リトライの安全なクローズ: 根本的な競合が起きなくなったため、一時的に導入していたリトライ wrapper PR を役目を終えたものとしてクローズした。
4. 契約テスト(Contract Test)による境界の固定
所有権を分離すると、「将来別の開発者が ignore_changes を勝手に消したり増やしたりして境界が崩れる」という新たなリスクが生まれます。
そこで、Terraform 定義に対する 契約テスト(Contract Test) を導入し、ignore_changes に指定された 4 つの属性が完全に一致していることを CI で機械的に保証するようにしました。
// ignore_changes の契約テスト(イメージ)
describe('Terraform migrate-job contract', () => {
it('ignore_changes の対象属性が正確に 4 項目と一致していること', () => {
const jobModule = loadTerraformJson('modules/migrate_job');
const ignored = jobModule.resource.google_cloud_run_v2_job.migrate.lifecycle.ignore_changes;
expect(ignored).toEqual([
'client',
'client_version',
'template[0].template[0].containers[0].image',
'template[0].template[0].containers[0].env',
]);
});
});
「環境変数を新しく追加したいときはどちらを直すべきか?」という疑問に対しても、「環境変数は CD 側のタスク定義が正(Terraform 側の記述は初期作成時のみの初期値)」という明確なルールが、契約テストとドキュメントによってチーム全体に固定されました。
まとめ: 1 つのリソースに対する「書き手」を 1 つに絞る
クラウドインフラと CI/CD パイプラインが連携する現場において、最も重要な原則は 「属性単位で責任者(Single Source of Truth)を 1 つに決める」 ことです。
- 二重所有を避ける: 同じリソースを Terraform と CLI の両方から無制限に更新しない
- 一時的な競合はピンポイントで受ける: 楽観ロック競合が発生した場合は、包括リトライではなく競合エラーだけを対象に指数バックオフでリトライする
ignore_changesで境界を引く: デプロイ頻度の高い属性(イメージ・環境変数)は CD に委ね、静的なインフラ構成は Terraform が保持する- 契約テストで固定する: 属性の分担表をコードとテストで縛り、設定のドリフトを未然に防ぐ
リソースの所有権境界を厳密に定義することで、差分のノイズが消え、安定して高速に回る CI/CD 基盤を維持することができます。