TT Lab
요금제

한국어

시작하기
블로그

블로그

HTTP/2 위의 gRPC: 머리말, 5바이트 접두, 트레일러의 상태 코드

상태: 초안 (2026-10-17), 일부 실측. 기준 판 grpc/grpc v1.84.0 doc/PROTOCOL-HTTP2.md·doc/statuscodes.md, grpc/grpc-go v1.83.2. 상태 코드 분포는 2026-10-17 03시대(KST) 작은 클러스터에서 Prometheus 와 제어 노드 A 의 etcd /metrics 를 읽기만 해서 얻었습니다. 실제 프레임을 캡처해 보지는 않았습니다(미실측, 필요한 것: grpcio 또는 grpc-go 예제 서버, Wireshark 등 캡처 도구). 그래서 3·4·5절의 바이트는 명세와 코드로 구성하고 계산한 값입니다.

목차

1. 직관: 봉투, 편지, 결과 도장

등기 우편을 떠올리면 쉽습니다. 봉투(요청 머리말)에는 받는 부서와 업무 이름, 처리 기한이 적혀 있습니다. 안에는 편지 여러 장(메시지)이 들어 있고, 장마다 「이 장은 몇 바이트」라는 띠지가 붙습니다. 처리 결과는 맨 마지막에 찍히는 결과 도장(트레일러)입니다. 우체국(HTTP)은 배달에 성공했다고 200 을 돌려주지만, 업무가 성공했는지는 도장을 봐야 압니다.

이 구조 덕분에 gRPC 는 응답을 한 장씩 흘려보내다가(스트리밍) 마지막에 상태를 붙일 수 있습니다. HTTP/1.1 의 상태 줄은 맨 앞에 와야 해서 이게 어렵습니다.

2. 그림

단항 호출 한 번이 HTTP/2 프레임으로 클라이언트 서버 HEADERS stream=1 :path /tt.v1.Scoreboard/Hit content-type: application/grpc · te: trailers DATA END_STREAM 00 00000015 + Rally 21바이트 HEADERS :status 200 · content-type DATA 00 + 길이 4바이트 + 응답 메시지 HEADERS END_STREAM 트레일러 grpc-status: 0 오류가 바로 나면 트레일러만 보낸다(Trailers-Only): :status 200 · grpc-status: 5 · grpc-message: %EB%9E%A0… HTTP 상태는 성공(200)이어도 호출의 성패는 grpc-status 가 정한다.
그림: 단항 호출 한 번의 HTTP/2 프레임 순서와 Trailers-Only 응답

3. 요청 머리말

명세의 Call-Definition 을 정리하면 다음과 같습니다(PROTOCOL-HTTP2.md Requests 절).

머리말 값 왜
:method POST 늘 POST
:path /tt.v1.Scoreboard/Hit 메서드 식별. 대소문자 구분
:authority 대상 이름 가상 호스트
te trailers 트레일러를 못 다루는 프록시를 걸러내려는 표시
content-type application/grpc(+proto·json…) 아니면 서버가 415 로 거절해야 함
grpc-timeout 8자리 이하 정수 + 단위(H·M·S·m·u·n) 남은 시간. 없으면 무한
grpc-encoding, grpc-accept-encoding gzip 등 메시지 압축 방식
사용자 메타데이터 소문자 이름. -bin 으로 끝나면 base64 grpc- 로 시작하는 이름은 예약

grpc-go 에서는 http2Client.createHeaderFields 가 이 목록을 만듭니다.

headerFields = append(headerFields, hpack.HeaderField{Name: ":method", Value: "POST"})
headerFields = append(headerFields, hpack.HeaderField{Name: ":scheme", Value: t.scheme})
headerFields = append(headerFields, hpack.HeaderField{Name: ":path", Value: callHdr.Method})
headerFields = append(headerFields, hpack.HeaderField{Name: ":authority", Value: callHdr.Host})
headerFields = append(headerFields, hpack.HeaderField{Name: "content-type", Value: grpcutil.ContentType(callHdr.ContentSubtype)})
headerFields = append(headerFields, hpack.HeaderField{Name: "user-agent", Value: t.userAgent})
headerFields = append(headerFields, hpack.HeaderField{Name: "te", Value: "trailers"})
...
if dl, ok := ctx.Deadline(); ok {
	timeout := time.Until(dl)
	if timeout <= 0 {
		return nil, status.Error(codes.DeadlineExceeded, context.DeadlineExceeded.Error())
	}
	headerFields = append(headerFields, hpack.HeaderField{Name: "grpc-timeout", Value: grpcutil.EncodeDuration(timeout)})
}

출처: grpc/grpc-go v1.83.2 internal/transport/http2_client.go 578~614행 발췌, Apache-2.0

3.1 grpc-timeout 의 단위 고르기

EncodeDuration 은 나노초부터 시작해 8자리 안에 들어가는 가장 작은 단위를 고르고 올림합니다. 같은 규칙을 파이썬으로 옮겨 계산한 값입니다(pb_by_hand.py).

남은 시간 grpc-timeout
50ms 50000000n
4.999987초 4999987u
2분(kubelet CRI 기본) 120000m
4분(RunPodSandbox) 240000m
1시간 3600000m

5초 deadline 을 걸어도 머리말을 만드는 순간까지 몇 마이크로초가 흘렀으므로 5S 가 아니라 4999987u 처럼 나갑니다. 서버는 decodeTimeout 으로 되읽어 자기 쪽 컨텍스트에 timeout 을 겁니다. 값이 깨져 있으면 INTERNAL 로 거절합니다(http2_server.go:453 부근).

4. 메시지 접두 5바이트

DATA 프레임에 실리는 것은 Length-Prefixed-Message 의 연속입니다. 압축 표시 1바이트(0 또는 1) + 길이 4바이트(빅엔디언) + 메시지.

무엇 바이트(계산)
2편의 Rally 21바이트 00 00 00 00 15 + 08 96 01 … 28 03 = 26바이트
헬스 체크 요청(빈 메시지) 00 00 00 00 00
헬스 체크 응답 SERVING 00 00 00 00 02 08 01

grpc-go 의 수신 쪽은 이렇습니다.

func (p *parser) recvMsg(maxReceiveMessageSize int) (payloadFormat, mem.BufferSlice, error) {
	err := p.r.ReadMessageHeader(p.header[:])
	if err != nil {
		return 0, nil, err
	}
	pf := payloadFormat(p.header[0])
	length := binary.BigEndian.Uint32(p.header[1:])
	...
	if int(length) > maxReceiveMessageSize {
		return 0, nil, status.Errorf(codes.ResourceExhausted, "grpc: received message larger than max (%d vs. %d)", length, maxReceiveMessageSize)
	}
	data, err := p.r.Read(int(length))

출처: grpc/grpc-go v1.83.2 rpc_util.go 785~800행 발췌, Apache-2.0

5. 응답과 트레일러

응답은 :status 200 과 content-type 이 든 머리말, 메시지들, 그리고 트레일러입니다. 트레일러에는 grpc-status(10진수)와 선택적으로 grpc-message, grpc-status-details-bin(base64 로 담은 google.rpc.Status)이 들어갑니다. 성공(0)이어도 상태는 반드시 트레일러에 실립니다.

grpc-message 는 UTF-8 을 퍼센트 인코딩합니다. 공백~물결표(% 제외)만 그대로 두고 나머지는 %XX 로 바꿉니다. grpc-go 의 encodeGrpcMessage 규칙을 옮겨 계산한 값입니다.

메시지 와이어의 grpc-message
rally not found rally not found
랠리 없음 %EB%9E%A0%EB%A6%AC %EC%97%86%EC%9D%8C
100% done 100%25 done

HTTP 상태가 200 이 아닌 응답(중간 프록시가 만든 502 같은 것)을 받으면 클라이언트가 상태를 지어냅니다. grpc-go 의 HTTPStatusConvTab 은 400→INTERNAL, 401→UNAUTHENTICATED, 403→PERMISSION_DENIED, 404→UNIMPLEMENTED, 429·502·503·504→UNAVAILABLE 입니다. 프록시 뒤에서 이유 없는 UNAVAILABLE 이 보이면 실제로는 HTTP 503 이었을 수 있다는 뜻입니다. 그 밖의 HTTP 오류는 UNKNOWN 이 됩니다.

반대로 스트림이 RST_STREAM 으로 끊기면 HTTP/2 오류 코드를 상태로 바꿉니다. REFUSED_STREAM→UNAVAILABLE(처리 전이라 재시도 가능), CANCEL→CANCELLED, ENHANCE_YOUR_CALM→RESOURCE_EXHAUSTED(4편의 keepalive 핑 위반), 나머지 대부분 INTERNAL 입니다 (http2ErrConvTab, 명세 Errors 절).

6. 상태 코드 17개

statuscodes.md 를 운영 관점으로 요약했습니다. 「라이브러리」 칸은 gRPC 라이브러리가 스스로 만들 수 있는 경우입니다.

번호 이름 뜻 라이브러리가 만드는 예
0 OK 성공 —
1 CANCELLED 보통 호출한 쪽이 취소 클라이언트 취소(양쪽)
2 UNKNOWN 알 수 없는 오류 서버 핸들러가 상태 없는 오류를 냄, 상태 해석 실패
3 INVALID_ARGUMENT 요청 자체가 잘못됨 문서상 없음(8편 참고)
4 DEADLINE_EXCEEDED 기한 초과. 서버에선 성공했을 수도 있음 양쪽
5 NOT_FOUND 대상 없음 없음
6 ALREADY_EXISTS 이미 있음 없음
7 PERMISSION_DENIED 권한 없음 —
8 RESOURCE_EXHAUSTED 할당량·자원 소진 메시지 크기 초과, 흐름 제어 한도
9 FAILED_PRECONDITION 상태가 맞지 않음, 고치기 전엔 재시도 말 것 없음
10 ABORTED 동시성 충돌, 더 위에서 재시도 없음
11 OUT_OF_RANGE 범위 밖(파일 끝 너머 등) 없음
12 UNIMPLEMENTED 메서드 없음·지원 안 함 서버에 메서드 없음, 미지원 압축
13 INTERNAL 불변식 깨짐 역직렬화 실패, 흐름 제어 위반
14 UNAVAILABLE 일시적. 백오프 후 재시도 서버 종료 중, 연결 끊김, keepalive 시간 초과
15 DATA_LOSS 복구 불가 손실 없음
16 UNAUTHENTICATED 인증 정보 없음·틀림 자격 증명 실패

문서는 INVALID_ARGUMENT·NOT_FOUND·ALREADY_EXISTS·FAILED_PRECONDITION·ABORTED·OUT_OF_RANGE·DATA_LOSS 는 라이브러리가 절대 만들지 않는다고 적습니다. 그래서 이 코드들을 보면 「앱이 의도해서 돌려준 것」이라고 읽을 수 있습니다. 다만 grpc-go 는 content-type 이 잘못된 요청에 HTTP 415 와 함께 grpc-status: 3(INVALID_ARGUMENT)을 실어 보냅니다(http2_server.go:504). 문서와 어긋나는 지점이라 8편의 기여 후보에 적었습니다.

7. 실측: 같은 호출을 양쪽에서 세면

제어 노드 A 에서 kube-apiserver 는 같은 노드의 etcd(127.0.0.1:2379)에만 붙습니다. 두 프로세스는 같은 초(2026-08-19 07:42:56 UTC)에 기동했으므로 누적 카운터가 같은 기간을 셉니다. 클라이언트 쪽은 apiserver 의 grpc_client_handled_total(Prometheus), 서버 쪽은 etcd 의 grpc_server_handled_total(노드에서 curl -s http://127.0.0.1:2381/metrics, 읽기만)입니다. OK 가 아닌 것만 모았습니다.

sum by (instance, grpc_code, grpc_method) (grpc_client_handled_total{job="apiserver", grpc_code!="OK"})
메서드 코드 apiserver(클라이언트) etcd(서버)
Watch(양방향) CANCELLED 6,982 6,980
Range OUT_OF_RANGE 94 94
Range UNAVAILABLE 1 1
Range CANCELLED 7 3
Range DEADLINE_EXCEEDED 1 0
Txn UNAVAILABLE 9 14
Txn CANCELLED 12 0
Txn DEADLINE_EXCEEDED 4 0
Txn UNKNOWN 0 4

같은 기간 OK 는 서버 기준 Range 8,333만, Txn 3,867만 번이라 오류 비율은 아주 작습니다. 읽을 거리는 비율보다 이름이 엇갈리는 방식입니다.

한 줄로 줄이면 상태 코드는 「누가 판정했는가」에 따라 다르게 남는다입니다. 경보는 호출자 쪽 지표로, 원인 분석은 양쪽을 나란히 놓고 합니다.

덤으로 하나 더. Cilium 에이전트 안에서 hubble list nodes 를 실행하면 rpc error: code = Unimplemented desc = GetNodes not implemented 가 나옵니다. 노드 하나의 Hubble 서버는 GetNodes 를 일부러 구현하지 않고(local_observer.go:250), 그 질문은 여러 노드를 아는 hubble-relay 에 하라는 뜻입니다. UNIMPLEMENTED 가 「메서드가 아예 없다」만이 아니라 「여기서는 지원하지 않는다」에도 쓰이는 예입니다.

8. 한계

9. 작은 클러스터와의 연결

apiserver→etcd 경보를 만든다면 grpc_code!="OK" 를 그대로 쓰지 말고 Watch 의 CANCELLED 와 OUT_OF_RANGE 를 따로 봐야 합니다. 앞의 것은 정상이고, 뒤의 것은 watch 가 너무 오래된 리비전을 요구했다는 신호라 압축 주기·watch 캐시를 볼 이유가 됩니다.

티티 한마디. 우체국이 「배달 완료(200)」라고 해도 안심하면 안 돼요. 편지 맨 끝 도장(grpc-status)을 꼭 확인하세요!

참고

모든 자료는 2026-10-17 에 열람했습니다.

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다