TT Lab
ブログ

한국어 English 日本語 中文

はじめる
ブログ

ブログ

protobuf ワイヤ形式: 21バイトを1区切りずつ

ステータス: ドラフト(2026-10-17)、実測。 バイトはすべてpython3(3.9、標準ライブラリのみ)で直接エンコーディングした値です。公式protobufライブラリで同じメッセージをシリアライズして照合する作業は、パッケージのダウンロードが必要なため行っていません(未実測、必要なもの: protobuf Pythonパッケージと protoc)。 規則は protobuf.devのEncodingドキュメントと proto3言語ガイド(2026-10-17閲覧)で確認しました。

目次

1. 直感: 項目名ではなく項目番号

JSONは {"rallyId":150} のように 項目名を毎回 記述します。読みやすいですが、同じ名前がメッセージごとに繰り返されます。protobufは契約書(.proto)を双方がすでに持っているとみなし、バイトには 項目番号と値のみを記述します。「1番目の項目: 150」。名前は契約書にあるため送信する必要はありません。

ここから2つの特徴が生じます。

2. 図: Rally 1件の21バイト

第1回の契約書のメッセージを1つ埋めます。 rally_id=150, server="티티", hits=[3,270,1], won=true, delta=-2.

Rally 메시지 21바이트를 한 칸씩 08 태그 96 01 12 태그 06 ed 8b b0 ed 8b b0 1a 태그 04 03 8e 02 01 20 태그 01 28 태그 03 읽는 법 필드 1 rally_id 태그 08 · 150=96 01 필드 2 server 태그 12 · 길이 06 · UTF-8 필드 3 hits 태그 1a · 길이 04 · 3,270,1 필드 4 won 태그 20 · 1 필드 5 delta 태그 28 · -2→3 태그 = (필드 번호 << 3) | 와이어 타입. 08 = 1번, varint 같은 값을 다른 형식으로 (실측, python3 표준 라이브러리) protobuf 21바이트 JSON 한 줄 72바이트 JSON 들여쓰기 111바이트
図: Rallyメッセージの21バイトをフィールド別に分けた図と、JSONとのサイズ比較

3. 4つの規則

3.1 varint: 7ビットずつ、下位の桁から

整数を7ビットずつ切り出し、 下位の桁から 記述し、後ろにバイトがまだある場合は最上位ビット(MSB)を1にします。150は二進数 1001 0110 なので、下位7ビット 001 0110 に継続ビットを立てた 0x96、残りの 1 が 0x01 になり 96 01 です。直接計算した値です。

値 バイト サイズ
1 01 1
127 7f 1
128 80 01 2
150 96 01 2
300 ac 02 2
16,384 80 80 01 3
2,147,483,647 ff ff ff ff 07 5
int32 -1 ff ff ff ff ff ff ff ff ff 01 10

最後の行が罠です。 int32 の負数は64ビットの2の補数に拡張して記述するため、常に10バイトになります(Encodingドキュメントの「Signed Integers」セクション)。負数が頻繁に登場するフィールドには sint32/sint64 を使用します。 ZigZag により0, -1, 1, -2, 2 …を0, 1, 2, 3, 4 …に対応させた後、varintとして記述します。

sint32値 ZigZag バイト
-1 1 01
1 2 02
-2 3 03
-64 127 7f
64 128 80 01

3.2 タグ = (フィールド番号 << 3) | ワイヤタイプ

ワイヤタイプは値の 形状のみを伝えます。0はVARINT(int32・int64・uint・bool・enum・sint)、1はI64(fixed64・double)、2はLEN(string・bytes・メッセージ・packedのまとまり)、5はI32(fixed32・float)であり、3と4は古いグループ用のため使用されません。タグもvarintであるため、番号が大きくなると長くなります。

フィールド番号 ワイヤタイプ タグバイト
1 0 08
2 2 12
15 0 78
16 0 80 01
2047 0 f8 7f
2048 0 80 80 01

1〜15番はタグが1バイト、16〜2047番は2バイトです。頻繁に値が入るフィールドには小さい番号を割り当てるべきという案内はここから来ています。番号は1から536,870,911(2²⁹-1)まで使用でき、19,000〜19,999は実装用に予約されています(proto3ガイド)。

3.3 LEN: 長さを先頭に

文字列・バイト・子メッセージ・packedのまとまりは 태그 + 길이(varint) + 내용 です。proto3で数値型 repeated は デフォルトでpacked なので、 hits=[3,270,1] はタグ 1a(3番、LEN)1つ + 長さ 04 + 03 8e 02 01 です。packedでなければ値ごとにタグがつき、7バイトになります(計算値)。

3.4 デフォルト値は送信しない

proto3の通常のスカラーフィールド(暗黙的な存在)は、値が0・空文字・falseの場合、 シリアライズされません。すべてのフィールドがデフォルト値であるRallyは0バイトです。受信側は「0を送信した」と「送信しなかった」を区別できません。区別が必要な場合は optional を付与して存在を明示します(proto3ガイドのField Presence)。第5回のヘルスチェックリクエスト HealthCheckRequest{service:""} が実際に 0バイトであることもこの規則のためです。

4. 実際にやってみる: 手動エンコーディングとJSONの比較

def varint(n):
    if n < 0:
        n &= (1 << 64) - 1          # int32 음수는 64비트로 늘려 10바이트
    out = bytearray()
    while True:
        b, n = n & 0x7F, n >> 7
        out.append(b | 0x80 if n else b)
        if not n:
            return bytes(out)

def tag(field, wire):
    return varint((field << 3) | wire)

結果(実測):

フィールド バイト サイズ
1 rally_id=150 08 96 01 3
2 server="티티" 12 06 ed 8b b0 ed 8b b0 8
3 hits=[3,270,1] 1a 04 03 8e 02 01 6
4 won=true 20 01 2
5 delta=-2 (sint32) 28 03 2
合計 21

同じ内容をJSONで記述した場合(フィールド名はproto3のJSON規約に従いlowerCamelCase)、空白なしの1行が 72バイト, indent=2 で、インデント付きでは 111バイトでした。この例において、protobufはJSONの1行の29%のサイズです。メッセージが数値中心であるほど差が大きくなり、長い文字列中心であれば縮小します。gzip圧縮後の比較は行っていません(未実測)。

delta を int32 と宣言していた場合、-2の値だけで10バイトになり、合計が30バイトになります(計算値)。フィールド1つのタイプの選択が、メッセージを43%大きくすることになります。

5. 下位互換性: 未知のフィールドをスキップする方法

新しいバージョンが6番目のフィールド string note = 6; を追加して "hello" を入れたとします。バイトの末尾に 32 05 68 65 6c 6c 6f が追加されます。6番目を知らない古いバージョンのパーサーは、タグ 32 からワイヤタイプ2(LEN)を読み込み、長さ5の分だけ スキップします。実際にスクリプトの「古いバージョンのパーサー」の模倣(skip_fields)は (6, 2, 6) を出力して最後まで読み込みました。値の形状をタグが教えてくれるため可能なことです。

proto3では、このように未知のフィールドを破棄せずに 保持しておき、再度シリアライズするときにそのまま使用します(proto3ガイドのUnknown Fields)。ただし、JSONに変換したりフィールドを1つずつコピーしたりすると消失する場合があります。

守るべき事項をまとめると以下の通りです(proto3ガイドの要約)。

変更内容 安全か 理由
フィールドの追加(新しい番号) 安全 古いバージョンはスキップし、新しいバージョンはデフォルト値として読み込む
フィールド名の変更 バイナリは安全 バイトに名前が含まれないため。ただしJSONやTextFormatは名前を使用する
フィールドの削除 番号と名前を reserved でブロックすれば安全 再利用すると古いデータが新しい意味として読み込まれてしまう
番号の再利用・変更 危険 解釈が食い違い、データの破損や流出につながるおそれがある
int32 ↔ int64 ↔ uint32 ↔ bool ワイヤ互換性あり すべてVARINT。範囲を超えると切り詰められる
int32 ↔ sint32 互換性なし ZigZagの解釈が異なり値が変わる(-2 ↔ 3)

最後の行は第4回の例で確認できます。 28 03 を int32 として読み込むと3、 sint32 として読み込むと-2になります。

6. 制限

7. 小規模クラスターとのつながり

kubeletとcontainerdの間のCRIメッセージもこの形式です。そのため、kubeletがcontainerdよりも新しいバージョンであっても(CRIに新しいフィールドが追加されても)、古いcontainerdは未知のフィールドをスキップしながら動作できます。逆に、CRI側がフィールド番号をむやみに変更しない理由も同様です。kubeletがCRIの応答を受け取る際の上限を、gRPCデフォルトの4MiBではなく16MiBに引き上げていること(第6回)は、数百個のコンテナのリストが1つのメッセージに収まるためです。

一言まとめ。 スコアボードに「1番目の項目: 150」とだけ記述しても、互いに同じフォーマットを持っていれば十分です。ただし、負のスコアをint32の項目に記述すると10枠を占有するため、sint32の項目を使用してください!

参考

すべての資料は2026-10-17に閲覧しました。

ログインするといいねできます

コメント

まだコメントはありません。

ログインするとコメントできます