Split View: Kubernetes ImagePullBackOff와 ErrImagePull 완전 해부 — 원인 문자열 하나로 끝내기
Kubernetes ImagePullBackOff와 ErrImagePull 완전 해부 — 원인 문자열 하나로 끝내기
들어가며 — 이미지 이름은 분명히 맞는데 파드가 안 뜹니다
배포하면 파드가 이 상태에서 멈춥니다.
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의 진단은 추론이 아니라 독해이며, 읽어야 할 문장은 이미 이벤트에 있습니다.
Kubernetes ImagePullBackOff and ErrImagePull Fully Dissected — Ending It With One Cause String
Introduction — The Image Name Is Clearly Correct but the Pod Will Not Start
After a deploy, the pod gets stuck in this state.
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
These are pods of the same Deployment, yet one is ImagePullBackOff and the other is ErrImagePull. You have re-read the image name several times and there is no typo. At this point most people open the registry UI and start verifying the tag by eye, but that is unnecessary. The kubelet already left the reason for the failure in an event, as a literal string.
ErrImagePull and ImagePullBackOff Are Two Stages of the Same Event
The relationship between the two states is simple.
- The kubelet attempts to download the image
- If it fails, the Reason in the container status becomes ErrImagePull, and a Failed event carrying the reason is recorded
- The kubelet retries a moment later. During that waiting window the Reason changes to ImagePullBackOff
- If the retry fails again, the wait doubles. It starts at 10 seconds and is capped at 5 minutes
So ErrImagePull is the moment of failure and ImagePullBackOff is the wait in between. That is also why pods of the same Deployment appear in different states. The only difference is where in each pod retry cycle the moment of your query happened to land.
A practically important conclusion follows from this. After fixing the registry side, deleting the pod is faster than waiting up to 5 minutes. A new pod has no backoff history and attempts its first pull immediately.
kubectl rollout restart deployment/ingest -n analytics
The Cause Is Written Verbatim in the Last Line of Events
Diagnosis starts with a single describe, and in practice it ends there.
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
The Message on the Warning Failed line is everything. Read that one string precisely and the cause is settled.
When sweeping several namespaces at once, filtering the events directly is faster.
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.
A common wrong answer: re-checking the image name first without reading the Events. A name typo is only one of the eight branches, and the other seven stay invisible no matter how long you stare at the name.
Cause Branches and Diagnosis
| Cause | Cause String in Events | Diagnostic Command | Fix |
|---|---|---|---|
| Nonexistent tag or name | not found, manifest unknown | crane manifest IMAGE | Correct it to a tag that actually exists |
| Private registry authentication failure | 401 Unauthorized, authentication required | Decode the Secret, then check the pod spec | Create the Secret, attach it to the ServiceAccount, recreate the pod |
| Secret in a different namespace | 401 Unauthorized (no credentials delivered at all) | kubectl get secret -n TARGET_NAMESPACE | Create the Secret in the same namespace |
| Docker Hub rate limit | toomanyrequests, pull rate limit | Query the rate limit headers | Switch to authenticated pulls, registry mirror |
| Network, proxy, air-gapped | i/o timeout, no such host, connection refused | Send the request from the node directly | Proxy environment variables, mirror, internal registry |
| Private CA not trusted | x509 certificate signed by unknown authority | Check the CA bundle on the node | Distribute the CA to the nodes, configure containerd |
| Architecture mismatch | no match for platform in manifest | docker buildx imagetools inspect | Multi-architecture build or node selection |
| Node disk exhaustion | no space left on device | The node DiskPressure condition | Tune the image GC thresholds, expand the disk |
A Tag That Does Not Exist
The simplest branch, and the one that shows up most often. Either CI applied the manifest before pushing the image, or the tag convention changed, or the tag was deleted by a cleanup policy. You can check it before ever entering the cluster.
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
There is no 2.7.0, but there is 2.7.0-rc1 and 2.7.1. The release pipeline failed to strip the rc suffix.
Node Disk Exhaustion
A branch that is missed surprisingly often. When the node disk fills up the kubelet image GC runs, but if you then try to fetch an image larger than what it freed, the pull fails.
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
A node whose DiskPressure is True also refuses to schedule new pods. The image GC thresholds are tuned with imageGCHighThresholdPercent and imageGCLowThresholdPercent in the kubelet configuration. The defaults are 85 and 80 respectively.
Private Registry Authentication — Confirming It Attached Is Harder Than Creating It
If a 401 came up, check the following in order.
First create the Secret. The trap is that the --docker-server value must match the registry host of the image reference exactly.
kubectl create secret docker-registry regcred \
--docker-server=registry.example.com \
--docker-username=deploy-bot \
--docker-password="$(cat ~/.registry-token)" \
--namespace=payments
Docker Hub is the exception. Even when the image reference is as short as nginx:1.25, the real host is normalized to docker.io, and the authentication server value must be https://index.docker.io/v1/. Put docker.io there and the Secret is created but the authentication never attaches.
Once you have created it, decode the contents and confirm them by eye.
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="
}
}
}
Here comes the real trap. Even with the Secret created, nothing happens unless the pod is instructed to use it. There is the approach of putting it directly in the pod spec and the approach of attaching it to the ServiceAccount, and since Helm charts often do not expose an imagePullSecrets value, the latter is the useful one in practice.
kubectl patch serviceaccount default -n payments \
-p '{"imagePullSecrets": [{"name": "regcred"}]}'
serviceaccount/default patched
This is where most people get stuck. The imagePullSecrets on a ServiceAccount is copied into the pod spec at the moment the pod is created. It is not applied retroactively to pods that were already running. After the patch you have to recreate the pods.
kubectl rollout restart deployment/checkout -n payments
Then confirm that it actually made it into the pod spec. This one line resolves most of the "I configured it, so why does it not work" cases.
kubectl get pod -n payments -l app=checkout \
-o jsonpath='{.items[0].spec.imagePullSecrets}'
[{"name":"regcred"}]
Organized as a checklist, there are four items.
- Is the Secret in the same namespace as the pod. Secrets do not cross namespaces
- Does the
--docker-servervalue match the host of the image reference as a string - Is imagePullSecrets actually attached to the pod spec or to the ServiceAccount the pod uses
- Is the pod actually using that ServiceAccount. The default is default, but the chart may have created a dedicated ServiceAccount
kubectl get pod -n payments -l app=checkout \
-o jsonpath='{.items[0].spec.serviceAccountName}'
checkout-sa
If you patched only default, this is where it goes wrong.
Finally, you have to remember token expiry on cloud registries. The AWS ECR authentication token expires after 12 hours, so a static Secret created with kubectl create secret will inevitably produce a 401 at some point. Node IAM role based authentication or a refresh controller is the right answer, and a static Secret should be used only for temporary diagnosis.
A common wrong answer: SSH into the node and run docker login. Most clusters use containerd as the runtime, so the Docker credentials are never referenced at all, and even when it does work it vanishes the moment the node is replaced.
Two Things That Work Locally but Fail in the Cluster
Architecture Mismatch
Run docker build on an Apple Silicon Mac and the default artifact is linux/arm64. Push that onto a cluster made of amd64 nodes and two different symptoms appear. Telling the two apart matters.
If the image is a manifest list but has no entry matching the node platform, it fails at the pull stage.
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
By contrast, if a single-architecture image slips past the platform check, the pull succeeds and it dies at the execution stage. In that case it shows up as CrashLoopBackOff rather than ImagePullBackOff.
kubectl logs ingest-6b8c94f7d5-4tzqr -n analytics --previous
exec /usr/local/bin/ingest: exec format error
exec format error is effectively the fingerprint of an architecture mismatch. Digging through application code after seeing this message is a waste of time.
Verification goes in both directions, the image side and the node side.
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
The image is arm64 only and the nodes are all amd64. The fix is a multi-architecture build.
docker buildx create --use --name multiarch
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/example/ingest:2.7.1 \
--push .
In a mixed-architecture cluster, another option is to constrain the pod so that it only goes to matching nodes.
spec:
nodeSelector:
kubernetes.io/arch: amd64
Docker Hub Rate Limit
If you use a public image and it fails only during certain time windows, it is the anonymous pull limit. The limit is counted per IP, so in a cluster sharing a single NAT gateway it drains faster as the node count grows.
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
You can query the currently remaining limit directly.
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
100 pulls per 6-hour window, with 7 remaining. There are three remedies, and you review them in order. Attaching imagePullSecrets so that pulls run under an authenticated account is the fastest, standing up an in-house registry mirror is the most fundamental, and replicating frequently used base images into the in-house registry is the safest.
The containerd mirror configuration is set on the node like this.
# /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
Preventing Recurrence — Sorting Out imagePullPolicy and Digest Pinning
The Trap Created by the imagePullPolicy Default
If you do not state it explicitly, Kubernetes decides by looking at the tag.
- If the tag is latest or there is no tag at all, Always
- Every other tag gets IfNotPresent
- Specifying by digest gives IfNotPresent
The second rule is what causes incidents. In a pipeline that overwrites a mutable tag, a node that has already cached that tag will not fetch the new image. As a result, pods of the same Deployment end up running different code, and it becomes a bug that does not reproduce. Comparing the actual image IDs per node exposes it.
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...
Same tag, different digests.
A common wrong answer: resolving this problem by switching imagePullPolicy to Always. The symptom disappears, but the node queries the registry for the manifest every time it starts a pod, which leaves you fully exposed to rate limits and registry outages. The fundamental fix is to make the tag immutable and pin by digest.
spec:
containers:
- name: ingest
image: ghcr.io/example/ingest@sha256:8f3c1d5b2e7a94c0f13d68b5a2e9c47f0d81b3a65e2c9f7048d1b6a35c8e29f4
imagePullPolicy: IfNotPresent
With digest pinning, every node runs identical bytes even if the tag is overwritten. Rollbacks become exact too.
Verify That the Image Exists Before Deploying
Add a step to CI that checks, before applying the manifest, that the image really exists and that authentication passes, and half of your ImagePullBackOff cases disappear.
skopeo inspect \
--creds "deploy-bot:${REGISTRY_TOKEN}" \
docker://registry.example.com/checkout:1.14.2 \
--format '{{.Digest}} {{.Architecture}}'
sha256:9c1f0f4d2a8b73e5c16f9d20a4b8e7c35f1d69a20c8b4e73f5a1d92c6b8e40f7 amd64
Catch Pull Failures With Alerts
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"
Since the pod never comes up at all, application metrics will never catch this. The rule absolutely has to be placed on the kube-state-metrics side.
Conclusion — Read the Cause String and Guessing Becomes Unnecessary
ImagePullBackOff is what you see most often in the status column, but it is also the problem you can finish fastest. The Warning Failed line of describe spells out whether it is not found, 401, toomanyrequests, no match for platform, or no space left on device, and these five strings cover most of the cause branches.
The one sentence to remember is this. Diagnosing ImagePullBackOff is reading comprehension rather than inference, and the sentence you need to read is already in the events.