Go サイズのセマンティクス

proto.Size の (誤った) 使用方法について説明します

proto.Size 関数は、すべてのフィールド (サブメッセージを含む) を走査することにより、proto.Message のワイヤーフォーマットエンコーディングのサイズをバイト単位で返します。

特に、Go Protobuf がメッセージをエンコードする方法のサイズを返します。

典型的な使用例

空のメッセージの識別

proto.Size が 0 を返すかどうかを確認することは、空のメッセージを認識する簡単な方法です

if proto.Size(m) == 0 {
    // No fields set (or, in proto3, all fields matching the default);
    // skip processing this message, or return an error, or similar.
}

サイズ制限されたプログラム出力

たとえば、バッチ処理パイプラインを作成しているとします。このパイプラインは、別のシステム (この例では「ダウンストリームシステム」と呼びます) 用のワークタスクを生成します。ダウンストリームシステムは、小規模から中規模のタスクを処理するようにプロビジョニングされていますが、負荷テストでは、500 MB を超えるワークタスクが提示されると、システムがカスケード障害に陥ることが示されています。

最善の修正は、ダウンストリームシステムに保護を追加することです (https://cloud.google.com/blog/products/gcp/using-load-shedding-to-survive-a-success-disaster-cre-life-lessons) を参照) が、負荷分散の実装が実現不可能な場合は、パイプラインに簡単な修正を追加することを決定する可能性があります

func (*beamFn) ProcessElement(key string, value []byte, emit func(proto.Message)) {
  task := produceWorkTask(value)
  if proto.Size(task) > 500 * 1024 * 1024 {
    // Skip every work task over 500 MB to not overwhelm
    // the brittle downstream system.
    return
  }
  emit(task)
}

誤った使用法: Unmarshal との関係なし

proto.Size は、Go Protobuf がメッセージをエンコードする方法のバイト数を返すため、着信 Protobuf メッセージのストリームをアンマーシャリング (デコード) する場合に proto.Size を使用するのは安全ではありません

func bytesToSubscriptionList(data []byte) ([]*vpb.EventSubscription, error) {
    subList := []*vpb.EventSubscription{}
    for len(data) > 0 {
        subscription := &vpb.EventSubscription{}
        if err := proto.Unmarshal(data, subscription); err != nil {
            return nil, err
        }
        subList = append(subList, subscription)
        data = data[:len(data)-proto.Size(subscription)]
    }
    return subList, nil
}

data非最小ワイヤーフォーマットのメッセージが含まれている場合、proto.Size は実際にアンマーシャリングされたサイズとは異なるサイズを返す可能性があり、解析エラー (最良の場合) または誤って解析されたデータ (最悪の場合) につながります。

したがって、この例は、すべての入力メッセージが (同じバージョンの) Go Protobuf によって生成されている場合にのみ、確実に機能します。これは驚くべきことであり、意図されていない可能性があります。

ヒント: Protobuf メッセージのサイズ区切りストリームを読み書きするには、代わりにprotodelim パッケージを使用してください。

高度な使用法: バッファの事前サイズ設定

proto.Size の高度な使用法は、マーシャリングする前にバッファに必要なサイズを決定することです

opts := proto.MarshalOptions{
    // Possibly avoid an extra proto.Size in Marshal itself (see docs):
    UseCachedSize: true,
}
// DO NOT SUBMIT without implementing this Optimization opportunity:
// instead of allocating, grab a sufficiently-sized buffer from a pool.
// Knowing the size of the buffer means we can discard
// outliers from the pool to prevent uncontrolled
// memory growth in long-running RPC services.
buf := make([]byte, 0, opts.Size(m))
var err error
buf, err = opts.MarshalAppend(buf, m) // does not allocate
// Note that len(buf) might be less than cap(buf)! Read below:

レイジーデコードが有効になっている場合、proto.Sizeproto.Marshal (および proto.MarshalAppend などのバリアント) が書き込むよりも多くのバイト数を返す可能性があることに注意してください! したがって、エンコードされたバイトをワイヤー (またはディスク上) に配置する場合は、必ず len(buf) を使用し、以前の proto.Size の結果は破棄してください。

具体的には、(サブ)メッセージは、次の場合に proto.Sizeproto.Marshal の間で「縮小」する可能性があります。

  1. レイジーデコードが有効になっている
  2. そして、メッセージが非最小ワイヤーフォーマットで到着した
  3. そして、メッセージは proto.Size が呼び出される前にアクセスされないため、まだデコードされていません
  4. そして、メッセージは proto.Size の後 (ただし、proto.Marshal の前) にアクセスされ、レイジーデコードされます

デコードの結果、後続の proto.Marshal 呼び出しはメッセージをエンコードします (ワイヤーフォーマットを単にコピーするのではなく)。これにより、Go がメッセージをエンコードする方法への暗黙的な正規化が行われます。これは現在、最小ワイヤーフォーマットです (ただし、それを当てにしないでください!)。

ご覧のとおり、シナリオはかなり具体的ですが、それでも proto.Size の結果を上限として扱い、結果が実際にエンコードされたメッセージサイズと一致することを決して想定しないのがベストプラクティスです。

背景: 非最小ワイヤーフォーマット

Protobuf メッセージをエンコードする場合、1 つの最小ワイヤーフォーマットサイズと、同じメッセージにデコードされる多数のより大きな非最小ワイヤーフォーマットがあります。

非最小ワイヤーフォーマット (「非正規化ワイヤーフォーマット」とも呼ばれることがあります) は、非反復フィールドが複数回出現する、最適でない可変長整数エンコーディング、ワイヤー上で非パックで表示されるパックされた反復フィールドなどのシナリオを指します。

さまざまなシナリオで非最小ワイヤーフォーマットに遭遇する可能性があります

  • 意図的に。 Protobuf は、ワイヤーフォーマットを連結することにより、メッセージの連結をサポートしています。
  • 偶発的に。 (おそらくサードパーティの) Protobuf エンコーダーは理想的にエンコードしません (例: 可変長整数をエンコードするときに必要以上のスペースを使用します)。
  • 悪意を持って。 攻撃者は、ネットワーク上でクラッシュをトリガーするために、特に Protobuf メッセージを細工する可能性があります。