IT/Kubernets

Kubernetes graceful node shutdown, 안에서 도는 것들

gfrog 2026. 5. 15. 09:14
반응형

스팟 인스턴스를 자주 쓰다 보면 kubelet이 노드 종료 시점에 어떻게 행동하는지가 늘 신경 쓰인다. 사실 nodelifecycle 컨트롤러가 Pod를 옮겨주기는 하지만, 그건 노드가 NotReady가 되고도 한참 뒤의 이야기다. 그 사이에 노드 위에 있던 Pod이 어떻게 끝나느냐는 전적으로 kubelet의 graceful node shutdown 동작에 달려 있다. 이 기능은 1.21에서 알파, 1.28에서 GA가 됐고, 1.24부터는 Pod priority별로 단계화도 가능해졌다. 그런데 막상 운영해보면 "켜 놨는데 안 도는 것 같다"는 케이스가 꽤 있다. 어디서 끊기는지 한번 따라가본다.

systemd-inhibitor라는 비밀번호

kubelet이 노드 종료를 감지하는 방식은 의외로 단순하다. 부팅 후 kubelet은 systemd-logind에 D-Bus로 inhibitor lock을 건다. lock 종류는 shutdown:delay. 이름 그대로 "종료를 잠깐 늦춰달라"는 부탁이다.

$ systemd-inhibit --list
   Who: kubelet (UID 0/root, PID 1234/kubelet)
   What: shutdown
   Why: Kubelet needs time for node shutdown
   Mode: delay

리눅스가 종료 신호(systemctl poweroff 등)를 받으면, systemd-logind는 모든 delay inhibitor 보유자에게 PrepareForShutdown(true) 시그널을 D-Bus로 쏘고, 최대 InhibitDelayMaxSec만큼 기다린다. 이 값의 기본은 5초. 그래서 노드 설정에서 보통 /etc/systemd/logind.conf.d/에 별도 drop-in을 두고 늘려둔다.

# /etc/systemd/logind.conf.d/kubelet.conf
[Login]
InhibitDelayMaxSec=180

여기서 첫 함정. InhibitDelayMaxSec가 kubelet config의 shutdownGracePeriod보다 작으면 의미가 없다. 5초만 기다려주는 logind가 60초 grace period를 약속한 kubelet을 잘라낸다. journal을 보면 Lock acquired, but inhibit timed out before kubelet released라는 메시지가 잡힌다. 처음 도입했을 때 우리도 이 부분에서 한참 헤맸다.

두 단계로 끝나는 grace period

kubelet은 시그널을 받고 나서 두 페이즈로 Pod을 정리한다.

# /var/lib/kubelet/config.yaml
shutdownGracePeriod: 120s
shutdownGracePeriodCriticalPods: 30s

먼저 일반 Pod에게 shutdownGracePeriod - shutdownGracePeriodCriticalPods 만큼 시간이 주어진다. 위 예시면 90초. 이 동안 kubelet은 각 Pod에 SIGTERM을 보내고 PodSpec의 terminationGracePeriodSeconds를 그대로 존중한다. 만약 어떤 Pod이 terminationGracePeriodSeconds: 60이라면 60초까지 살 수 있는 거고, 90초를 다 쓰는 게 아니다.

그 다음 critical Pod 페이즈가 30초 동안 돈다. 여기서 말하는 critical은 PodSpec에 priorityClassName: system-cluster-critical 또는 system-node-critical이 붙은 것들이다. CNI, kube-proxy, csi-node 같은 컴포넌트들. 일반 Pod이 다 정리되어야 이 단계로 넘어가도록 설계된 이유는 명확하다. 데이터 평면을 먼저 끄면 일반 Pod의 graceful shutdown 핸들러가 외부와 통신을 못 하니까.

여기까지가 1.21~1.23의 모델이다. 단점이 보인다. priority가 3단계 이상이면? 1.24부터는 단계화가 가능하다.

1.24+ — priority 기반 다단 종료

shutdownGracePeriodByPodPriority가 들어왔다. 위 두 옵션 대신 이걸 쓴다.

shutdownGracePeriodByPodPriority:
  - priority: 0
    shutdownGracePeriodSeconds: 60
  - priority: 10000
    shutdownGracePeriodSeconds: 60
  - priority: 2000000000   # system-cluster-critical
    shutdownGracePeriodSeconds: 30
  - priority: 2000001000   # system-node-critical
    shutdownGracePeriodSeconds: 10

priority 값을 기준으로 buckets를 만들고, 낮은 priority bucket이 끝나야 다음 bucket이 시작된다. 위 설정이면 총 160초가 잡힌다. logind의 InhibitDelayMaxSec는 이 합보다 커야 한다는 점은 동일.

bucket의 priority 매칭은 "이 값 이하"가 아니라 "이 값에 가장 가까운 작거나 같은 bucket"으로 떨어진다. 그래서 priority 5000인 Pod은 위 예에서 10000 bucket에 들어간다. 0과 10000이 60초로 같은 이유는 일반 워크로드 안에서도 비지니스 우선순위(예: 결제 vs 백그라운드 ETL)를 나눌 여지를 두고 싶었기 때문이다. 우리 팀은 결제 라인을 10000으로 올려 두고, 그 라인이 먼저 끝난 뒤에야 ETL이 정리되도록 잡았다.

디버깅 — 진짜 돌고 있는 게 맞나

"설정해뒀으니 알아서 잘 되겠지"가 가장 위험하다. 다음 세 가지를 확인한다.

첫째, inhibitor가 실제로 잡혀있는지. systemd-inhibit --list 또는 busctl call org.freedesktop.login1 /org/freedesktop/login1 org.freedesktop.login1.Manager ListInhibitors. 잡혀있지 않다면 kubelet이 logind와 D-Bus로 통신 못 하는 거다. Bottlerocket이나 Talos 같은 immutable OS 중 일부는 logind를 안 띄운다. 이때는 kubelet이 이 기능을 자동으로 비활성화하고 시작한다. 로그에는 Cannot connect to logind, graceful shutdown disabled가 찍힌다.

둘째, 실제 종료가 일어났을 때 journal 추적. 노드를 일부러 systemctl poweroff로 꺼본다.

$ journalctl -u kubelet -t kubelet --since "1 minute ago" | grep -i shutdown
kubelet[1234]: I0515 00:02:13 nodeshutdown_manager: Shutdown manager detected new shutdown event
kubelet[1234]: I0515 00:02:13 nodeshutdown_manager: Processing shutdown, killing pods (priority 0) with grace period 60s
kubelet[1234]: I0515 00:03:13 nodeshutdown_manager: Processing shutdown, killing pods (priority 2000000000) with grace period 30s

nodeshutdown_manager가 핵심 키워드다.

셋째, 종료 사유가 Pod 객체에 남는지. kubelet은 노드가 꺼지기 직전 Pod status에 Reason: TerminatedMessage: Pod was terminated in response to imminent node shutdown.를 박는다. 1.30부터는 더 명확해져서 nodeShutdown 필드도 들어간다. API 서버까지 PATCH가 도달해야 다른 컴포넌트(예: argo-rollouts, KEDA)가 이 신호를 받아서 후속 행동을 할 수 있다. 노드 종료가 너무 급박해서 PATCH 전에 죽으면 kube-controller-manager의 pod-gc가 한참 뒤에야 정리해주는데, 그 사이 Service 엔드포인트는 stale이다.

Karpenter/Spot과 엮였을 때

여기가 의외로 골치 아프다. AWS Spot interruption 알림은 ~120초 전에 온다. Karpenter는 이 알림을 받자마자 노드를 cordon하고 drain을 시작한다. 그런데 kubelet의 graceful shutdown은 OS가 실제로 종료를 시작해야만 트리거된다. drain이 끝나고 kubelet이 한가해진 다음, OS가 종료에 들어갔을 때부터 inhibitor 시계가 도는 셈이다.

결과적으로 우리 팀에서는 다음처럼 시간을 잡아뒀다.

  • Spot interruption notice: T-120s
  • Karpenter cordon + drain 시작: T-119s 부근
  • drain이 60~90초 내에 끝나도록 PDB와 terminationGracePeriod 조정
  • 그 후 OS 종료 시작, kubelet graceful shutdown: 남은 30초 안에 critical Pod 마무리
  • shutdownGracePeriodByPodPriority 합계는 30초 이하로 잡음

shutdownGracePeriod 총합을 너무 크게 잡지 말 것. drain이 길어지면 정작 shutdown grace는 inhibitor timeout에 잘려나간다. 한 번 인시던트 때, shutdownGracePeriod=300s로 잡아둔 노드에서 critical Pod이 SIGKILL로 끊긴 적이 있다. drain이 길게 걸리는 동안 EC2가 진짜로 회수 시점을 맞추는 게 우선이고, kubelet의 grace는 정해진 예산 안에서만 의미 있다는 걸 그때 알았다.

사실 더 파고들 게 있다

이 글에서는 systemd-logind 경로만 다뤘다. Windows 노드는 별도 메커니즘이고, cgroup v2 환경에서는 kubelet이 cgroup freeze로 일관된 종료 순서를 보장하려는 시도가 1.32 알파에 들어가 있다. CRI 쪽 변화(containerd 2.0의 sandbox API 개편)와 맞물려서 또 한 번 그림이 바뀔 가능성이 있는데, 그건 좀 더 운영해보고 따로 쓰려고 한다.

당장 점검할 거리는 세 줄로 줄여둔다. logind의 InhibitDelayMaxSec, kubelet config의 shutdownGracePeriod 합계, 그리고 spot interruption 시간 예산. 이 셋이 어긋나면 graceful shutdown은 이름만 graceful이다.

반응형