プロトコルの進化と互換性

このページの目次

プロトコルがデプロイされた後、クライアント、サーバー、プロキシ、キャッシュ、履歴データ、オフラインタスクは同時にアップグレードされません。互換性設計とは、単にヘッダーにバージョン番号を追加するだけでなく、パース処理を「できるだけ受け入れる」ように要求するのではなく、明確な境界内でこれらの異なるバージョンが共存できるようにすることです。

次元維持すべき関係失敗の例
バイナリ形式フィールドを特定し、正しくパースできること新しいフィールドの長さが旧パース処理を破壊する
データセマンティクス単位、デフォルト値、意味が一致していること同じ数値が秒からミリ秒へ意味を変える
動作状態機械、エラー、リトライのセマンティクスが一致していること新しいサービスが、旧クライアントが処理できない中間状態を許可する
セキュリティ認証、権限、完全性への要件が緩和されないことフォールバック時に認証制約を密かに省略する
運用混合バージョン、ストレージ、ロールバックが利用可能であること新しいデータがディスクに書き込まれ、旧プログラムが再読み込みできない

データの生成元と読み取り元を明確にする

互換性:読み書きの方向を決め、意味が保たれるか確認する 旧書き込み側 → 新読み取り側 新実装が履歴データを読み取れること:一般的に後方互換と呼ばれる 新書き込み側 → 旧読み取り側 旧実装が新しいデータ処理できること:一般的に前方互換と呼ばれる 新書き込み側 → 旧中間層 → 新読み取り側 読めるだけでは不十分:転送、変換、または返却時に必要な情報を失ってはならない リクエストとレスポンスの書き込み方向は逆です。「新クライアント/旧サーバー」とだけ書いても、完全な互換関係は記述できません。

データプロデューサー/コンシューマーを基準とし、新しいリーダーが古いライターデータの読み取りに成功することは一般的に後方互換(backward compatible)と呼ばれ、古いリーダーが新しいライターデータを処理できることは前方互換(forward compatible)と呼ばれます。異なるコミュニティの用語参照は異なる場合があるため、設計ドキュメントでは明確なバージョン方向を併記するのが望ましいです。

「新クライアントと旧サーバーの組み合わせ」には2つのパスが含まれます:新クライアントがリクエストを書き込み、旧サーバーがそれを読み取るパス、および旧サーバーがレスポンスを書き込み、新クライアントがそれを読み取るパスです。これら2つのパスは異なる能力を必要とし、単なるクライアント/サーバーの新旧関係の表でデータ方向の行列を代替することはできません。

フィールドの単位変更が、ワイヤ互換性をビジネスエラーに変える方法

元のフィールド timeout_seconds = 1 が uint32 であると仮定します。新しい要件でミリ秒をサポートしたい場合、フィールド名を timeout_ms に変更すると、バイトレベルでのパースは成功するかもしれませんが、元の値 5 が「5秒」から「5ミリ秒」に意味を変えてしまい、セマンティクスの破壊となります。uint64 に変更しても、単位の意味は修復されません。

より制御可能な進化としては、旧フィールドを保持したまま、存在性を持つ新しい timeout_ms = 2 を導入し、両方が同時に出現した場合の優先順位を定義し、旧リーダーが退出する前に利用可能な書き込み方法を維持します。もし 0 が「タイムアウト未設定」を意味する場合、「指定されていない」ことと「明示的に 0 を指定した」ことを区別しなければならず、真偽値の判定だけでは不十分です。

# 教育モデル:フィールド名はパース済みのフィールドを表し、明示的な存在性はキーの存在有無で表現される。
def read_timeout_ms(message):
    if 'timeout_ms' in message:
        value = message['timeout_ms']
    elif 'timeout_seconds' in message:
        value = message['timeout_seconds'] * 1000
    else:
        value = 30_000
    if type(value) is not int or not 0 <= value <= 3_600_000:
        raise ValueError('invalid timeout')
    return value

assert read_timeout_ms({'timeout_seconds': 5}) == 5000
assert read_timeout_ms({'timeout_ms': 0}) == 0
assert read_timeout_ms({}) == 30000
assert read_timeout_ms({'timeout_seconds': 5, 'timeout_ms': 1200}) == 1200
# 旧リーダーが秒単位の整数のみをサポートする場合、1.2秒は旧フィールドに損失なく書き戻せない。
assert 1200 % 1000 != 0
print('単位、デフォルト値、明示的な0、および二重フィールドの優先順位が通過した')

新しいフィールドの任意の値が「自動的にフォールバックする」と主張してはいけません。1.2秒は旧フォーマットでは拒否するか、新機能を制限するか、明示的な有損変換戦略を採用するしかなく、選択はビジネス契約に属します。Protobufでは、欠落と明示的なデフォルト値を表現するために適切な存在性(presence)メカニズムを使用できます。言語生成コードとフィールドバージョンも一緒に確認する必要があります。Protobuf Field Presence

変更内容確認すべき点
フィールドの追加旧リーダーが不明な値を許可するか;新リーダーのデフォルト値が正しいか
フィールドの削除フィールド番号/名前を保持し、履歴データと旧ライターを確認する
数値型の変更ワイヤ互換性があるか、範囲がオーバーフローしないか、言語マッピングが変更されないか
enum値の追加旧コードのデフォルト分岐、不明な値の保持、ビジネス処理
oneofの追加/フィールドの所属変更存在性、排他性、旧値のクリアセマンティクス
必須制約の変更旧ライターが依然として受け入れられるリクエストを生成できるか

Protobufでフィールドを削除した後、対応する識別子を reserve し、旧バイト列が将来別の意味で解釈されないようにする必要があります。フィールド番号が同じでワイヤタイプが読み取れるからといって、型、数値、ビジネス互換性が同等であるわけではありません。Proto3 更新メッセージ

不明なデータ:スキップ、保持、拒否は異なる戦略

シナリオ適切な行動の根拠
表示のみに関係するオプション属性契約に従って無視でき、既存の動作を保持できる
中間層の透過的な転送または永続化不明なフィールドを保持する必要がある場合があり、バックエンドで新情報が失われるのを防ぐため
新しい権限制約または理解必須のコマンド理解できない場合は拒否するか、機能の有効化を拒否する
不明なフレームで、一般的な長さがない場合境界を特定できないため、盲目的にスキップできない
既知のメッセージがエラー状態に現れた場合状態機械に従ってエラーを報告すべきで、オプション拡張を装ってはならない

HTTP/2 は不明なフレームタイプの無視ルールを定義し、不明な SETTINGS パラメータの処理を規定していますが、接続プレファクスや CONTINUATION シーケンスなどの制約に従う必要があります。QUIC v1 は不明なトランスポートフレームタイプに対して FRAME_ENCODING_ERROR を要求します。QUICはフレームごとに統一された長さを持つTLVではないため、HTTP/2のルールをそのまま適用することはできません。HTTP/2、QUIC §12.4

HTTP/3 にも独自のフレームおよびストリーム拡張ルールがあり、「不明」と「既知だが禁止された位置に現れた」ものを区別する必要があります。拡張の許容度は、各レイヤーの構文とセマンティクスによって共同で決定されるべきです。HTTP/3 拡張

旧中間層が正常にパースして書き出したとしても、情報を失う可能性があります。Protobuf を JSON に変換したり、フィールドごとに DTO にコピーしたり、旧列のみを含むデータベースに保存したりすると、不明なフィールドのパスが変更されます。新メッセージが実際の中間層を通過した後の往復結果をテストすべきであり、単に最新の2つのライブラリだけで相互テストを行うだけでは不十分です。

旧中間層はパース成功と言っているのに、なぜ新フィールドは消えてしまったのか?

まず ms=1200 を送信し、保持パスでは 1.2秒 のままですが、再構築パスでは旧フィールドの 5秒 に変わります。次に ms を 0 に設定すると、保持パスでは明確に「タイムアウトなし」となり、フィールドの存在性を取消すことで初めて 5秒 にフォールバックします。この優先順位を if(value) で判断することはできません。

図を準備しています
旧中間層はパース成功と言っているのに、なぜ新フィールドは消えてしまったのか?

スキップできるからといって、損失なく転送できるわけではありません。明示的な0と欠落は異なります;既知のフィールドのみで再構築されたオブジェクトは新セマンティクスを失い、すべてのパースが成功したとしても同様です。

交渉、検出、フォールバックを1ステップに混ぜない

メカニズム作用する時点決定される内容
HTTP/1.1 Upgrade既存の接続内でのプロトコル切替リクエストとレスポンス条件を満たした場合、その接続上で切替える
TLS ALPNTLS ハンドシェイク内クライアントが提供したリストから、その接続用のアプリケーションプロトコルを選択する
HTTP Alt-Svc代替サービス広告を受信した後今後のリクエスト用に別の接続、または既存の代替接続を選択できる
QUIC バージョンメカニズム接続確立と適用可能なバージョンの交渉プロセストランスポートバージョンおよび対応する互換性ルール
アプリケーション capabilityアプリケーションプロトコルで定義された時点特定のオプション機能が両側で有効化されているかどうか

HTTP Upgrade の典型的な現代の使用例としては WebSocket が参照できます。HTTP/2 の旧 h2c Upgrade メカニズムは RFC 9113 で廃止されており、現在の一般的な HTTP/2 接続確立のチュートリアルとして使い続けるべきではありません。ALPN は TLS 1.2 では ServerHello で、TLS 1.3 では EncryptedExtensions でサーバー選択が行われます。すべての TLS バージョンに対して ServerHello の回答のみを描画することはできません。HTTP Upgrade、ALPN、TLS 1.3 拡張位置

Alt-Svc は、既存の TCP 接続をその場で QUIC に変更するものではなく、クライアントに必ず移行を強制するものでもありません。クライアントは依然として、代替サービスが元の origin に対する権威を持っていることを検証する必要があります。広告の有効期限、失敗時のキャッシュ、および既存の接続が実際の選択に影響を与えます。Alt-Svc

バージョン番号と capability は同じものではありません。バージョン 2 の一部の実装には、特定のオプション圧縮機能がない場合があります。機能の存在には方向性、パラメータ範囲、依存関係がある可能性があり、ビットマップでは適用可能な単純な能力集合しか表現できません。TLS の cipher suites リスト自体が capability のビットマップではないことにも注意してください。

利用可能な共通部分集合をまず求め、その後ローカルポリシーを実行する

def choose(local_preference, peer_offered, forbidden=()):
    offered = set(peer_offered)
    for name in local_preference:
        if name in offered and name not in forbidden:
            return name
    raise ValueError('no acceptable common mode')

assert choose(['v2', 'v1'], ['v1']) == 'v1'
assert choose(['v2', 'v1'], ['v1', 'v2']) == 'v2'
try:
    choose(['v2', 'v1'], ['v1'], forbidden={'v1'})
except ValueError:
    pass
else:
    raise AssertionError('local minimum policy bypassed')
print('共通部分集合、優先順位、およびローカル最低要件がそれぞれ有効になった')

これは教育用の選択関数であり、すべてのプロトコルが同一の側の優先順位でソートされることを意味するものではありません。実際には、相手側の選択が正当なオファーから来たことを検証し、保護が必要な交渉内容を認証ハンドシェイクにバインドして、中間デバイスが強力な能力を削除した後にフォールバックを強制されないようにする必要があります。

フォールバックは、試行失敗後の戦略であり、交渉そのものではありません。一時的なネットワーク障害、バージョン非対応、証明書検証失敗、および権限不足は、すべて同じ種類のフォールバックをトリガーすべきではありません。フォールバックは、認証を密かに解除したり、ビジネス権限を変更したりしてはいけません。最低要件が存在する場合、受け入れ可能な共通部分集合がないことは失敗を意味します。

GREASE:「無視できるべき」な拡張位置を継続的に使用し続ける

プロトコル仕様では不明な値は無視されるべきとされていますが、エコシステムが長期間にわたって数個の固定値のみを送信している場合、誤った実装はこれらの値をハードコードしてしまう可能性があります。GREASE は、許可された位置に予約済み値を継続的に送信することで、このようなエラーを早期に露呈させ、拡張ポイントの可用性を維持します。

TLS GREASE は、仕様で予約された値を使用しており、任意のランダムなバイト列を任意のフィールドに注入するものではありません。サーバーは不明な候補として処理し、他の合法能力を選択すべきです。もしサーバーが GREASE 値を実際の交渉結果として返した場合、クライアントはむしろ拒否しなければなりません。GREASE が検証するのは、正しい不明な値の動作であり、すべての位置で寛容に受信することを要求するものではありません。RFC 8701

HTTP/3 は、一部の設定などのスペースに対して 0x1f * N + 0x21 形式の値を予約し、対応する無視ルールを鍛えています。異なるレジストリにはそれぞれ規定があり、この数式を TLS やすべての QUIC レイヤーの統一ルールとして使用すべきではありません。GREASE がすべての実装によってすべてのバリエーションが送信されることを保証するものでもありません。HTTP/3 予約設定

もし実装がホワイトリスト内の GREASE 値に対してのみ特別扱いで許可し、本当に不明な値に対しては依然としてエラーを報告する場合、目的は達成されていません。テストでは、異なる値、位置、長さ、および既知のフィールドとの混合をカバーし、同時に仕様で禁止された入力を厳格に拒否する必要があります。

QUIC の進化可能性には明確な境界がある

QUIC は大量のトランスポート詳細を暗号化して保護し、中間デバイスがそれらに対して誤った推論を行う機会を減らします。しかし、「すべてのフラグが暗号化され、中間ボックスは CID しか見えない」というわけではありません。ロングヘッダーには依然としてバージョンや接続IDなど、可視フィールドがあります。また、最初のバイトのビットの一部には公開された意味があります。ヘッダー保護は、ヘッダーコンテンツ全体を完全に隠すこととは異なります。QUIC 不変条件

QUIC Initial の初期保護キーは公開情報から派生できるため、パッシブオブザーバーに対してすべてのハンドシェイクコンテンツを隠すための汎用保証として使用することはできません。接続の移行は CID、パス検証、および状態に依存しており、IP/ポートをネットワークパスから完全に削除するものではありません。進化可能な内部フォーマットと安定した外部不変条件は、共同で設計する必要があります。

基礎となる Version Negotiation パケット自体には、通常のハンドシェイクのような認証保証がないため、そこにリストされているものだけを盲目的に信じて安全なフォールバックを実現することはできません。RFC 9368 は互換バージョンの交渉および関連検証を定義しており、アップグレードウィンドウやサービスクラスターの能力もフォールバック防止の境界に影響を与えます。QUIC 互換バージョン交渉

デプロイ順序にはストレージとロールバックを含める

ローリング進化:まず読み取り能力を拡張し、次に新しい書き込みセマンティクスを有効化する ① リーダーを拡張 新バージョンは旧フィールドと新フィールドの両方を読み取り可能だが、依然として旧フォーマットで書き込む ② 全パスの能力を確認 サービス、プロキシ、コンシューマー、オフラインタスク、およびロールバックバージョンをすべてチェックリストに含める ③ 新ライターを有効化 能力または範囲に応じてデプロイし、旧リーダーと永続データの動作を観察する 最後に旧パスの収縮を検討する;バイナリロールバックは、すでに書き込まれた新フォーマットデータを自動的に旧フォーマットに戻すものではない。

サービスをまず「双方向読み取りだが旧書き込み」にアップグレードし、旧コンシューマーが正常に動作し続けることを仮定します。その後、すべての必須パスが新フィールドを読み取れることを確認し、段階的に新書き込みを有効化します。元の旧バイナリへのロールバックをサポートする場合、それが読み取れない、または正しく解釈できないデータを書き込んではいけません。より一般的なロールバック可能なターゲットは、「双方向読み取りをサポートしているが、新書き込み機能を無効化した」バージョンです。

テスト組み合わせ検証すべき内容
旧ライター → 新リーダー履歴データとデフォルト値
新ライター → 旧リーダー不明なフィールド、範囲、サポートされていない機能に対する明確な失敗
新 → 旧中間層 → 新不明な情報が保持されているかどうか
新データ → ロールバックバージョンデータとコードのロールバックの境界
一部ノードに新能力ありルーティング、リトライ、および capability キャッシュが正しいかどうか
切り捨て、重複、不明な重要値異なるバージョンでの失敗動作が一致しているかどうか

長接続、オフラインクライアント、リトライキュー、バックアップ、およびバッチ処理の最長存続期間も考慮に入れる必要があります。「生産トラフィックに旧バージョンがもう存在しない」からといって、旧フォーマットデータが完全に消滅したことを意味しません。旧パスを削除する前に、使用証拠と明確な保持ウィンドウが必要です。

寛容なパース処理に統一ルールがない場合、同じ入力に対して実装が異なる結果を生み出し、かえって相互運用性やセキュリティの問題を引き起こします。許容可能な入力、拡張位置、および失敗動作を明確に文書化し、混合バージョンと実際のデータパスを通じて継続的に検証することで、プロトコルの安定した進化を実現できます。Maintaining Robust Protocols