HTTPS를 자동으로 유지하기
사설 CA를 만들고, 인증서 자동 갱신을 지켜보고, 만료 알림까지 실제로 울려 본 기록
cert-manager는 Certificate 리소스에 적은 대로 인증서를 발급해 Secret에 넣고, 만료 전에 알아서 다시 발급하는 컨트롤러입니다. kind 클러스터에 cert-manager v1.21.3을 설치하고 자체 서명 → 루트 CA → 서버 인증서로 이어지는 사설 CA를 만든 뒤, Gateway API의 HTTPS 리스너에 연결했습니다. HTTP로 들어온 요청은 HTTPS로 옮기고, 인증서를 2분마다 갱신하게 해서 Envoy가 재시작 없이 새 인증서를 쓰는 것까지 확인했습니다. 운영에서 실제로 문제가 된 것은 인증서보다 그 주변이었습니다. hostPort를 쓰는 Envoy가 교체되지 못하고 멈췄고, 만료 알림 규칙은 지표의 namespace 라벨이 예상과 달라 엉뚱한 위치를 가리켰습니다. 두 문제의 원인과 해결 방법도 함께 정리했습니다.
1~5편의 kind 클러스터(노드 3개, Envoy Gateway v1.10.0, kube-prometheus-stack 93.1.0)를 이어서 씁니다. cert-manager v1.21.3 기준이며, 인증서는 사설 CA로만 발급합니다. 공인 인증서(Let's Encrypt 같은 ACME 발급)는 공개 도메인과 외부 접속이 필요해 로컬에서는 다루지 않고, 차이만 설명합니다. 사설 CA를 Windows에 등록하는 단계는 관리자 권한이 필요 없는 현재 사용자 저장소 기준입니다.
HTTPS 인증서와 CA, 먼저 개념부터
HTTPS 인증서는 "이 서버가 정말 grafana.localhost가 맞다"는 증명서입니다. 증명서에는 도메인 이름(SAN), 유효 기간, 공개키가 들어 있고, 이것을 CA(인증 기관)가 자기 비밀키로 서명합니다. 브라우저는 서명한 CA를 믿을 때만 인증서를 받아들입니다. 그래서 인증서 문제는 대부분 "누가 서명했고, 그 서명자를 누가 믿느냐"의 문제입니다.
| 구분 | 공인 CA | 사설 CA |
|---|---|---|
| 예 | Let's Encrypt, 상용 인증 기관 | 조직이나 팀이 직접 만든 루트 CA |
| 누가 믿나 | 브라우저·OS에 처음부터 들어 있음 | 직접 등록한 PC·서버만 |
| 발급 조건 | 도메인 소유 증명(외부에서 접속 가능해야 함) | 제한 없음(.localhost, 내부 도메인도 가능) |
| 주로 쓰는 곳 | 외부 사용자에게 공개하는 서비스 | 사내 서비스, 서비스 간 통신, 로컬·개발 환경 |
| 운영 부담 | 발급자는 신경 쓸 일이 적음 | CA 비밀키 보관, 신뢰 배포, 폐기 관리가 내 책임 |
로컬의 grafana.localhost는 외부에서 접속할 수 없어서 공인 CA로는 인증서를 받을 수 없습니다. 이 글에서는 사설 CA를 만들어 씁니다. 운영 환경으로 옮길 때도 구조는 같고, 발급자(Issuer)만 사설 CA에서 ACME로 바꾸면 됩니다.
루트 CA는 스스로 서명한 최상위 인증서입니다. 서버 인증서는 루트 CA(또는 중간 CA)가 서명한 인증서입니다. 이 서명 관계를 따라 올라가는 것을 인증서 체인이라고 합니다. 브라우저는 체인 끝의 루트 CA가 신뢰 저장소에 있으면 서버 인증서를 믿습니다.
cert-manager 구성 요소와 설치
cert-manager는 인증서를 쿠버네티스 리소스로 다룹니다. 사람이 Certificate에 "어떤 이름으로, 얼마 동안, 누구에게 받을지"를 적으면, cert-manager가 발급 요청을 만들고 결과를 Secret에 저장하고 만료 전에 다시 발급합니다.
| 리소스 | 역할 | 이 실습에서 |
|---|---|---|
| Issuer / ClusterIssuer | 인증서를 서명해 주는 발급자. Issuer는 한 네임스페이스, ClusterIssuer는 클러스터 전체에서 사용 | selfsigned-bootstrap, lab-ca(둘 다 ClusterIssuer) |
| Certificate | 원하는 인증서의 내용(도메인, 기간, 키 종류, 발급자, 저장할 Secret) | lab-root-ca, lab-localhost-tls |
| CertificateRequest | 발급할 때마다 만들어지는 실제 요청. 갱신 이력 확인에 사용 | 갱신될 때마다 하나씩 생기고 마지막 것만 남음 |
| Secret(kubernetes.io/tls) | 발급 결과. tls.crt, tls.key, ca.crt |
Gateway가 이 Secret을 읽음 |
설치는 공식 문서대로 OCI 차트를 썼습니다. 4편의 Prometheus는 release: kps 라벨이 붙은 ServiceMonitor만 수집하므로, 차트에서 ServiceMonitor를 켜면서 그 라벨도 붙였습니다. 이 라벨을 빼면 설치는 되지만 만료 지표가 수집되지 않아, 뒤에서 만들 알림이 아무것도 감시하지 못합니다. cert-manager Helm 설치 문서
crds:
enabled: true
prometheus:
enabled: true
servicemonitor:
enabled: true
labels:
release: kps
helm install cert-manager oci://quay.io/jetstack/charts/cert-manager \
--version v1.21.3 -n cert-manager --create-namespace \
-f 06-cert-manager-values.yaml --wait
설치에는 24초가 걸렸고, 파드 3개(cert-manager, cainjector, webhook)와 CRD 6종이 생겼습니다. CRD 중 orders와 challenges는 ACME(공인 인증서) 발급에 쓰이는 것이라 이 실습에서는 쓰지 않습니다.

Headlamp에서 본 cert-manager 네임스페이스입니다. 세 파드가 모두 한 노드(lab-worker2)에 있고 컨트롤러도 1개라서, 이 노드가 멈추면 cert-manager 전체가 멈춥니다. 이미 발급된 인증서는 Secret에 남아 있어 HTTPS는 계속 동작하지만, 그동안 갱신과 새 발급은 일어나지 않습니다. 뒤에서 만드는 CertManagerAbsent 알림이 이 상황을 잡는 장치입니다.
crds.enabled: true로 설치하면 CRD도 Helm이 관리합니다. 차트 기본값 crds.keep: true 덕분에 helm uninstall을 해도 CRD는 남습니다. CRD가 지워지면 모든 Certificate와 Issuer가 함께 지워지므로, 운영에서는 이 값을 끄지 않습니다. 또 차트 기본 컨트롤러는 1개라서, 공식 values 설명대로 운영에서는 2~3개로 늘리는 것을 검토합니다.
사설 CA 체인 만들기: 자체 서명 → 루트 CA → 서버 인증서
루트 CA를 직접 openssl로 만들어 Secret에 넣을 수도 있지만, cert-manager 안에서 만들면 CA 인증서도 다른 인증서처럼 기간과 갱신을 관리할 수 있습니다. 순서는 네 단계입니다.
selfsigned-bootstrap: 자기 자신을 서명하는 발급자. 루트 CA를 한 번 만드는 데만 씁니다.lab-root-ca: 10년짜리 루트 CA 인증서(isCA: true). 결과 Secret에 CA 비밀키가 들어갑니다.lab-ca: 위 Secret의 CA 비밀키로 서명하는 발급자.lab-localhost-tls:grafana.localhost,keycloak.localhost용 서버 인증서. Gateway가 쓰는 인증서입니다.
kind: ClusterIssuer
metadata:
name: selfsigned-bootstrap
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: lab-root-ca
namespace: cert-manager # ClusterIssuer(CA)는 이 네임스페이스의 Secret을 읽는다
spec:
isCA: true
commonName: Lab Local Root CA
secretName: lab-root-ca
duration: 87600h # 10년
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: selfsigned-bootstrap
kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: lab-ca
spec:
ca:
secretName: lab-root-ca
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: lab-localhost-tls
namespace: default # Gateway와 같은 네임스페이스
spec:
secretName: lab-localhost-tls
dnsNames:
- grafana.localhost
- keycloak.localhost
duration: 2160h # 90일
renewBefore: 720h # 만료 30일 전에 갱신
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: lab-ca
kind: ClusterIssuer
ClusterIssuer가 CA 비밀키 Secret을 읽는 네임스페이스는 기본적으로 cert-manager가 설치된 네임스페이스입니다. 그래서 루트 CA Certificate는 cert-manager 네임스페이스에 만들었습니다. cert-manager CA 발급자 문서
적용하고 1초 만에 두 인증서가 Ready가 되었습니다. openssl로 확인한 결과는 아래와 같습니다.
subject=
X509v3 Subject Alternative Name: critical
DNS:grafana.localhost, DNS:keycloak.localhost
verify: OK
서버 인증서의 subject가 비어 있는 것은 오류가 아닙니다. Certificate에 commonName을 적지 않고 dnsNames만 적었기 때문입니다. 요즘 브라우저는 CN이 아니라 SAN(Subject Alternative Name)만 보고 도메인을 확인하므로, 서버 인증서는 SAN만 두는 편이 깔끔합니다. 그래서 뒤에 나오는 브라우저 인증서 창에서도 발급 대상의 일반 이름(CN)이 "인증서에 속하지 않음"으로 표시됩니다.
lab-root-ca Secret의 tls.key가 이 사설 CA의 도장입니다. 이 키가 새면 누구든 이 CA 이름으로 아무 도메인의 인증서를 만들 수 있고, 이 CA를 믿는 PC는 그 인증서도 믿습니다. 운영에서는 이 Secret을 읽을 수 있는 권한을 최소로 두고, 루트 CA는 클러스터 밖(HSM, Vault, AWS Private CA 등)에 두고 중간 CA만 클러스터에 두는 구성을 검토합니다.
Gateway에 HTTPS 리스너 붙이고 HTTP는 HTTPS로 보내기
3편의 Gateway에는 HTTP(80) 리스너만 있었습니다. 여기에 HTTPS(443) 리스너를 추가하고, cert-manager가 만든 Secret을 연결합니다. mode: Terminate는 Gateway(Envoy)에서 TLS를 풀고 안쪽 서비스에는 HTTP로 전달한다는 뜻입니다. Gateway API TLS 가이드
kind: Gateway
metadata:
name: lab-gateway
namespace: default
spec:
gatewayClassName: eg
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: All
- name: https
protocol: HTTPS
port: 443
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: lab-localhost-tls
allowedRoutes:
namespaces:
from: All
Grafana 라우트는 sectionName: https로 HTTPS 리스너에만 붙이고, HTTP 리스너에는 HTTPS로 옮기는 라우트를 따로 둡니다. 리다이렉트에 port: 8443을 꼭 적어야 합니다. 클러스터 안의 리스너는 443이지만 Windows에서 보이는 포트는 1편에서 연결한 8443이기 때문입니다. 포트를 빼면 브라우저가 https://grafana.localhost(443)로 가서 접속에 실패합니다.
kind: HTTPRoute
metadata:
name: https-redirect
namespace: default
spec:
parentRefs:
- name: lab-gateway
sectionName: http
hostnames:
- grafana.localhost
- keycloak.localhost
rules:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
port: 8443
statusCode: 301
kind 노드의 443은 Envoy 컨테이너 안에서 10443으로 바뀝니다. Envoy Gateway는 1024 미만 포트를 쓰지 않으려고 리스너 포트에 10000을 더해 컨테이너 포트로 씁니다. 그래서 3편에서 80 → 10080을 연결했던 EnvoyProxy 패치에 10443 → hostPort 443을 추가했습니다.
301 → https://grafana.localhost:8443/login
$ curl -s -o /dev/null -w "%{http_code}\n" --cacert ca.crt https://grafana.localhost:8443/login
200
$ curl https://grafana.localhost:8443/login
curl: (60) SSL certificate problem: unable to get local issuer certificate
사설 CA를 지정하면 200, 지정하지 않으면 "발급자를 모른다"는 오류가 납니다. 인증서는 맞게 붙었고, 남은 것은 PC가 이 CA를 믿게 하는 일입니다.
마지막으로 Grafana의 root_url을 https://grafana.localhost:8443으로 바꿨습니다. Grafana가 만드는 리다이렉트와 링크가 이 값을 기준으로 하고, 다음 편의 SSO 로그인에서 돌아올 주소(콜백)도 이 값으로 만들어지기 때문입니다.
grafana:
grafana.ini:
server:
root_url: https://grafana.localhost:8443
helm upgrade kps prometheus-community/kube-prometheus-stack --version 93.1.0 \
-n monitoring --reuse-values -f 06-kps-grafana-values.yaml
운영 함정 1: hostPort를 쓰는 Envoy가 교체되지 않는다
EnvoyProxy에 포트를 추가하자 Envoy Deployment가 새 파드를 만들었는데, 새 파드가 계속 Pending에 머물렀습니다.
2 node(s) didn't match Pod's node affinity/selector.
원인은 Deployment의 기본 교체 방식입니다. 기본값 RollingUpdate는 새 파드를 먼저 띄우고(maxSurge 25%, 1개 파드면 1개 추가) 준비되면 옛 파드를 내립니다. 그런데 Envoy는 hostPort 80·443을 쓰고 ingress-ready 노드 하나에만 뜨도록 되어 있어서, 옛 파드가 포트를 쥐고 있는 동안 새 파드는 들어갈 노드가 없습니다. 옛 파드는 그대로 동작하니 접속은 유지되지만, 설정 변경은 반영되지 않은 채로 멈춥니다. Deployment 교체 전략 문서
먼저 strategy.type: Recreate(옛 파드를 모두 내리고 새로 띄움)로 바꿔 봤지만, Envoy Gateway 로그에 아래 오류가 나고 반영되지 않았습니다.
spec.strategy.rollingUpdate: Forbidden: may not be specified when strategy type is 'Recreate'
이미 만들어진 Deployment에는 기본 rollingUpdate 값이 들어 있는데, type만 Recreate로 바꾸면서 그 값이 남아 충돌한 것입니다. 그래서 type은 RollingUpdate로 두고, "새 파드를 추가로 띄우지 않고(maxSurge 0), 한 개까지 내려도 된다(maxUnavailable 1)"로 설정했습니다. 결과적으로 옛 파드를 먼저 내리고 새 파드를 띄우는 Recreate와 같은 순서가 됩니다.
kind: EnvoyProxy
metadata:
name: kind-hostport
namespace: envoy-gateway-system
spec:
provider:
type: Kubernetes
kubernetes:
envoyDeployment:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 0
maxUnavailable: 1
# nodeSelector, toleration, 10080/10443 hostPort 패치는 3편과 같다
교체는 72초 걸렸고, 그동안 Grafana 접속이 끊겼습니다. 포트를 노드에 직접 묶는 구성에서는 무중단 교체가 불가능하다는 뜻입니다.
hostPort는 kind처럼 LoadBalancer가 없는 로컬 환경을 위한 우회입니다. 운영 클러스터에서는 Envoy 서비스를 LoadBalancer(클라우드 로드밸런서)로 두고 replicas를 2개 이상으로 둡니다. 그러면 기본 RollingUpdate로 무중단 교체가 됩니다. hostPort나 hostNetwork를 꼭 써야 한다면, 교체 중 끊김을 감안해 점검 시간에 변경하고 노드 여러 대에 나눠 띄우는 구성을 검토합니다.
자동 갱신을 직접 보기: 2분마다 갱신시켜 보기
인증서 자동 갱신은 운영에서 가장 중요한 기능이지만, 90일짜리 인증서로는 실습 중에 볼 수 없습니다. 그래서 잠깐 duration: 1h, renewBefore: 58m으로 바꿔 발급 2분 뒤마다 갱신되게 했습니다.
Renewing certificate as renewal was scheduled at ...
Stored new private key in temporary Secret resource "lab-localhost-tls-xxxxx"
Created new CertificateRequest resource "lab-localhost-tls-5"
The certificate has been successfully issued
약 10분 동안 5번 갱신되었고, 확인한 동작은 아래와 같습니다.
- 갱신할 때마다 비밀키도 새로 만듭니다. 이벤트의 "Stored new private key"가 그 단계입니다.
- CertificateRequest는 갱신마다 하나씩 생기지만 마지막 것만 남습니다. 오래된 요청이 쌓이지 않습니다.
- Envoy는 재시작 없이 새 인증서를 내보냅니다. 갱신 전후로 openssl 접속에서 만료 시각이 05:23:14에서 05:31:14로 바뀌었고, Envoy 파드의 재시작 횟수는 그대로였습니다.
- Envoy Gateway가 Secret 변경을 감지해 Envoy 설정을 바꿔 주기 때문입니다. 인증서를 파일로 읽는 앱이라면 다시 읽기까지 시간이 걸리거나 재시작이 필요할 수 있습니다.
확인한 뒤 duration: 2160h(90일), renewBefore: 720h(30일 전)로 되돌렸습니다. renewBefore를 30일로 잡는 이유는 갱신이 실패하더라도 고칠 시간을 30일 남겨 두기 위해서입니다. 되돌리자 기존 인증서의 남은 기간(1시간)이 renewBefore보다 짧아 즉시 90일짜리로 다시 발급되었습니다. cert-manager Certificate 문서
cert-manager에는 따로 웹 화면이 없습니다. 인증서 현황은 kubectl로 보는데, 기본 출력에는 만료일이 없어서 열을 직접 지정하면 한눈에 보기 좋습니다.
NAMESPACE NAME READY ISSUER NOT_AFTER RENEWAL REV
cert-manager lab-root-ca True selfsigned-bootstrap 2036-11-05T04:23:14Z 2033-07-07T12:23:14Z 1
default lab-localhost-tls True lab-ca 2027-02-06T05:47:29Z 2027-01-07T05:47:29Z 44

서버 인증서는 2027-02-06에 만료되고, 30일 전인 2027-01-07에 갱신이 예정되어 있습니다. REV 44는 2분 갱신 실험 동안 44번 발급되었다는 뜻입니다. Headlamp에서는 사이드바의 사용자 정의 리소스(Custom Resources) → certificates.cert-manager.io에서 같은 목록과 이벤트를 볼 수 있습니다.
표에서 루트 CA(lab-root-ca)의 갱신 예정일이 2033-07-07로 잡혀 있습니다. renewBefore를 따로 정하지 않아 수명의 2/3 시점에 cert-manager가 루트 CA도 자동으로 다시 발급하기 때문입니다. 이 실습은 갱신할 때마다 새 비밀키를 만드는 기본 동작이라, 그날 새 루트 CA가 생기면 PC에 등록해 둔 옛 루트 CA로는 새로 발급된 서버 인증서를 믿지 못합니다. 운영에서는 루트 CA 교체를 자동 갱신에 맡기지 않고, 새 루트를 먼저 배포한 뒤 바꾸는 일정을 따로 관리합니다.
Windows가 사설 CA를 믿게 하기
브라우저에서 경고 없이 열려면 Windows가 루트 CA를 믿어야 합니다. 루트 CA의 공개 인증서(비밀키 아님)만 Secret에서 꺼내 Windows에 등록합니다.
kubectl get secret -n cert-manager lab-root-ca \
-o go-template='{{index .data "ca.crt" | base64decode}}' > lab-root-ca.crt
openssl x509 -in lab-root-ca.crt -noout -subject -enddate -fingerprint -sha1
# Windows PowerShell(일반 권한): 현재 사용자 신뢰 저장소에 등록
Import-Certificate -FilePath .\lab-root-ca.crt -CertStoreLocation Cert:\CurrentUser\Root
등록 명령을 실행하면 Windows가 보안 경고 창을 띄우고 지문(SHA1 Thumbprint)을 보여 줍니다. openssl로 확인한 지문과 같은지 보고 승인합니다. 이 확인이 중간에 파일이 바뀌지 않았는지 검증하는 단계입니다. 등록 후 브라우저를 완전히 다시 켜면 https://grafana.localhost:8443이 경고 없이 열립니다.


브라우저의 인증서 창에서 발급 기관이 Lab Local Root CA이고, 유효 기간이 90일로 되돌린 인증서의 기간(2026-11-08 ~ 2027-02-06)과 같은 것을 확인할 수 있습니다. 발급 대상의 일반 이름(CN)이 비어 있는 것은 앞에서 설명한 대로 SAN만 둔 인증서이기 때문입니다.
한 가지 더 확인한 것이 있습니다. Windows 기본 curl(schannel)로 접속하면 인증서를 믿는데도 실패합니다.
schannel: next InitializeSecurityContext failed: CRYPT_E_NO_REVOCATION_CHECK (0x80092012)
> curl.exe --ssl-no-revoke -s -o NUL -w "%{http_code}" https://grafana.localhost:8443/login
200
이 사설 CA는 폐기 목록(CRL)이나 OCSP 주소를 제공하지 않아서, schannel은 "폐기 여부를 확인할 수 없다"며 접속을 막습니다. 브라우저는 확인할 수 없으면 넘어가므로 문제가 없습니다. 사내 사설 CA를 운영할 때 일부 도구만 접속에 실패한다면 이 폐기 확인을 의심해 볼 만합니다.
루트 CA를 신뢰 저장소에 넣는 것은 그 CA가 서명한 모든 인증서를 믿겠다는 뜻입니다. 실습이 끝나면 "사용자 인증서 관리"(certmgr.msc) → 신뢰할 수 있는 루트 인증 기관에서 Lab Local Root CA를 지웁니다. Firefox는 Windows 저장소가 아닌 자체 저장소를 쓰므로 따로 설정해야 합니다.
만료 알림 만들기: 자동 갱신을 믿되 감시한다
자동 갱신이 있어도 알림은 필요합니다. 발급자 설정이 바뀌거나, CA Secret이 지워지거나, ACME라면 도메인 확인이 실패해서 갱신이 조용히 멈출 수 있습니다. 갱신이 멈추면 인증서는 정해진 날에 그대로 만료되고, 그날 서비스가 한꺼번에 끊깁니다. cert-manager는 인증서마다 만료 시각, 갱신 예정 시각, Ready 상태를 Prometheus 지표로 내보냅니다. cert-manager Prometheus 지표 문서
| 지표 | 뜻 |
|---|---|
certmanager_certificate_expiration_timestamp_seconds |
인증서 만료 시각(유닉스 시간) |
certmanager_certificate_renewal_timestamp_seconds |
cert-manager가 갱신하려는 시각 |
certmanager_certificate_ready_status |
Certificate의 Ready 상태(condition 라벨별 1/0) |
규칙은 네 개를 두었습니다. 만료가 가까워지는 것만 보지 않고, 갱신 예정 시각이 지났는데 갱신되지 않은 상태와 지표 자체가 사라진 상태도 잡습니다. 특히 마지막 규칙이 중요합니다. 지표가 아예 안 들어오면 앞의 세 규칙은 계산할 값이 없어 조용히 아무 알림도 내지 않습니다.
kind: PrometheusRule
metadata:
name: cert-manager-alerts
namespace: cert-manager
labels:
release: kps # 4편의 Prometheus가 읽는 라벨
spec:
groups:
- name: cert-manager
rules:
- alert: CertificateExpiringSoon
expr: (certmanager_certificate_expiration_timestamp_seconds - time()) < 14 * 24 * 3600
for: 10m
labels:
severity: warning
annotations:
summary: "{{ $labels.exported_namespace }}/{{ $labels.name }} 인증서가 14일 안에 만료됩니다"
- alert: CertificateRenewalOverdue
expr: time() > certmanager_certificate_renewal_timestamp_seconds + 3600
for: 10m
labels:
severity: warning
- alert: CertificateNotReady
expr: certmanager_certificate_ready_status{condition="True"} != 1
for: 10m
labels:
severity: critical
- alert: CertManagerAbsent
expr: absent(up{job="cert-manager"} == 1)
for: 10m
labels:
severity: critical
14일 기준은 renewBefore(30일)보다 짧게 잡았습니다. 정상이라면 30일 전에 갱신되므로 14일 경고는 "갱신이 2주 넘게 실패하고 있다"는 뜻이 됩니다. 갱신 직전에 매번 울리는 알림은 결국 무시되기 때문에, 사람이 손을 대야 하는 상황에서만 울리게 합니다.
알림 문구에 처음에는 $labels.namespace를 썼는데, 실제 지표를 보니 default에 있는 인증서가 namespace="cert-manager"로 나왔습니다. Prometheus가 지표를 수집할 때 수집 대상(cert-manager 파드)의 네임스페이스로 namespace 라벨을 덮어쓰고, 원래 값은 exported_namespace로 옮기기 때문입니다. 이걸 모르면 알림이 엉뚱한 네임스페이스를 가리켜 대응이 늦어집니다. 인증서 위치는 exported_namespace로 써야 합니다.
{name="lab-localhost-tls", namespace="cert-manager", exported_namespace="default", issuer_name="lab-ca"} 1
{name="lab-root-ca", namespace="cert-manager", exported_namespace="cert-manager", issuer_name="selfsigned-bootstrap"} 1
수정 전에는 알림 요약이 cert-manager/lab-localhost-tls로 나왔고, exported_namespace로 바꾼 뒤에는 default/lab-localhost-tls로 실제 위치를 가리켰습니다.
규칙이 정말 울리는지도 확인했습니다. 이때 인증서는 아직 갱신 관찰용 1시간짜리였으니 "14일 안에 만료" 조건에 해당합니다. 규칙을 넣자 바로 pending이 되었고, for: 10m이 지난 뒤 firing으로 바뀌어 Alertmanager까지 전달되었습니다. 그다음 인증서를 90일로 되돌리자 새 인증서가 발급되었고, 1분 안에 알림이 해제되었습니다.
05:47:08 CertificateExpiringSoon firing
# Alertmanager
CertificateExpiringSoon warning active | default/lab-localhost-tls 인증서가 14일 안에 만료됩니다 | 남은 시간 58m 20s
# 90일로 되돌린 뒤
notBefore=2026-11-08T05:47:29Z notAfter=2027-02-06T05:47:29Z renewalTime=2027-01-07T05:47:29Z
05:48:29 CertificateExpiringSoon inactive
renewalTime이 만료 30일 전(2027-01-07)으로 잡힌 것도 확인할 수 있습니다. 알림은 이렇게 한 번 일부러 울려 봐야 라벨, 문구, 전달 경로가 맞는지 알 수 있습니다. 규칙을 만들고 한 번도 울려 보지 않으면, 정작 필요할 때 엉뚱한 내용으로 오거나 오지 않는 것을 그때 알게 됩니다.
운영 체크리스트
이 실습에서 확인한 내용을 운영 기준으로 정리하면 아래와 같습니다.
- 인증서는 직접 만들지 말고 Certificate 리소스로 선언하고, 기간(duration)과 갱신 시점(renewBefore)을 명시한다.
- renewBefore는 갱신 실패를 고칠 시간이 충분하도록 잡는다(90일 인증서라면 30일 전).
- 사설 CA 비밀키 Secret은 읽기 권한을 최소로 두고, 가능하면 루트 CA는 클러스터 밖에 둔다.
- Gateway와 인증서 Secret은 같은 네임스페이스에 두거나, 다른 네임스페이스라면 ReferenceGrant로 명시적으로 허용한다.
- 만료 임박, 갱신 지연, Ready 아님, 지표 없음의 네 가지를 알림으로 감시하고, 인증서 위치는
exported_namespace라벨로 표시한다. - hostPort·hostNetwork로 띄운 게이트웨이는 무중단 교체가 안 되므로 교체 전략(maxSurge 0)과 점검 시간을 정해 둔다. 운영에서는 LoadBalancer와 2개 이상 replicas를 쓴다.
- 인증서를 파일로 읽는 앱은 갱신 후 다시 읽는지 확인한다. Envoy Gateway처럼 Secret 변경을 감지하는 구성인지가 중요하다.
- 사설 CA를 PC에 등록할 때는 지문을 확인하고, 쓰지 않으면 지운다.
자주 묻는 질문
Let's Encrypt로 바꾸려면 무엇을 바꾸면 되나요?
Certificate와 Gateway 설정은 그대로 두고, 발급자를 ACME ClusterIssuer로 바꾸고 Certificate의 `issuerRef`만 그 이름으로 바꾸면 됩니다. 대신 인증서에 넣을 도메인이 공개 DNS에 등록되어 있고, HTTP-01 방식이라면 외부에서 그 도메인의 80 포트로 접속할 수 있어야 합니다. `.localhost`처럼 외부에서 확인할 수 없는 이름에는 공인 인증서를 받을 수 없습니다.
Ingress를 쓸 때와 무엇이 다른가요?
Ingress는 주석(annotation)을 달면 cert-manager가 Certificate를 자동으로 만들어 주는 방식을 많이 씁니다. Gateway API도 cert-manager의 Gateway 지원을 켜면 Gateway 주석으로 같은 일을 할 수 있습니다. 이 글은 동작을 잘 보이게 하려고 Certificate를 직접 만들고 Gateway의 `certificateRefs`로 연결했습니다.
인증서가 갱신되면 서비스가 끊기나요?
이 실습의 Envoy Gateway는 Secret 변경을 감지해 재시작 없이 새 인증서를 썼고, 2분 간격으로 5번 갱신되는 동안 파드 재시작은 없었습니다. 끊김 여부는 인증서를 쓰는 쪽이 Secret이나 파일 변경을 바로 반영하는지에 달려 있습니다.
작성 방식과 다음 편
이 글의 명령, 오류 메시지, 결과는 1편의 실습 PC에서 직접 실행한 것이며, 설정은 cert-manager·Gateway API·Envoy Gateway 공식 문서를 기준으로 했습니다. 실행 기록 정리와 글 작성에 AI 도구를 함께 사용했습니다. 다음 편에서는 이 HTTPS 위에 Keycloak을 운영 모드(Keycloak Operator + PostgreSQL)로 설치하고, Grafana 로그인을 SSO로 바꿉니다. 이번에 인증서에 미리 넣어 둔 keycloak.localhost가 그때 쓰입니다.
'Kubernetes Guides' 카테고리의 다른 글
| Fluent Bit과 Loki로 쿠버네티스 로그 모으기: kind 실습과 Grafana 조회, AWS 확장 예시 (0) | 2026.11.09 |
|---|---|
| kube-prometheus-stack 설치와 Grafana 접속: kind 클러스터 모니터링과 target down 해결 (0) | 2026.11.08 |
| NGINX Ingress 대신 Gateway API: kind 클러스터 앱을 Windows localhost로 접속하기 (0) | 2026.11.08 |
| Headlamp 설치와 사용법: 로컬 쿠버네티스를 화면으로 보고 파드 배포하기 (0) | 2026.11.08 |
| Windows에서 무료 쿠버네티스 실습 환경 만들기: WSL2 + Docker Engine + kind (0) | 2026.11.08 |










