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

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

누구나 붙던 서버에 인증서를 요구한다

TT Lab 에서 이어서 보기

목표

openssl 로 사설 CA·서버·클라이언트 인증서를 만들고, 서버 TLS 와 서버 이름 확인, 클라이언트 인증서를 요구하는 mTLS 를 차례로 건 뒤, 인증서에 담긴 SPIFFE ID 로 쓰기 호출을 허락합니다.

왜 중요한가

평문 gRPC 는 같은 망의 누구든 엿보고 흉내 낼 수 있습니다. 서버 TLS 는 클라이언트가 「부르려던 상대가 맞는지」 를 CA 서명과 SAN 의 이름으로 확인하게 하고, mTLS 는 서버가 「부르는 상대가 누구인지」 를 같은 방식으로 확인하게 합니다. 서비스 메시가 자동으로 해 주는 일이 바로 이것이라, 직접 한 번 해 보면 메시의 인증서 만료나 이름 불일치 장애를 읽을 수 있게 됩니다. 그리고 인가는 확인된 인증서에서만 신원을 읽어야 합니다. 메타데이터 머리는 누구나 적을 수 있습니다.

단계

  1. 사설 CA 를 만든다: /root/grpcops/tls/pki/ca.key 와 /root/grpcops/tls/pki/ca.crt 를 만드세요. 자기 서명 인증서이고, 주체의 CN 은 grpcops-lab-ca, basicConstraints 는 critical 로 CA:TRUE, keyUsage 에 keyCertSign 이 있어야 하며, 지금부터 30일 넘게 유효해야 합니다. 그리고 cd /root/grpcops/tls && /opt/rt-lab/bin/python -m grpc_tools.protoc -I /opt/fixtures/grpcops/proto --python_out=. --grpc_python_out=. shop/v1/inventory.proto 로 재고 서비스의 코드도 만들어 두세요.
  2. 서버 인증서에 이름을 담는다: /root/grpcops/tls/pki/server.key 와 /root/grpcops/tls/pki/server.crt 를 만드세요. 1단계의 CA 가 서명하고, subjectAltName 에 DNS:inventory.grpcops.internal, DNS:localhost, IP:127.0.0.1 세 개가 있고, extendedKeyUsage 는 serverAuth 입니다.
  3. TLS 서버를 띄운다: /root/grpcops/tls/server.py 에 serve(port) 를 만드세요. sys.path 에 /root/grpcops/tls 와 /opt/fixtures/grpcops 를 더하고, inventory_impl.make_servicer(pb, rpc) 를 붙인 서버를 add_secure_port("127.0.0.1:port", 자격) 로 띄웁니다. 자격은 server.key·server.crt 로 만든 grpc.ssl_server_credentials 입니다. 채점기는 ca.crt 를 믿는 클라이언트로 localhost:port 에 붙고, 평문 연결은 실패해야 합니다.
  4. 서버 이름을 맞춰 부르는 클라이언트: /root/grpcops/tls/client.py 에 make_channel(target, ca_path, server_name=None) 을 만드세요. ca_path 의 인증서만 믿는 grpc.ssl_channel_credentials 로 secure_channel 을 열어 돌려주되, server_name 이 주어지면 채널 옵션 grpc.ssl_target_name_override 로 인증서에서 확인할 이름을 그 값으로 바꿉니다. 채점기는 inventory.grpcops.internal 이라는 이름만 가진 기준 서버를 IP 주소로 부릅니다.
  5. 클라이언트에게 신원을 준다: /root/grpcops/tls/pki/orders.key 와 /root/grpcops/tls/pki/orders.crt 를 만드세요. 1단계의 CA 가 서명하고, CN 은 svc-orders, subjectAltName 은 URI:spiffe://grpcops.internal/ns/shop/sa/orders, extendedKeyUsage 는 clientAuth 입니다.
  6. 서버가 클라이언트 인증서를 요구한다: /root/grpcops/tls/server.py 에 serve_mtls(port) 를 더하세요. serve 와 같되, 자격을 ssl_server_credentials(키 쌍 목록, root_certificates=ca.crt 의 바이트, require_client_auth=True) 로 만듭니다. 채점기는 클라이언트 인증서 없이, 다른 CA 가 서명한 인증서로, orders 인증서로 각각 붙어 봅니다.
  7. 인증서의 신원으로 쓰기 호출을 허락한다: serve_mtls 의 서비스에서 Reserve 는 피어 인증서의 SAN 에 spiffe://grpcops.internal/ns/shop/sa/orders 가 있을 때만 처리하고, 아니면 PERMISSION_DENIED 로 끝내게 하세요. GetItem·ListItems 는 인증된 누구나 부를 수 있습니다. 신원은 context.auth_context() 의 x509_subject_alternative_name 에서 읽습니다. 채점기는 같은 CA 가 서명한 다른 서비스(report)의 인증서로도 부르고, 그 인증서에 메타데이터 x-spiffe-id 로 orders 를 적어 보내기도 합니다.

참고

사설 CA 를 만든다

/root/grpcops/tls/pki/ca.key 와 /root/grpcops/tls/pki/ca.crt 를 만드세요. 자기 서명 인증서이고, 주체의 CN 은 grpcops-lab-ca, basicConstraints 는 critical 로 CA:TRUE, keyUsage 에 keyCertSign 이 있어야 하며, 지금부터 30일 넘게 유효해야 합니다. 그리고 cd /root/grpcops/tls && /opt/rt-lab/bin/python -m grpc_tools.protoc -I /opt/fixtures/grpcops/proto --python_out=. --grpc_python_out=. shop/v1/inventory.proto 로 재고 서비스의 코드도 만들어 두세요.

openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes 로 키와 자기 서명 인증서를 한 번에 만듭니다. 확장은 -addext "basicConstraints=critical,CA:TRUE" 처럼 붙입니다. CA:TRUE 가 없으면 이 인증서로 서명한 서버 인증서를 클라이언트가 믿지 않습니다.

서버 인증서에 이름을 담는다

/root/grpcops/tls/pki/server.key 와 /root/grpcops/tls/pki/server.crt 를 만드세요. 1단계의 CA 가 서명하고, subjectAltName 에 DNS:inventory.grpcops.internal, DNS:localhost, IP:127.0.0.1 세 개가 있고, extendedKeyUsage 는 serverAuth 입니다.

요즘 TLS 클라이언트는 CN 을 보지 않고 SAN 만 봅니다. CN 에만 이름을 적으면 「No match found for server name」 으로 핸드셰이크가 끊깁니다. openssl req -newkey 로 CSR 을 만들고, openssl x509 -req -CA ca.crt -CAkey ca.key -extfile <확장 파일> 로 서명합니다. 확장 파일 한 줄이 subjectAltName=DNS:...,IP:... 입니다.

TLS 서버를 띄운다

/root/grpcops/tls/server.py 에 serve(port) 를 만드세요. sys.path 에 /root/grpcops/tls 와 /opt/fixtures/grpcops 를 더하고, inventory_impl.make_servicer(pb, rpc) 를 붙인 서버를 add_secure_port("127.0.0.1:port", 자격) 로 띄웁니다. 자격은 server.key·server.crt 로 만든 grpc.ssl_server_credentials 입니다. 채점기는 ca.crt 를 믿는 클라이언트로 localhost:port 에 붙고, 평문 연결은 실패해야 합니다.

ssl_server_credentials 는 (개인 키 바이트, 인증서 체인 바이트) 쌍의 목록을 받습니다. 파일은 바이너리 모드("rb")로 읽습니다. add_insecure_port 를 함께 열어 두면 평문으로도 붙을 수 있게 되니 하나만 엽니다.

서버 이름을 맞춰 부르는 클라이언트

/root/grpcops/tls/client.py 에 make_channel(target, ca_path, server_name=None) 을 만드세요. ca_path 의 인증서만 믿는 grpc.ssl_channel_credentials 로 secure_channel 을 열어 돌려주되, server_name 이 주어지면 채널 옵션 grpc.ssl_target_name_override 로 인증서에서 확인할 이름을 그 값으로 바꿉니다. 채점기는 inventory.grpcops.internal 이라는 이름만 가진 기준 서버를 IP 주소로 부릅니다.

주소(127.0.0.1)와 인증서의 이름이 다를 때 확인을 끄는 것이 아니라, 확인할 이름을 알려 주는 것이 맞는 방법입니다. 쿠버네티스에서 파드 IP 로 바로 붙을 때가 이 경우입니다. server_name 을 주지 않았을 때는 기본 동작(주소로 확인)이 그대로여야 하며, 그러면 이 기준 서버에는 붙지 못합니다.

클라이언트에게 신원을 준다

/root/grpcops/tls/pki/orders.key 와 /root/grpcops/tls/pki/orders.crt 를 만드세요. 1단계의 CA 가 서명하고, CN 은 svc-orders, subjectAltName 은 URI:spiffe://grpcops.internal/ns/shop/sa/orders, extendedKeyUsage 는 clientAuth 입니다.

SPIFFE ID 는 「어느 신뢰 영역의, 어느 네임스페이스의, 어느 서비스 계정」 을 URI 하나로 적는 약속입니다. 서비스 메시가 파드마다 나눠 주는 인증서에도 이 모양의 URI 가 SAN 으로 들어갑니다.

서버가 클라이언트 인증서를 요구한다

/root/grpcops/tls/server.py 에 serve_mtls(port) 를 더하세요. serve 와 같되, 자격을 ssl_server_credentials(키 쌍 목록, root_certificates=ca.crt 의 바이트, require_client_auth=True) 로 만듭니다. 채점기는 클라이언트 인증서 없이, 다른 CA 가 서명한 인증서로, orders 인증서로 각각 붙어 봅니다.

require_client_auth 를 빼면 서버는 인증서를 요청만 하고 없어도 받아들입니다. 그러면 mTLS 처럼 보이지만 누구나 붙습니다. root_certificates 는 「어느 CA 가 서명한 클라이언트를 믿을지」 입니다. serve 는 그대로 두세요. 3단계 채점기가 계속 그것을 씁니다.

인증서의 신원으로 쓰기 호출을 허락한다

serve_mtls 의 서비스에서 Reserve 는 피어 인증서의 SAN 에 spiffe://grpcops.internal/ns/shop/sa/orders 가 있을 때만 처리하고, 아니면 PERMISSION_DENIED 로 끝내게 하세요. GetItem·ListItems 는 인증된 누구나 부를 수 있습니다. 신원은 context.auth_context() 의 x509_subject_alternative_name 에서 읽습니다. 채점기는 같은 CA 가 서명한 다른 서비스(report)의 인증서로도 부르고, 그 인증서에 메타데이터 x-spiffe-id 로 orders 를 적어 보내기도 합니다.

메타데이터는 클라이언트가 마음대로 적는 값이라 신원의 근거가 될 수 없습니다. 근거는 핸드셰이크에서 CA 서명을 확인한 인증서뿐입니다. auth_context() 의 값은 bytes 의 list 입니다. 재고 서비스 클래스는 type(inventory_impl.make_servicer(pb, rpc)) 로 얻어 상속하면 Reserve 만 덮어쓸 수 있습니다.