쿠버네티스 인증서 만료 확인과 kubeadm certs renew 갱신 방법

3/27/2025 ·impact

쿠버네티스 인증서 만료 확인과 kubeadm certs renew 갱신 방법

Argo CD 배포가 계속 지연되고 kubectl 명령이 응답하지 않는데, 정작 노드와 파드는 겉보기에 멀쩡한 상황이 있습니다. 원인은 Kubernetes 인증서 만료였습니다. 진단이 늦어지는 이유는 증상이 배포 실패로 먼저 보이기 때문입니다. 파이프라인이 죽으면 대개 이미지나 매니페스트부터 열어 보게 되고, 인증서는 평소 건드릴 일이 없어 후보에 잘 오르지 않습니다. 확인하려는 순간 kubectl이 이미 막혀 있다는 점도 겹칩니다. 클러스터에 물어보는 평소 방식이 통하지 않습니다.

쿠버네티스 공식 문서 Certificate Management with kubeadm은 kubeadm이 발급하는 인증서의 기본 유효 기간을 certificateValidityPeriod: 8760h(1년), CA 인증서를 caCertificateValidityPeriod: 87600h(10년)으로 명시합니다. 유효 기간 1년이 붙는 것은 클라이언트 인증서만이 아닙니다. apiserver·etcd-server 같은 서버 인증서를 포함한 리프 인증서 전반이 같은 기본값을 씁니다. CA만 10년이라, 구축하고 한참 지난 클러스터가 어느 날 갑자기 통신을 거부합니다.

인증서가 만료되면 이렇게 드러납니다

CI/CD 파이프라인이 먼저 신호를 보내는 경우가 많습니다. 작업용 파드가 뜨지 못한 채 타임아웃으로 끝납니다.

ERROR: Build failed (system failure): timed out waiting for pod to start

터미널에서 kubectl을 실행하면 조금 더 직접적인 메시지가 나옵니다.

Unable to connect to the server: x509: certificate has expired or is not yet valid

x509 문자열이 보인다면 TLS 핸드셰이크 단계에서 이미 실패했다는 뜻입니다. 요청을 보내기도 전에 막힌 상태라, 권한이나 매니페스트를 아무리 손봐도 달라지지 않습니다. 영향은 kubectl 한 곳에 그치지 않습니다. Argo CD는 sync를 시작하지 못한 채 대기하고, API 서버에 붙어서 동작하는 쪽이 함께 멈춥니다. 반면 이미 떠 있던 애플리케이션 파드는 계속 돌기 때문에, 서비스는 되는데 배포만 안 되는 애매한 모습으로 보이기도 합니다. 클러스터 전역에서 동시에 증상이 나타난다는 점을 먼저 보세요.

서버 쪽인지 클라이언트 쪽인지부터 가릅니다

kubectl이 뱉는 x509: certificate has expired 는 두 가지 경우에 나옵니다. API 서버가 내미는 서버 인증서가 만료됐거나, 내 kubeconfig에 박힌 클라이언트 인증서가 만료됐거나. 메시지만으로는 구분되지 않으니 둘 다 봅니다.

컨트롤 플레인 노드에 접속한 뒤 API 서버 인증서의 유효 기간을 직접 읽습니다.

openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -text | grep ' Not '

출력은 아래와 같은 형태입니다.

Not Before: Mar 01 06:00:00 2023 GMT
Not After : Feb 29 06:00:00 2024 GMT

Not Before는 유효 시작 시각, Not After는 만료 시각입니다. 현재 시각이 Not After를 지났다면 만료된 상태입니다. GMT 기준으로 찍힌다는 점은 짚어 둘 만합니다. 로컬 시간대에 맞춰 둔 시계와 그대로 비교하면 만료가 임박한 시점에 판단이 어긋납니다.

kubeconfig에 내장된 클라이언트 인증서는 파일 안에 base64로 들어 있어 그대로는 읽히지 않습니다. 꺼내서 같은 방식으로 확인합니다. -datesopenssl x509 문서가 "인증서의 시작·만료 날짜를 출력한다"고 설명하는 옵션입니다.

# 내 계정이 쓰는 사본
grep client-certificate-data $HOME/.kube/config | awk '{print $2}' | base64 -d | openssl x509 -noout -dates

# 원본
grep client-certificate-data /etc/kubernetes/admin.conf | awk '{print $2}' | base64 -d | openssl x509 -noout -dates

client-certificate-data 대신 client-certificate: 로 파일 경로가 적혀 있다면 그 경로를 openssl x509 -in 에 바로 넣으면 됩니다. 두 결과가 다르게 나오는 경우가 있는데, 그 자체가 뒤에서 다룰 사본 문제의 신호입니다.

파일을 하나씩 열어 보는 대신 kubeadm이 관리하는 인증서를 한 번에 볼 수도 있습니다.

kubeadm certs check-expiration

공식 문서의 인증서 만료 확인 항목에 따르면 이 명령은 /etc/kubernetes/pki 의 인증서와 kubeadm이 쓰는 kubeconfig 파일(admin.conf, controller-manager.conf, scheduler.conf)에 내장된 클라이언트 인증서의 만료 시점과 잔여 시간을 함께 보여줍니다. 문서의 예시 출력에서 리프 인증서는 RESIDUAL TIME364d, CA는 9y 로 찍힙니다. 외부 CA가 서명한 인증서는 EXTERNALLY MANAGED 로 표시되고, 같은 문서가 "kubeadm은 외부 CA가 서명한 인증서를 관리할 수 없다"고 못 박고 있어 이 경우 갱신은 직접 처리해야 합니다.

컨트롤 플레인 노드를 여러 대로 두었다면 확인할 곳도 그만큼 늘어납니다. 노드를 어떻게 붙였는지는 마스터·워커 노드를 추가하던 기록에 정리해 두었습니다.

백업하고 kubeadm certs renew all 로 갱신합니다

작업 전에 인증서 디렉터리를 통째로 복사해 두고 일괄 갱신을 겁니다.

cp -r /etc/kubernetes/pki /etc/kubernetes/pki-backup
kubeadm certs renew all

kubeadm certs 레퍼런스는 이 서브커맨드를 "컨트롤 플레인 구동에 필요한 알려진 인증서 전부를 갱신한다"로 설명하고, 만료일과 무관하게 무조건 재발급한다고 덧붙입니다. 갱신은 /etc/kubernetes/pki 에 있는 기존 CA 키와 인증서로 이뤄집니다. CN·O·SAN 같은 속성은 인증서 관리 문서의 설명대로 기존 인증서에서 그대로 가져오므로 다시 넣어 줄 필요가 없습니다. 예전 문법인 kubeadm alpha certs renew all 은 deprecated 되었으니 그대로 쓰지 않도록 합니다.

복제된 컨트롤 플레인을 쓴다면 같은 문서가 "이 명령을 모든 컨트롤 플레인 노드에서 실행해야 한다"고 안내합니다. 한 대만 갱신하면 나머지는 만료 상태로 남습니다.

갱신이 끝나면 처음과 같은 명령으로 날짜가 밀렸는지 봅니다.

openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -text | grep ' Not '
Not After : Mar 27 07:30:00 2025 GMT

여기까지는 디스크의 파일만 바뀐 상태입니다. 공식 문서는 모든 컴포넌트와 인증서가 동적 리로드를 지원하지는 않기 때문에 갱신 후 컨트롤 플레인 파드를 재시작해야 한다고 안내합니다.

renew가 손대는 파일, 빠지는 파일

renew가 건드리는 범위는 /etc/kubernetes/pki 아래의 인증서와 admin.conf·controller-manager.conf·scheduler.conf 에 내장된 클라이언트 인증서입니다. 레퍼런스도 kubeadm certs renew admin.conf 를 "관리자와 kubeadm 자신이 쓰는 kubeconfig 파일에 내장된 인증서를 갱신한다"로 설명합니다. super-admin.conf 가 디스크에 있으면 그 인증서도 함께 1년으로 갱신됩니다.

반대로 빠지는 파일이 둘 있습니다. 이 중 $HOME/.kube/config 는 갱신 후에도 kubectl이 계속 실패하는 원인이 됩니다.

kubelet.conf 는 애초에 목록에 없습니다. 문서는 그 이유를 kubeadm이 kubelet에 자동 인증서 갱신을 설정해 두고 /var/lib/kubelet/pki 아래의 로테이션 가능한 인증서를 쓰게 하기 때문이라고 설명합니다. kubelet이 알아서 갱신하니 사람이 손댈 파일이 아니라는 뜻입니다.

$HOME/.kube/config 는 사정이 다릅니다. 클러스터를 처음 세팅할 때 admin.conf 를 복사해 만든 사본이라, 원본이 갱신돼도 사본은 옛 인증서를 그대로 들고 있습니다. 인증서 관리 문서도 admin.conf 갱신 뒤 아래 두 줄로 사본을 다시 맞추라고 안내합니다.

sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config

-i 옵션 때문에 덮어쓸지 되묻습니다. 여기서 덮어쓰지 않으면 앞의 작업이 kubectl에 반영되지 않습니다.

kubelet 클라이언트 인증서가 만료됐다면

kubelet 쪽은 kubeadm kubeconfig userkubelet.conf 를 새로 만들어 덮어쓰면 안 됩니다. 그렇게 만든 파일은 인증서를 파일 안에 내장하므로 자동 로테이션 경로에서 이탈하고, 1년 뒤 같은 장애가 그대로 돌아옵니다.

로테이션이 실패했다면 kubeadm 트러블슈팅 문서의 Kubelet client certificate rotation fails 절차를 그대로 따릅니다. 문제 노드의 /etc/kubernetes/kubelet.conf/var/lib/kubelet/pki/kubelet-client* 를 백업 후 삭제하고, ca.key 가 있는 정상 컨트롤 플레인 노드에서 kubeadm kubeconfig user --org system:nodes --client-name system:node:$NODE > kubelet.conf 로 임시 파일을 만들어 문제 노드에 복사한 뒤 kubelet을 재시작합니다. /var/lib/kubelet/pki/kubelet-client-current.pem 이 다시 생기면 kubelet.confclient-certificate-data·client-key-data 를 그 pem 경로를 가리키는 client-certificate·client-key 로 바꾸고 kubelet을 한 번 더 재시작합니다. 이 마지막 단계를 건너뛰면 자동 로테이션으로 되돌아가지 못합니다.

$NODE$(hostname) 을 그대로 넣지 마세요. PKI 인증서 문서kubelet.conf 의 노드 이름이 kubelet이 API 서버에 등록할 때 쓴 이름과 정확히 일치해야 한다고 밝힙니다. 클라우드 인스턴스처럼 호스트명과 노드 이름이 다른 환경에서는 kubectl get nodes 로 실제 이름을 확인하고 넣어야 합니다.

kubeconfig 파일이 지워졌거나 깨졌다면

renew로 해결되지 않는 경우가 있습니다. kubeconfig 파일 자체가 없어진 상황입니다. 이때는 kubeadm kubeconfig user 로 다시 만듭니다. 레퍼런스 기준으로 --client-name 은 인증서의 CN, --org 는 O로 들어가고, --validity-period 의 기본값은 8760h0m0s 입니다. kubeadm init phase kubeconfig 에도 admin·kubelet·controller-manager·scheduler·super-admin 서브커맨드가 따로 있습니다.

cp -r /etc/kubernetes /etc/kubernetes-backup
kubeadm kubeconfig user --org kubeadm:cluster-admins --client-name kubernetes-admin > /etc/kubernetes/admin.conf
kubeadm kubeconfig user --client-name system:kube-controller-manager > /etc/kubernetes/controller-manager.conf
kubeadm kubeconfig user --client-name system:kube-scheduler > /etc/kubernetes/scheduler.conf

CN과 O를 정확히 맞춰야 하는 이유가 있습니다. 인증 문서는 클라이언트 인증서의 commonName을 사용자 이름으로, organization 필드를 그룹 소속으로 읽는다고 설명합니다. 값이 어긋나면 의도한 것과 다른 주체로 인증됩니다.

O에 무엇을 넣을지는 클러스터 버전을 타므로 확인하고 쓰세요. Kubernetes v1.29 CHANGELOG에 따르면 이 릴리스부터 admin.conf 의 사용자가 새 RBAC 그룹 kubeadm:cluster-admins 에 묶이고, system:masters 는 별도 파일 super-admin.conf(CN = kubernetes-super-admin)로 분리됐습니다. 그전까지는 admin.conf 자체가 system:masters 였습니다. system:masters 는 RBAC 인가 계층을 우회하는 break-glass 그룹이라, 1.29 이상에서 --org system:mastersadmin.conf 를 만들면 필요 이상의 권한을 상시로 들고 다니게 됩니다. /etc/kubernetes/super-admin.conf 가 존재하는지, kubeadm certs check-expiration 출력에 그 줄이 보이는지로 1.29 이후 구조인지 먼저 확인하세요.

컨트롤 플레인 재시작하기

kubeadm 클러스터 구축 문서에 따르면 kubeadm은 kubelet을 systemd 서비스로 실행합니다.

systemctl daemon-reload
systemctl restart kubelet

kube-apiserver, kube-scheduler, kube-controller-manager는 다릅니다. 이들은 /etc/kubernetes/manifests 의 매니페스트로 뜨는 static Pod라 로컬 kubelet이 관리하고, 인증서 관리 문서도 "static Pod는 API 서버가 아니라 로컬 kubelet이 관리하므로 kubectl로 지우고 재시작할 수 없다"고 명시합니다. 같은 문서가 제시하는 방법은 매니페스트를 디렉터리 밖으로 잠시 옮겼다가 되돌리는 것이고, 대기 시간은 20초입니다. 문서는 이 값이 KubeletConfiguration의 fileCheckFrequency 라고 밝히고 있고, kubelet 레퍼런스--file-check-frequency 기본값도 20s 입니다.

mv /etc/kubernetes/manifests/kube-apiserver.yaml /tmp/
# 20초 이상 대기 — kubelet이 파드를 내린다
mv /tmp/kube-apiserver.yaml /etc/kubernetes/manifests/
# 다시 20초 이상 — kubelet이 파드를 새로 만든다

kube-scheduler와 kube-controller-manager도 같은 방법으로 처리합니다. 다만 구축 방식은 환경마다 다르므로 ls /etc/kubernetes/manifestssystemctl list-units | grep kube 를 먼저 실행해 어느 쪽 구조인지 확인한 다음 진행하세요.

컨테이너를 직접 내려 kubelet이 다시 띄우게 하는 방법도 쓰이는데, 런타임 명령은 환경을 많이 탑니다. dockershim은 v1.24에서 kubelet에서 제거되었고, 그 이후 버전에서 Docker Engine을 계속 쓰려면 cri-dockerd 같은 어댑터가 필요합니다. containerd나 CRI-O로 넘어간 클러스터에는 docker 명령이 아예 없습니다.

재시작 후에는 kubectl get nodes 응답이 돌아오는지 확인하고, 그다음 Argo CD가 sync를 재개하는지 봅니다. 컨트롤 플레인이 다시 뜨는 동안에는 응답이 없을 수 있으니 한 번 실패했다고 바로 다음 조치로 넘어가지 마세요.

만료 전에 잡는 방법

유효 기간이 1년이라는 건 1년마다 같은 작업이 돌아온다는 뜻입니다. 사내 개발용이나 오래된 온프레미스 환경이 여기에 걸리기 쉽습니다.

감시는 kubeadm certs check-expiration 출력을 파싱하는 대신 openssl의 종료 코드로 거는 편이 안전합니다. RESIDUAL TIME364d 뿐 아니라 3d15h, 23h, <invalid> 처럼 형태가 바뀌고 컬럼 위치도 고정이 아니라서, 정작 만료가 임박했을 때 정규식이 빗나갈 여지가 큽니다. openssl 문서의 -checkend 는 "인자로 준 초 안에 만료되면 0이 아닌 값으로 종료한다"고 정의되어 있어 판정이 명확합니다.

#!/bin/sh
# /usr/local/bin/check-k8s-certs.sh — 30일 이내 만료 예정이면 알림
THRESHOLD=$((30 * 86400))
EXPIRING=""

for f in /etc/kubernetes/pki/apiserver.crt /etc/kubernetes/pki/apiserver-kubelet-client.crt; do
 openssl x509 -in "$f" -noout -checkend "$THRESHOLD" >/dev/null 2>&1 || EXPIRING="$EXPIRING $f"
done

for c in /etc/kubernetes/admin.conf /etc/kubernetes/controller-manager.conf /etc/kubernetes/scheduler.conf; do
 grep client-certificate-data "$c" | awk '{print $2}' | base64 -d \
 | openssl x509 -noout -checkend "$THRESHOLD" >/dev/null 2>&1 || EXPIRING="$EXPIRING $c"
done

[ -z "$EXPIRING" ] && exit 0
kubeadm certs check-expiration | mail -s "$(hostname): 인증서 만료 임박 -$EXPIRING" ops@example.com
0 9 * * 1 /usr/local/bin/check-k8s-certs.sh

판정은 openssl이 하고, 사람이 읽을 전체 현황은 check-expiration 출력을 그대로 메일 본문에 실어 보냅니다. 임계값 30일은 정기 점검 주기가 주 단위일 때의 기준이고, 유지보수 창을 월 단위로 잡는다면 60일로 올려 잡는 편이 낫습니다. 알림 경로가 메일이 아니라 Slack 웹훅이면 마지막 줄만 curl 로 바꾸면 됩니다. cron은 로그인 셸이 아니라서 $HOME 이 기대와 다를 수 있으니, 사용자 사본($HOME/.kube/config)까지 보려면 경로를 절대 경로로 박아 두세요. 컨트롤 플레인 노드가 여러 대면 노드마다 걸어야 하고, 노드 목록은 마스터·워커 노드를 추가하던 기록과 대조해 빠진 곳이 없는지 확인합니다.

갱신을 별도 작업으로 두지 않는 방법도 있습니다. 공식 문서의 Automatic certificate renewal 항목은 kubeadm이 컨트롤 플레인 업그레이드 과정에서 모든 인증서를 갱신한다고 설명하고, 업그레이드 간격이 1년 미만이라면 이것만으로 클러스터가 유지된다고 덧붙입니다. 끄는 플래그는 --certificate-renewal=false 이고 kubeadm upgrade applykubeadm upgrade node 에 붙습니다. 업그레이드 스크립트에 이 플래그가 들어가 있지 않은지 한 번 확인해 두세요.

갱신 작업은 컨트롤 플레인 재시작을 동반하므로 트래픽이 적은 시간대에 잡습니다.

이 절차를 그대로 적용할 수 있는 조건

이 절차는 kubeadm으로 세운 클러스터가 전제입니다. 이미 만료된 뒤라면 서버 인증서인지 kubeconfig에 박힌 클라이언트 인증서인지부터 구분해야 하고, 갱신 범위 밖인 $HOME/.kube/config 는 손으로 다시 복사해야 합니다. 반대로 kubelet.conf 는 그대로 두는 편이 안전합니다.

컨트롤 플레인이 여러 대면 renew를 노드마다 반복해야 하고, 매니페스트를 옮겼다 되돌리는 동안 해당 노드의 컴포넌트가 내려가는데, 내리는 20초와 다시 뜨는 20초를 합쳐 최소 40초는 비어 있습니다. /var/lib/kubelet/pki 자동 로테이션은 클러스터 설정마다 켜진 곳도 꺼진 곳도 있어, 이 절차의 전제로 삼을 수 없습니다.

지금 갱신할지 미룰지를 가리는 숫자는 kubeadm certs check-expiration 이 내놓는 잔여 기간 하나입니다. 이 값이 다음 유지보수 창보다 짧으면 트래픽이 적은 시간대를 잡아 갱신하고, 길면 컨트롤 플레인 업그레이드가 함께 갱신해 주는 쪽에 맡겨도 됩니다.