ステータス: ドラフト(2026-10-17)、実測。 バイトはすべてpython3(3.9、標準ライブラリのみ)で直接エンコーディングした値です。公式protobufライブラリで同じメッセージをシリアライズして照合する作業は、パッケージのダウンロードが必要なため行っていません(未実測、必要なもの:
protobufPythonパッケージとprotoc)。 規則は protobuf.devのEncodingドキュメントと proto3言語ガイド(2026-10-17閲覧)で確認しました。
目次
- 1. 直感: 項目名ではなく項目番号
- 2. 図: Rally 1件の21バイト
- 3. 4つの規則
- 4. 実際にやってみる: 手動エンコーディングとJSONの比較
- 5. 下位互換性: 未知のフィールドをスキップする方法
- 6. 制限
- 7. 小規模クラスターとのつながり
- 参考
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.
- 1つの色が1つのフィールドです。各フィールドは タグバイトで始まります(図の「タグ」表示)。
- 文字列
"티티"はUTF-8で6バイト(ed 8b b02回)であり、先頭に長さ06がつきます。 hitsの270は2バイト(8e 02)なので、まとまりの長さは4です。
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の比較
- 環境: ノートPC、Python 3.9.6、標準ライブラリのみ(
struct,json) - コードと生データ: 作業ディレクトリ
measure/grpc/pb_by_hand.py、出力out-pb_by_hand.txt - 方法: 第3節の規則に従ってフィールドを1つずつバイトに変換し、結合しました。
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. 制限
- メッセージサイズのの上限: シリアライズされたメッセージは2GiB未満である必要があります(Encodingドキュメント)。gRPCはそれよりはるかに小さいデフォルトの上限(受信4MiB)を個別に設けています(第3回)。
- この記事ではproto3のみを扱いました。Editions(2023〜)の設定機能、proto2のrequired・group、
mapのワイヤ表現(繰り返しのサブメッセージ)については扱っていません。 - 手動エンコーディングは規則を理解するためのものであり、実装を検証したものではありません。公式ライブラリの出力とのバイト単位の照合は未実測です。
- フィールドの順序: 上記の例では番号順に記述しましたが、仕様ではシリアライズの順序は保証されません。同じメッセージが常に同じバイトになると仮定してはなりません(ハッシュや署名に使用する際の注意)。
7. 小規模クラスターとのつながり
kubeletとcontainerdの間のCRIメッセージもこの形式です。そのため、kubeletがcontainerdよりも新しいバージョンであっても(CRIに新しいフィールドが追加されても)、古いcontainerdは未知のフィールドをスキップしながら動作できます。逆に、CRI側がフィールド番号をむやみに変更しない理由も同様です。kubeletがCRIの応答を受け取る際の上限を、gRPCデフォルトの4MiBではなく16MiBに引き上げていること(第6回)は、数百個のコンテナのリストが1つのメッセージに収まるためです。
一言まとめ。 スコアボードに「1番目の項目: 150」とだけ記述しても、互いに同じフォーマットを持っていれば十分です。ただし、負のスコアをint32の項目に記述すると10枠を占有するため、sint32の項目を使用してください!
参考
すべての資料は2026-10-17に閲覧しました。
- protobuf.dev: Encoding(ワイヤタイプ、varint、ZigZag、packed、サイズ上限)、 Language Guide (proto3)(フィールド番号の範囲、reserved、unknown fields、タイプの互換性、field presence)
- バイト計算コードと出力: 作業ディレクトリ
measure/grpc/pb_by_hand.py,out-pb_by_hand.txt