Kubernetes Pod가 안 뜰 때: CrashLoopBackOff·ImagePullBackOff·Pending 원인 찾기
이 글의 핵심
Pod가 Running이 되지 않을 때 무작정 재배포부터 하면 원인을 놓치기 쉽습니다. 상태와 RESTARTS 값을 읽는 법, 로그·describe·이벤트를 어떤 순서로 봐야 하는지부터 정리하고, DB 연결 실패, ConfigMap·Secret 누락, 권한 문제, 헬스체크 타임아웃처럼 실제로 자주 만나는 원인별 해결 패턴을 설명합니다.
들어가며
Pod가 CrashLoopBackOff, ImagePullBackOff, Pending 상태에 빠지면 원인은 대개 세 층 중 하나에 있습니다. 스케줄러가 Pod를 둘 노드를 못 찾았거나(Pending), 노드의 kubelet이 컨테이너를 만들지 못했거나(ImagePullBackOff, CreateContainerConfigError), 컨테이너는 떴지만 애플리케이션이 죽고 있거나(CrashLoopBackOff)입니다. 어느 층의 문제인지 먼저 가르면 봐야 할 도구가 정해집니다. 스케줄링과 컨테이너 생성 문제는 kubectl describe의 Events에, 애플리케이션 문제는 kubectl logs --previous에 답이 있는 경우가 대부분입니다.
이 글은 그 판단에 필요한 상태 읽는 법, 진단 도구의 동작과 사용 순서, 상태별 원인과 해결 방법을 다룹니다.
Pod 상태 이해
그림: Pod는 스케줄링, 이미지 받기, 컨테이너 실행 순서로 진행되고 막힌 단계에 따라 Pending, ImagePullBackOff, CrashLoopBackOff 상태가 보인다. 상태 이름만 봐도 어느 단계부터 확인할지 정해진다.
Pod가 생성되어 실행되기까지
kubectl apply로 Pod(또는 Pod를 만드는 Deployment)를 제출하면 대략 다음 순서로 진행됩니다. 각 단계에서 실패하면 서로 다른 증상이 나타나므로, 흐름을 알고 있으면 증상에서 실패 지점을 거꾸로 추적할 수 있습니다.
- API 서버가 요청을 인증, 인가하고 어드미션 컨트롤러를 거칩니다. ResourceQuota 초과나 Pod Security Admission 위반은 여기서 거절되며, Deployment라면 Pod가 아예 만들어지지 않고 ReplicaSet의 이벤트에만 오류가 남습니다. 통과한 Pod 정의는 etcd에 저장됩니다.
- 스케줄러가
spec.nodeName이 비어 있는 Pod를 발견하고 노드를 고릅니다. 먼저 리소스 요청(requests), nodeSelector와 affinity, taint와 toleration, 볼륨 토폴로지 등을 만족하지 못하는 노드를 걸러 내고(filtering), 남은 노드에 점수를 매겨(scoring) 하나를 골라 바인딩합니다. 남는 노드가 없으면 Pod는 Pending으로 남고FailedScheduling이벤트가 기록됩니다. - 해당 노드의 kubelet이 Pod를 받아 CRI(containerd, CRI-O 등)를 통해 이미지를 받고, 볼륨을 마운트하고, CNI 플러그인으로 네트워크를 설정한 뒤 컨테이너를 생성합니다. 이 단계에서 Pod의 phase는 여전히 Pending이고 STATUS 열에는
ContainerCreating이 보입니다. 이미지 풀 실패는ErrImagePull/ImagePullBackOff, 참조한 ConfigMap이나 Secret이 없으면CreateContainerConfigError, 볼륨 마운트 실패는FailedMount이벤트로 나타납니다. - 컨테이너 프로세스가 시작되면 phase가 Running이 됩니다. 이후 프로세스가 종료되면 kubelet이
restartPolicy에 따라 재시작하고, 반복해서 죽으면 재시작 사이에 대기 시간을 두는데 이 대기 중 상태가CrashLoopBackOff입니다.
kubelet의 재시작 대기 시간은 10초, 20초, 40초처럼 두 배씩 늘어나 최대 5분에서 멈춥니다. 컨테이너가 10분 동안 문제없이 실행되면 대기 시간이 초기화됩니다. 그래서 오래 크래시를 반복한 Pod는 원인을 고친 뒤에도 다음 재시작까지 최대 5분을 기다릴 수 있고, 빨리 확인하려면 Pod를 지우거나 kubectl rollout restart로 새로 만드는 편이 빠릅니다.
Pod phase와 STATUS 열은 다르다
kubectl get pods의 STATUS 열에 보이는 값 중 Pod의 공식 phase는 Pending, Running, Succeeded, Failed, Unknown 다섯 가지뿐입니다. CrashLoopBackOff, ImagePullBackOff 같은 값은 kubectl이 컨테이너의 대기(waiting)나 종료(terminated) 사유를 보여 주는 것입니다. 예를 들어 CrashLoopBackOff인 Pod의 phase는 보통 Running입니다.
| STATUS 표시 | 실제 의미 | 일반적 원인 |
|---|---|---|
| Pending | phase. 노드에 배정되지 않았거나, 배정됐지만 컨테이너가 아직 시작되지 않음 | 리소스 부족, PVC 바인딩 실패, nodeSelector 불일치 |
| ContainerCreating | 컨테이너 대기 사유. 이미지 풀, 볼륨 마운트, 네트워크 설정 중 | 큰 이미지 다운로드, 볼륨 attach 지연 |
| Running | phase. 컨테이너가 최소 하나 실행 중 | 정상(단, READY 열을 함께 확인) |
| Succeeded | phase. 모든 컨테이너가 exit code 0으로 종료, 재시작 안 함 | Job 완료 |
| Failed | phase. 모든 컨테이너가 종료됐고 하나 이상이 실패, 재시작 안 함 | restartPolicy: Never/OnFailure인 Job 실패 |
| Unknown | phase. 노드와 통신이 안 되어 상태를 모름 | 노드 다운, 네트워크 장애 |
| CrashLoopBackOff | 컨테이너 대기 사유. 반복 크래시 후 재시작 대기 중 | 애플리케이션 에러, 설정 오류, OOM |
| ImagePullBackOff / ErrImagePull | 컨테이너 대기 사유. 이미지 풀 실패 후 재시도 대기 중 | 이미지/태그 없음, 인증 실패 |
| CreateContainerConfigError | 컨테이너 대기 사유. 컨테이너 설정을 만들 수 없음 | 참조한 ConfigMap/Secret 또는 키가 없음 |
| Error / OOMKilled / Completed | 컨테이너 종료 사유 | 비정상 종료, 메모리 초과, 정상 종료 |
| Terminating | 삭제 중(deletionTimestamp 설정됨) | 정상 종료 과정, 또는 finalizer나 응답 없는 노드로 멈춤 |
| Init:0/1 | Init 컨테이너 1개 중 0개 완료 | Init 컨테이너 실행 중 또는 실패 반복 |
Deployment가 만든 Pod는 기본 restartPolicy: Always라서 컨테이너가 실패해도 Failed phase가 되지 않고 계속 재시작됩니다. Failed는 주로 Job이나 단독 Pod에서 봅니다.
Pod 상태 확인 명령어
kubectl get pods
NAME READY STATUS RESTARTS AGE
myapp-5d4b7c8f9-abc12 1/1 Running 0 5m
myapp-5d4b7c8f9-def34 0/1 CrashLoopBackOff 5 3m
myapp-5d4b7c8f9-ghi56 0/1 ImagePullBackOff 0 2m
myapp-5d4b7c8f9-jkl78 0/1 Pending 0 1m
NAME에는 Deployment가 만든 경우 ReplicaSet 해시와 임의 접미사가 붙습니다. READY는 Ready 상태인 컨테이너 수와 전체 컨테이너 수(Init 컨테이너 제외)이고, RESTARTS는 컨테이너 재시작 횟수의 합입니다.
# IP와 노드까지 보기
kubectl get pods -o wide
# 전체 정의와 상태
kubectl get pod myapp-abc -o yaml
kubectl get pod myapp-abc -o json | jq '.status'
# phase로 필터링
kubectl get pods --field-selector=status.phase!=Running
kubectl get pods --field-selector=status.phase=Pending
# 재시작이 5회를 넘는 컨테이너가 있는 Pod
kubectl get pods -o json | jq -r '.items[] | select(any(.status.containerStatuses[]?; .restartCount > 5)) | .metadata.name'
# 변경이 생길 때마다 한 줄씩 출력
kubectl get pods -w
--field-selector=status.phase!=Running은 phase 기준이라 CrashLoopBackOff인 Pod(phase는 Running)는 걸러지지 않는다는 점에 주의합니다. -w는 주기적으로 새로 고치는 것이 아니라 API 서버의 watch로 변경이 생길 때마다 출력합니다.
READY 열 읽기
READY가 0/1인데 STATUS가 Running이면 컨테이너 프로세스는 살아 있지만 readiness probe를 통과하지 못한 상태입니다. 이 Pod는 Service의 엔드포인트에서 빠져 트래픽을 받지 않습니다. 원인은 대개 헬스체크 엔드포인트의 실패나 초기화 지연입니다.
멀티 컨테이너 Pod에서 2/3처럼 일부만 Ready라면 어느 컨테이너인지 확인합니다.
kubectl get pod app-abc -o jsonpath='{range .status.containerStatuses[*]}{.name}{"\t"}{.ready}{"\n"}{end}'
# main true
# sidecar true
# worker false
종료 사유와 Exit Code
재시작이 있었다면 직전 컨테이너가 왜 끝났는지부터 봅니다.
kubectl describe pod myapp-abc | grep -A 6 "Last State"
Last State: Terminated
Reason: Error
Exit Code: 1
Started: Mon, 17 Apr 2026 10:00:00 +0900
Finished: Mon, 17 Apr 2026 10:00:05 +0900
| Exit Code | 의미 | 일반적 원인 |
|---|---|---|
| 0 | 정상 종료 | 할 일을 마치고 끝남. Deployment에서는 이것도 재시작 대상 |
| 1 | 일반 에러 | 처리되지 않은 예외, 설정 검증 실패 |
| 2 | 잘못된 사용 | 셸 내장 명령이나 명령행 인자 오류 |
| 126 | 실행 불가 | 실행 권한 없음, 바이너리 형식 불일치 |
| 127 | 명령 없음 | 명령 오타, PATH 문제, 이미지에 바이너리 없음 |
| 128+N | 시그널 N으로 종료 | 아래 참고 |
| 137 | SIGKILL(128+9) | OOM 킬, 또는 종료 유예 시간 초과 후 강제 종료 |
| 143 | SIGTERM(128+15) | 삭제나 liveness 실패로 받은 종료 신호에 따라 종료 |
| 255 | 범위를 벗어난 종료 값 | exit(-1) 같은 호출 |
137은 OOM의 대표 증상이지만 SIGKILL이면 모두 137입니다. Reason이 OOMKilled인지 함께 확인해야 하고, Error라면 liveness probe 실패 후 terminationGracePeriodSeconds 안에 종료하지 않아 강제 종료된 경우일 수 있습니다.
진단 도구
kubectl logs - 애플리케이션 로그
kubectl logs는 컨테이너가 stdout/stderr로 출력한 내용을 보여 줍니다. 요청은 API 서버를 거쳐 해당 노드의 kubelet으로 전달되고, kubelet이 노드의 로그 파일(/var/log/pods/<namespace>_<pod>_<uid>/<container>/<재시작 번호>.log)을 읽어 돌려줍니다. 파일은 컨테이너 런타임이 CRI 로그 형식(타임스탬프, 스트림, 내용)으로 기록하며, kubelet 설정 containerLogMaxSize(기본 10Mi)와 containerLogMaxFiles(기본 5)에 따라 회전됩니다. kubelet은 직전에 종료된 컨테이너의 로그까지만 보관하므로 그보다 오래된 로그가 필요하면 중앙 로그 수집이 있어야 합니다.
# 현재 컨테이너 로그
kubectl logs myapp-abc
# 직전에 종료된 컨테이너 로그: 크래시 원인은 보통 여기에 있음
kubectl logs myapp-abc --previous
# 실시간 추적
kubectl logs myapp-abc -f
# 최근 N줄 / 시간 범위
kubectl logs myapp-abc --tail=100
kubectl logs myapp-abc --since=5m
kubectl logs myapp-abc --since-time=2026-04-17T10:00:00Z
# 타임스탬프 포함
kubectl logs myapp-abc --timestamps
CrashLoopBackOff 상태에서 kubectl logs만 실행하면 방금 재시작된 컨테이너의 시작 로그만 보이거나 아예 비어 있습니다. 크래시 직전의 에러 메시지는 --previous로 봐야 합니다.
kubectl logs myapp-abc --previous
2026-04-17 10:29:55 INFO Starting application...
2026-04-17 10:29:56 ERROR Database connection failed
2026-04-17 10:29:56 ERROR Cannot connect to postgresql://db:5432
2026-04-17 10:29:57 FATAL Exiting with code 1
멀티 컨테이너 Pod에서는 -c로 컨테이너를 지정하고, 여러 Pod는 레이블 셀렉터로 한꺼번에 볼 수 있습니다.
kubectl get pod myapp-abc -o jsonpath='{.spec.containers[*].name}'
kubectl logs myapp-abc -c sidecar
kubectl logs myapp-abc --all-containers --prefix
# Init 컨테이너 로그도 -c로 봅니다
kubectl logs myapp-abc -c wait-for-db
# 레이블로 여러 Pod
kubectl logs -l app=myapp --all-containers --prefix --tail=50
레이블 셀렉터(-l)를 쓰면 --tail의 기본값이 10줄로 바뀌고, 동시에 따라가는 Pod 수도 --max-log-requests(기본 5)로 제한됩니다. 출력이 생각보다 짧다면 이 기본값 때문입니다.
kubectl logs가 비어 있다면 애플리케이션이 로그를 stdout이 아니라 파일에 쓰고 있을 가능성이 큽니다. 컨테이너가 실행 중이라면 kubectl exec -it myapp-abc -- cat /var/log/app.log로 볼 수 있지만, 근본적으로는 애플리케이션이 stdout/stderr에 출력하도록 바꾸는 것이 맞습니다. 그래야 kubectl logs와 노드의 로그 수집기가 모두 같은 경로로 로그를 받습니다. JSON 한 줄 형식으로 출력하면 수집기에서 필드별로 검색하기 쉽고, 에러를 로깅할 때는 메시지뿐 아니라 스택 트레이스까지 남겨야 --previous로 봤을 때 원인 위치를 알 수 있습니다.
kubectl describe - 상태와 이벤트
kubectl describe pod는 Pod 객체와 그 Pod에 연결된 Event 객체를 함께 조회해 사람이 읽기 좋게 보여 줍니다. 이벤트는 별도 리소스로 저장되며 API 서버의 --event-ttl(기본 1시간)이 지나면 사라지므로, 오래전에 일어난 문제는 describe에 남아 있지 않을 수 있습니다.
Name: myapp-5d4b7c8f9-abc12
Namespace: default
Node: node1/192.168.1.10
Labels: app=myapp
pod-template-hash=5d4b7c8f9
Status: Running
IP: 10.244.1.5
Containers:
main:
Image: myapp:v1.0.0
Image ID: registry.example.com/myapp@sha256:def456...
State: Running
Started: Mon, 17 Apr 2026 10:30:00 +0900
Last State: Terminated
Reason: Error
Exit Code: 1
Ready: True
Restart Count: 5
Limits:
cpu: 500m
memory: 512Mi
Requests:
cpu: 100m
memory: 128Mi
Liveness: http-get http://:8080/health delay=30s timeout=5s period=10s #success=1 #failure=3
Readiness: http-get http://:8080/ready delay=5s timeout=3s period=5s #success=1 #failure=3
Environment:
DATABASE_URL: postgresql://postgres:5432/mydb
API_KEY: <set to the key 'api-key' in secret 'api-secret'> Optional: false
Conditions:
Type Status
Initialized True
Ready True
ContainersReady True
PodScheduled True
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 5m default-scheduler Successfully assigned default/myapp-abc to node1
Normal Pulling 5m kubelet Pulling image "myapp:v1.0.0"
Normal Pulled 4m30s kubelet Successfully pulled image "myapp:v1.0.0"
Normal Created 4m30s kubelet Created container main
Normal Started 4m30s kubelet Started container main
Warning Unhealthy 3m (x3 over 4m) kubelet Liveness probe failed: HTTP probe failed with statuscode: 500
Normal Killing 3m kubelet Container main failed liveness probe, will be restarted
Warning BackOff 2m (x5 over 3m) kubelet Back-off restarting failed container
읽을 때 중요한 부분은 다음과 같습니다. Node는 문제가 특정 노드에만 몰려 있는지 판단할 때 씁니다. IP는 Pod에 할당된 Pod IP입니다(Service의 ClusterIP와는 다릅니다). Last State와 Exit Code는 직전 종료 사유, Limits/Requests는 OOM과 스케줄링 문제를 볼 때, Liveness/Readiness는 probe 설정, Environment는 환경 변수 누락을 확인할 때 봅니다. Secret에서 온 값은 값 대신 참조만 표시됩니다.
Conditions는 PodScheduled(노드 배정), Initialized(Init 컨테이너 완료), ContainersReady(모든 컨테이너 Ready), Ready(Service 트래픽 수신 가능) 순서로 True가 됩니다. 어느 조건에서 False로 멈췄는지 보면 어느 단계에서 막혔는지 알 수 있습니다.
Events는 시간 순서로 읽고, Warning 위주로 봅니다. (x3 over 4m)처럼 반복 횟수가 붙은 이벤트는 같은 문제가 계속되고 있다는 뜻입니다. 위 예시는 liveness probe가 500을 받아 kubelet이 컨테이너를 재시작했고, 재시작이 반복되며 BackOff에 들어간 흐름입니다.
kubectl get events - 네임스페이스 이벤트
# 시간순(오래된 것부터) 정렬
kubectl get events --sort-by=.lastTimestamp
# 특정 네임스페이스, 모든 네임스페이스
kubectl get events -n production --sort-by=.lastTimestamp
kubectl get events -A --sort-by=.lastTimestamp
# 특정 Pod의 이벤트만
kubectl get events --field-selector involvedObject.kind=Pod,involvedObject.name=myapp-abc
# Warning만
kubectl get events --field-selector type=Warning
# 실시간
kubectl get events -w
# 최근 10분 이내 (lastTimestamp는 문자열이므로 변환 후 비교)
kubectl get events -o json | jq '.items[] | select(.lastTimestamp != null and (.lastTimestamp | fromdateiso8601) > (now - 600))'
--sort-by는 오름차순이라 가장 최근 이벤트가 맨 아래에 옵니다. Kubernetes 1.26 이상이라면 kubectl events --for pod/myapp-abc처럼 정렬된 이벤트를 바로 보여 주는 kubectl events 명령도 쓸 수 있습니다. Deployment가 Pod를 아예 만들지 못하는 문제(쿼터 초과, Pod Security 위반)는 Pod가 없으니 describe로 볼 수 없고, ReplicaSet의 이벤트에 FailedCreate로 남으므로 이때 네임스페이스 이벤트가 유용합니다.
진단 순서
# 1. 상태와 READY, RESTARTS 확인
kubectl get pods
# 2. 재시작이 있었다면 직전 컨테이너 로그
kubectl logs <pod-name> --previous
# 3. 종료 사유, 설정, 이벤트
kubectl describe pod <pod-name>
# 4. Pod 밖의 맥락(ReplicaSet, 노드, PVC 이벤트)
kubectl get events --sort-by=.lastTimestamp
# 5. 리소스가 의심되면 사용량과 할당량
kubectl top pods
kubectl describe node <node-name>
예를 들어 CrashLoopBackOff에서 --previous 로그가 Error: DATABASE_URL is not set이라면, describe의 Environment에 DATABASE_URL이 있는지 보고 없으면 Deployment에 추가합니다(kubectl set env 또는 매니페스트 수정).
CrashLoopBackOff 해결
CrashLoopBackOff란?
컨테이너가 시작했다가 종료되기를 반복하면, kubelet은 즉시 재시작하는 대신 대기 시간을 두는데 이 대기 중인 상태가 CrashLoopBackOff입니다. 대기 시간은 앞에서 설명한 대로 10초부터 두 배씩 늘어 최대 5분입니다. 대기를 두는 이유는 즉시 무한 재시작하면 노드 자원을 낭비하고, DB 재시작처럼 잠시 후 저절로 해결될 문제에 시간을 주기 위해서입니다.
중요한 점은 CrashLoopBackOff 자체는 원인이 아니라 결과라는 것입니다. 원인은 컨테이너가 종료된 이유이고, 그것은 --previous 로그와 Last State의 Exit Code에서 찾습니다.
# 현재 컨테이너: 방금 재시작돼 시작 로그만 보임
kubectl logs myapp-abc
2026-04-17 10:05:00 INFO Starting application...
# 직전 컨테이너: 크래시 원인이 보임
kubectl logs myapp-abc --previous
2026-04-17 10:04:55 INFO Starting application...
2026-04-17 10:04:56 ERROR Failed to connect to database
2026-04-17 10:04:56 ERROR Connection refused: postgresql://db:5432
2026-04-17 10:04:57 FATAL Exiting with code 1
로그에서는 ERROR, FATAL, Exception 외에 Connection refused, not set, ENOENT(파일 없음), EACCES(권한 없음) 같은 문자열을 찾습니다. 로그가 비어 있거나 중간에 끊겼다면 프로세스가 로그를 남길 틈 없이 SIGKILL로 죽었을 가능성이 크므로 Exit Code와 Reason을 확인합니다.
애플리케이션 에러 (Exit Code 1)
kubectl logs myapp-abc --previous
# TypeError: Cannot read properties of undefined (reading 'name')
# at /app/server.js:45:20
처리되지 않은 예외가 프로세스를 종료시킨 경우입니다. 스택 트레이스가 가리키는 코드를 고치는 것이 해결책이며, 요청 처리 중의 예외가 프로세스 전체를 죽이지 않도록 경계에서 예외를 잡아 오류 응답으로 바꿔야 합니다.
// user가 없으면 TypeError로 프로세스가 죽을 수 있음
const user = await getUser(userId);
console.log(user.name);
// 없는 경우를 명시적으로 처리
const user = await getUser(userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
환경 변수 누락 (Exit Code 1)
kubectl logs myapp-abc --previous
# Error: Environment variable DATABASE_URL is not set
# Pod 스펙에 정의된 환경 변수 확인
kubectl describe pod myapp-abc | grep -A 10 "Environment:"
# 실행 중인 컨테이너의 실제 환경 (컨테이너가 떠 있을 때만)
kubectl exec myapp-abc -- env | grep DATABASE
Deployment 매니페스트에 추가하는 것이 기본입니다.
spec:
template:
spec:
containers:
- name: myapp
env:
- name: DATABASE_URL
valueFrom:
configMapKeyRef:
name: app-config
key: DATABASE_URL
- name: API_KEY
valueFrom:
secretKeyRef:
name: api-secret
key: api-key
급히 확인할 때는 kubectl set env deployment/myapp DATABASE_URL=...로 바로 넣을 수 있지만, 이렇게 바꾼 내용은 다음 kubectl apply나 GitOps 동기화 때 매니페스트 값으로 덮어써지므로 매니페스트에도 반영해야 합니다. 환경 변수를 바꾸면 Pod 템플릿이 바뀌므로 새 Pod로 롤아웃됩니다. 반면 ConfigMap 내용만 바꾸면 환경 변수로 주입된 값은 이미 실행 중인 Pod에 반영되지 않으므로 kubectl rollout restart deployment/myapp이 필요합니다.
참조한 ConfigMap/Secret이 없는 경우 (CreateContainerConfigError)
환경 변수가 존재하지 않는 ConfigMap이나 Secret, 또는 그 안의 없는 키를 참조하면 컨테이너가 아예 만들어지지 않습니다. 이때 STATUS는 CrashLoopBackOff가 아니라 CreateContainerConfigError이고, 컨테이너가 시작된 적이 없으므로 kubectl logs에는 아무것도 없습니다.
kubectl describe pod myapp-abc
# Warning Failed ... Error: configmap "app-config" not found
ConfigMap/Secret을 같은 네임스페이스에 만들면 kubelet이 재시도하며 컨테이너가 생성됩니다. 선택 사항인 값이라면 참조에 optional: true를 붙여 없어도 시작되게 할 수 있습니다.
kubectl create configmap app-config \
--from-literal=DATABASE_URL=postgresql://postgres:5432/mydb
kubectl create secret generic api-secret \
--from-literal=api-key=your-api-key
볼륨으로 마운트한 ConfigMap/Secret이 없으면 ContainerCreating에 머물며 FailedMount 이벤트가 남습니다.
의존 서비스 연결 실패 (Exit Code 1)
kubectl logs myapp-abc --previous
# Error: connect ECONNREFUSED 10.96.120.15:5432
# Error: getaddrinfo ENOTFOUND postgres
ENOTFOUND는 DNS 이름을 풀지 못한 것이고, ECONNREFUSED는 이름은 풀렸지만 그 주소에서 포트를 받는 대상이 없다는 뜻입니다. 둘을 구분하면 확인할 대상이 달라집니다.
# Service가 있는지, 엔드포인트(실제 Pod IP)가 붙어 있는지
kubectl get svc postgres
kubectl get endpointslices -l kubernetes.io/service-name=postgres
# 같은 네임스페이스에서 이름 풀이와 포트 연결 확인 (디버그용 임시 Pod)
kubectl run tmp-debug --rm -it --image=busybox:1.36 --restart=Never -- \
sh -c 'nslookup postgres && nc -zv -w 3 postgres 5432'
# DB Pod 상태
kubectl get pods -l app=postgres
Service는 있는데 엔드포인트가 비어 있다면 Service의 selector가 DB Pod 레이블과 맞지 않거나, DB Pod가 Ready가 아닌 상태입니다. 다른 네임스페이스의 Service라면 postgres.<namespace>.svc.cluster.local처럼 네임스페이스를 포함한 이름을 써야 합니다. NetworkPolicy가 있는 클러스터라면 트래픽이 정책에 막혔을 수도 있습니다. 애플리케이션 이미지에는 nslookup, nc, curl이 없는 경우가 많으므로 위처럼 임시 Pod를 띄우거나 kubectl debug로 디버그 컨테이너를 붙이는 편이 확실합니다.
DB가 늦게 뜨는 것이 원인이라면 두 가지 방법이 있습니다. Init 컨테이너로 시작 전에 기다리게 할 수 있습니다.
spec:
initContainers:
- name: wait-for-db
image: busybox:1.36
command: ['sh', '-c']
args:
- |
until nc -z -w 2 postgres 5432; do
echo "Waiting for postgres..."
sleep 2
done
다만 Init 컨테이너는 시작 시점에만 확인하므로, 실행 중에 DB가 재시작되는 상황까지 대비하려면 애플리케이션이 연결 실패 시 재시도하도록 만드는 편이 근본적입니다.
async function connectWithRetry(maxRetries = 10) {
for (let i = 0; i < maxRetries; i++) {
try {
return await connectDB();
} catch (error) {
const delay = Math.min(1000 * 2 ** i, 30000); // 지수 백오프, 최대 30초
console.error(`DB connection attempt ${i + 1} failed: ${error.message}`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error('Failed to connect to database after retries');
}
메모리 초과 (OOMKilled, Exit Code 137)
kubectl describe pod myapp-abc
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
kubectl get pod myapp-abc -o jsonpath='{.spec.containers[0].resources}'
# {"limits":{"memory":"512Mi"},"requests":{"memory":"256Mi"}}
컨테이너의 메모리 사용량이 limits.memory에 도달하면 커널의 cgroup OOM killer가 프로세스를 죽입니다. kubectl top pod는 metrics-server가 수십 초 간격으로 수집한 값이라 순간적인 급증은 보이지 않을 수 있으므로, 사용량 그래프가 limit보다 낮게 보여도 OOMKilled라면 짧은 피크가 있었다고 봐야 합니다.
해결 방향은 세 가지입니다. 실제로 그만큼의 메모리가 필요한 워크로드라면 limit을 늘립니다. 요청이 들어올 때마다 커지는 메모리가 있다면 누수를 고칩니다. 예를 들어 요청마다 키를 추가하고 지우지 않는 모듈 수준 캐시는 시간이 지나면 반드시 limit에 도달하므로, 크기 상한과 TTL이 있는 캐시로 바꿉니다.
// 상한 없는 캐시: 계속 쌓임
const cache = {};
app.get('/api/data/:id', (req, res) => {
cache[req.params.id] = loadLargeData(req.params.id);
});
// 항목 수와 TTL 상한이 있는 캐시 (lru-cache v7 이상)
const { LRUCache } = require('lru-cache');
const lru = new LRUCache({ max: 500, ttl: 1000 * 60 * 60 });
런타임이 컨테이너 limit을 모르고 메모리를 잡는 경우도 있습니다. Node.js라면 --max-old-space-size로 V8 힙 상한을 limit보다 작게 정합니다. 힙 외에도 스택, 버퍼, 네이티브 모듈이 메모리를 쓰므로 limit과 같은 값으로 두면 여전히 OOM이 납니다.
resources:
requests:
memory: "512Mi"
limits:
memory: "1Gi"
env:
- name: NODE_OPTIONS
value: "--max-old-space-size=768" # 1Gi limit 중 힙에 768MB, 나머지는 여유분
JVM은 JDK 10 이후 컨테이너 limit을 인식하므로 -XX:MaxRAMPercentage로 비율을 정하는 편이 관리하기 쉽습니다.
Liveness Probe 실패
kubectl describe pod myapp-abc
Warning Unhealthy Liveness probe failed: HTTP probe failed with statuscode: 500
Normal Killing Container myapp failed liveness probe, will be restarted
liveness probe가 failureThreshold번 연속 실패하면 kubelet이 컨테이너를 재시작합니다. 흔한 원인은 세 가지입니다. 헬스체크 엔드포인트가 실제로 에러를 내는 경우, 애플리케이션이 시작을 마치기 전에 probe가 시작되는 경우, 응답이 timeoutSeconds보다 늦는 경우입니다.
# 컨테이너 안에서 직접 호출 (이미지에 curl이 있을 때)
kubectl exec myapp-abc -- curl -sv http://localhost:8080/health
# 또는 로컬에서 포트포워딩 후 호출
kubectl port-forward pod/myapp-abc 8080:8080
curl -v http://localhost:8080/health
시작이 느린 애플리케이션에 initialDelaySeconds를 크게 주는 방법도 있지만, 시작 시간은 환경에 따라 달라 맞추기 어렵습니다. startup probe를 두면 이것이 성공할 때까지 liveness와 readiness probe가 시작되지 않으므로, 시작에는 넉넉한 시간을 주면서 실행 중에는 빠르게 장애를 감지할 수 있습니다.
startupProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 5
failureThreshold: 30 # 최대 150초까지 시작을 기다림
livenessProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: 8080
periodSeconds: 5
liveness 엔드포인트에서 DB 같은 외부 의존성을 확인하는 것도 흔한 실수입니다. DB가 잠시 느려지면 모든 Pod의 liveness가 동시에 실패해 전부 재시작되고, 재시작한다고 DB 문제가 풀리지도 않습니다. liveness는 프로세스 자체가 응답할 수 있는지만 보고, 의존성 확인은 readiness에서 해서 트래픽만 잠시 빼는 편이 맞습니다.
// liveness: 프로세스가 응답하는지만 확인
app.get('/health', (req, res) => res.send('OK'));
// readiness: 의존성까지 확인, 실패하면 Service에서 잠시 제외
app.get('/ready', async (req, res) => {
const ok = await db.ping().catch(() => false);
if (!ok) return res.status(503).send('Not ready');
res.send('Ready');
});
파일 권한 문제 (Exit Code 126, EACCES)
kubectl logs myapp-abc --previous
# /app/start.sh: Permission denied
# 또는
# Error: EACCES: permission denied, open '/app/data/file.txt'
스크립트에 실행 권한이 없다면 이미지를 빌드할 때 부여합니다.
COPY --chmod=755 start.sh /app/start.sh
CMD ["/app/start.sh"]
비루트 사용자로 실행하는 컨테이너가 이미지 안의 root 소유 디렉터리에 쓰려는 경우라면, Dockerfile에서 chown으로 소유자를 바꾸거나 쓰기 대상 경로에 볼륨을 마운트합니다. securityContext.fsGroup은 마운트된 볼륨의 그룹 소유권을 바꿔 주는 설정이라 이미지 파일시스템의 권한에는 영향을 주지 않습니다.
securityContext:
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000 # 아래 볼륨의 그룹 소유권을 1000으로
containers:
- name: myapp
volumeMounts:
- name: data
mountPath: /app/data
volumes:
- name: data
emptyDir: {}
명령을 찾을 수 없음 (Exit Code 127)
kubectl logs myapp-abc --previous
# /bin/sh: node: not found
command나 args에 쓴 실행 파일이 이미지에 없거나 PATH에 없는 경우입니다. 베이스 이미지를 바꾸면서 바이너리 위치가 달라졌거나, distroless처럼 셸이 없는 이미지에서 sh -c를 쓴 경우에도 같은 증상이 납니다. 이미지 안에 실제로 무엇이 있는지 확인하고 절대 경로를 쓰거나 Dockerfile의 ENTRYPOINT/CMD를 맞춥니다.
크래시하는 컨테이너 안을 들여다보기
CrashLoopBackOff인 컨테이너는 금방 죽기 때문에 kubectl exec로 들어가기 어렵습니다. 이때는 kubectl debug로 명령을 바꾼 사본 Pod를 만들면 원래 Pod를 건드리지 않고 같은 이미지와 설정으로 셸을 열 수 있습니다.
# 원본 Pod를 복사하되, myapp 컨테이너의 명령을 셸로 바꿔 실행
kubectl debug myapp-abc -it --copy-to=myapp-debug --container=myapp -- sh
# 확인이 끝나면 사본 삭제
kubectl delete pod myapp-debug
이미지에 셸이 없다면 --image=busybox와 --share-processes로 디버그용 컨테이너를 붙이는 방식을 씁니다. 매니페스트의 명령을 node server.js || sleep 3600처럼 바꿔 컨테이너를 살려 두는 방법도 있지만, 실수로 그대로 배포되면 장애를 숨기게 되므로 임시 확인에만 씁니다.
ImagePullBackOff 해결
kubelet이 이미지를 받지 못하면 먼저 ErrImagePull이 되고, 재시도 사이 대기 중에는 ImagePullBackOff가 됩니다. 원인은 Events의 메시지에 거의 그대로 나옵니다.
kubectl describe pod <pod-name>
Events:
Normal Pulling Pulling image "registry.example.com/myapp:v1.0.0"
Warning Failed Failed to pull image "registry.example.com/myapp:v1.0.0": ... not found
Warning Failed Error: ErrImagePull
Normal BackOff Back-off pulling image "registry.example.com/myapp:v1.0.0"
Warning Failed Error: ImagePullBackOff
메시지가 not found나 manifest unknown이면 이미지 이름이나 태그가 틀린 것이고, unauthorized나 401이면 인증 문제, i/o timeout이나 no such host면 노드에서 레지스트리로 가는 네트워크나 DNS 문제입니다. 노드 아키텍처(예: arm64)에 맞는 이미지가 없을 때는 no matching manifest for linux/arm64 같은 메시지가 나옵니다.
이미지 이름과 태그
이미지가 실제로 있는지 로컬에서 확인합니다.
docker pull registry.example.com/myapp:v1.0.0
# 또는 이미지를 받지 않고 매니페스트만 확인
docker manifest inspect registry.example.com/myapp:v1.0.0
태그가 없다면 CI가 실제로 푸시한 태그를 확인해 매니페스트를 고칩니다. 존재하는 latest로 바꾸는 것은 해결책이 아닙니다. latest는 시점마다 다른 이미지를 가리킬 수 있어 어떤 버전이 배포됐는지 알 수 없게 되고, 롤백도 어려워집니다. 버전 태그나 다이제스트(@sha256:...)로 고정하는 편이 안전합니다.
프라이빗 레지스트리 인증
kubectl create secret docker-registry regcred \
--docker-server=registry.example.com \
--docker-username=myuser \
--docker-password='<token>' \
-n <pod와 같은 네임스페이스>
spec:
imagePullSecrets:
- name: regcred
containers:
- name: myapp
image: registry.example.com/myapp:v1.0.0
imagePullSecret은 Pod와 같은 네임스페이스에 있어야 합니다. 네임스페이스의 모든 Pod에 적용하려면 ServiceAccount의 imagePullSecrets에 추가하는 방법도 있습니다. --docker-server 값이 이미지 이름의 레지스트리 호스트와 정확히 일치하지 않으면 인증 정보가 쓰이지 않는다는 점도 자주 놓칩니다.
imagePullPolicy
imagePullPolicy: Always # 시작할 때마다 레지스트리 확인
imagePullPolicy: IfNotPresent # 노드에 없을 때만 받음
imagePullPolicy: Never # 노드에 있는 이미지만 사용
imagePullPolicy를 지정하지 않으면 태그가 latest이거나 생략된 경우 Always, 그 외에는 IfNotPresent가 기본값입니다. 같은 태그로 이미지를 다시 푸시했는데 반영되지 않는다면 IfNotPresent 때문에 노드의 옛 이미지가 쓰이고 있는 것입니다. kind나 minikube에서 로컬 빌드 이미지를 쓸 때 Never나 IfNotPresent를 지정하지 않으면 레지스트리에서 받으려다 실패합니다.
Pending 상태 해결
Pending의 원인은 Events의 FailedScheduling 메시지에 노드별 사유로 요약되어 나옵니다.
kubectl describe pod <pod-name>
Events:
Warning FailedScheduling default-scheduler 0/3 nodes are available: 3 Insufficient cpu. preemption: 0/3 nodes are available: 3 No preemption victims found for incoming pod.
리소스 부족
스케줄러는 노드의 실제 사용량이 아니라 이미 배정된 Pod들의 requests 합계를 기준으로 판단합니다. 그래서 kubectl top nodes에서 CPU 사용률이 낮게 보여도 requests 합계가 노드 할당 가능량에 차 있으면 새 Pod는 들어가지 못합니다. 확인해야 할 것은 kubectl describe node의 Allocated resources입니다.
kubectl describe node node1 | grep -A 8 "Allocated resources"
Allocated resources:
Resource Requests Limits
-------- -------- ------
cpu 1850m (92%) 3 (150%)
memory 6Gi (80%) 9Gi (120%)
해결 방법은 Pod의 requests를 실제 사용량에 맞게 줄이거나, 노드를 추가하거나(클러스터 오토스케일러가 있다면 자동), 필요 없는 워크로드를 정리하는 것입니다. requests를 줄일 때는 kubectl top pods로 실제 사용량을 보고 정합니다. 실제 사용량보다 지나치게 낮추면 노드가 과밀해져 CPU 스로틀링이나 메모리 부족으로 인한 축출(eviction)이 생깁니다.
resources:
requests:
cpu: "250m"
memory: "256Mi"
PVC 바인딩 실패
kubectl get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS
myapp-pvc Pending fast
kubectl describe pvc myapp-pvc
Warning ProvisioningFailed storageclass.storage.k8s.io "fast" not found
존재하지 않는 StorageClass를 참조했다면 kubectl get storageclass로 있는 이름을 확인해 고칩니다. StorageClass의 volumeBindingMode가 WaitForFirstConsumer라면 PVC는 Pod가 스케줄될 때까지 Pending인 것이 정상이므로, 이 경우에는 PVC가 아니라 Pod의 스케줄링 이벤트를 봐야 합니다. 이미 특정 가용 영역에 만들어진 볼륨은 그 영역의 노드에만 붙을 수 있어서, 해당 영역에 여유 노드가 없으면 volume node affinity conflict로 Pending이 됩니다.
노드 선택 실패와 Taint
Events:
Warning FailedScheduling 0/3 nodes are available: 3 node(s) didn't match Pod's node affinity/selector.
nodeSelector:
disktype: ssd # 이 레이블을 가진 노드가 없으면 스케줄되지 않음
kubectl get nodes --show-labels로 레이블을 확인해 nodeSelector나 affinity를 고치거나 노드에 레이블을 붙입니다. node(s) had untolerated taint 메시지라면 노드의 taint(kubectl describe node의 Taints)를 Pod가 견디지(tolerate) 못하는 경우로, 그 노드에 올려야 하는 워크로드라면 toleration을 추가하고 아니라면 다른 노드를 확보합니다.