노드 드레인이 안 끝나던 새벽, 범인은 PDB였다

지난 주말에 클러스터 노드 OS 패치 작업이 있었다. 24대짜리 워커 노드를 하나씩 cordon → drain → 재부팅 → uncordon 하는 흔한 작업이다. 자동화 스크립트도 있고, 평소엔 노드 한 대당 5분 정도면 끝난다. 그런데 그날따라 9번째 노드에서 kubectl drain 명령이 멈췄다. 30분이 지나도 진척이 없었다. 새벽 2시였고, 나는 졸린 눈으로 터미널을 노려보고 있었다.
일단 진정하고 상태 확인부터
drain 로그를 보니 이런 메시지가 반복되고 있었다.
evicting pod default/payment-api-7c8d9-x4k2p
error when evicting pods/"payment-api-7c8d9-x4k2p" -n default
(will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
PDB 위반이었다. 어떤 PDB가 막고 있나 봤다.
kubectl get pdb -A
payment-api라는 PDB가 보였다. MIN AVAILABLE이 3, CURRENT가 3, ALLOWED DISRUPTIONS가 0. 레플리카가 정확히 3개인데 minAvailable이 3이면 단 한 개도 못 죽인다는 뜻이다. 이 PDB는 우리 팀이 작년에 PCI 감사 대응으로 추가했던 거였다. "결제 API는 절대 다운되면 안 된다"는 절박한 마음을 그대로 yaml에 적어둔 셈이다.
근데 그게 진짜 문제가 아니었다. 결제팀에서 며칠 전에 HPA를 손봤는데, minReplicas를 5에서 3으로 낮춘 거였다. 트래픽 줄어든 새벽 시간대 비용 절감 차원에서. PDB의 minAvailable=3은 그대로 두고. 결과적으로 평소엔 5개 떠 있어서 멀쩡해 보였지만, 오토스케일러가 새벽에 3개로 줄여놓자마자 PDB가 막아버린 거다.
잠깐의 멘붕
순간 떠오른 선택지는 세 가지였다.
첫째, PDB를 일시적으로 삭제하고 drain 진행. 가장 빠르지만 결제 API 가용성을 흔들 수 있다.
둘째, minAvailable을 2로 낮추고 drain. 깔끔하지만 한 번 변경하면 누가 다시 3으로 돌려놓을지가 애매하다. 변경 이력이 yaml 리뷰 없이 슬쩍 들어가는 건 PCI 감사때 또 지적당할 수 있다.
셋째, HPA를 강제로 늘려서 일단 레플리카를 5개로 만들고 drain. 우회로지만 시간이 가장 적게 든다.
새벽 2시였다는 점이 컸다. 그냥 세 번째로 갔다. HPA의 minReplicas를 5로 일시적으로 patch 했고, 60초쯤 지나니 노드 추가 분산이 일어났다. 그 다음 drain은 잘 끝났다. 대신 작업 끝나고 minReplicas를 다시 3으로 되돌리는 걸 잊지 않으려고 캘린더에 알람을 박아놨다.
그래서 진짜 교훈은
다음 날 회고를 했다. 결론은 두 가지였다.
PDB와 HPA를 같은 PR에서 관리해야 한다. 이건 너무 당연한데 우리 팀은 그렇게 안 하고 있었다. PDB는 SRE팀이 한꺼번에 manage하는 별도 리포에 있었고, HPA는 각 서비스팀의 리포에 있었다. 누가 HPA를 바꿔도 PDB는 따로 검토되지 않았다. 일단 임시방편으로 kubectl get hpa,pdb -n <ns> 결과를 매주 비교하는 Argo Workflow를 만들었다. minReplicas < (replicas - maxUnavailable) 또는 minReplicas <= minAvailable 인 케이스를 잡아낸다.
unhealthyPodEvictionPolicy: AlwaysAllow를 켜자. 이건 사실 이번 사건과 직접 관련은 없는데, 검토하다가 발견한 거다. Kubernetes 1.27에서 GA됐고 우리 클러스터는 1.30이라 이미 쓸 수 있었다. 이걸 PDB에 추가하면 readiness가 빠진 pod는 PDB 카운트에서 빠지기 때문에 unhealthy한 pod로 인해 drain이 영원히 멈추는 케이스를 막을 수 있다. 우리는 이번에 운 좋게 모든 pod가 healthy해서 이 함정엔 안 빠졌는데, 다른 팀이 한번 비슷한 케이스에서 4시간을 헤맸다고 들어서 추가했다.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: payment-api
spec:
minAvailable: 50%
selector:
matchLabels:
app: payment-api
unhealthyPodEvictionPolicy: AlwaysAllow
그리고 minAvailable을 정수 대신 퍼센트로 바꿨다. 50%면 레플리카가 3이든 5든 10이든 비례해서 작동한다. HPA가 어떻게 움직여도 한 대는 항상 죽일 수 있게 된다.
마무리
아직도 그날 졸린 와중에 HPA patch 하는 게 정답이었는지는 모르겠다. PDB를 그 자리에서 50%로 바꾸는 게 더 깔끔했을지도. 다만 새벽 2시에 yaml 수정해서 결제 API 설정 건드리는 건 심리적으로 부담이 커서 회피했던 것 같다. 비슷한 경험 있는 분 계시면 어떻게 푸셨는지 궁금하다.
다음에는 우리가 만든 PDB-HPA 정합성 체커도 정리해서 공유해볼까 한다. 그렇게 대단한 건 아니고 50줄짜리 shell 스크립트인데, 같은 함정에 빠진 팀이 있을 것 같아서.