📚 背景知識(読んでから問題へ)
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 バイト)
Compressed-Flag
1 byte
Message-Length = 31
4 byte big endian
tag
field1/LEN
len
29
"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。実測)。
- 実装する:
TODO(1)〜TODO(4)を埋め、go test -race .をすべて通してください。条件:- (a)
readFrameは、ヘッダも本文も必要なバイト数が揃うまで読むこと - (b)
Message-Lengthが上限(4 MiB)を超えていたら、本文用のメモリを確保する前に RESOURCE_EXHAUSTED を返すこと - (c) 失敗はメッセージを1つも送らない Trailers-Only で返し、成功は Response-Headers → メッセージ → trailer の順で返すこと
- (a)
- 予測してから確かめる: 本物の 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 しか話さない)
- B1: 本文を書いた後で
- 説明する:
TODO(2)をio.ReadFullではなくr.Read(msg)を1回だけ呼ぶ形で書くと、TestReadFrameOneByteAtATimeは失敗します。ところが実際の HTTP/2 越しに送ると、サービス名が 1,000 バイトや 16,000 バイトなら正しく動き、100,000 バイトで壊れます(実測)。なぜサイズで結果が変わるのか、そして「小さい値でしか試さないテスト」がなぜこのバグを見逃すのかを説明してください。 - (任意)本物で確かめる: 模範解答の末尾にある
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 HTML | UNAVAILABLE | content-type が gRPC でないので「gRPC 以外の応答」として扱い、HTTP ステータスから写像。本文の HTML も message に入る |
| B3 404 text/plain | UNIMPLEMENTED | 同上。grpc-go の HTTPStatusConvTab で 404 → Unimplemented |
| B4 HTTP 404 + grpc-status 5 | NOT_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,000 | 32,763 / 100,004 | INTERNAL truncated message body |
| 1,000,000 | 114,683 / 1,000,004 | INTERNAL 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.Uvarint | Day 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 の検査)は、そのままミドルウェアに切り出せる部分です