Skip to content
Published on

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

공유하기
Authors

들어가며 — 이미지 이름은 분명히 맞는데 파드가 안 뜹니다

배포하면 파드가 이 상태에서 멈춥니다.

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 unknowncrane 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 manifestdocker 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의 진단은 추론이 아니라 독해이며, 읽어야 할 문장은 이미 이벤트에 있습니다.