Kubernetes 镜像拉取故障排查:当 Pod Pending 遇上 Registry 连接耗尽
一次生产环境镜像仓库迁移后,Pod 长时间 Pending 的故障排查实录,从现象到底层原理的深度分析。
背景
我们生产环境有两套 Nexus 环境,Docker 镜像制品也使用 Nexus。老环境运行已久,Nexus 非常不稳定,故障 BUG 特别多。在新环境迁移工作中,我们已经转向 Harbor。
某天 Nexus 突然完全故障,所有请求 404(Nexus 本身经常出 BUG,虚机层面磁盘故障等问题造成数据错乱)。于是我们放弃老的 Nexus,全部切换到新的 Harbor。
故障过程复盘
第一阶段:切换完成后的异常
在 2-3 天的排查调整后,基本切换完成,业务没有反馈仓库等问题了。但在中午变更时发现:
异常现象:
- 老环境 Pod 发布后,一直处于 Pending 状态
- 可能等待 10 多分钟才能启动成功
- 观察 Pod event、deployment 甚至 replica sets,都没有任何错误事件
根据多年经验,至少 replica sets 会有故障事件,这次什么都没有,非常不科学。
第二阶段:深入排查
回到 Pod 本身,仔细观察 event 事件:
关键发现:
- 虽然没有报错,但事件信息有异常
- 只有拉取镜像的事件,没有拉取成功的事件
- 正常情况下一定会有拉取成功的事件
登录节点服务器,手动执行 docker pull,可以正常拉取,没有问题。而且服务器拉取镜像后,Pod 就正常启动了。
第三阶段:扩大影响范围
一时间没有头绪。该服务器连接公网,只有一台,怀疑某些网络问题,先观察。
到了晚上业务变更高峰期,这个问题仍然存在,并且部署在哪个节点都存在同样问题。好在这是老环境,变更系统不多。
临时措施:
- 选一些节点手动拉取镜像
- 固定 Pod 调度,避免手动拉取镜像后重新调度到没有镜像的节点
特殊现象:
- 故障节点即使手动拉取完镜像后,Pod 也不启动
- 需要重启 Pod(或等待 10-20 分钟)才会启动
因为是镜像拉取相关问题,怀疑是镜像仓库故障导致的。
环境架构
我们有两套 Docker 仓库:
| 仓库 | 域名 | 类型 | 位置 | 状态 |
|---|---|---|---|---|
| 新仓库 | registry-domain-new | Harbor | 内蒙数据中心 | 正常 |
| 老仓库 | registry-domain-old | Nexus | 杭州数据中心 | 故障 |
业务服务新旧集群都使用 registry-domain-new(新的 Harbor),一直没有任何问题。老环境在杭州数据中心,我们怀疑是专线或网络故障导致从杭州到内蒙的异常。
排查过程
排查组件日志
第二天早上,联系运维同事排查镜像仓库问题,但没有任何收获:
- 手动操作就是 OK 的
- 新的镜像仓库在新集群也在使用,新集群是正常的
逐步排查相关组件的日志,模拟故障现场:
journalctl -u kubelet -n 200 -f
journalctl -u docker -n 200 -f
tail -f /var/log/messages | grep docker
结果:没有任何不对的日志。
关键突破
回顾现有信息:
- Pod 没有拉取镜像成功的事件
- 镜像在节点准备好之后,就可以正常启动 Pod
- 相关组件没有任何错误日志
- 镜像仓库是正常的
- 手动操作和另外一套新集群都正常
怀疑方向:可能是 dockerd 卡住了,资源耗尽或 TCP 连接被占用无法释放?
解决方案
清理集群内的 Corn Job Pod:
- 这些 Pod 还在使用老仓库
- 除了这些,还有一些启动不了的 Pod
- 大概有 300 多个 Pod 处于 Pending 状态
清理之后,终于恢复正常了!
结论:
- 老仓库故障了
- 大量的 Pod 还从故障的仓库拉取导致资源被耗尽
- 推测是日志级别不够,没有打印这么底层的详细日志
问题分析回顾
真实情况分析
更可能的情况是:
- kubelet 根本没找 docker 拉镜像,而是找了 containerd / cri-dockerd
- 手动
docker pull时,拉的是 docker 的镜像库,与 kubelet 使用的 containerd 完全无关 - 所以:
docker pull很快 → 说明镜像仓库侧没问题- 但 kubelet 一直在等 containerd 拉镜像
- 这个过程要么卡住(containerd 内部问题、磁盘问题、某层损坏)
- 或已经拉好但容器创建阶段卡在 snapshotter/filesystem
- 去
journalctl -u docker看不到任何日志,因为 docker 服务根本没参与
Pod 创建流程解析
以 "创建一个 Pod" 为例,看请求如何从 k8s 走到 dockerd 和 runtime:
详细流程:
-
用户提交:
kubectl apply -f pod.yaml- kubectl → kube-apiserver:提交 Pod 对象
-
调度阶段:kube-controller-manager / scheduler
- 将 Pod 调度到某个 Node
- 写到 etcd,apiserver 记录
-
节点执行:目标 Node 上的 kubelet 发现有新 Pod
- kubelet 通过 CRI 请求 "create sandbox、create container、start container" 等
-
CRI 转换:kubelet 内部的 dockershim 收到 CRI 调用
- dockershim 把 CRI 请求转换为 Docker API:
POST /containers/createPOST /containers/(id)/start
- dockershim 把 CRI 请求转换为 Docker API:
-
Docker 执行:dockerd 收到请求
- 若需要拉镜像:调用 registry 拉镜像,写入本地镜像存储
- 创建容器配置(namespace、cgroup、mount 等)
- 调用 containerd / runc,按照 OCI 规范实际启动容器进程
-
状态上报:容器启动后
- kubelet 周期性通过 CRI 检查容器状态
- kubelet 把 Pod 状态上报给 kube-apiserver
调用链路:
kubelet
-> 调用 CRI 接口 (CreateContainer, StartContainer, ListContainers, ...)
-> dockershim 实现这些接口
-> 调用 dockerd 的 HTTP API (/containers/create, /containers/start, ...)
技术深度分析
1. 一个节点上 dockerd 是怎么跑的?
常规生产环境(老的 Docker + k8s)
- 节点上只有一个 dockerd 进程
ps aux | grep dockerd
- 它通过 systemd 启动,监听一个 Docker API 端口:
- Unix socket:
/var/run/docker.sock - 有时还会暴露 TCP:
tcp://0.0.0.0:2375(不安全,仅测试环境偶见)
- Unix socket:
架构设计
从代码和架构设计看:
-
dockerd 设计:单实例、多 goroutine 的 daemon
- 接收 API 请求
- 内部有任务调度 / worker 池
- 调用 containerd / graphdriver 等子模块
-
多 dockerd 理论可行:
- 需要完全隔离 runtime 数据目录
- k8s/kubelet 只会连接一个指定的 Docker Endpoint
- 部署上几乎没人这么干
结论:所有 k8s 上的 Pod 创建 / 镜像拉取 + 手工 docker pull 都是在打同一个 dockerd。
2. CLI 好用,k8s 卡住,代码层面更可能是哪一层的问题?
分层架构
kubelet
-> dockershim (CRI 实现)
-> /var/run/docker.sock
-> dockerd
-> containerd / graphdriver
-> registry A/B
2.1 dockershim 会不会是瓶颈?
dockershim 的职责:
- 实现 CRI 接口:PullImage、CreateContainer、StartContainer、ListContainers 等
- 每个接口内部基本是:
- 解析 CRI 请求
- 组装 Docker API 调用参数
- 调用 Docker client(HTTP 客户端)通过
/var/run/docker.sock发请求 - 把 Docker 的返回转成 CRI 返回
代码特点:
- 没有复杂的"队列 + 限流 + 优先级调度逻辑"
- 并发模型:
- kubelet 上层的 worker 池 + goroutine
- 每次 Pull 请求就起一个协程调 Docker API
结论:如果 dockerd 反应正常,dockershim 不太容易成为瓶颈。
反过来说:手动 docker pull 正常,但 kubelet/dockershim 拉都卡住,且全集群有大量对坏仓库的请求时,更合理的是 dockerd 资源被塞满。
2.2 dockerd/containerd 里面的并发限制
这里是问题的关键!
HTTP client / registry 连接池限制
- Docker 对 registry 使用 HTTP client 池,每个 host 的并发连接数有限
- 大量针对坏仓库 A 的 pull:
- 会占满 A 对应的连接
- 如果底层实现 / DNS / TLS 握手卡住,还会占用大量 goroutine 和 FD
- 某些版本的 Docker 在"长时间无法建立连接"的情况下,资源回收并不积极
镜像拉取任务队列
- dockerd 会将 Pull 请求排队
- containerd 内部也有内容存储、snapshotter 的操作队列
- 几百个 Pod 同时触发 pull 同一个坏仓库的镜像:
- 队列充满"必然失败但要等很久"的任务
- 新的正常请求(去仓库 B)也得排队
- 占用共享资源(CPU、网络带宽、FD)
- 表现出:"整体都慢 / 卡住"
文件句柄、goroutine、系统资源
- 每个拉取任务可能占用多个 FD(socket、文件、pipe)
- 若节点 ulimit -n 较小或 Docker 的内部 FD 使用不当,很容易被耗光
- 这时:
- 新的连接建立失败
- docker API 响应也可能异常
- kubelet 看到的是 Pull 超时或一直 pending
现象对比
| 对比项 | CLI docker pull | kubelet/dockershim |
|---|---|---|
| 仓库 | 正常仓库 B | 坏仓库 A |
| 状态 | 能拉,说明 dockerd 还活着 | 卡住 |
| 资源占用 | 轻量使用者,偶尔发起 | 大量请求占满资源 |
结论:从代码 / 架构视角,问题主要出在 dockerd/containerd 的资源 / 并发限制,而不是 dockershim。
3. kubelet 这一层会不会限制并发?
kubelet 确实有一些对 image pulls 的控制:
--serialize-image-pulls:串行拉取镜像(默认多并发)--image-pull-progress-deadline:镜像拉取超时时间- kubelet 会为每个 Pod 创建任务,触发 PullImage,如果慢会重试
但两个事实很关键:
- kubelet 默认允许并发拉取,不做强限流
- 就算 kubelet 限制并发,也挡不住"集群多个节点上都在并发拉同一个坏仓库"
结论:
- kubelet 本身不会产生"让 CLI 请求快、CRI 请求慢"的逻辑
- 全集群大量 CronJob Pod 都去坏仓库拉镜像,每个节点的 dockerd 都被拖住
常见问题解答
Q1:节点上就一个 dockerd 进程服务,只要 API 调用就能执行?
A:
- 是,一个节点上典型就是一个 dockerd
- 只要能连上
/var/run/docker.sock,任何客户端都可以发请求 - 但 dockerd 内部会受资源限制影响:
- 请求"能发送" ≠ 一定"能被快速处理"
- 被阻塞/排队/超时也都是可能的结果
Q2:既然是同一个 dockerd,那为什么 CLI 正常而 k8s 卡住?
A:
- dockerd 内部资源有限:
- 被大量来自 kubelet 的坏仓库 A 拉取任务占用
- CLI 只是偶尔发起一个 B 仓库的拉取,有时还能挤进来
- 换句话说:
- k8s 这条路径是被挤爆的重灾区
- CLI 是轻量使用者,有一定概率幸存
Q3:会是 dockershim 层并发限制了吗?
A:
- 从设计和代码职责上看,dockershim 更像是"一个薄薄的翻译层":
- 不负责复杂的限流
- 也不会自己维护大而持久的队列
- 现象更符合:dockerd/containerd 的请求/任务层资源被坏仓库 A 的拉取拖死
排查方法
如果想从 "代码层面" 更确证,可以用以下工具验证:
1. 看 dockerd 的连接状况
ss -antp | grep docker
ss -antp | grep <坏仓库域名或IP>
看是否有大量半开 /ESTABLISHED 等待中的连接。
2. 看 dockerd 占用的 fd / goroutine
lsof -p $(pidof dockerd) | wc -l
cat /proc/$(pidof dockerd)/limits
如果监控里有 Go runtime metrics(dockerd 启用 pprof/prometheus),可以看到 goroutine 数非常高。
3. 停掉 CronJob + 清 Pending Pod
步骤:
- 暂停/删除使用坏仓库的 CronJob
- 删除所有 Pending 的那些 Pod
- 观察几分钟内,其他业务 Pod 拉取是否恢复正常
如果立即恢复,基本就是 "坏仓库拉取任务把 dockerd 拖死" 这个模式。
Pod 事件对比分析
异常情况 - Pod 一直处于 Pending 状态
| 字段 | 内容 |
|---|---|
| 事件 | Pulling image |
| 镜像地址 | [registry-domain]/eos/fy-demo:v1.2025-12-24.09-31-42 |
| 来源 | kubelet itk8s-worker12 |
| 出现次数 | 1 |
| 子对象 | spec.containers{fy-demo-v1} |
| 最后发生时间 | 2025-12-24T09:32:28+08:00 |
问题描述:
- Pod 仅显示 "Pulling image" 事件,随后陷入 Pending 状态
- 无任何错误信息和日志输出
- 持续等待数十分钟仍无进展
正常情况 - Pod 成功启动
| 字段 | 内容 |
|---|---|
| 事件 | Started container |
| 来源 | kubelet itk8s-worker08 |
| 子对象 | spec.containers{hichain-api-v1} |
| 最后发生时间 | 2025-12-24T09:29:51+08:00 |
| 字段 | 内容 |
|---|---|
| 事件 | Successfully pulled image |
| 镜像地址 | [registry-domain]/feiyun-custom-images/wms/h3c-centre-app:2025122401 |
| 拉取耗时 | 3m10.905694503s |
| 来源 | kubelet itk8s-worker08 |
| 子对象 | spec.containers{hichain-api-v1} |
| 最后发生时间 | 2025-12-24T09:29:50+08:00 |
启动流程:
Pulling image → Successfully pulled image (3分10秒) → Created container → Started container
对比分析
| 对比项 | 异常情况 | 正常情况 |
|---|---|---|
| 镜像拉取状态 | 一直处于 Pulling,无进展 | 成功完成拉取 |
| 拉取耗时 | 无限等待 | 约 3 分 10 秒 |
| 后续事件 | 无 | Created → Started |
| 错误信息 | 无 | 无 |
| 日志输出 | 无 | 正常 |
总结与建议
核心结论
- 一个节点只有一个 dockerd,所有 k8s 和 CLI 的拉取都走它
- 现象从实现/代码职责角度,最合理的根因在 dockerd/containerd 的拉取并发与资源耗尽
- dockershim 几乎只是"翻译员",真正"堵车"的是 dockerd 内部的任务队列/连接池/资源上限
运维建议
1. 快速封禁坏仓库
# 在 /etc/hosts 中添加
echo "0.0.0.0 registry-domain-old" >> /etc/hosts
# 或使用 iptables
iptables -A OUTPUT -p tcp -d <坏仓库IP> -j REJECT
2. 清理异常 Pod
# 查找使用坏仓库的 Pod
kubectl get pods --all-namespaces -o json | \
jq -r '.items[] | select(.spec.containers[].image | contains("registry-domain-old")) | .metadata.namespace + "/" + .metadata.name'
# 批量删除
kubectl get pods --all-namespaces -o json | \
jq -r '.items[] | select(.spec.containers[].image | contains("registry-domain-old")) | .metadata.namespace + " " + .metadata.name' | \
xargs -I {} kubectl delete pod -n {} --force --grace-period=0
3. 给 CronJob 加保险
apiVersion: batch/v1beta1
kind: CronJob
metadata:
name: my-cronjob
spec:
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
concurrencyPolicy: Forbid # 禁止并发
startingDeadlineSeconds: 300 # 超时跳过
4. kubelet 参数优化
# /etc/systemd/system/kubelet.service.d/10-kubeadm.conf
--serialize-image-pulls=false \
--image-pull-progress-deadline=10m \
--maximum-dead-containers-per-container=2
5. dockerd 配置优化
// /etc/docker/daemon.json
{
"max-concurrent-downloads": 3,
"max-concurrent-uploads": 5,
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
参考资料
文章发布日期:2025-12-24