Kubernetes 镜像生命周期:拉取失败、节点存储与 kubelet 镜像回收
ImagePullBackOff 只说明容器启动前的一次镜像准备没有完成。它不说明镜像层是否已经下载、镜像是否留在节点上,也不说明删除 Pod 后镜像会被清理。镜像从 Pod 引用到节点存储,中间有 API 对象、kubelet、CRI 和容器运行时几层状态。
本文在 Kubernetes v1.35.1 上用 kind 验证两条路径:不存在的仓库地址如何变成 ImagePullBackOff;节点上的镜像文件系统超过 kubelet 阈值后,正在使用的镜像和未使用镜像分别怎样被判断。实验主机是 zj,使用独立单节点 kind v0.33.0 集群 article-image(kindest/node:v1.35.1,提交 8fea90b45245ef5c8ba54e7ae044d3e777c22500),containerd 2.2.1。镜像拉取、存储和回收的具体行为与运行时实现有关。
Pod 中的 image 只是一项引用
PodSpec 里的 image 是给 kubelet 的引用,不是已经存在于节点的文件。kubelet 收到绑定到本节点的 Pod 后,按容器配置决定是否拉取镜像。imagePullPolicy 决定什么时候查询仓库;策略不保证仓库一定有对应 tag,也不保证节点一定保留镜像。三个策略值和默认规则见 Images。
创建 Pod 后,API Server 只保存对象和默认值。调度器负责选择节点,kubelet 随后通过 CRI 的 PullImage 和 CreateContainer 推进节点侧工作。Pod 变成 Pending 时,需要继续看容器状态里的等待原因,而不是只看 Pod phase。
sequenceDiagram
participant U as 用户
participant A as API Server
participant S as scheduler
participant K as kubelet
participant R as containerd / CRI
participant G as 镜像仓库
U->>A: 创建 Pod,写入 image 引用
A-->>S: watch 待调度 Pod
S->>A: Binding
A-->>K: watch 本节点 Pod
K->>K: 根据 imagePullPolicy 选择拉取路径
alt 本地已有可用镜像
K->>R: 查询镜像
R-->>K: 返回镜像 ID
else 需要拉取
K->>R: PullImage
R->>G: 解析 manifest 和下载镜像层
alt 仓库或网络失败
G-->>R: 错误
R-->>K: PullImage 失败
K->>A: 更新等待原因 ErrImagePull
K->>A: 退避后更新 ImagePullBackOff
else 拉取成功
R-->>K: 镜像 ID 和引用
end
end
K->>R: CreateContainer / StartContainer
图中没有把“镜像拉取成功”画成容器已经运行。拉取只是启动条件之一,随后还有容器创建、卷挂载和启动命令。
一次失败的拉取会留下什么
实验创建了两个 Pod:一个使用本地已加载的 alpine:3.20,另一个引用 example.invalid/kubernetes-image-does-not-exist:never。无效引用最终进入等待状态:
| |
失败发生在镜像仓库主机名解析阶段,尚未进入容器创建。此时 Pod 有确定的节点,kubelet 仍在按退避策略重试;API 对象没有因为一次拉取失败而被删除。具体错误留在 Pod 事件和 kubelet 日志里,不能只凭 ImagePullBackOff 判断是凭据、网络、tag 还是仓库服务故障。
这个退避的起点是 10 秒,上限 300 秒(v1.35.1 的 imageBackOffPeriod 与 MaxImageBackOff,kubelet.go:177-225)。拉取失败后 backOff.Next 递增,成功才清零,所以同一个 Pod 上错误的镜像名会越等越久。两个等待原因的名字就说明了状态差别:ErrImagePull 表示这一次拉取失败,ImagePullBackOff 表示当前仍在退避窗口内(types.go:27-35)。
排查时先读完整的 message,再按照错误出现的位置分层:
| 观察 | 优先检查 |
|---|---|
no such host、connection refused | DNS、仓库地址、网络和代理 |
unauthorized、manifest unknown | 认证、镜像名、tag 和 digest |
failed to unpack、磁盘不足 | 节点镜像文件系统和 containerd 存储 |
| 拉取成功但容器仍不启动 | CRI 创建、卷、命令和安全上下文 |
实验日志中的错误来自 CRI PullImage,所以不能把它归类成 scheduler 或 API Server 的失败。
镜像在节点上的存在形式
拉取成功后,镜像由容器运行时保存。kind 节点上能看到 containerd 的镜像列表:
| |
实验的 images-before.json 和 images-after.json 都包含来自不同组件的基础镜像,以及正在运行 Pod 使用的 alpine:3.20。同一个镜像可以被多个容器使用,Pod 删除也不等于镜像立即删除,两者的生命周期是分开的。
实验重跑后,节点上同名 tag 出现两条记录:带 docker.io/library/article-unused:1 的 sha256:061ea9a,和不带任何 tag 的 sha256:dd649b7。两个镜像 ID 大小都是 536873292 字节。实验脚本每次运行都用随机内容重新构建这个镜像,重新构建会得到新的镜像 ID,而 kind load 更新 tag 后,旧条目只剩镜像 ID。所以同名 tag 不能证明节点上存的是哪一份内容;确认具体镜像要以镜像 ID 或 digest 为准。
kubelet 的 Image Manager 会定期列出镜像和当前 Pod 容器,建立正在使用的镜像集合。它保留镜像 ID、首次发现时间和最后使用时间等记录。容器正在使用时,镜像会获得较新的使用时间;这层判断比“有没有同名 tag”更接近实际回收条件。
GarbageCollect 还处理一类特殊镜像。运行时把某些镜像标为 pinned 后,kubelet 在排序时直接跳过,不参与 freeSpace;这段逻辑在 image_gc_manager.go 的 imagesInEvictionOrder(L547-L570)。pinned 的意义是运行时声明这块镜像不能被删,常见于 sandbox 镜像;它和“正在使用”是两种独立判断,一个镜像可以被 pin 却没有容器在跑。
ImageFS 阈值怎样触发回收
kubelet 的 ImageGC 按固定周期运行,Pod 删除不会立刻触发。v1.35.1 的 ImageGCPeriod 是 5 分钟(kubelet.go:230),重启 kubelet 会立即执行一轮。相关配置项及其默认值如下:
imageGCHighThresholdPercent:使用率达到该值后,尝试回收镜像,默认 85。imageGCLowThresholdPercent:回收目标,尽量把已使用比例降回该值以下,默认 80。imageMinimumGCAge:未使用镜像至少存在多久才可能被删除,默认 2 分钟。imageMaximumGCAge:按最长未使用时间回收,不要求磁盘使用率先超过高阈值。
前三个默认值来自 v1.35.1 的 defaults.go。imageMaximumGCAge 的默认值为 0,即不启用;它对应的功能门在 v1.35 已 GA 并 LockToDefault,字段说明见 kubelet config v1beta1。
想彻底关掉镜像回收,要同时把 imageGCHighThresholdPercent 设成 100、把 imageMaximumGCAge 设为 0;只改其中一个不会停掉 GC(kubelet.go:1641-1644)。这条判断也说明这两个配置走的是两条独立路径:阈值按磁盘使用率触发,最大存在时间按镜像年龄触发,任何一条成立都会删镜像。
在 v1.35.1 的 image_gc_manager.go 中,GarbageCollect 的顺序值得单独说。它先按淘汰顺序排好镜像,再进 freeOldImages(L425-L452),最后才读 ImageFS 统计、计算使用率。freeOldImages 只做一件事:MaxAge 不为 0 且镜像的最后使用时间超过它,就删除;它不看磁盘用得多满。
后面才是阈值分支(L383-L402)。超过高阈值时,amountToFree = capacity*(100-low)/100 - available,也就是“要释放到低阈值还差多少”,然后按最后使用时间和首次发现时间排序,跳过正在使用和 pinned 的镜像,逐个尝试删除,直到释放够或没有可删镜像。
这里有两处容易误读:高阈值触发的只是“尝试”,不保证真能释放足够空间;正在使用的镜像不会被当作普通未使用镜像删除,MinAge 还会挡住刚拉取、可能马上要用的镜像。
GarbageCollect 失败时不是静默的。连续多轮都失败后,kubelet 会把它升级成事件和日志里的固定措辞,提示磁盘可能被日志、volume 或其他数据占用。实验日志里那一行 Image garbage collection failed multiple times in a row 就是这么来的(kubelet-gc-lines.json)。
实验把高阈值设为 1%,低阈值设为 0%,并构造约 512 MiB 的未使用镜像。本次 kubelet 日志进入了评估分支:
| |
但 kind 节点的 ImageFS 和宿主机 Docker 数据目录共用整块磁盘(443.2 GiB)。ImageFsStats 返回的 capacityBytes 是整块文件系统的容量,availableBytes 也是整块盘的可用空间,所以 usage 反映的是这块盘用了多少,不是镜像占了多少。日志里的 26% 就是这么算出来的,其中镜像本身只占约 5.23 GiB(result.json 的 image_fs_used_bytes),其余是该文件系统上的日志、容器可写层和其他数据。
构造的 512 MiB 镜像 article-unused:1 在 images-after.json 里仍然存在;日志中被删的是另一个镜像 ID(sha256:19aa6a21,约 2.8 MB),删完后 kubelet 仍报告未能释放到低阈值。使用中的 alpine:3.20 始终留在节点上。
因此这次实验能证明的是:kubelet 进入回收判断、按条件挑出可删镜像、放过使用中的镜像,并在目标无法达成时发出 FreeDiskSpaceFailed 事件。它不能证明“所有未使用镜像已经清理,空间恢复”,也不能证明磁盘用量真的降到了低阈值以下。
日志里的这行数字解释了为什么目标无法达成:amountToFree=119640612864,约 111 GiB。低阈值设成 0 时,公式 capacity*(100-low)/100 - available 等于把可用空间补到磁盘 100%,所以只要盘上有 111 GiB 数据被计为已用,就得释放 111 GiB。可删的未使用镜像全部加起来也不到 6 GiB,用一个 2.8 MB 的镜像试过之后没有更多候选,freed < amountToFree 成立,于是报错退出。
这个结果说明 lowThresholdPercent=0 在共享文件系统上不构成可达目标:kubelet 要把整块盘清空才可能满足它。它是阈值设到极端值时的预期行为,不能读成回收逻辑失效,也不能读成“镜像占满了磁盘”。
被删的 sha256:19aa6a21 值得单说:它不在这次实验保存的任何一份镜像快照里。images-before、images-loaded、images-after 三份记录都没有这个 ID,只有 kubelet 日志留下了它的删除行。所以“哪块镜像会被删”不能只用实验前后的两次 crictl images 反推:删除发生在两份快照之间,被删对象可能在这两份快照里都看不到。要解释一次具体删除,得同时读 kubelet 日志里的 Removing image to free bytes 行和当时的镜像记录。
这也是排查节点磁盘问题时容易误判的地方:容器镜像只是 ImageFS 的一类占用,日志、容器可写层、volume 和宿主机其他数据都可能阻止空间降回低阈值。kubelet 报“无法释放足够空间”时,需要继续检查 ImageFS 的实际组成,而不是反复删除同一个 Pod。
删除 Pod 后镜像何时可能消失
删除 Pod 会改变运行时容器和 kubelet 的 Pod 记录,但镜像管理器要以镜像为对象判断是否仍被其他容器引用。孤立的镜像可能在下一次周期扫描后被标记为未使用;真正删除还要经过最小存在时间、磁盘压力和运行时删除操作。下面三个阶段分属不同组件,彼此没有统一的完成屏障:
flowchart LR
A[删除 Pod API 对象] --> B[停止并清理容器]
B --> C[镜像不再被 Pod Worker 引用]
C --> D[Image Manager 记录未使用]
D --> E{达到 GC 条件?}
E -->|否| F[继续保留]
E -->|是| G[调用运行时删除镜像]
G --> H[ImageFS 空间可能下降]
图中各步分属不同组件。API 删除成功不保证容器已经停止,容器停止也不自动保证镜像被删除。
用证据收窄故障范围
遇到镜像问题时,按下面的顺序取证:
- 在 Pod 内先看
containerStatuses的waiting.reason和完整 message。 - 读 kubelet 日志,确认失败发生在
PullImage、CreateContainer还是启动后的进程。 - 在目标节点上用运行时工具查询镜像 ID、repo tag 和容器引用。
- 只有在出现 ImageFS 压力或 GC 日志时,才把问题扩展到 kubelet 镜像回收。
镜像名没写 registry 时,默认指向 docker.io 的 library 仓库;这只是命名解析规则,不代表镜像已经在节点上。CRI 调用链本身和 Sandbox 创建的关系见 容器运行时与 Sandbox。Pod 从创建到容器启动的完整顺序见 Pod 创建流程。
实验目录和可复现步骤见 k8sdev/article-image-lifecycle。公共实验环境见 article-next-common。本次验证了无效仓库地址进入 ImagePullBackOff、节点 containerd 镜像列表、Image Manager 的 GC 评估、一次未使用镜像删除和使用中镜像保留;没有验证私有仓库认证、跨节点镜像缓存和成功的低阈值回收到位。