14분 읽기

쿠버네티스 서비스 사이에 mTLS 적용하기

클러스터 안에서 통신한다고 호출자를 자동으로 믿을 수 있는 것은 아니다. 같은 Kubernetes namespace 안에도 잘못 배포된 Pod나 유출된 ServiceAccount 토큰이 있을 수 있다. Gateway가 신뢰하는 Worker만 요청을 보내도록 하려면 양쪽이 인증서를 확인하는 mTLS를 사용할 수 있다.

이 글에서는 가상의 platform namespace에서 task-workerinternal-gateway를 호출하는 상황을 축소해 본다. 인증서 필드 자체가 낯설다면 먼저 OpenSSL 인증서 설정 읽기: req, CN, SAN, KU, EKU는 무엇일까를 읽고 오면 좋다.

mTLS로 양쪽의 신원 확인하기

일반 TLS에서는 Worker가 Gateway 인증서를 검증한다. mTLS에서는 Gateway도 Worker 인증서를 검증한다.

일반 TLS  Worker ── Gateway 서버 인증서 검증 ──▶ Gateway
mTLS      Worker ◀── 양쪽이 CA 기준으로 상대 인증서 검증 ──▶ Gateway

이 예시의 역할은 다음과 같다.

주체갖고 있는 것확인하는 것
task-worker클라이언트 인증서·개인키, Gateway CA 인증서Gateway 인증서 체인과 SAN
internal-gateway서버 인증서·개인키, Worker 인증서 발급 CA 인증서Worker 인증서 체인과 허용된 호출자 신원
내부 CACA 인증서·개인키서버와 클라이언트 인증서 발급
가상의 platform namespace에서 Worker와 Gateway가 각각 인증서와 CA 번들을 마운트하고 mTLS로 통신하는 구조
Worker는 Gateway의 SAN을, Gateway는 Worker의 클라이언트 인증서 체인을 확인한다. 두 검증 모두 각 Pod에 있는 신뢰 CA 번들에서 시작한다.

Service DNS와 인증서 SAN 맞추기

Kubernetes Service의 정규 DNS 이름은 보통 다음 형태다.

<service>.<namespace>.svc.cluster.local

따라서 이 예시에서 Worker가 호출할 주소를 다음처럼 정한다.

https://internal-gateway.platform.svc.cluster.local:8443

같은 namespace의 Pod에서는 https://internal-gateway:8443도 DNS search suffix 덕분에 해석된다. 하지만 TLS 검증은 DNS가 최종적으로 찾은 IP가 아니라, 클라이언트가 URL과 SNI에 사용한 호스트 이름과 인증서 SAN을 비교한다.

그래서 Gateway 서버 인증서에는 실제 호출 표기를 넣는다.

dnsNames:
  - internal-gateway.platform.svc.cluster.local
  - internal-gateway.platform.svc
  - internal-gateway

운영에서는 전체 주소를 적은 FQDN 하나를 표준으로 쓰는 편이 가장 단순하다. 짧은 이름과 FQDN을 섞어 쓴다면, 실제로 사용하는 모든 이름을 SAN에 포함해야 한다.

cert-manager로 내부 인증서 준비하기

아래 YAML은 cert-manager가 이미 설치되어 있고, platform namespace에 내부 CA 키 쌍을 담은 platform-mtls-ca-keypair Secret이 준비됐다는 전제다.

CA 키 쌍 Secret에는 CA 인증서 tls.crt와 CA 개인키 tls.key가 들어간다. 이 개인키는 인증서 발급 권한 그 자체다. 애플리케이션 Deployment에 마운트하지 말고, CA를 만들고 보관하는 절차도 별도로 통제해야 한다.

apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
  name: platform-ca
  namespace: platform
spec:
  ca:
    secretName: platform-mtls-ca-keypair

Issuer는 같은 namespace에서만 사용할 수 있다. 여러 namespace가 같은 CA를 쓴다면 ClusterIssuer를 검토할 수 있지만, 권한 범위와 CA 분리 전략도 함께 결정해야 한다.

1. Gateway 서버 인증서 만들기

Certificate 리소스를 선언하면 cert-manager가 개인키와 발급받은 인증서를 지정한 Secret에 넣는다.

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: internal-gateway
  namespace: platform
spec:
  secretName: internal-gateway-tls
  commonName: internal-gateway
  dnsNames:
    - internal-gateway.platform.svc.cluster.local
    - internal-gateway.platform.svc
    - internal-gateway
  usages:
    - server auth
  duration: 2160h # 90일
  renewBefore: 360h # 만료 15일 전 갱신 시도
  issuerRef:
    name: platform-ca
    kind: Issuer

여기서 commonName은 사람이 확인할 subject 이름으로 두고, 서버 이름 검증은 dnsNames로 한다. usages: [server auth]는 이 인증서를 Gateway 서버 역할에만 쓰겠다는 뜻이다.

생성된 internal-gateway-tls는 보통 tls.crttls.key를 갖는다. Kubernetes의 kubernetes.io/tls Secret 타입은 이 두 key가 존재하는지 확인하지만, 인증서 내용의 유효성까지 Kubernetes API가 검증하는 것은 아니다. 발급 상태는 cert-manager의 Certificate 상태로 확인한다.

2. Worker 클라이언트 인증서 만들기

Worker 인증서에는 호출자가 누구인지 나타내는 신원 정보가 필요하다. 이 예시에서는 CN을 task-worker로 둔다.

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: task-worker-client
  namespace: platform
spec:
  secretName: task-worker-client-tls
  commonName: task-worker
  usages:
    - client auth
  duration: 2160h
  renewBefore: 360h
  issuerRef:
    name: platform-ca
    kind: Issuer

Gateway 또는 앞단 프록시는 인증서 검증이 끝난 뒤 클라이언트 인증서의 subject나 SAN을 읽어 task-worker만 허용할 수 있다. subject나 URI SAN 중 어떤 값을 서비스 신원으로 사용할지는 애플리케이션이나 프록시의 인증 정책으로 정한다. 인증서가 유효하다는 사실과 그 서비스를 어떤 작업까지 허용할지는 별개의 문제다.

3. CA 공개 인증서 공유하기

서버 인증서와 클라이언트 인증서는 모두 같은 내부 CA가 발급했다고 가정한다. Worker는 Gateway를 검증할 때, Gateway는 Worker를 검증할 때 CA 공개 인증서가 필요하다.

아래 ConfigMap에는 CA 공개 인증서만 넣는다. CA 개인키는 절대 넣지 않는다.

apiVersion: v1
kind: ConfigMap
metadata:
  name: platform-mtls-ca-bundle
  namespace: platform
data:
  ca.crt: |
    -----BEGIN CERTIFICATE-----
    # 내부 CA의 공개 인증서 PEM 내용을 넣는다.
    -----END CERTIFICATE-----

실제 운영에서는 이 ConfigMap을 Git에서 손으로 유지하기보다, 신뢰 번들을 배포하는 절차를 자동화하는 편이 좋다. CA 교체 시에는 새 CA와 기존 CA를 일정 기간 함께 신뢰해야 인증서 갱신 중인 Pod가 끊기지 않는다.

4. Gateway에 인증서 연결하기

먼저 Service는 TLS 포트로 Gateway Pod를 가리킨다.

apiVersion: v1
kind: Service
metadata:
  name: internal-gateway
  namespace: platform
spec:
  selector:
    app: internal-gateway
  ports:
    - name: https
      port: 8443
      targetPort: https

Gateway는 자신의 tls.crt, tls.key와 클라이언트 인증서를 검증할 CA 번들을 함께 마운트한다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: internal-gateway
  namespace: platform
spec:
  replicas: 2
  selector:
    matchLabels:
      app: internal-gateway
  template:
    metadata:
      labels:
        app: internal-gateway
    spec:
      containers:
        - name: gateway
          image: example.com/example/internal-gateway:1.0.0
          ports:
            - name: https
              containerPort: 8443
          args:
            - --tls-cert=/var/run/mtls/server/tls.crt
            - --tls-key=/var/run/mtls/server/tls.key
            - --client-ca=/var/run/mtls/client-ca/ca.crt
            - --require-client-cert=true
          volumeMounts:
            - name: gateway-server-tls
              mountPath: /var/run/mtls/server
              readOnly: true
            - name: mtls-ca-bundle
              mountPath: /var/run/mtls/client-ca
              readOnly: true
      volumes:
        - name: gateway-server-tls
          secret:
            secretName: internal-gateway-tls
        - name: mtls-ca-bundle
          configMap:
            name: platform-mtls-ca-bundle

args의 이름은 예시다. Java/Spring, Envoy, NGINX, Go 서버마다 TLS 설정 방식은 다르다. 중요한 것은 서버가 클라이언트 인증서를 요구하고, 그 검증에 사용할 CA 번들을 별도로 설정한다는 점이다.

5. Worker에 인증서 연결하기

Worker도 자신의 인증서·개인키와 Gateway를 검증할 CA 번들을 읽어야 한다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: task-worker
  namespace: platform
spec:
  replicas: 1
  selector:
    matchLabels:
      app: task-worker
  template:
    metadata:
      labels:
        app: task-worker
    spec:
      containers:
        - name: worker
          image: example.com/example/task-worker:1.0.0
          env:
            - name: GATEWAY_BASE_URL
              value: https://internal-gateway.platform.svc.cluster.local:8443
            - name: GATEWAY_CA_FILE
              value: /var/run/mtls/server-ca/ca.crt
            - name: CLIENT_CERT_FILE
              value: /var/run/mtls/client/tls.crt
            - name: CLIENT_KEY_FILE
              value: /var/run/mtls/client/tls.key
          volumeMounts:
            - name: worker-client-tls
              mountPath: /var/run/mtls/client
              readOnly: true
            - name: mtls-ca-bundle
              mountPath: /var/run/mtls/server-ca
              readOnly: true
      volumes:
        - name: worker-client-tls
          secret:
            secretName: task-worker-client-tls
        - name: mtls-ca-bundle
          configMap:
            name: platform-mtls-ca-bundle

Worker의 HTTP 클라이언트는 GATEWAY_CA_FILE을 신뢰할 CA 목록으로 사용하고, CLIENT_CERT_FILECLIENT_KEY_FILE을 자신의 인증서와 개인키로 읽는다. Gateway의 서버 인증서 SAN에는 GATEWAY_BASE_URL의 호스트 이름이 들어 있어야 한다.

연결이 실패하면 어디부터 확인할까?

증상흔한 원인확인 방법
certificate verify failedWorker trust store에 Gateway CA가 없거나 SAN이 URL host와 다름openssl x509 -in tls.crt -noout -text로 SAN·issuer 확인
bad certificate 또는 연결 과정 오류Gateway가 Worker 인증서를 발급한 CA를 신뢰하지 않음Gateway의 클라이언트 CA 번들과 클라이언트 인증서 발급자 확인
certificate requiredWorker가 client cert/key를 전달하지 않음Worker의 TLS client 설정과 Secret mount 경로 확인
인증서 갱신 뒤 일부 Pod만 실패갱신된 Secret을 프로세스가 다시 읽지 않거나 CA 교체 순서가 잘못됨인증서 만료일, Pod 재시작·설정 다시 읽기, CA 공개 인증서 배포 순서 확인

클러스터 안에서 빠르게 확인할 때는 Worker Pod에서 직접 요청해 볼 수 있다.

kubectl exec -n platform deploy/task-worker -- \
  curl --fail --verbose \
  --cacert /var/run/mtls/server-ca/ca.crt \
  --cert /var/run/mtls/client/tls.crt \
  --key /var/run/mtls/client/tls.key \
  https://internal-gateway.platform.svc.cluster.local:8443/health

이미지에 curl이 없으면 디버그 전용 Pod를 사용하거나 애플리케이션의 HTTP 클라이언트 로그로 같은 정보를 확인한다. 운영 Pod에 디버그 도구를 억지로 넣을 필요는 없다.

운영할 때 기억할 세 가지

  1. CA 개인키를 애플리케이션에 배포하지 않는다. 서버와 Worker에는 각자 사용할 인증서와 개인키, CA의 공개 인증서만 필요하다.
  2. Secret 갱신과 애플리케이션 재로딩을 함께 확인한다. Kubernetes volume의 파일이 바뀌어도 JVM이나 프록시가 자동으로 새 key pair를 다시 읽는지는 별개다.
  3. CA 교체는 겹치는 신뢰 기간을 둔다. 새 CA 공개 인증서를 먼저 신뢰 목록에 추가하고 새 서비스 인증서를 발급한 뒤, 기존 CA를 목록에서 제거한다.

핵심 정리

Kubernetes에서 mTLS를 적용하는 핵심은 인증서 파일을 Pod에 넣는 작업 자체보다, Service DNS·SAN·호출 URL·신뢰 CA·서비스 identity를 같은 설계로 맞추는 일이다.

  • Gateway server certificate에는 실제 Service 호출 DNS를 SAN으로 넣는다.
  • Worker client certificate와 Gateway server certificate는 역할을 나눠 발급한다.
  • 각 워크로드는 상대 인증서를 검증할 CA 공개 번들만 신뢰한다.
  • Certificate와 Secret 갱신 이후 애플리케이션이 새 인증서를 반영하는지까지 운영 범위다.

참고 자료