Kubernetes: Pod이 ImagePullBackOff에서 멈춤
안녕하세요, BlueByte입니다. ImagePullBackOff에서 벗어나지 못하는 Pod는 크래시가 난 게 아닙니다 — 스케줄링은 정상이었지만 kubelet이 컨테이너 이미지를 가져오지 못해 계속 재시도하며 back-off하는 상태입니다. 증상은 Pod가 0/1에 멈춘 채 STATUS: ImagePullBackOff(조금 전에는 ErrImagePull)로 표시되는 것입니다. 오늘은 이 두 상태가 뜻하는 것, Events를 읽어 진짜 원인을 찾는 법, 원인별 해결, 그리고 예방까지 하나씩 짚어보겠습니다.
ImagePullBackOff와 ErrImagePull이 알려주는 것
kubectl get pods에 관련된 두 상태가 나타납니다:
NAME READY STATUS RESTARTS AGE
web-6d4b8f9c7c-2xk9p 0/1 ImagePullBackOff 0 3mErrImagePull은 즉각적인 실패입니다: kubelet이 컨테이너 런타임에 이미지 pull을 요청했고 그 pull이 실패했습니다. ImagePullBackOff는 그 뒤에 이어지는 상태로, 실패가 반복되면 kubelet이 다시 시도하기 전에 대기하며 매번 지연을 늘려 최대 5분까지 갑니다. 둘 다 애플리케이션이 깨졌다는 뜻은 아닙니다. 이미지가 도착하지 못해 컨테이너가 아예 시작되지 않은 것뿐입니다.
kubelet이 이미지를 가져오지 못하는 이유
pull은 몇 가지 구체적인 이유 중 하나로 실패합니다:
- 이미지 이름이나 레지스트리가 틀림 — 오타이거나, 레지스트리 접두사가 빠져 Docker Hub로 기본 처리됨.
- 태그가 존재하지 않음 — 이미지는 실제로 있지만
:1.2.3을 푸시한 적이 없거나,:1.2.3을 의도했는데:1.23으로 입력함. - 이미지가 프라이빗인데 Pod에 자격 증명이 없음 — 레지스트리가 authorization 오류로 응답함.
- 레지스트리 레이트 리밋에 걸림(Docker Hub는 익명 pull을 제한) — 지금은 pull이 거부됨.
imagePullPolicy: Never가 설정됐지만 이미지가 노드에 이미 존재하지 않음.
Events를 읽어 진짜 원인 확인하기
추측하지 마세요 — kubectl describe 아래쪽 Events가 정확한 실패를 알려줍니다:
kubectl describe pod web-6d4b8f9c7c-2xk9pEvents:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning Failed 30s (x4 over 2m) kubelet Failed to pull image "myrepo/app:1.2.3":
manifest unknown: manifest unknown
Warning Failed 30s (x4 over 2m) kubelet Error: ErrImagePull
Normal BackOff 5s (x6 over 2m) kubelet Back-off pulling image "myrepo/app:1.2.3"Message가 전부입니다. manifest unknown은 태그가 없다는 뜻, pull access denied나 unauthorized는 프라이빗인데 인증이 안 됐다는 뜻, no such host는 레지스트리 이름이 틀렸다는 뜻입니다. 노드나 로컬에서 같은 이미지를 직접 pull해 확인해 보세요:
docker pull myrepo/app:1.2.3같은 방식으로 실패하면 문제는 Kubernetes가 아니라 이미지나 자격 증명입니다.
원인별로 고치기: 이름·태그·프라이빗 레지스트리
- 이름이나 태그가 틀림 — 레지스트리에 태그가 있는지 먼저 확인하고, 참조를 고쳐 다시 적용합니다:
kubectl set image deployment/web app=myrepo/app:1.4.0- 프라이빗 레지스트리 — pull secret을 만들어 Pod 템플릿에 연결합니다:
kubectl create secret docker-registry regcred \
--docker-server=registry.example.com \
--docker-username=<user> \
--docker-password=<token>spec:
imagePullSecrets:
- name: regcred- Docker Hub 레이트 리밋 — 같은 secret 방식으로 인증하거나, 이미지를 자체 레지스트리로 미러링합니다.
실제 사례: pull secret 없는 프라이빗 이미지
registry.example.com/team/api:2.1.0을 배포했는데 Pod가 ImagePullBackOff에 멈춥니다. kubectl describe pod는 Failed to pull image ...: pull access denied, repository does not exist or may require authorization을 보여줍니다. 이미지는 실제로 있고 푸시됐으니 authorization 문제입니다. kubectl create secret docker-registry regcred ...로 pull secret을 만들고, Deployment의 Pod 템플릿에 imagePullSecrets: [{ name: regcred }]을 추가한 뒤 kubectl rollout restart deployment/api를 실행합니다. 몇 초 안에 새 Pod가 이미지를 pull하고 1/1 Running에 도달합니다. 레지스트리는 처음부터 닿아 있었고, kubelet에 제시할 자격 증명이 없었을 뿐입니다.
Pod가 정말 Running인지 확인하기
고친 뒤 Pod가 올라오는지 지켜봅니다:
kubectl get pod -l app=api -wNAME READY STATUS RESTARTS AGE
api-7c9d5f8b6d-abcde 1/1 Running 0 12s1/1과 Running에 restart가 없으면 이미지가 pull됐고 컨테이너가 시작된 것입니다. 이제 kubectl describe pod는 Failed 대신 Pulled 이벤트를 보여줍니다.
다시 생기지 않게 예방하기
이미지를 :latest가 아니라 실제 불변 태그나 digest로 고정하세요. 그러면 재태깅되거나 삭제된 태그에 Pod가 걸리지 않습니다 — :latest면 기본 imagePullPolicy가 Always라 재시작마다 다시 pull합니다. 레지스트리 자격 증명은 프라이빗 레지스트리가 필요한 모든 워크로드가 참조하는 Secret으로 두고, Docker Hub를 대규모로 쓴다면 인증된 미러를 통해 pull해 레이트 리밋 아래로 유지하세요. 롤아웃 전에 kubectl apply --dry-run=server -f pod.yaml로 스펙을 검증해 잘못된 이미지 참조를 미리 잡으세요.
CrashLoopBackOff와 무엇이 다른가
ImagePullBackOff는 컨테이너가 실행되기 전에 생깁니다 — 이미지가 아예 도착하지 못했습니다. CrashLoopBackOff는 그 이후입니다: 이미지는 잘 pull됐고, 컨테이너가 시작됐다가 반복해서 종료됩니다. kubectl describe에 Pulled 이벤트가 보이는데도 Pod가 계속 재시작한다면 그것은 CrashLoopBackOff이며, 이미지 참조가 아니라 컨테이너 로그를 읽어야 합니다. 둘 다 같은 지수 back-off를 쓰지만, 원인은 "이미지가 로드됐는가"의 반대편에 있습니다.
관련 질문
Pod에 ImagePullBackOff가 아니라 ErrImagePull이 뜹니다. 다른 문제인가요?
같은 문제의 앞 단계입니다. ErrImagePull은 즉각적인 pull 실패이고, ImagePullBackOff는 계속 실패한 뒤 kubelet이 재시도 사이에 대기하는 상태입니다. 어느 쪽이든 kubectl describe의 같은 Events를 읽으면 됩니다.
kubectl describe에 'pull access denied'가 뜹니다.
이미지가 프라이빗인데 Pod에 자격 증명이 없습니다. kubectl create secret docker-registry로 docker-registry Secret을 만들고 Pod 템플릿의 imagePullSecrets에서 참조하세요.
'manifest unknown'이 뜨는데 이미지는 존재합니다.
리포지토리는 있지만 그 특정 태그가 없습니다. 레지스트리에서 태그 목록을 확인해 실제로 푸시된 태그로 고정하고, :1.23과 :1.2.3 같은 오타를 확인하세요.
로컬에서는 pull이 되는데 클러스터에서는 안 됩니다.
로컬은 레지스트리에 로그인돼 있고 노드는 아닙니다. imagePullSecrets로 Pod에 pull secret을 주거나, 노드의 컨테이너 런타임이 그 레지스트리 자격 증명을 갖게 하세요.
back-off는 얼마나 지속되나요?
kubelet은 재시도마다 지연을 최대 5분까지 늘리며 계속 재시도합니다 — 포기하지 않습니다. 이미지 참조나 secret을 고치면 충분하고, 다음 예약된 재시도에서 pull이 성공합니다.
참고 자료
Haneul Seo
Infrastructure engineer · 10+ years running Linux fleets
같은 카테고리 다른 글
Git: fatal: detected dubious ownership in repository
Git 2.35.2의 CVE-2022-24765 수정 이후, Git은 작업 트리나 .git 디렉터리의 소유자가 명령을 실행한 사용자와 다르면 저장소를 읽지 않습니다. 컨테이너, CI 작업, sudo 세션, 공유 드라이브에서 주로 나타납니다. 저장소가 내 것이어야 한다면 소유권을 바로잡고, 아니라면 global 설정의 safe.directory에 정확한 경로를 추가하세요 — Git은 이 설정을 저장소 자신의 설정에서는 무시합니다.
Kubernetes: Internal error occurred: failed calling webhook
admission webhook이 쓰기 요청 앞에 서 있는데 API server가 응답을 받지 못했고, 기본값인 failurePolicy: Fail이 그 침묵을 거부로 바꾼 것입니다. 메시지 끝부분이 곧 진단입니다 — context deadline exceeded는 호출이 도달하지 못한 것, no endpoints available은 떠 있는 게 없는 것, x509 줄은 API server가 webhook 인증서를 신뢰하지 않는 것입니다. 원인마다 해결이 다르고, 어느 것도 매니페스트 문제가 아닙니다.
MySQL: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
다른 트랜잭션이 쥐고 있는 행 잠금을 innodb_lock_wait_timeout 만큼 기다리다 포기한 것입니다. sys.innodb_lock_waits 가 막고 있는 세션을 지목하고 KILL 문까지 만들어 주며, blocking_query 가 NULL 이면 블로커가 열린 트랜잭션 위에서 놀고 있다는 뜻입니다. 재시도 로직이 가장 자주 틀리는 지점은 따로 있습니다. 기본값에서는 타임아웃된 문장 하나만 롤백되므로, 트랜잭션은 그대로 열린 채 앞서 잡은 잠금을 전부 쥐고 있습니다.
Redis: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk
읽기는 되는데 모든 쓰기가 거부됩니다. 마지막 백그라운드 저장이 실패했고 stop-writes-on-bgsave-error 기본값이 yes 이기 때문입니다. 진짜 원인은 로그에 적혀 있습니다 — 디스크 공간 부족, redis 사용자가 쓸 수 없는 dir, rename 시점의 read-only 마운트, 또는 fork 가 Cannot allocate memory 로 실패하는 경우입니다. 원인을 고치고 BGSAVE 한 번만 돌리면 재시작 없이 쓰기가 돌아옵니다. rdb_last_bgsave_status 가 err 에서 ok 로 바뀝니다. stop-writes-on-bgsave-error no 는 쓰기를 즉시 되살리지만 스냅샷은 여전히 실패한 상태로 두므로, 해결이 아니라 의도한 맞교환으로 다뤄야 합니다.
Node.js: FATAL ERROR: Reached heap limit — JavaScript heap out of memory (종료 코드 134)
V8 힙에는 시스템 메모리와 Node 릴리스에서 유도되는 자기만의 천장이 있고, 가진 RAM 보다 훨씬 낮은 경우가 흔합니다. 빌드나 서버가 거기 닿으면 V8 은 FATAL ERROR: Reached heap limit 과 종료 코드 134 로 스스로 중단합니다. v8.getHeapStatistics().heap_size_limit 으로 실제 한도를 읽은 뒤, 큰 작업은 --max-old-space-size(MiB)나 NODE_OPTIONS 로 올리고, 컨테이너 안에서는 cgroup 한도보다 낮게 잡고, 오래 도는 프로세스의 누수는 --heapsnapshot-near-heap-limit 으로 잡으세요. FATAL ERROR 줄 없이 137 로 끝나면 컨테이너 kill 이지 이 오류가 아닙니다.
Docker: 컨테이너 시작 직후 "exec format error" — 플랫폼이 다른 이미지, 에뮬레이터 없음, 셔뱅 없는 스크립트
컨테이너가 첫 명령에서 exec format error 로 끝납니다. 커널의 ENOEXEC, 즉 파일은 있지만 여기서는 실행할 수 없다는 뜻입니다. 실제로는 한 CPU 아키텍처에서 빌드한 이미지(Apple 실리콘 Mac은 linux/arm64 를 만듭니다)를 binfmt_misc 에 QEMU 핸들러가 없는 다른 아키텍처(x86_64 서버)에서 돌리거나, 엔트리포인트 스크립트의 첫 줄이 셔뱅이 아닌 경우입니다. uname -m, docker image inspect, ls /proc/sys/fs/binfmt_misc 로 원인을 가르고, docker buildx build --platform 을 명시(또는 두 플랫폼 매니페스트 목록 발행)하거나, 에뮬레이션이 목적이면 QEMU 등록과 --platform, 스크립트라면 #!/bin/sh 한 줄로 해결합니다.