Day 055 — 2026-09-25 | gRPC/Protocol Buffers実装(続き): 標準ライブラリだけで grpc.health.v1 を話す——5バイトの枠と trailer を実装し、本物の grpc-go クライアントで確かめる

2026-09-25 🟡 上級者 / Phase 3 実装 gRPC/Protocol Buffers実装

📚 背景知識(読んでから問題へ)

Day 051〜053 で「メッセージがどんなバイト列になるか」、Day 054 で「失敗をどのステータスコードで返すか」を扱いました。残っているのは、そのバイト列とステータスが HTTP/2 の上にどう載っているかです。今日はここを標準ライブラリだけで実装し、本物の grpc-go クライアントから呼べることを確かめます。

gRPC の1回の呼び出しは、HTTP/2 の1本のストリーム

仕様は grpc/grpc の doc/PROTOCOL-HTTP2.md(2026-09-25 に WebFetch で取得し確認)です。unary(1往復)の呼び出しは次の形になります。

クライアント → サーバー
  HEADERS  :method POST
           :path /grpc.health.v1.Health/Check        ← /{パッケージ.サービス}/{メソッド}
           content-type application/grpc             ← "application/grpc" で始まること
           te trailers                               ← 仕様の注記: "Used to detect incompatible proxies"
  DATA     Length-Prefixed-Message                   ← 今日の実装対象その1
  (END_STREAM)

サーバー → クライアント(通常)
  HEADERS  :status 200, content-type application/grpc
  DATA     Length-Prefixed-Message
  HEADERS  grpc-status 0, grpc-message ...(END_STREAM)  ← trailer。今日の実装対象その2

サーバー → クライアント(Trailers-Only。すぐ失敗するとき)
  HEADERS  :status 200, content-type application/grpc, grpc-status 5, grpc-message ...(END_STREAM)

仕様の定義(原文の ABNF を抜き出し):

Length-Prefixed-Message → Compressed-Flag Message-Length Message
Compressed-Flag → 0 / 1                    ; encoded as 1 byte unsigned integer
Message-Length → {length of Message}       ; encoded as 4 byte unsigned integer (big endian)
Response → (Response-Headers *Length-Prefixed-Message Trailers) / Trailers-Only
Trailers-Only → HTTP-Status Content-Type Trailers

HealthCheckRequest{service: "inventory.v1.InventoryService"} を送ると、DATA の中身は次の 36 バイトです(導出。Day 052 の Length-delimited と同じ規則)。

00             Compressed-Flag = 0(圧縮なし)
00 00 00 1f    Message-Length = 31(big endian)
0a             タグ: フィールド1、ワイヤタイプ2(LEN)
1d             長さ 29
69 6e 76 ...   "inventory.v1.InventoryService"(29 バイト)
00
Compressed-Flag
1 byte
00 00 00 1f
Message-Length = 31
4 byte big endian
0a
tag
field1/LEN
1d
len
29
69 6e 76 65 …
"inventory.v1.InventoryService"
29 byte

紫+青の 5 バイトが gRPC の枠(今日の実装)、オレンジ以降が PB の中身(Day 051〜052)。

PB の中は varint(可変長)なのに、外側の枠は 4 バイト固定長の big endian です。受け手は最初の 5 バイトだけ読めば「あと何バイト読めばメッセージが揃うか」が分かり、HTTP/2 の DATA フレームがどこで分割されていても組み立て直せます。

ステータスは「最後」に来る

HTTP ステータスは gRPC のエラーでも 200 です。成否は trailer の grpc-status で返します。ストリーミングでは、何件か送った後に失敗することがあり、結果は最後にしか分からないためです。HTTP/1.1 の世界では trailer を落とす中継機器が多かったので、クライアントは te: trailers を付け、「trailer を理解しない経路」を見分けられるようにしています。

grpc-message は percent-encoding します。0x20〜0x7E のうち % 以外はそのまま、それ以外のバイト(日本語の UTF-8 など)は %XX にします。grpc-go v1.84.0 の internal/transport/http_util.go の encodeGrpcMessage も同じ規則です(モジュールキャッシュのソースで確認)。

今日の題材: grpc.health.v1.Health/Check

doc/health-checking.md(2026-09-25 確認)で定義された、gRPC 標準のヘルスチェックです。Kubernetes の gRPC probe(公式ドキュメント)や grpc-go の health パッケージがこのプロトコルを使います。

message HealthCheckRequest  { string service = 1; }
message HealthCheckResponse {
  enum ServingStatus { UNKNOWN = 0; SERVING = 1; NOT_SERVING = 2; SERVICE_UNKNOWN = 3; }
  ServingStatus status = 1;
}
service Health {
  rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
  rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}

仕様の決まりは2つです。登録されていないサービスを聞かれたら NOT_FOUND を返す。空文字列 "" はサーバー全体の状態を表す。

Go 1.24 から、平文の HTTP/2 を標準ライブラリだけで話せる

gRPC を TLS なしで使うとき、HTTP/2 は「最初から HTTP/2 で話す」(prior knowledge、h2c)方式になります。以前は golang.org/x/net/http2/h2c が必要でしたが、Go 1.24 で http.Protocols が入り、SetUnencryptedHTTP2(true) で標準ライブラリだけで受けられるようになりました(go doc net/http.Protocols を go1.27.1 で確認)。

📝 問題

次の3ファイルで作業します(go 1.24 以上)。main.go の TODO(1)〜TODO(4) が今日の実装範囲です。PB の符号化(Day 051〜053 の復習)と grpc-message の percent-encoding は書いてあります。

go.mod

module day055

go 1.24

main.go(スケルトン)

// 標準ライブラリだけで grpc.health.v1.Health/Check を話すサーバー。
// 実行: go run .   (127.0.0.1:50051 で h2c = 平文 HTTP/2 を待ち受ける)
package main

import (
	"encoding/binary"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"strconv"
	"strings"
)

// ---- gRPC ステータス(番号は grpc.io/docs/guides/status-codes と同じ) ----

const (
	codeOK                = 0
	codeNotFound          = 5
	codeResourceExhausted = 8
	codeUnimplemented     = 12
	codeInternal          = 13
)

type rpcError struct {
	code int
	msg  string
}

func (e *rpcError) Error() string { return fmt.Sprintf("code=%d: %s", e.code, e.msg) }

// ---- 1. Length-Prefixed-Message(PROTOCOL-HTTP2.md) ----
// Compressed-Flag(1 byte) + Message-Length(4 byte, big endian) + Message

const maxRecvMsgSize = 4 << 20 // grpc-go の defaultServerMaxReceiveMessageSize と同じ 4 MiB

func writeFrame(w io.Writer, msg []byte) error {
	// TODO(1): Compressed-Flag(1 byte) + Message-Length(4 byte, big endian) + msg を書く
	return errors.New("TODO")
}

func readFrame(r io.Reader, max int) ([]byte, error) {
	// TODO(2): 5 バイトのヘッダを読み、フラグと長さを検査してから本文を読む。
	// 失敗は *rpcError(code と msg)で返す。msg は grpc-go と同じ文言にする(main_test.go 参照)
	return nil, &rpcError{codeUnimplemented, "TODO"}
}

// ---- 2. HealthCheckRequest / HealthCheckResponse の符号化(Day051〜053 の復習) ----
// message HealthCheckRequest  { string service = 1; }
// message HealthCheckResponse { ServingStatus status = 1; }  // SERVING=1, NOT_SERVING=2

func decodeHealthCheckRequest(b []byte) (service string, err error) {
	for len(b) > 0 {
		tag, n := binary.Uvarint(b) // PB の varint と同じ形式(LEB128)
		if n <= 0 {
			return "", errors.New("bad tag")
		}
		b = b[n:]
		field, wt := tag>>3, tag&7
		switch wt {
		case 0: // VARINT
			if _, n = binary.Uvarint(b); n <= 0 {
				return "", errors.New("bad varint")
			}
			b = b[n:]
		case 1: // I64
			if len(b) < 8 {
				return "", errors.New("short i64")
			}
			b = b[8:]
		case 2: // LEN
			l, n := binary.Uvarint(b)
			if n <= 0 || l > uint64(len(b)-n) {
				return "", errors.New("bad length")
			}
			if field == 1 {
				service = string(b[n : n+int(l)])
			} // それ以外は未知フィールドとして読み飛ばす(Day053)
			b = b[n+int(l):]
		case 5: // I32
			if len(b) < 4 {
				return "", errors.New("short i32")
			}
			b = b[4:]
		default:
			return "", fmt.Errorf("unsupported wire type %d", wt)
		}
	}
	return service, nil
}

func encodeHealthCheckResponse(status uint64) []byte {
	if status == 0 { // proto3: 既定値(UNKNOWN=0)は書かない → 0 バイトのメッセージ
		return nil
	}
	return binary.AppendUvarint([]byte{1<<3 | 0}, status)
}

// ---- 3. grpc-message の percent-encoding(0x20〜0x7E のうち '%' 以外はそのまま) ----

func encodeGrpcMessage(s string) string {
	var sb strings.Builder
	for i := 0; i < len(s); i++ {
		c := s[i]
		if c >= 0x20 && c <= 0x7E && c != '%' {
			sb.WriteByte(c)
		} else {
			fmt.Fprintf(&sb, "%%%02X", c)
		}
	}
	return sb.String()
}

// ---- 4. ハンドラ ----

type healthServer struct {
	statuses map[string]uint64 // "" はサーバー全体の状態(health-checking.md)
}

// Trailers-Only: メッセージを1つも送らず、ステータスを HEADERS(END_STREAM 付き)に載せる。
func writeTrailersOnly(w http.ResponseWriter, code int, msg string) {
	// TODO(3): Content-Type・grpc-status・grpc-message(percent-encoding)を HEADERS に載せて返す
	_ = strconv.Itoa // grpc-status は 10 進の文字列にする
}

func (s *healthServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "gRPC requires POST", http.StatusMethodNotAllowed)
		return
	}
	if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") {
		http.Error(w, "not a gRPC request", http.StatusUnsupportedMediaType) // 仕様: SHOULD 415
		return
	}
	// ここから先は gRPC として答える: HTTP は必ず 200、成否は grpc-status で返す
	if r.URL.Path != "/grpc.health.v1.Health/Check" {
		writeTrailersOnly(w, codeUnimplemented, "unknown method "+r.URL.Path)
		return
	}
	req, err := readFrame(r.Body, maxRecvMsgSize)
	if err != nil {
		var re *rpcError
		errors.As(err, &re)
		writeTrailersOnly(w, re.code, re.msg)
		return
	}
	service, err := decodeHealthCheckRequest(req)
	if err != nil {
		writeTrailersOnly(w, codeInternal, "grpc: error unmarshalling request: "+err.Error())
		return
	}
	st, ok := s.statuses[service]
	if !ok {
		writeTrailersOnly(w, codeNotFound, "unknown service: "+service)
		return
	}

	// TODO(4): 通常の応答。Response-Headers → Length-Prefixed-Message → Trailers(grpc-status: 0)
	_ = st
}

func newServer(addr string, h http.Handler) *http.Server {
	var p http.Protocols
	p.SetUnencryptedHTTP2(true) // Go 1.24+: 平文 HTTP/2(prior knowledge)だけを受ける
	return &http.Server{Addr: addr, Handler: h, Protocols: &p}
}

func main() {
	hs := &healthServer{statuses: map[string]uint64{
		"":                              1, // SERVING
		"inventory.v1.InventoryService": 1, // SERVING
		"payment.v1.PaymentService":     2, // NOT_SERVING
		"search.v1.SearchService":       0, // UNKNOWN(応答は 0 バイトのメッセージになる)
	}}
	srv := newServer("127.0.0.1:50051", hs)
	log.Printf("listening on %s (h2c)", srv.Addr)
	log.Fatal(srv.ListenAndServe())
}

main_test.go(受け入れテスト。書き換えない)

package main

import (
	"bytes"
	"encoding/binary"
	"io"
	"net/http"
	"net/http/httptest"
	"runtime"
	"strings"
	"testing"
	"testing/iotest"
)

// 標準ライブラリの h2c クライアントで、grpc-go と同じバイト列を送る。
func call(t *testing.T, url, contentType string, body []byte) (httpStatus int, grpcStatus, grpcMessage string, msgs [][]byte, trailersOnly bool) {
	t.Helper()
	var p http.Protocols
	p.SetUnencryptedHTTP2(true)
	c := &http.Client{Transport: &http.Transport{Protocols: &p}}
	req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
	req.Header.Set("Content-Type", contentType)
	req.Header.Set("Te", "trailers")
	resp, err := c.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.ProtoMajor != 2 {
		t.Fatalf("proto = %s, want HTTP/2", resp.Proto)
	}
	for {
		m, err := readFrame(resp.Body, maxRecvMsgSize)
		if err != nil {
			break // 本文の終わり(EOF)
		}
		msgs = append(msgs, m)
	}
	io.Copy(io.Discard, resp.Body) // trailer は本文を読み切った後に resp.Trailer に入る
	if s := resp.Header.Get("Grpc-Status"); s != "" {
		return resp.StatusCode, s, resp.Header.Get("Grpc-Message"), msgs, true
	}
	return resp.StatusCode, resp.Trailer.Get("Grpc-Status"), resp.Trailer.Get("Grpc-Message"), msgs, false
}

func frame(msg []byte) []byte {
	var b bytes.Buffer
	writeFrame(&b, msg)
	return b.Bytes()
}

func reqFor(service string) []byte { // HealthCheckRequest{service}
	b := binary.AppendUvarint([]byte{1<<3 | 2}, uint64(len(service)))
	return append(b, service...)
}

func startServer(t *testing.T) string {
	t.Helper()
	ts := httptest.NewUnstartedServer(&healthServer{statuses: map[string]uint64{
		"": 1, "inventory.v1.InventoryService": 1, "payment.v1.PaymentService": 2, "search.v1.SearchService": 0}})
	var p http.Protocols
	p.SetUnencryptedHTTP2(true)
	ts.Config.Protocols = &p
	ts.Start()
	t.Cleanup(ts.Close)
	return ts.URL
}

func TestCheck(t *testing.T) {
	base := startServer(t)
	const check = "/grpc.health.v1.Health/Check"
	tests := []struct {
		name, path, ct string
		body           []byte
		wantHTTP       int
		wantStatus     string
		wantMsg        string // grpc-message(percent-encoding 済みの生の値)
		wantResp       []byte // nil なら応答メッセージ無し
		wantTrOnly     bool
	}{
		{"全体の状態", check, "application/grpc", frame(reqFor("")), 200, "0", "", []byte{0x08, 0x01}, false},
		{"UNKNOWN(0) は 0 バイトのメッセージ", check, "application/grpc", frame(reqFor("search.v1.SearchService")), 200, "0", "", []byte{}, false},
		{"SERVING", check, "application/grpc+proto", frame(reqFor("inventory.v1.InventoryService")), 200, "0", "", []byte{0x08, 0x01}, false},
		{"NOT_SERVING もRPCとしては成功", check, "application/grpc", frame(reqFor("payment.v1.PaymentService")), 200, "0", "", []byte{0x08, 0x02}, false},
		{"未登録は NOT_FOUND、日本語は percent-encoding", check, "application/grpc", frame(reqFor("在庫")), 200, "5",
			"unknown service: %E5%9C%A8%E5%BA%AB", nil, true},
		{"未実装メソッド", "/grpc.health.v1.Health/Watch", "application/grpc", frame(reqFor("")), 200, "12",
			"unknown method /grpc.health.v1.Health/Watch", nil, true},
		{"64 MiB を名乗るヘッダ", check, "application/grpc", []byte{0, 0x04, 0, 0, 0}, 200, "8",
			"grpc: received message larger than max (67108864 vs. 4194304)", nil, true},
		{"圧縮フラグ", check, "application/grpc", []byte{1, 0, 0, 0, 0}, 200, "13",
			"grpc: compressed flag set with identity or empty encoding", nil, true},
		{"gRPC でない", check, "application/json", []byte(`{}`), 415, "", "", nil, false},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			code, st, msg, msgs, trOnly := call(t, base+tt.path, tt.ct, tt.body)
			if code != tt.wantHTTP || st != tt.wantStatus || msg != tt.wantMsg || trOnly != tt.wantTrOnly {
				t.Fatalf("got http=%d status=%q msg=%q trailersOnly=%v, want %d %q %q %v",
					code, st, msg, trOnly, tt.wantHTTP, tt.wantStatus, tt.wantMsg, tt.wantTrOnly)
			}
			if tt.wantResp == nil {
				if len(msgs) != 0 {
					t.Fatalf("unexpected messages %x", msgs)
				}
				return
			}
			if len(msgs) != 1 || !bytes.Equal(msgs[0], tt.wantResp) {
				t.Fatalf("messages = %x, want [%x]", msgs, tt.wantResp)
			}
		})
	}
}

// HTTP/2 の DATA フレームは任意の位置で分割されうる。1 バイトずつしか返さない Reader でも読めること。
func TestReadFrameOneByteAtATime(t *testing.T) {
	want := reqFor("inventory.v1.InventoryService")
	got, err := readFrame(iotest.OneByteReader(bytes.NewReader(frame(want))), maxRecvMsgSize)
	if err != nil || !bytes.Equal(got, want) {
		t.Fatalf("got %x, %v", got, err)
	}
}

// 長さを確かめる前に make してはいけない: 64 MiB を名乗る 5 バイトだけで 64 MiB 確保されないこと。
func TestReadFrameRejectsBeforeAllocating(t *testing.T) {
	hdr := []byte{0, 0x04, 0, 0, 0} // Message-Length = 64 MiB。本文は送らない
	var before, after runtime.MemStats
	runtime.ReadMemStats(&before)
	_, err := readFrame(bytes.NewReader(hdr), maxRecvMsgSize)
	runtime.ReadMemStats(&after)
	if got := after.TotalAlloc - before.TotalAlloc; got > 1<<20 {
		t.Fatalf("allocated %d bytes for a 5-byte header (err=%v)", got, err)
	}
}

func TestUnknownFieldsAreSkipped(t *testing.T) {
	// field 2 (varint 150) + field 1 "x" + field 3 (fixed32)
	b := []byte{0x10, 0x96, 0x01, 0x0a, 0x01, 'x', 0x1d, 1, 2, 3, 4}
	s, err := decodeHealthCheckRequest(b)
	if err != nil || s != "x" {
		t.Fatalf("got %q, %v", s, err)
	}
}

func TestEncodeGrpcMessage(t *testing.T) {
	if got := encodeGrpcMessage("100% 在庫\n"); !strings.EqualFold(got, "100%25 %E5%9C%A8%E5%BA%AB%0A") {
		t.Fatalf("got %q", got)
	}
}

// 実際の HTTP/2 では 16 KB 程度まではたまたま 1 回の Read で届き、大きいと分割される。
func TestLargeMessageOverHTTP2(t *testing.T) {
	base := startServer(t)
	for _, n := range []int{1_000, 100_000, 1_000_000} {
		_, st, _, _, _ := call(t, base+"/grpc.health.v1.Health/Check", "application/grpc",
			frame(reqFor(strings.Repeat("x", n))))
		if st != "5" { // 未登録サービスなので、本文を読み切れていれば NOT_FOUND
			t.Fatalf("service of %d bytes: grpc-status=%s, want 5", n, st)
		}
	}
}

スケルトンのまま go test . を実行すると、10 件が失敗します(TestCheck の 8 サブテストと TestReadFrameOneByteAtATime・TestLargeMessageOverHTTP2。実測)。

  1. 実装する: TODO(1)〜TODO(4) を埋め、go test -race . をすべて通してください。条件:
    • (a) readFrame は、ヘッダも本文も必要なバイト数が揃うまで読むこと
    • (b) Message-Length が上限(4 MiB)を超えていたら、本文用のメモリを確保する前に RESOURCE_EXHAUSTED を返すこと
    • (c) 失敗はメッセージを1つも送らない Trailers-Only で返し、成功は Response-Headers → メッセージ → trailer の順で返すこと
  2. 予測してから確かめる: 本物の grpc-go v1.84.0 クライアントで、次の5つの「壊れたサーバー」に Check を送ったとき、クライアントが受け取るステータスコードを予測してください(答えは模範解答に実測で載せています)。
    • B1: 本文を書いた後で w.Header().Set("Grpc-Status", "0") した。Trailer ヘッダでの宣言はしていない
    • B2: 途中の L7 プロキシ(ロードバランサ)が HTTP 502 と HTML を返した
    • B3: ルーティングの設定漏れで、HTTP 404 と text/plain が返った
    • B4: 「NOT_FOUND だから」と HTTP 404 を返し、content-type: application/grpc と grpc-status: 5 も付けた
    • B5: http.Server の Protocols を設定し忘れた(平文では HTTP/1.1 しか話さない)
  3. 説明する: TODO(2) を io.ReadFull ではなく r.Read(msg) を1回だけ呼ぶ形で書くと、TestReadFrameOneByteAtATime は失敗します。ところが実際の HTTP/2 越しに送ると、サービス名が 1,000 バイトや 16,000 バイトなら正しく動き、100,000 バイトで壊れます(実測)。なぜサイズで結果が変わるのか、そして「小さい値でしか試さないテスト」がなぜこのバグを見逃すのかを説明してください。
  4. (任意)本物で確かめる: 模範解答の末尾にある interop/main.go で、grpc-go の生成済みクライアント(grpc_health_v1)からあなたのサーバーを呼んでください。google.golang.org/grpc のダウンロードが必要です。

💡 ヒント(クリックで開く)

ヒント1 — 方向性

仕様の ABNF をそのまま関数にします。Length-Prefixed-Message は「1 バイト + 4 バイト + 本文」、Response は「ヘッダ → 本文 → trailer」か「Trailers-Only」のどちらかです。net/http で trailer を送るには、ヘッダを書く前に「後で trailer を送る」と宣言する必要があります。

ヒント2 — アプローチ
  • 固定長の整数は encoding/binary の binary.BigEndian.PutUint32 / Uint32
  • 「揃うまで読む」は io.ReadFull。io.Reader.Read は、要求より少ないバイト数を返してもよいという契約です
  • trailer は w.Header().Add("Trailer", "Grpc-Status") で宣言してから WriteHeader、本文を書いた後に w.Header().Set("Grpc-Status", "0")。go doc net/http.ResponseWriter の例を参照
  • Trailers-Only は「grpc-status をヘッダに入れて WriteHeader(200) し、本文を書かずに return」で作れます
ヒント3 — コード骨格
func readFrame(r io.Reader, max int) ([]byte, error) {
	var hdr [5]byte
	if _, err := io.ReadFull(r, hdr[:]); err != nil { /* EOF なら「メッセージが無い」 */ }
	switch hdr[0] { /* 0: OK / 1: 圧縮は受け付けていない / それ以外: 不正 */ }
	n := binary.BigEndian.Uint32(hdr[1:])
	if int64(n) > int64(max) { /* make より前 */ }
	msg := make([]byte, n)
	// io.ReadFull で本文
}

✅ 模範解答

模範解答を表示する(まず TODO を埋めて go test を通してから)

1. 実装

main.go(完成版。go run . で 127.0.0.1:50051 に h2c サーバーが立つ)

// 標準ライブラリだけで grpc.health.v1.Health/Check を話すサーバー。
// 実行: go run .   (127.0.0.1:50051 で h2c = 平文 HTTP/2 を待ち受ける)
package main

import (
	"encoding/binary"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"strconv"
	"strings"
)

// ---- gRPC ステータス(番号は grpc.io/docs/guides/status-codes と同じ) ----

const (
	codeOK                = 0
	codeNotFound          = 5
	codeResourceExhausted = 8
	codeUnimplemented     = 12
	codeInternal          = 13
)

type rpcError struct {
	code int
	msg  string
}

func (e *rpcError) Error() string { return fmt.Sprintf("code=%d: %s", e.code, e.msg) }

// ---- 1. Length-Prefixed-Message(PROTOCOL-HTTP2.md) ----
// Compressed-Flag(1 byte) + Message-Length(4 byte, big endian) + Message

const maxRecvMsgSize = 4 << 20 // grpc-go の defaultServerMaxReceiveMessageSize と同じ 4 MiB

func writeFrame(w io.Writer, msg []byte) error {
	var hdr [5]byte // hdr[0] = 0: 圧縮なし
	binary.BigEndian.PutUint32(hdr[1:], uint32(len(msg)))
	if _, err := w.Write(hdr[:]); err != nil {
		return err
	}
	_, err := w.Write(msg)
	return err
}

func readFrame(r io.Reader, max int) ([]byte, error) {
	var hdr [5]byte
	if _, err := io.ReadFull(r, hdr[:]); err != nil {
		if errors.Is(err, io.EOF) {
			return nil, &rpcError{codeInternal, "no request message"}
		}
		return nil, &rpcError{codeInternal, "truncated message header"}
	}
	switch hdr[0] {
	case 0:
	case 1: // grpc-encoding を1つも受け付けていないので、圧縮済みは読めない
		return nil, &rpcError{codeInternal, "grpc: compressed flag set with identity or empty encoding"}
	default:
		return nil, &rpcError{codeInternal, fmt.Sprintf("grpc: received unexpected payload format %d", hdr[0])}
	}
	n := binary.BigEndian.Uint32(hdr[1:])
	if int64(n) > int64(max) { // 確保する「前」に比べる
		return nil, &rpcError{codeResourceExhausted,
			fmt.Sprintf("grpc: received message larger than max (%d vs. %d)", n, max)}
	}
	msg := make([]byte, n)
	if _, err := io.ReadFull(r, msg); err != nil {
		return nil, &rpcError{codeInternal, "truncated message body"}
	}
	return msg, nil
}

// ---- 2. HealthCheckRequest / HealthCheckResponse の符号化(Day051〜053 の復習) ----
// message HealthCheckRequest  { string service = 1; }
// message HealthCheckResponse { ServingStatus status = 1; }  // SERVING=1, NOT_SERVING=2

func decodeHealthCheckRequest(b []byte) (service string, err error) {
	for len(b) > 0 {
		tag, n := binary.Uvarint(b) // PB の varint と同じ形式(LEB128)
		if n <= 0 {
			return "", errors.New("bad tag")
		}
		b = b[n:]
		field, wt := tag>>3, tag&7
		switch wt {
		case 0: // VARINT
			if _, n = binary.Uvarint(b); n <= 0 {
				return "", errors.New("bad varint")
			}
			b = b[n:]
		case 1: // I64
			if len(b) < 8 {
				return "", errors.New("short i64")
			}
			b = b[8:]
		case 2: // LEN
			l, n := binary.Uvarint(b)
			if n <= 0 || l > uint64(len(b)-n) {
				return "", errors.New("bad length")
			}
			if field == 1 {
				service = string(b[n : n+int(l)])
			} // それ以外は未知フィールドとして読み飛ばす(Day053)
			b = b[n+int(l):]
		case 5: // I32
			if len(b) < 4 {
				return "", errors.New("short i32")
			}
			b = b[4:]
		default:
			return "", fmt.Errorf("unsupported wire type %d", wt)
		}
	}
	return service, nil
}

func encodeHealthCheckResponse(status uint64) []byte {
	if status == 0 { // proto3: 既定値(UNKNOWN=0)は書かない → 0 バイトのメッセージ
		return nil
	}
	return binary.AppendUvarint([]byte{1<<3 | 0}, status)
}

// ---- 3. grpc-message の percent-encoding(0x20〜0x7E のうち '%' 以外はそのまま) ----

func encodeGrpcMessage(s string) string {
	var sb strings.Builder
	for i := 0; i < len(s); i++ {
		c := s[i]
		if c >= 0x20 && c <= 0x7E && c != '%' {
			sb.WriteByte(c)
		} else {
			fmt.Fprintf(&sb, "%%%02X", c)
		}
	}
	return sb.String()
}

// ---- 4. ハンドラ ----

type healthServer struct {
	statuses map[string]uint64 // "" はサーバー全体の状態(health-checking.md)
}

// Trailers-Only: メッセージを1つも送らず、ステータスを HEADERS(END_STREAM 付き)に載せる。
func writeTrailersOnly(w http.ResponseWriter, code int, msg string) {
	h := w.Header()
	h.Set("Content-Type", "application/grpc")
	h.Set("Grpc-Status", strconv.Itoa(code))
	if msg != "" {
		h.Set("Grpc-Message", encodeGrpcMessage(msg))
	}
	w.WriteHeader(http.StatusOK) // gRPC のエラーでも HTTP は 200
}

func (s *healthServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "gRPC requires POST", http.StatusMethodNotAllowed)
		return
	}
	if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") {
		http.Error(w, "not a gRPC request", http.StatusUnsupportedMediaType) // 仕様: SHOULD 415
		return
	}
	// ここから先は gRPC として答える: HTTP は必ず 200、成否は grpc-status で返す
	if r.URL.Path != "/grpc.health.v1.Health/Check" {
		writeTrailersOnly(w, codeUnimplemented, "unknown method "+r.URL.Path)
		return
	}
	req, err := readFrame(r.Body, maxRecvMsgSize)
	if err != nil {
		var re *rpcError
		errors.As(err, &re)
		writeTrailersOnly(w, re.code, re.msg)
		return
	}
	service, err := decodeHealthCheckRequest(req)
	if err != nil {
		writeTrailersOnly(w, codeInternal, "grpc: error unmarshalling request: "+err.Error())
		return
	}
	st, ok := s.statuses[service]
	if !ok {
		writeTrailersOnly(w, codeNotFound, "unknown service: "+service)
		return
	}

	// 通常の応答: Response-Headers → Length-Prefixed-Message → Trailers
	h := w.Header()
	h.Set("Content-Type", "application/grpc")
	h.Add("Trailer", "Grpc-Status") // 本文より前に「後で trailer を送る」と宣言する
	h.Add("Trailer", "Grpc-Message")
	w.WriteHeader(http.StatusOK)
	if err := writeFrame(w, encodeHealthCheckResponse(st)); err != nil {
		return // 相手が切断済み。trailer も届かない
	}
	h.Set("Grpc-Status", strconv.Itoa(codeOK)) // 本文を書いた「後」に値を入れる
}

func newServer(addr string, h http.Handler) *http.Server {
	var p http.Protocols
	p.SetUnencryptedHTTP2(true) // Go 1.24+: 平文 HTTP/2(prior knowledge)だけを受ける
	return &http.Server{Addr: addr, Handler: h, Protocols: &p}
}

func main() {
	hs := &healthServer{statuses: map[string]uint64{
		"":                              1, // SERVING
		"inventory.v1.InventoryService": 1, // SERVING
		"payment.v1.PaymentService":     2, // NOT_SERVING
		"search.v1.SearchService":       0, // UNKNOWN(応答は 0 バイトのメッセージになる)
	}}
	srv := newServer("127.0.0.1:50051", hs)
	log.Printf("listening on %s (h2c)", srv.Addr)
	log.Fatal(srv.ListenAndServe())
}

実行結果(実測。go1.27.1 darwin/arm64、go test -race -count=1 -v .):

--- PASS: TestCheck (0.01s)
--- PASS: TestReadFrameOneByteAtATime (0.00s)
--- PASS: TestReadFrameRejectsBeforeAllocating (0.00s)
--- PASS: TestUnknownFieldsAreSkipped (0.00s)
--- PASS: TestEncodeGrpcMessage (0.00s)
--- PASS: TestLargeMessageOverHTTP2 (0.24s)
PASS
ok  	day055	1.713s

条件 (b) のテストが本当にバグを捕まえるかも確かめました。readFrame で make([]byte, n) を上限チェックより前に移した版では、次のように失敗します(実測)。5 バイトのヘッダを受け取っただけで 64 MiB 確保しています。

--- FAIL: TestReadFrameRejectsBeforeAllocating (0.01s)
    main_test.go:132: allocated 67115784 bytes for a 5-byte header (err=code=8: grpc: received message larger than max (67108864 vs. 4194304))

2. 本物の grpc-go クライアントから呼んだ結果

go run . でサーバーを起動し、別ターミナルで interop/(末尾に掲載)を go run . した出力です(実測。grpc-go v1.84.0、go1.27.1 darwin/arm64)。

== 標準ライブラリ製サーバー(go run . で起動したもの)
  Check("") -> OK status=SERVING
  Check("inventory.v1.InventoryService") -> OK status=SERVING
  Check("payment.v1.PaymentService") -> OK status=NOT_SERVING
  Check("search.v1.SearchService") -> OK status=UNKNOWN
  Check("在庫") -> code=NotFound msg="unknown service: 在庫"
  Check("<5242880 bytes>") -> code=ResourceExhausted msg="grpc: received message larger than max (5242885 vs. 4194304)"
  Watch("") -> code=Unimplemented msg="unknown method /grpc.health.v1.Health/Watch"
== 壊れたサーバー
  B1 trailer 未宣言               -> code=Internal msg="server closed the stream without sending trailers"
  B2 プロキシの 502 HTML            -> code=Unavailable msg="unexpected HTTP status code received from server: 502 (Bad Gateway); transport: received unexpected content-type \"text/html\"\ndata: \"<html><body>502 Bad Gateway</body></html>\""
  B3 404 text/plain            -> code=Unimplemented msg="unexpected HTTP status code received from server: 404 (Not Found); transport: received unexpected content-type \"text/plain; charset=utf-8\"\ndata: \"404 page not found\\n\""
  B4 HTTP 404 + grpc-status 5  -> code=NotFound msg=""
  B5 HTTP/1.1 のみ               -> code=Unavailable msg="connection error: desc = \"error reading server preface: http2: failed reading the frame payload: http2: frame too large, note that the frame header looked like an HTTP/1.1 header\""

正常系: 生成済みの healthpb.NewHealthClient(conn).Check(...) が、標準ライブラリだけのサーバーと通信できました。在庫 の NOT_FOUND は、サーバーが %E5%9C%A8%E5%BA%AB に percent-encoding したものをクライアントが元の日本語に戻しています。5 MiB のサービス名は、PB の中身 5,242,880 バイト+タグ 1 バイト+長さ varint 4 バイト= 5,242,885 バイト(導出。出力の数値と一致)で、上限 4,194,304 を超えたため RESOURCE_EXHAUSTED になりました。

壊れたサーバー(問題2の答え)

ケースgrpc-go が返したコード何が起きたか
B1 trailer 未宣言INTERNAL宣言の無いヘッダは WriteHeader 後に Set しても送られない。クライアントは本文を受け取ったのに成否が分からず、server closed the stream without sending trailers
B2 プロキシの 502 HTMLUNAVAILABLEcontent-type が gRPC でないので「gRPC 以外の応答」として扱い、HTTP ステータスから写像。本文の HTML も message に入る
B3 404 text/plainUNIMPLEMENTED同上。grpc-go の HTTPStatusConvTab で 404 → Unimplemented
B4 HTTP 404 + grpc-status 5NOT_FOUND(message は空)content-type: application/grpc があるので gRPC の応答とみなし、HTTP 404 は無視して grpc-status を使った。grpc-go では偶然動くが、仕様上 HTTP は 200 にすべきで、間の中継機器が 404 を見てエラーページに差し替えると B3 になる
B5 HTTP/1.1 のみUNAVAILABLE接続の最初で失敗。http2: frame too large, note that the frame header looked like an HTTP/1.1 header(HTTP/1.1 の応答を HTTP/2 のフレームとして読んでしまった)

写像表の出どころは grpc-go v1.84.0 の internal/transport/http_util.go の HTTPStatusConvTab です(400→Internal、401→Unauthenticated、403→PermissionDenied、404→Unimplemented、429・502・503・504→Unavailable。表に無いものは Unknown)。B2 のようにプロキシが返した 502 は UNAVAILABLE になり、Day 054 の既定のリトライ設定でリトライされます。

3. r.Read 1回のバグがサイズで出たり出なかったりする理由

TODO(2) の本文読み込みを r.Read(msg) 1回にした版で、実際の HTTP/2 越しに送った結果です(実測)。

サービス名の長さ1回目の Read で読めたバイト数結果
1,000全部NOT_FOUND(正しい)
16,000全部NOT_FOUND(正しい)
100,00032,763 / 100,004INTERNAL truncated message body
1,000,000114,683 / 1,000,004INTERNAL truncated message body

io.Reader.Read の契約は「最大 len(p) バイトを読み、その時点で手元にある分だけ返してよい」です。HTTP/2 のリクエスト本文は DATA フレーム(既定の最大 16,384 バイト)に分かれて届き、さらにフロー制御の窓の分だけしか先に送られません。小さいメッセージは、ハンドラが Read を呼ぶ時点で全部届いているので1回で読めてしまいます。大きいメッセージは、届いている分だけが返ります(100,000 バイトで 32,763 バイトなのはこのため。正確な値は送受信のタイミング次第で、固定ではありません)。

テストが小さい値でしか試していないと、開発中は必ず通り、本番で大きな要求が来たときだけ壊れるバグになります。iotest.OneByteReader で「1 バイトずつしか返さない Reader」を使うテストは、この「少なく返してよい」という契約の最悪ケースを、ネットワーク無しで毎回再現できます。

4. interop/main.go(任意。grpc-go v1.84.0 が必要)

interop/go.mod は require google.golang.org/grpc v1.84.0 を書いて go mod tidy で作ります(grpc-go v1.84.0 は go 1.25.0 を要求するため、go mod tidy で go 行が 1.25.0 に上がります。実測)。

// 本物の grpc-go クライアント(生成済みの grpc_health_v1)で、標準ライブラリ製サーバーを呼ぶ。
// 前提: 別ターミナルで親ディレクトリの `go run .` を起動しておく(127.0.0.1:50051)。
// 実行: go run .
package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"strings"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	healthpb "google.golang.org/grpc/health/grpc_health_v1"
	"google.golang.org/grpc/status"
)

func check(addr, service string) string {
	conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		return "dial: " + err.Error()
	}
	defer conn.Close()
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	resp, err := healthpb.NewHealthClient(conn).Check(ctx, &healthpb.HealthCheckRequest{Service: service})
	if err != nil {
		s := status.Convert(err)
		return fmt.Sprintf("code=%s msg=%q", s.Code(), s.Message())
	}
	return "OK status=" + resp.GetStatus().String()
}

func watch(addr string) string {
	conn, _ := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
	defer conn.Close()
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	st, err := healthpb.NewHealthClient(conn).Watch(ctx, &healthpb.HealthCheckRequest{})
	if err == nil {
		_, err = st.Recv()
	}
	s := status.Convert(err)
	return fmt.Sprintf("code=%s msg=%q", s.Code(), s.Message())
}

// ---- 壊れたサーバー(よくある実装ミスと、途中の L7 プロキシの振る舞い) ----

func h2c(addr string, h http.HandlerFunc) {
	var p http.Protocols
	p.SetUnencryptedHTTP2(true)
	go (&http.Server{Addr: addr, Handler: h, Protocols: &p}).ListenAndServe()
}

var servingFrame = []byte{0, 0, 0, 0, 2, 0x08, 0x01} // HealthCheckResponse{status: SERVING}

func main() {
	const good = "127.0.0.1:50051"
	fmt.Println("== 標準ライブラリ製サーバー(go run . で起動したもの)")
	for _, svc := range []string{"", "inventory.v1.InventoryService", "payment.v1.PaymentService",
		"search.v1.SearchService", "在庫", strings.Repeat("x", 5<<20)} {
		name := svc
		if len(name) > 40 {
			name = fmt.Sprintf("<%d bytes>", len(svc))
		}
		fmt.Printf("  Check(%q) -> %s\n", name, check(good, svc))
	}
	fmt.Printf("  Watch(\"\") -> %s\n", watch(good))

	// B1: trailer を宣言せずに、本文の後で Grpc-Status を Set した
	h2c("127.0.0.1:50061", func(w http.ResponseWriter, r *http.Request) {
		io.Copy(io.Discard, r.Body)
		w.Header().Set("Content-Type", "application/grpc")
		w.WriteHeader(http.StatusOK)
		w.Write(servingFrame)
		w.Header().Set("Grpc-Status", "0") // 宣言が無いので捨てられる
	})
	// B2: 途中の L7 プロキシが 502 と HTML を返した
	h2c("127.0.0.1:50062", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/html")
		w.WriteHeader(http.StatusBadGateway)
		io.WriteString(w, "<html><body>502 Bad Gateway</body></html>")
	})
	// B3: ルーティングの設定漏れで 404
	h2c("127.0.0.1:50063", func(w http.ResponseWriter, r *http.Request) {
		http.NotFound(w, r)
	})
	// B4: gRPC のエラーを HTTP ステータスで返した(NOT_FOUND のつもりで 404 + application/grpc)
	h2c("127.0.0.1:50064", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/grpc")
		w.Header().Set("Grpc-Status", "5")
		w.WriteHeader(http.StatusNotFound)
	})
	// B5: Protocols を設定し忘れた(平文では HTTP/1.1 しか話さない)
	go (&http.Server{Addr: "127.0.0.1:50065", Handler: http.NotFoundHandler()}).ListenAndServe()
	time.Sleep(200 * time.Millisecond)

	fmt.Println("== 壊れたサーバー")
	for _, b := range []struct{ name, addr string }{
		{"B1 trailer 未宣言", "127.0.0.1:50061"},
		{"B2 プロキシの 502 HTML", "127.0.0.1:50062"},
		{"B3 404 text/plain", "127.0.0.1:50063"},
		{"B4 HTTP 404 + grpc-status 5", "127.0.0.1:50064"},
		{"B5 HTTP/1.1 のみ", "127.0.0.1:50065"},
	} {
		fmt.Printf("  %-28s -> %s\n", b.name, check(b.addr, ""))
	}
}

🪜 Step-by-Step 解説

Step 1: writeFrame — 固定長の枠を書く

5 バイトの配列を用意し、hdr[0] は 0(圧縮なし)、hdr[1:5] に binary.BigEndian.PutUint32 で長さを入れます。PB の中身は varint ですが、外側の枠は固定長です。受け手が最初の 5 バイトだけで次に読むべき量を確定できるので、Step 2 の「揃うまで読む」が書けます。

Step 2: readFrame — 検査の順番が防御になる

順番は「ヘッダを揃える → フラグを検査 → 長さを上限と比べる → 確保 → 本文を揃える」です。

  • 長さの検査を make より前に置く: Message-Length は相手が書いた値です。検査前に make([]byte, n) すると、5 バイト送るだけで相手はこちらに最大 4 GiB を確保させられます(上の実測では 64 MiB を名乗るヘッダで 67,115,784 バイト確保)。grpc-go も rpc_util.go の recvMsg で、長さを maxReceiveMessageSize と比べてから本文を読んでいます
  • エラー文言を grpc-go に揃える: grpc: received message larger than max (%d vs. %d) は grpc-go のソースと同じ文言です。運用者がログ検索で同じ文字列を使えます
  • 圧縮フラグ 1 は INTERNAL: このサーバーは grpc-encoding を何も受け付けていないので、圧縮済みのメッセージは読めません。grpc-go の checkRecvPayload も、grpc-encoding が空か identity なのにフラグが 1 なら Internal を返します

Step 3: trailer の宣言 — net/http の「先に言っておく」規則

net/http では、WriteHeader を呼んだ後に w.Header() へ入れた値は、Trailer ヘッダで事前に宣言した名前だけが trailer として送られます。宣言していない名前は黙って捨てられます(B1。コンパイルもテストの一部も通るので気づきにくい)。

h.Add("Trailer", "Grpc-Status") // WriteHeader の前
w.WriteHeader(http.StatusOK)
writeFrame(w, ...)
h.Set("Grpc-Status", "0")       // 本文の後

事前に名前が分からない trailer には http.TrailerPrefix("Trailer:" を付けたキー)も使えますが、go doc net/http.TrailerPrefix は「名前が事前に分かるなら通常の方式を推奨」としています。grpc-status は必ず送るので宣言方式にしました。

Step 4: Trailers-Only — エラーは HEADERS 1つで終える

メッセージを1つも送らない失敗は、grpc-status をヘッダに入れて WriteHeader(200) し、本文を書かずに return します。net/http の HTTP/2 サーバーは、本文の無い応答を END_STREAM 付きの HEADERS 1つで送るので、仕様の Trailers-Only → HTTP-Status Content-Type Trailers になります。テストの trailersOnly 列(resp.Header に Grpc-Status があるか)で、エラー系が全部この形であることを確認しています。

Step 5: 415 と 200 の線引き

content-type が application/grpc で始まらない要求には、仕様どおり HTTP 415 を返します("gRPC servers SHOULD respond with HTTP status of 415")。相手が gRPC クライアントではない可能性が高いので、HTTP の言葉で答えます。それより後(未知のメソッド・大きすぎるメッセージ・未登録サービス)は、相手が gRPC を話しているので HTTP は 200、成否は grpc-status で答えます。B4 のように 404 を混ぜると、grpc-go 以外の実装や中継機器が HTTP の失敗として扱う余地が生まれます。

🧭 設計思想・なぜこう書くのか

gRPC は「HTTP/2 の上の、薄い規約」にすぎない。 今日のサーバーは 218 行(テスト 161 行)で、grpc-go の生成済みクライアントと正常系・異常系の両方で通信できました。gRPC 固有の部分は「5 バイトの枠」「/サービス/メソッド のパス」「grpc-status の trailer」の3つだけで、残りは HTTP/2 そのものです。Go の標準ライブラリが HTTP/2 を持っているので、この薄さを外部モジュール無しで確かめられます。

io.Reader の小さな契約を守ることが、そのまま堅牢さになる。 io.ReadFull と「確保の前に検査」は、どちらも Go の標準的な書き方です。ネットワークの分割やフロー制御、悪意ある長さといった複雑な事情は、この2つの規則を守るだけで扱えます。

迷った点と採らなかった案

論点採った案採らなかった案と理由前提が変わったら
実装の土台標準ライブラリだけで自作(学習とデバッグのため)grpc-go(GitHub の star 23,074、2026-09-25 に gh api で取得)。本番で gRPC サーバーを書くならこちらが当然の選択で、フロー制御・keepalive・圧縮・ストリーミング・interceptor が揃っている。今日は「何がワイヤに載るか」を見るために使わなかった本番のサービスなら grpc-go を使う。今日の知識は、grpc-go のエラー文言やパケットキャプチャを読むときに使う
net/http と共存させたい場合(今日は扱わない)grpc.Server.ServeHTTP。grpc-go の doc comment に、Go の HTTP/2 実装を使う別経路で "Performance and features may vary"、かつ EXPERIMENTAL とある。connect-go(star 4,089、同日取得)は net/http の Handler として gRPC・gRPC-Web・Connect プロトコルを話せる既存の http.ServeMux に gRPC を相乗りさせたいなら connect-go を先に比較する
trailer の送り方Trailer ヘッダで事前宣言http.TrailerPrefix。go doc が「名前が事前に分かるなら通常方式を推奨」としているエラー時だけ付ける可変の trailer(grpc-status-details-bin など)が増えたら TrailerPrefix が楽になる
受信上限4 MiB(grpc-go の defaultServerMaxReceiveMessageSize と同じ)上限なし。Step 2 のとおり、5 バイトで大量確保させられる大きなデータを扱うなら上限を上げるより、ストリーミングで分割するかページングする
varint の復号encoding/binary.UvarintDay 051 の自作 decodeVarint。同じ LEB128 形式なので標準を使った方が短く、境界の検査(n <= 0)も揃っている—

🌐 他言語との比較

実装HTTP/2 の土台今日の内容との関係
Go(grpc-go)独自の HTTP/2 実装(internal/transport)。ServeHTTP 経由なら標準の net/http今日の実測はすべてこれ
Java(grpc-java)Netty か OkHttp枠と trailer の規約は同じ。B4 のような「HTTP 404 + grpc-status」をどう扱うかは未確認
Python(grpcio)C コア(C で書かれた共通実装)の HTTP/2同上
ブラウザ(gRPC-Web)ブラウザは HTTP/2 のフレームや trailer を JavaScript に見せないPROTOCOL-WEB.md(2026-09-25 確認)では、trailer を本文の中に入れ、枠の 1 バイト目の最上位ビットを 1(10000000b)にして区別する。今日の hdr[0] を拡張した形

Java・Python の行は各実装の構成として記載しています。今日は実行して確かめていません。

🏭 実務での使いどころ

  • Unavailable: unexpected HTTP status code received from server: 502 を見たら、まず間の機器を疑う: B2 の実測のとおり、この文言は「gRPC 以外の応答が返ってきた」ことを示します。サーバー側のログに該当リクエストが無ければ、ロードバランサやサイドカープロキシが HTTP/2 や trailer を扱えていない可能性が高い(ALB の target group を HTTP/1.1 のまま使っているなど)。received unexpected content-type "text/html" の後ろに続く本文は、誰が返したかの手がかりになります
  • ResourceExhausted: grpc: received message larger than max (N vs. 4194304): 一覧 API が件数の増加で 4 MiB を超えたときの典型的な文言です。受信側の MaxRecvMsgSize を上げる前に、ページングかストリーミングに分けられないかを検討します。受け手の上限は相手に大量確保させないための防御でもあります(Step 2)
  • ヘルスチェックの設計: Kubernetes の gRPC probe は今日と同じ grpc.health.v1.Health/Check を呼びます。NOT_SERVING は RPC としては成功(grpc-status 0)で、中身が status: NOT_SERVING です。RPC の失敗(UNAVAILABLE など)と「サービスが自分から止まっていると答えた」ことは区別されます
  • パケットキャプチャを読む: Wireshark などで h2c の通信を見ると、DATA の先頭 5 バイトが今日の枠です。00 00 00 00 02 08 01 は「圧縮なし・長さ 2・フィールド1=1(SERVING)」と読めます

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
r.Body.Read(buf) 1回で本文が揃う小さいメッセージで試すと揃うRead は少なく返してよい。io.ReadFull を使う。100,000 バイトで 32,763 バイトしか返らなかった(実測)
長さを読んだらすぐ make素直に書くとそうなる相手の申告値なので、上限と比べてから確保する
gRPC のエラーは HTTP ステータスで返すREST の感覚HTTP は 200、成否は grpc-status。HTTP 404 は grpc-go では UNIMPLEMENTED に写像される(B3)
WriteHeader 後に Header().Set すれば trailer になるnet/http の trailer の規則を知らないTrailer で事前宣言した名前だけが送られる。未宣言だと INTERNAL server closed the stream without sending trailers(B1)
NOT_SERVING はエラーで返す「不健康」=失敗だと考えるRPC は成功(grpc-status 0)で、応答の status が NOT_SERVING
grpc-message に日本語をそのまま入れる普通のヘッダだと思うpercent-encoding する。受け手(grpc-go)が元に戻す
proto3 の応答は必ず何バイトかある「メッセージを送った」なら中身があると思う既定値(UNKNOWN = 0)のフィールドは書かれないので、長さ 0 のメッセージになる(テストの search.v1.SearchService)

🚧 扱わなかった範囲

  • grpc-timeout ヘッダ: クライアントの期限(Day 049)は grpc-timeout ヘッダで届きますが、今日のサーバーは読んでいません。net/http の r.Context() はこのヘッダを知らないので、実物では値を解釈して context.WithTimeout する必要があります
  • TLS と ALPN: 今日は平文(h2c)だけです。TLS では ALPN で h2 を合意します
  • ストリーミング(Watch): UNIMPLEMENTED を返すだけにしました。サーバーストリーミングでは、DATA に枠を複数並べ、最後に trailer を送ります
  • 圧縮(grpc-encoding: gzip)とメタデータ(-bin で終わるヘッダの base64)
  • grpc-status-details-bin: Day 054 の ErrorInfo(Reason・Metadata)を載せる trailer です。google.rpc.Status を PB で符号化して base64 にします
  • HTTP/2 そのものの制御(フロー制御の窓の調整、GOAWAY、keepalive): net/http に任せています
  • grpc-go 以外のクライアントでの B1〜B5: 実測は grpc-go v1.84.0 だけです

🚀 次のステップ

  • 発展: grpc-timeout ヘッダ(例 1S、100m。単位は H/M/S/m/u/n)を解釈して context.WithTimeout を作り、期限切れなら DEADLINE_EXCEEDED を返すようにしてみましょう。interop 側で context.WithTimeout(ctx, 50*time.Millisecond) を渡し、ハンドラで time.Sleep させて確かめられます
  • 次回予告: gRPC/Protocol Buffers は今日で8日目です。自己評価欄が空のままなので、テーマは継続しています。Day 053〜055 のうち3つが「完全理解/おおむね理解」になれば、テーマ15「ミドルウェア設計パターン」(http.Handler のチェーン設計)へ進みます。今日の ServeHTTP の前半(POST と content-type の検査)は、そのままミドルウェアに切り出せる部分です

🎯 自己評価

自分の回答

気づき・メモ