t4g.medium 위에서 Pod가 죽지 않는 이유

Pod 하나가 31분째 Terminating 상태에서 멈춰 있었다. grace period는 30초다. 이 상태가 지속됐다는 건, 삭제 신호 자체가 전달되지 못하고 있다는 뜻이었다.

동일 노드의 CronJob Pod 3개도 함께 멈춰 있었다. 특정 Pod의 문제가 아니라 노드 수준의 문제였다. 이 글은 왜 kubelet이 죽었고, 무엇이 근본 원인인지를 추적한 기록이다.


먼저 알아야 할 것

진단을 시작하기 전에, Kubernetes가 리소스를 정리하는 과정과 메모리를 관리하는 방식을 알아보자.

1. Pod는 어떻게 종료될까?

정상적인 상황에서 Pod가 삭제되는 흐름은 다음과 같다.

sequenceDiagram
    participant API as API Server
    participant Kubelet as Kubelet
    participant Container as Container

    API->>API: Pod에 DeletionTimestamp 설정
    API->>Kubelet: 삭제 이벤트 전달
    Kubelet->>Container: SIGTERM 전송 (Grace Period 시작)
    Note over Container: 정상 종료 작업 수행
    Kubelet->>Container: SIGKILL 전송 (Grace Period 만료 시)
    Kubelet->>API: Pod 삭제 완료 보고

Pod를 삭제하면 즉시 사라지지 않는다. API 서버는 Pod에 DeletionTimestamp를 설정하고, 각 노드의 kubelet이 이를 감지하여 컨테이너에 SIGTERM을 보낸다. grace period(기본 30초)가 지나면 SIGKILL을 보내 강제 종료한다. 만약 이 과정을 수행해야 할 kubelet이 죽어 있다면 어떻게 될까? Pod는 영원히 Terminating 상태로 남게 된다.

2. SystemOOM은 Container OOM과 어떻게 다른가?

Container OOM(Out Of Memory)은 개별 컨테이너가 설정된 memory limit을 초과했을 때 발생한다. 컨테이너 런타임이 해당 컨테이너만 조용히 재시작한다. 반면 SystemOOM은 노드 전체의 물리 메모리가 고갈되었을 때 발생한다. 이때는 Linux 커널의 OOM killer가 직접 개입하여, 노드를 살리기 위해 OOM score가 높은(메모리를 많이 쓰는) 프로세스부터 죽인다.


멈춰있는 Pod

오전 11시경, api 네임스페이스의 api-worker-app Pod 하나가 31분째 Terminating 상태라는 제보를 받았다. 다행히 다른 replica가 살아있어 서비스 영향은 없었다.

하지만 단순히 강제 삭제(force delete)하고 넘어가기엔 찜찜했다. 노드 ip-10-0-1-100를 확인해보니 동일 노드의 CronJob Pod 3개도 함께 멈춰 있었다.


진단

1단계: 왜 Pod가 종료되지 않는가?

가장 먼저 의심한 것은 finalizer다. finalizer가 붙어있으면, 제거될 때까지 Kubernetes는 리소스를 삭제하지 않는다.

kubectl get pod api-worker-app-5bc96869d-dh9qs -n api -o json \
  | python3 -c "import sys,json; d=json.load(sys.stdin); \
    print('Finalizers:', d['metadata'].get('finalizers',[]))"

결과는 Finalizers: []였다. finalizer가 원인이 아니었다.

Pod 종료를 책임지는 주체는 kubelet이므로 노드의 상태를 확인했다.

kubectl get node ip-10-0-1-100.ap-northeast-2.compute.internal -o wide

노드 상태는 NotReady였다. Taint node.kubernetes.io/unreachable:NoExecute가 붙어 있었고, kubelet의 마지막 heartbeat는 10:53분에서 멈춰 있었다.

상황을 정리하면: 노드가 응답하지 않아 TaintManager가 Pod 삭제를 트리거했다. 하지만 삭제를 수행할 kubelet이 응답 불능(Unresponsive) 상태에 빠져 있어 Pod가 멈춘 것이다. Heartbeat 중단이 반드시 프로세스의 물리적 사망을 의미하지는 않지만, 최소한 관리 기능을 수행할 수 없는 불능 상태임은 확실했다. 그렇다면 질문이 바뀐다. 왜 노드가 이 지경이 되었는가?

2단계: SystemOOM의 발생 원인

노드의 이벤트 로그를 열어보았다.

Warning  SystemOOM  57m  kubelet  System OOM: node_exporter, pid 49693
Warning  SystemOOM  52m  kubelet  System OOM: node_exporter, pid 157977
Warning  SystemOOM  51m  kubelet  System OOM: prometheus-conf, pid 49767
Warning  SystemOOM  50m  kubelet  System OOM: prometheus-conf, pid 49949
Warning  SystemOOM  49m  kubelet  System OOM: alloy, pid 49696
Normal   NodeNotReady  47m  node-controller  ...

무수한 SystemOOM 이벤트가 찍혀 있었다. 커널 OOM killer가 개입한 것이다. 죽은 프로세스는 node_exporter, prometheus-conf, alloy 등 모두 모니터링 DaemonSet 컴포넌트들이었다.

여기서 Overcommit(과도한 예약)System OOM(실제 고갈) 의 상관관계를 이해해야 한다. Overcommit은 잠재적인 리스크다. 리스크가 현실의 장애 이벤트로 터지려면 물리적 임계치를 넘기는 ‘방아쇠’가 필요한데, 4GB라는 작은 메모리를 가진 t4g.medium 노드에서는 그 방아쇠가 매우 민감하게 작동한다.

결국 노드 메모리가 고갈되자 커널이 이들을 차례로 희생시켰고, 이 과정에서 관리 프로세스인 kubelet마저 자원을 할당받지 못해 heartbeat를 보내지 못하는 불능 상태가 된 것이다. 더 결정적인 단서가 있었다. Normal NodeNotReady 47m (x16 over 6d). 이 노드는 지난 6일 동안 16번이나 같은 이유로 뻗고 있었다. 약 9시간마다 반복된 셈이다.

3단계: 수치로 보는 메모리 고갈

t4g.medium 노드의 가용 메모리(Allocatable)는 3369Mi다. 노드에 뜬 Pod들의 memory limit을 모두 더해보았다.

PodMemory Limit
api-worker2260Mi
alloy-profiles768Mi
alloy-logs256Mi
alloy-receiver~256Mi
worker-module CronJobs × 3~750Mi
합계~4340Mi
노드 가용량3369Mi

전체 limit 합계가 노드 가용량의 129% 를 차지했다.

당시 api-worker평상시 사용량(Baseline Usage) 은 185Mi 수준에 불과했다. “실제로는 185Mi밖에 안 쓰는데 왜 4GB 노드가 터질까?”라는 의문이 생길 수 있다. 하지만 Grafana를 통해 과거 48시간의 데이터를 분석한 결과, 관측된 최대 피크(Observed Peak) 는 383Mi에 달했다.

더 심각한 것은 메모리가 베이스라인으로 복구되지 않는 패턴이었다. 특정 버전에서는 피크(363Mi)를 찍은 뒤에도 320Mi 이상을 계속 붙들고 있는 모습이 관측되었다. 즉, 129%의 Overcommit 상태에서 이런 작지만 지속적인 메모리 압박이 누적되었고, t4g.medium의 좁은 여유 공간(Buffer) 안에서 작은 스파이크가 발생하자마자 노드가 속수무책으로 무너진 것이다.


근본 원인

이 모든 관찰을 종합하면 다음과 같은 인과관계 체인이 완성된다.

flowchart TD
    A["노드 물리 메모리 고갈<br>(limits 합계 4340Mi > 가용량 3369Mi)"]
    B["Linux 커널 OOM killer 발동<br>(SystemOOM 이벤트)"]
    C["node_exporter, alloy 등 사망"]
    D["kubelet heartbeat 중단<br>(응답 불능 상태)"]
    E["노드 NotReady 전환"]
    F["TaintManager가 Pod 삭제 트리거"]
    G["kubelet 불능으로 삭제 실행 불가"]
    H["Pod Terminating 멈춤"]

    A --> B --> C --> D --> E --> F --> G --> H

해결 방법

1. 즉시 조치: Stuck Pod 정리와 노드 교체

죽은 노드는 복구 불가 상태였다. API 서버에서 멈춰있는 Pod 레코드를 직접 날려야 했다.

kubectl delete pod \
  api-worker-app-5bc96869d-dh9qs \
  worker-module-check-status-29582035-447w4 \
  -n api --force --grace-period=0

멈춘 Pod들을 강제 삭제했다. 이후 노드 오브젝트도 삭제했지만, EC2 인스턴스는 자동으로 지워지지 않았다. kubectl delete node는 K8s API 서버의 레코드만 지울 뿐, AWS ASG(Auto Scaling Group)의 인스턴스를 종료시키지는 않기 때문이다. EC2 콘솔에서 직접 인스턴스를 종료하고 나서야 ASG가 새 노드를 띄웠다.

2. 단기 조치: 메모리 설정 재조정

새 노드가 떠도 과도한 limit을 그대로 두면 다시 OOM이 터진다. 모니터링 시스템에서 max_over_time(container_memory_working_set_bytes[1h])로 48시간 피크를 조회했다. 최대 피크는 383 MiB였고, 피크 이후 337 MiB 선에 안착하는 패턴을 보였다.

# 변경 전
resources:
  requests:
    memory: 1130Mi
  limits:
    memory: 2260Mi
 
# 변경 후
resources:
  requests:
    memory: 256Mi
  limits:
    memory: 512Mi

관측된 피크(383Mi) 대비 약 33%의 여유를 두고 limits를 512Mi로 대폭 낮췄다. 이를 배포한 후, 노드의 메모리 limit 초과율은 129%에서 54%로 안정화되었다.


이 장애가 남긴 것

같은 원인으로 6일 동안 16번 반복된 장애였다. 실제 사용량이 185Mi인데 requests가 1130Mi로 설정된 것이 언제부터였는지 아직 알 수 없지만, 이 설정이 t4g.medium 노드에서 모니터링 데몬들과 함께 뜰 때 노드를 죽이는 결정적 요인이 되었다.

조치의 완료는 수치가 변했을 때다. 설정 파일이 바뀌었을 때가 아니다. 문제가 해결되었다고 믿기 전에 정상적으로 돌아왔는지 끝까지 관찰해야 한다.


참고 자료