Claude CodeでAvroスキーマの互換性をCIとhookで強制する
.avscの変更をSchema Registryの互換性APIに照会し、非互換ならPRを落とすCIと、Claudeの編集直後に気づかせるhookを組みます。BACKWARDだけでは止まらない変更も押さえます。
Kafkaのトピックに流れるAvroデータは、スキーマを書き換えた瞬間にコンシューマーを壊すことがあります。Claude Codeに.avscを触らせると、エラーを消すために型を変えたりフィールドを足したりする編集が混ざりやすくなります。
防ぎ方は、Schema Registryの互換性APIをCIとhookの両方から呼ぶ形です。CIは「PRを落とす」最終関門、hookは「Claudeが保存した直後に気づかせる」早い関門になります。この記事では、そのスクリプトとワークフローを一式組みます。MCPでスキーマを管理する話はKafka Schema Registry MCPサーバーの記事が扱っていて、ここで作るのは人もClaudeも通る固定の検問です。
BACKWARDだけでは「フィールド削除」を止められない
最初に、何を止められるかを決めます。Confluent Schema Registryの既定の互換性レベルはBACKWARDです。意味は「新しいスキーマを使うコンシューマーが、直前のスキーマで書かれたデータを読める」ことです。
この定義には落とし穴があります。Confluentの説明では、BACKWARDで互換になる変更の例として「フィールドの削除」が挙がっています。新スキーマのコンシューマーは、旧データにあった余計なフィールドを読み捨てるだけで済むからです。反対に、デフォルト値のないフィールドの追加は、旧データに値がないためBACKWARDで落ちます。
同じ変更でも、レベルで判定が変わる
BACKWARD(既定)
デフォルト付きのフィールド追加は通ります。デフォルトなしの追加は落ちます。フィールドの削除は通ります。
FULL
デフォルト付きのフィールドなら、追加も削除も通ります。デフォルトのない項目を消すと、FORWARDの条件で落ちます。
つまり「BACKWARD違反でPRを落とす」だけでは、emailを黙って消す編集は素通りします。古いスキーマで動いているコンシューマーが新しいデータを読む方向(FORWARD)は、BACKWARDでは見ていないためです。FORWARDの説明にも、デフォルトを持たないフィールドを消したスキーマは、元のスキーマと前方互換にならない例が載っています。
どこまで止めたいかで、サブジェクトのレベルを選びます。
| 守りたいこと | レベルの目安 | アップグレード順 |
|---|---|---|
| 古いトピックを頭から読み直せる | レベルの目安BACKWARD(既定) | アップグレード順コンシューマーを先に |
| 追加も削除も両方向で安全 | レベルの目安FULL | アップグレード順順序を問わない |
| 3世代以上前のデータも読む | レベルの目安_TRANSITIVE付き | アップグレード順レベルによる |
アップグレード順は、ConfluentがレベルごとにBACKWARDならコンシューマー先、FULLなら独立してよいと説明しています。レベルを変える操作は、既存のプロデューサー・コンシューマーの運用に響きます。CIの記事の範囲を超えるので、変更前にチームで合意してから行います。
互換性APIは「登録せずに判定だけ」できる
Schema Registryには、登録せずに互換性だけを試すエンドポイントがあります。CIで使うのは次の2つです。
POST /compatibility/subjects/{subject}/versions/latest: 最新の1世代だけと比べるPOST /compatibility/subjects/{subject}/versions: サブジェクトに設定されたレベルで判定する。登録時と同じチェックを行う
後者は、BACKWARD・FORWARD・FULLなら最新の1世代、_TRANSITIVE付きなら過去すべてと比べます。レベルの設定に追従するので、CIには後者を選びます。どちらも適用されるのはサブジェクトのレベルで、未設定ならグローバルのレベルが使われます。
リクエストのボディは{"schema": "<スキーマ文字列>"}です。.avscはJSONですが、APIには文字列としてエスケープして渡す必要があります。jq -Rsでファイルを丸ごと文字列にして包めば、手でエスケープしなくて済みます。
jq -Rs '{schema: .}' schemas/user-value.avsc | curl -s \
-X POST "$SCHEMA_REGISTRY_URL/compatibility/subjects/user-value/versions?verbose=true" \
-H 'Content-Type: application/vnd.schemaregistry.v1+json' \
--data @-返ってくるのは{"is_compatible": true}かfalseです。?verbose=trueを付けると、不合格のときに理由の文字列がmessages配列に入ります。この理由をそのままPRのログとClaudeへのフィードバックに使えるので、付けておく価値があります。
エラー側の形式も押さえます。サブジェクトが存在しないときは404でエラーコード40401、スキーマが不正なときは422で42201が返ります。Schema Registryはエラー本文にerror_codeを入れるので、成功か失敗かは本文のその有無でも判定できます。
チェックスクリプト: 新規サブジェクトを通し、ほかは落とす
APIを呼ぶだけのスクリプトでも、扱いを決める分岐が3つあります。
is_compatibleがtrueなら合格falseなら、messagesを表示して不合格- 404(サブジェクトが未登録)なら、初回登録なので合格として扱う
3つ目を落とすと、新しいトピック用のスキーマを足すPRが毎回赤くなります。404はURLの誤りでも返りうるので、本文のerror_codeが40401のときだけ通します。それ以外の想定外の応答は、静かに通さず不合格にします。
ファイル名とサブジェクト名の対応は、リポジトリ側の約束です。ここではschemas/user-value.avscというファイル名がそのままサブジェクト名user-valueになる規約にします。
#!/usr/bin/env bash
set -uo pipefail
: "${SCHEMA_REGISTRY_URL:?SCHEMA_REGISTRY_URL を設定してください}"
status=0
for f in "$@"; do
subject="$(basename "$f" .avsc)"
body="$(mktemp)"
code="$(jq -Rs '{schema: .}' "$f" | curl -s -o "$body" -w '%{http_code}' \
-X POST \
"$SCHEMA_REGISTRY_URL/compatibility/subjects/$subject/versions?verbose=true" \
-H 'Content-Type: application/vnd.schemaregistry.v1+json' \
--data @-)"
if [ "$code" = "200" ] && [ "$(jq -r '.is_compatible' "$body")" = "true" ]; then
echo "OK $subject"
elif [ "$code" = "200" ]; then
echo "NG $subject: 互換性違反"
jq -r '.messages[]?' "$body" | sed 's/^/ /'
status=1
elif [ "$code" = "404" ] && [ "$(jq -r '.error_code' "$body")" = "40401" ]; then
echo "NEW $subject: 未登録のサブジェクトなので初回登録として扱います"
else
echo "ERR $subject: HTTP $code"
cat "$body"; echo
status=1
fi
rm -f "$body"
done
exit "$status"終了コードが1なら、CIのジョブは赤になります。ERRの枝が「分からないものは落とす」側に倒してある点が要です。レジストリに届かない、認証が通らないといった障害まで合格にすると、検問が実質無効になります。
GitHub Actionsで必須チェックにする
CIでは、PRで変わった.avscだけを対象にします。ベースブランチとの差分を取るため、チェックアウトのfetch-depthを0にします。
name: Avro Compatibility
on:
pull_request:
paths:
- "schemas/**/*.avsc"
jobs:
compat:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: 互換性を判定する
env:
SCHEMA_REGISTRY_URL: ${{ secrets.SCHEMA_REGISTRY_URL }}
BASE_REF: ${{ github.base_ref }}
run: |
files=$(git diff --name-only --diff-filter=AM \
"origin/${BASE_REF}...HEAD" -- 'schemas/*.avsc')
if [ -z "$files" ]; then echo "対象なし"; exit 0; fi
./scripts/check-avro-compat.sh $files--diff-filter=AMは追加と変更だけを拾います。ファイルの削除はこのチェックでは検出されません。サブジェクトごと消す変更は、別の経路(レビューや削除専用のルール)で止める前提です。
3点に注意が要ります。
- レジストリへの到達性: GitHubのホステッドランナーから、社内ネットワークのレジストリには届きません。その場合はセルフホストランナーを使うか、ステージング用に公開されたエンドポイントを用意する形になります
- シークレット: URLや認証情報はGitHub Secretsに置き、ワークフローには直接書きません。公開リポジトリでは、フォークからのPRに対してGitHubがシークレットを渡さないため、このチェックは同じリポジトリ内のブランチからのPRで動きます
- 必須チェックへの登録: ワークフローを置いただけでは、赤でもマージできます。ブランチ保護で
compatジョブを必須にします。チェック名のずれの探し方はブランチ保護をgh apiで棚卸しする手順にあります
on.pull_request.pathsで絞っているため、.avscを含まないPRではジョブ自体が起動せず、必須チェックが「待機中のまま」になる場合があります。必須化するなら、パスで絞らずジョブ内で対象なしなら成功終了する形のほうが事故が少なくなります。上のスクリプトは「対象なし」でexit 0するので、pathsの行を外せば成立します。
hookで、Claudeが保存した直後に気づかせる
CIは強い関門ですが、PRを作ってからでないと失敗が分かりません。Claude Codeには、ツール実行の前後で任意のスクリプトを走らせるhookがあります。.avscを書いた直後に同じ判定を流せば、Claudeがその場で自分の編集の非互換に気づきます。
使うイベントはPostToolUseです。ツールの実行後に走るので編集そのものは取り消せませんが、終了コード2で終わらせるとstderrの内容がClaudeに見えます。「Claudeは終了コード0のhookのstderrを見ない」ため、非互換のときだけ2で返すのがこの設計の要点です。
.claude/settings.jsonに次を足します。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/avro-compat.sh"
}
]
}
]
}
}hookのスクリプトは、標準入力のJSONから編集されたファイルのパスを取り、.avsc以外なら何もせず終わります。
#!/usr/bin/env bash
file="$(jq -r '.tool_input.file_path // empty')"
case "$file" in *.avsc) ;; *) exit 0 ;; esac
# 手元に開発用レジストリが無い環境では何もしない
[ -n "${SCHEMA_REGISTRY_URL:-}" ] || exit 0
"$CLAUDE_PROJECT_DIR"/scripts/check-avro-compat.sh "$file" >&2 || exit 2chmod +xで実行権限を付けます。SCHEMA_REGISTRY_URLが無いときは黙って通すのは、レジストリの無い手元で作業を止めないためです。そのぶんCIが最終関門として残ります。
向ける先は、本番ではなく開発・ステージングのレジストリにします。互換性APIは登録を行わない読み取りの呼び出しですが、本番の認証情報をClaudeの作業環境に置かない運用のほうが筋が良い構成です。
なおhookは、決定的に止める道具としては限界があります。ifによるBashコマンドの絞り込みは「ベストエフォート」と説明されており、確実に許可・拒否したいなら権限設定を使う、と案内されています。今回のhookは「気づかせる」役、CIは「止める」役と割り切るのが安全です。
CLAUDE.mdに「直し方の方針」を書く
判定が不合格だったとき、Claudeが次に何をするかも決めておきます。何も書かないと、テストを通すためにサブジェクトの互換性レベルをNONEに下げるといった、本末転倒の直し方がありえます。次の規約をCLAUDE.mdに置きます。
## Avroスキーマ(schemas/*.avsc)
- フィールドを足すときは必ず default を付ける。任意項目は ["null", "string"] のように
null を先頭に置き、"default": null とする
- 既存フィールドの型は変えない。変えたい場合は新しいフィールドを足す
- フィールドは削除しない。やむを得ない場合は、先に default を付けた版を出す
- check-avro-compat.sh が NG を返したら、スキーマ側を直す。
互換性レベルの変更とレジストリへの直接登録は行わないnullを先頭に置く理由は、Avroではデフォルト値が共用体の最初の型に一致する必要があるためです。Confluentの説明でも、"null"を先頭にして"default": nullとする書き方が一般的とされています。
落ちたPRをClaudeに直させる
GitHub Actionsには、PRコメントの@claudeで起動する対話モードがあります。prompt入力を持たないワークフローは、トリガーとなる語を待って動きます。CIが赤になったら、人が「@claude schemas/user-value.avscのNGを、CLAUDE.mdの方針で直して」とコメントする流れです。
ここで知っておく制約があります。Claude Code GitHub Actionは、ボットが起こしたイベントを既定で拒否します。CIがコメントを自動投稿して@claudeを呼ぶ構成は、allowed_botsにそのボットを載せない限り動きません。ループを防ぐ仕組みなので、最初は人が@claudeを打つ形が安全です。実装の骨格と@claudeワークフローの作り方は、同じ型のgraphql-inspectorでスキーマの破壊的変更を止める記事に載せています。
つまずきやすい点
新規サブジェクトで毎回赤くなる: 404を合格にしていないのが原因です。スクリプトのNEWの枝を確認します。
レベルがNONEのまま: サブジェクトの互換性がNONEだと、APIは常に互換と答えます。CIが緑でも何も守れていません。GET /config/{subject}で、サブジェクトのレベルを確認します。?defaultToGlobal=trueを付けると、実際に効いているレベルが分かります。
直前の世代しか見ていない: BACKWARD・FORWARD・FULLは直前の1世代とだけ比べます。1世代ずつなら通る変更が、3世代前とは噛み合わない事態がありえます。古いデータを読み続けるトピックには_TRANSITIVE付きのレベルを検討します。
スキーマの書式だけが違う: 空白や項目の順序の違いで同じ内容と扱われるかは、正規化の扱い次第です。APIには?normalize=trueがあり、既定はfalseなので、比較の揺れが気になる場合に使います。
まとめ
Avroの変更を止める検問は、BACKWARDなのかFULLなのかを先に決めるところで性格が変わります。既定のBACKWARDは「新しい読み手が古いデータを読める」ことを守りますが、フィールド削除は通します。両方向を守りたいトピックは、FULLにして初めてemailの黙った削除が赤になります。
スクリプトは一本、呼ぶ場所は二か所です。hookはClaudeの編集直後に、CIはPRの最終関門に置き、CLAUDE.mdが直し方の方向を決めます。MCPでスキーマを探索・登録する使い方と組み合わせる場合も、登録の最後はこの検問を通る形にしておくと、人とClaudeのどちらが書いても同じ基準で止まります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。