Published on

K8s CrashLoopBackOff - OOMKilled·Probe 5분 진단

Authors

서버가 멀쩡한데 Pod가 계속 재시작되면 대부분 CrashLoopBackOff로 보입니다. 하지만 CrashLoopBackOff는 원인이 아니라 결과입니다. 핵심은 “컨테이너가 왜 종료(exit)했는가”와 “왜 kubelet이 죽였는가(OOM, Probe 실패 등)”를 5분 안에 갈라내는 것입니다.

이 글은 현장에서 가장 빈도가 높은 두 축인 OOMKilledLiveness/Readiness Probe 실패를 중심으로, 최소 명령어로 빠르게 결론에 도달하는 진단 루틴을 제공합니다.

0. 5분 진단 로드맵(먼저 이대로 따라하기)

아래 순서대로 보면 대부분 5분 내에 원인이 좁혀집니다.

  1. 이벤트/상태로 “누가 죽였는지” 확인
kubectl -n <ns> get pod <pod> -o wide
kubectl -n <ns> describe pod <pod>
  • Last State: TerminatedReasonOOMKilled인지
  • EventsLiveness probe failed, Readiness probe failed, Back-off restarting failed container가 있는지
  1. 직전 크래시 로그 확보(재시작 루프에서 가장 중요)
kubectl -n <ns> logs <pod> -c <container> --previous --tail=200
  1. 컨테이너 종료 코드/시그널 확인
kubectl -n <ns> get pod <pod> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason} {.status.containerStatuses[0].lastState.terminated.exitCode}{"\n"}'
  • OOMKilled면 대개 exitCode: 137(SIGKILL)
  • 애플리케이션이 스스로 죽으면 exitCode: 1 등 다양
  1. OOM이면 “limit/실사용/스파이크”를 바로 확인
kubectl -n <ns> top pod <pod>
kubectl -n <ns> get pod <pod> -o jsonpath='{.spec.containers[0].resources}'
  1. Probe면 “엔드포인트/타임아웃/초기 지연/포트”를 바로 확인
kubectl -n <ns> get pod <pod> -o yaml | yq '.spec.containers[].livenessProbe, .spec.containers[].readinessProbe'

이제부터는 OOMKilled와 Probe를 각각 더 깊게 파고듭니다.

1. CrashLoopBackOff의 정체: ‘재시작 백오프’일 뿐

CrashLoopBackOff는 kubelet이 컨테이너를 재시작하려고 시도했지만, 짧은 시간에 반복 실패해서 재시작 간격(backoff)을 늘리는 상태입니다.

  • 컨테이너가 즉시 종료(잘못된 커맨드, 설정 오류, 예외로 프로세스 종료)
  • kubelet이 강제 종료(OOMKilled, liveness probe 실패)

둘 중 하나가 대부분입니다. 따라서 “왜 종료했는지”를 먼저 분류해야 합니다.

2. OOMKilled 2분 진단: ‘누가 메모리를 죽였나’부터

OOMKilled는 크게 두 종류가 있습니다.

  • 컨테이너 OOMKilled: cgroup memory limit 초과 → kubelet이 SIGKILL
  • 노드 OOM(시스템 OOM): 노드 메모리 고갈 → 커널 OOM killer가 희생자 선택

describe pod에서 Reason: OOMKilled가 보이면 우선 컨테이너 limit 초과를 의심합니다.

2.1 빠른 체크리스트

(1) requests/limits 확인

kubectl -n <ns> get deploy <deploy> -o jsonpath='{.spec.template.spec.containers[0].resources}{"\n"}'
  • limits.memory가 너무 낮거나
  • requests.memory가 너무 낮아 노드에 과밀 스케줄링되었거나

(2) 현재 사용량(스파이크는 놓칠 수 있음)

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

top은 “현재” 값이라, 순간 스파이크로 죽는 OOM은 안 보일 수 있습니다. 이 경우 Prometheus/Grafana의 container_memory_working_set_bytes 같은 시계열이 필요합니다.

(3) 종료 코드 137 확인

kubectl -n <ns> get pod <pod> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.exitCode}{"\n"}'

보통 OOMKilled는 137입니다.

2.2 OOMKilled의 흔한 원인 6가지

  1. JVM/Node/Python 런타임의 기본 메모리 정책
  • Java: 컨테이너 메모리 인식 옵션/JDK 버전 차이
  • Node.js: 기본 old space 제한(버전에 따라 다름)
  1. 캐시/버퍼가 무한정 커짐
  • in-memory cache, LRU 설정 누락
  1. 대용량 요청 처리(압축/파싱/이미지 변환)로 순간 피크

  2. 로그/메트릭 버퍼 폭증

  • stdout 폭주 + sidecar/agent 버퍼
  1. 메모리 누수
  • 특정 엔드포인트 호출 패턴에서만 증가
  1. requests가 너무 낮아 노드가 과밀(노드 OOM 유발)

2.3 즉시 적용 가능한 대응(안전한 순서)

(A) limit 상향 + request 동반 조정

limit만 올리면 노드가 과밀해져 다른 Pod까지 연쇄 장애가 날 수 있습니다. request도 현실에 맞게 올려 스케줄링을 안정화하세요.

resources:
  requests:
    cpu: "200m"
    memory: "512Mi"
  limits:
    cpu: "1"
    memory: "1Gi"

(B) 애플리케이션 메모리 상한 설정

예: Node.js

node --max-old-space-size=768 server.js

예: JVM(컨테이너 환경에 맞춰)

JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:InitialRAMPercentage=50" \
java -jar app.jar

(C) HPA/부하 분산으로 ‘피크’를 쪼개기

메모리 피크가 트래픽과 연동되면 스케일아웃이 더 근본적인 해결일 수 있습니다. 다만 HPA가 기대처럼 줄지 않는 이슈(예: PDB, stabilization window 등)가 있으면 스케일 정책부터 점검해야 합니다.

관련 글: Kubernetes HPA가 0으로 안 줄 때 - PDB·윈도우·종료

3. Probe 실패 3분 진단: ‘정상인데 죽는다’의 전형

Probe는 크게 두 가지 목적이 있습니다.

  • Readiness: 트래픽을 받을 준비가 됐는가(로드밸런싱 포함 여부)
  • Liveness: 프로세스가 비정상 상태로 굳었는가(재시작 필요 여부)

문제는 liveness를 readiness처럼 쓰면, 초기 구동/일시적 지연에도 컨테이너를 계속 죽여서 CrashLoopBackOff가 됩니다.

3.1 describe pod 이벤트로 먼저 분류

kubectl -n <ns> describe pod <pod> | sed -n '/Events/,$p'

자주 보이는 패턴:

  • Liveness probe failed: HTTP probe failed with statuscode: 500
  • Readiness probe failed: dial tcp ...: connect: connection refused
  • context deadline exceeded(timeout)

3.2 가장 흔한 원인 7가지

  1. 포트/경로 불일치
  • 앱은 8080인데 probe는 80
  • /health가 아니라 /actuator/health
  1. 초기화 시간이 긴데 initialDelaySeconds가 짧음
  • DB 마이그레이션, 캐시 워밍업, 모델 로딩
  1. timeoutSeconds가 너무 짧음
  • GC/IO로 1~2초 멈추는 순간에 실패
  1. failureThreshold가 낮아 ‘스파이크’에 취약

  2. liveness가 외부 의존성(DB, Redis)까지 확인

  • 외부 장애 시 앱을 계속 재시작 → 더 악화
  1. HTTP 500을 liveness로 바로 죽임
  • 일시적 500은 재시작보다 트래픽 차단(readiness)이 적절
  1. startupProbe 없이 liveness를 바로 켬
  • 시작 중인 컨테이너를 liveness가 죽임

3.3 권장 Probe 설계(실전 템플릿)

초기 구동이 길 수 있는 서비스라면 startupProbe를 적극 권장합니다.

containers:
  - name: app
    image: your/app:1.0.0
    ports:
      - containerPort: 8080
    startupProbe:
      httpGet:
        path: /healthz
        port: 8080
      # 최대 60초까지 시작을 기다림 (10 * 6)
      failureThreshold: 10
      periodSeconds: 6
    readinessProbe:
      httpGet:
        path: /readyz
        port: 8080
      initialDelaySeconds: 0
      timeoutSeconds: 2
      periodSeconds: 5
      failureThreshold: 3
    livenessProbe:
      httpGet:
        path: /livez
        port: 8080
      # startupProbe 통과 후에만 의미가 생김
      timeoutSeconds: 2
      periodSeconds: 10
      failureThreshold: 3

설계 원칙:

  • startupProbe: “부팅 완료”만 확인(길게)
  • readinessProbe: “트래픽 받을 준비” 확인(외부 의존성 포함 가능)
  • livenessProbe: “프로세스가 살아있고 응답 가능한가”만 확인(외부 의존성은 가급적 제외)

3.4 로컬에서 probe를 그대로 재현하기

Pod 안에서 probe 엔드포인트를 직접 호출해 “진짜로 느린지/응답이 없는지” 확인합니다.

kubectl -n <ns> exec -it <pod> -c <container> -- sh -lc 'wget -qO- -S http://127.0.0.1:8080/healthz'

또는 임시 디버그 Pod로 네트워크 관점에서 확인:

kubectl -n <ns> run netshoot --rm -it --image=nicolaka/netshoot -- bash
# inside
curl -v http://<pod-ip>:8080/healthz

4. OOMKilled + Probe가 같이 보일 때(가장 헷갈리는 케이스)

현장에서는 다음 조합이 자주 나옵니다.

  • 메모리가 빡빡함 → GC/스왑 없는 환경에서 지연 증가 → readiness timeout
  • 지연 중에 liveness 실패 → 재시작
  • 재시작 직후 트래픽 재유입 → 메모리 피크 → OOMKilled

이 경우 먼저 OOM을 해결(limit/request/상한)하고, 그 다음 **probe를 완화(startupProbe, timeoutSeconds 조정)**하는 순서가 안정적입니다.

5. 운영 팁: 재시작 루프를 멈추고 ‘증거’를 남기는 방법

CrashLoopBackOff는 로그를 놓치기 쉽습니다. 아래를 습관화하면 재현 없이도 원인 파악이 빨라집니다.

5.1 terminationMessage 활용

앱 종료 직전에 핵심 정보를 /dev/termination-log로 남기면 describe pod에서 바로 볼 수 있습니다.

terminationMessagePath: /dev/termination-log
terminationMessagePolicy: FallbackToLogsOnError

5.2 preStop + graceful shutdown

종료가 급하면 요청 처리 중인 워커가 비정상 종료하고 다음 부팅에서 더 오래 걸릴 수 있습니다.

lifecycle:
  preStop:
    exec:
      command: ["sh", "-lc", "sleep 10"]
terminationGracePeriodSeconds: 30

6. 결론: 5분 안에 결론내는 질문 3개

CrashLoopBackOff를 보면 아래 3가지만 순서대로 답하면 됩니다.

  1. 누가 죽였나? (OOMKilled / liveness / 앱 자체 종료)
  2. 죽기 직전 로그는 무엇인가? (--previous)
  3. 즉시 완화책은 무엇인가? (메모리 상향·상한 설정 / startupProbe 도입·timeout 조정)

추가로, 인그레스/로드밸런서 레벨에서 499/500이 같이 튀는 경우에는 “Pod가 readiness를 제대로 유지하는지”까지 연결해서 봐야 합니다. 트래픽 리셋/클라이언트 중단이 함께 보이면 아래 글도 참고할 만합니다.

이제 describe pod에서 OOMKilled인지 probe 실패인지부터 갈라내고, 위 템플릿대로 조정하면 CrashLoopBackOff의 80%는 빠르게 정리됩니다.