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

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

旧版と新版が同時に動くとき

TT Labで続きを見る

一言でいうと

旧版と新版が同時に動いている間に起きる互換性事故は、エラーを出しません。そのため診断はログではなく、同じバイトを2つのスキーマで読んで突き合わせることから始め、食い違った名前 → その名前の番号 → バイトのそのレコード → 2つの.protoのその番号、の順に下りていきます。修正は常に新版の側です。

なぜ必要なのか

前のモジュールでルールを学びました。番号を変えない、削除した番号はreservedで封印する、型はワイヤー上で互換なもの同士でだけ変える、というものです。ところが現場で人のもとにやって来るのはルール違反ではなく、症状です。「精算の数字が昨日から合わない。バッチは成功で終わった」。例外も警告もないので、ログをいくら探しても出てきません。ベストプラクティスのドキュメントが言うように、クライアントとサーバーは同じ瞬間にはデプロイされず、数か月前にビルドしたバッチが今日も動いています。そのバッチはビルドしたときの.protoを抱えていて、サーバーが何に変わったかを知りません。

どう動くのか

2つのプロセス、1つのバイト。 新版サーバーはデータベースの値をフィールド名で探し、自分の.protoが決めた番号・型で載せます。旧版クライアントはそのバイトを番号で読み、自分の.protoの名前を付けます。名前は両側の.protoにしかなく、ワイヤーにはありません。そのため、同じレスポンスを2つのクライアントで読んで名前ごとに突き合わせれば、食い違った名前がそのまま事故の場所です。このラボのサーバーは、gRPCのメッセージフレーム(圧縮フラグ1バイト、ビッグエンディアンの長さ4バイト、メッセージ)をTCPの上に直接載せます。本物のgRPCは、このフレームをHTTP/2のDATAフレームに載せます(gRPC over HTTP/2)。TCPはメッセージの境界を知らないので、長さの分だけすべて届くまで読むのはクライアントの役目です。

エラーなしで間違える5つのパターン。 ラボのデモで4つ(field-renumberedは前のモジュールで見ました)に、インシデントで5つすべてに出会います。

コード v2でしたこと 旧版に見えるもの
enum-renumbered enumの1に新しい値を挟み、後ろをずらした 名前が1つずつずれる(PAID → SHIPPED)
field-renumbered 同じ型の2つのフィールドの番号を入れ替えた 2つの値が入れ替わる
number-reused 文字列フィールドを削除し、その番号を新しい文字列フィールドに与えた 削除した欄に他人の値が見える
sint-changed int32をsint32に 同じVARINTなのでZigZagされた数字。150が300、-40が79
wire-type-changed int32をfixed32に ワイヤータイプが違うので不明なフィールドとして読み飛ばし、0

4つ目と5つ目の違いが興味深いところです。proto3の言語ガイドは、sint32はsint64とだけ互換で、他の整数型とは互換ではないと書いています。ワイヤータイプが同じなので、パーサーは何も疑わずに値を変えて読みます。fixed32はワイヤータイプそのものがI32なので、このラボのデコーダーはそのレコードを不明なフィールドとして読み飛ばします。実装によってはパースエラーになることもありますが、エラーが出るほうがかえってましなケースです。

誤警報2つ。 2つの版の差分がすべて事故というわけではありません。新版が削除したフィールドは旧版にデフォルト値で見えるのが正しく、enumの末尾に追加した新しい値は旧版に知らない数字で見えるのが正しいのです。proto3のenumは開いていて(open)、知らない番号も値として保持します。言語によって見え方は異なります。テストがこの2つを事故として捕まえると、誰もそのテストを信じなくなります。

バイトから原因へ。 食い違った名前を見つけたら、旧版の.protoでその名前の番号を見ます。スキーマなしでバイトをレコードに展開したダンプでその番号を探すと、ワイヤータイプと生の値が原因を指し示します。次に、その番号で2つの.protoの宣言を突き合わせれば、5つのうちの1つに絞り込めます。新版には安全な変更も混ざっているので、差分を全部戻すのではなく、その1つを直します。

現場での姿

最もよくある構図は「新版同士は正常」です。新版クライアントと新版サーバーは同じ.protoを使うので、どんな変更でも互いには正しく読めます。そのため新版チームの統合テストはすべて緑で、問題は別チームの旧バッチでだけ見えます。旧版を直して合わせたくなりますが、たいていは不可能です。別チームのバイナリであり、モバイルアプリならユーザーの端末にインストールされています。そのため修正は、新版のスキーマを古い番号と互換になるように戻すことです。そして二度と同じことが起きないように、2つの版を一緒に起動して同じバイトを突き合わせるテストをデプロイの前に置きます。静的な契約チェッカーが見逃すもの(enum値の番号のように、ツールが比較しない箇所)も、このテストは捕まえます。

もう1つ。直した.protoを保存しただけでサーバーを再起動せず、「直したのに変わらない」というケースがよくあります。サーバーはスキーマを起動時にコンパイルして保持しています。本物のgRPCサーバーは生成されたコードとしてコンパイルされているので、なおさらです。

次のラボですること

gRPCのメッセージフレームをソケットで直接やり取りする小さなツールから始めます。次に、4つの有害な変更(上の表からfield-renumberedを除いたもの)が混ざったデモv2サーバーの同じレスポンスを旧版・新版の2つのクライアントで読んで食い違った名前を突き止め、ダンプで番号とワイヤータイプを探して原因を付け、新機能を生かしたまま互換になるように直します。その手順をテストスクリプトとして固めた後、3つのサービスが同じ症状を互いに異なる原因で起こすインシデントを診断して直します。