TT Lab
ブログ はじめる
学ぶ 学習パス コース

フィールド番号を変えたら、古いクライアントが黙って間違った値を読んだ

旧版と新版が同時に動く — 黙って誤った値をバイトで追う

TT Labで続きを見る

目標

新版(v2)サーバー1台が送る同じバイトを、旧版(v1)と新版のクライアント2つのプロセスがそれぞれの.protoで読むようにし、食い違った名前 → バイトのフィールド番号 → 2つの.protoの差分、の順に原因を絞り込みます。その手順をテストスクリプトとして固めた後、3つのサービスが同じ症状を互いに異なる原因で起こすインシデントに対応します。

なぜ重要なのか

前のラボでは、番号を変えると旧クライアントが誤って読むというルールを学びました。現場ではルールではなく症状から出発します。精算の数字が合わないのに、エラーログは1行もありません。このとき2つの版を並べて起動し、同じレスポンスを2つの目で読んでみると、食い違った名前がすぐに見えます。その名前が旧版にとって何番なのかがわかれば、バイトダンプからそのレコード1つを特定できます。名前はそれぞれの.protoにしかなく、バイトには番号しかないという事実をツールで確かめる過程が、そのまま診断手順になります。そして修正は常に新版の側です。旧版はすでにデプロイされていて、再ビルドできない場合がほとんどだからです。

ステップ

  1. デモのv1サーバーを7100で起動し(python3 /opt/fixtures/grpcc/gcsvc.py --proto /opt/fixtures/grpcc/demo/v1.proto --message Order --data /opt/fixtures/grpcc/demo/data.json --port 7100 &)、標準ライブラリのsocket・structだけで/root/grpc/skew/tap.py <port> <id> <out-file>を書いてください。GetRequest(int32 id = 1)をgRPCのメッセージフレームに入れて送り、レスポンスのフレーム(先頭5バイトを含む)を丸ごと保存します。id 2のレスポンスを/root/grpc/skew/01-resp.binに保存してください。
  2. デモのv2サーバーを7101で起動し(--proto /opt/fixtures/grpcc/demo/v2.proto)、同じid 2を旧版(/opt/fixtures/grpcc/gcclient.py --proto /opt/fixtures/grpcc/demo/v1.proto ...)と新版(--proto /opt/fixtures/grpcc/demo/v2.proto)で読んでください。2つの版が異なって読んだ名前ごとに、<name> old=<old-value> new=<new-value>の1行を/root/grpc/skew/02-diff.txtに書きます(新版にその名前がなければnew=(none))。
  3. gcclient.py --dump demo --port 7101 --id 2でバイトをレコード行に展開し、ステップ2の名前ごとに<name> field=<number-in-v1> wire=<wire-type-on-the-wire> cause=<code>を/root/grpc/skew/03-cause.txtに書いてください。コードはenum-renumbered field-renumbered number-reused sint-changed wire-type-changedのいずれかです。
  4. デモv2の新機能(PENDING・coupon・currency)は生かしつつ、旧版が正しく読めるように直したスキーマを/root/grpc/skew/04-v2.protoとして書いてください。そのスキーマでサーバーをもう1台起動し、2つのクライアントで確認します。
  5. /root/grpc/skew/skewtest.py <port> <old.proto> <new.proto> <message> <id,id,...>を書いてください。同じレスポンスを2つのスキーマで読み、食い違った名前ごとにMISMATCH id=<id> <name> old=<value> new=<value>を出力してexit 1、なければ最後の行にOK・exit 0です。新版が削除した名前は、旧版がデフォルト値を読めば正常です。旧版が知らない新しいenum値が数字で見えるのも正常です。
  6. incident gc-silent-skewでインシデントを発生させ、チケットを読んでください。直す前にサービスごとに原因を突き止め、/root/incident/gc-silent-skew/report.txtに<service>: <code>の3行と、サービスごとにその原因を示すgc-tapの出力1行をevidence: <tap-line>として書いてください(3行)。
  7. 3つのサービスを直してください。精算バッチ(旧版)がすべての記録を正しく読み、新版のクライアントもv2の欄をそのまま読めなければなりません。直す場所は/srv/gc/<service>/v2.protoで、直した後はgc-svc restart <service>で再起動します。

参考

gRPCのメッセージフレームを手でやり取りする

/root/grpc/skew/tap.py <port> <id> <out-file>をsocket・structだけで書き、7100で起動したデモv1サーバーから、id 2のレスポンスフレーム(先頭5バイトを含む)を/root/grpc/skew/01-resp.binに保存してください。

方向: リクエストもレスポンスも「圧縮フラグ1バイト+長さ4バイト(ビッグエンディアン)+メッセージ」のフレームに入ります。リクエストメッセージはGetRequest 1つで、フィールド1にvarintでidを載せます。

ツール: struct.pack(">I", n)・struct.unpack、そしてsock.recv()を必要な長さが揃うまで繰り返す小さな関数。サーバーはレスポンスをわざと分割して送るので、recv()1回では先頭5バイトしか返ってきません。

具体: 先頭5バイトをすべて受け取った後、その中の長さの分だけ本体を受け取り、2つをつなげてファイルに書きます。存在しないidに対してサーバーは長さ0のフレームを送るので、長さが0なら本体を待たないようにしてください。skewlib・gcclientをインポートして使うと、このステップは不合格になります。

同じバイト、2つの世界観

デモv2サーバー(7101)のid 2のレスポンスを旧版(v1.proto)と新版(v2.proto)のクライアントで読み、異なって読んだ名前ごとに<name> old=<old-value> new=<new-value>を/root/grpc/skew/02-diff.txtに書いてください。

方向: サーバーは1つ、バイトも1つです。違うのはクライアントが抱えている.protoだけで、どちらもエラーを出しません。

ツール: python3 /opt/fixtures/grpcc/gcclient.py --proto <v1-or-v2.proto> --message Order --port 7101 --id 2を2回実行し、出力をファイルに受けて名前ごとに突き合わせてください。

具体: 新版にない名前はnew=(none)ですが、そのとき旧版がデフォルト値(0・空文字列)を読んだのなら、食い違いではありません。削除したフィールドは、旧版にデフォルト値で見えるのが正しいのです。値が同じ名前は書きません。

食い違った名前からバイトの番号へ

gcclient.py --dump demo --port 7101 --id 2のレコード行と2つの.protoを見て、ステップ2の名前ごとに<name> field=<number> wire=<wire> cause=<code>を/root/grpc/skew/03-cause.txtに書いてください。

方向: バイトには名前がありません。「旧版がその名前に付けた番号」をv1.protoで探し、ダンプでその番号のレコードが実際にどのワイヤータイプで載っているかを見て、最後にその番号でv1とv2の宣言がどう違うかを見ます。

ツール: ダンプ行のfield=・wire=・raw=、2つの.protoの同じ番号の行、enumブロックの値の番号。

具体: 古い名前がv2に別の番号で残っていれば(番号だけが移されたなら)field-renumbered、古い名前は消えてその番号を別の名前が使っていればnumber-reused、同じ名前なのにenum値の番号がずれていればenum-renumbered、ワイヤータイプそのものが違えばwire-type-changed、同じVARINTなのに数字が2倍ほどならsint-changedを疑います(150のZigZagは300)。

新機能は生かし、旧版も正しく

デモv2のPENDING・coupon・currencyは生かしつつ、旧版が正しく読めるように直したスキーマを/root/grpc/skew/04-v2.protoとして書いてください。

方向: 旧版が知っている番号の意味(名前・ワイヤー上で互換な型・enum値の番号)は1つも変えず、新しいものは新しい番号に置きます。v1に丸ごと戻すと、新版の欄が消えて不合格になります。

ツール: ステップ3の表。原因ごとに戻すべき場所が1つずつあります。直したファイルでサーバーをもう1台起動し、gcclient.pyを2回実行して確認してください。

具体: enumの新しい値は末尾の番号に置き、別の名前が使っていた古い番号からは新しいフィールドを外して新しい番号に移し、その古い番号はreservedで封印します。sint32とint32、fixed32とint32は、ワイヤー上で互換ではありません。

2つの目で読むテストを作る

/root/grpc/skew/skewtest.py <port> <old.proto> <new.proto> <message> <id,id,...>を書いてください。食い違った名前ごとにMISMATCH id=<id> <name> old=<value> new=<value>を出力してexit 1、なければ最後の行にOKを出力してexit 0です。

方向: サーバーに1回問い合わせ、そのバイト1つを2つのスキーマで読んで、名前ごとに突き合わせます。エラーの出ない互換性事故を捕まえる唯一の方法がこれです。

ツール: /opt/fixtures/grpcc/skewlib.pyのcall・load・decode_view・default_of・show(sys.path.insert(0, "/opt/fixtures/grpcc"))。

具体: 誤警報を2つ避けなければなりません。新版にない名前は、旧版の値がその型のデフォルト値なら正常です。旧版が知らない新しいenum値(新版の名前が古いenumにない)が数字で見えるのも正常です。採点ツールは、初めて見る有害な組5つ(原因ごとに1つ)と互換な組1つを起動し、存在しないid(空のレスポンス)も混ぜて問い合わせます。2つの.protoを比べるだけだったり、最初のidしか見なかったりすると不合格になります。

インシデント: 3つのサービス、同じ症状、違う原因

incident gc-silent-skewを実行してください。新版のデプロイ後、精算バッチ(旧版)がorders・ledger・stockの3つのサービスで、エラーなしに見当違いの値を読んでいます。直す前にサービスごとに原因を突き止め、/root/incident/gc-silent-skew/report.txtに<service>: <code>の3行を書いてください。コードはenum-renumbered・field-renumbered・number-reused・sint-changed・wire-type-changedのいずれかです。そして、サービスごとに原因を示すgc-tap <service>の出力1行を、evidence: <tap-line>として貼ってください(サービスごとに正確に1行)。間違ったコードで3回採点するとインシデントがロックされ、出し直す必要があります(原因が新しく決まります)。

方向: 症状は3つとも同じで、原因はサービスごとに違います。各v2には安全な変更(新しいフィールド・enum末尾の新しい値・deprecated・宣言の順序)も混ざっているので、差分を全部見るのではなく、旧版が誤って読んだその番号の差分1つを選び出さなければなりません。

ツール: gc-old・gc-new(またはステップ5のskewtest)で食い違った名前を探し、v1.protoでその名前の番号を、gc-tapでその番号のレコードを見ます。

具体: 証拠の行は、直す前のgc-tapの出力を手を加えずに丸ごと貼ります(<service> id=… field=… wire=… raw=… bytes=…)。原因となったフィールド番号の行でなければなりません。

インシデント: 旧版はそのまま、新版を互換に

3つのサービスを直してください。精算バッチ(旧版、/srv/gc/<service>/v1.proto)は再デプロイできず、新版を丸ごと戻すこともできません。/srv/gc/<service>/v2.protoを直してgc-svc restart <service>で再起動し、旧版がすべての記録を正しく読み、新版のクライアントもv2の欄をそのまま読めるようにしてください。削除した古いフィールドの番号はreservedで封印します。

方向: 修正とは、ステップ6で選んだその差分1つを戻すことです。安全な変更まで戻すと、新版が壊れます。

ツール: gc-svc restart <service>、gc-old・gc-new、ステップ5のskewtestを3つのサービスに。

具体: 古い名前は古い番号・古い型・古いenum番号に戻し、古い番号を占めた新しいフィールドは空いている番号に移して、その古い番号はreservedにします。v1.protoとdata.jsonには手を触れません。