- Published on
Kubernetes ImagePullBackOff와 ErrImagePull 완전 해부 — 원인 문자열 하나로 끝내기
- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며 — 이미지 이름은 분명히 맞는데 파드가 안 뜹니다
배포하면 파드가 이 상태에서 멈춥니다.
kubectl get pod -n analytics
NAME READY STATUS RESTARTS AGE
ingest-6b8c94f7d5-4tzqr 0/1 ImagePullBackOff 0 3m12s
ingest-6b8c94f7d5-9wdhm 0/1 ErrImagePull 0 3m12s
같은 Deployment의 파드인데 하나는 ImagePullBackOff, 하나는 ErrImagePull입니다. 이미지 이름을 몇 번이나 다시 봤지만 오타는 없습니다. 이 지점에서 대부분 레지스트리 UI를 열어 태그를 눈으로 확인하기 시작하는데, 그럴 필요가 없습니다. kubelet은 실패한 이유를 문자열 그대로 이벤트에 남겨 두었습니다.
ErrImagePull과 ImagePullBackOff는 같은 사건의 두 단계입니다
두 상태의 관계는 단순합니다.
- kubelet이 이미지를 내려받으려 시도한다
- 실패하면 컨테이너 상태의 Reason이 ErrImagePull이 되고, 실패 사유가 담긴 Failed 이벤트가 기록된다
- kubelet은 잠시 후 재시도한다. 이 대기 구간 동안 Reason은 ImagePullBackOff로 바뀐다
- 재시도가 또 실패하면 대기 시간이 두 배로 늘어난다. 10초에서 시작해 최대 5분에서 고정된다
즉 ErrImagePull이 실패의 순간이고 ImagePullBackOff는 그 사이의 대기입니다. 같은 Deployment의 파드가 서로 다른 상태로 보이는 이유도 여기 있습니다. 조회한 시점이 각 파드의 재시도 주기 어디쯤이었느냐의 차이일 뿐입니다.
여기서 실무적으로 중요한 결론이 나옵니다. 레지스트리 쪽을 고친 뒤에는 최대 5분을 기다릴 필요 없이 파드를 지우는 편이 빠릅니다. 새 파드는 백오프 이력 없이 즉시 첫 풀을 시도합니다.
kubectl rollout restart deployment/ingest -n analytics
Events의 마지막 한 줄에 원인이 그대로 적혀 있습니다
진단은 describe 한 번으로 시작하고, 사실상 거기서 끝납니다.
kubectl describe pod ingest-6b8c94f7d5-4tzqr -n analytics | tail -12
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 3m24s default-scheduler Successfully assigned analytics/ingest-6b8c94f7d5-4tzqr to ip-10-0-2-77
Normal Pulling 2m1s (x4 over 3m23s) kubelet Pulling image "ghcr.io/example/ingest:2.7.0"
Warning Failed 2m1s (x4 over 3m22s) kubelet Failed to pull image "ghcr.io/example/ingest:2.7.0": rpc error: code = NotFound desc = failed to pull and unpack image "ghcr.io/example/ingest:2.7.0": failed to resolve reference "ghcr.io/example/ingest:2.7.0": ghcr.io/example/ingest:2.7.0: not found
Warning Failed 2m1s (x4 over 3m22s) kubelet Error: ErrImagePull
Normal BackOff 97s (x6 over 3m22s) kubelet Back-off pulling image "ghcr.io/example/ingest:2.7.0"
Warning Failed 97s (x6 over 3m22s) kubelet Error: ImagePullBackOff
Warning Failed 줄의 Message가 전부입니다. 이 문자열만 정확히 읽으면 원인이 확정됩니다.
여러 네임스페이스를 한꺼번에 훑을 때는 이벤트를 직접 필터링하는 편이 빠릅니다.
kubectl get events -A --field-selector reason=Failed --sort-by=.lastTimestamp | tail -5
NAMESPACE LAST SEEN TYPE REASON OBJECT MESSAGE
analytics 41s Warning Failed pod/ingest-6b8c94f7d5-4tzqr Failed to pull image "ghcr.io/example/ingest:2.7.0": ... not found
payments 2m8s Warning Failed pod/checkout-5d7f8b9c4-x2klm Failed to pull image "registry.example.com/checkout:1.14.2": ... 401 Unauthorized
media 6m11s Warning Failed pod/transcode-79c5d6f8b-vn4pq Failed to pull image "redis:7.2": ... toomanyrequests: You have reached your pull rate limit.
흔한 오답: Events를 읽지 않고 이미지 이름부터 다시 확인하는 것. 이름 오타는 여덟 갈래 중 하나일 뿐이고, 나머지 일곱은 이름을 아무리 봐도 보이지 않습니다.
원인 분기와 진단
| 원인 | Events의 원인 문자열 | 진단 명령 | 해결 |
|---|---|---|---|
| 존재하지 않는 태그·이름 | not found, manifest unknown | crane manifest IMAGE | 실제 존재하는 태그로 교정 |
| 프라이빗 레지스트리 인증 실패 | 401 Unauthorized, authentication required | 시크릿 디코드 후 파드 스펙 확인 | 시크릿 생성 후 서비스어카운트에 부착, 파드 재생성 |
| 시크릿이 다른 네임스페이스 | 401 Unauthorized (인증 정보가 아예 전달 안 됨) | kubectl get secret -n 대상네임스페이스 | 같은 네임스페이스에 시크릿 생성 |
| Docker Hub 레이트 리밋 | toomanyrequests, pull rate limit | 레이트 리밋 헤더 조회 | 인증 풀 전환, 레지스트리 미러 |
| 네트워크·프록시·에어갭 | i/o timeout, no such host, connection refused | 노드에서 직접 요청 | 프록시 환경변수, 미러, 내부 레지스트리 |
| 사설 CA 미신뢰 | x509 certificate signed by unknown authority | 노드의 CA 번들 확인 | 노드에 CA 배포, containerd 설정 |
| 아키텍처 불일치 | no match for platform in manifest | docker buildx imagetools inspect | 멀티 아키텍처 빌드 또는 노드 선택 |
| 노드 디스크 부족 | no space left on device | 노드 DiskPressure 컨디션 | 이미지 GC 임계값 조정, 디스크 증설 |
존재하지 않는 태그
가장 단순하지만 가장 자주 나옵니다. CI가 이미지를 푸시하기 전에 매니페스트를 먼저 적용했거나, 태그 규칙이 바뀌었거나, 태그가 정리 정책에 의해 삭제된 경우입니다. 클러스터에 들어가기 전에 확인할 수 있습니다.
crane ls ghcr.io/example/ingest | tail -5
2.6.4
2.6.5
2.7.0-rc1
2.7.1
crane manifest ghcr.io/example/ingest:2.7.0
Error: fetching manifest ghcr.io/example/ingest:2.7.0: GET https://ghcr.io/v2/example/ingest/manifests/2.7.0: MANIFEST_UNKNOWN
2.7.0은 없고 2.7.0-rc1과 2.7.1이 있습니다. 릴리스 파이프라인이 rc 접미사를 떼지 못한 것입니다.
노드 디스크 부족
의외로 자주 놓치는 갈래입니다. 노드 디스크가 차면 kubelet의 이미지 GC가 돌지만, 그보다 큰 이미지를 받으려 하면 풀이 실패합니다.
kubectl describe node ip-10-0-2-77 | grep -A8 "Conditions:"
Conditions:
Type Status LastTransitionTime Reason Message
---- ------ ------------------ ------ -------
MemoryPressure False Sat, 25 Jul 2026 22:10:44 +0900 KubeletHasSufficientMemory kubelet has sufficient memory available
DiskPressure True Sun, 26 Jul 2026 08:52:03 +0900 KubeletHasDiskPressure kubelet has disk pressure
PIDPressure False Sat, 25 Jul 2026 22:10:44 +0900 KubeletHasSufficientPID kubelet has sufficient PID available
Ready True Sat, 25 Jul 2026 22:10:54 +0900 KubeletReady kubelet is posting ready status
DiskPressure가 True인 노드는 새 파드 스케줄링도 거부합니다. 이미지 GC 임계값은 kubelet 설정의 imageGCHighThresholdPercent와 imageGCLowThresholdPercent로 조정합니다. 기본값은 각각 85와 80입니다.
프라이빗 레지스트리 인증 — 만드는 것보다 붙었는지 확인하는 게 어렵습니다
401이 떴다면 순서대로 확인합니다.
먼저 시크릿을 만듭니다. --docker-server 값이 이미지 참조의 레지스트리 호스트와 정확히 일치해야 한다는 점이 함정입니다.
kubectl create secret docker-registry regcred \
--docker-server=registry.example.com \
--docker-username=deploy-bot \
--docker-password="$(cat ~/.registry-token)" \
--namespace=payments
Docker Hub는 예외입니다. 이미지 참조가 nginx:1.25처럼 짧아도 실제 호스트는 docker.io로 정규화되며, 인증 서버 값은 https://index.docker.io/v1/을 써야 합니다. 여기에 docker.io를 넣으면 시크릿은 만들어지지만 인증이 붙지 않습니다.
만들었으면 내용을 디코드해서 눈으로 확인합니다.
kubectl get secret regcred -n payments \
-o jsonpath='{.data.\.dockerconfigjson}' | base64 -d | jq .
{
"auths": {
"registry.example.com": {
"username": "deploy-bot",
"password": "glpat-xxxxxxxxxxxx",
"auth": "ZGVwbG95LWJvdDpnbHBhdC14eHh4eHh4eHh4eHg="
}
}
}
다음이 진짜 함정입니다. 시크릿을 만들었어도 파드가 그것을 쓰라는 지시를 받지 않으면 아무 일도 일어나지 않습니다. 파드 스펙에 직접 넣는 방법과 서비스어카운트에 붙이는 방법이 있는데, 헬름 차트가 imagePullSecrets 값을 노출하지 않는 경우가 많아 실무에서는 후자가 유용합니다.
kubectl patch serviceaccount default -n payments \
-p '{"imagePullSecrets": [{"name": "regcred"}]}'
serviceaccount/default patched
여기서 대부분이 막힙니다. 서비스어카운트의 imagePullSecrets는 파드가 생성되는 순간에 파드 스펙으로 복사됩니다. 이미 떠 있던 파드에는 소급 적용되지 않습니다. 패치 후 파드를 다시 만들어야 합니다.
kubectl rollout restart deployment/checkout -n payments
그리고 실제로 파드 스펙에 들어갔는지 확인합니다. 이 한 줄이 "설정했는데 왜 안 되지"의 대부분을 해결합니다.
kubectl get pod -n payments -l app=checkout \
-o jsonpath='{.items[0].spec.imagePullSecrets}'
[{"name":"regcred"}]
체크리스트로 정리하면 네 가지입니다.
- 시크릿이 파드와 같은 네임스페이스에 있는가. 시크릿은 네임스페이스를 넘지 않습니다
--docker-server값이 이미지 참조의 호스트와 문자열로 일치하는가- 파드 스펙 또는 파드가 사용하는 서비스어카운트에 imagePullSecrets가 실제로 붙어 있는가
- 파드가 그 서비스어카운트를 쓰고 있는가. 기본값은 default이지만 차트가 전용 서비스어카운트를 만들었을 수 있습니다
kubectl get pod -n payments -l app=checkout \
-o jsonpath='{.items[0].spec.serviceAccountName}'
checkout-sa
default에만 패치했다면 여기서 어긋납니다.
마지막으로 클라우드 레지스트리의 토큰 만료를 기억해야 합니다. AWS ECR의 인증 토큰은 12시간 후 만료되므로 kubectl create secret으로 만든 정적 시크릿은 반드시 언젠가 401을 냅니다. 노드 IAM 역할 기반 인증이나 갱신 컨트롤러를 쓰는 것이 정답이고, 정적 시크릿은 임시 진단용으로만 씁니다.
흔한 오답: 노드에 SSH로 들어가 docker login을 하는 것. 대부분의 클러스터는 containerd를 런타임으로 쓰므로 도커 자격 증명이 아예 참조되지 않고, 통하더라도 노드가 교체되는 순간 사라집니다.
로컬에서는 되는데 클러스터에서 안 되는 두 가지
아키텍처 불일치
Apple Silicon 맥에서 docker build를 하면 기본 산출물은 linux/arm64입니다. 이걸 amd64 노드로 이루어진 클러스터에 올리면 두 가지 다른 증상이 나옵니다. 어느 쪽인지 구분하는 게 중요합니다.
이미지가 매니페스트 목록인데 노드 플랫폼에 맞는 항목이 없으면 풀 단계에서 실패합니다.
kubectl describe pod ingest-6b8c94f7d5-4tzqr -n analytics | grep "Failed to pull"
Warning Failed 8s kubelet Failed to pull image "ghcr.io/example/ingest:2.7.1": no match for platform in manifest: not found
반면 단일 아키텍처 이미지가 플랫폼 검사를 통과해 버리면 풀은 성공하고 실행 단계에서 죽습니다. 이때는 ImagePullBackOff가 아니라 CrashLoopBackOff로 나타납니다.
kubectl logs ingest-6b8c94f7d5-4tzqr -n analytics --previous
exec /usr/local/bin/ingest: exec format error
exec format error는 사실상 아키텍처 불일치의 지문입니다. 이 메시지를 보고 애플리케이션 코드를 뒤지는 것은 시간 낭비입니다.
확인은 이미지 쪽과 노드 쪽 양방향으로 합니다.
docker buildx imagetools inspect ghcr.io/example/ingest:2.7.1
Name: ghcr.io/example/ingest:2.7.1
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:2b1e7f43c9d8a0b6e2f14c7d9a3b58e0c1f26d4a8b7e930f5c2a1d6b4e83f97c
Manifests:
Name: ghcr.io/example/ingest:2.7.1@sha256:8f3c1d...
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/arm64
kubectl get nodes -o custom-columns=NAME:.metadata.name,ARCH:.status.nodeInfo.architecture
NAME ARCH
ip-10-0-1-14 amd64
ip-10-0-2-77 amd64
ip-10-0-3-91 amd64
이미지는 arm64 하나뿐이고 노드는 전부 amd64입니다. 해결은 멀티 아키텍처 빌드입니다.
docker buildx create --use --name multiarch
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/example/ingest:2.7.1 \
--push .
혼합 아키텍처 클러스터라면 파드가 맞는 노드로만 가도록 제약을 거는 방법도 있습니다.
spec:
nodeSelector:
kubernetes.io/arch: amd64
Docker Hub 레이트 리밋
퍼블릭 이미지를 쓰는데 특정 시간대에만 실패한다면 익명 풀 한도입니다. 한도는 IP 단위로 집계되므로, NAT 게이트웨이 하나를 공유하는 클러스터에서는 노드 수가 늘수록 빨리 소진됩니다.
kubectl describe pod transcode-79c5d6f8b-vn4pq -n media | grep "Failed to pull"
Warning Failed 15s kubelet Failed to pull image "redis:7.2": failed to pull and unpack image "docker.io/library/redis:7.2": failed to resolve reference "docker.io/library/redis:7.2": unexpected status from HEAD request to https://registry-1.docker.io/v2/library/redis/manifests/7.2: 429 Too Many Requests
현재 남은 한도는 직접 조회할 수 있습니다.
TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:ratelimitpreview/test:pull" | jq -r .token)
curl -s --head -H "Authorization: Bearer $TOKEN" \
https://registry-1.docker.io/v2/ratelimitpreview/test/manifests/latest | grep -i ratelimit
ratelimit-limit: 100;w=21600
ratelimit-remaining: 7;w=21600
6시간 창에 100회, 남은 건 7회입니다. 해결책은 세 가지이고 순서대로 검토합니다. 인증된 계정으로 풀하도록 imagePullSecrets를 붙이는 것이 가장 빠르고, 사내 레지스트리 미러를 두는 것이 가장 근본적이며, 자주 쓰는 베이스 이미지를 사내 레지스트리에 복제해 두는 것이 가장 안전합니다.
containerd 미러 설정은 노드에서 이렇게 잡습니다.
# /etc/containerd/certs.d/docker.io/hosts.toml
server = "https://registry-1.docker.io"
[host."https://registry-mirror.example.com/v2"]
capabilities = ["pull", "resolve"]
skip_verify = false
재발 방지 — imagePullPolicy 정리와 다이제스트 고정
imagePullPolicy 기본값이 만드는 함정
명시하지 않으면 쿠버네티스가 태그를 보고 정합니다.
- 태그가 latest이거나 태그가 아예 없으면 Always
- 그 외 모든 태그는 IfNotPresent
- 다이제스트로 지정하면 IfNotPresent
두 번째 규칙이 사고를 만듭니다. 가변 태그를 덮어쓰는 파이프라인에서, 그 태그를 이미 캐시한 노드는 새 이미지를 받지 않습니다. 결과적으로 같은 Deployment의 파드들이 서로 다른 코드를 실행하게 되고, 재현되지 않는 버그가 됩니다. 노드별로 실제 이미지 ID를 비교하면 드러납니다.
kubectl get pods -n analytics -l app=ingest \
-o custom-columns=POD:.metadata.name,NODE:.spec.nodeName,IMAGEID:.status.containerStatuses[0].imageID
POD NODE IMAGEID
ingest-6b8c94f7d5-4tzqr ip-10-0-1-14 ghcr.io/example/ingest@sha256:8f3c1d...
ingest-6b8c94f7d5-9wdhm ip-10-0-2-77 ghcr.io/example/ingest@sha256:2b1e7f...
같은 태그인데 다이제스트가 다릅니다.
흔한 오답: 이 문제를 imagePullPolicy를 Always로 바꿔 해결하는 것. 증상은 사라지지만 노드가 파드를 띄울 때마다 레지스트리에 매니페스트를 조회하므로 레이트 리밋과 레지스트리 장애에 그대로 노출됩니다. 근본 해결은 태그를 불변으로 만들고 다이제스트로 고정하는 것입니다.
spec:
containers:
- name: ingest
image: ghcr.io/example/ingest@sha256:8f3c1d5b2e7a94c0f13d68b5a2e9c47f0d81b3a65e2c9f7048d1b6a35c8e29f4
imagePullPolicy: IfNotPresent
다이제스트로 고정하면 태그가 덮어써져도 모든 노드가 동일한 바이트를 실행합니다. 롤백도 정확해집니다.
배포 전에 이미지 존재를 검증합니다
매니페스트를 적용하기 전에 이미지가 실제로 있고 인증이 통하는지 확인하는 단계를 CI에 넣으면 ImagePullBackOff의 절반이 사라집니다.
skopeo inspect \
--creds "deploy-bot:${REGISTRY_TOKEN}" \
docker://registry.example.com/checkout:1.14.2 \
--format '{{.Digest}} {{.Architecture}}'
sha256:9c1f0f4d2a8b73e5c16f9d20a4b8e7c35f1d69a20c8b4e73f5a1d92c6b8e40f7 amd64
풀 실패를 알림으로 잡습니다
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: image-pull-alerts
namespace: monitoring
spec:
groups:
- name: image-pull
rules:
- alert: ImagePullFailing
expr: kube_pod_container_status_waiting_reason{reason=~"ImagePullBackOff|ErrImagePull"} == 1
for: 5m
labels:
severity: critical
annotations:
summary: "{{ $labels.namespace }}/{{ $labels.pod }} cannot pull its image"
파드가 아예 뜨지 못하는 상태이므로 애플리케이션 지표로는 절대 잡히지 않습니다. 반드시 kube-state-metrics 쪽에 규칙을 걸어야 합니다.
마치며 — 원인 문자열을 읽으면 추측이 필요 없습니다
ImagePullBackOff는 상태 컬럼에서 가장 자주 보이지만 가장 빨리 끝낼 수 있는 문제이기도 합니다. describe의 Warning Failed 줄에 not found인지, 401인지, toomanyrequests인지, no match for platform인지, no space left on device인지가 그대로 적혀 있고, 이 다섯 문자열이 원인 분기의 대부분을 덮습니다.
기억할 한 문장은 이것입니다. ImagePullBackOff의 진단은 추론이 아니라 독해이며, 읽어야 할 문장은 이미 이벤트에 있습니다.