거절된 호출이 기록에서 사라졌다
목표
재고 서비스 둘레에 서버 인터셉터(접근 기록·인증·인가·요청 번호)와 클라이언트 인터셉터(메타데이터·재시도)를 만들고, 인터셉터의 순서가 기록과 재시도의 결과를 바꾸는 것을 확인합니다.
왜 중요한가
기록·인증·추적·재시도는 모든 메서드에 똑같이 필요하지만 메서드 안에 적으면 하나를 빠뜨리는 순간 구멍이 됩니다. 인터셉터는 모든 호출이 지나는 한 자리라서 빠뜨릴 수 없습니다. 그 대신 여러 인터셉터가 겹치면 순서가 의미를 가집니다. 인증 바깥에 있는 기록만 거절된 호출을 보고, 요청 번호 안쪽에 있는 재시도만 같은 번호로 다시 보냅니다. 실제 장애 조사에서 「공격 시도가 기록에 없다」 거나 「같은 주문이 세 번 들어왔다」 는 일이 바로 이 순서에서 나옵니다.
단계
- 재고 서비스와 헬스 서비스의 코드를 만든다: cd /root/grpcops/intercept && /opt/rt-lab/bin/python -m grpc_tools.protoc -I /opt/fixtures/grpcops/proto --python_out=. --grpc_python_out=. shop/v1/inventory.proto health.proto 로 /root/grpcops/intercept 에 shop/v1/inventory_pb2*.py 와 health_pb2*.py 를 만드세요. 서비스 구현은 /opt/fixtures/grpcops/inventory_impl.py 의 make_servicer(pb, rpc) 가 줍니다. 토큰과 권한 범위는 /opt/fixtures/grpcops/tokens.json 에 있습니다.
- 모든 호출을 한 줄씩 기록한다: /root/grpcops/intercept/server.py 에 serve(port) 를 만드세요. 재고 서비스와 헬스 서비스(예시 파일의 Health)를 붙인 서버를 127.0.0.1:port 에 띄우되, grpc.server(..., interceptors=[...]) 에 AccessLog 인터셉터를 둡니다. AccessLog 는 호출이 끝날 때마다 /root/grpcops/intercept/access.log 에 JSON 한 줄을 덧붙입니다. 키는 method(/shop.v1.Inventory/GetItem 같은 전체 이름), code(OK·NOT_FOUND 같은 상태 코드 이름), ms(걸린 밀리초, 숫자)입니다. 서버 스트리밍 호출은 스트림이 끝난 뒤에 한 줄을 씁니다.
- 토큰과 권한 범위를 한 곳에서 본다: Auth 인터셉터를 더하세요. 메타데이터 authorization 이 "Bearer <토큰>" 이고 그 토큰이 tokens.json 에 있어야 합니다. 없거나 모르는 토큰이면 UNAUTHENTICATED, 토큰은 맞지만 그 메서드의 범위(GetItem·ListItems 는 read, Reserve 는 write)가 없으면 PERMISSION_DENIED 로 끝냅니다. /grpc.health.v1.Health/Check 는 토큰 없이 통과시킵니다. 표에 없는 메서드는 거절합니다.
- 거절된 호출도 기록에 남긴다: interceptors 목록의 순서를 정해, 토큰 없이 온 호출도 access.log 에 code=UNAUTHENTICATED 로 남게 하세요. 채점기는 토큰 없이 GetItem 을 부른 뒤 그 줄을 찾습니다.
- 요청 번호를 받아 쓰고 돌려준다: AccessLog 를 고쳐, 메타데이터 x-request-id 가 있으면 그 값을, 없으면 uuid4 의 hex 를 요청 번호로 정하게 하세요. 그 번호를 기록 줄의 request_id 키에 넣고, context.set_trailing_metadata 로 트레일러 x-request-id 에 실어 돌려줍니다. 거절된 호출에도 같습니다.
- 클라이언트 인터셉터로 메타데이터를 붙인다: /root/grpcops/intercept/client.py 에 make_channel(target, token) 을 만드세요. grpc.intercept_channel 로 감싼 채널을 돌려주며, 그 채널의 모든 호출(단항·서버 스트리밍)에 authorization: Bearer 과 호출마다 새 x-request-id 를 붙입니다. 호출하는 쪽이 x-request-id 를 직접 넘기면 그 값을 그대로 둡니다. 채점기는 받은 메타데이터를 기록하는 기준 서버를 상대로 부릅니다.
- 재시도 인터셉터와 그 자리: /root/grpcops/intercept/client.py 에 make_retry_channel(target, token, attempts=3) 을 더하세요. 단항 호출이 UNAVAILABLE 로 끝나면 0.05초, 0.1초처럼 두 배씩 쉬며 attempts 번까지 다시 보내고, 다른 상태 코드는 곧바로 돌려줍니다. 다시 보낼 때도 x-request-id 는 처음 정한 값 그대로여야 합니다. 채점기는 처음 두 번은 UNAVAILABLE 로 실패하는 품목, INVALID_ARGUMENT 로 끝나는 품목, 늘 UNAVAILABLE 인 품목을 부릅니다.
참고
- 작업 폴더는 /root/grpcops/intercept 입니다. mkdir -p /root/grpcops/intercept 로 먼저 만드세요.
- 서버는 cd /root/grpcops/intercept && /opt/rt-lab/bin/python -c "import server; server.serve(50053)" 로 띄워 볼 수 있습니다. 토큰을 붙여 부를 때는 rpcurl 의 -H 'authorization: Bearer tok-reader-7f3a9c' 를 씁니다(리플렉션을 켜야 합니다. 리플렉션 메서드도 표에 넣거나 PUBLIC 에 더하세요).
- 토큰은 실습용 상수입니다. 실제 서비스는 신원 공급자가 서명한 토큰(JWT 등)을 받아 서명과 만료를 검증합니다. 이 실습은 그 검증을 어디에 두는가를 다룹니다.
- 흔한 실수 두 가지입니다. 처리기를 감싸지 않고 고르는 순간에 기록해 모든 호출이 OK 로 적히는 것, 그리고 거절을 다른 종류의 처리기(스트리밍 메서드에 단항 처리기)로 돌려주는 것입니다.
- 블로그 글 grpc-go 코드 지도 에 Go 의 인터셉터 사슬이 어디서 조립되는지 있습니다.
- 파이썬은 /opt/rt-lab/bin/python 으로 실행합니다. grpcio·grpcio-tools·protobuf 가 그 가상환경에만 있어서, 그냥 python3 로 돌리면 ModuleNotFoundError 가 납니다. alias rpy=/opt/rt-lab/bin/python 처럼 줄여 두면 편합니다.
- 코드 생성기는 /opt/rt-lab/bin/python -m grpc_tools.protoc 입니다. protoc 과 같은 깃발(-I, --python_out, --grpc_python_out)을 받고, google/protobuf 의 기본 타입(any·duration 등)을 찾는 경로를 스스로 더합니다.
- 실습 파드는 바깥으로 나가는 연결이 막혀 있습니다. 모든 통신은 같은 파드 안의 127.0.0.1 에서 일어나며, 설치나 다운로드는 필요 없습니다.
- 채점기는 여러분의 코드를 별도 프로세스로 띄워 실제로 호출해 봅니다. 서버를 미리 켜 둘 필요는 없고, 채점기는 빈 포트를 골라 따로 띄웁니다. 앞 단계에서 완성한 함수는 지우지 마세요.
- 실습 세션이 끝나면 /root 의 파일은 남지 않습니다. 필요한 코드는 끝내기 전에 따로 보관하세요.
재고 서비스와 헬스 서비스의 코드를 만든다
cd /root/grpcops/intercept && /opt/rt-lab/bin/python -m grpc_tools.protoc -I /opt/fixtures/grpcops/proto --python_out=. --grpc_python_out=. shop/v1/inventory.proto health.proto 로 /root/grpcops/intercept 에 shop/v1/inventory_pb2*.py 와 health_pb2*.py 를 만드세요. 서비스 구현은 /opt/fixtures/grpcops/inventory_impl.py 의 make_servicer(pb, rpc) 가 줍니다. 토큰과 권한 범위는 /opt/fixtures/grpcops/tokens.json 에 있습니다.
이 실습에서 만드는 것은 서비스가 아니라 그 둘레입니다. inventory_impl 을 열어 GetItem·ListItems 는 읽기, Reserve 는 쓰기라는 것과 NOT_FOUND 를 어디서 내는지 확인해 두세요.
모든 호출을 한 줄씩 기록한다
/root/grpcops/intercept/server.py 에 serve(port) 를 만드세요. 재고 서비스와 헬스 서비스(예시 파일의 Health)를 붙인 서버를 127.0.0.1:port 에 띄우되, grpc.server(..., interceptors=[...]) 에 AccessLog 인터셉터를 둡니다. AccessLog 는 호출이 끝날 때마다 /root/grpcops/intercept/access.log 에 JSON 한 줄을 덧붙입니다. 키는 method(/shop.v1.Inventory/GetItem 같은 전체 이름), code(OK·NOT_FOUND 같은 상태 코드 이름), ms(걸린 밀리초, 숫자)입니다. 서버 스트리밍 호출은 스트림이 끝난 뒤에 한 줄을 씁니다.
intercept_service(continuation, handler_call_details) 는 처리기가 돌기 전에 불립니다. 여기서 바로 기록하면 결과를 모릅니다. continuation 이 돌려준 처리기의 본체(unary_unary 또는 unary_stream)를 감싼 새 처리기를 만들어 돌려주고, 본체가 끝난 뒤 context.code() 로 결과를 읽습니다. 성공이면 None 이 나오니 OK 로 적습니다.
토큰과 권한 범위를 한 곳에서 본다
Auth 인터셉터를 더하세요. 메타데이터 authorization 이 "Bearer <토큰>" 이고 그 토큰이 tokens.json 에 있어야 합니다. 없거나 모르는 토큰이면 UNAUTHENTICATED, 토큰은 맞지만 그 메서드의 범위(GetItem·ListItems 는 read, Reserve 는 write)가 없으면 PERMISSION_DENIED 로 끝냅니다. /grpc.health.v1.Health/Check 는 토큰 없이 통과시킵니다. 표에 없는 메서드는 거절합니다.
거절은 continuation 이 준 처리기와 같은 종류(단항·스트리밍)의 처리기로 돌려줘야 합니다. 본체에서 context.abort(code, 설명) 을 부르면 됩니다. 토큰 비교는 hmac.compare_digest 로 합니다. 일반 비교는 첫 글자가 다르면 곧바로 끝나서 걸린 시간으로 토큰을 한 글자씩 맞춰 볼 수 있습니다. kubelet 의 프로브는 토큰을 보내지 않으므로 헬스 체크는 열어 둡니다.
거절된 호출도 기록에 남긴다
interceptors 목록의 순서를 정해, 토큰 없이 온 호출도 access.log 에 code=UNAUTHENTICATED 로 남게 하세요. 채점기는 토큰 없이 GetItem 을 부른 뒤 그 줄을 찾습니다.
파이썬 grpcio 는 목록의 앞쪽 인터셉터를 바깥에 둡니다. 바깥 인터셉터는 안쪽이 돌려준 처리기를 감싸므로 안쪽의 거절까지 봅니다. 기록이 인증 안쪽에 있으면 거절된 호출은 기록에 닿지 않습니다. 공격 시도가 기록에서 빠지는 셈입니다.
요청 번호를 받아 쓰고 돌려준다
AccessLog 를 고쳐, 메타데이터 x-request-id 가 있으면 그 값을, 없으면 uuid4 의 hex 를 요청 번호로 정하게 하세요. 그 번호를 기록 줄의 request_id 키에 넣고, context.set_trailing_metadata 로 트레일러 x-request-id 에 실어 돌려줍니다. 거절된 호출에도 같습니다.
요청 번호는 클라이언트·게이트웨이·서버의 기록을 한 줄로 잇는 실입니다. 사용자가 「방금 실패했다」 고 할 때 이 번호 하나로 세 곳의 기록을 찾습니다. 트레일러는 오류로 끝난 호출에도 실려 갑니다.
클라이언트 인터셉터로 메타데이터를 붙인다
/root/grpcops/intercept/client.py 에 make_channel(target, token) 을 만드세요. grpc.intercept_channel 로 감싼 채널을 돌려주며, 그 채널의 모든 호출(단항·서버 스트리밍)에 authorization: Bearer 과 호출마다 새 x-request-id 를 붙입니다. 호출하는 쪽이 x-request-id 를 직접 넘기면 그 값을 그대로 둡니다. 채점기는 받은 메타데이터를 기록하는 기준 서버를 상대로 부릅니다.
grpc.UnaryUnaryClientInterceptor 와 grpc.UnaryStreamClientInterceptor 를 함께 상속한 클래스 하나로 두 종류를 다 받습니다. client_call_details 는 바꿀 수 없으니 같은 여섯 칸(method·timeout·metadata·credentials·wait_for_ready·compression)을 가진 새 객체를 만들어 continuation 에 넘깁니다.
재시도 인터셉터와 그 자리
/root/grpcops/intercept/client.py 에 make_retry_channel(target, token, attempts=3) 을 더하세요. 단항 호출이 UNAVAILABLE 로 끝나면 0.05초, 0.1초처럼 두 배씩 쉬며 attempts 번까지 다시 보내고, 다른 상태 코드는 곧바로 돌려줍니다. 다시 보낼 때도 x-request-id 는 처음 정한 값 그대로여야 합니다. 채점기는 처음 두 번은 UNAVAILABLE 로 실패하는 품목, INVALID_ARGUMENT 로 끝나는 품목, 늘 UNAVAILABLE 인 품목을 부릅니다.
intercept_channel(채널, 앞, 뒤) 에서 앞이 바깥입니다. 재시도가 메타데이터 인터셉터 바깥에 있으면 다시 보낼 때마다 안쪽이 새 번호를 붙입니다. 그러면 서버는 같은 예약을 세 번 받은 줄 압니다. continuation 이 돌려준 객체의 code() 로 결과를 보고, 실패한 그 객체를 그대로 돌려주면 스텁이 예외를 올립니다. 채널 설정의 retryPolicy 와 함께 켜면 시도 횟수가 곱해지니 한쪽만 씁니다.