TT Lab
블로그 시작하기
배우기 러닝패스 코스

필드 번호를 바꿨더니 옛 클라이언트가 조용히 틀린 값을 읽었다

옛 판과 새 판이 함께 돈다 — 조용히 틀린 값을 바이트로 추적한다

TT Lab 에서 이어서 보기

목표

새 판(v2) 서버 하나가 보내는 같은 바이트를 옛 판(v1)·새 판 클라이언트 두 프로세스가 각자의 .proto 로 읽게 하고, 어긋난 이름 → 바이트의 필드 번호 → 두 .proto 의 차이 순으로 원인을 좁힙니다. 그 과정을 시험 스크립트로 굳힌 뒤, 세 서비스가 같은 증상을 서로 다른 원인으로 내는 사건을 맞습니다.

왜 중요한가

앞 실습들에서 번호를 바꾸면 옛 클라이언트가 틀리게 읽는다는 규칙을 배웠습니다. 현장에서는 규칙이 아니라 증상에서 출발합니다 — 정산 숫자가 안 맞는데 오류 로그는 한 줄도 없습니다. 이때 두 판을 나란히 띄우고 같은 응답을 두 눈으로 읽어 보면 어긋난 이름이 바로 보이고, 그 이름이 옛 판에게 어느 번호인지 알면 바이트 덤프에서 그 레코드 하나를 집을 수 있습니다. 이름은 각자의 .proto 에만 있고 바이트에는 번호만 있다는 사실을 도구로 확인하는 과정이 곧 진단 절차입니다. 그리고 고침은 언제나 새 판 쪽입니다 — 옛 판은 이미 배포되어 다시 빌드할 수 없는 경우가 대부분이기 때문입니다.

단계

  1. 데모 v1 서버를 7100 에 띄우고(python3 /opt/fixtures/grpcc/gcsvc.py --proto /opt/fixtures/grpcc/demo/v1.proto --message Order --data /opt/fixtures/grpcc/demo/data.json --port 7100 &), 표준 라이브러리 socket·struct 만으로 /root/grpc/skew/tap.py <port> <id> <out-file> 을 쓰세요. GetRequest(int32 id = 1)를 gRPC 메시지 틀에 담아 보내고 응답 틀(머리 5바이트 포함)을 통째로 저장합니다. id 2 의 응답을 /root/grpc/skew/01-resp.bin 에 저장하세요.
  2. 데모 v2 서버를 7101 에 띄우고(--proto /opt/fixtures/grpcc/demo/v2.proto), 같은 id 2 를 옛 판(/opt/fixtures/grpcc/gcclient.py --proto /opt/fixtures/grpcc/demo/v1.proto ...)과 새 판(--proto /opt/fixtures/grpcc/demo/v2.proto)으로 읽으세요. 두 판이 다르게 읽은 이름마다 <name> old=<old-value> new=<new-value> 한 줄을 /root/grpc/skew/02-diff.txt 에 적습니다(새 판에 그 이름이 없으면 new=(none)).
  3. gcclient.py --dump demo --port 7101 --id 2 로 바이트를 레코드 줄로 펴고, 2단계의 이름마다 <name> field=<number-in-v1> wire=<wire-type-on-the-wire> cause=<code> 를 /root/grpc/skew/03-cause.txt 에 적으세요. 코드는 enum-renumbered field-renumbered number-reused sint-changed wire-type-changed 중 하나입니다.
  4. 데모 v2 의 새 기능(PENDING·coupon·currency)은 살리면서 옛 판이 바르게 읽게 고친 스키마를 /root/grpc/skew/04-v2.proto 로 쓰세요. 그 스키마로 서버를 하나 더 띄워 두 클라이언트로 확인합니다.
  5. /root/grpc/skew/skewtest.py <port> <old.proto> <new.proto> <message> <id,id,...> 를 쓰세요. 같은 응답을 두 스키마로 읽어 어긋난 이름마다 MISMATCH id=<id> <name> old=<value> new=<value> 을 찍고 exit 1, 없으면 마지막 줄 OK · exit 0. 새 판이 지운 이름은 옛 판이 기본값을 읽으면 정상이고, 옛 판이 모르는 새 enum 값이 숫자로 보이는 것도 정상입니다.
  6. incident gc-silent-skew 로 사건을 내고 티켓을 읽으세요. 고치기 전에 서비스마다 원인을 가려 /root/incident/gc-silent-skew/report.txt 에 <service>: <code> 세 줄과, 서비스마다 그 원인을 보여 주는 gc-tap 출력 한 줄을 evidence: <tap-line> 로 적으세요(세 줄).
  7. 세 서비스를 고치세요 — 정산 배치(옛 판)가 모든 기록을 바르게 읽고, 새 판 클라이언트도 v2 의 칸을 그대로 읽어야 합니다. 고치는 곳은 /srv/gc/<service>/v2.proto 이고, 고친 뒤 gc-svc restart <service> 로 다시 띄웁니다.

참고

gRPC 메시지 틀을 손으로 주고받는다

/root/grpc/skew/tap.py <port> <id> <out-file> 을 socket·struct 만으로 쓰고, 7100 에 띄운 데모 v1 서버에서 id 2 의 응답 틀(머리 5바이트 포함)을 /root/grpc/skew/01-resp.bin 에 저장하세요.

방향: 요청도 응답도 「압축 표시 1바이트 + 길이 4바이트(빅엔디언) + 메시지」 틀에 담깁니다. 요청 메시지는 GetRequest 하나 — 필드 1 에 varint 로 id 를 싣습니다.

도구: struct.pack(">I", n)·struct.unpack, 그리고 sock.recv() 를 원하는 길이가 찰 때까지 되풀이하는 작은 함수. 서버는 응답을 일부러 쪼개 보내서 recv() 한 번은 머리 5바이트만 돌려줍니다.

구체: 머리 5바이트를 다 받은 뒤 그 안의 길이만큼 몸통을 받고, 둘을 이어 파일에 씁니다. 없는 id 에 서버는 길이 0 인 틀을 보내니, 길이가 0 이면 몸통을 기다리지 않아야 합니다. skewlib·gcclient 를 불러 쓰면 이 단계는 떨어집니다.

같은 바이트, 두 세계관

데모 v2 서버(7101)의 id 2 응답을 옛 판(v1.proto)과 새 판(v2.proto) 클라이언트로 읽고, 다르게 읽은 이름마다 <name> old=<old-value> new=<new-value> 을 /root/grpc/skew/02-diff.txt 에 적으세요.

방향: 서버는 하나, 바이트도 하나입니다. 다른 것은 클라이언트가 품은 .proto 뿐이고, 어느 쪽도 오류를 내지 않습니다.

도구: python3 /opt/fixtures/grpcc/gcclient.py --proto <v1-or-v2.proto> --message Order --port 7101 --id 2 를 두 번, 출력을 파일로 받아 이름별로 맞대 보세요.

구체: 새 판에 없는 이름은 new=(none) 인데, 그때 옛 판이 기본값(0·빈 문자열)을 읽었다면 어긋난 것이 아닙니다 — 지운 필드는 옛 판에 기본값으로 보이는 것이 맞습니다. 값이 같은 이름은 적지 않습니다.

어긋난 이름에서 바이트의 번호로

gcclient.py --dump demo --port 7101 --id 2 의 레코드 줄과 두 .proto 를 보고, 2단계의 이름마다 <name> field=<number> wire=<wire> cause=<code> 를 /root/grpc/skew/03-cause.txt 에 적으세요.

방향: 바이트에는 이름이 없습니다. 「옛 판이 그 이름에 붙인 번호」 를 v1.proto 에서 찾고, 덤프에서 그 번호의 레코드가 실제로 어떤 와이어 타입으로 실렸는지 보고, 마지막으로 그 번호에서 v1 과 v2 의 선언이 어떻게 다른지 봅니다.

도구: 덤프 줄의 field=·wire=·raw=, 두 .proto 의 같은 번호 줄, enum 블록의 값 번호.

구체: 옛 이름이 v2 에 다른 번호로 남아 있으면(번호만 옮겨졌으면) field-renumbered, 옛 이름은 사라지고 그 번호를 다른 이름이 쓰면 number-reused, 같은 이름인데 enum 값 번호가 밀렸으면 enum-renumbered, 와이어 타입 자체가 다르면 wire-type-changed, 같은 VARINT 인데 숫자가 두 배 남짓이면 sint-changed 를 의심합니다(150 의 ZigZag 는 300).

새 기능은 살리고 옛 판도 바르게

데모 v2 의 PENDING·coupon·currency 는 살리면서 옛 판이 바르게 읽게 고친 스키마를 /root/grpc/skew/04-v2.proto 로 쓰세요.

방향: 옛 판이 아는 번호의 뜻(이름·와이어에서 호환되는 타입·enum 값 번호)은 하나도 바꾸지 않고, 새 것은 새 번호에 둡니다. v1 으로 통째로 되돌리면 새 판의 칸이 사라져 떨어집니다.

도구: 3단계의 표 — 원인마다 되돌릴 곳이 하나씩입니다. 고친 파일로 서버를 하나 더 띄워 gcclient.py 두 번으로 확인하세요.

구체: enum 의 새 값은 끝 번호로, 다른 이름이 쓰던 옛 번호에서 새 필드를 빼 새 번호로 옮기고 그 옛 번호는 reserved 로 봉인합니다. sint32 와 int32, fixed32 와 int32 는 와이어에서 호환되지 않습니다.

두 눈으로 읽는 시험을 만든다

/root/grpc/skew/skewtest.py <port> <old.proto> <new.proto> <message> <id,id,...> 를 쓰세요 — 어긋난 이름마다 MISMATCH id=<id> <name> old=<value> new=<value> 과 exit 1, 없으면 마지막 줄 OK 와 exit 0.

방향: 서버에 한 번 묻고, 그 바이트 하나를 두 스키마로 읽어 이름마다 맞댑니다. 오류가 없는 호환성 사고를 잡는 유일한 방법이 이것입니다.

도구: /opt/fixtures/grpcc/skewlib.py 의 call·load·decode_view·default_of·show (sys.path.insert(0, "/opt/fixtures/grpcc")).

구체: 거짓 경보 둘을 피해야 합니다 — 새 판에 없는 이름은 옛 판 값이 그 타입의 기본값이면 정상, 옛 판이 모르는 새 enum 값(새 판 이름이 옛 enum 에 없음)이 숫자로 보이는 것도 정상입니다. 채점기는 처음 보는 해로운 쌍 다섯(원인마다 하나)과 호환되는 쌍 하나를 띄우고, 없는 id(빈 응답)도 섞어 묻습니다 — 두 .proto 만 비교하거나 첫 id 만 보면 떨어집니다.

사건: 세 서비스, 같은 증상, 다른 원인

incident gc-silent-skew 를 실행하세요. 새 판 배포 뒤 정산 배치(옛 판)가 orders·ledger·stock 세 서비스에서 오류 없이 엉뚱한 값을 읽습니다. 고치기 전에 서비스마다 원인을 가려 /root/incident/gc-silent-skew/report.txt 에 <service>: <code> 세 줄을 적으세요. 코드는 enum-renumbered · field-renumbered · number-reused · sint-changed · wire-type-changed 중 하나입니다. 그리고 서비스마다 원인을 보여 주는 gc-tap <service> 출력 한 줄을 evidence: <tap-line> 로 붙이세요(서비스마다 정확히 한 줄). 틀린 코드로 세 번 채점하면 사건이 잠겨 다시 내야 합니다(원인이 새로 정해집니다).

방향: 증상은 셋이 같고 원인은 서비스마다 다릅니다. 각 v2 에는 안전한 변경(새 필드·enum 끝의 새 값·deprecated·선언 순서)도 섞여 있어서, 차이를 다 볼 것이 아니라 옛 판이 틀리게 읽은 그 번호의 차이 하나를 골라야 합니다.

도구: gc-old·gc-new(또는 5단계의 skewtest)로 어긋난 이름을 찾고, v1.proto 에서 그 이름의 번호를, gc-tap 에서 그 번호의 레코드를 봅니다.

구체: 증거 줄은 고치기 전 gc-tap 출력을 고치지 말고 통째로 붙입니다(<service> id=… field=… wire=… raw=… bytes=…). 원인이 된 필드 번호의 줄이어야 합니다.

사건: 옛 판은 그대로, 새 판을 호환되게

세 서비스를 고치세요. 정산 배치(옛 판, /srv/gc/<service>/v1.proto)는 다시 배포할 수 없고 새 판을 통째로 되돌릴 수도 없습니다. /srv/gc/<service>/v2.proto 를 고치고 gc-svc restart <service> 로 다시 띄워, 옛 판이 모든 기록을 바르게 읽고 새 판 클라이언트도 v2 의 칸을 그대로 읽게 하세요. 지운 옛 필드의 번호는 reserved 로 봉인합니다.

방향: 고침은 6단계에서 고른 그 차이 하나를 되돌리는 것입니다. 안전한 변경까지 되돌리면 새 판이 깨집니다.

도구: gc-svc restart <service>(서버는 .proto 를 시작할 때만 읽습니다), gc-old·gc-new, 5단계의 skewtest 를 세 서비스에.

구체: 옛 이름은 옛 번호·옛 타입·옛 enum 번호로, 옛 번호를 차지한 새 필드는 빈 번호로 옮기고 그 옛 번호는 reserved 로. v1.proto 와 data.json 은 건드리지 않습니다.