Published on

Kubernetes CrashLoopBackOff 원인 7가지와 재현·해결

Authors

서버가 아니라 파드가 죽습니다. CrashLoopBackOff 는 쿠버네티스가 컨테이너를 재시작하려고 시도하지만, 컨테이너가 짧은 시간 내에 반복적으로 종료되어 백오프(backoff) 지연이 붙는 상태입니다. 중요한 점은 CrashLoopBackOff 자체가 원인이 아니라 증상이라는 것입니다.

이 글에서는 운영에서 자주 만나는 원인 7가지를 재현 -> 관찰 포인트 -> 해결 순서로 정리합니다. 마지막에는 트러블슈팅을 빠르게 만드는 명령어 루틴도 제공합니다.

CrashLoopBackOff를 먼저 정확히 관찰하기

아래 명령으로 “왜 죽었는지”를 먼저 좁히는 것이 핵심입니다.

kubectl get pod -n <namespace>

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

# 직전 크래시 로그(매우 중요)
kubectl logs -n <namespace> <pod-name> --previous

# 현재 떠 있는 컨테이너 로그
kubectl logs -n <namespace> <pod-name>

# 종료 코드와 이유만 빠르게
kubectl get pod -n <namespace> <pod-name> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason} {.status.containerStatuses[0].lastState.terminated.exitCode}{"\n"}'

관찰 포인트는 보통 아래 4가지로 수렴합니다.

  • Exit Code1 인가, 137 인가, 143 인가
  • ReasonError 인가, OOMKilled 인가
  • 이벤트에 Liveness probe failed 가 있는가
  • 애플리케이션 로그에 “설정 누락, 포트 바인딩 실패, DB 연결 실패” 같은 명확한 에러가 있는가

아래 원인 7가지는 이 관찰 포인트에 정확히 대응합니다.

원인 1) 엔트리포인트 즉시 종료(프로세스가 포그라운드가 아님)

재현

컨테이너가 실행되자마자 종료되는 가장 흔한 패턴입니다. 예를 들어 CMD 가 백그라운드로만 뜨거나, 스크립트가 끝나면 컨테이너가 종료됩니다.

FROM alpine:3.19
RUN apk add --no-cache bash

# 스크립트가 끝나면 컨테이너 종료
CMD ["bash", "-lc", "echo start; sleep 1; echo end"]

관찰

  • kubectl logs --previous 에는 정상 로그가 짧게 찍히고 끝
  • Exit Code 가 보통 0 또는 1
  • 이벤트에는 특별한 probe 실패 없이 계속 재시작

해결

  • 메인 프로세스는 반드시 포그라운드에서 유지
  • bash -c 로 백그라운드 실행(&)만 하고 끝내지 않기
  • 웹서버/워커 등은 exec 로 PID 1이 되도록 실행
# bad
bash -lc "my-server &"

# good
bash -lc "exec my-server"

원인 2) 설정/시크릿 누락으로 애플리케이션이 즉시 크래시

재현

환경변수나 시크릿이 없으면 애플리케이션이 시작 단계에서 예외를 내고 종료합니다.

apiVersion: v1
kind: Pod
metadata:
  name: crash-env
spec:
  containers:
    - name: app
      image: alpine:3.19
      command: ["sh", "-c"]
      args:
        - |
          test -n "$REQUIRED" || (echo "REQUIRED is missing"; exit 1)
          sleep 3600

관찰

  • 로그에 missing env, config not found, permission denied
  • Exit Code 는 대개 1
  • describe 이벤트에 Error: secret "..." not found 가 뜨기도 함

해결

  • envFromvalueFrom.secretKeyRef 를 사용할 때 키 이름 오타 확인
  • ConfigMap/Secret 존재 여부 확인
kubectl get cm -n <namespace>
kubectl get secret -n <namespace>

kubectl describe pod -n <namespace> <pod-name> | sed -n '1,200p'
  • 애플리케이션은 “필수 설정 누락”을 명확한 로그로 남기고 종료하도록 유지(이건 좋은 크래시입니다)

원인 3) Liveness Probe 오탐으로 강제 재시작

컨테이너가 실제로는 정상인데, livenessProbe 조건이 너무 빡세서 kubelet이 계속 죽이는 케이스입니다. 특히 초기 부팅이 느린 JVM, 마이그레이션 수행 앱에서 흔합니다.

재현

apiVersion: v1
kind: Pod
metadata:
  name: crash-probe
spec:
  containers:
    - name: app
      image: nginx:1.25
      livenessProbe:
        httpGet:
          path: /health
          port: 80
        initialDelaySeconds: 1
        periodSeconds: 2
        timeoutSeconds: 1
        failureThreshold: 1

nginx 기본 설정에는 /health 가 없으니 404 로 실패하고 계속 재시작될 수 있습니다.

관찰

  • kubectl describe pod 이벤트에 Liveness probe failed 반복
  • Exit Code137 또는 143 으로 보일 수 있음(강제 종료)

해결

  • startupProbe 를 추가해 “기동 완료 전에는 liveness를 평가하지 않게” 하기
  • initialDelaySecondsfailureThreshold 를 현실적으로 조정
  • health endpoint는 “의존성(DB 등)”을 포함할지, “프로세스 생존”만 볼지 정책을 통일
startupProbe:
  httpGet:
    path: /health
    port: 80
  failureThreshold: 30
  periodSeconds: 2

livenessProbe:
  httpGet:
    path: /health
    port: 80
  periodSeconds: 10
  failureThreshold: 3

원인 4) OOMKilled: 메모리 제한 초과로 종료(Exit 137)

가장 운영적인 원인입니다. 파드가 노드 메모리를 다 쓰는 게 아니라, 컨테이너에 걸린 limit 을 넘으면 커널이 해당 프로세스를 죽입니다.

재현

apiVersion: v1
kind: Pod
metadata:
  name: crash-oom
spec:
  containers:
    - name: app
      image: python:3.12-slim
      resources:
        requests:
          memory: "64Mi"
        limits:
          memory: "64Mi"
      command: ["python", "-c"]
      args:
        - |
          a = []
          while True:
              a.append('x' * 1024 * 1024)  # 1MiB씩 증가

관찰

  • describeOOMKilled
  • 종료 코드는 보통 137

이 주제는 리눅스 관점에서 보면 더 빨리 이해됩니다. 필요하다면 리눅스 OOM Killer로 프로세스 죽을 때 진단법 도 함께 보세요.

해결

  • limit 상향 또는 메모리 사용량 최적화
  • JVM이면 -Xmx 를 limit에 맞게 설정(컨테이너 환경에서 자동 튜닝이 항상 안전하지 않음)
  • 워커/배치면 동시성(concurrency) 낮추기
  • 메모리 누수 의심 시 heap dump 나 프로파일링 연결
kubectl top pod -n <namespace>

원인 5) Crash는 아닌데 의존성 실패로 즉시 종료(잘못된 재시작 정책)

DB, Kafka, 외부 API 장애 시 애플리케이션이 “바로 종료”하도록 만들어져 있으면, 의존성이 잠깐 흔들려도 CrashLoopBackOff로 확대됩니다.

재현

apiVersion: v1
kind: Pod
metadata:
  name: crash-dependency
spec:
  containers:
    - name: app
      image: alpine:3.19
      command: ["sh", "-c"]
      args:
        - |
          # DB 접속 실패하면 즉시 종료
          nc -zvw1 db 5432 || exit 1
          sleep 3600

관찰

  • 로그에 connection refused, timeout 후 종료
  • Exit Code1

해결

  • “일시적 장애는 재시도”가 기본. 애플리케이션 레벨에서 exponential backoff 적용
  • 쿠버네티스에서는 readinessProbe 로 트래픽만 차단하고, 프로세스는 유지하는 전략이 흔히 더 안정적
  • DB 마이그레이션 같은 “반드시 성공해야만 기동” 작업은 initContainer 로 분리
initContainers:
  - name: migrate
    image: myapp:1.0
    command: ["sh", "-c"]
    args: ["./migrate.sh"]

원인 6) 이미지/아키텍처 불일치 또는 실행 파일 형식 문제

노드 아키텍처가 arm64 인데 amd64 이미지를 올리거나, 실행 권한이 없거나, 동적 링크 라이브러리가 빠진 경우 컨테이너가 즉시 죽습니다.

재현

  • alpine 에서 glibc 바이너리를 그대로 실행
  • 실행 권한 없는 엔트리포인트
FROM alpine:3.19
COPY mybin /usr/local/bin/mybin
CMD ["/usr/local/bin/mybin"]

관찰

  • 로그에 exec format error, no such file or directory(실제로는 동적 로더 없음), permission denied

해결

  • 멀티 아키텍처 이미지 빌드(buildx) 또는 노드 셀렉터로 아키텍처 고정
  • 실행 권한과 shebang 확인
chmod +x mybin
file mybin

원인 7) 볼륨 마운트/파일 권한 문제로 애플리케이션이 크래시

특히 readOnlyRootFilesystem: true 를 켰거나, PV 마운트 권한이 맞지 않으면 로그/캐시/업로드 디렉터리에 쓰지 못해 바로 종료하는 경우가 많습니다.

재현

apiVersion: v1
kind: Pod
metadata:
  name: crash-permission
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
  containers:
    - name: app
      image: alpine:3.19
      command: ["sh", "-c"]
      args:
        - |
          echo test > /var/log/app.log
          sleep 3600
      securityContext:
        readOnlyRootFilesystem: true

관찰

  • 로그에 Read-only file system, Permission denied
  • Exit Code1

해결

  • 쓰기가 필요한 경로를 emptyDir 로 분리 마운트
  • fsGroup 또는 initContainer 로 권한 정리
spec:
  securityContext:
    fsGroup: 2000
  volumes:
    - name: varlog
      emptyDir: {}
  containers:
    - name: app
      volumeMounts:
        - name: varlog
          mountPath: /var/log

빠르게 끝내는 CrashLoopBackOff 진단 루틴

운영에서 시간을 아끼는 순서만 정리하면 아래와 같습니다.

  1. 이벤트로 “누가 죽였는지” 확인
kubectl describe pod -n <namespace> <pod-name>
  1. 직전 크래시 로그 확인
kubectl logs -n <namespace> <pod-name> --previous
  1. 종료 코드로 분기
  • 137 이면 OOM 또는 강제 종료 가능성이 큼
  • 143 이면 SIGTERM 기반 종료(롤링 업데이트, probe 실패 등)
  • 1 이면 애플리케이션 예외/설정/권한 문제 가능성이 큼
  1. probe 실패 여부 확인(이벤트에 답이 있음)

  2. 리소스 확인

kubectl top pod -n <namespace>
kubectl get pod -n <namespace> <pod-name> -o yaml | sed -n '1,200p'

마무리: CrashLoopBackOff를 “증상”으로 다루기

CrashLoopBackOff 를 없애는 가장 좋은 방법은 “재시작”을 줄이는 게 아니라, 왜 종료되는지 를 구조적으로 제거하는 것입니다.

  • 프로세스 수명: PID 1이 명확한가
  • probe: startupProbe 로 기동 구간을 보호하는가
  • 메모리: limit에 맞는 런타임 튜닝이 되어 있는가
  • 의존성: readiness로 트래픽만 차단하고 프로세스는 버티는가
  • 파일/권한: 쓰기 경로를 볼륨으로 분리했는가

운영에서 OOM이 의심된다면 리눅스 관점의 원인 분석이 큰 도움이 됩니다. 앞서 언급한 리눅스 OOM Killer로 프로세스 죽을 때 진단법 도 함께 참고하면, 쿠버네티스 이벤트만 볼 때보다 훨씬 빠르게 결론에 도달할 수 있습니다.