Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

staging環境をdestroyしたらGCSのNoSuchBucketで本番画像が404になった

公開 読了時間 約4分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

prodのseedは環境変数SEED_GINAN_AD_MEDIA_BASEを上書きしない運用だったため、コードの既定値がそのまま本番DBのmedia_urlに焼き込まれ、その既定値が指すstagingのGCSバケットをterraform destroyで撤去した瞬間に本番画像がNoSuchBucketで404になった。

結論

GCS の公開バケットを指す URL をコードの既定値として持たせ、その既定値を環境変数で上書きしないまま実行すると、既定値が指すバケットは「使われていないコード上の文字列」ではなく「本番 DB に焼き込まれた実データ」になる。 その既定値が本来 staging 用のバケットを指したままだった場合、staging 環境を terraform destroy で撤去した瞬間に、本番側の画像配信が GCS の NoSuchBucketThe specified bucket does not exist.)で 404 になる。直し方はコード側の既定値を prod 用バケットへ変更し、画像を prod バケット側に作り直すことだが、再発防止としてはビルド設定側の既定値も含めて洗い出す必要があった。

症状

本番の広告画像が複数枚、同時に 404 になった。サイネージは 2026-07-28 から夏季休業で停止中だったため、盤面上での表示実害は発生していない。原因を辿ると、画像の URL(media_url)がすべて staging 環境の GCS バケットを指していた。このバケットは 2026-08-04 に staging 環境を terraform destroy で撤去した際に、他の staging リソースと一緒に消滅している。

原因

広告画像の URL を DB に書き込む seed スクリプトは、base URL をこう定義していた。

/** staging 公開 GCS バケット(Terraform: modules/ad_media、envs/staging)。末尾スラッシュなし。 */
export const AD_MEDIA_BASE =
  process.env.SEED_AD_MEDIA_BASE ??
  "https://storage.googleapis.com/<staging用バケット>/ads";

環境変数で上書きできる設計にはなっているが、フォールバック先のコード上の既定値は staging 用バケットを指している。開発中は staging を実行対象にすることが多く、この既定値のままで違和感なく動く。

問題は prod に対して seed を実行する経路が、この環境変数を渡していなかったことだった。渡していない以上 ?? の右辺が採用され、prod の seed 実行のたびにコード上の staging バケット URL がそのまま本番 DB の media_url 列に書き込まれていた。DB に一度書き込まれた URL は、以後 GCS 側で何が起きようと自動では変わらない。

さらに調べると、この状態は seed スクリプト単体の問題ではなかった。web サービスの Cloud Build 設定にも _AUTH_DOMAIN _PROJECT_ID _REPO の既定値として staging プロジェクトの値が焼き込まれており、prod 向けに明示的な --substitutions を渡し忘れて build すると、存在しない Firebase プロジェクトやレジストリを指すイメージができる状態になっていた。staging を既定値にする設計そのものが、複数箇所に同じ形で埋め込まれていた。

結果として、terraform が管理する staging 環境のリソース依存関係だけを見ても、本番 DB の列やビルド設定の既定値という IaC の外側に残った参照 には気づけない。terraform destroy 自体の判断(検証環境としての価値が薄いのに月1万円超のコストがかかっていた)は正しかったが、撤去対象への参照が IaC の外にも残っていないかは、別途洗い出す必要があった。

直す

同じパターンを共有していた seed 4 ファイルの既定値を prod 用バケットへ変更した。

/** prod 公開 GCS バケット(Terraform: modules/ad_media、envs/prod)。末尾スラッシュなし。
 *  ⚠ 2026-08-04: staging 環境を撤去したため staging → prod へ変更。staging を指したまま
 *  destroy したことで本番の広告 5 枚が 404 になった。以後 prod 以外を既定にしないこと。 */
export const AD_MEDIA_BASE =
  process.env.SEED_AD_MEDIA_BASE ??
  "https://storage.googleapis.com/<prod用バケット>/ads";

掲載中の画像は原本から prod バケットへ再アップロードし、DB の media_url は seed の再実行で更新した。あわせて web・jobs・migrate の build 設定と Dockerfile の既定値も staging プロジェクトから prod プロジェクト(プロジェクト番号を含む)へ変更し、_AUTH_DOMAIN _PROJECT_ID _REPO が指す先を prod に揃えた。

再発防止

修正のコメントに、そのまま「以後 prod 以外を既定にしないこと」という注記を残した。だがこの注記自体は次に同じ種類の既定値を書く人への警告にしかならず、「既定値が実データに焼き込まれる」という構造そのものは変わっていない。 環境変数で上書きする設計は、渡し忘れた瞬間に無言でコード側の既定値へ戻る。渡し忘れに気づけなかったのは、seed 実行時に「どの値が採用されたか」を出力していなかったためでもある。既定値を持たせる設計を採るなら、採用した値をログに残すか、prod 実行時は環境変数が未設定だとそもそも起動しない構成にするほうが、同じ形の事故を防げる。

よくある質問

Q1GCSでNoSuchBucketが出るのはどんな時ですか?

指定したバケットが存在しない時にGCSが返すエラーです。storage.googleapis.com/<bucket>/<path>という公開URLに対し、そのバケットが削除済みか名前を間違えていると、XML形式で「The specified bucket does not exist.」が返ります。バケット名を見ても、URLを組み立てている側の既定値がどこを指しているかは分からないため、原因調査ではその既定値の出どころまで辿る必要があります。

Q2環境変数で上書きしているのに、なぜ既定値のままDBに焼き込まれたのですか?

本番の実行コマンドがその環境変数を渡していなかったためです。コード側は `process.env.SEED_GINAN_AD_MEDIA_BASE ?? "<staging既定値>"` という形で、環境変数が無ければコードの既定値にフォールバックする実装だった。stagingを立てた開発中はデプロイスクリプトが `--update-env-vars` で明示的に上書きしていたが、本番の実行経路はそれを渡していなかったため、seedを実行するたびにコード側の既定値(staging)がそのままDBの列に書き込まれていた。

Q3staging環境をdestroyする前に何を確認すればよかったのですか?

撤去するバケットのURLが、他の環境のデータベースやコードの既定値からまだ参照されていないかです。今回はTerraformでstagingを削除すること自体は正しい判断だったが、本番DBの列にそのバケットのURLが既に永続化されていることは、Terraformの依存関係からは見えない。IaCで管理していないランタイムのデータ(DBの列やビルド設定の既定値)に同じ環境名の参照が残っていないかを、削除前に別途洗い出す必要があった。

Q4サイネージ端末側は今回どう挙動したのですか?

この事故が起きた期間はサイネージが夏季休業で停止中だったため、端末側での表示実害は発生していない。ただし本番の広告画像URLは事故発生時点から404のままDBに残っており、稼働を再開すれば発見されずに表示され続けていた状態だった。

確認した環境

  • Terraform >=1.9 / google provider ~>6.0(envs/prod)
  • 2026-08-04 に発生・同日中に修正

この記事の根拠

  • TypeScriptファイル 29〜32行目コミット 925aeac
  • TypeScriptファイル 29〜34行目コミット 5d81772
  • YAMLファイル 1〜3行目コミット 12c4406
  • YAMLファイル 35〜40行目コミット 12c4406

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。