开篇 — 镜像名明明是对的,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 unknown | crane 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 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 的节点还会拒绝调度新的 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 的诊断不是推理而是阅读理解,需要读的那句话已经在事件里了。
현재 단락 (1/193)
部署之后 Pod 就卡在这个状态。