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.type을 kubernetes.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/v1CRD로 배포되어 있었다.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으로 동기화하는 얇은 다리 역할만 한다는 것이 이 구조 전체를 관통하는 핵심이다.