K8s 버전 업그레이드 장애 회고
- 원인 : 컨트롤플레인 버전만 업그레이드 되어 클러스터 DNS(coredns)가 전멸 → 클러스터에서 서빙 중인 서비스 대부분이 정상 응답 불가
- 컨트롤 플레인과 노드그룹 모두의 버전을 맞춰서 이슈 해결
개요
컴플라이언스 대응으로 개발계 K8s 클러스터 버전을 1.29.x 에서 1.31.8 로(마이너를 건너뛸 수 없어 1.30 경유) 업그레이드하는 과정에서 장애가 발생했습니다 클러스터 DNS가 전멸해서 이 클러스터에서 서빙 중인 서비스 대부분이 정상 응답을 못 하는 상태였고, 그 상태에서 복구한 기록을 공유합니다. 이 클러스터는 매니지드 서비스로 올라간 서비스였고, 개발계(Dev Phase) 클러스터였습니다. 원인 특정까지 꽤 헤맸고 중간에 제가 크게 잘못 짚은 것도 있어서, 그것까지 그대로 남깁니다.
한 줄 결론: 컨트롤플레인만 올려 노드그룹과 버전이 벌어진 게 원인이었고, 버전 정렬 + cilium 재시작으로 복구했음
원인 — 마이너를 두 단계 올려야 해서 v1.29.x → v1.30 → v1.31.8 두 홉으로 진행했음. 첫 홉은 노드그룹까지 같이 올라가 정렬됐는데(kubelet v1.30.10), 두 번째 홉에서 컨트롤플레인만 v1.31.8로 올라가고 노드그룹은 v1.30.10에 남음. 이 버전 스큐 때문에 프라이빗 서브넷 노드가 새 버전 coredns/kube-proxy 이미지를 퍼블릭 레지스트리에서 못 가져왔고, 여기서 전부 파생됨.
장애 규모 — coredns 0/2로 클러스터 DNS 전멸. 파드 113개 중 32개가 비정상(CrashLoop 28 / ImagePullBackOff 4)이었고, Ready로 남아 있던 파드도 DNS가 없으니 DB·내부 API 호출이 전부 실패. ingress-nginx까지 내려가서 외부 진입 자체가 막혔음. "파드 몇 %가 죽었다"보다 DNS와 인그레스가 동시에 없어서 대부분의 서비스가 서빙 불가였다가 정확한 표현임.
해결 — 노드그룹을 1.31로 올려 버전을 맞춤 → coredns/kube-proxy 정상화 → 마지막으로 남은 pod → Service 라우팅 불통을 cilium 재시작으로 해소.
내가 한 것 (시간순)
- 파드 113개(Running 81 / CrashLoop 28 / ImagePull 4) 상태를 훑어서 증상을 경로 단위로 분해 — 노드↔노드, 노드→로컬 파드, 파드→ClusterIP, 파드→외부, 노드→레지스트리 중 어디가 깨졌는지 표로 만듦. "장애 28건"이 아니라 "깨진 경로 2개"로 문제를 줄인 게 이 대응의 핵심이었음
- 1차 원인(레지스트리 인증 소실)은 벤더 없이 자체 해결 — 5개 서비스 네임스페이스에
docker-registry시크릿 만들고 워크로드 12개에imagePullSecrets패치 → 이미지 풀 실패 33건 전건 해소 - DNS를 살리려고 coredns 이미지를 사내 레지스트리 미러로 임시 교체해 퍼블릭 egress 의존을 우회
- 노드 SSH로 iptables/라우팅/커널 파라미터/RPF 드랍 카운터까지 떠서 벤더 영역(노드 프로비저닝)임을 입증할 증거 패키지를 만들고 티켓 발급
- 서포트 엔지니어와 같이 순서 잡아 노드그룹 업그레이드 → 애드온 정상화 → cilium 재시작 집행, 복구 확인
재발 방지 — (1) 마이너 한 단계를 올릴 때마다 컨트롤플레인과 노드그룹을 한 세트로 정렬하고 다음 홉으로 넘어감, (2) 클러스터 필수 애드온(coredns/kube-proxy/CNI) 이미지는 사내 레지스트리 미러를 미리 확보, (3) imagePullSecrets를 배포 매니페스트에 영구 반영해 노드 라이프사이클과 분리.
남는 인사이트 한 줄 — 매니지드 K8s에서 컨트롤플레인 업그레이드는 "버전 숫자 하나 바꾸는 일"이 아니라 "노드 전량 재생성 + 애드온 이미지 재조달"임. 콘솔 버튼 하나가 kubeadm으로 노드 20대 재구축하는 것과 같은 무게라는 걸, 작업 전에 그렇게 안 봤음.
발단: 컴플라이언스 준수를 위한 버전 업그레이드
시작은 장애 대응이 아니라 정기 작업이었음. 컴플라이언스 요건상 지원 종료가 가까운 K8s 버전을 올려야 했음.
목표는 v1.29.x → v1.31.8인데, 마이너를 건너뛸 수 없어서 v1.30을 한 번 거쳐 가는 두 홉 여정이었음.
- 1홉 (v1.29.x → v1.30): 컨트롤플레인과 노드그룹을 같이 올려서 정렬됨 (kubelet v1.30.10)
- 2홉 (v1.30 → v1.31.8): 2026-07-07 13:17~13:34 KST 실행. 컨트롤플레인만 올라가고 노드그룹은 v1.30.10에 남음
- 2홉에서 워커 노드 20대가 전부 재생성됨
여기서 놓친 게 두 개임. 하나는 홉을 넘어갈 때 노드그룹을 같이 안 끌고 간 것. 다른 하나는 노드 20대 전량 재생성의 무게를 안 본 것.
1홉이 별 문제 없이 끝나서 2홉도 같은 작업이라고 생각했음. 근데 1홉이 무사했던 이유가 "버전이 맞았기 때문"이라는 걸 그때는 몰랐음. 성공한 작업에서 무엇이 성공을 만들었는지 모르면, 같은 작업을 반복해도 재현이 안 됨.
두 번째 것도 짚어둠. kubeadm으로 올린 클러스터라면 "노드 20대 재생성"은 절대 버튼 한 번으로 안 함. 드레인 순서, 이미지 프리로드, 노드 로컬 설정 복원까지 다 계획을 세움. 매니지드라서 그 과정이 안 보였고, 안 보이니까 가볍게 봤음. 추상화가 걷어준 건 작업량이고, 리스크는 그대로 남아 있었음.
1차 증상: 노드 재생성으로 레지스트리 자격증명이 소실됨
노드가 새로 뜨면서 이미지 캐시와 노드 레벨 레지스트리 자격증명이 같이 사라졌고, 사내 레지스트리 이미지 풀 33건이 전부 ImagePullBackOff가 됨.
인증 문제로 좁힌 근거는 이랬음.
- 레지스트리 자체는 외부에서 응답 정상
- 자격증명으로 토큰 발급 검증 성공 (인증 정보 자체는 유효)
- → 레지스트리 장애가 아니라 노드에 있던 인증 정보가 없어진 문제
여기까지 확인되니 벤더에 물을 게 없었고 바로 조치했음.
- 5개 서비스 네임스페이스(app1~app5) + ingress-nginx, kube-system에
docker-registry시크릿 생성 - 실패 중이던 워크로드 12개에
imagePullSecrets패치
적용 즉시 사내 레지스트리 풀은 전건 해소됐고, 여기서 파생됐던 ingress-nginx 다운도 자동 복구됨.
얻은 건 하나. 노드 레벨 자격증명에 의존하는 이미지 풀은 노드 재생성 시점에 반드시 깨짐. 파드 스펙의 imagePullSecrets로 내려와 있어야 노드 라이프사이클과 무관해짐.
2차 증상: 진짜 문제는 깨진 경로가 정확히 두 개였다는 것
사내 레지스트리는 살렸는데 coredns가 안 올라왔음. 여기서부터 하루를 씀.
CrashLoop 28개를 하나씩 보는 건 의미가 없었음. 파드 이름이 아니라 통신 경로 기준으로 다시 세웠고, 그러니 깨진 게 정확히 두 가지로 정리됐음.
노드 → 자기 노드 위의 파드 (kubelet probe 경로)
- app1-fe 파드: 앱(nuxt)은 파드 IP:3000에서 정상 리스닝 중인데, 같은 노드 kubelet의 TCP readiness probe가 19시간 동안 7,805회 전부 i/o timeout
- app1-mobile, app2-web(HTTP probe), app3 등 여러 노드에 같은 증상 분산
- probe가 없는 파드(app4-api 등)는 Ready로 뜸 → probe 경로만 깨진 것과 정합
cilium-health가 이걸 제일 깔끔하게 보여줬음. 샘플 노드 3대 모두 동일했음.
Cluster health: 19/20 reachable
host-10-0-10-20 (localhost):
Host connectivity to 10.0.10.20: ICMP OK / HTTP OK
Endpoint connectivity to 192.168.28.106: ICMP Connection timed out / HTTP context deadline exceeded
(다른 19개 노드의 Endpoint 는 전부 OK — 크로스노드는 정상)자기 노드 위 파드만 unreachable이고 크로스노드는 전부 정상. cilium-health-ep 컨트롤러는 노드 생성 직후부터 20시간 넘게 연속 실패(실패 카운트 약 354회)였음. 업그레이드 이후 어느 시점에 깨진 게 아니라, 노드가 처음부터 이 상태로 올라왔다는 뜻임.
파드 → 클러스터 서비스 / 외부
coredns 파드 로그(이미지는 사내 미러로 우회해 컨테이너 자체는 기동한 상태):
dial tcp 10.96.0.1:443: i/o timeout # 파드 → API서버 ClusterIP
read udp 192.168.x.x:xxxx->10.0.25.1:53: i/o timeout # 파드 → VPC DNS
read udp 192.168.x.x:xxxx->10.0.29.100:53: i/o timeoutkube-proxy가 정상인 노드에서도 동일하게 재현됐음 → kube-proxy 단독 문제는 아니었음.
반면 정상이던 경로도 명확했음. 노드↔노드, 노드→사내 레지스트리, 크로스노드 파드↔파드는 전부 OK. 노드(호스트)에서 VPC DNS TCP 53, API 마스터 6443 접속도 됐음. 그래서 VPC ACL/SG 구간은 초반에 용의선상에서 뺐음. 이게 나중에 티켓에서 "네트워크 ACL 확인해보세요"로 대화가 돌아가는 걸 막아줬음.
참고 구성은 cilium v1.14.4, KubeProxyReplacement=False(kube-proxy 병용), Masquerading=IPTables, Host Routing=Legacy, Direct Routing(eth0).
내가 잘못 짚은 것: rp_filter 가설
노드 안쪽 netfilter를 의심해서 rp_filter를 근본 원인으로 특정했는데, 결과적으로 이건 최종 원인이 아니었음.
당시 근거는 나름 그럴듯했음.
- 재생성된 노드가
net.ipv4.conf.all.rp_filter = 1(strict reverse-path filter)로 올라와 있었음 - cilium은 rp_filter 비활성이 필요한데, 커널은
all과 인터페이스별 값 중 max를 적용하므로 cilium이lxc*/cilium_host에 0을 넣어도all=1이 이김 - RPF 드랍 카운터
TcpExtIPReversePathFilter = 54,919(노드 1대 기준) — 커널이 실제로 대량 드랍 중이었음 - 증상 정합도 맞아 보였음: 호스트→로컬 파드, 파드→외부(응답 복귀 경로)만 깨지고, BPF가 호스트 스택을 우회하는 크로스노드 파드↔파드는 정상
그래서 "노드 이미지/프로비저닝 기본값 문제, 노드 재생성마다 재발"까지 결론을 냈음. 근데 실제로 클러스터가 살아난 건 rp_filter를 건드려서가 아니라 버전 정렬 + cilium 재시작이었음.
rp_filter=1과 드랍 카운터는 실제로 관측된 값이라 아무 영향이 없었다고 말하긴 어려움. 다만 그게 주 원인이었는지, 아니면 cilium이 비정상 상태라 데이터패스를 제대로 못 세운 결과였는지는 지금 데이터로는 구분이 안 됨. [확인 필요]
한 줄로 남기면: 증거가 가설과 정합하는 것과, 그 가설이 원인인 것은 다른 얘기임. 커널 쪽을 볼 줄 안다는 게 오히려 발목을 잡았음. 카운터가 올라가는 걸 보고 확신이 너무 빨리 붙어서, 더 싸고 위에 있는 검증(cilium 재시작)을 뒤로 미뤘음.
실제 해결: 버전 정렬 → 애드온 정상화 → cilium 재시작
증거 패키지로 티켓을 열고, 서포트 엔지니어와 순서를 잡아 이렇게 집행했음.
- 노드그룹을 1.31로 업그레이드 — 컨트롤플레인과 노드그룹 버전을 먼저 맞춤
- coredns / kube-proxy 정상화 — 버전이 맞으면서 애드온 이미지 문제가 풀림
- cilium 재시작 — pod 대역 → k8s Service 접근이 안 되는 게 남아 있었고, cilium 재시작 후 정상화
서포트 엔지니어의 진단을 그대로 옮기면 이렇게 됨.
클러스터 버전이 올라가면서 kube-proxy, coredns 버전도 올라갔고, 그 과정에서 외부 컨테이너 이미지를 못 가져와 이슈가 생긴 것 같습니다. 우선 클러스터 버전과 노드그룹 버전이 같아야 할 것 같습니다.
pod 대역 → k8s svc 접근이 안 되는 것 같아 cilium 재시작을 진행했고, 정상화된 것 같습니다. cilium에서 pod → svc로 라우팅이 안 됐던 듯합니다.
벤더 답변: 프라이빗 서브넷 + 버전 불일치는 이미 알려진 이슈였음
벤더 쪽에서는 "프라이빗 서브넷에서 클러스터와 노드그룹 버전이 벌어지면 coredns/kube-proxy 이미지를 퍼블릭에서 못 가져오는 이슈"를 이미 인지하고 있고, 개선 준비 중이라고 전달받았음.
그래서 이 장애의 상당 부분은 처음 만난 미지의 버그가 아니라, 이미 알려진 제약을 모르고 밟은 것에 가까움. 개선 전까지는 사용자 쪽에서 순서를 지키는 게 유일한 방어임.
여기서 매니지드의 비대칭이 하나 보였음. kubeadm 클러스터면 애드온 이미지 조달 경로를 내가 정하니 이 제약 자체가 안 생김. 매니지드는 그 부분을 벤더가 쥐고 있어서, 내가 고칠 수 없는 구간의 제약을 미리 알고 있어야만 피할 수 있음. 릴리즈 노트와 알려진 이슈 목록을 업그레이드 전에 읽는 게 그래서 작업 항목이 됐음.
그래서 무엇을 바꿀 것인가
가장 중요한 것부터.
마이너 한 단계마다 컨트롤플레인 + 노드그룹을 같이 올리고 다음 홉으로 넘어감
다단계 업그레이드의 작업 단위는 "전체 여정"이 아니라 "홉 하나"임. 홉을 끝냈다는 판정 기준에 노드그룹 버전 정렬을 넣고, 정렬 안 됐으면 다음 홉을 시작하지 않음.
이번엔 1홉(1.29→1.30)에서 정렬됐던 게 2홉(1.30→1.31.8)에서 깨졌음. K8s가 kubelet 한 마이너 스큐를 공식적으로 허용하더라도, 매니지드 서비스의 애드온 이미지 조달 경로까지 그 허용 범위 안에 있다는 보장은 없음. 작업 항목 이름도 "클러스터 업그레이드"가 아니라 "클러스터 + 노드그룹 업그레이드" 로 씀.
클러스터 필수 애드온 이미지는 사내 레지스트리에 미러를 미리 확보함
coredns / kube-proxy / CNI 같은 필수 애드온이 퍼블릭 레지스트리에 의존하는 순간, 프라이빗 서브넷에서는 egress 하나로 클러스터 전체가 인질이 됨. 장애 중에 급하게 미러링하는 것과 미리 있는 것은 완전히 다른 얘기임. (이번엔 급하게 미러를 만들어 우회했고, 컨트롤플레인이 원본 이미지로 되돌리는지 확인이 필요한 상태로 남았음)
imagePullSecrets를 배포 매니페스트에 영구 반영함
이번엔 라이브 패치로 넣어서 재배포하면 유실됨. 노드 재생성마다 같은 장애를 반복하지 않으려면 매니페스트에 들어가 있어야 함.
데이터패스 문제는 "어느 경로가 깨졌는지" 부터 좁힘
이번에 가장 값이 컸던 판단임. 다음에도 이 순서로 봄.
- 노드 → 노드
- 노드 → 자기 노드 위 파드
- 파드 → 같은 노드 파드 / 크로스노드 파드
- 파드 → ClusterIP(Service)
- 파드 → VPC / 인터넷
- 노드 → 사내 레지스트리 / 퍼블릭 레지스트리
어디가 되고 어디가 안 되는지만 정확히 채우면, ACL 구간인지 노드 안쪽인지 CNI인지가 대체로 갈림. 매니지드에서는 이 표가 그대로 "내 책임 구간 / 벤더 책임 구간" 경계선이 되기도 함. 이번엔 "파드 → ClusterIP"가 끝까지 안 됐던 게 결국 답이었고, 그 칸을 더 무겁게 봤어야 했음.
진단은 스택 위에서 아래로 — CNI 재시작을 초반 카드로 둠
파드→Service 라우팅이 통째로 안 될 때 CNI 에이전트 재시작은 비용이 낮은 편임. 커널 파라미터를 파고들기 전에 먼저 해봤어도 됐을 것 같음. (물론 재시작으로 증거가 날아갈 수 있으니, 상태 덤프는 먼저 떠두는 게 맞음)
업그레이드 작업 전 체크리스트
- 이 업그레이드가 노드를 재생성하는지 확인 (재생성이면 사실상 클러스터 전체 재구성으로 취급)
- 노드 레벨에만 있는 상태(레지스트리 자격증명, 이미지 캐시, 로컬 튜닝)가 뭔지 목록화
- 필수 애드온 이미지 조달 경로가 프라이빗 서브넷에서 가능한지 확인
- 벤더 릴리즈 노트 + 알려진 이슈 목록 확인 (이번 건은 여기서 걸렀어야 함)
- 컨트롤플레인/노드그룹 업그레이드를 한 세트로 계획하고, 마이너 여러 단계면 홉마다 그 세트를 반복
- 홉 완료 판정에
kubectl get nodes의 kubelet 버전 정렬 확인을 포함 (이번 건은 여기서도 걸렀어야 함) - 롤백보다 정상 판정 기준을 먼저 정의 (파드 Ready 수가 아니라 위 경로 표가 기준)
정직하게 남기는 아쉬운 점
- 노드 20대 전량 재생성의 무게를 작업 전에 과소평가했음. kubeadm으로는 절대 그렇게 안 했을 작업을, 매니지드라서 가볍게 봤음
- rp_filter 가설에 확신이 너무 빨리 붙어서, 더 싼 검증을 뒤로 미뤘음
- 개발 클러스터라 20시간 넘게 깨진 상태로 둘 수 있었음. 운영이었으면 이 회고는 다른 톤으로 쓰였을 것 같음
아직 다 정리된 건 아니에요. 특히 rp_filter가 실제로 얼마나 기여했는지는 재현 없이는 확정하기 어려워서, 다음 노드 재생성 때 값부터 확인해보려고 합니다. 비슷한 매니지드 K8s + 프라이빗 서브넷 구성이시라면 컨트롤플레인과 노드그룹 버전을 같이 올리는 것 하나만 가져가셔도 충분할 것 같습니다.
부록: 진단에 썼던 명령어
kubectl만으로 (전부 읽기 전용)
export KUBECONFIG=<kubeconfig 경로>
# 1. cilium 상태 + 로컬 파드 unreachable 증거
kubectl get pods -n kube-system -l k8s-app=cilium -o wide
kubectl exec -n kube-system <cilium-파드> -c cilium-agent -- cilium status
kubectl exec -n kube-system <cilium-파드> -c cilium-agent -- cilium-health status
# 2. kubelet probe 실패 증거
kubectl describe pod -n <ns> -l app=<앱> | grep -A8 Events:
# 3. coredns의 API서버/상위DNS timeout 증거
kubectl logs -n kube-system -l k8s-app=kube-dns --tail=30
# 4. 퍼블릭 레지스트리 풀 실패 증거 (외부 egress)
kubectl get events -n kube-system --field-selector reason=Failed | grep registry.k8s.io
# 5. 전체 파드 상태 스냅샷
kubectl get pods -A -o wide > pods-snapshot-$(date +%Y%m%d-%H%M).txt노드 SSH 후
# 1. iptables 상태 — FORWARD 정책, CILIUM/KUBE 체인 존재 여부
sudo iptables -S | head -30
sudo iptables -L FORWARD -n --line-numbers | head -20
sudo iptables -t nat -S | grep -c KUBE # kube-proxy 규칙 개수 (0이면 문제)
sudo iptables -S | grep -c CILIUM # cilium 규칙 개수 (0이면 문제)
# 2. 로컬 파드로 직접 통신 테스트
ping -c2 -W2 <로컬파드IP>
curl -m5 http://<로컬파드IP>:<포트>/
# 3. 라우팅/커널 파라미터
ip route | head -20 # 파드 CIDR 라우팅 (cilium_host/lxc 경유 확인)
sysctl net.ipv4.ip_forward # 1이어야 정상
sysctl -a 2>/dev/null | grep 'rp_filter' # cilium은 0(또는 2)이어야 정상
cat /proc/net/netstat | tr ' ' '\n' | grep -n IPReversePathFilter # RPF 드랍 카운터
# 4. 외부 egress 테스트 (퍼블릭 vs 사내 레지스트리 대비)
curl -m5 -sI https://registry.k8s.io/v2/
curl -m5 -sI https://<사내-레지스트리>/v2/
# 5. 패킷 레벨 — probe 패킷이 파드까지 가는지
sudo tcpdump -i any -c 20 host <로컬파드IP>