三个 Pod 拉不下镜像,换个节点就好:私有仓库的证书断点
同一份 Deployment,五个副本正常三个 ImagePullBackOff,删了重建还失败,换台机器立刻好。describe 只看前两行会把 x509 读成 401,真正的问题在节点本地的 CA 目录。
凌晨两点,发布流水线显示 8 个副本全部滚动完成,kubectl get pods 里却有 3 个卡在 ImagePullBackOff,另外 5 个已经是 Running。同一份 Deployment、同一个镜像地址、同一份 Secret,偏偏只有一部分拉不下来。
更怪的是,把卡住的那几个 Pod 删掉重建,还是失败;把副本数调大让调度器换台机器,立刻就好了。值班同事的第一判断是「镜像仓库偶发 5xx」,于是把 harbor 重启了一遍,重启完该失败的还是失败,只是 Pod 重建后落到了另一台坏节点上,看起来像是「时好时坏」。
我接手时手里只有一句 Failed to pull image。真正的原因挖了四十分钟才落地,而且跟「账号密码错了」没有半点关系。这篇把整个过程记下来,顺便说清楚私有仓库这条链路上到底有几个断点。
一、describe 的 Events 只看了前两行
kubectl describe pod 的 Events 按时间正序,最旧的在最上面。Pod 反复拉取失败时,前面几条往往是第一次尝试的简短错误,真正的细节在后面反复刷屏的那几条里。只看开头两行,很容易把一个 TLS 错误读成认证错误。
kubectl -n prod describe pod api-7d9c8f6b5-x2vqn | sed -n '/Events:/,$p'
完整的报错是这样的:
Failed to pull image "harbor.lan/base/api:20261112-0142":
failed to do request: Head "https://harbor.lan/v2/base/api/manifests/20261112-0142":
tls: failed to verify certificate: x509: certificate signed by unknown authority
x509: certificate signed by unknown authority。不是 401,不是 404,是 TLS 握手阶段就断了,请求根本没走到鉴权那一步。这一条就否掉了两个方向:改 Secret 没用,重启 harbor 也没用。
顺手记一下常见报错分别卡在哪一段,能省掉大量来回:
| 报错片段 | 卡在哪一段 | 通常先查什么 |
|---|---|---|
x509: certificate signed by unknown authority |
TLS 握手 | 节点上的 CA 证书目录 |
401 Unauthorized |
仓库鉴权 | imagePullSecrets 是否挂对、Secret 类型 |
manifest unknown |
取 manifest | tag 拼写、流水线是否真的推送成功 |
no such host |
DNS 解析 | 节点 resolv.conf、CoreDNS、内网域名 |
i/o timeout |
网络连通 | 安全组、防火墙、代理白名单 |
二、把拉取链路拆成四段,逐段验证
在节点上绕过 K8s 直接试一次,比对着 Pod 事件猜要快得多。节点上的运行时是 containerd,用 crictl 手动拉:
crictl pull harbor.lan/base/api:20261112-0142
在坏节点上返回一模一样的 x509,在好节点上正常拉完。这一步把「K8s 组件的问题」和「这台机器的问题」彻底分开了:同一个镜像地址一台能拉一台不能,基本可以排除镜像本身、凭证、DNS 和网络策略。
再往前退一步,只做 TLS 握手,看证书链验到哪一环失败:
openssl s_client -connect harbor.lan:443 -servername harbor.lan </dev/null 2>&1 | grep -E "Verify return code|depth"
好节点上输出 Verify return code: 0 (ok),坏节点上是 20 (unable to get local issuer certificate)。20 这个错误码的意思很明确:服务器给的证书链里少了一个中间证书,或者本机没有能验它的根证书。到这一步就不用再怀疑别的东西了。
最后确认节点配置差异:
ls /etc/containerd/certs.d/harbor.lan/
好节点上有 ca.crt 和 hosts.toml,坏节点报 No such file or directory。
三、坏节点为什么没有证书
集群前一阵扩容,新加了两台机器,用的是同一份节点初始化脚本。脚本里有一段「分发 harbor CA」,但它依赖变量 HARBOR_CA,而这个变量只在第一批机器的 inventory 里定义过。第二批机器跑脚本时变量为空,install -D -m 0644 因为源文件不存在被 || true 吞掉,没报错,也没人检查。
这类配置漂移在节点数超过五台之后几乎必然出现。判断方法很土但有效:
for h in 192.168.2.100 192.168.2.101 192.168.2.102; do
printf "%-16s " "$h"
ssh "$h" "sha256sum /etc/containerd/certs.d/harbor.lan/ca.crt 2>/dev/null | cut -c1-16 || echo MISSING"
done
三台机器打出两个哈希加一个 MISSING,结论就出来了。修的时候要注意,光补 ca.crt 不够,hosts.toml 也要一起补。containerd 在 certs.d 下找不到对应 host 的配置时会走默认逻辑,SNI 和端口不一定对得上,表现为补了证书仍然报别的错。
补完执行 systemctl reload containerd。这里有个容易误判的点:reload 不会重建已经存在的 Pod,处于 ImagePullBackOff 的 Pod 会按指数退避重试,间隔最长 5 分钟。改完配置直接 kubectl rollout restart,别对着没变化的 Pod 反复怀疑自己。
四、imagePullSecrets 生效的三个前提
既然是私有仓库,把凭证那一半也顺手理清。它出问题的概率比证书高得多,报错还更隐蔽。
前提一,Secret 类型必须是 kubernetes.io/dockerconfigjson。用 kubectl create secret generic 手工拼的 Secret,字段名是 config.json 而不是 .dockerconfigjson,kubelet 读不到,直接当没配,报 401。
kubectl -n prod create secret docker-registry harbor-cred \
--docker-server=harbor.lan --docker-username='robot$prod' \
--docker-password='********' --dry-run=client -o yaml | kubectl apply -f -
前提二,Secret 必须和 Pod 在同一个 namespace。跨 namespace 引用不支持,很多人在 default 里建了 Secret,然后在 prod 里纳闷为什么没生效。
前提三,要么在 spec.template.spec.imagePullSecrets 显式挂,要么挂到 ServiceAccount 上让它自动继承。后者更省事,新加的工作负载不会漏:
kubectl -n prod patch serviceaccount default \
-p '{"imagePullSecrets":[{"name":"harbor-cred"}]}'
还有个细节:--docker-server 要和镜像地址里的 host 完全一致。镜像写 harbor.lan/base/api,Secret 里写 https://harbor.lan,部分运行时版本会匹配失败,表现就是「明明配了还是要 401」。
五、把节点配置从「人肉铺」变成「自动收敛」
这次事故的直接原因是证书没铺全,根因是「节点上的有状态配置靠脚本一次性写入,没有收敛机制」。补证书只解决今天,下次扩容还会再来一遍。
我们的做法是把证书分发做成 DaemonSet,每个节点都跑一次,跑完就挂着不动。好处是不依赖 SSH 和 inventory 变量,节点一加入集群自动执行,坏节点重启也会被重新纠正:
initContainers:
- name: install
image: busybox:1.36
securityContext: { privileged: true }
command: ["sh","-c","mkdir -p /host/etc/containerd/certs.d/harbor.lan &&
cp /ca/* /host/etc/containerd/certs.d/harbor.lan/ &&
nsenter -t 1 -m -u -n -i -- systemctl reload containerd || true"]
这个 DaemonSet 自己拉镜像也需要证书,鸡生蛋的问题。落地时用的是节点预置的基础镜像加 imagePullPolicy: IfNotPresent,或者干脆把这段逻辑塞进节点初始化的 systemd unit,用 ConditionPathExists 保证幂等。关键是「节点一上线就自动收敛」,而不是「靠人记得跑脚本」。
六、验证与防复发
修完不等于结束。第二天我补了两道检查,都不重。
第一道是拉取探针。CronJob 每十分钟在随机节点上拉一次几百 KB 的测试镜像,失败就告警。它比等到发布时才发现要早得多:
kubectl -n ops create job --from=cronjob/registry-probe probe-manual
kubectl -n ops logs job/probe-manual --tail=20
第二道是配置漂移巡检:把每个节点的 CA 哈希、containerd 版本、/etc/hosts 里与 harbor 相关的行聚合上报,和基线比对。用不着多复杂的工具,一个每分钟跑一次的 shell 就够,输出推进现有指标系统。
小结
- 私有仓库拉取失败先读完整报错。
x509是 TLS 阶段的问题,跟账号密码无关,改 Secret 一定是白改。 - 判断是「节点问题」还是「集群问题」,最快的办法是在单机上
crictl pull复现,一次就能分清。 - containerd 的仓库证书和
hosts.toml是节点本地文件,不在集群里。任何节点级配置都要有收敛机制,脚本一次性写入必然漂移。 imagePullSecrets三个前提:Secret 类型为kubernetes.io/dockerconfigjson、同 namespace、挂在 Pod 模板或 ServiceAccount 上。- 凭证用 ServiceAccount 默认挂载比逐个子配稳,新工作负载不会漏。
- 补完配置记得
rollout restart,退避重试会让修好的 Pod 看起来仍然失败。
正在加载评论…