상태: 초안 (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. 직관: 봉투, 편지, 결과 도장
- 2. 그림
- 3. 요청 머리말
- 4. 메시지 접두 5바이트
- 5. 응답과 트레일러
- 6. 상태 코드 17개
- 7. 실측: 같은 호출을 양쪽에서 세면
- 8. 한계
- 9. 작은 클러스터와의 연결
- 참고
1. 직관: 봉투, 편지, 결과 도장
등기 우편을 떠올리면 쉽습니다. 봉투(요청 머리말)에는 받는 부서와 업무 이름, 처리 기한이 적혀 있습니다. 안에는 편지 여러 장(메시지)이 들어 있고, 장마다 「이 장은 몇 바이트」라는 띠지가 붙습니다. 처리 결과는 맨 마지막에 찍히는 결과 도장(트레일러)입니다. 우체국(HTTP)은 배달에 성공했다고 200 을 돌려주지만, 업무가 성공했는지는 도장을 봐야 압니다.
이 구조 덕분에 gRPC 는 응답을 한 장씩 흘려보내다가(스트리밍) 마지막에 상태를 붙일 수 있습니다. HTTP/1.1 의 상태 줄은 맨 앞에 와야 해서 이게 어렵습니다.
2. 그림
- 스트림 번호(stream=1)가 호출 하나입니다. 한 연결에서 클라이언트가 여는 스트림은 홀수 번호로 늘어납니다.
- 요청의 마지막 DATA 에 END_STREAM 이 붙으면 「더 보낼 것 없음」입니다. 응답은 HEADERS → DATA → HEADERS(트레일러, END_STREAM) 순서입니다.
- 오류를 바로 낼 때는 HEADERS 한 번(Trailers-Only)으로 끝납니다. 4편의 재시도는 이 차이를 보고 판단합니다.
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
- deadline 은 절대 시각이 아니라 남은 시간으로 보냅니다. 기계마다 시계가 달라도 동작하게 하려는 선택입니다. 이미 지났으면 보내지도 않고 DEADLINE_EXCEEDED 로 끝냅니다.
- 재시도한 호출이면
grpc-previous-rpc-attempts가 더 붙습니다(4편).
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
- 메시지 경계와 DATA 프레임 경계는 관계가 없습니다. 큰 메시지는 여러 DATA 에 나뉘고, 작은 메시지 여럿이 DATA 하나에 들어갈 수 있습니다(명세의 Data Frames). 그래서 수신 쪽은 프레임이 아니라 5바이트 머리를 기준으로 잘라 읽습니다.
- 길이를 먼저 읽고 상한부터 검사합니다. 기본 수신 상한은 클라이언트·서버 모두 4MiB 입니다(
clientconn.go:139,server.go:61). 넘으면 RESOURCE_EXHAUSTED. 보내는 상한은 기본 무제한(int32 최대)입니다. - 압축 표시 1 은
grpc-encoding에 적힌 방식으로 압축했다는 뜻입니다. 압축 문맥은 메시지마다 새로 시작합니다. 보내는 쪽msgHeader가 이 5바이트를 씁니다.
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만 번이라 오류 비율은 아주 작습니다. 읽을 거리는 비율보다 이름이 엇갈리는 방식입니다.
- OUT_OF_RANGE 94 = 94. 라이브러리가 만들지 않는 코드라 양쪽이 정확히 같습니다. etcd 가 「이미 압축(compaction)된 리비전을 읽으려 했다」는
뜻으로 직접 돌려준 것입니다. etcd 는 「압축된 리비전」과 「아직 없는 미래 리비전」 두 오류를 OUT_OF_RANGE 로 정의합니다(
api/v3rpc/rpctypes/error.go:32). 94건이 둘 중 어느 쪽인지는 지표로 구분되지 않습니다(미확인). - Watch 의 CANCELLED 는 정상 종료입니다. apiserver 가 watch 를 다시 걸려고 스트림을 닫을 때마다 하나씩 올라갑니다. 지표만 보고 오류로 경보를 걸면 안 되는 대표 사례입니다.
- CANCELLED·DEADLINE_EXCEEDED 는 클라이언트에만 있습니다. 이 둘은 각자 자기 쪽 시계와 컨텍스트로 판정합니다. 클라이언트가 기한이 지나 포기한 시점에 서버는 아직 처리 중이거나 이미 끝냈을 수 있습니다. 서버의 Txn UNKNOWN 4 와 클라이언트의 Txn DEADLINE_EXCEEDED 4 가 같은 사건일 가능성이 있지만 확인하지 못했습니다(미확인).
- Txn UNAVAILABLE 은 서버 14, 클라이언트 9 입니다. etcd 클라이언트(clientv3)는 연결이 아직 없을 때의 UNAVAILABLE 처럼 안전한 경우만
재시도하는 정책을 갖고 있어(
client/v3/retry.go), 그 재시도 뒤의 최종 결과만 클라이언트 지표에 남았을 수 있습니다(추정, 인터셉터 순서 미확인).
한 줄로 줄이면 상태 코드는 「누가 판정했는가」에 따라 다르게 남는다입니다. 경보는 호출자 쪽 지표로, 원인 분석은 양쪽을 나란히 놓고 합니다.
덤으로 하나 더. Cilium 에이전트 안에서 hubble list nodes 를 실행하면 rpc error: code = Unimplemented desc = GetNodes not implemented 가 나옵니다.
노드 하나의 Hubble 서버는 GetNodes 를 일부러 구현하지 않고(local_observer.go:250),
그 질문은 여러 노드를 아는 hubble-relay 에 하라는 뜻입니다. UNIMPLEMENTED 가 「메서드가 아예 없다」만이 아니라 「여기서는 지원하지 않는다」에도 쓰이는 예입니다.
8. 한계
- HPACK(머리말 압축)의 실제 바이트, SETTINGS·WINDOW_UPDATE·PING 프레임의 흐름은 캡처하지 않았습니다(미실측).
grpc-status-details-bin으로 실어 보내는 풍부한 오류 정보(google.rpc.ErrorInfo·RetryInfo등)는 6편의 OTLP 재시도에서만 짧게 봅니다.- 7절 표는 누적 카운터 한 번 읽기입니다. 사건마다 시각을 맞춰 대조한 것이 아니므로 「같은 사건」 짝짓기는 추정입니다.
9. 작은 클러스터와의 연결
apiserver→etcd 경보를 만든다면 grpc_code!="OK" 를 그대로 쓰지 말고 Watch 의 CANCELLED 와 OUT_OF_RANGE 를 따로 봐야 합니다. 앞의 것은 정상이고,
뒤의 것은 watch 가 너무 오래된 리비전을 요구했다는 신호라 압축 주기·watch 캐시를 볼 이유가 됩니다.
티티 한마디. 우체국이 「배달 완료(200)」라고 해도 안심하면 안 돼요. 편지 맨 끝 도장(grpc-status)을 꼭 확인하세요!
참고
모든 자료는 2026-10-17 에 열람했습니다.
- grpc/grpc v1.84.0:
doc/PROTOCOL-HTTP2.md,doc/statuscodes.md - grpc/grpc-go v1.83.2:
internal/transport/http2_client.go,internal/transport/http2_server.go,internal/transport/http_util.go,internal/grpcutil/encode_duration.go,rpc_util.go - etcd v3.6.5:
api/v3rpc/rpctypes/error.go,client/v3/retry.go,api/etcdserverpb/rpc.proto - cilium v1.20.1:
pkg/hubble/observer/local_observer.go - 계산 코드: 작업 디렉터리
measure/grpc/pb_by_hand.py - 인용 코드: grpc-go v1.83.2, Apache License 2.0.