Gadeninアプリを入れる

運用と障害読了 11 分

Cloudflare D1のmigrationとデプロイ順序で本番障害を2回起こした話

Cloudflare WorkersとD1で、migrationより先にコードをデプロイして公開APIを500にし、その13日後にはreleaseブランチをmainへ戻し忘れてAPIを消した。コードとDBの不整合で2度の本番障害を起こした原因と、expand/contractやCI/CDを調べて見直したデプロイ設計をまとめる。

失敗した話も公開していきたいと思います。まず、障害が起きてしまったといっても、まだGadeninリリースして1週間ちょっとということもあり、実際のユーザー影響はゼロに近しい状態だったため、当時はそこまで大問題にはならなかったが、現時点では数十人にインストールしていただいているため、今は絶対に起こしてはならない問題を共有する。

まず9月9日、Gadeninの本番APIがほぼ全滅した。

Accept-Language ヘッダを付けるだけで、作物APIも病害虫APIも 500 Internal Server Error を返すようになった。わけがわからない。

その13日後、9月22日。

今度は、本番からシーズンレポートのAPIそのものが消えた。App Storeに公開しているGadenin APPがそのAPIを呼び出し、普通に失敗する。

根本的な原因については、まだユーザー影響がない状態のうちは単に自分がAI使ってイケイケドンドンな開発を良しとしていたからであると言ってしまえばそれまでなのだが、、直接的な原因はデータの不整合ということにはなる。

  • 9月9日:コードが新しく、DBが古かった
  • 9月22日:DBが新しく、コードが古かった

逆の事を言っているようだが、根本的な問題は同じである。

コードとデータベースをどの順番で本番へ出すかを、人間が覚えていた。

そして、その人間は自分ひとり。

この記事では、Cloudflare Workers + D1で実際に起こした2つの本番障害を振り返りながら、DB migrationとアプリケーションコードを安全にデプロイするにはどう設計すべきなのかを整理する。

1回目:Cloudflare D1のmigrationを忘れ、公開APIが500になった

あるとき、Gadenin for WEBのビルドがおちるようになったことがきっかけて異変に気づいた。ビルド実行時に本番のAPIを叩いたりしていることもあって、そこで気づけたのである。

色々と調べていたら、Accept-Languageヘッダーをつけている場合に500が返り、取り除くと200となるという謎現象が起きていた。

curl -H 'Accept-Language: ja' [API のURL]
# → 500

curl [API のURL]
# → 200

わけがわからない。

影響していたのは、少なくとも次のAPIだった。

  • 作物・植物の一覧取得API
  • 作物・植物の詳細取得API
  • 病害虫、悩み系のデータ取得API

ブラウザは通常、ユーザーの言語設定に応じて Accept-Language を自動送信する仕様なので、一般的なウェブブラウザを使っている方々はほぼ全員500エラーを受ける状態になっていたということ。

直接的な原因はデプロイの順序のミスによりコードが先に本番にでたこと

あるあるの事故原因で、普通はこんな事あってはならないんだけど、コードが先に本番にでてしまい、DBの状態が古い状態であると、I/Fの破壊的変更についていけずに落ちるという初歩的なミス。これをClaude codeでほぼ並列で走らせていた自分が悪いんで、わかりきっている。まだユーザー数が限りなくゼロに近い状態だったからこそ起きた問題。

応急処置として対応したのは「テーブルがなければ日本語で返す」

本来なら、その場でmigrationを適用すれば直る。

ただし、多言語対応を担当していた作業セッションの状態を確認せず、別のセッションから他の変更を含むmigrationを本番へ流すことは避けた。

そこで、まずアプリケーション側を壊れにくくした。

async function withoutLocalizationWhenMissing<T>(
  run: () => Promise<T>,
  fallback: () => Promise<T>,
): Promise<T> {
  try {
    return await run();
  } catch (error) {
    if (!isMissingSchema(error)) throw error;

    console.warn(
      '[i18n] 多言語の表が未適用のため日本語で返します:',
      error instanceof Error ? error.message : error,
    );

    return await fallback();
  }
}

握りつぶすのは、以下2点の限定的な状況だけ。

no such table
no such column

それ以外の例外は今までどおり500にする。

当然すべてをtry catchしてフォールバックしてしまうと、何が原因で落ちたか将来的に根本原因が潰れてしまって調査が全くできなくなってしまうから、絶対にやってはいけないこと。

これで作物と病害虫のAPIは、翻訳テーブルが無い場合でも日本語を返せるようになった。

ところが約2時間後、まだ500になるAPIが見つかった。

こちらは多言語対応で追加した新しいテーブルを直接参照していたため、先ほどのように日本語へフォールバックすることもできない。

そこで一時的に、テーブルが存在しない場合は空のデータを返すように変更した。

その日のうちにmigrationを本番へ適用し、多言語対応は無事に稼働した。これで一旦、一件落着。

2回目:今度は本番のコードが古くなった

しかし13日後、今度はまったく逆の事故を起こした。9月22日、アプリの動作確認中に一部の機能が使えなくなっていることに気づいた。

調べてみると、必要なAPIが本番から消えていた。

原因は単純だった。

新機能を開発していたreleaseブランチをmainへ戻さないまま、その後mainを本番へデプロイしてしまった。

その結果、データベースは新しいのに、アプリケーションコードだけが古い状態になった。

Application Code: OLD
Database Schema:   NEW

1回目とは、きれいに逆だった。

  • 1回目:コードが新しく、DBが古い
  • 2回目:DBが新しく、コードが古い

修正はreleaseブランチをmainへマージして再デプロイするだけだった。

結局、2回とも問題の本質は同じだった。

本番に何を、どの順番で出すかを、自分の記憶に頼っていた。

2回の本番障害を並べると、原因は同じだった

9月9日 9月22日
障害 公開APIが500 APIが404
影響 作物・病害虫・気候帯API シーズンレポート・成長ムービー
コード 新しい 古い
DB 古い 新しい
忘れたこと migrationの適用 release → mainの反映
発見 Webビルド失敗 App Storeスクショ撮影
復旧 fallback + migration適用 releaseをmainへマージ

2回の事故を並べた図。9月9日はコードが新しくDBが古い状態で公開APIが500になった。9月22日はコードが古くDBが新しい状態でAPIが404になった。どちらもコードとDBがずれている 2回とも、

コードとDBのバージョンがずれた。

違うのは、どちらが先に進んでいたかだけだった。

Cloudflare D1のmigrationとWorkerのデプロイは別操作

Cloudflare D1では、migrationを migrations/ 以下のSQLファイルとして管理できる。

本番への適用は、たとえば次のコマンドになる。

wrangler d1 migrations apply DB --remote

一方、Cloudflare Workerのデプロイは別だ。

wrangler deploy

Cloudflareの仕組み上、この2つは別の操作になる。

つまり、

DB migration
↓
Worker deploy

という順番を保証したいなら、自分たちのCI/CD側で保証する必要がある。

当時の package.json はこうなっていた。

{
  "deploy": "wrangler deploy",
  "migrate": "wrangler d1 migrations apply DB --remote",
  "migrate:status": "wrangler d1 migrations list DB --remote && wrangler d1 migrations list DB --env staging --remote"
}

deploy は migrate を呼ばない。

CIのdeployジョブも、

npm run deploy

するだけ。

migrationには触れない。

つまり、

migrationを先に実行するというルールは、CIではなく人間の頭の中に存在していた。

今回いちばん問題だったのはここだった。

Cloudflare D1では wrangler d1 migrations apply で未適用migrationを適用でき、Cloudflare Workers側とは別のコマンドとして提供されている。そのため、順序を保証するならCI/CD側でフローを組む必要がある。

調べ直したら、昔から答えがあった

事故のあと、DB migrationとデプロイについて改めて調べ直した。

特別新しい問題ではない。

むしろ、何十年も前からかなり整理されている。

自分たちに足りなかったものを、4つに分けた。

1. DB migrationをコードと同じリリースの一部として扱う

Pramod SadalageとMartin Fowlerの「Evolutionary Database Design」では、DBスキーマの変更もアプリケーションコードと同じようにバージョン管理し、小さなmigrationとして積み上げていく考え方が紹介されている。

Gadeninでも、migrationファイル自体はGitで管理していた。

問題はその次だった。

migrationファイルを作る → Gitで管理する

までは自動化されている。

しかし、

本番へ適用する

だけは人間だった。

半分しか仕組みにしていなかった。

2. expand / contractで「どちらが先でも壊れない」変更にする

Martin Fowlerのサイトで紹介されている「Parallel Change」、いわゆる expand / migrate / contract は、互換性を壊す変更を一度に行わないための考え方だ。

ざっくり書けば、

Expand
↓
Migrate
↓
Contract

の3段階に分ける。

たとえばカラムを変更するなら、

1. 新しいカラムを追加する
2. 新旧両方に対応したコードを出す
3. データを移行する
4. 新しいカラムだけを使う
5. 古いカラムを削除する

というように進める。

重要なのは、

途中のどの状態でもアプリが動くこと。

9月9日の事故では、新しいコードが新しいテーブルを「必ず存在するもの」として読んでいた。

だから、

Code NEW + DB OLD

で壊れた。

後から追加した、

翻訳テーブルが無ければ日本語を返す

というfallbackは、結果的にはこの互換期間を作ったことになる。

最初からそうしていれば、migrationとWorkerのデプロイ順序が多少前後しても500にはならなかった。

3. Cloudflare WorkersをロールバックしてもD1は戻らない

Cloudflare Workersには、以前のWorkerバージョンへ戻すロールバック機能がある。

ただし重要なのは、

Workerをロールバックしても、接続しているD1などのリソースの状態まで同時に過去へ戻るわけではない。

Cloudflareのドキュメントでも、以前のコードへ戻した際、データ構造が変更されているとエラーが起こり得ると注意されている。

つまり、

Worker NEW
D1     NEW

という状態からWorkerだけ戻せば、

Worker OLD
D1     NEW

になる。

これは、9月22日に自分で作った状態とほぼ同じだ。

だからDB migrationを伴うリリースでは、

「問題があったらWorkerをrollbackすればいい」

だけでは足りない。

古いコードでも新しいDBを扱える期間を作る必要がある。

ここでもexpand / contractが効いてくる。

4. releaseブランチでは開発しない

2回目の事故を調べていて、かなり刺さったのがTrunk Based Developmentの「Branch for release」だった。

releaseブランチは継続開発する場所ではなく、基本的な変更はtrunkで行い、必要な修正だけをrelease側へcherry-pickする運用が紹介されている。

自分は、ほぼ逆をやっていた。

release/1.6.0
  ↓
8コミット開発
  ↓
本番へデプロイ
  ↓
mainへ戻し忘れる

その後、

main
↓
別変更
↓
本番へデプロイ

したことで、release側にしか存在しなかった機能が全部消えた。

もし開発の正本を常に main にして、

main
↓
release

という一方向だけにしていれば、この事故はかなり起こりにくかった。

releaseからmainへ「あとで戻す」という仕事そのものが発生しないからだ。

Trunk Based Developmentでも、release branchでは継続開発せず、修正はtrunk側で行ってrelease branchへcherry-pickする方向が推奨されている。

結局、2回とも「人が覚える仕組み」が壊れた

2つの事故をまとめると、どちらも同じだった。

1回目。

migrationを流す
↓
Workerをdeployする

2回目。

releaseをmainへ反映する
↓
mainをdeployする

どちらも、

「AをやったらBをやる」

という手順だった。

そして、その順番を人間が覚えていた。

人間なので、忘れた。

Railsの strong_migrations のようなツールが、危険なmigrationを開発段階で機械的に検出しようとするのも、結局は同じ思想だと思う。

安全性を人の注意力に依存させない。

事故後、デプロイ前の確認を2つ増やした

ひとまず、事故後に2つのチェックを追加した。

1. 未適用migrationを確認する

npm run migrate:status

本番とstagingの未適用migrationを見る。

未適用があれば、そのままWorkerをデプロイしない。

2. releaseがmainより先に進んでいないか確認する

git log origin/main..origin/release/<latest>

差分があれば、本番デプロイしない。

どちらも手順書に追加した。

これで防げる。

……と書こうとして、手が止まった。

手順を増やしても、人間が実行するならまた忘れる

今回の2つの事故も、知らなかったから起きたわけではない。

migrationを先に流すことは知っていた。

releaseブランチの変更をmainへ戻す必要があることも知っていた。

知っていたけれど、その日は確認しなかった。

だから、

デプロイ前にmigrationを確認する
デプロイ前にreleaseとの差分を確認する

という儀式を2つ増やしただけでは、本質的な改善になっていない。

人間が覚える手順が、

2個から4個になっただけだ。

次にやるべきことは、かなり明確になった。

CI
│
├─ releaseがmainより先なら失敗
│
├─ expand側のmigrationを適用
│
├─ Workerをdeploy
│
└─ contract側のmigrationを適用

少なくとも、

  • migration適用をCI/CDへ組み込む
  • releaseとmainの不整合をCIで止める
  • migrationをexpand / contractに分ける

ところまでは、人間の仕事から外したい。

デプロイの順番の図。現在は未適用migrationの確認、releaseがmainへ反映されているかの確認、migration適用、Workerデプロイを人間が順に行っている。将来はCIがrelease差分を検証し、expand migrationを適用してWorkerをデプロイし、contract migrationを後から適用する

2回本番を壊して分かったこと

振り返ってみれば、どちらも高度な障害ではなかった。

Cloudflare Workersのバグでもない。

D1がおかしくなったわけでもない。

1回目はmigrationを流さなかった。

2回目はreleaseブランチをmainへ戻さなかった。

ものすごく地味だ。

だからこそ怖い。

特殊な条件でしか発生しない難しい障害なら、慎重になる。

しかし、

migrationを流す
releaseをmainへ戻す

という日常作業は、慣れるほど注意を払わなくなる。

そして今回、同じ種類の失敗を13日間で2回やった。

人が覚える手順は、人が忘れる。

次に直すべきなのはコードではなく、デプロイの仕組みそのものだと思っている。

「人が気をつける」から「仕組みで守る」へ

今回の障害を振り返って、改めて読み返したくなったのがGoogleのSRE本。 障害対応だけでなく、変更管理やリリースエンジニアリングなど、「人が頑張って事故を防ぐ」のではなく、信頼性を仕組みとしてどう設計するかを体系的に学べる一冊だ。

SRE サイトリライアビリティエンジニアリング

Googleの信頼性を支える考え方と実践をまとめた定番書。今回のようなデプロイや変更管理の失敗を、個人の注意力ではなくシステム設計の問題として考え直すきっかけになる。

¥5,280 (2026年9月29日 時点の参考価格)

参考資料

この記事は広告を含みます。

関連記事

Gadenin 栽培記録アプリ・無料アプリを入れる