Vault와 External Secrets Operator로 Kubernetes Secret 관리 자동화하기 (2편) — cert-manager 인증서를 Ingress TLS로 연동하기

1편에서는 DB 계정 정보처럼 사람이 직접 만든 값을 Vault에 저장하고, External Secrets Operator(ESO)로 Kubernetes Secret으로 동기화해 MariaDB에 연결하는 과정을 다뤘다. 이번 2편에서는 같은 구조를 한 단계 더 확장해서, cert-manager가 발급한 TLS 인증서를 Vault에 저장하고 ESO로 다시 Kubernetes Secret으로 동기화해 Ingress에 사용하는 예를 실제로 구성했다.

cert-manager (Private CA)
  │
  │ *.cnapcloud.com 와일드카드 인증서 발급
  ▼
Kubernetes Secret (tls.crt / tls.key)
  │
  │ 수동 export
  ▼
Vault
  │
  │ ESO 동기화
  ▼
Kubernetes Secret (kubernetes.io/tls)
  │
  ▼
Ingress (TLS termination)
  │
  ▼
nginx 기본 웰컴 페이지

이런 식으로 인증서를 cert-manager → Vault → ESO 순서로 한 번 더 거치게 하는 이유는, cert-manager 없이도 여러 클러스터가 동일한 인증서를 공유할 수 있기 때문이다. 예를 들어 한 클러스터에서만 cert-manager로 실제 발급을 수행하고, 나머지 클러스터들은 ESO만으로 Vault에서 같은 인증서를 받아 쓰는 구조를 만들 수 있다.

이 실습에서는 실제 운영 도메인에 Let’s Encrypt 인증서를 발급하는 대신, cert-manager의 자체 CA(Private CA) 기능으로 *.cnapcloud.com 인증서를 발급해 흐름만 검증했다.

1. cert-manager 설치

helm repo add jetstack https://charts.jetstack.io
helm repo update

kubectl create namespace cert-manager

helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --set installCRDs=true
kubectl get pods -n cert-manager
NAME                                      READY   STATUS    RESTARTS   AGE
cert-manager-5c4b7b5c7b-6mk4c             1/1     Running   0          41s
cert-manager-cainjector-c8785f548-kxxmg   1/1     Running   0          41s
cert-manager-webhook-68877cb8f5-6c4zx     1/1     Running   0          41s

2. Private CA 기반 ClusterIssuer 구성

와일드카드 인증서는 원래 DNS-01 챌린지를 통해 Let’s Encrypt 같은 공인 CA에서 발급받는 것이 일반적이지만(Cloudflare + cert-manager 글 참고), 이번에는 외부 DNS/ACME 서버 없이 클러스터 안에서 자체 CA를 만들어 검증했다. selfSigned Issuer로 루트 CA 인증서를 하나 발급하고, 그 CA로 실제 와일드카드 인증서에 서명하는 2단계 구조다.

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: selfsigned-bootstrap
spec:
  selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: cnapcloud-root-ca
  namespace: cert-manager
spec:
  isCA: true
  commonName: cnapcloud-private-ca
  secretName: cnapcloud-root-ca-secret
  duration: 87600h
  privateKey:
    algorithm: ECDSA
    size: 256
  issuerRef:
    name: selfsigned-bootstrap
    kind: ClusterIssuer
    group: cert-manager.io
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: cnapcloud-private-ca
spec:
  ca:
    secretName: cnapcloud-root-ca-secret
kubectl apply -f selfsigned-issuer.yaml
kubectl get certificate -n cert-manager
kubectl get clusterissuer
NAME                READY   SECRET                     AGE
cnapcloud-root-ca   True    cnapcloud-root-ca-secret   9s

NAME                   READY   AGE
cnapcloud-private-ca   True    9s
selfsigned-bootstrap   True    9s

3. *.cnapcloud.com 와일드카드 인증서 발급

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: cnapcloud-wildcard
  namespace: cert-manager
spec:
  secretName: cnapcloud-wildcard-tls
  isCA: false
  duration: 2160h
  renewBefore: 360h
  privateKey:
    algorithm: ECDSA
    size: 256
  dnsNames:
    - "*.cnapcloud.com"
    - "cnapcloud.com"
  issuerRef:
    name: cnapcloud-private-ca
    kind: ClusterIssuer
    group: cert-manager.io
kubectl apply -f wildcard-cert.yaml
kubectl get certificate -n cert-manager cnapcloud-wildcard
NAME                 READY   SECRET                   AGE
cnapcloud-wildcard   True    cnapcloud-wildcard-tls   5s

발급된 인증서의 SAN을 확인해보면 와일드카드가 제대로 들어가 있다.

kubectl get secret cnapcloud-wildcard-tls -n cert-manager -o jsonpath='{.data.tls\.crt}' | base64 -d | \
  openssl x509 -noout -subject -issuer -ext subjectAltName
issuer=CN=cnapcloud-private-ca
X509v3 Subject Alternative Name: critical
    DNS:*.cnapcloud.com, DNS:cnapcloud.com

4. 발급된 인증서를 Vault에 저장

cert-manager는 발급한 인증서를 Kubernetes Secret에만 저장하므로, 이 Secret의 tls.crt/tls.key를 꺼내 Vault KV에 그대로 옮겨 담는다.

kubectl get secret cnapcloud-wildcard-tls -n cert-manager -o jsonpath='{.data.tls\.crt}' | base64 -d > wildcard-tls.crt
kubectl get secret cnapcloud-wildcard-tls -n cert-manager -o jsonpath='{.data.tls\.key}' | base64 -d > wildcard-tls.key

kubectl cp wildcard-tls.crt vault/vault-0:/tmp/wildcard-tls.crt
kubectl cp wildcard-tls.key vault/vault-0:/tmp/wildcard-tls.key

kubectl exec -n vault -it vault-0 -- sh -c "
export VAULT_TOKEN=$ROOT_TOKEN
vault kv put secret/tls/cnapcloud-wildcard \
  tls.crt=@/tmp/wildcard-tls.crt \
  tls.key=@/tmp/wildcard-tls.key
"

5. ESO로 Vault의 인증서를 다시 Kubernetes TLS Secret으로 동기화

Ingress를 붙일 demo 네임스페이스에 SecretStore와 ExternalSecret을 새로 만든다. 1편의 MariaDB 예제와 동일한 패턴이지만, target.template.typekubernetes.io/tls로 지정해서 Ingress가 바로 인식할 수 있는 타입의 Secret을 만든다는 점이 다르다.

apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
  name: vault-backend
  namespace: demo
spec:
  provider:
    vault:
      server: "http://vault.vault.svc.cluster.local:8200"
      path: "secret"
      version: "v2"
      auth:
        kubernetes:
          mountPath: "kubernetes"
          role: "eso-role-demo"
          serviceAccountRef:
            name: eso-vault-sa
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: cnapcloud-wildcard-tls
  namespace: demo
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: cnapcloud-wildcard-tls
    creationPolicy: Owner
    template:
      type: kubernetes.io/tls
  data:
    - secretKey: tls.crt
      remoteRef:
        key: tls/cnapcloud-wildcard
        property: tls.crt
    - secretKey: tls.key
      remoteRef:
        key: tls/cnapcloud-wildcard
        property: tls.key

이 실습에 설치된 ESO 버전은 external-secrets.io/v1beta1이 아니라 external-secrets.io/v1 CRD로 배포되어 있었다. SecretStore/ExternalSecret을 만들 때 클러스터에 등록된 CRD 버전을 kubectl api-resources | grep external-secrets로 먼저 확인하는 것이 안전하다. 또한 SecretStore는 네임스페이스 스코프라, serviceAccountRef가 가리키는 ServiceAccount와 Vault role의 bound_service_account_namespaces도 반드시 SecretStore와 같은 네임스페이스(demo)로 맞춰야 admission webhook 검증을 통과한다.

kubectl apply -f secretstore-demo.yaml
kubectl apply -f externalsecret-tls.yaml

kubectl get secretstore -n demo
kubectl get externalsecret -n demo
kubectl get secret cnapcloud-wildcard-tls -n demo
NAME            AGE   STATUS   CAPABILITIES   READY
vault-backend   8s    Valid    ReadWrite      True

NAME                     STORETYPE     STORE           REFRESH INTERVAL   STATUS         READY   LAST SYNC
cnapcloud-wildcard-tls   SecretStore   vault-backend   1h                 SecretSynced   True    5s

NAME                     TYPE                DATA   AGE
cnapcloud-wildcard-tls   kubernetes.io/tls   2      5s

6. nginx 기본 웰컴 페이지를 백엔드로 하는 Ingress 구성

인증서가 실제로 서빙되는지 확인하기 위한 백엔드는 별도 애플리케이션 없이 nginx 기본 이미지의 웰컴 페이지를 그대로 사용한다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-welcome
  namespace: demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx-welcome
  template:
    metadata:
      labels:
        app: nginx-welcome
    spec:
      containers:
        - name: nginx
          image: nginx:latest
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: nginx-welcome
  namespace: demo
spec:
  selector:
    app: nginx-welcome
  ports:
    - port: 80
      targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nginx-welcome
  namespace: demo
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - demo.cnapcloud.com
      secretName: cnapcloud-wildcard-tls
  rules:
    - host: demo.cnapcloud.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: nginx-welcome
                port:
                  number: 80

kubernetes.io/ingress.class 어노테이션은 deprecated 되어 최신 ingress-nginx에서는 무시되므로, 반드시 spec.ingressClassName으로 지정해야 CLASS가 제대로 붙는다.

kubectl apply -f nginx-demo.yaml
kubectl get ingress -n demo nginx-welcome
NAME            CLASS   HOSTS                ADDRESS         PORTS     AGE
nginx-welcome   nginx   demo.cnapcloud.com   192.168.0.180   80, 443   28s

7. 접속 테스트

실제 DNS에 demo.cnapcloud.com을 등록하지 않고, --resolve로 ingress-nginx의 LoadBalancer IP를 직접 지정해서 확인했다.

# HTTP는 HTTPS로 강제 리다이렉트된다
curl -s -o /dev/null -w "%{http_code}\n" --resolve demo.cnapcloud.com:80:192.168.0.180 http://demo.cnapcloud.com/
308
# 인증서 검증을 건너뛰고(-k) 접속하면 nginx 기본 페이지가 응답한다
curl -sk --resolve demo.cnapcloud.com:443:192.168.0.180 https://demo.cnapcloud.com/ | head -3
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
# TLS 핸드셰이크에서 확인되는 issuer가 Vault를 거쳐 온 Private CA와 일치하는지 확인
curl -sk -v --resolve demo.cnapcloud.com:443:192.168.0.180 https://demo.cnapcloud.com/ 2>&1 | grep "issuer:"
*  issuer: CN=cnapcloud-private-ca

마지막으로, Private CA의 루트 인증서를 --cacert로 직접 넘겨서 인증서 검증까지 정상적으로 통과하는지 확인했다.

kubectl get secret cnapcloud-root-ca-secret -n cert-manager -o jsonpath='{.data.tls\.crt}' | base64 -d > root-ca.crt

curl -s --cacert root-ca.crt --resolve demo.cnapcloud.com:443:192.168.0.180 https://demo.cnapcloud.com/ | grep -o "<title>.*</title>"
<title>Welcome to nginx!</title>

-k 없이도, 즉 자체 서명이 아니라 정식으로 신뢰되는 CA 체인으로 접속에 성공한 것이다. cert-manager가 발급한 인증서가 Vault를 거쳐 ESO로 동기화되고, 그 값이 실제 Ingress의 TLS 종료(termination)에 그대로 사용된다는 것을 end-to-end로 확인했다.

8. 마무리

이 구조를 실제 운영에 적용한다면, cert-manager의 CA를 Let’s Encrypt 같은 공인 CA로 바꾸는 것 외에는 달라지는 부분이 없다. *.cnapcloud.com처럼 실제 서비스가 걸려 있는 도메인이라면 Cloudflare + cert-manager 글에서 다룬 DNS-01 챌린지 방식으로 ClusterIssuer만 교체하면 된다.

다만 주의할 점이 하나 있다. 인증서는 만료 전에 cert-manager가 자동으로 갱신하고, 그 결과인 Kubernetes Secret도 자동으로 바뀐다. 하지만 Vault와 그 값을 받아쓰는 다른 클러스터들에 대한 재동기화는 이 구조에서 별도로 자동화되어 있지 않다. cert-manager가 갱신한 Secret을 Vault로 다시 밀어넣는 과정(4장에서 수동으로 실행한 vault kv put 단계)까지 자동화하려면, cert-manager의 갱신 이벤트를 감지해 Vault에 write하는 별도의 컨트롤러나 CronJob이 추가로 필요하다.

정리하면, cert-manager → Vault → ESO → Ingress로 이어지는 이 구조는 DB 계정 정보(1편)와 TLS 인증서(2편) 모두에서 동일한 패턴으로 동작한다는 것을 확인했다. Vault가 여러 종류의 Secret에 대해 단일한 진실의 원천 역할을 하고, ESO는 그 값을 각 클러스터의 Kubernetes Secret으로 동기화하는 얇은 다리 역할만 한다는 것이 이 구조 전체를 관통하는 핵심이다.