I renumbered a field and the old client silently read the wrong value
Read and write the bytes by hand
한국어 원문으로 표시합니다.
목표
protoc 도 protobuf 패키지도 없는 파드에서, 파이썬 표준 라이브러리만으로 protobuf 와이어 포맷의 인코더·디코더를 직접 만듭니다. varint → 태그 → 부호 있는 정수 → 필드 → 메시지 순으로 쌓아 올리고, 마지막에는 서브메시지와 packed repeated 를 해독합니다. 만든 도구는 다음 실습에서도 씁니다.
왜 중요한가
gRPC 장애의 절반은 "바이트에 이름이 없다" 는 사실을 몰라서 생깁니다. 와이어에는
필드 번호와 와이어 타입만 실리고, 이름과 선언 타입은 읽는 쪽 .proto 가
붙입니다. 이걸 손으로 한 번 인코딩해 보면 왜 번호를 바꾸면 안 되는지, 왜
int32 음수가 10바이트나 되는지, 왜 옛 클라이언트가 새 필드를 오류 없이
건너뛰는지가 계산으로 보입니다. 라이브러리가 감춰 둔 것을 한 번 열어 본 사람은
스키마 리뷰에서 다른 것을 봅니다.
단계
/root/grpc/pb.py를 만들고encode_varint(n) -> bytes와decode_varint(data, pos=0) -> (값, 다음 위치)를 구현하세요. 1, 150, 300, 16384 를 인코딩한 16진수를/root/grpc/01-varint.txt에값=16진수로 한 줄씩 적으세요.encode_tag(field, wire_type) -> bytes와decode_tag(data, pos=0) -> (번호, 와이어타입, 다음 위치)를 더하세요.1:02:23:216:0네 태그의 16진수를/root/grpc/02-tag.txt에번호:와이어타입=16진수로 적으세요.zigzag_encode(n) -> int와zigzag_decode(u) -> int를 더하고,int32 -1sint32 -1int32 -150sint32 -150을 실제로 인코딩해/root/grpc/03-signed.txt에타입 값 = 16진수 (N bytes)로 적으세요. int32 는 64비트 2의 보수를 varint 로, sint32 는 ZigZag 를 거친 값을 varint 로 보냅니다.encode_field(field, kind, value) -> bytes를 더하세요. kind 는int32 int64 uint32 uint64 sint32 sint64 bool string bytes입니다. 그 함수로id(1)=4242, qty(2)=17, note(3)="한글 메모"인 주문을 만드는/root/grpc/04-make.py를 쓰고 실행해/root/grpc/04-order.bin을 남기세요.decode_message(data) -> [(번호, 와이어타입, 원시값), ...]를 더하세요. VARINT 는 int, LEN 은 bytes, I32/I64 는 4·8바이트 bytes 그대로입니다./opt/app/grpc/order_v1.bin을 해독해/root/grpc/05-decode.txt에field=N wire=W value=V로 한 줄씩 적으세요(LEN 값은 16진수).encode_records(records) -> bytes(decode_message 의 역함수) 를 더하세요./opt/app/grpc/order_v2.bin을 해독해 레코드마다 v1 스키마(/opt/app/grpc/order_v1.proto)가 아는 번호인지/root/grpc/06-unknown.txt에field=N wire=W known또는unknown으로 적으세요./opt/app/grpc/nested.bin의 필드 6 은Customer서브메시지입니다. 그 페이로드를 다시 decode_message 로 해독해/root/grpc/07-nested.txt에customer.bytes=16진수customer.name=이름customer.tier=숫자세 줄을 적으세요.decode_packed_varints(raw) -> [int, ...]를 더하고/opt/app/grpc/packed.bin의 필드 7(packed repeated int32)을 풀어/root/grpc/08-packed.txt에tags=쉼표목록packed_record=16진수(필드 7 레코드 전체)expanded_record=16진수(원소마다 태그를 붙인 형태)세 줄을 적으세요.
참고
- 규칙의 원본은 Protocol Buffers 인코딩 문서입니다. 태그는
(field_number << 3) | wire_type, 와이어 타입은 0 VARINT · 1 I64 · 2 LEN · 5 I32. - 참조 구현
/opt/app/grpc/pbmini.py가 있습니다. 막히면 읽되, 이 실습의 채점기는 여러분의/root/grpc/pb.py를 불러와 지시문에 없는 무작위 값으로도 돌립니다. 답을 박아 두면 그 자리에서 떨어집니다. - 픽스처 바이트는
python3 -c "print(open('/opt/app/grpc/order_v1.bin','rb').read().hex())"로 볼 수 있습니다. - 흔한 실수:
decode_varint가pos인자를 무시하는 것, 문자열 길이를 글자 수로 세는 것(UTF-8 바이트 수여야 합니다), 음수int32를 32비트로 자르는 것(64비트입니다).
varint 를 만들고 읽는다
/root/grpc/pb.py 에 encode_varint(n) 과 decode_varint(data, pos=0) 를 구현하고, 1·150·300·16384 의 16진수를 /root/grpc/01-varint.txt 에 값=16진수 로 적으세요.
값을 7비트씩 잘라 낮은 자리부터 내보내고, 뒤에 더 있으면 그 바이트의 최상위 비트(0x80)를 세웁니다. 150 은 이진수로 10010110 → 아래 7비트 0010110 에 계속 표시를 붙여 0x96, 남은 1 이 0x01 — 그래서 96 01 입니다.
decode_varint 는 pos 부터 읽어서 (값, 다음 위치) 를 돌려줘야 합니다. 채점기가 pos=3 처럼 중간에서도 불러 봅니다.
검산: python3 -c "import sys; sys.path.insert(0,'/root/grpc'); import pb; print(pb.encode_varint(300).hex())" 가 ac02 여야 합니다.
태그에 번호와 와이어 타입을 담는다
encode_tag(field, wire_type) 와 decode_tag(data, pos=0) 를 /root/grpc/pb.py 에 더하고, 1:0 2:2 3:2 16:0 의 16진수를 /root/grpc/02-tag.txt 에 번호:와이어타입=16진수 로 적으세요.
태그는 그냥 varint 입니다 — (field << 3) | wire_type 을 encode_varint 에 넣으면 끝입니다. 읽을 때는 varint 를 읽은 뒤 아래 3비트가 와이어 타입, 나머지를 3비트 오른쪽으로 민 것이 번호입니다.
번호 1~15 는 태그가 한 바이트, 16 부터는 두 바이트가 됩니다. 자주 쓰는 필드에 작은 번호를 주라는 문서의 권고가 여기서 나옵니다.
(1 << 3) | 0 은 8 이라 08, (2 << 3) | 2 는 18 이라 12 입니다.
음수 int32 는 10바이트, sint32 는 한두 바이트
zigzag_encode(n) 과 zigzag_decode(u) 를 더하고, int32 -1 sint32 -1 int32 -150 sint32 -150 을 실제로 인코딩해 /root/grpc/03-signed.txt 에 타입 값 = 16진수 (N bytes) 로 적으세요.
int32 의 음수는 64비트 2의 보수를 unsigned 로 본 뒤 varint 로 보냅니다 — 파이썬에서는 n & 0xFFFFFFFFFFFFFFFF 를 encode_varint 에 넣으면 됩니다. 최상위 비트가 서 있으니 열 바이트를 다 씁니다.
sint32 는 ZigZag 를 먼저 거칩니다. 문서의 식은 (n << 1) ^ (n >> 31) 이고, 파이썬 정수는 산술 시프트라 이 식을 그대로 쓰면 -1 → 1, 1 → 2, -2 → 3 이 나옵니다. 되돌리는 식은 (u >> 1) ^ -(u & 1) 입니다.
줄 형식은 예를 들어 sint32 -1 = 01 (1 bytes) 입니다.
필드 하나를 레코드로 만든다
encode_field(field, kind, value) 를 더하세요(kind: int32 int64 uint32 uint64 sint32 sint64 bool string bytes). /root/grpc/04-make.py 에서 그 함수로 id(1)=4242, qty(2)=17, note(3)="한글 메모" 를 이어 붙여 /root/grpc/04-order.bin 으로 저장하세요.
레코드는 태그 + 값입니다. varint 계열은 encode_tag(f, 0) + encode_varint(값), string 은 encode_tag(f, 2) + encode_varint(len(raw)) + raw 인데 raw 는 UTF-8 로 인코딩한 bytes 이고 길이도 그 바이트 수입니다. "한글 메모" 는 5글자지만 13바이트입니다.
int32/int64 음수는 3단계처럼 64비트 마스크, sint 계열은 ZigZag(64비트는 n >> 63), bool 은 0 또는 1 입니다.
메시지는 레코드를 그냥 이어 붙인 것입니다 — 구분자도 헤더도 없습니다.
바이트를 레코드 목록으로 되돌린다
decode_message(data) 를 더해 [(번호, 와이어타입, 원시값), ...] 를 돌려주게 하세요. /opt/app/grpc/order_v1.bin 을 해독해 /root/grpc/05-decode.txt 에 field=N wire=W value=V 로 적으세요(LEN 값은 16진수).
끝날 때까지 반복합니다: 태그를 읽고 → 와이어 타입에 따라 값을 읽습니다. 0 이면 varint, 2 면 길이 varint 뒤에 그만큼의 bytes, 5 면 4바이트, 1 이면 8바이트입니다. 원시값은 해석하지 않습니다 — 여기서는 이름도 선언 타입도 모르기 때문입니다.
이 함수가 pos 를 정확히 옮기지 못하면 다음 태그를 엉뚱한 자리에서 읽습니다. 채점기가 무작위 메시지 12개로 돌려 봅니다.
픽스처 확인: python3 -c "print(open('/opt/app/grpc/order_v1.bin','rb').read().hex())".
모르는 필드를 건너뛰되 잃지 않는다
encode_records(records) (decode_message 의 역함수) 를 더하세요. /opt/app/grpc/order_v2.bin 을 해독해 레코드마다 v1 스키마(/opt/app/grpc/order_v1.proto)가 아는 번호인지 /root/grpc/06-unknown.txt 에 field=N wire=W known 또는 unknown 으로 적으세요.
옛 파서가 새 필드를 오류 없이 건너뛸 수 있는 것은 와이어 타입이 길이를 알려 주기 때문입니다. 그리고 proto3 파서는 그 모르는 필드를 보존해서 다시 직렬화할 때 포함합니다 — encode_records 가 그 일을 합니다: 레코드마다 태그를 다시 쓰고, VARINT 면 varint, LEN 이면 길이+bytes, I32/I64 면 bytes 그대로.
채점기는 무작위 메시지를 여러분의 decode_message → encode_records 로 왕복시켜 원본과 바이트가 같은지 봅니다.
v1 이 아는 번호는 1·2·3 뿐입니다.
서브메시지는 LEN 안의 또 다른 메시지
/opt/app/grpc/nested.bin 의 필드 6(Customer 서브메시지) 페이로드를 다시 decode_message 로 해독해 /root/grpc/07-nested.txt 에 customer.bytes=16진수 customer.name=이름 customer.tier=숫자 세 줄을 적으세요.
서브메시지는 string 과 똑같이 LEN 레코드입니다 — 다만 그 bytes 가 또 하나의 메시지라서 같은 함수로 한 번 더 해독하면 됩니다. 스키마(/opt/app/grpc/nested.proto)를 보면 Customer 는 string name = 1; int32 tier = 2; 입니다.
customer.bytes 는 필드 6 의 원시값(태그·길이를 뺀 페이로드)을 .hex() 한 것입니다.
packed repeated 를 푼다
decode_packed_varints(raw) 를 더하고 /opt/app/grpc/packed.bin 의 필드 7(packed repeated int32)을 풀어 /root/grpc/08-packed.txt 에 tags=쉼표목록 packed_record=16진수 expanded_record=16진수 세 줄을 적으세요.
packed 는 태그 하나짜리 LEN 레코드 안에 varint 가 이어 붙어 있는 것입니다. 페이로드가 끝날 때까지 decode_varint 를 반복하면 목록이 나옵니다.
packed_record 는 packed.bin 에서 필드 7 레코드 전체(태그+길이+페이로드)의 16진수, expanded_record 는 같은 목록을 원소마다 encode_field(7, "int32", v) 로 만들어 이어 붙인 16진수입니다. 둘의 길이를 비교해 보세요 — 태그 비용이 얼마나 줄었는지가 보입니다.