Skip to content
Published on

Kubernetes ImagePullBackOff 与 ErrImagePull 完全解剖 — 一个原因字符串就能收尾

分享
Authors

开篇 — 镜像名明明是对的,Pod 却起不来

部署之后 Pod 就卡在这个状态。

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 的 Pod,一个是 ImagePullBackOff,另一个是 ErrImagePull。镜像名反复看了好几遍,并没有拼写错误。到这一步大多数人会打开镜像仓库的 UI 用眼睛核对标签,但其实没有必要。kubelet 已经把失败的理由原样以字符串的形式留在了事件里。

ErrImagePull 与 ImagePullBackOff 是同一事件的两个阶段

两个状态之间的关系很简单。

  • kubelet 尝试下载镜像
  • 失败时容器状态的 Reason 变成 ErrImagePull,同时记录一条带有失败原因的 Failed 事件
  • kubelet 过一会儿会重试。在这段等待区间里 Reason 变成 ImagePullBackOff
  • 重试再次失败时等待时间翻倍。从 10 秒开始,最多固定在 5 分钟

也就是说 ErrImagePull 是失败发生的瞬间,而 ImagePullBackOff 是两次失败之间的等待。同一个 Deployment 的 Pod 之所以看上去处于不同状态,原因也在这里。差别只在于你查询的那个时刻落在各个 Pod 重试周期的哪个位置。

由此可以得出一个在实际工作中很重要的结论。修正镜像仓库那一侧之后,不必等最多 5 分钟,直接删掉 Pod 更快。新的 Pod 没有退避历史,会立刻尝试第一次拉取。

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解码 Secret 后检查 Pod 规格创建 Secret 并挂到 ServiceAccount,重建 Pod
Secret 在别的命名空间401 Unauthorized(凭据根本没有传过去)kubectl get secret -n 目标命名空间在同一个命名空间里创建 Secret
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 的节点还会拒绝调度新的 Pod。镜像 GC 的阈值通过 kubelet 配置里的 imageGCHighThresholdPercent 和 imageGCLowThresholdPercent 调整。默认值分别是 85 和 80。

私有镜像仓库认证 — 确认它挂上了比创建它更难

如果出现了 401,就按顺序检查。

先创建 Secret。陷阱在于 --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 的话,Secret 会被创建出来,但认证挂不上去。

创建好之后,把内容解码出来用眼睛确认一遍。

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="
    }
  }
}

接下来才是真正的陷阱。即使创建了 Secret,只要 Pod 没有收到使用它的指示,就什么都不会发生。做法有两种,一种是直接写进 Pod 规格,另一种是挂到 ServiceAccount 上,而 Helm chart 往往不暴露 imagePullSecrets 这个值,所以实际工作中后者更有用。

kubectl patch serviceaccount default -n payments \
  -p '{"imagePullSecrets": [{"name": "regcred"}]}'

serviceaccount/default patched

大多数人卡在这里。ServiceAccount 上的 imagePullSecrets 是在 Pod 被创建的那一刻复制进 Pod 规格的。它不会追溯应用到已经在运行的 Pod 上。打完补丁之后必须重新创建 Pod。

kubectl rollout restart deployment/checkout -n payments

然后确认它是否真的进入了 Pod 规格。这一行就能解决大半的「明明配置了却不生效」。

kubectl get pod -n payments -l app=checkout \
  -o jsonpath='{.items[0].spec.imagePullSecrets}'

[{"name":"regcred"}]

整理成检查清单就是四项。

  • Secret 是否在与 Pod 相同的命名空间里。Secret 不会跨越命名空间
  • --docker-server 的值是否与镜像引用中的主机在字符串层面一致
  • Pod 规格或者 Pod 所使用的 ServiceAccount 上是否真的挂着 imagePullSecrets
  • Pod 是否真的在用那个 ServiceAccount。默认值是 default,但 chart 有可能创建了专用的 ServiceAccount
kubectl get pod -n payments -l app=checkout \
  -o jsonpath='{.items[0].spec.serviceAccountName}'

checkout-sa

如果只给 default 打了补丁,就会在这里对不上。

最后必须记住云镜像仓库的令牌过期问题。AWS ECR 的认证令牌在 12 小时后过期,所以用 kubectl create secret 创建的静态 Secret 一定会在某个时刻返回 401。正确答案是使用基于节点 IAM 角色的认证或者刷新控制器,静态 Secret 只用于临时诊断。

常见的错误答案:SSH 登录到节点上执行 docker login。大多数集群用 containerd 作为运行时,所以 Docker 的凭据根本不会被引用,就算生效了,节点被替换的那一刻也会消失。

本地能跑、集群里却不行的两件事

架构不匹配

在 Apple Silicon 的 Mac 上执行 docker build,默认产物是 linux/arm64。把它放到由 amd64 节点组成的集群上,会出现两种不同的症状。区分清楚是哪一种很重要。

如果镜像是 manifest list 清单列表,却没有与节点平台匹配的条目,就会在拉取阶段失败。

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 .

如果是混合架构的集群,也可以给 Pod 加上约束,让它只调度到匹配的节点上。

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 默认值制造的陷阱

不显式指定的话,Kubernetes 会看着标签来决定。

  • 标签是 latest 或者根本没有标签时是 Always
  • 其他所有标签都是 IfNotPresent
  • 用摘要指定时是 IfNotPresent

第二条规则会制造事故。在会覆盖可变标签的流水线里,已经缓存过那个标签的节点不会去拉取新镜像。结果就是同一个 Deployment 的多个 Pod 在运行互不相同的代码,变成一个无法复现的 bug。按节点比较一下实际的镜像 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 来解决这个问题。症状确实消失了,但节点每次启动 Pod 都要去镜像仓库查询清单,于是完全暴露在速率限制和镜像仓库故障之下。根本的解决办法是让标签不可变,并用摘要固定。

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"

因为 Pod 根本起不来,所以靠应用指标绝对捕捉不到。必须把规则挂在 kube-state-metrics 这一侧。

结语 — 读懂原因字符串就不需要猜测

ImagePullBackOff 是状态列里出现得最频繁的,但同时也是能最快结束的问题。describe 的 Warning Failed 那一行原样写着它是 not found、是 401、是 toomanyrequests、是 no match for platform,还是 no space left on device,而这五个字符串覆盖了大部分的原因分支。

要记住的一句话是这样的。ImagePullBackOff 的诊断不是推理而是阅读理解,需要读的那句话已经在事件里了。