evolving-apis-and-schemas について
サービス、クライアント、永続化データにまたがる API やスキーマ変更を扱う開発者向けのスキルです。段階的で元に戻せるリリースにより、デプロイ中も旧読者と新読者の互換性を保ちます。
主な機能
- 01スキーマ利用者の特定
- 02変更と互換性リスクの分類
- 03拡張・移行・縮小の段階設計
- 04Protobuf、SQL、HTTP、JSONのガイド
- 05復元と検証の計画
活用シーン
- 01サービス間でのDB列名変更
- 02呼び出し元を壊さない必須フィールド追加
- 03大規模データのバックフィル計画
- 04Protobuf変更の互換性レビュー
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ osmontero/ opencode-skills --skill evolving-apis-and-schemasインストール後、Claude Code で/evolving-apis-and-schemasを入力して読み込まれたか確認します。
同梱ファイルreferences/migration-recipes.md
詳細
API とスキーマの進化
概要
スキーマ変更は、1 行の差分を装った分散システムの問題です。データを読み取るコードは、これからデプロイしようとしているコードだけではありません。ロールアウト中に稼働し続けるバージョン、制御できないクライアント、そして昨年のバイナリが書き込んだレコードも含まれます。
基本原則: ロールアウトのあらゆる時点で、古いリーダーと新しいリーダーの両方が正しく動作しなければなりません。そうでない瞬間があるなら、必要なのは自信ではなく、変更の手順を増やすことです。
関連スキル: 複数リリースにまたがる変更の順序付けには writing-plans を、破壊的な処理を実行する前には verifying-before-completion を使用してください。
鉄則
ADDITIVE FIRST, DESTRUCTIVE LAST — NEVER IN THE SAME DEPLOY
追加は元に戻せます。削除、名前変更、型の範囲を狭める変更は元に戻せません。その間に、両方の形が動作するリリースを完全にデプロイし、そこをまたいでロールバックしないと確信できるだけの時間を置く必要があります。
例外はありません:
- 「古いカラムは確実に使われていない」場合でも、grep ではなくクエリで証明してください
- 「すべてをまとめてデプロイする」場合でも、ロールアウトもロールバックもアトミックではありません
- 「小さなテーブル」でも例外ではありません
- 名前変更も例外ではありません。名前変更は削除と追加であり、障害を引き起こす最も一般的な原因です
ステップ 1:利用者を特定する
変更を分類する前に、このスキーマを読み取る利用者を列挙してください。その答えによって、拡張/縮小が任意か必須かが決まります。
| 利用者 | プロデューサーとアトミックに再デプロイできますか? | 影響 |
|---|---|---|
| 同じバイナリ、同じ構造体 | はい | 通常のリファクタリングです。コンパイラーが検出します |
| 自分たちが所有する別のサービス | いいえ — ローリングデプロイでは両方のバージョンが同時に稼働します | 拡張/縮小が必須です |
| 制御できないクライアント(SDK、モバイルアプリ、サードパーティー) | いいえ、常に不可能です | バージョンを分けてください。古い形式は恒久的に残ります |
| 永続化データ | いいえ — レコードを再デプロイすることはできません | 古い形式のレコードは、すべてのデプロイ後も残ります |
| キューまたはイベントログ | いいえ — 処理中のメッセージは古いコードによって書き込まれています | 両方の形式を解析できる必要があります |
| レプリカまたはクラスター内のピア | いいえ — アップグレード中はバージョンが混在します | 双方向の相互運用が必要です |
最も忘れられがちな行は永続化データです。 ディスク上の値はすべて、もう実行していないコードによって書き込まれています。新しいコードがそれを読み取れない場合、古いレコードでのみ発生するデータ破損バグをリリースしたことになります。
ステップ 2:変更を分類する
| 分類 | 例 | 必要な対応 |
|---|---|---|
| 安全 | オプションのフィールドを追加する、新しいエンドポイント・テーブル・RPC を追加する、インデックスを追加する、型の範囲を広げる | デプロイします |
| 拡張/縮小 | 必須フィールドの追加、名前変更、型変更、分割または統合、あらゆるものの削除 | 以下の 3 段階の手順に従います |
| 破壊的 — バージョン管理が必須 | 既存の名前の意味を変える、公開済み API から削除する、受け入れる入力の範囲を狭める | 新しいバージョンと、非推奨化の猶予期間が必要です |
2 行目と 3 行目の違いは、拡張/縮小が有効なのは、最終的にすべてのリーダーを更新できる場合です。更新できない場合 — リリース済みのモバイルアプリや公開 API など — は、サポート期間が定める限り、古い形式も引き続き動作させなければなりません。
ステップ 3:拡張/縮小の手順
順番に 3 回デプロイします。それぞれ独立してリリースでき、個別に元へ戻せます。
1. EXPAND Add the new shape. Nothing reads it yet.
Writers write BOTH old and new. ← revertible
─── deploy, verify, let it soak ───
2. MIGRATE Backfill existing data into the new shape.
Switch readers to the new shape.
Writers still write both. ← revertible
─── deploy, verify, let it soak ───
3. CONTRACT Stop writing the old shape. Drop it. ← the point of no return
各フェーズの間に設けるのは、時間だけでなく確認ゲートです: 縮小に進む前に、古い形式への書き込みも読み取りもないことをクエリで確認してください。アプリケーションログ、pg_stat_statements、またはフェーズ 1 で追加した明示的なカウンターを使います。「2 週間経った」は証拠になりません。
バックフィルはマイグレーションではありません。 スキーマ変更(DDL)はマイグレーションに含め、データの移動(DML)は再開可能なバッチジョブとして分けてください。1,000 万行に対する単一の UPDATE はロックを保持し、WAL を膨張させ、タイムアウト後に再開できません。
よくあるケースの実践的な手順 — NOT NULL カラムの追加、名前変更、型変更、テーブルの分割、SQLite の再構築手順 — については、references/migration-recipes.md を参照してください。
Protobuf と gRPC
フィールドの番号がワイヤ上の識別子です。名前ではありません。
| 変更 | 判定 |
|---|---|
| 新しい番号でフィールドを追加する | 安全 |
| RPC、メッセージ、または列挙値を追加する | 不明な値を許容するリーダーに対しては安全 |
| フィールドを削除する | 番号と名前の両方に reserved を指定した場合に限り安全 |
| 削除したフィールド番号を再利用する | 絶対にしないでください。 古いデータが誤ったフィールドとして、気づかれないままデコードされます |
| フィールドの名前を変更する | ワイヤ上は安全ですが、JSON/テキストエンコーディングと、生成済みのすべてのクライアントに影響します |
| フィールドの型を変更する | 互換性のある型のグループ内に限ります。int32↔int64 は可、int32↔sint32 は不可です |
singular ↔ repeated | 破壊的変更として扱ってください。ワイヤ形式が維持される場合でも、意味が変わります |
既存のフィールドに optional を追加する | ワイヤ上は安全です。存在の有無を表せるようになりますが、ワイヤ上では何も変わりません |
| 列挙値の番号を変更する | 破壊的変更 |
| メッセージ名またはパッケージ名を変更する | 破壊的変更です。サービスパスと Any 型 URL の一部だからです |
| RPC を削除または名前変更する | 破壊的変更 |
// Deleting a field: reserve BOTH, forever
message Document {
reserved 4, 7 to 9;
reserved "legacy_owner", "old_status";
}
buf breaking --against '.git#branch=main' # wire this into review, not just CI
列挙型のゼロ値は、値が設定されていない場合のデフォルトです。ゼロ値は必ず UNSPECIFIED にし、意味のある状態にはしないでください。そうしないと、「未設定」と「最初の選択肢に設定済み」を区別できません。
SQL スキーマの変更
危険の原因は、正しさであることはほとんどありません。問題はロック時間です。短時間の排他ロックが必要なステートメントでも、長時間実行中の読み取りの後ろに並び、ほかのすべての処理もその後ろに並ぶことがあります。
SET lock_timeout = '3s'; -- fail fast instead of blocking the whole table
PostgreSQL:
| 操作 | コスト | 安全な方法 |
|---|---|---|
NULL 許容、または定数のデフォルト値付きの ADD COLUMN | 短時間のロック。再書き込みなし(PG 11+) | そのままリリースします |
揮発性のデフォルト値付きの ADD COLUMN | 全体の再書き込み | NULL 許容で追加し、複数のバッチに分けてバックフィルしてから、デフォルト値を設定します |
SET NOT NULL | 排他ロック中に全件スキャン | ADD CONSTRAINT ... CHECK (c IS NOT NULL) NOT VALID → VALIDATE CONSTRAINT → その後の SET NOT NULL は低コストです |
CREATE INDEX | 作成中は書き込みをブロック | CREATE INDEX CONCURRENTLY を使います。トランザクション外で実行し、失敗した場合は INVALID インデックスが残っていないか確認します |
ADD FOREIGN KEY | 検証のため両方のテーブルをロック | NOT VALID を指定し、その後 VALIDATE CONSTRAINT を実行します |
ALTER COLUMN TYPE | 全体の再書き込み、排他 | 新しい列を使った拡張/縮小方式にします |
DROP COLUMN | 高速ですが、取り消せません | 縮小フェーズでのみ実行します |
SQLite がサポートするのは ADD COLUMN、RENAME COLUMN、RENAME TABLE、DROP COLUMN のみです。また、ADD COLUMN では、デフォルト値なしの NOT NULL、PRIMARY KEY、UNIQUE は指定できません。それ以外はすべて、作成・コピー・削除・名前変更による再構築が必要です。正しい順序についてはレシピのリファレンスを参照してください。順序は重要です。外部キーを有効にしたまま誤った手順で実行すると、行が気づかれないまま削除されます。
HTTP と JSON API
- オプションのレスポンスフィールドの追加が安全なのは、コンシューマーが未知のフィールドを無視する場合に限ります。
additionalProperties: falseを使って検証するクライアントは、フィールドの追加によって動作しなくなります。決めつける前に確認してください。 - 必須のリクエストフィールドを追加すると、既存のすべての呼び出し元で問題が発生します。デフォルト値を設定して、オプションとして追加してください。
- 名前の再利用は絶対にしないでください。
status_v2よりも、新しい意味で使われるstatusのほうが悪質です。何もはっきりと失敗しないためです。 - エラーコードとページネーションの形式は、スキーマに含まれていなくても契約の一部です。
- API 全体ではなく、リソース単位でバージョン管理してください。グローバルな
/v2を導入すると、変更されていないエンドポイントまで書き換えることになります。
可逆性
すべてのマイグレーションで、次の問いに答えられるようにしてください。午前3時に、これをどう元に戻すのか?
- 追加的なマイグレーション:ダウンマイグレーションは削除です。記述してください。
- 破壊的なマイグレーション:ダウンマイグレーションはありません。そのことを、実際に必要となる復旧方法(バックアップからの復元、イベントログからの再生、別のテーブルからの再導出)とともに、マイグレーションファイルに明記してください。
- データを気づかれないまま失うダウンマイグレーションは、絶対に記述しないでください。削除を「元に戻す」ために空の列を再作成するマイグレーションは、処理を拒否するマイグレーションよりも悪質です。
- 空の開発データベースではなく、本番データのコピーに対してダウンマイグレーションの経路をテストしてください。
検証
buf breaking --against '.git#branch=main' # proto
migrate up && migrate down 1 && migrate up # round-trip
psql -c "EXPLAIN (ANALYZE, BUFFERS) <the query the new index serves>"
- 本番環境の復元コピーに対して、所要時間を計測しながらマイグレーションを実行します。開発環境の200行のデータでは40msで終わるマイグレーションでも、実際のテーブルでは40分かかることがあります。
- フェーズ2では、両方の形式から読み取って比較します。不一致があればログに記録し、新しい形式を信頼する前に、実際のトラフィックのもとで動作させます。
- 新しいスキーマに対して、古いバイナリのテストも引き続き成功することを確認します。これがロールバックの手段ですが、誰もテストしないものです。
危険信号
ADDとDROPの両方を含むマイグレーションファイル- デプロイ可能なバイナリが複数あるコードベースでの
ALTER TABLE ... RENAME WHERE句もバッチ処理もない単一のUPDATE- クエリではなくgrepを根拠にした「古いフィールドは使われていない」という主張
- 誰かが書き込みを行っているテーブルに対する、
CONCURRENTLYを付けないCREATE INDEX - 再利用されたprotoフィールド番号
- 一度も実行したことのないダウンマイグレーション
- スキーマ変更と、それに依存するコードを同じリリースでデプロイすること
これらはすべて、デプロイをさらに分割すべきというサインです。
よくある間違い
**その場で名前を変更する。**コンパイラーが知らせてくれるのは自分のコードのことだけで、まだ稼働中の別のバイナリについては何もわかりません。
**デプロイがアトミックだと考える。**ローリングデプロイ中は両方のバージョンがトラフィックを処理します。多くの場合、何分も続き、ロールアウトが停滞すればいつまでも続くこともあります。
**古い行を忘れる。**新しいコードは、これまでに書き込まれたすべての形式を解析できなければなりません。すべての形式を書き換えたと確実に証明できるまでは、その状態が続きます。
**スキーママイグレーション内でバックフィルする。**その間、ロックが保持され、処理を再開できません。
**ORMの自動マイグレーションを信頼する。**ほかに読み取る側がいることを把握していないため、破壊的な形式を生成します。
**十分な稼働確認を省略する。**フェーズ3は取り消せません。フェーズ2を十分な期間実行し、確信が持てる状態にしておくことだけが、あなたを守ります。