상태: 초안 (2026-10-26), 실측 반영. 실측은 2026-10-26 에 TT Lab 클러스터(cert-manager 1.21 계열)에서 읽기만 해서 얻었습니다.
kubectl get·logs, Secret 의 인증서를openssl x509 -noout -dates로 날짜만 읽는 것, 공개 주소에openssl s_client로 붙어 보는 것까지입니다. 공개 이름·내부 객체 이름은 가렸습니다(<공개 이름 A>,<클러스터 이슈어>). 공식 문서와 RFC 는 같은 날 원문을 열어 확인했습니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」, 확인하지 못한 것은 「확인 못 함」, 짐작은 「추정」으로 적습니다.
목차
- 1. 사람이 인증서를 갱신하면 생기는 일
- 2. 객체 다섯과 그것을 움직이는 컨트롤러
- 3. ACME: 「이 이름이 정말 네 것이냐」를 증명하는 규약
- 4. 누가 서명하나: SelfSigned, CA, ACME
- 5. 언제 갱신하나
- 6. 웹훅과 cainjector: 왜 컴포넌트가 셋인가
- 7. 게이트웨이·Ingress 와 이어지는 법: shim
- 8. 실측: TT Lab 클러스터에서 읽은 것
- 9. 흔한 장애와 확인 순서
- 10. 정리
- 참고
1. 사람이 인증서를 갱신하면 생기는 일
Let's Encrypt 인증서는 90일짜리입니다. 석 달에 한 번 누군가 달력을 보고 갱신 명령을 쳐야 한다는 뜻이고, 그 누군가는 언젠가 잊습니다. 인증서가 만료되면 브라우저는 경고를 띄우고, 서비스끼리 TLS 로 말하던 곳은 통째로 끊깁니다. 사람의 기억에 기대는 일은 반드시 한 번은 실패합니다.
cert-manager 는 이 문제를 쿠버네티스다운 방식으로 풉니다. 사람은 「이런 인증서가 이 Secret 에 늘 있어야 한다」는 원하는 상태만 객체로 적습니다. 나머지, 즉 키 만들기, 인증 기관(CA)에 요청하기, 도메인 소유 증명하기, Secret 에 쓰기, 만료 전에 다시 받기는 컨트롤러가 계속 돌면서 맞춥니다. Deployment 를 적으면 파드가 생기고 죽으면 다시 뜨는 것과 같은 원리입니다.
이 글은 그 「맞추는 과정」을 한 겹씩 엽니다. 어떤 객체가 어떤 순서로 생기는지(2절), ACME 서버와 무엇을 주고받는지(3절), 누가 서명하는지(4절), 언제 갱신하는지(5절), 웹훅과 cainjector 는 왜 따로 있는지(6절), 게이트웨이와는 어떻게 이어지는지(7절)를 본 다음, TT Lab 클러스터에서 실제로 읽은 상태와 사고 하나(8절), 흔한 장애(9절)로 마무리합니다.
2. 객체 다섯과 그것을 움직이는 컨트롤러
cert-manager 의 CRD 는 여섯 가지입니다. 이 가운데 사람이 직접 쓰는 것은 둘(이슈어와 Certificate)이고, 나머지는 컨트롤러가 일을 나누려고 만드는 중간 객체입니다.
| 객체 | 범위 | 누가 만드나 | 무엇을 담나 |
|---|---|---|---|
Issuer / ClusterIssuer |
네임스페이스 / 클러스터 | 사람 | 누가 서명하나: ACME(Let's Encrypt 등), CA, SelfSigned, Vault, 외부 이슈어 |
Certificate |
네임스페이스 | 사람 또는 shim | 원하는 인증서: 이름 목록, 이슈어, Secret 이름, 수명·갱신 시점, 키 종류 |
CertificateRequest |
네임스페이스 | 컨트롤러 | CSR 한 장. 리비전 번호가 붙고, 승인(Approved) 뒤 서명된다 |
Order |
네임스페이스 | 컨트롤러(ACME 만) | ACME 서버의 주문 하나와 그 URL |
Challenge |
네임스페이스 | 컨트롤러(ACME 만) | 이름 하나에 대한 소유 증명 하나 |
가장 짧은 사용 예는 이렇습니다. Let's Encrypt 에 HTTP-01 로 증명하고, 증명용 경로를 Gateway API 의 HTTPRoute 로 여는 ClusterIssuer 와, 그 이슈어로 인증서 하나를 원하는 Certificate 입니다.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata: { name: <클러스터 이슈어> }
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef: { name: <ACME 계정 키 Secret> } # 계정 키는 여기 한 번 만들어 계속 쓴다
solvers:
- http01:
gatewayHTTPRoute:
parentRefs: [{ kind: Gateway, name: <게이트웨이>, namespace: <네임스페이스> }]
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata: { name: <인증서> }
spec:
secretName: <인증서 Secret> # 결과가 쓰일 자리
dnsNames: [<공개 이름 A>]
issuerRef: { kind: ClusterIssuer, name: <클러스터 이슈어> }
상태 기계: 「지금 발급해야 하나」를 누가 정하나
Certificate 의 상태는 조건(condition) 두 개로 읽습니다. Ready 는 Secret 이 있고, 만료되지 않았고, 키가 짝이 맞고, 이름이 스펙과 같다는 뜻입니다. Issuing 은 「지금 새로 받는 중」이라는 표시로, 필요할 때 붙었다가 발급이 끝나면 issuing 컨트롤러가 뗍니다(공식 API 문서).
컨트롤러는 한 덩어리가 아니라 일을 나눈 여러 개입니다. 순서대로 보면 다음과 같습니다.
- trigger: Certificate 와 Secret 을 비교해 「다시 받아야 한다」고 판단하면
Issuing=True를 붙입니다. 실측 로그에 남은 이유는 둘이었습니다. Secret 이 아예 없을 때의DoesNotExist, Secret 의 개인키가 지금 요청과 다를 때의SecretMismatch입니다(8절). 갱신 시각이 지났을 때도 같은 자리에서 발급이 시작됩니다. - keymanager: 다음 인증서에 쓸 개인키를 임시 Secret 에 먼저 만듭니다. Certificate 의
status.nextPrivateKeySecretName이 그 이름이고, 실측에서는 CertificateRequest 의 주석cert-manager.io/private-key-secret-name에<인증서>-xxxxx처럼 난수 꼬리가 붙은 이름이 남아 있었습니다. 키를 Secret 에 바로 쓰지 않는 까닭은 서명이 실패했을 때 지금 쓰는 키·인증서 짝을 망가뜨리지 않기 위해서입니다. v1.18 부터는 키 회전 정책 기본값이Never에서Always로 바뀌어, 갱신할 때마다 새 키를 만듭니다(1.18 릴리스 노트). - requestmanager: 그 키로 CSR 을 만들어 CertificateRequest 를 하나 만듭니다. 이름 끝의 숫자가 리비전입니다(
<인증서>-1,-2, …). - approver: CertificateRequest 에
Approved를 붙입니다. 기본 승인자는 cert-manager 자신이고, 정책 도구(approver-policy)로 바꿀 수 있습니다. 승인 없이는 어떤 이슈어도 서명하지 않습니다. - 이슈어별 서명기: SelfSigned·CA 는 그 자리에서 서명합니다. ACME 는 Order 를 만들고 결과를 기다립니다. CertificateRequest 는 한 번 실패하면 다시 시도하지 않고, 재시도는 Certificate 쪽이 새 리비전으로 합니다(공식 문서).
- issuing: Ready 가 된 CertificateRequest 의 인증서와 임시 Secret 의 키를 짝지어 진짜 Secret 에 쓰고,
Issuing을 뗍니다. - revisionmanager: 오래된 CertificateRequest 를 지웁니다. v1.18 부터
revisionHistoryLimit기본값이 1 이라 마지막 하나만 남습니다(실측에서도 인증서마다 CR 이 하나씩만 있었습니다).
모든 컨트롤러가 같은 객체를 동시에 고치려 하므로 「낙관적 잠금으로 다시 큐에 넣는다」는 로그가 흔하게 찍힙니다. 실측 발급 한 번에도 이런 줄이 여섯 번 나왔고, 모두 곧바로 다시 시도되어 문제가 없었습니다. 쿠버네티스 컨트롤러의 정상적인 모습이니 놀라지 않아도 됩니다.
3. ACME: 「이 이름이 정말 네 것이냐」를 증명하는 규약
Let's Encrypt 같은 공개 CA 는 사람을 확인하지 않습니다. 확인하는 것은 이름을 실제로 조종할 수 있느냐 하나입니다. 그 절차를 정한 것이 ACME(RFC 8555)입니다.
계정, 주문, 권한, 챌린지
- 계정. 클라이언트는 계정 키(비대칭 키)를 하나 만들어 ACME 서버에 등록합니다. 이후 모든 요청은 이 키로 서명한 JWS 입니다. cert-manager 에서는 ClusterIssuer 의
privateKeySecretRef가 그 키이고, 이슈어의Ready조건이 「계정이 등록됐다」를 뜻합니다(실측:The ACME account was registered with the ACME server). - 주문(order). 「이 이름들로 인증서를 원한다」는 요청입니다. RFC 8555 §7.1.6 에 따라 주문은
pending으로 태어나, 모든 권한이valid가 되면ready, CSR 을 내면processing, 발급되면valid가 됩니다. 어느 단계든 실패하면invalid입니다. - 권한(authorization)과 챌린지. 이름마다 권한 객체가 하나 있고, 서버는 그 권한을 얻는 방법(챌린지)을 여러 개 제시합니다. 실측 주문에는
http-01·dns-01·tls-alpn-01세 가지가 같은 token 으로 제시됐고, cert-manager 는 이슈어에 적힌 풀이 방식(여기서는 HTTP-01)만 골랐습니다. cert-manager 가 쓰는 방식은 HTTP-01 과 DNS-01 둘입니다. - finalize 와 내려받기. 모든 권한이 valid 가 되면 CSR 을
finalizeURL 로 보내고(§7.4), 발급된 인증서 사슬을 받아 옵니다(§7.4.2).
key authorization: 토큰 하나로 무엇을 증명하나
서버가 준 token 만 웹에 올리면 되는 것이 아닙니다. 그러면 token 을 엿본 제3자도 같은 일을 할 수 있습니다. RFC 8555 §8.1 은 token 뒤에 계정 키의 지문을 붙이게 합니다.
keyAuthorization = token + "." + base64url(SHA-256(계정 공개키 JWK 의 정규형)) # 지문은 RFC 7638
HTTP-01: http://<이름>/.well-known/acme-challenge/<token> 이 keyAuthorization 을 돌려준다 (§8.3)
DNS-01 : _acme-challenge.<이름> 의 TXT = base64url(SHA-256(keyAuthorization)) (§8.4)
정말 그렇게 계산되는지 파이썬 표준 라이브러리로 확인했습니다. 지문의 입력은 RFC 7638 §3.1 의 예제 RSA 공개키이고, token 은 RFC 8555 예시에 나오는 모양의 값입니다.
import base64, hashlib, json
b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
jwk = {"e": "AQAB", "kty": "RSA", "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWh…"} # RFC 7638 예제(줄임)
thumb = b64u(hashlib.sha256(json.dumps(jwk, separators=(",", ":"), sort_keys=True).encode()).digest())
ka = "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA" + "." + thumb
print(thumb, b64u(hashlib.sha256(ka.encode()).digest()))
결과(2026-10-26, 로컬 python3): 지문은 NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs 로 RFC 7638 이 적어 둔 기대값과 같았고, 그 토큰의 DNS-01 TXT 값은 ZTRx1Ckl1-tM05o5zaizTTA0yUy5AGereMgSNWC6Ll8 였습니다. 지문이 계정 키에서 나오므로, 계정 키를 가진 쪽만 올바른 응답을 만들 수 있습니다.
HTTP-01 과 DNS-01, 무엇을 고르나
| HTTP-01 | DNS-01 | |
|---|---|---|
| 증명하는 자리 | 포트 80 의 /.well-known/acme-challenge/ |
_acme-challenge.<이름> TXT 레코드 |
| 바깥에서 닿아야 하나 | 예. 인터넷에서 포트 80 으로 | 아니오. DNS 만 공개되면 된다 |
와일드카드(*.example.com) |
안 됨 | 됨 |
| 클러스터가 가져야 할 권한 | 경로 하나를 여는 권한(풀이 파드·라우트) | DNS 제공자 API 키(그 영역 전체를 바꿀 수 있는 경우가 많다) |
| 잘 걸리는 곳 | 방화벽·리디렉트·클러스터 안 헤어핀 | 전파 지연, 위임(CNAME) 설정 |
와일드카드와 포트 80 조건은 Let's Encrypt 공식 문서(챌린지 종류)에서 확인했습니다. DNS-01 의 API 키는 「DNS 영역을 통째로 바꿀 수 있는 열쇠」가 클러스터 안에 들어온다는 뜻이라 보안 비용이 큽니다. 공개 이름이 몇 개뿐이고 포트 80 이 열려 있다면 HTTP-01 이 더 단순합니다.
cert-manager 가 HTTP-01 을 푸는 법
Challenge 컨트롤러는 이름마다 풀이 파드(acmesolver 이미지)와 Service, 그리고 그 경로를 여는 라우트(Ingress 또는 Gateway API 의 HTTPRoute)를 만듭니다. 그다음 바로 ACME 서버에 「준비됐다」고 알리지 않고 자체 점검(self check) 을 합니다. 클러스터 안에서 그 공개 이름으로 직접 GET 해 보고, 올바른 key authorization 이 돌아올 때만 알립니다. ACME 서버에 실패를 쌓지 않으려는 장치입니다. 검증 실패는 이름·계정당 시간당 5번으로 제한되기 때문입니다(Let's Encrypt rate limit). 대신 클러스터 안에서 자기 공인 주소로 나갔다 들어오는 길(헤어핀)이 막혀 있으면 자체 점검이 영영 통과하지 못합니다. 9절에서 다시 봅니다.
4. 누가 서명하나: SelfSigned, CA, ACME
공개 CA 만 이슈어가 아닙니다. 클러스터 안의 웹훅 서버처럼 우리 쪽 클라이언트만 믿으면 되는 인증서는 사설 CA 로 충분합니다.
- SelfSigned: 자기 키로 자기 인증서에 서명합니다. 받는 쪽이 믿을 근거가 인증서 자신뿐이라, 보통은 CA 를 처음 만드는 용도로 씁니다.
- CA: Secret 에 든 CA 키로 서명합니다. 공식 문서가 권하는 부트스트랩 모양은 「SelfSigned ClusterIssuer 로
isCA: true인 루트 Certificate 를 만들고, 그 Secret 을 CA Issuer 가 쓴다」입니다.
kind: ClusterIssuer
metadata: { name: bootstrap }
spec: { selfSigned: {} }
---
kind: Certificate
metadata: { name: root-ca }
spec:
isCA: true
commonName: example-root-ca # 기본은 빈 subject 이니 이름을 준다(공식 문서의 경고)
secretName: root-ca
issuerRef: { kind: ClusterIssuer, name: bootstrap }
---
kind: ClusterIssuer
metadata: { name: internal-ca }
spec: { ca: { secretName: root-ca } }
사설 CA 의 진짜 어려움은 서명이 아니라 신뢰 배포입니다. 루트 인증서를 모든 클라이언트의 신뢰 목록에 넣고, 루트를 바꿀 때 옛것과 새것을 함께 믿게 해야 합니다. 공식 문서는 trust-manager 를 그 용도로 소개하고, 운영 PKI 에는 회전·배포·복구 계획이 필요하다고 경고합니다. 파드에 Secret 없이 인증서를 바로 꽂는 csi-driver 도 있습니다.
5. 언제 갱신하나
spec.duration 의 기본값은 90일, spec.renewBefore 의 기본값은 수명의 1/3 입니다. 즉 아무것도 적지 않으면 수명의 2/3 지점에서 갱신합니다. v1.16 부터는 비율로 적는 renewBeforePercentage 도 있습니다(공식 문서, 1.16 릴리스 노트). ACME 처럼 CA 가 수명을 정하는 경우에는 요청한 duration 이 아니라 실제로 받은 인증서의 notBefore·notAfter 로 계산합니다. 실측 값이 그대로 보여 줍니다.
| 인증서(2026-10-26 읽음) | notBefore → notAfter | 수명 | renewalTime | 만료까지 남는 기간 |
|---|---|---|---|---|
| 공개 인증서 A (Let's Encrypt, 설정 없음) | 09-09 12:09:29 → 12-08 12:09:28 | 90일 − 1초 | 11-08 12:09:28 | 30일(1/3) |
| 공개 인증서 B (Let's Encrypt, 설정 없음) | 10-08 23:15:09 → 01-06 23:15:08 | 90일 − 1초 | 12-07 23:15:08 | 30일(1/3) |
자체 서명(duration: 2160h·renewBefore: 360h 를 적은 것) |
08-23 08:45:34 → 11-21 08:45:34 | 90일 | 11-06 08:45:34 | 15일 |
| 자체 서명(웹훅용, 설정 없음) | 10-18 → 01-16 | 90일 | 12-17 | 30일(1/3) |
시각은 모두 UTC 이고, kubectl get certificate -o custom-columns=… 의 status.notBefore·notAfter·renewalTime 과, Secret 의 tls.crt 를 openssl x509 -noout -startdate -enddate 로 읽은 날짜가 서로 같았습니다. 계산하면 공개 인증서의 renewalTime 은 정확히 notAfter − 30일 입니다.
공개 CA 쪽의 변화
Let's Encrypt 는 2025-12-02 에 수명 단축 일정을 발표했습니다. 기본 프로필이 2027-02-10 에 64일, 2028-02-16 에 45일이 되고, 권한 재사용 기간도 30일에서 10일, 다시 7시간으로 줄어듭니다. 발표문은 「60일마다」처럼 고정 간격으로 갱신하는 방식으로는 더 이상 부족하다고 못박습니다. 비율(2/3)로 갱신하는 cert-manager 기본값은 이 변화를 그대로 따라가지만, renewBefore: 720h 같은 고정 값을 적어 둔 곳은 45일 인증서에서 갱신 여유가 15일로 줄어듭니다. CA 가 「언제 갱신하라」고 알려 주는 ARI(RFC 9773)도 있습니다. cert-manager 는 v1.21 에서 ACMEUseARI 기능 게이트 뒤에 실험적으로 넣었고, 기본은 꺼져 있습니다.
6. 웹훅과 cainjector: 왜 컴포넌트가 셋인가
cert-manager 를 설치하면 Deployment 가 셋 생깁니다. TT Lab 클러스터에서도 controller·webhook·cainjector 가 하나씩 돌고 있었습니다.
- controller: 2절의 컨트롤러들이 모두 이 프로세스 안에 있습니다.
- webhook: API 서버가 cert-manager 객체를 저장하기 전에 부르는 서버입니다. 공식 문서가 꼽는 일은 셋입니다. 스펙 검증(잘못된 Certificate 를 아예 저장 못 하게), 기본값 채우기, API 판 사이의 변환입니다. 웹훅은 API 서버와 TLS 로 말하므로 웹훅 자신도 인증서가 필요합니다. 닭과 달걀 문제를 피하려고 webhook 은 cert-manager 의 발급 경로를 쓰지 않고 스스로 CA 를 만들어 Secret 에 둡니다(실측: 그 CA 는 2026-08-19 부터 1년짜리).
- cainjector: 웹훅 설정(Validating/MutatingWebhookConfiguration), CRD 의 변환 웹훅, APIService 의
caBundle칸을 채웁니다. 대상 객체에cert-manager.io/inject-ca-from: <네임스페이스>/<Certificate>주석을 달면, 그 Certificate 의 CA 를 읽어 넣고 인증서가 바뀌면 다시 넣습니다. TT Lab 클러스터에서도 여러 구성요소의 검증·변경 웹훅 설정과 CRD 가 이 주석으로 CA 를 받고 있었습니다.
cainjector 가 없으면 웹훅 서버의 인증서가 갱신된 날 API 서버가 새 인증서를 믿지 못해 해당 종류의 객체 생성이 모두 실패합니다. 웹훅 인증서 갱신이 조용한 장애가 되기 쉬운 이유입니다.
7. 게이트웨이·Ingress 와 이어지는 법: shim
Certificate 를 일일이 쓰지 않고 게이트웨이 쪽에 주석 하나만 달 수도 있습니다.
- ingress-shim:
cert-manager.io/cluster-issuer(또는issuer) 주석이 달린 Ingress 를 보고,tls.secretName마다 Certificate 를 만듭니다. - gateway-shim: 같은 주석이 달린 Gateway 를 보고, HTTPS 리스너의
hostname과certificateRefs로 Certificate 를 만듭니다. 이 기능은 기본으로 꺼져 있습니다. v1.15 에서 베타가 되며--enable-gateway-api로 켰고, v1.21 에서는 설정 파일의gatewayAPI.enabled로 켭니다(예전 이름enableGatewayAPI는 아직 동작하지만 폐기 예정). GA 라는 공식 문장은 찾지 못했습니다.
TT Lab 클러스터의 controller 설정 파일에는 예전 이름인 enableGatewayAPI: true 가 들어 있었고, Gateway 에는 cert-manager.io/cluster-issuer: <클러스터 이슈어> 주석이 있었습니다. 그 결과를 읽어 보면 shim 의 성격이 잘 보입니다.
- 공개 인증서 A 의 Certificate 는
ownerReferences가 Gateway 였습니다. shim 이 만든 객체라는 뜻이고, Gateway 를 지우면 함께 지워집니다. - 공개 인증서 B 는 사람이
kubectl apply로 따로 만든 Certificate 였습니다. 같은 Gateway 에 B 의 리스너가 있으니 shim 도 그 Secret 이름으로 Certificate 를 만들려 했지만, 발급 동안 「주인이 없는 Certificate 는 고치지 않는다」는 로그를 남기고 손대지 않았습니다(실측, 2026-10-09 로그 10줄). 사람이 만든 것을 shim 이 덮어쓰지 않는 안전장치입니다. 다만 한 이름을 두 경로로 관리하고 있다는 신호이므로, 한쪽으로 정리하는 편이 낫습니다.
인증서가 바뀌면 게이트웨이는 언제 알아채나
게이트웨이(Envoy 기반)는 Secret 을 지켜보다가 바뀌면 다시 읽습니다. 프록시를 재시작하지 않습니다. 실측으로 확인한 경로는 이렇습니다. TT Lab 클러스터의 Cilium 게이트웨이는 참조하는 TLS Secret 을 자기 전용 네임스페이스로 해시 이름의 사본으로 복사합니다. 공개 인증서 A 의 사본은 2026-08-19(첫 발급)에 만들어졌는데 안에는 2026-09-09 에 다시 받은 인증서가 들어 있었습니다. 사본을 새로 만들지 않고 제자리에서 갱신한 것입니다. 바깥에서 openssl s_client 로 붙어 보니 공개 주소가 내주는 인증서의 notAfter 도 Dec 8 12:09:28 2026 으로 같았습니다.
반면 Secret 을 파일로 마운트해 시작할 때 한 번만 읽는 애플리케이션은 다릅니다. 공식 문서는 그런 앱이면 새 인증서가 바로 쓰이지 않는다고 적고, kubectl rollout restart 나 Reloader 같은 도구를 권합니다. 9절의 함정 중 가장 흔한 것입니다.
8. 실측: TT Lab 클러스터에서 읽은 것
환경: cert-manager 1.21 계열(Helm 차트, controller·webhook·cainjector 각 1개). 읽은 날 2026-10-26, 시각은 모두 UTC 입니다.
| 항목 | 값 |
|---|---|
| 이슈어 | 공개 인증서용 ClusterIssuer(ACME, HTTP-01, Gateway API 풀이)와 구성요소별 SelfSigned Issuer 들 |
| Certificate | 공개 인증서 몇 개와, 웹훅·내부 TLS 용 자체 서명 인증서 여럿 |
| Order·Challenge | Order 는 공개 인증서마다 마지막 하나가 valid 로 남아 있고, Challenge 는 없다 — 성공하면 지워진다 |
| 공개 인증서의 키 | RSA 2048, 서명 sha256WithRSA. 발급자는 Let's Encrypt 의 중간 CA |
새 이름 하나를 처음 받을 때: 6분 5초
공개 인증서 B 를 처음 만든 날(2026-10-09)의 controller 로그가 이전 컨테이너 로그(kubectl logs --previous)에 남아 있었습니다. 줄을 시각순으로 줄이면 이렇습니다.
| 시각 | 무슨 일 |
|---|---|
| 00:07:36.141 | trigger: Certificate must be re-issued, 이유 DoesNotExist(Secret 이 없다) → Issuing |
| 00:07:36.332 | CertificateRequest 리비전 1 생성·Approved |
| 00:07:38.209 | 풀이 파드 생성 |
| 00:07:38.274 | 풀이용 HTTPRoute 생성(Gateway 를 부모로) |
| 00:07:48 – 00:13:28 | 풀이 파드·Service·HTTPRoute 가 있는지 확인만 반복(처음 10초, 나중 20초 간격, 23번) |
| 00:13:41.037 | certificate issued, CR Ready |
| 00:13:41.075 | Certificate Ready=True |
준비는 2초 만에 끝났고 나머지 6분은 기다렸습니다. 무엇을 기다렸는지, 즉 자체 점검이 왜 늦게 통과했는지는 이 로그 단계(--v=2)에 남지 않아 확인 못 함입니다. 새로 만든 공개 이름이었으므로 DNS 전파를 기다렸을 가능성이 있습니다(추정). 주문 객체에는 서버가 제시한 챌린지 세 가지가 그대로 남아 있었고, 권한의 처음 상태는 pending 이었습니다.
같은 이름을 다시 받을 때: 4초
공개 인증서 A 의 리비전 2 는 2026-09-09 13:07:57 에 CR 이 생기고 13:08:01 에 Ready 가 됐습니다. 4초입니다. 주문 객체를 보면 이유가 보입니다. 권한의 처음 상태가 initialState: valid 였고, 챌린지 목록에는 http-01 하나만 있었습니다. 그 이름의 권한을 21일 전(첫 발급)에 이미 얻었고 Let's Encrypt 가 권한을 30일 동안 재사용하므로, 풀이 파드도 자체 점검도 필요 없었던 것입니다. 이 재사용 기간이 위 일정대로 10일, 7시간으로 줄면 이런 지름길은 거의 사라집니다. 리비전 2 가 왜 생겼는지는 남은 로그가 없어 확인 못 함이고, Certificate 의 generation 이 3 이라 스펙이 바뀐 적이 있다는 것까지만 압니다.
notBefore 는 발급 시각보다 앞선다
두 공개 인증서 모두 notBefore 가 CR 이 Ready 가 된 시각보다 정확히 58분 32초 앞이었습니다(A: 13:08:01 대 12:09:29, B: 00:13:41 대 23:15:09). 시계가 느린 클라이언트도 새 인증서를 「아직 유효하지 않다」고 거절하지 않게 Let's Encrypt 가 notBefore 를 약 1시간 앞당긴다는 설명은 Let's Encrypt 커뮤니티 포럼의 답변에서만 찾았고 공식 문서에서는 확인 못 했습니다.
사고: 한 Secret 에 주인이 둘이면 수천 번 다시 발급된다
클러스터 안의 한 구성요소가 쓰는 웹훅 인증서(자체 서명)의 리비전 번호가 1만을 넘어 있었습니다. 2026-10-18 에 Certificate 가 만들어지고 고리가 멈추기까지 73분 동안 1만여 번 다시 발급됐습니다(평균 초당 약 2.7번). 남은 로그는 마지막 1분 남짓뿐이었는데, 그 안에서만 300번 넘게 같은 줄이 반복됐습니다.
"Certificate must be re-issued" … reason="SecretMismatch"
message="Secret contains a private key that does not match the current CertificateRequest"
발급 → Secret 에 씀 → 곧바로 「Secret 의 키가 지금 요청과 다르다」 → 다시 발급의 고리입니다. 누군가 cert-manager 가 쓴 Secret 을 덮어쓰고 있었다는 뜻입니다. 정황은 이렇습니다. 그날 그 구성요소는 「자기 컨트롤러가 웹훅 인증서를 스스로 만드는」 설치 방식에서 「cert-manager 가 만드는」 방식으로 넘어갔고, 두 방식이 같은 Secret 이름을 썼습니다. 옛 설정으로 뜬 컨트롤러가 자기 인증서를 계속 같은 Secret 에 썼다고 보면 고리가 설명됩니다(추정). 고리가 멈춘 시각과 그 컨트롤러가 다시 뜬 시각이 몇 분 어긋나서, 정확히 무엇이 고리를 끊었는지는 확인 못 했습니다. 자체 서명이라 외부 한도에 걸리지 않았지만, ACME 이슈어였다면 첫 몇 분 안에 Let's Encrypt 의 주당 한도(같은 이름 집합 5장)에 걸려 일주일 가까이 발급이 막혔을 것입니다.
교훈은 단순합니다. Secret 하나에 주인은 하나. 그리고 revision 숫자나 CertificateRequest 생성 속도처럼 「평소에는 거의 움직이지 않는 값」을 지켜보면 이런 고리를 일찍 잡을 수 있습니다.
9. 흔한 장애와 확인 순서
① 챌린지가 끝나지 않는다. 위에서 아래로 따라 내려가며 describe 합니다. 어느 객체에서 멈췄는지가 곧 원인의 자리입니다.
kubectl -n <ns> describe certificate <이름> # Issuing 이 붙어 있나, 메시지
kubectl -n <ns> get certificaterequest,order,challenge # 어디까지 생겼나
kubectl -n <ns> describe challenge <이름> # Reason: 자체 점검 실패 문구가 여기 있다
curl -v http://<이름>/.well-known/acme-challenge/<token> # 바깥에서 직접
HTTP-01 에서 흔한 원인은 셋입니다. 포트 80 이 바깥에서 막혀 있음, 모든 http 를 https 로 돌리는 리디렉트가 풀이 경로보다 먼저 걸림, 그리고 헤어핀입니다. 마지막은 클러스터 안에서 자기 공인 주소로 나가는 길이 공유기나 로드밸런서에서 막혀, 바깥에서는 되는데 cert-manager 의 자체 점검만 실패하는 경우입니다. 공유기 뒤의 작은 클러스터에서 특히 잦습니다. DNS-01 이면 TXT 전파 지연과 위임(CNAME) 설정을 먼저 봅니다.
② rate limit 에 걸렸다. Let's Encrypt 의 현재 한도는 등록 도메인당 7일에 50장, 같은 이름 집합 7일에 5장, 이름·계정당 검증 실패 1시간에 5번, 계정당 새 주문 3시간에 300개입니다(공식 rate limit 문서). 같은 이름 집합 한도는 34시간에 한 장씩만 회복합니다. 설정을 시험할 때는 반드시 staging 서버로 먼저 해 보라는 것이 Let's Encrypt 와 cert-manager 튜토리얼 모두의 권고입니다. 8절의 고리가 ACME 에서 일어났다면 바로 이 한도에 걸렸을 것입니다.
③ 인증서는 바뀌었는데 앱은 옛것을 쓴다. 프록시는 스스로 다시 읽지만, 시작할 때 키스토어를 한 번 만드는 앱(자바의 PKCS12 키스토어를 시작 스크립트에서 만드는 경우 등)은 재시작해야 합니다. Secret 을 subPath 로 마운트하면 kubelet 이 파일을 갱신하지 않는다는 점도 함께 기억합니다. 해결은 재시작을 자동화(Reloader 등)하거나, 앱이 파일 변경을 보고 다시 읽게 만드는 것입니다.
④ 한 Secret 에 두 주인. 8절의 사고입니다. 다른 도구(차트의 자체 인증서 생성기, 손으로 만든 Secret, 다른 컨트롤러)가 같은 이름을 쓰는지 봅니다. SecretMismatch 가 반복되고 revision 이 계속 오르면 거의 이것입니다.
⑤ 웹훅 인증서 갱신 날의 장애. cainjector 가 멈춰 있거나 대상 객체의 주석이 틀리면 caBundle 이 옛 CA 로 남아, 그 웹훅을 거치는 모든 요청이 TLS 오류로 실패합니다. 웹훅 설정의 caBundle 지문과 Secret 의 ca.crt 를 비교하면 바로 압니다.
⑥ shim 과 손으로 만든 Certificate 의 충돌. 7절처럼 shim 은 주인이 없는 Certificate 를 건드리지 않습니다. 「주석을 달았는데 인증서가 안 바뀐다」면 같은 이름의 손으로 만든 Certificate 가 있는지 봅니다.
10. 정리
- cert-manager 는 「원하는 인증서」를 적은 Certificate 를 여러 컨트롤러가 나눠 맡아 Secret 으로 맞추는 시스템입니다. 중간 객체(CertificateRequest·Order·Challenge)는 일의 단계이자, 고장 났을 때 어디서 멈췄는지 알려 주는 표지입니다.
- ACME 는 「이름을 조종할 수 있음」을 key authorization(토큰 + 계정 키 지문)으로 증명합니다. HTTP-01 은 단순하지만 포트 80 과 헤어핀에, DNS-01 은 와일드카드가 되지만 DNS API 키에 값을 치릅니다.
- 갱신은 기본으로 수명의 2/3 지점입니다. 공개 CA 의 수명이 64일, 45일로 줄어드니 고정 기간을 적어 둔 설정부터 찾아 고칩니다.
- 실측에서 새 이름 발급은 6분 5초, 권한을 재사용한 재발급은 4초였고, 한 Secret 을 두 컨트롤러가 각자 주인으로 쓰자 73분에 1만여 번 다시 발급하는 고리가 생겼습니다.
티티 한마디. 인증서는 랠리랑 같아요. 공을 넘기는 사람이 둘이면 끝나지 않죠. Secret 하나에는 주인도 한 명만! 그리고 「석 달마다」가 아니라 「수명의 3분의 2」에서 넘겨야, 공이 짧아져도(45일!) 안 놓쳐요.
참고
- RFC 8555, Automatic Certificate Management Environment (ACME). §7.1.3·§7.1.6(주문 상태), §7.4(finalize), §8.1(key authorization), §8.3(HTTP-01), §8.4(DNS-01). https://www.rfc-editor.org/rfc/rfc8555.html
- RFC 7638, JSON Web Key (JWK) Thumbprint. §3.1 예제. https://www.rfc-editor.org/rfc/rfc7638.html
- RFC 9773, ACME Renewal Information (ARI). https://www.rfc-editor.org/rfc/rfc9773.html
- cert-manager 문서: Certificate(갱신 기본값·재시작 안내) https://cert-manager.io/docs/usage/certificate/ · API 레퍼런스 https://cert-manager.io/docs/reference/api-docs/ · CertificateRequest https://cert-manager.io/docs/concepts/certificaterequest/ · ACME Orders and Challenges https://cert-manager.io/docs/concepts/acme-orders-challenges/ · Webhook https://cert-manager.io/docs/concepts/webhook/ · CA Injector https://cert-manager.io/docs/concepts/ca-injector/ · Ingress https://cert-manager.io/docs/usage/ingress/ · Gateway https://cert-manager.io/docs/usage/gateway/ · SelfSigned https://cert-manager.io/docs/configuration/selfsigned/ · trust-manager https://cert-manager.io/docs/trust/trust-manager/
- cert-manager 릴리스 노트 1.15(Gateway API 베타)·1.16(renewBeforePercentage)·1.18(키 회전 기본값 Always, revisionHistoryLimit 1)·1.21(ARI 실험, 설정 이름). https://cert-manager.io/docs/releases/
- Let's Encrypt: Challenge Types https://letsencrypt.org/docs/challenge-types/ · Rate Limits https://letsencrypt.org/docs/rate-limits/ · Staging https://letsencrypt.org/docs/staging-environment/ · 「From 90 to 45」(2025-12-02) https://letsencrypt.org/2025/12/02/from-90-to-45
- notBefore 앞당김에 대한 설명(공식 문서 아님): Let's Encrypt 커뮤니티 포럼 https://community.letsencrypt.org/t/certificate-not-valid-before-dated-1-hour-prior-to-generation/47246
- 모든 원문은 2026-10-26 에 열어 확인했습니다.