三个 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 看起来仍然失败。
查看「容器与编排」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。

三个 Pod 拉不下镜像,换个节点就好:私有仓库的证书断点 - 起航社区