TT Lab
블로그

한국어

시작하기
블로그

블로그

cert-manager 는 어떻게 인증서를 만들고 갈아 끼우나

상태: 초안 (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. 사람이 인증서를 갱신하면 생기는 일

Let's Encrypt 인증서는 90일짜리입니다. 석 달에 한 번 누군가 달력을 보고 갱신 명령을 쳐야 한다는 뜻이고, 그 누군가는 언젠가 잊습니다. 인증서가 만료되면 브라우저는 경고를 띄우고, 서비스끼리 TLS 로 말하던 곳은 통째로 끊깁니다. 사람의 기억에 기대는 일은 반드시 한 번은 실패합니다.

cert-manager 는 이 문제를 쿠버네티스다운 방식으로 풉니다. 사람은 「이런 인증서가 이 Secret 에 늘 있어야 한다」는 원하는 상태만 객체로 적습니다. 나머지, 즉 키 만들기, 인증 기관(CA)에 요청하기, 도메인 소유 증명하기, Secret 에 쓰기, 만료 전에 다시 받기는 컨트롤러가 계속 돌면서 맞춥니다. Deployment 를 적으면 파드가 생기고 죽으면 다시 뜨는 것과 같은 원리입니다.

이 글은 그 「맞추는 과정」을 한 겹씩 엽니다. 어떤 객체가 어떤 순서로 생기는지(2절), ACME 서버와 무엇을 주고받는지(3절), 누가 서명하는지(4절), 언제 갱신하는지(5절), 웹훅과 cainjector 는 왜 따로 있는지(6절), 게이트웨이와는 어떻게 이어지는지(7절)를 본 다음, TT Lab 클러스터에서 실제로 읽은 상태와 사고 하나(8절), 흔한 장애(9절)로 마무리합니다.

2. 객체 다섯과 그것을 움직이는 컨트롤러

Certificate 하나가 Secret 이 되기까지 왼쪽은 객체(원하는 상태), 오른쪽은 그 객체를 움직이는 컨트롤러 Gateway · Ingress cert-manager.io/cluster-issuer gateway-shim · ingress-shim Secret 이름마다 Certificate 생성 Certificate 이름 목록 · 이슈어 · Secret 이름 trigger · keymanager 갱신 시점·Secret 불일치 판단, 새 키 CertificateRequest CSR 한 장 · 리비전 번호 approver · 이슈어별 서명기 승인 → 서명 → Ready (실패 시 끝) Order (ACME 만) ACME 주문 1건 · finalize URL orders 주문 → 권한 → CSR → 인증서 Challenge (이름마다) token · 방식(http-01/dns-01) challenges 풀이 → 자체 점검 → 준비 알림 Secret (kubernetes.io/tls) tls.crt · tls.key · ca.crt issuing 컨트롤러가 쓴다 → 게이트웨이·Ingress·파드가 읽는다 CR 이 Ready 가 되면 주문과 챌린지는 ACME 이슈어일 때만 생긴다. 자체 서명·CA 이슈어는 CR 에서 바로 서명한다.
그림: Gateway 주석에서 Certificate, CertificateRequest, Order, Challenge 를 거쳐 Secret 이 되기까지의 객체 사슬과 각 단계의 컨트롤러

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 문서).

컨트롤러는 한 덩어리가 아니라 일을 나눈 여러 개입니다. 순서대로 보면 다음과 같습니다.

  1. trigger: Certificate 와 Secret 을 비교해 「다시 받아야 한다」고 판단하면 Issuing=True 를 붙입니다. 실측 로그에 남은 이유는 둘이었습니다. Secret 이 아예 없을 때의 DoesNotExist, Secret 의 개인키가 지금 요청과 다를 때의 SecretMismatch 입니다(8절). 갱신 시각이 지났을 때도 같은 자리에서 발급이 시작됩니다.
  2. keymanager: 다음 인증서에 쓸 개인키를 임시 Secret 에 먼저 만듭니다. Certificate 의 status.nextPrivateKeySecretName 이 그 이름이고, 실측에서는 CertificateRequest 의 주석 cert-manager.io/private-key-secret-name 에 <인증서>-xxxxx 처럼 난수 꼬리가 붙은 이름이 남아 있었습니다. 키를 Secret 에 바로 쓰지 않는 까닭은 서명이 실패했을 때 지금 쓰는 키·인증서 짝을 망가뜨리지 않기 위해서입니다. v1.18 부터는 키 회전 정책 기본값이 Never 에서 Always 로 바뀌어, 갱신할 때마다 새 키를 만듭니다(1.18 릴리스 노트).
  3. requestmanager: 그 키로 CSR 을 만들어 CertificateRequest 를 하나 만듭니다. 이름 끝의 숫자가 리비전입니다(<인증서>-1, -2, …).
  4. approver: CertificateRequest 에 Approved 를 붙입니다. 기본 승인자는 cert-manager 자신이고, 정책 도구(approver-policy)로 바꿀 수 있습니다. 승인 없이는 어떤 이슈어도 서명하지 않습니다.
  5. 이슈어별 서명기: SelfSigned·CA 는 그 자리에서 서명합니다. ACME 는 Order 를 만들고 결과를 기다립니다. CertificateRequest 는 한 번 실패하면 다시 시도하지 않고, 재시도는 Certificate 쪽이 새 리비전으로 합니다(공식 문서).
  6. issuing: Ready 가 된 CertificateRequest 의 인증서와 임시 Secret 의 키를 짝지어 진짜 Secret 에 쓰고, Issuing 을 뗍니다.
  7. revisionmanager: 오래된 CertificateRequest 를 지웁니다. v1.18 부터 revisionHistoryLimit 기본값이 1 이라 마지막 하나만 남습니다(실측에서도 인증서마다 CR 이 하나씩만 있었습니다).

모든 컨트롤러가 같은 객체를 동시에 고치려 하므로 「낙관적 잠금으로 다시 큐에 넣는다」는 로그가 흔하게 찍힙니다. 실측 발급 한 번에도 이런 줄이 여섯 번 나왔고, 모두 곧바로 다시 시도되어 문제가 없었습니다. 쿠버네티스 컨트롤러의 정상적인 모습이니 놀라지 않아도 됩니다.

3. ACME: 「이 이름이 정말 네 것이냐」를 증명하는 규약

Let's Encrypt 같은 공개 CA 는 사람을 확인하지 않습니다. 확인하는 것은 이름을 실제로 조종할 수 있느냐 하나입니다. 그 절차를 정한 것이 ACME(RFC 8555)입니다.

HTTP-01 한 번: 누가 누구에게 무엇을 묻나 cert-manager 컨트롤러 ACME 서버 Let's Encrypt 게이트웨이 + 풀이 파드 ① 새 주문(이름 목록) order: pending ② 권한(authz) 조회 챌린지 3종 + token ③ 풀이 파드 · HTTPRoute 생성 /.well-known/acme-challenge/<token> ④ 자체 점검: 공인 주소로 GET 통과할 때까지 반복 ⑤ 챌린지에 「준비됐다」 POST ⑥ 여러 곳에서 포트 80 GET key authorization 이 맞나 ⑦ authz valid → order ready ⑧ finalize(CSR) processing → valid ⑨ 인증서 내려받기 → Secret, 풀이 객체 삭제 실측(2026-10-09, 새 이름 하나): ①→⑨ 6분 5초. ③은 2초 뒤, 나머지는 ④에서 기다렸다. 같은 이름을 30일 안에 다시 받을 때는 authz 가 이미 valid 라 ③–⑥이 없었다.
그림: cert-manager, ACME 서버, 게이트웨이 사이에서 HTTP-01 한 번이 오가는 아홉 단계

계정, 주문, 권한, 챌린지

  1. 계정. 클라이언트는 계정 키(비대칭 키)를 하나 만들어 ACME 서버에 등록합니다. 이후 모든 요청은 이 키로 서명한 JWS 입니다. cert-manager 에서는 ClusterIssuer 의 privateKeySecretRef 가 그 키이고, 이슈어의 Ready 조건이 「계정이 등록됐다」를 뜻합니다(실측: The ACME account was registered with the ACME server).
  2. 주문(order). 「이 이름들로 인증서를 원한다」는 요청입니다. RFC 8555 §7.1.6 에 따라 주문은 pending 으로 태어나, 모든 권한이 valid 가 되면 ready, CSR 을 내면 processing, 발급되면 valid 가 됩니다. 어느 단계든 실패하면 invalid 입니다.
  3. 권한(authorization)과 챌린지. 이름마다 권한 객체가 하나 있고, 서버는 그 권한을 얻는 방법(챌린지)을 여러 개 제시합니다. 실측 주문에는 http-01·dns-01·tls-alpn-01 세 가지가 같은 token 으로 제시됐고, cert-manager 는 이슈어에 적힌 풀이 방식(여기서는 HTTP-01)만 골랐습니다. cert-manager 가 쓰는 방식은 HTTP-01 과 DNS-01 둘입니다.
  4. finalize 와 내려받기. 모든 권한이 valid 가 되면 CSR 을 finalize URL 로 보내고(§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 로 충분합니다.

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 로 계산합니다. 실측 값이 그대로 보여 줍니다.

90일 인증서는 60일째에 갱신한다 (기본: 수명의 2/3) 9월 10월 11월 12월 1월 공개 인증서 A 갱신 11/8 만료 12/8 공개 인증서 B 갱신 12/7 만료 1/6 자체 서명 (renewBefore 15일) 갱신 11/6 만료 11/21 읽은 날 10/26 공개 인증서는 Let's Encrypt, 진한 칸은 갱신 구간(만료 전 1/3). 공개 CA 는 기본 수명을 2027-02 에 64일, 2028-02 에 45일로 줄인다. 날짜 대신 비율(2/3)이나 ARI 로 갱신한다.
그림: 공개 인증서 둘과 자체 서명 인증서 하나의 유효 기간, 갱신 시점, 읽은 날
인증서(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 가 하나씩 돌고 있었습니다.

cainjector 가 없으면 웹훅 서버의 인증서가 갱신된 날 API 서버가 새 인증서를 믿지 못해 해당 종류의 객체 생성이 모두 실패합니다. 웹훅 인증서 갱신이 조용한 장애가 되기 쉬운 이유입니다.

7. 게이트웨이·Ingress 와 이어지는 법: shim

Certificate 를 일일이 쓰지 않고 게이트웨이 쪽에 주석 하나만 달 수도 있습니다.

TT Lab 클러스터의 controller 설정 파일에는 예전 이름인 enableGatewayAPI: true 가 들어 있었고, Gateway 에는 cert-manager.io/cluster-issuer: <클러스터 이슈어> 주석이 있었습니다. 그 결과를 읽어 보면 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. 정리

티티 한마디. 인증서는 랠리랑 같아요. 공을 넘기는 사람이 둘이면 끝나지 않죠. Secret 하나에는 주인도 한 명만! 그리고 「석 달마다」가 아니라 「수명의 3분의 2」에서 넘겨야, 공이 짧아져도(45일!) 안 놓쳐요.

참고

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다