13년차의 서버실

서버, 인프라, 홈랩 기술 블로그

[태그:] OOMKilled

  • [k8s] 쿠버네티스 Pod 트러블슈팅: CrashLoopBackOff, OOMKilled 완벽 해결

    쿠버네티스 Pod가 계속 죽는다면? 당신만 그런 게 아닙니다

    쿠버네티스를 처음 운영하다 보면 꼭 한 번씩 마주치는 상황이 있어요. 열심히 작성한 배포 파일을 kubectl apply로 올렸는데, Pod 상태가 CrashLoopBackOff거나 OOMKilled로 떠 있는 겁니다. 처음엔 진짜 당황스럽죠. 로그를 봐도 뭔가 무한루프처럼 에러가 쌓이고, 뭘 고쳐야 할지 막막하고요.

    저도 입사 초반에 이거 때문에 밤새 삽질한 기억이 있습니다. 당시엔 kubectl describe pod 명령어조차 제대로 활용 못 했거든요. 지금은 쿠버네티스 Pod 트러블슈팅이 거의 반사적으로 손가락이 움직일 정도가 됐지만, 처음엔 정말 막막했어요. 이번 글에서는 제가 직접 겪고 해결한 경험을 바탕으로, CrashLoopBackOff와 OOMKilled 두 가지 대표적인 Pod 오류를 어떻게 진단하고 해결하는지 단계별로 풀어드릴게요.

    ▲ 쿠버네티스 Pod의 주요 상태 전환 흐름. Pending → Running → CrashLoopBackOff / OOMKilled 경로를 시각화한 다이어그램

    CrashLoopBackOff와 OOMKilled, 이게 대체 뭔가요?

    CrashLoopBackOff — “계속 죽었다 살아났다 반복”

    쉽게 말해서, 컨테이너가 시작됐다가 바로 죽고, 쿠버네티스가 다시 살리고, 또 죽고를 반복하는 상태예요. 쿠버네티스는 기본적으로 컨테이너가 죽으면 재시작(restart)을 시도하는데, 계속 실패하면 재시작 간격을 점점 늘리면서 BackOff 상태로 전환됩니다. 10초, 20초, 40초… 이런 식으로요.

    원인은 정말 다양합니다:

    • 애플리케이션 자체 버그 (예외 처리 안 된 panic, exit code 1)
    • 잘못된 환경 변수(Environment Variable) 설정
    • ConfigMap이나 Secret 마운트 실패
    • Liveness Probe(생존 확인 프로브) 설정 오류
    • 의존 서비스(DB, 외부 API 등)에 연결 못 하는 경우

    OOMKilled — “메모리를 너무 많이 먹어서 강제 종료”

    OOM은 Out Of Memory의 약자입니다. 컨테이너가 설정된 메모리 Limit(제한)을 초과하면, 리눅스 커널의 OOM Killer가 해당 프로세스를 강제로 죽여버려요. kubectl describe pod로 확인하면 OOMKilled라고 딱 찍혀 있고, exit code는 137이에요.

    이건 크게 두 가지 경우인데요:

    • 메모리 Limit이 너무 낮게 설정된 경우: 실제 앱이 필요한 메모리보다 Limit이 작아서 죽는 거예요
    • 메모리 누수(Memory Leak)가 있는 경우: Limit은 적절한데 앱이 메모리를 계속 잡아먹고 반환 안 하는 경우
    오류 유형 주요 원인 Exit Code 확인 명령어
    CrashLoopBackOff 앱 오류, 설정 문제, Probe 실패 1, 2 등 다양 kubectl logs, kubectl describe
    OOMKilled 메모리 Limit 초과, 메모리 누수 137 kubectl describe, metrics-server

    1단계: 상황 파악 — 일단 뭐가 문제인지 보자

    쿠버네티스 Pod 트러블슈팅의 첫 번째 단계는 항상 현재 상태 파악이에요. 저는 이 순서대로 확인합니다.

    Pod 상태 전체 확인

    # 네임스페이스 전체 Pod 상태 확인
    kubectl get pods -n <네임스페이스> -o wide
    
    # 모든 네임스페이스에서 문제 있는 Pod만 필터링
    kubectl get pods -A | grep -v Running | grep -v Completed

    여기서 RESTARTS 컬럼 숫자가 높으면 CrashLoopBackOff 의심이에요. 한 자리면 괜찮은데, 두세 자리 넘어가면 심각한 거거든요.

    Pod 상세 이벤트 확인 — 이게 핵심입니다

    kubectl describe pod  -n <네임스페이스>

    출력 맨 아래 Events: 섹션을 꼭 보세요. 여기에 실제로 무슨 일이 있었는지 타임라인이 찍혀 있어요. OOMKilled라면 이런 식으로 보입니다:

    Events:
      Type     Reason     Age                From               Message
      ----     ------     ----               ----               -------
      Warning  BackOff    2m (x5 over 5m)   kubelet            Back-off restarting failed container
      Normal   Pulled     6m                kubelet            Successfully pulled image
      Normal   Started    6m                kubelet            Started container app
      Warning  OOMKilling 5m                kubelet            Memory limit reached, killing container

    로그 확인 — 현재 로그와 이전 컨테이너 로그

    # 현재 컨테이너 로그
    kubectl logs  -n <네임스페이스>
    
    # 이전에 죽은 컨테이너 로그 (이게 더 중요할 때가 많아요!)
    kubectl logs  -n <네임스페이스> --previous
    
    # 실시간 로그 스트리밍
    kubectl logs -f  -n <네임스페이스>
    
    # 멀티 컨테이너 Pod인 경우 컨테이너 지정
    kubectl logs  -c  -n <네임스페이스> --previous

    💡 팁: --previous 플래그를 꼭 써보세요. 현재 로그에는 아무것도 없어도, 이전에 죽을 때의 로그가 여기 남아 있거든요. 저도 처음엔 이걸 몰라서 한참 헤맸습니다 ㅎㅎ

    ▲ kubectl describe pod 명령어 실행 결과. Events 섹션에서 OOMKilled 및 CrashLoopBackOff 원인을 확인하는 화면

    2단계: CrashLoopBackOff 원인별 해결법

    케이스 1: 애플리케이션 자체 오류

    로그에서 스택 트레이스(stack trace)나 panic, exception 같은 키워드가 보인다면 앱 코드 문제예요. 이건 개발팀에 공유해야 하는 케이스고, 인프라 엔지니어 입장에서 할 수 있는 건 명확한 로그를 전달하는 거예요.

    # 마지막 100줄 로그 확인
    kubectl logs  --previous --tail=100 -n <네임스페이스>

    케이스 2: Liveness Probe 설정 오류

    이게 은근히 많은 케이스더라고요. Liveness Probe(생존 확인 프로브)가 너무 엄격하게 설정되어 있으면, 앱이 정상인데도 쿠버네티스가 죽었다고 판단해서 재시작시켜 버립니다.

    # 잘못된 설정 예시 — 너무 빠른 초기 딜레이
    livenessProbe:
      httpGet:
        path: /health
        port: 8080
      initialDelaySeconds: 3   # 앱 시작에 10초 걸리는데 3초만 기다림
      periodSeconds: 5
      failureThreshold: 1      # 1번만 실패해도 재시작
    
    ---
    
    # 올바른 설정 예시
    livenessProbe:
      httpGet:
        path: /health
        port: 8080
      initialDelaySeconds: 30  # 앱 시작 시간보다 여유 있게
      periodSeconds: 10
      failureThreshold: 3      # 3번 연속 실패해야 재시작
      timeoutSeconds: 5        # 응답 타임아웃도 여유 있게

    ⚠️ 주의: initialDelaySeconds는 컨테이너 시작 후 처음 Probe를 시도하기 전 대기 시간이에요. 앱이 완전히 뜨는 데 걸리는 시간보다 넉넉하게 잡아야 합니다. 저는 보통 실제 시작 시간의 1.5~2배로 잡아요.

    케이스 3: ConfigMap / Secret 마운트 실패

    # ConfigMap 존재 여부 확인
    kubectl get configmap -n <네임스페이스>
    
    # Secret 존재 여부 확인
    kubectl get secret -n <네임스페이스>
    
    # describe에서 Volume 마운트 실패 메시지 확인
    kubectl describe pod  -n <네임스페이스> | grep -A5 "Volumes"

    describe 출력에서 이런 메시지가 보이면 ConfigMap이나 Secret이 없는 겁니다:

    Warning  FailedMount  10s   kubelet  MountVolume.SetUp failed for volume "config" :
    configmap "my-app-config" not found

    케이스 4: 환경 변수 누락

    # 실행 중인 Pod의 환경 변수 확인
    kubectl exec  -n <네임스페이스> -- env | sort
    
    # Pod가 죽어서 exec가 안 되는 경우 — 임시 디버그 Pod 실행
    kubectl run debug-pod --image=busybox -it --rm -- /bin/sh

    3단계: OOMKilled 해결법

    현재 메모리 사용량 확인

    먼저 실제로 얼마나 쓰고 있는지 봐야죠. metrics-server가 설치되어 있다면:

    # Pod 리소스 사용량 확인
    kubectl top pod  -n <네임스페이스>
    
    # 컨테이너별 상세 확인
    kubectl top pod  -n <네임스페이스> --containers

    현재 설정된 Limit 확인:

    kubectl get pod  -n <네임스페이스> -o jsonpath='{.spec.containers[*].resources}'

    메모리 Limit 조정

    실제 사용량이 Limit에 근접하거나 초과한다면 Limit을 올려야 해요. 이건 Deployment를 수정해야 합니다:

    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: my-app
    spec:
      template:
        spec:
          containers:
          - name: app
            image: my-app:latest
            resources:
              requests:
                memory: "256Mi"   # 스케줄링 기준 (최소 보장량)
                cpu: "250m"
              limits:
                memory: "512Mi"   # 최대 허용량 (이 이상 쓰면 OOMKilled)
                cpu: "500m"

    💡 Request와 Limit의 차이:

    • requests: 쿠버네티스가 Pod를 노드에 배치할 때 기준이 되는 최소 보장량이에요. 이 공간이 있는 노드에만 배치됩니다.
    • limits: 컨테이너가 절대 넘을 수 없는 상한선이에요. 메모리 Limit을 넘으면 OOMKilled, CPU Limit을 넘으면 쓰로틀링(throttling)이 발생해요.

    메모리 누수 의심 케이스

    Limit을 올렸는데도 계속 OOMKilled가 난다면 메모리 누수를 의심해야 해요. 이 경우엔 시간에 따른 메모리 증가 패턴을 봐야 합니다. Prometheus + Grafana 조합이 있다면 메모리 사용량 그래프를 확인하세요. 선형으로 계속 올라간다면 누수 가능성이 높습니다.

    # 메모리 사용량 변화를 5초 간격으로 모니터링
    watch -n 5 kubectl top pod  -n <네임스페이스>

    누수가 확인되면 개발팀에 공유하고, 임시 방편으로는 Deployment에 주기적 재시작을 설정하는 방법도 있어요 (권장하진 않지만요):

    # CronJob으로 주기적 재시작 — 근본 해결책은 아닙니다!
    apiVersion: batch/v1
    kind: CronJob
    metadata:
      name: restart-my-app
    spec:
      schedule: "0 4 * * *"  # 매일 새벽 4시
      jobTemplate:
        spec:
          template:
            spec:
              containers:
              - name: kubectl
                image: bitnami/kubectl
                command:
                - kubectl
                - rollout
                - restart
                - deployment/my-app
              restartPolicy: OnFailure

    ▲ Grafana 대시보드에서 Pod 메모리 사용량 추이를 모니터링하는 화면. 메모리 누수 시 선형 증가 패턴이 확인됨

    4단계: 고급 디버깅 — 그래도 모르겠을 때

    임시 디버그 컨테이너 (kubectl debug)

    쿠버네티스 1.23 이상이라면 kubectl debug를 활용할 수 있어요. 이미지에 쉘이 없거나, distroless 이미지를 쓰는 경우에도 쿠버네티스 Pod를 디버깅할 수 있습니다.

    # 실행 중인 Pod에 디버그 컨테이너 추가
    kubectl debug -it  -n <네임스페이스> \
      --image=busybox \
      --target=
    
    # Pod를 복사해서 디버그 버전으로 실행
    kubectl debug  -n <네임스페이스> \
      -it \
      --copy-to=debug-pod \
      --image=ubuntu \
      -- bash

    Pod가 계속 죽어서 exec가 안 될 때

    이건 진짜 난감한 상황인데요. 이럴 때 쓰는 트릭이 있어요. 컨테이너 커맨드를 sleep infinity로 덮어써서 앱이 실행되지 않은 상태에서 내부를 들여다보는 겁니다:

    # 임시로 커맨드를 sleep으로 덮어쓰기
    kubectl debug  -n <네임스페이스> \
      --copy-to=debug-pod \
      --image= \
      -- sleep infinity
    
    # 그 다음 exec로 들어가서 환경 확인
    kubectl exec -it debug-pod -n <네임스페이스> -- bash

    네트워크 연결 문제 확인

    DB나 외부 서비스 연결 실패로 CrashLoopBackOff가 나는 경우도 많아요:

    # 네임스페이스 내 Service 확인
    kubectl get svc -n <네임스페이스>
    
    # DNS 해석 확인 (임시 Pod 사용)
    kubectl run dns-test --image=busybox -it --rm -n <네임스페이스> \
      -- nslookup my-db-service
    
    # TCP 연결 확인
    kubectl run tcp-test --image=busybox -it --rm -n <네임스페이스> \
      -- nc -zv my-db-service 5432

    검증 — 제대로 고쳐졌는지 확인하기

    수정 후에는 꼭 아래 순서로 확인해요:

    1. Pod 상태가 Running으로 안정적으로 유지되는지 확인
    2. RESTARTS 카운트가 더 이상 올라가지 않는지 확인
    3. 로그에 정상 동작 메시지가 찍히는지 확인
    4. Readiness Probe(준비 확인 프로브)가 통과하는지 확인
    # 실시간으로 Pod 상태 변화 모니터링
    kubectl get pods -n <네임스페이스> -w
    
    # Rollout 상태 확인
    kubectl rollout status deployment/ -n <네임스페이스>
    
    # 최근 이벤트 확인
    kubectl get events -n <네임스페이스> --sort-by='.lastTimestamp' | tail -20

    🎉 kubectl get pods에서 STATUS가 Running이고 READY가 1/1이면서 RESTARTS가 안 올라가면 성공입니다!

    쿠버네티스 Pod 트러블슈팅 체크리스트 정리

    확인 항목 CrashLoopBackOff OOMKilled 명령어
    Pod 상태 확인 ✅ ✅ kubectl get pods
    이벤트 로그 확인 ✅ ✅ kubectl describe pod
    이전 컨테이너 로그 ✅ ✅ kubectl logs –previous
    Probe 설정 확인 ✅ – kubectl get pod -o yaml
    메모리 사용량 확인 – ✅ kubectl top pod
    리소스 Limit 조정 – ✅ kubectl edit deployment
    ConfigMap/Secret 확인 ✅ – kubectl get cm,secret
    네트워크 연결 확인 ✅ – kubectl run (임시 Pod)

    ▲ CrashLoopBackOff와 OOMKilled 트러블슈팅 플로우차트. 증상별 진단 경로와 해결 방법을 한눈에 정리한 인포그래픽

    마무리 — 결국 로그가 답이더라고요

    여러 해 동안 수많은 인프라 사고를 겪어오면서 느낀 건, 쿠버네티스 Pod 트러블슈팅의 핵심은 결국 로그와 이벤트를 제대로 읽는 것이에요. 화려한 도구가 없어도, kubectl describe와 kubectl logs --previous 두 개만 잘 써도 대부분의 문제는 해결됩니다.

    CrashLoopBackOff는 원인이 다양하니까 로그를 꼼꼼히 보고 하나씩 제거해 나가는 방식이 좋아요. OOMKilled는 일단 메모리 사용량 측정부터 시작해서 Limit 조정이냐, 누수 수정이냐를 판단하면 되고요.

    정리하면:

    • ✅ CrashLoopBackOff: kubectl logs --previous로 죽기 직전 로그 확인 → Probe 설정, 환경 변수, 마운트 순서로 체크
    • ✅ OOMKilled: kubectl top pod로 실제 사용량 확인 → Limit 상향 조정 또는 누수 수정
    • ✅ 막막할 땐 kubectl debug로 임시 컨테이너 붙여서 내부 확인

    다음 글에서는 Pending 상태에서 Pod가 뜨지 않는 경우 (노드 리소스 부족, Taints/Tolerations 문제 등)를 다뤄볼 예정입니다. Pod가 아예 시작조차 안 된다면 그쪽 글을 참고해 주세요!

    혹시 이 글에서 다루지 않은 케이스로 고생하고 계신 분 있으시면 댓글로 남겨주세요. 같이 고민해 볼게요 😊