쿠버네티스에 서비스를 올리고 나면 꼭 밟는 단계가 TLS다. 예전에는 인증서 만료일을 캘린더에 적어두고 분기마다 갱신하던 시절이 있었는데, 클러스터 안에서는 그런 수동 작업을 cert-manager가 거의 다 가져간다. 나도 새 클러스터를 세팅할 때마다 ingress-nginx 깔고 바로 cert-manager를 얹는 순서가 손에 붙어버렸다. 이 글은 그 순서를 그대로 정리한 것이다.
요약부터 말하자면, cert-manager를 Helm으로 설치하고 Let's Encrypt를 가리키는 ClusterIssuer를 하나 만든 뒤, Ingress에 애너테이션 한 줄만 붙이면 인증서가 자동으로 발급된다. 만료가 다가오면 cert-manager가 알아서 갱신한다. 와일드카드 인증서가 필요하면 HTTP-01 대신 DNS-01 방식을 써야 한다.
1. cert-manager가 무엇이고 ACME는 왜 필요한가
cert-manager는 쿠버네티스 안에서 TLS 인증서를 발급, 저장, 갱신하는 일을 대신해주는 컨트롤러다. Certificate라는 커스텀 리소스를 하나 선언해두면, cert-manager가 발급기관과 통신해서 인증서를 받아오고 그 결과를 쿠버네티스 Secret에 넣어준다.
Let's Encrypt는 무료로 인증서를 발급해주는 공인 CA다. 발급받으려면 ACME(Automatic Certificate Management Environment)라는 프로토콜을 따라야 하는데, 핵심은 '이 도메인이 정말 당신 것이냐'를 자동으로 증명하는 절차다. 이 증명 과정을 cert-manager가 solver라는 이름으로 처리한다.
검증 방식은 두 가지다. HTTP-01은 cert-manager가 임시 경로(/.well-known/acme-challenge/...)에 토큰을 올려두고 Let's Encrypt가 HTTP로 접속해 확인하는 방식이다. DNS-01은 _acme-challenge TXT 레코드를 DNS에 넣어 소유권을 증명한다. 와일드카드(*.example.com)는 HTTP-01로는 안 되고 DNS-01만 가능하다.
2. Helm으로 cert-manager 설치
공식에서 권장하는 방식은 OCI 레지스트리에서 바로 설치하는 것이다. CRD(커스텀 리소스 정의)를 같이 깔아야 ClusterIssuer, Certificate 같은 리소스를 쓸 수 있으니 crds.enabled=true를 빼먹지 말자. 버전 번호는 저장소 상태에 따라 다를 수 있다.
$ helm install cert-manager oci://quay.io/jetstack/charts/cert-manager \
--version v1.21.2 \
--namespace cert-manager \
--create-namespace \
--set crds.enabled=true
Pulled: quay.io/jetstack/charts/cert-manager:v1.21.2
NAME: cert-manager
LAST DEPLOYED: Sat Oct 4 10:21:33 2026
NAMESPACE: cert-manager
STATUS: deployed
REVISION: 1
예전 jetstack 저장소가 익숙하다면 그쪽도 아직 동작한다. 다만 공식 문서는 OCI 차트를 source of truth로 본다.
$ helm repo add jetstack https://charts.jetstack.io --force-update
$ helm install cert-manager jetstack/cert-manager \
--namespace cert-manager --create-namespace \
--version v1.21.2 --set crds.enabled=true
파드가 세 개(컨트롤러, webhook, cainjector) 다 뜨는지 확인한다. webhook이 Running이 되기 전에 ClusterIssuer를 적용하면 admission 오류가 나니 일단 기다린다.
$ kubectl get pods -n cert-manager NAME READY STATUS RESTARTS AGE cert-manager-5d7f97b46d-8xq2v 1/1 Running 0 48s cert-manager-cainjector-69d6f4d487-tz9kl 1/1 Running 0 48s cert-manager-webhook-6c9dd55dc4-4pnrw 1/1 Running 0 48s
3. ClusterIssuer로 Let's Encrypt 연결 (HTTP-01)
Issuer는 특정 네임스페이스 안에서만, ClusterIssuer는 클러스터 전체에서 쓸 수 있는 발급기 설정이다. 여러 네임스페이스에서 인증서를 뽑을 거면 ClusterIssuer가 편하다.
처음부터 운영(production)으로 가지 말고 스테이징으로 먼저 테스트하는 걸 강하게 권한다. Let's Encrypt 운영 환경은 발급 횟수 제한(rate limit)이 빡빡해서, 설정이 틀린 채로 반복 시도하다 보면 같은 도메인으로 한동안 막힌다. 스테이징은 제한이 느슨하고, 대신 브라우저가 신뢰하지 않는 가짜 루트로 서명한다.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
email: admin@sarc.io
server: https://acme-staging-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-staging-account-key
solvers:
- http01:
ingress:
ingressClassName: nginx
email은 만료 임박 알림을 받을 주소다. privateKeySecretRef는 ACME 계정의 개인키를 저장할 Secret 이름인데, 없으면 cert-manager가 알아서 만든다. server만 운영 URL로 바꾸면 그대로 운영용 발급기가 된다.
$ kubectl apply -f clusterissuer-staging.yaml clusterissuer.cert-manager.io/letsencrypt-staging created $ kubectl get clusterissuer NAME READY AGE letsencrypt-staging True 12s
READY가 True면 ACME 계정 등록까지 성공한 것이다. False면 kubectl describe clusterissuer letsencrypt-staging으로 이벤트를 본다. 이메일 형식이 틀렸거나 클러스터가 바깥으로 HTTPS를 못 나갈 때 여기서 막힌다.
운영용은 name과 server, Secret 이름만 바꿔 하나 더 만들어 둔다.
spec:
acme:
email: admin@sarc.io
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-prod-account-key
solvers:
- http01:
ingress:
ingressClassName: nginx
4. Ingress에 애너테이션으로 연동
Ingress를 쓰면 Certificate 리소스를 직접 쓸 필요가 없다. cert-manager의 ingress-shim이 애너테이션을 보고 Certificate를 자동으로 만들어준다. 애너테이션 한 줄과 tls 블록만 추가하면 된다.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web
annotations:
cert-manager.io/cluster-issuer: letsencrypt-staging
spec:
ingressClassName: nginx
tls:
- hosts:
- app.sarc.io
secretName: app-sarc-io-tls
rules:
- host: app.sarc.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web
port:
number: 80
여기서 secretName이 중요하다. cert-manager가 발급한 인증서를 이 이름의 Secret에 넣고, ingress-nginx가 그 Secret을 읽어 TLS를 종료한다. 적용하면 Certificate가 생기고 발급이 시작된다.
$ kubectl apply -f ingress.yaml $ kubectl get certificate NAME READY SECRET AGE app-sarc-io-tls False app-sarc-io-tls 8s
처음엔 READY가 False다. HTTP-01 검증이 끝나야 True로 바뀐다. 보통 수십 초에서 2~3분 안에 끝나지만, DNS 전파나 네트워크 상황에 따라 더 걸릴 수 있다. 여기서 한 번 걸렸다. app.sarc.io의 A 레코드가 Ingress 로드밸런서를 가리키지 않으면, Let's Encrypt가 챌린지 경로에 접속하지 못해 영원히 False다. 도메인이 실제로 클러스터로 들어와야 HTTP-01이 성공한다.
검증이 끝나면 이렇게 바뀐다.
$ kubectl get certificate NAME READY SECRET AGE app-sarc-io-tls True app-sarc-io-tls 2m14s
스테이징으로 여기까지 확인했으면 애너테이션을 letsencrypt-prod로 바꾸고, 기존 Secret은 지운 뒤(kubectl delete secret app-sarc-io-tls) 다시 발급받는다. 그래야 브라우저가 신뢰하는 진짜 인증서가 들어온다.
5. DNS-01로 와일드카드 인증서 발급 (Route53)
*.sarc.io처럼 서브도메인 전체를 덮는 와일드카드 인증서는 HTTP-01로는 발급되지 않는다. DNS-01만 가능하다. AWS를 쓴다면 Route53 solver를 붙인다.
인증 수단은 두 가지다. EKS에서 IRSA나 Pod Identity로 cert-manager 파드에 권한을 붙였다면 설정이 거의 비어도 된다. region은 환경변수에서 자동으로 잡는다.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-dns
spec:
acme:
email: admin@sarc.io
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-dns-account-key
solvers:
- dns01:
route53:
region: ap-northeast-2
hostedZoneID: Z0123456789ABCDEFGHIJ
IRSA를 안 쓰고 액세스 키로 간다면 Secret을 하나 만들어 참조한다. cert-manager에 주는 IAM 권한은 해당 호스티드 존의 route53:ChangeResourceRecordSets와 route53:GetChange, route53:ListHostedZonesByName 정도면 된다.
solvers:
- dns01:
route53:
region: ap-northeast-2
hostedZoneID: Z0123456789ABCDEFGHIJ
accessKeyIDSecretRef:
name: route53-credentials
key: access-key-id
secretAccessKeySecretRef:
name: route53-credentials
key: secret-access-key
와일드카드는 Ingress 애너테이션으로는 깔끔하게 안 나오니, Certificate를 직접 선언하는 쪽이 명확하다. dnsNames에 와일드카드와 루트를 같이 넣는 패턴을 자주 쓴다.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wildcard-sarc-io
namespace: default
spec:
secretName: wildcard-sarc-io-tls
issuerRef:
name: letsencrypt-dns
kind: ClusterIssuer
dnsNames:
- "sarc.io"
- "*.sarc.io"
DNS-01은 클러스터로 인바운드 트래픽이 들어오지 않아도 발급된다. 내부용 서비스나 로드밸런서가 아직 없는 단계에서도 인증서를 미리 받아둘 수 있다는 게 HTTP-01과 다른 점이다.
6. 발급 실패 디버깅
인증서가 계속 False에 머무르면 리소스 체인을 따라 내려가며 본다. cert-manager는 Certificate → CertificateRequest → Order → Challenge 순서로 하위 리소스를 만든다. 실패 원인은 보통 제일 아래 Challenge에 적혀 있다.
먼저 Certificate의 이벤트를 본다.
$ kubectl describe certificate app-sarc-io-tls ... Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal Issuing 1m cert-manager Issuing certificate as Secret does not exist Normal Requested 1m cert-manager Created new CertificateRequest resource "app-sarc-io-tls-1"
그다음 CertificateRequest, 이어서 Order와 Challenge로 내려간다. Challenge의 상태 블록에 있는 Reason이 핵심이다.
$ kubectl get challenge
NAME STATE DOMAIN AGE
app-sarc-io-tls-1-xxxx pending app.sarc.io 40s
$ kubectl describe challenge app-sarc-io-tls-1-xxxx
...
Status:
Presented: true
Processing: true
Reason: Waiting for HTTP-01 challenge propagation: failed to perform
self check GET request: dial tcp ...: connect: connection refused
State: pending
자주 만나는 원인 몇 가지를 정리한다.
- self check 실패 (connection refused / timeout): 도메인 A 레코드가 Ingress 로드밸런서를 안 가리키거나, 보안 그룹에서 80 포트가 막혔다. HTTP-01은 80 포트로 들어오는 요청을 받아야 한다.
- DNS-01 propagation 대기가 끝나지 않음: TXT 레코드가 아직 전파 안 됐거나, IAM 권한이 모자라 레코드를 못 넣는 경우다.
Challenge이벤트에 Route53 API 에러가 그대로 찍힌다. - rate limit 초과:
429 urn:ietf:params:acme:error:rateLimited. 운영 환경에서 반복 발급하다 걸린 것이다. 스테이징에서 먼저 돌려야 하는 이유다. - ClusterIssuer가 Ready가 아님: 상위가 깨져 있으면 하위는 생기지도 않는다.
kubectl get clusterissuer부터 확인한다.
cert-manager 공식 CLI인 cmctl을 깔아두면 체인을 한 번에 요약해준다. 어디서 막혔는지 리소스를 일일이 describe 하지 않아도 된다.
$ cmctl status certificate app-sarc-io-tls Name: app-sarc-io-tls Namespace: default Conditions: Ready: False, Reason: MissingData, Message: Issuing certificate as Secret does not exist Issuer: Name: letsencrypt-prod Kind: ClusterIssuer ... Order: Name: app-sarc-io-tls-1-xxxx State: pending
7. 자동 갱신 확인
Let's Encrypt 인증서는 유효기간이 90일이다. cert-manager는 기본적으로 만료 30일 전부터 갱신을 시도한다. 내가 할 일은 사실상 없고, 갱신이 제대로 걸려 있는지 Certificate의 만료, 갱신 시각만 확인해두면 된다.
$ kubectl get certificate app-sarc-io-tls -o jsonpath='{.status.notAfter} {.status.renewalTime}'
2027-01-02T09:15:00Z 2026-12-03T09:15:00Z
renewalTime이 비어 있거나 과거면 갱신이 제대로 스케줄되지 않은 것이니 이벤트를 다시 본다. 갱신은 새 CertificateRequest를 만들어 같은 Secret을 덮어쓰는 식으로 일어나고, ingress-nginx는 Secret 변경을 감지해 무중단으로 새 인증서를 적용한다.
8. 정리
순서만 지키면 어렵지 않다. Helm으로 cert-manager를 깔고, 스테이징 ClusterIssuer로 먼저 검증한 뒤 운영으로 넘기고, Ingress엔 애너테이션 한 줄이면 끝난다. 와일드카드가 필요하면 DNS-01로 Route53을 붙이고, 막히면 Certificate부터 Challenge까지 체인을 따라 내려가며 Reason을 읽으면 된다. 수동 갱신에서 벗어나는 값은 충분히 크다.