
`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。镜像拉取、存储和回收的具体行为与运行时实现有关。

<!--more-->

## Pod 中的 image 只是一项引用

PodSpec 里的 `image` 是给 kubelet 的引用，不是已经存在于节点的文件。kubelet 收到绑定到本节点的 Pod 后，按容器配置决定是否拉取镜像。`imagePullPolicy` 决定什么时候查询仓库；策略不保证仓库一定有对应 tag，也不保证节点一定保留镜像。三个策略值和默认规则见 [Images](https://v1-35.docs.kubernetes.io/docs/concepts/containers/images/)。

创建 Pod 后，API Server 只保存对象和默认值。调度器负责选择节点，kubelet 随后通过 CRI 的 `PullImage` 和 `CreateContainer` 推进节点侧工作。Pod 变成 `Pending` 时，需要继续看容器状态里的等待原因，而不是只看 Pod phase。

```mermaid
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`。无效引用最终进入等待状态：

```text
reason: ImagePullBackOff
message: Back-off pulling image
  "example.invalid/kubernetes-image-does-not-exist:never":
  ErrImagePull: failed to resolve reference ...
```

失败发生在镜像仓库主机名解析阶段，尚未进入容器创建。此时 Pod 有确定的节点，kubelet 仍在按退避策略重试；API 对象没有因为一次拉取失败而被删除。具体错误留在 Pod 事件和 kubelet 日志里，不能只凭 `ImagePullBackOff` 判断是凭据、网络、tag 还是仓库服务故障。

这个退避的起点是 10 秒，上限 300 秒（v1.35.1 的 `imageBackOffPeriod` 与 `MaxImageBackOff`，[`kubelet.go:177-225`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/kubelet.go#L177-L225)）。拉取失败后 `backOff.Next` 递增，成功才清零，所以同一个 Pod 上错误的镜像名会越等越久。两个等待原因的名字就说明了状态差别：`ErrImagePull` 表示这一次拉取失败，`ImagePullBackOff` 表示当前仍在退避窗口内（[`types.go:27-35`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/images/types.go#L27-L35)）。

排查时先读完整的 `message`，再按照错误出现的位置分层：

| 观察 | 优先检查 |
| --- | --- |
| `no such host`、`connection refused` | DNS、仓库地址、网络和代理 |
| `unauthorized`、`manifest unknown` | 认证、镜像名、tag 和 digest |
| `failed to unpack`、磁盘不足 | 节点镜像文件系统和 containerd 存储 |
| 拉取成功但容器仍不启动 | CRI 创建、卷、命令和安全上下文 |

实验日志中的错误来自 CRI `PullImage`，所以不能把它归类成 scheduler 或 API Server 的失败。

## 镜像在节点上的存在形式

拉取成功后，镜像由容器运行时保存。kind 节点上能看到 containerd 的镜像列表：

```bash
docker exec article-image-control-plane crictl images
docker exec article-image-control-plane crictl inspecti docker.io/library/alpine:3.20
```

实验的 `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](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/images/image_gc_manager.go#L547-L570)）。pinned 的意义是运行时声明这块镜像不能被删，常见于 sandbox 镜像；它和“正在使用”是两种独立判断，一个镜像可以被 pin 却没有容器在跑。

## ImageFS 阈值怎样触发回收

kubelet 的 ImageGC 按固定周期运行，Pod 删除不会立刻触发。v1.35.1 的 `ImageGCPeriod` 是 5 分钟（[`kubelet.go:230`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/kubelet.go#L228-L230)），重启 kubelet 会立即执行一轮。相关配置项及其默认值如下：

- `imageGCHighThresholdPercent`：使用率达到该值后，尝试回收镜像，默认 85。
- `imageGCLowThresholdPercent`：回收目标，尽量把已使用比例降回该值以下，默认 80。
- `imageMinimumGCAge`：未使用镜像至少存在多久才可能被删除，默认 2 分钟。
- `imageMaximumGCAge`：按最长未使用时间回收，不要求磁盘使用率先超过高阈值。

前三个默认值来自 v1.35.1 的 [`defaults.go`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/apis/config/v1beta1/defaults.go#L153-L162)。`imageMaximumGCAge` 的默认值为 0，即不启用；它对应的功能门在 v1.35 已 GA 并 `LockToDefault`，字段说明见 [kubelet config v1beta1](https://v1-35.docs.kubernetes.io/docs/reference/config-api/kubelet-config.v1beta1/)。

想彻底关掉镜像回收，要同时把 `imageGCHighThresholdPercent` 设成 100、把 `imageMaximumGCAge` 设为 0；只改其中一个不会停掉 GC（`kubelet.go:1641-1644`）。这条判断也说明这两个配置走的是两条独立路径：阈值按磁盘使用率触发，最大存在时间按镜像年龄触发，任何一条成立都会删镜像。

在 v1.35.1 的 [`image_gc_manager.go`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/images/image_gc_manager.go) 中，`GarbageCollect` 的顺序值得单独说。它先按淘汰顺序排好镜像，再进 `freeOldImages`（[L425-L452](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/images/image_gc_manager.go#L425-L452)），最后才读 ImageFS 统计、计算使用率。`freeOldImages` 只做一件事：`MaxAge` 不为 0 且镜像的最后使用时间超过它，就删除；它不看磁盘用得多满。

后面才是阈值分支（[L383-L402](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/kubelet/images/image_gc_manager.go#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`](https://github.com/jimyag/k8sdev/blob/917db9bc548a27980afe404592b862cafea22db4/article-image-lifecycle/evidence/kubelet-gc-lines.json)）。

实验把高阈值设为 1%，低阈值设为 0%，并构造约 512 MiB 的未使用镜像。本次 kubelet 日志进入了评估分支：

```text
Disk usage on image filesystem is over the high threshold,
trying to free bytes down to the low threshold
usage=...
highThreshold=1
lowThreshold=0
```

但 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 记录，但镜像管理器要以镜像为对象判断是否仍被其他容器引用。孤立的镜像可能在下一次周期扫描后被标记为未使用；真正删除还要经过最小存在时间、磁盘压力和运行时删除操作。下面三个阶段分属不同组件，彼此没有统一的完成屏障：

```mermaid
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 删除成功不保证容器已经停止，容器停止也不自动保证镜像被删除。

## 用证据收窄故障范围

遇到镜像问题时，按下面的顺序取证：

1. 在 Pod 内先看 `containerStatuses` 的 `waiting.reason` 和完整 message。
2. 读 kubelet 日志，确认失败发生在 `PullImage`、`CreateContainer` 还是启动后的进程。
3. 在目标节点上用运行时工具查询镜像 ID、repo tag 和容器引用。
4. 只有在出现 ImageFS 压力或 GC 日志时，才把问题扩展到 kubelet 镜像回收。

镜像名没写 registry 时，默认指向 `docker.io` 的 library 仓库；这只是命名解析规则，不代表镜像已经在节点上。CRI 调用链本身和 Sandbox 创建的关系见 [容器运行时与 Sandbox](/posts/kubernetes-cri-sandbox/)。Pod 从创建到容器启动的完整顺序见 [Pod 创建流程](/posts/kubernetes-pod-creation-flow/)。

实验目录和可复现步骤见 [k8sdev/article-image-lifecycle](https://github.com/jimyag/k8sdev/tree/917db9bc548a27980afe404592b862cafea22db4/article-image-lifecycle)。公共实验环境见 [article-next-common](https://github.com/jimyag/k8sdev/tree/917db9bc548a27980afe404592b862cafea22db4/article-next-common)。本次验证了无效仓库地址进入 `ImagePullBackOff`、节点 containerd 镜像列表、Image Manager 的 GC 评估、一次未使用镜像删除和使用中镜像保留；没有验证私有仓库认证、跨节点镜像缓存和成功的低阈值回收到位。

