Skip to content

필사 모드: Kubernetes CrashLoopBackOff 원인별 진단과 해결 — 로그가 비어 있을 때 무엇을 봐야 하나

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며 — RESTARTS 숫자만 올라가고 로그는 비어 있습니다

배포 직후 파드 목록을 보면 이런 모습입니다.

kubectl get pod -n payments

NAME                        READY   STATUS             RESTARTS        AGE
checkout-7d9f6c5b4d-2xk9p   0/1     CrashLoopBackOff   6 (2m41s ago)   11m
checkout-7d9f6c5b4d-lm4vz   0/1     CrashLoopBackOff   6 (2m38s ago)   11m
checkout-7d9f6c5b4d-q8w2n   0/1     CrashLoopBackOff   6 (2m44s ago)   11m

로그를 보려고 하면 아무것도 나오지 않습니다.

kubectl logs checkout-7d9f6c5b4d-2xk9p -n payments

Error from server (BadRequest): container "checkout" in pod "checkout-7d9f6c5b4d-2xk9p" is waiting to start: CrashLoopBackOff

여기서 많은 사람이 "로그가 안 남는 앱"이라고 결론 내리고 로깅 설정을 뒤지기 시작합니다. 그건 잘못된 방향입니다. 로그는 남아 있습니다. 지금 조회하려는 컨테이너가 아직 시작되지 않은 다음 세대의 컨테이너일 뿐입니다.

CrashLoopBackOff는 원인이 아니라 재시작 대기입니다

이름을 정확히 읽어야 합니다. CrashLoopBackOff는 컨테이너가 왜 죽었는지에 대해 아무것도 말해 주지 않습니다. kubelet이 "이 컨테이너가 계속 죽으니까 재시작을 잠시 미루겠다"라고 선언한 상태일 뿐입니다.

kubelet의 재시작 지연은 정해진 규칙을 따릅니다.

  • 첫 실패 후 10초 대기
  • 실패할 때마다 대기 시간이 두 배로 증가 — 10초, 20초, 40초, 80초, 160초
  • 상한은 300초, 즉 5분
  • 컨테이너가 충분히 오래(기본 10분) 정상 실행되면 지연 타이머가 초기화되어 다시 10초부터 시작

이 규칙에서 두 가지 실무적 결론이 나옵니다.

첫째, 파드가 오래 방치되어 있으면 재시작 간격이 5분까지 벌어져 있습니다. 수정한 이미지를 올리고 "왜 아직도 안 뜨지"라고 기다릴 필요가 없습니다. 파드를 지우면 새 파드는 백오프 이력 없이 즉시 시작합니다.

kubectl delete pod checkout-7d9f6c5b4d-2xk9p -n payments

둘째, RESTARTS 카운트가 조금씩 늘어나는 파드는 CrashLoopBackOff로 표시되지 않을 수도 있습니다. 10분 넘게 버티다가 죽는 앱은 매번 백오프가 초기화되기 때문에 상태 컬럼에는 Running으로 보이고 RESTARTS만 조용히 증가합니다. 이 패턴이 오히려 더 위험합니다. 아래 명령으로 주기적으로 훑어야 합니다.

kubectl get pods -A --sort-by='.status.containerStatuses[0].restartCount' | tail -20

NAMESPACE   NAME                          READY   STATUS    RESTARTS         AGE
search      indexer-6c4d9f7b8-h2klp       1/1     Running   17 (43m ago)     6d
payments    ledger-5f8b7c6d9-nm3xt        1/1     Running   23 (12m ago)     9d

진단의 시작 — Last State, Exit Code, 그리고 previous 로그

진단 순서는 항상 같습니다. describe로 죽은 사유를 확인하고, 그다음 죽은 컨테이너의 로그를 읽습니다.

kubectl describe pod checkout-7d9f6c5b4d-2xk9p -n payments

Containers:
  checkout:
    Container ID:   containerd://3f2a91c4e88b7d5641a0c2f9e37b1d80
    Image:          registry.example.com/checkout:1.14.2
    Image ID:       registry.example.com/checkout@sha256:9c1f...
    Port:           8080/TCP
    State:          Waiting
      Reason:       CrashLoopBackOff
    Last State:     Terminated
      Reason:       Error
      Exit Code:    1
      Started:      Sun, 26 Jul 2026 09:41:12 +0900
      Finished:     Sun, 26 Jul 2026 09:41:13 +0900
    Ready:          False
    Restart Count:  6
Events:
  Type     Reason     Age                    From     Message
  ----     ------     ----                   ----     -------
  Normal   Pulled     3m12s (x5 over 11m)    kubelet  Container image "registry.example.com/checkout:1.14.2" already present on machine
  Normal   Created    3m12s (x5 over 11m)    kubelet  Created container checkout
  Normal   Started    3m11s (x5 over 11m)    kubelet  Started container checkout
  Warning  BackOff    2m41s (x24 over 10m)   kubelet  Back-off restarting failed container checkout

읽어야 할 값은 셋입니다.

  • Last State의 Reason — Error, OOMKilled, Completed 중 무엇인가
  • Exit Code — 뒤에서 다룰 종료 코드 사전과 대조
  • Started와 Finished의 간격 — 1초 차이면 기동 중 즉시 사망, 30초 이상이면 기동은 했으나 이후에 죽은 것

위 예시는 Started와 Finished가 1초 차이입니다. 프로세스가 뜨자마자 죽었다는 뜻이므로 런타임 로직이 아니라 기동 시점의 설정을 의심해야 합니다.

이제 죽은 컨테이너의 로그를 읽습니다. 핵심은 --previous 플래그입니다. kubelet은 컨테이너를 재시작할 때 새 컨테이너를 만들기 때문에, 플래그 없는 kubectl logs는 아직 시작하지 않았거나 방금 태어난 컨테이너를 가리킵니다. 죽은 컨테이너의 로그는 직전 세대에 있습니다.

kubectl logs checkout-7d9f6c5b4d-2xk9p -n payments --previous

2026-07-26T00:41:12.881Z INFO  starting checkout 1.14.2
2026-07-26T00:41:13.104Z ERROR config: required key PAYMENT_SIGNING_KEY is not set
2026-07-26T00:41:13.105Z FATAL exiting with status 1

흔한 오답: 로그가 비어 있으니 애플리케이션이 로그를 안 남긴다고 판단하는 것. 실제로는 --previous를 안 쳤을 뿐인 경우가 압도적으로 많습니다. --previous까지 비어 있다면 그때야 비로소 프로세스가 로그 한 줄도 못 쓰고 죽었다는 뜻이고, 그 경우 원인은 거의 항상 이미지 진입점 또는 볼륨 마운트입니다.

컨테이너가 여러 개인 파드라면 어떤 컨테이너가 죽는지부터 특정해야 합니다.

kubectl get pod checkout-7d9f6c5b4d-2xk9p -n payments \
  -o jsonpath='{range .status.containerStatuses[*]}{.name}{"\t"}{.restartCount}{"\t"}{.lastState.terminated.reason}{"\t"}{.lastState.terminated.exitCode}{"\n"}{end}'

checkout    6    Error    1
istio-proxy 0    <none>   <none>

종료 코드 사전 — 0, 1, 127, 137, 139, 143

종료 코드는 진단을 절반으로 줄여 줍니다. 128보다 큰 값은 128에 시그널 번호를 더한 것이라 어떤 시그널에 죽었는지 역산됩니다.

  • 0 — 정상 종료. 프로세스가 자기 일을 마치고 끝났다는 뜻입니다. Deployment의 restartPolicy는 항상 Always이므로, 정상 종료해도 kubelet은 다시 띄우고 결국 CrashLoopBackOff가 됩니다. Reason에는 Error가 아니라 Completed가 찍힙니다. 원인은 대개 프로세스를 포그라운드로 실행하지 않은 것입니다. nginx를 데몬 모드로 실행하거나, 셸 스크립트가 마지막 명령을 백그라운드로 던지고 끝나는 경우가 전형적입니다.
  • 1 — 일반적인 애플리케이션 오류. 프레임워크가 잡지 못한 예외, 설정 검증 실패, 필수 환경변수 부재가 여기에 몰립니다. 로그에 원인이 반드시 있습니다.
  • 2 — 셸 사용법 오류. 잘못된 인자를 넘겼을 때 셸이 반환합니다.
  • 126 — 파일은 있는데 실행 권한이 없음. 엔트리포인트 스크립트에 실행 비트를 안 준 경우입니다.
  • 127 — 명령을 찾을 수 없음. command에 오타가 있거나, distroless 또는 scratch 이미지에 없는 셸을 호출한 경우입니다.
  • 137 — 128 더하기 9, 즉 SIGKILL. 커널 OOM 킬러 또는 kubelet이 강제로 죽였습니다. Reason이 OOMKilled면 메모리 한계 초과이고, Reason이 Error면 SIGTERM 이후 유예 시간 안에 종료하지 못해 강제 종료된 것입니다.
  • 139 — 128 더하기 11, 즉 SIGSEGV. 네이티브 코드의 세그멘테이션 폴트입니다. 네이티브 확장 모듈, JNI, CGO, 또는 아키텍처 불일치 이미지에서 나옵니다.
  • 143 — 128 더하기 15, 즉 SIGTERM. 누군가 정상 종료를 요청했고 프로세스가 응했습니다. 롤링 업데이트나 노드 드레인 중이면 정상이지만, 아무 이유 없이 143이 반복된다면 liveness 프로브가 컨테이너를 죽이고 있을 가능성이 큽니다.

흔한 오답: 137을 보자마자 메모리 한도를 올리는 것. 137은 SIGKILL이라는 사실만 말해 줍니다. 메모리 때문인지는 Reason 필드가 OOMKilled인지로 판별해야 합니다. liveness 실패 후 종료를 거부해 강제로 죽은 컨테이너에 메모리를 더 주면 아무것도 달라지지 않습니다. 메모리 쪽으로 확정됐다면 OOMKilled 137 진단 편으로 넘어가는 편이 빠릅니다.

원인 분기와 진단 명령

CrashLoopBackOff로 수렴하는 경로는 크게 일곱 갈래입니다. 하나씩 배제해 나가면 됩니다.

원인대표 신호진단 명령해결
애플리케이션 예외로 즉시 종료Exit Code 1, Reason Error, 실행 1초 이내kubectl logs POD --previous로그의 스택트레이스를 그대로 수정
필수 설정·시크릿 누락Exit Code 1, 로그에 키 이름이 찍힘kubectl get secret, kubectl describe podConfigMap·Secret 키 이름과 네임스페이스 확인
정상 종료 후 반복 재시작Exit Code 0, Reason Completedkubectl logs POD --previous프로세스를 포그라운드로 실행
OOMKilledExit Code 137, Reason OOMKilleddescribe의 Last State한도 상향 또는 런타임 힙 설정 조정
liveness 프로브에 의한 강제 종료Exit Code 143 또는 137, Events에 Unhealthy와 Killingkubectl get events --field-selector reason=UnhealthystartupProbe 추가, 임계값 완화
잘못된 command·entrypointExit Code 126 또는 127, Message에 executable file not foundkubectl describe pod의 Messagecommand와 args 수정, 이미지에 셸 존재 여부 확인
볼륨 마운트 실패컨테이너가 시작조차 못 함, Events에 FailedMountkubectl describe pod의 EventsPVC 바인딩과 시크릿 존재 확인
init 컨테이너 실패STATUS가 Init:CrashLoopBackOffkubectl logs POD -c INIT_NAME --previousinit 로직 수정, 의존 서비스 기동 순서 확인

설정과 시크릿 누락

가장 흔합니다. 파드 스펙이 참조하는 키가 실제로 존재하는지부터 확인합니다.

kubectl get secret payment-keys -n payments -o jsonpath='{.data}' | tr ',' '\n'

{"PAYMENT_API_URL":"aHR0cHM6...","PAYMENT_WEBHOOK_SECRET":"czNjcjN0"}

스펙은 PAYMENT_SIGNING_KEY를 요구하는데 시크릿에는 그 키가 없습니다. 키 이름 오타가 원인입니다.

여기서 중요한 설계 포인트가 있습니다. env 아래 secretKeyRef로 참조하면서 optional: true를 주면 키가 없어도 파드는 뜨고 애플리케이션이 런타임에 죽습니다. 반대로 optional을 명시하지 않으면 kubelet이 컨테이너를 시작조차 하지 않고 CreateContainerConfigError로 멈춥니다. 후자가 훨씬 진단하기 쉽습니다.

env:
  - name: PAYMENT_SIGNING_KEY
    valueFrom:
      secretKeyRef:
        name: payment-keys
        key: PAYMENT_SIGNING_KEY
        # optional을 생략하면 기본값 false — 키가 없으면 컨테이너를 시작하지 않는다

이때 파드 상태는 CrashLoopBackOff가 아니라 CreateContainerConfigError로 나타나며, describe에 정확한 이유가 적힙니다.

kubectl describe pod checkout-7d9f6c5b4d-2xk9p -n payments | grep -A3 "Warning  Failed"

  Warning  Failed     9s (x3 over 25s)  kubelet  Error: couldn't find key PAYMENT_SIGNING_KEY in Secret payments/payment-keys

liveness 프로브가 죽이고 있는 경우

Events에 Unhealthy와 Killing이 함께 보이면 프로브가 범인입니다.

kubectl get events -n payments --field-selector involvedObject.name=checkout-7d9f6c5b4d-2xk9p --sort-by=.lastTimestamp

LAST SEEN   TYPE      REASON      OBJECT                          MESSAGE
4m12s       Warning   Unhealthy   pod/checkout-7d9f6c5b4d-2xk9p   Liveness probe failed: Get "http://10.42.3.17:8080/healthz": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
4m12s       Normal    Killing     pod/checkout-7d9f6c5b4d-2xk9p   Container checkout failed liveness probe, will be restarted

기동에 40초가 걸리는 앱에 initialDelaySeconds 10짜리 liveness를 걸어 두면 영원히 뜨지 못합니다. 이 경우 initialDelaySeconds를 늘리는 것은 임시방편이고, 정답은 startupProbe입니다. 자세한 계산은 프로브 3종 설계 편에 정리했습니다.

잘못된 command와 entrypoint

Message에 실행 파일을 못 찾았다는 문장이 그대로 나옵니다.

kubectl describe pod migrate-runner-0 -n payments | grep -A2 "Last State"

    Last State:     Terminated
      Reason:       StartError
      Message:      failed to create containerd task: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: exec: "/app/entrypoint.sh": permission denied

권한 문제면 126, 파일 자체가 없으면 127입니다. distroless 이미지에 command: ["sh", "-c", ...]를 쓰면 셸이 없어서 127이 납니다.

볼륨 마운트 실패와 init 컨테이너

컨테이너가 시작조차 못 했다면 로그가 아니라 Events를 봐야 합니다.

kubectl describe pod ledger-0 -n payments | tail -8

Events:
  Type     Reason       Age                 From               Message
  ----     ------       ----                ----               -------
  Warning  FailedMount  2m15s (x9 over 12m) kubelet            MountVolume.SetUp failed for volume "ledger-data" : rpc error: code = Internal desc = volume attachment is being deleted
  Warning  FailedMount  38s                 kubelet            Unable to attach or mount volumes: unmounted volumes=[ledger-data], unattached volumes=[ledger-data kube-api-access-x9k2m]: timed out waiting for the condition

init 컨테이너가 실패하면 STATUS 컬럼에 Init 접두어가 붙고, 메인 컨테이너는 아예 실행되지 않습니다. 로그를 볼 때 컨테이너 이름을 반드시 지정해야 합니다.

kubectl get pod ledger-0 -n payments

NAME       READY   STATUS                  RESTARTS      AGE
ledger-0   0/1     Init:CrashLoopBackOff   4 (48s ago)   3m

kubectl logs ledger-0 -n payments -c wait-for-db --previous

waiting for postgres.payments.svc.cluster.local:5432 ...
timeout after 30s

컨테이너를 살려 둔 채 안으로 들어가기

로그만으로 안 풀리면 죽는 컨테이너 안을 직접 봐야 합니다. 두 가지 방법이 있고, 쓰임새가 다릅니다.

첫째, command를 덮어써서 프로세스 대신 잠만 자게 만드는 방법입니다. 파일시스템, 환경변수, DNS를 전부 원본과 동일한 조건에서 확인할 수 있습니다.

apiVersion: v1
kind: Pod
metadata:
  name: checkout-shell
  namespace: payments
spec:
  restartPolicy: Never
  containers:
    - name: checkout
      image: registry.example.com/checkout:1.14.2
      command: ['sh', '-c', 'sleep infinity']
      envFrom:
        - secretRef:
            name: payment-keys
      volumeMounts:
        - name: config
          mountPath: /etc/checkout
  volumes:
    - name: config
      configMap:
        name: checkout-config
kubectl apply -f checkout-shell.yaml
kubectl exec -it checkout-shell -n payments -- sh

/ # env | grep PAYMENT
PAYMENT_API_URL=https://api.example.com
PAYMENT_WEBHOOK_SECRET=s3cr3t
/ # /app/checkout
ERROR config: required key PAYMENT_SIGNING_KEY is not set

기존 Deployment를 건드리지 않고 같은 효과를 내려면 복제본을 만듭니다.

kubectl debug checkout-7d9f6c5b4d-2xk9p -n payments \
  --copy-to=checkout-debug \
  --container=checkout \
  -- sleep infinity

kubectl exec -it checkout-debug -n payments -- sh

둘째, 임시 컨테이너입니다. 원본 이미지에 셸이 없는 distroless 환경에서 특히 유용합니다. 대상 컨테이너와 프로세스 네임스페이스를 공유하므로 죽어 가는 프로세스도 관찰할 수 있습니다.

kubectl debug -it checkout-7d9f6c5b4d-2xk9p -n payments \
  --image=busybox:1.36 \
  --target=checkout \
  -- sh

Defaulting debug container name to debugger-7v2mp.
/ # ls /proc
1  14  self ...
/ # cat /proc/1/cmdline
/app/checkout
/ # wget -qO- http://localhost:8080/healthz
wget: can't connect to remote host: Connection refused

임시 컨테이너는 파드 스펙을 변경하지 않으므로 재시작을 유발하지 않습니다. 다만 볼륨은 기본적으로 공유되지 않으니, 마운트된 파일을 봐야 한다면 --target으로 지정한 컨테이너의 파일시스템을 /proc/1/root 경로로 접근합니다.

/ # ls /proc/1/root/etc/checkout
application.yaml  logging.yaml

재발 방지 — 크래시가 배포 파이프라인에서 드러나게

같은 사고를 반복하지 않으려면 세 군데에 장치를 답니다.

첫째, 애플리케이션이 기동 시점에 설정을 전부 검증하고 명확한 메시지와 함께 죽도록 만듭니다. 필수 키를 런타임 첫 요청에서야 읽는 코드는 CrashLoopBackOff 대신 500 에러로 나타나 훨씬 늦게 발견됩니다.

둘째, 배포 명령이 실패를 기다렸다가 반환하게 만듭니다. kubectl apply는 리소스를 제출하고 바로 성공을 반환하므로, CI가 초록불인데 프로덕션은 죽어 있는 상태가 만들어집니다.

kubectl apply -f deploy/checkout.yaml
kubectl rollout status deployment/checkout -n payments --timeout=180s

Waiting for deployment "checkout" rollout to finish: 0 of 3 updated replicas are available...
error: deployment "checkout" exceeded its progress deadline

Deployment에 progressDeadlineSeconds를 명시해 두면 이 판정이 자동으로 붙습니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: checkout
  namespace: payments
spec:
  replicas: 3
  progressDeadlineSeconds: 180
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1

maxUnavailable: 0은 새 파드가 Ready가 되기 전까지 기존 파드를 죽이지 않게 합니다. 크래시하는 새 버전을 배포해도 서비스는 유지됩니다.

셋째, 재시작 증가를 알림으로 잡습니다. CrashLoopBackOff는 눈에 띄지만, 앞서 말한 "10분마다 조용히 재시작"은 대시보드를 보지 않으면 몇 주씩 방치됩니다.

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: pod-restart-alerts
  namespace: monitoring
spec:
  groups:
    - name: pod-health
      rules:
        - alert: PodRestartingRepeatedly
          expr: increase(kube_pod_container_status_restarts_total[1h]) > 3
          for: 10m
          labels:
            severity: warning
          annotations:
            summary: "{{ $labels.namespace }}/{{ $labels.pod }} restarted more than 3 times in an hour"
        - alert: PodInCrashLoop
          expr: kube_pod_container_status_waiting_reason{reason="CrashLoopBackOff"} == 1
          for: 5m
          labels:
            severity: critical

마치며 — 로그가 비어 있으면 previous를 먼저 칩니다

CrashLoopBackOff 진단은 재능이 아니라 순서입니다. describe로 Last State의 Reason과 Exit Code를 확인하고, kubectl logs --previous로 죽은 컨테이너의 마지막 말을 듣고, 그래도 안 나오면 command를 덮어써서 컨테이너를 살려 둔 채 들어갑니다. 이 세 단계면 대부분의 사고가 10분 안에 끝납니다.

기억할 한 문장은 이것입니다. CrashLoopBackOff는 진단명이 아니라 대기실 이름이고, 진짜 진단명은 언제나 Exit Code와 직전 컨테이너의 로그에 적혀 있습니다.

현재 단락 (1/222)

배포 직후 파드 목록을 보면 이런 모습입니다.

작성 글자: 0원문 글자: 11,038작성 단락: 0/222