Published on

K8s CrashLoopBackOff와 OOMKilled 원인 추적

Authors

운영 중인 파드가 CrashLoopBackOff 에 빠졌는데, 이유가 OOMKilled 로 찍히는 순간부터는 단순히 메모리를 “늘리면 된다”로 끝내면 안 됩니다. 같은 OOMKilled 라벨이라도 원인은 크게 세 갈래로 갈립니다.

  • 컨테이너 메모리 한도(limits.memory)가 너무 낮아 정상 트래픽에도 터짐
  • 애플리케이션 메모리 누수, 캐시 폭주, 워크로드 특성상 피크가 커짐
  • 노드 메모리 압박으로 인한 축출(eviction) 또는 커널 OOM 상황

이 글은 kubectl describe 한 번 보고 끝내는 수준이 아니라, 이벤트, 종료 코드, cgroup, 메트릭, 재현까지 이어지는 “원인 추적 루트”를 제공합니다. CrashLoopBackOff 자체는 증상이고, OOMKilled는 결과일 뿐이므로, 어디서 메모리가 소진되었는지를 끝까지 추적하는 것이 핵심입니다.

참고로 “무한 재시작”이라는 현상 자체는 k8s만의 문제가 아닙니다. 시스템 레벨에서 재시작 루프를 다룬 관점이 필요하다면 systemd 서비스가 무한 재시작되는 원인 7가지도 함께 보면 원인 분류에 도움이 됩니다.

1) 먼저 확인할 것: OOMKilled가 맞는지 증거 수집

1-1. Pod 이벤트와 종료 사유 확인

가장 먼저 “진짜로 OOMKilled인가”를 확정합니다.

kubectl get pod -n <namespace>
kubectl describe pod <pod> -n <namespace>

describe 결과에서 아래를 확인하세요.

  • Last State: Terminated
  • Reason: OOMKilled
  • Exit Code: 137

Exit Code: 137 은 SIGKILL로 죽었음을 의미하며, 컨테이너 런타임이 메모리 제한 초과로 프로세스를 강제 종료했을 때 흔히 보입니다.

또한 이벤트 섹션에서 아래 패턴도 자주 등장합니다.

  • Back-off restarting failed container
  • Killing container with id ... 와 함께 OOMKilled 언급

1-2. 컨테이너별로 원인 컨테이너를 특정

Pod에 사이드카(Envoy, Fluent Bit 등)가 붙어 있으면 애플리케이션이 아니라 사이드카가 죽는 경우도 있습니다.

kubectl get pod <pod> -n <namespace> -o jsonpath='{.status.containerStatuses[*].name}'
kubectl get pod <pod> -n <namespace> -o jsonpath='{.status.containerStatuses[*].lastState.terminated.reason}'

어떤 컨테이너가 OOMKilled 인지 먼저 특정해야 뒤가 빨라집니다.

1-3. 재시작 직전 로그 확보

CrashLoopBackOff에서는 “이전 실행” 로그를 봐야 합니다.

kubectl logs <pod> -n <namespace> -c <container> --previous

로그가 남지 않는다면, 애플리케이션이 메모리 폭발 후 즉시 SIGKILL로 종료되어 flush가 안 되었을 수 있습니다. 이 경우에는 메트릭과 cgroup 관찰이 더 중요해집니다.

2) OOMKilled의 두 종류: 컨테이너 OOM vs 노드 OOM/축출

OOMKilled 라고 해서 항상 limits.memory 를 넘었다는 뜻은 아닙니다. 크게 두 영역이 있습니다.

2-1. 컨테이너 메모리 제한 초과(가장 흔함)

  • 파드 spec에 resources.limits.memory 가 있고
  • 애플리케이션이 그 한도를 넘으면
  • 해당 컨테이너가 SIGKILL로 종료됩니다.

이 경우 해결은 “적절한 limit 산정”과 “메모리 사용량 피크 원인 제거” 둘 다 필요합니다.

2-2. 노드 메모리 압박에 의한 축출(eviction)

노드 전체 메모리가 부족해지면 kubelet이 QoS와 우선순위에 따라 파드를 축출합니다.

  • 이벤트에 Evicted 가 찍히거나
  • The node was low on resource: memory 같은 메시지가 나옵니다.

이 케이스는 컨테이너 limit을 올려도 다시 죽을 수 있습니다. 노드 사이징, 다른 워크로드의 메모리 폭주, DaemonSet 오버커밋 등을 함께 봐야 합니다.

3) 리소스 설정 점검: requests/limits와 QoS 클래스

OOMKilled 추적에서 가장 먼저 보는 “설정 레이어”입니다.

kubectl get pod <pod> -n <namespace> -o jsonpath='{.spec.containers[*].resources}'

3-1. requests와 limits의 의미

  • requests.memory: 스케줄링 기준(노드에 올릴 때 보장하려는 최소)
  • limits.memory: 런타임 강제 상한(넘으면 컨테이너가 죽을 수 있음)

3-2. QoS 클래스가 생존성에 미치는 영향

QoS는 eviction 우선순위에 영향을 줍니다.

  • Guaranteed: 모든 컨테이너가 requests == limits 를 CPU/Memory 모두에 대해 만족
  • Burstable: requests 또는 limits가 일부만 설정되었거나 서로 다름
  • BestEffort: requests/limits 모두 없음
kubectl get pod <pod> -n <namespace> -o jsonpath='{.status.qosClass}'

노드 압박 상황에서 BestEffortBurstable 은 먼저 축출될 수 있습니다. OOMKilled가 “컨테이너 제한”이 아니라 “노드 압박”이면 QoS 설계부터 다시 봐야 합니다.

4) 메트릭으로 피크를 잡기: 사용량의 시간축 확인

OOMKilled는 순간 피크가 원인인 경우가 많습니다. 평균만 보고 limit을 잡으면 계속 터집니다.

4-1. Metrics Server로 빠른 확인(정밀도는 낮음)

kubectl top pod -n <namespace>
kubectl top pod <pod> -n <namespace> --containers

재시작이 너무 빠르면 top 에 잡히지 않을 수 있습니다.

4-2. Prometheus가 있다면: RSS와 Working Set을 분리

가능하면 아래 지표를 구분해서 보세요.

  • container_memory_working_set_bytes: 실제로 압박에 영향을 주는 메모리(캐시 일부 제외)
  • container_memory_rss: 익명 메모리 중심

OOMKilled는 보통 working set 피크가 limit에 닿는지로 판단하는 편이 안전합니다.

5) 노드/커널 레벨 증거: dmesg, kubelet, container runtime

클러스터 권한이 있다면 “커널이 누구를 죽였는지”가 가장 확실한 증거입니다.

5-1. 노드에서 커널 OOM 로그 확인

노드에 접속 가능하다면:

sudo dmesg -T | grep -i -E 'oom|killed process|out of memory'

여기서 특정 PID, 프로세스 이름이 찍히면 “노드 OOM” 가능성이 커집니다.

5-2. kubelet 로그에서 eviction 확인

sudo journalctl -u kubelet --since "1 hour ago" | grep -i -E 'evict|memory'

eviction manager 로그가 보이면 노드 압박 축출입니다.

6) 애플리케이션 원인 좁히기: 패턴별 체크리스트

여기부터는 “왜 메모리가 늘었는지”를 애플리케이션 관점에서 분해합니다.

6-1. 트래픽 피크에 비례해 메모리가 증가

  • 요청당 버퍼가 큰 엔드포인트(파일 업로드, 이미지 변환)
  • 동시성 제한 없음(스레드, 고루틴, 워커가 무한 증가)
  • 큐 컨슈머가 backlog를 한 번에 당겨 처리

대응:

  • 동시성 제한(워커 수, 큐 prefetch)
  • 요청 바디/응답 바디 스트리밍
  • 큰 작업은 비동기 오프로딩

6-2. 시간이 지날수록 계속 증가(누수 의심)

  • 캐시가 TTL 없이 무한 성장
  • 전역 맵/딕셔너리에 키가 계속 쌓임
  • 관측/로깅 버퍼가 flush되지 않음

대응:

  • 힙 프로파일링, 누수 의심 경로 차단
  • 캐시 상한(용량 기반 eviction) 설정

6-3. 특정 배포 이후 갑자기 OOM

  • 라이브러리 업그레이드로 기본 버퍼 사이즈 증가
  • JVM/Node 런타임 플래그 변경
  • 사이드카 추가로 파드 전체 메모리 합이 증가

대응:

  • 변경점 기반 비교(이미지 태그, Helm values diff)
  • 컨테이너별 limit 재산정

7) 언어별 “바로 적용 가능한” 메모리 방어 설정

7-1. JVM: 힙 상한을 컨테이너 limit에 맞추기

JVM은 컨테이너 인지를 하더라도, 힙 외 메모리(메타스페이스, 스레드 스택, direct buffer)까지 고려해야 합니다.

예시(힙을 limit의 일정 비율로 제한):

JAVA_TOOL_OPTIONS='-XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=50'

또는 명시적으로:

JAVA_TOOL_OPTIONS='-Xms512m -Xmx1024m'

주의: -Xmx 를 limit과 동일하게 두면 힙 외 영역 때문에 OOMKilled가 날 수 있습니다.

7-2. Node.js: old space 제한

Node는 기본 힙 한계가 워크로드에 따라 애매하게 잡힐 수 있어, 컨테이너 limit에 맞춰 상한을 두는 편이 안전합니다.

NODE_OPTIONS='--max-old-space-size=1024'

단위는 MB입니다.

7-3. Python: 워커/동시성, 프리로드 전략 점검

Gunicorn 같은 프리포크 모델은 워커 수가 메모리를 선형으로 늘립니다.

gunicorn app:app -w 2 --threads 4 --max-requests 1000 --max-requests-jitter 100
  • --max-requests 는 누수 완화에 도움이 될 수 있지만 근본 해결은 아닙니다.

8) 재현 가능한 형태로 만들기: 메모리 한계 테스트

원인을 좁히려면 “어떤 조건에서 얼마만큼 메모리가 오르는지”를 재현해야 합니다.

8-1. 임시로 limit을 낮춰서 경계 확인(주의해서 사용)

스테이징에서 limit을 낮춰 OOM 임계점을 빨리 관찰할 수 있습니다.

resources:
  requests:
    memory: "256Mi"
    cpu: "200m"
  limits:
    memory: "512Mi"
    cpu: "500m"

8-2. 부하 테스트로 피크를 만들고 메트릭과 함께 확인

  • 특정 엔드포인트 호출
  • 큐 backlog 주입
  • 캐시 워밍 시나리오

이때 container_memory_working_set_bytes 피크가 limits.memory 에 닿는지 확인하면 “설정 문제”인지 “코드 문제”인지 구분이 빨라집니다.

9) 자주 하는 실수 6가지

  1. requests 만 올리고 limits 는 그대로 둠
  2. limits 를 무작정 올려 노드 압박을 악화시킴
  3. 사이드카 메모리를 계산에서 빼먹음
  4. 평균 사용량만 보고 limit을 정함(피크 미반영)
  5. JVM 힙을 limit과 같게 둠(힙 외 영역 미고려)
  6. CrashLoopBackOff를 “재시작 정책 문제”로만 봄(실제는 자원/코드 문제)

10) 결론: OOMKilled는 “증거 기반”으로 분류하고 처방하자

CrashLoopBackOffOOMKilled 조합은 대개 아래 순서로 접근하면 빠르게 해결됩니다.

  1. describeOOMKilled 와 컨테이너를 확정하고 --previous 로그 확보
  2. requests/limits 및 QoS 확인, 사이드카 포함 총합 점검
  3. 메트릭으로 피크를 확인해 “limit 부족”인지 “누수/폭주”인지 분리
  4. 노드 로그로 eviction 또는 커널 OOM 여부 확인
  5. 언어 런타임 상한 설정과 동시성 제어로 재발 방지

특히 노드 압박과 admission 이슈가 섞이면 원인 파악이 더 어려워집니다. 배포나 정책 변경 직후부터 문제가 시작됐다면, 클러스터 레벨에서 요청이 막히거나 지연되는 이슈도 함께 의심해볼 만합니다. 관련해서는 EKS에서 Webhook 타임아웃? Admission 진단법도 참고하면 “증상이 비슷한 다른 장애”를 빠르게 배제할 수 있습니다.

OOMKilled를 단순히 “메모리 늘리기”로 처리하면, 언젠가 더 큰 트래픽에서 다시 터집니다. 반대로 증거를 모아 유형을 분류하고, 피크를 통제하고, limit을 근거 있게 산정하면 CrashLoopBackOff는 재발하지 않는 장애로 바뀝니다.