새벽 3시에 터지는 Kubernetes 클러스터, 인증서 때문이었다

|Platform Decision|15분 읽기

새벽 3시의 악몽

며칠 전 동료한테 들은 이야기입니다. 새벽 3시, PagerDuty 알림이 끊임없이 울리더니 Kubernetes API 서버에 아예 접근이 안 되더랍니다. SSH로 급히 들어가보니 Pod들이 줄줄이 실패하고, 노드가 하나씩 NotReady로 떨어지고 있었다고 해요.

로그를 보니 답은 한 줄이었습니다.

x509: certificate has expired

잊혀진 인증서 하나가 클러스터 전체를 마비시킨 겁니다. GitHub(2025년), Microsoft Azure(2019년)에서도 비슷한 사고가 있었죠. 규모는 다르지만 원인은 똑같았습니다. 아무도 인증서 만료일을 챙기지 않았다는 것.

복잡한 버그도 아니고, 예방 불가능한 장애도 아닙니다. 그냥 관리되지 않은 시간이 쌓여서 터진 거예요. 인증서는 코드가 아니라 운영의 영역인데, 한 번 발급하고 나면 다들 코드처럼 잊어버립니다. 발급하는 순간 만료라는 타이머가 같이 시작된다는 걸 놓치는 거죠.

금융권에서 인증서를 다뤄본 입장에서 보면 이건 익숙한 패턴입니다. 인증서 갱신은 항상 '누군가는 알고 있겠지'의 영역에 있다가, 그 누군가가 휴가를 가거나 퇴사한 순간 사고가 됩니다. 책임이 어느 시스템에도 명시적으로 박혀 있지 않으니까요.

Kubernetes의 인증서들

먼저 Kubernetes 안에서 돌아가는 인증서들을 정리해보겠습니다.

인증서 유형 용도 만료 시 영향
API 서버 인증서 제어 평면과의 보안 통신 클러스터 전체 접근 불가
Kubelet 인증서 노드의 API 서버 인증 노드 NotReady, Pod 스케줄링 실패
Ingress 인증서 외부 HTTPS 트래픽 서비스 접근 불가
etcd 인증서 etcd 멤버 간 통신 데이터 저장소 장애

각각 만료일이 있고, 만료되면 해당 통신이 끊깁니다. 구조 자체는 의외로 단순해요. 문제는 이 인증서들이 한 군데 모여 있지 않다는 겁니다. 어떤 건 Secret 안에, 어떤 건 노드 파일시스템에, 어떤 건 etcd 멤버 사이에 흩어져 있죠. 흩어진 만큼 시야 밖으로 빠지기 쉽고, 시야 밖에 있는 건 결국 관리되지 않습니다.

실수 1: 만료일을 모르고 있기

대부분의 장애는 "언제 만료되는지 몰랐다"에서 시작됩니다. 볼 수 없는 건 고칠 수도 없으니까요. 모니터링의 첫 번째 가치는 알림이 아니라 가시성입니다.

해결책: 자동 모니터링 구축

1단계: x509-certificate-exporter 설치

Prometheus 메트릭으로 클러스터 안 인증서를 통째로 감시할 수 있게 해줍니다.

helm repo add enix https://charts.enix.io
helm repo update

helm upgrade --install x509-certificate-exporter enix/x509-certificate-exporter \
  --set prometheusServiceMonitor.enabled=true \
  --set prometheusServiceMonitor.labels.release=prometheus \
  --set service.port=9793

2단계: cert-manager 메트릭 활성화

helm upgrade --install cert-manager oci://quay.io/jetstack/charts/cert-manager \
  --version v1.19.4 \
  --namespace cert-manager \
  --create-namespace \
  --set prometheus.enabled=true \
  --set prometheus.servicemonitor.enabled=true

3단계: Prometheus 알림 규칙 설정

30일, 7일 전에 미리 알려주는 규칙을 만듭니다. 임계가 두 단계인 이유는 단순합니다. 30일은 여유롭게 계획할 시간이고, 7일은 "이제 진짜 손대야 한다"는 신호니까요.

apiVersion: v1
kind: ConfigMap
metadata:
  name: prometheus-cert-rules
  namespace: monitoring
data:
  cert-expiry.rules: |
    groups:
    - name: certificate_expiry
      interval: 1h
      rules:
      - alert: CertManagerCertExpiring30Days
        expr: (certmanager_certificate_expiration_timestamp_seconds - time()) / 86400 < 30
        for: 1h
        labels:
          severity: warning
        annotations:
          summary: "인증서 {{ $labels.name }}가 30일 내 만료됩니다"
          
      - alert: ControlPlaneCertExpiring7Days
        expr: (x509_cert_not_after - time()) / 86400 < 7
        for: 1h
        labels:
          severity: critical
        annotations:
          summary: "긴급: 제어플레인 인증서가 7일 내 만료됩니다"

물론 손으로도 확인할 수 있습니다. 모니터링이 깨졌을 때 마지막으로 기댈 수 있는 게 이 명령어들이에요.

# kubelet 인증서 확인
openssl x509 -in /var/lib/kubelet/pki/kubelet-client-current.pem -noout -enddate

# API 서버 인증서 확인
openssl s_client -connect localhost:6443 -showcerts 2>/dev/null | \
openssl x509 -noout -enddate

실수 2: 수동으로 인증서 관리하기

수동 관리는 시간도 오래 걸리고, 실수도 잦고, 스케일도 안 됩니다. 그런데 가장 치명적인 건 따로 있어요. 깜빡한다는 것. 사람의 기억에 의존하는 운영은 언젠가 반드시 한 번은 실패합니다. 그게 하필 새벽 3시일 뿐이죠.

해결책: cert-manager로 자동화

ClusterIssuer 생성

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: your-email@example.com
    privateKeySecretRef:
      name: letsencrypt-prod-key
    solvers:
    - http01:
        ingress:
          class: nginx

자동 갱신되는 Ingress 인증서

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-ingress
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
    cert-manager.io/renew-before: "720h"  # 30일 전 갱신
spec:
  tls:
  - hosts:
    - example.com
    secretName: example-com-tls
  rules:
  - host: example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: app-service
            port:
              number: 80

cert-manager가 알아서 인증서를 발급하고, 만료 30일 전에 자동으로 갱신합니다. 손댈 일이 없어요. 인증서 갱신을 사람의 일에서 시스템의 일로 옮기는 것, 자동화의 핵심은 결국 책임의 위치를 바꾸는 데 있습니다.

실수 3: Kubelet 인증서 회전 설정 안 하기

Kubelet 인증서가 만료되면 노드가 API 서버와 말을 못 합니다. 그 순간 노드는 NotReady로 떨어지죠. Ingress 인증서는 워낙 눈에 잘 띄어서 챙기는데, Kubelet 인증서는 평소에 보이지 않으니 의외로 많이들 놓칩니다.

해결책: Kubelet 자동 회전 활성화

# /var/lib/kubelet/config.yaml
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
rotateCertificates: true
serverTLSBootstrap: true

kubeadm으로 구성된 클러스터라면 이렇게요.

apiVersion: kubeadm.k8s.io/v1beta3
kind: ClusterConfiguration
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
featureGates:
  RotateKubeletServerCertificate: true
rotateCertificates: true
serverTLSBootstrap: true

설정 후 kubelet을 재시작하면 자동 회전이 시작됩니다.

실수 4: 회전이 잘 되는지 테스트 안 하기

자동화를 걸어놓고 실제로 도는지는 확인하지 않는 경우가 많습니다. 그러다 정작 진짜 상황에서 회전이 실패하죠. 백업이든 자동화든, 검증해본 적 없는 안전장치는 안전장치가 아닙니다.

해결책: 정기적인 회전 훈련

짧은 TTL로 테스트 인증서를 만들어 회전을 직접 눈으로 확인해보세요.

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: test-rotation-cert
spec:
  secretName: test-rotation-tls
  duration: 2h      # 2시간만 유효
  renewBefore: 1h   # 1시간 전 갱신
  issuerRef:
    name: letsencrypt-staging  # 반드시 staging 사용!
    kind: ClusterIssuer
  dnsNames:
  - test.example.com

⚠️ 중요: 테스트에는 반드시 Let's Encrypt staging 환경을 쓰세요. production에는 요청 제한이 있어서, 테스트로 돌리다가 실제 인증서 발급이 막힐 수 있습니다. 갱신 자동화를 검증하려다 오히려 갱신을 못 하게 되는 웃지 못할 상황이 나옵니다.

실수 5: 인증서 백업 안 하기

인증서가 실수로 삭제되거나 회전 중 문제가 생겼을 때, 백업이 없으면 복구가 막막해집니다. 자동화가 있어도 그 자동화가 깨지는 순간이 있고, 그때 기댈 곳이 백업이에요.

해결책: 자동 백업 시스템

apiVersion: batch/v1
kind: CronJob
metadata:
  name: cert-backup
  namespace: kube-system
spec:
  schedule: "0 2 * * *"  # 매일 새벽 2시
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: backup
            image: bitnami/kubectl:latest
            command:
            - /bin/sh
            - -c
            - |
              kubectl get secret --all-namespaces -o yaml > /backup/certs-$(date +%Y%m%d).yaml
            volumeMounts:
            - name: backup-volume
              mountPath: /backup
          volumes:
          - name: backup-volume
            persistentVolumeClaim:
              claimName: cert-backup-pvc
          restartPolicy: OnFailure

한 가지 짚어둘 건, Secret 덤프에는 개인키가 그대로 들어간다는 점입니다. 백업 볼륨의 접근 권한과 암호화는 별도로 챙겨야 해요. 백업이 새로운 유출 경로가 되면 안 되니까요.

실수 6: 프로덕션에서 자체 서명 인증서 사용

자체 서명 인증서는 브라우저 경고를 띄우고, 자동 회전이 까다롭고, 문제 해결을 복잡하게 만듭니다. PoC 단계에서 빠르게 띄우려고 쓴 자체 서명 인증서가 그대로 운영까지 따라오는 경우, 생각보다 흔합니다.

해결책: 신뢰할 수 있는 CA 사용

  • 외부 서비스: Let's Encrypt (무료, 자동화, 신뢰됨)
  • 내부 서비스: cert-manager와 private CA 조합

내부 CA 설정 예시입니다.

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: internal-ca
spec:
  ca:
    secretName: internal-ca-key-pair

내부 서비스라도 신뢰 체인을 명확히 잡아두면, 회전과 검증을 외부 서비스와 같은 파이프라인으로 묶을 수 있습니다. 인증서를 두 종류의 운영 방식으로 쪼개지 않는 게 장기적으로 편해요.

실수 7: 사고 대응 계획 없이 운영하기

예상치 못한 인증서 만료가 터졌을 때 절차가 없으면 복구 시간이 몇 배로 늘어납니다. 새벽 3시에 머릿속으로 명령어를 떠올리는 것과, 문서를 펴놓고 그대로 따라 치는 건 전혀 다른 일이거든요.

해결책: 인증서 사고 대응 플레이북

1단계: 즉시 평가 (5분)

# 영향받은 인증서 식별
kubectl get certificate --all-namespaces

# 만료일 확인
openssl x509 -in /path/to/cert.pem -noout -enddate

2단계: 즉각적인 완화 (10분)

# Ingress 인증서 강제 갱신
kubectl delete certificate <cert-name> -n <namespace>
kubectl apply -f ingress-with-cert.yaml

# Kubelet 인증서 갱신
sudo kubeadm certs renew all
sudo systemctl restart kubelet

# API 서버 인증서 갱신
sudo kubeadm certs renew apiserver

3단계: 검증 (5분)

# 새 인증서 확인
kubectl get nodes
kubectl get pods --all-namespaces
curl -k https://your-api-endpoint/healthz

시간 배분(5분-10분-5분)을 적어둔 건 형식이 아닙니다. 장애 한가운데서 시간 감각이 무너질 때, 단계별 목표 시간이 있으면 "지금 더 파야 하나, 우회해야 하나"를 판단하는 기준이 됩니다.

경험에서 배운 것들

인증서 관리는 생각보다 단순합니다. 복잡한 로직이 필요한 게 아니라, 미리 알고, 자동으로 갱신하고, 잘 도는지 확인하는 것뿐이에요.

핵심은 이 세 가지입니다.

  1. 30일 전에 알림 — 여유롭게 대응할 시간 확보
  2. cert-manager로 자동화 — 사람이 깜빡하는 지점 제거
  3. 정기적인 테스트 — 자동화가 실제로 도는지 확인

결국 인증서 장애는 기술 문제가 아니라 운영 문제입니다. 코드 자리에서 보면 "한 줄짜리 만료 에러"지만, 운영 자리에서 보면 "누구도 책임지지 않던 타이머"였던 거죠. 자동화의 진짜 목적은 그 타이머를 사람의 기억에서 시스템으로 옮기는 데 있습니다.

새벽 3시에 깨는 일은 정말 피하고 싶잖아요. 이건 한 번 설정해두면 두고두고 마음이 편해지는 종류의 일입니다. 미루는 비용이 가장 비싼 항목이기도 하고요.

#Kubernetes#TLS인증서#cert-manager#DevOps#장애대응