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

gRPC 서비스를 직접 세우고 운영한다

같은 이체 실패에 서로 다른 상태 코드와 이유를 싣는다

TT Lab 에서 이어서 보기

목표

이체 서비스의 실패를 gRPC 상태 코드로 나누고, 예상하지 못한 예외를 숨기며, 어느 필드가·어떤 이유로·몇 초 뒤에를 google.rpc.Status 상세로 실어 보낸 뒤, 클라이언트가 그 상세를 읽어 분기하게 합니다.

왜 중요한가

상태 코드는 클라이언트에게 보내는 「다음에 무엇을 하라」 는 신호입니다. 재시도 정책, 사용자 안내, 경보가 모두 이 코드로 갈립니다. 실패를 전부 INTERNAL 이나 UNKNOWN 으로 보내면 재시도해야 할 일은 버그로 신고되고, 버그는 끝없이 재시도됩니다. 그리고 코드 하나로는 어느 필드가 틀렸는지, 몇 초 뒤에 다시 해도 되는지를 말할 수 없어서 구글 API 오류 모델은 상세 메시지를 트레일러에 싣습니다. 예외 문자열을 그대로 내보내는 기본 동작이 내부 정보를 흘리는 통로라는 것도 이 실습에서 직접 봅니다.

단계

  1. 서비스와 오류 모델의 코드를 만든다: cd /root/grpcops/status && /opt/rt-lab/bin/python -m grpc_tools.protoc -I /opt/fixtures/grpcops/proto --python_out=. --grpc_python_out=. bank/v1/transfer.proto google/rpc/status.proto google/rpc/error_details.proto 로 세 파일의 코드를 /root/grpcops/status 에 만드세요. bank/v1/transfer_pb2.py 는 이체 서비스, google/rpc/status_pb2.py 는 오류를 담는 Status, google/rpc/error_details_pb2.py 는 BadRequest·ErrorInfo·RetryInfo 같은 표준 상세 메시지입니다. /opt/fixtures/grpcops/ledger.py 의 맨 위 설명에서 계좌와 규칙도 읽어 두세요.
  2. 실패마다 상태 코드를 고른다: /root/grpcops/status/server.py 에 serve(port) 를 만드세요. sys.path 에 /root/grpcops/status 와 /opt/fixtures/grpcops 를 더하고 ledger.Ledger() 하나를 쓰는 Bank 서비스를 127.0.0.1:port 에 띄웁니다. Transfer 는 먼저 요청을 검사해 보내는 계좌나 받는 계좌가 비었거나, 두 계좌가 같거나, 금액이 1 보다 작거나 ledger.MAX_TRANSFER_CENTS 보다 크면 INVALID_ARGUMENT 로 끝냅니다. 그다음 장부에 이체를 맡기고 예외를 이렇게 바꿉니다. UnknownAccount 는 NOT_FOUND, InsufficientFunds 와 AccountFrozen 은 FAILED_PRECONDITION, RateLimited 는 RESOURCE_EXHAUSTED, Unavailable 은 UNAVAILABLE 입니다. 성공하면 transfer_id 와 from_balance_cents 를 돌려줍니다.
  3. 예상하지 못한 예외는 숨기고 기록한다: Transfer 가 장부에서 예상하지 못한 예외(ledger 의 예외가 아닌 것)를 만나면 INTERNAL 로 끝내게 하세요. 설명은 「내부 오류입니다 (ref=<참조 번호>)」 모양으로, 참조 번호는 호출마다 새로 만든 16진수 8자 이상입니다. 예외 문자열은 응답에 넣지 않고, 참조 번호와 예외 문자열을 한 줄로 /root/grpcops/status/errors.log 에 덧붙입니다. X-500 계좌에서 보내면 장부의 버그가 터집니다.
  4. 어느 필드가 왜 틀렸는지 싣는다: INVALID_ARGUMENT 로 끝낼 때 google.rpc.Status 를 만들어 트레일러 grpc-status-details-bin 에 실으세요. Status 의 code 는 3, message 는 grpc 상태 설명과 같은 문자열이고, details 에는 BadRequest 하나를 Any 로 넣습니다. BadRequest 에는 틀린 필드마다 FieldViolation(field=필드 이름, description=이유)을 하나씩, 틀린 것을 모두 담습니다. 필드 이름은 from_account·to_account·amount_cents 입니다.
  5. 같은 코드, 다른 이유: FAILED_PRECONDITION 으로 끝낼 때 ErrorInfo 를 실으세요. domain 은 bank.grpcops.lab 이고, 잔액 부족이면 reason 이 INSUFFICIENT_FUNDS, metadata 에 account·balance_cents·requested_cents 를, 동결이면 reason 이 ACCOUNT_FROZEN, metadata 에 account 를 넣습니다. metadata 의 값은 모두 문자열입니다.
  6. 언제 다시 해도 되는지 알린다: RESOURCE_EXHAUSTED 로 끝낼 때 RetryInfo(retry_delay=장부가 알려 준 retry_after_s 초)와 QuotaFailure (violations 하나, subject 는 account:<계좌>)를 싣고, UNAVAILABLE 로 끝낼 때도 RetryInfo(retry_delay=장부가 알려 준 초)를 실으세요. retry_delay 는 google.protobuf.Duration 입니다.
  7. 클라이언트가 이유를 읽는다: /root/grpcops/status/client.py 에 transfer(target, from_account, to_account, amount_cents) 를 만드세요. timeout=5 로 Transfer 를 불러, 성공이면 {"ok": True, "transfer_id": ..., "balance_cents": ...} 를, 실패면 {"ok": False, "code": 상태 코드 이름, "reason": ErrorInfo.reason 또는 None, "retry_after": RetryInfo 의 초(float) 또는 None, "fields": BadRequest 의 필드 이름을 정렬한 list} 를 돌려줍니다. 상세가 없으면 reason·retry_after 는 None, fields 는 빈 list 입니다. 채점기는 기준 서버를 상대로 부릅니다.

참고

서비스와 오류 모델의 코드를 만든다

cd /root/grpcops/status && /opt/rt-lab/bin/python -m grpc_tools.protoc -I /opt/fixtures/grpcops/proto --python_out=. --grpc_python_out=. bank/v1/transfer.proto google/rpc/status.proto google/rpc/error_details.proto 로 세 파일의 코드를 /root/grpcops/status 에 만드세요. bank/v1/transfer_pb2.py 는 이체 서비스, google/rpc/status_pb2.py 는 오류를 담는 Status, google/rpc/error_details_pb2.py 는 BadRequest·ErrorInfo·RetryInfo 같은 표준 상세 메시지입니다. /opt/fixtures/grpcops/ledger.py 의 맨 위 설명에서 계좌와 규칙도 읽어 두세요.

google/rpc 의 두 파일은 grpcio-status 와 googleapis-common-protos 패키지가 이미 만들어 두는 코드입니다. 실습 이미지에는 그 패키지가 없어서 원본 .proto 로 직접 만듭니다. 와이어의 바이트는 같습니다.

실패마다 상태 코드를 고른다

/root/grpcops/status/server.py 에 serve(port) 를 만드세요. sys.path 에 /root/grpcops/status 와 /opt/fixtures/grpcops 를 더하고 ledger.Ledger() 하나를 쓰는 Bank 서비스를 127.0.0.1:port 에 띄웁니다. Transfer 는 먼저 요청을 검사해 보내는 계좌나 받는 계좌가 비었거나, 두 계좌가 같거나, 금액이 1 보다 작거나 ledger.MAX_TRANSFER_CENTS 보다 크면 INVALID_ARGUMENT 로 끝냅니다. 그다음 장부에 이체를 맡기고 예외를 이렇게 바꿉니다. UnknownAccount 는 NOT_FOUND, InsufficientFunds 와 AccountFrozen 은 FAILED_PRECONDITION, RateLimited 는 RESOURCE_EXHAUSTED, Unavailable 은 UNAVAILABLE 입니다. 성공하면 transfer_id 와 from_balance_cents 를 돌려줍니다.

gRPC 문서의 기준은 「클라이언트가 무엇을 해야 하는가」 입니다. 같은 호출을 곧 다시 하면 되면 UNAVAILABLE, 시스템 상태를 누가 고치기 전에는 다시 해도 소용없으면 FAILED_PRECONDITION 입니다. 잔액 부족을 INVALID_ARGUMENT 로 보내면 클라이언트는 요청을 고치려 들고, 점검 중을 INTERNAL 로 보내면 재시도하지 않고 버그로 신고합니다.

예상하지 못한 예외는 숨기고 기록한다

Transfer 가 장부에서 예상하지 못한 예외(ledger 의 예외가 아닌 것)를 만나면 INTERNAL 로 끝내게 하세요. 설명은 「내부 오류입니다 (ref=<참조 번호>)」 모양으로, 참조 번호는 호출마다 새로 만든 16진수 8자 이상입니다. 예외 문자열은 응답에 넣지 않고, 참조 번호와 예외 문자열을 한 줄로 /root/grpcops/status/errors.log 에 덧붙입니다. X-500 계좌에서 보내면 장부의 버그가 터집니다.

아무것도 하지 않으면 파이썬 grpc 가 UNKNOWN 과 함께 예외 문자열을 그대로 보냅니다. 그 문자열에는 내부 주소나 키가 섞여 있곤 합니다. 밖으로는 참조 번호만 주고, 사람이 찾을 수 있게 같은 번호로 안쪽 기록을 남깁니다. uuid.uuid4().hex[:12] 가 참조 번호로 충분합니다.

어느 필드가 왜 틀렸는지 싣는다

INVALID_ARGUMENT 로 끝낼 때 google.rpc.Status 를 만들어 트레일러 grpc-status-details-bin 에 실으세요. Status 의 code 는 3, message 는 grpc 상태 설명과 같은 문자열이고, details 에는 BadRequest 하나를 Any 로 넣습니다. BadRequest 에는 틀린 필드마다 FieldViolation(field=필드 이름, description=이유)을 하나씩, 틀린 것을 모두 담습니다. 필드 이름은 from_account·to_account·amount_cents 입니다.

context.abort_with_status(상태) 는 code·details·trailing_metadata 세 속성을 가진 grpc.Status 를 받습니다. status_pb2.Status 를 SerializeToString() 한 바이트를 ("grpc-status-details-bin", 바이트) 로 trailing_metadata 에 넣습니다. 첫 번째 위반만 알려 주면 사용자는 고치고 다시 보내기를 필드 수만큼 되풀이합니다.

같은 코드, 다른 이유

FAILED_PRECONDITION 으로 끝낼 때 ErrorInfo 를 실으세요. domain 은 bank.grpcops.lab 이고, 잔액 부족이면 reason 이 INSUFFICIENT_FUNDS, metadata 에 account·balance_cents·requested_cents 를, 동결이면 reason 이 ACCOUNT_FROZEN, metadata 에 account 를 넣습니다. metadata 의 값은 모두 문자열입니다.

잔액 부족과 계좌 동결은 둘 다 「상태가 맞지 않는다」 라서 상태 코드가 같습니다. 클라이언트가 둘을 다르게 처리하려면(충전 안내와 고객센터 안내) 설명 문자열을 파싱하지 않고 분기할 값이 필요합니다. 그것이 ErrorInfo.reason 이고, 바꾸지 않는 상수로 다룹니다.

언제 다시 해도 되는지 알린다

RESOURCE_EXHAUSTED 로 끝낼 때 RetryInfo(retry_delay=장부가 알려 준 retry_after_s 초)와 QuotaFailure (violations 하나, subject 는 account:<계좌>)를 싣고, UNAVAILABLE 로 끝낼 때도 RetryInfo(retry_delay=장부가 알려 준 초)를 실으세요. retry_delay 는 google.protobuf.Duration 입니다.

Duration 은 seconds 와 nanos 두 칸입니다. duration_pb2.Duration() 을 만든 뒤 FromTimedelta 나 FromNanoseconds 로 채우면 2.5초가 seconds=2, nanos=500000000 으로 들어갑니다. 서버가 기다릴 시간을 알려 주면 클라이언트들이 같은 순간에 몰려 다시 오는 일을 줄일 수 있습니다.

클라이언트가 이유를 읽는다

/root/grpcops/status/client.py 에 transfer(target, from_account, to_account, amount_cents) 를 만드세요. timeout=5 로 Transfer 를 불러, 성공이면 {"ok": True, "transfer_id": ..., "balance_cents": ...} 를, 실패면 {"ok": False, "code": 상태 코드 이름, "reason": ErrorInfo.reason 또는 None, "retry_after": RetryInfo 의 초(float) 또는 None, "fields": BadRequest 의 필드 이름을 정렬한 list} 를 돌려줍니다. 상세가 없으면 reason·retry_after 는 None, fields 는 빈 list 입니다. 채점기는 기준 서버를 상대로 부릅니다.

오류의 트레일러는 grpc.RpcError 의 trailing_metadata() 에 있습니다. 그중 grpc-status-details-bin 을 status_pb2.Status.FromString 으로 풀고, details 의 Any 마다 Is(ErrorInfo.DESCRIPTOR) 로 종류를 확인한 뒤 Unpack 합니다. 실제 프로젝트에서는 grpcio-status 의 rpc_status.from_call(error) 가 앞부분을 해 줍니다.