先给你还原一个常见的现场:集群里 kubelet 一直报ImagePullBackOff,你登录节点想用crictl pull手动拉一下镜像验证,结果抛出一串x509: certificate signed by unknown authority;或者更诡异一点,nerdctl pull能通,但 kubelet 就是拉不下来。装好 containerd、也装了 Harbor,为什么私有仓库就是不通?大多数人第一反应是“证书问题”或“账号问题”,但真正的原因往往是你没有把 containerd 生态里那几个工具的配置搞清楚。
这篇文章就围绕 containerd 和 Harbor 的集成这件事,从底层配置逻辑讲到端到端验证,最后把我在生产环境踩过的坑和长期维护经验一并梳理出来。不管是 Kubernetes 节点上要拉 Harbor 的镜像,还是你只是想用 containerd 原生环境推送私有镜像,这套配置路径都适用。
1. 为什么 containerd 不像 Docker 那样天然认得 Harbor 仓库
很多从 Docker 切过来的人会有一个惯性思维:只要装了 containerd,在/etc/docker/daemon.json里写上insecure-registries就能访问私有仓库。先别急,containerd 和 Docker daemon 根本不是一回事。containerd 只是个底层容器运行时,它自己并不像 dockerd 那样内置一套完整的镜像仓库客户端逻辑。镜像从哪里拉、怎么认证,这些决定权在上面那层“工具”手里。
1.1 一张表看懂 ctr、nerdctl、crictl 的配置来源
平时你面对 containerd,至少有三种操作镜像的方式,它们的配置来源完全不一样:
| 工具 | 谁在用 | 读取配置的位置 | 认证方式 |
|---|---|---|---|
ctr | 直接调试 containerd 的 CLI | 默认不读 CRI registry 配置,需要--hosts-dir指定 hosts 目录 | hosts.toml 中的identitytoken/header |
nerdctl | 类 Docker 体验的 containerd 客户端 | 会读取/etc/containerd/certs.d/下的 hosts.toml,也会读取/root/.docker/config.json | nerdctl login写入的 docker config |
crictl | CRI 调试工具,kubelet 也走同一套 CRI 插件逻辑 | 读取 containerd 配置文件中[plugins."io.containerd.grpc.v1.cri".registry]指定的config_path | config.toml 中的registry.configs,或 crictl 命令的--creds |
也就是说,你在一个终端里用nerdctl login harbor.example.com登录成功了,这只影响 nerdctl 自己以及它调用的 containerd 客户端逻辑;kubelet 不会因为你登录过 nerdctl 就自动拿到 Harbor 的凭据。这是整个集成过程中最容易被忽略的一条分界线。
1.2 一个常见的失败现场:证书与授权的双重报错
我见过一个很典型的报错组合。先是证书:
crictl pull harbor.example.com/library/nginx:1.25 FATA[0000] failed to pull and unpack image: failed to resolve reference "harbor.example.com/library/nginx:1.25": failed to do request: x509: certificate signed by unknown authority你想着“那就跳过证书验证”,转头把config_path换成insecure_registries,结果报错变成了:
unauthorized: authentication required这两个报错串起来,正好说明了 containerd 接入 Harbor 需要同时解决三件事:
- 地址解析:
harbor.example.com能解析到 Harbor 服务的 IP; - TLS 信任:containerd 要信任 Harbor 用的 CA 证书;
- 认证凭据:Harbor 是私库,必须给 containerd 或 kubelet 提供能读项目的账号信息。
但这三件事并非都写在同一个文件里。接下来我们先把 Harbor 侧准备到位,再回到 containerd 侧做配置。
2. Harbor 侧准备:域名、项目、机器人账号与升级注意事项
很多人部署 Harbor 后都喜欢用 IP 地址访问,比如https://192.168.1.10:8443。这种方式做测试无所谓,但要在 containerd 里长期集成,我强烈建议你给它一个正式域名,比如registry.internal.example.com。理由有两个:
- 证书的 SAN 必须和仓库地址匹配,用域名方便签发和轮换正规证书;
- containerd 的 hosts.toml 是按 registry host 名匹配配置的,用 IP 加端口会让配置路径变得很绕(虽然也能写,但排查问题时很容易眼花)。
2.1 先确认 Harbor 自身的服务正常
在配置 containerd 之前,先在任意一台有 Harbor 访问权限的机器上验证:
curl https://registry.example.com/v2/如果返回401 Unauthorized或{},说明服务正常,因为/v2/本身就要求认证。如果返回503或超时,先别急着配 containerd,去查 Harbor 的容器状态和数据库。
如果 Harbor 用的是自签名证书,curl 时需要加-k,但只用于验证,不要让 containerd 也跟着-k。
2.2 创建项目和最小化权限的机器人账号
Harbor 里的权限边界很清晰:项目、用户、机器人账号。给 containerd 集成用的凭据,我建议一律用机器人账号,不要直接用管理员账号,更不要拿管理员密码到处写。
创建机器人账号时有几个关键点:
- 选择对应的项目,比如
library; - 勾选权限类型:pull-only 还是 push/pull;
- 记住 UI 上展示的完整用户名,通常长这样:
robot$library+pullbot,注意中间有$和+,后面跟的就是 token 密码; - 机器人账号 token 只会完整显示一次,丢失后要重新生成。
为什么强调机器人账号?因为它可以按项目、按动作(pull / push)精细授权,出了问题可以单独回收,不影响其他项目的镜像拉取。生产环境里你不可能因为某个镜像仓库应用轮换了密钥,就去改 Harbor 所有用户的密码。
2.3 Harbor 版本升级对集成配置的冲击
回到热词“harbor 版本升级”。如果你是从 Harbor 1.x 升到 2.x,或者 2.x 内的大版本升级,有一个特别容易踩的雷:机器人账号 token 的兼容性不是百分百继承的。
Harbor 2.0 重新设计了机器人账号体系,旧的系统级 robot account 和新的项目级 robot account 在 token 生成逻辑上完全不同。很多团队升级完成后,旧的 token 看着还在,但其实已经失效,或者权限映射不对。反映到 containerd 侧,就是原来配置的identitytoken突然不好使,拉取时报 401。
所以在升级 Harbor 之前,建议先做三件事:
- 备份
/etc/harbor(或你的 Harbor 安装目录)以及数据库目录; - 记录当前所有机器人账号的用户名和权限,但不要依赖旧 token;
- 升级完成后,到 UI 里逐个重新生成机器人 token,再更新到 containerd 的配置里。
我们把 Harbor 这侧的状态理顺后,再来看 containerd 该怎么配。
3. 正确姿势:用 hosts.toml 管理 TLS 信任,把认证交给上层
我第一次配 containerd 和 Harbor 的集成时,也走了一些弯路。当时还在 containerd 1.4 时代,大家喜欢直接在config.toml里写:
[plugins."io.containerd.grpc.v1.cri".registry.mirrors."registry.example.com"] endpoint = ["https://registry.example.com"]这种配置只解决了“允许从哪里拉”,并没有解决证书和认证。所以后来 containerd 1.6 引入了config_path,情况才变得可控。
3.1 config_path 的作用与配置方法
config_path让 containerd 从/etc/containerd/certs.d/<registry-host>/hosts.toml读取每个仓库的独立配置。这个机制的好处是:
- 每个镜像仓库一份配置,互不干扰;
- TLS 证书、CA、认证 header 都可以写在 hosts.toml 里;
- kubelet、crictl、nerdctl 只要走 CRI 插件逻辑,就会统一读到这份配置。
在 containerd 的config.toml中,找到 CRI 段,加上:
version = 2 root = "/var/lib/containerd" [plugins."io.containerd.grpc.v1.cri".registry] config_path = "/etc/containerd/certs.d"然后重启 containerd:
systemctl restart containerd注意:如果你的 containerd 是 1.6.0 之前的版本,config_path还不存在。我建议直接升级到 1.7 或更高版本,因为新版本对 hosts.toml 的支持更完整,也避免你在旧配置模型里浪费时间。
3.2 hosts.toml 的语法:server、ca、header 怎么配合
假设你的 Harbor 域名是registry.example.com,先建目录:
mkdir -p /etc/containerd/certs.d/registry.example.com接着编写/etc/containerd/certs.d/registry.example.com/hosts.toml:
server = "https://registry.example.com" [host."https://registry.example.com"] ca = "/etc/containerd/certs.d/registry.example.com/ca.crt" [host."https://registry.example.com".header] authorization = "Basic dXNlcm5hbWU6cGFzc3dvcmQ="这里的authorization是 Base64 编码后的username:password。我建议你把这个字段留给机器人账号。生成 Base64 的方式很简单:
echo -n 'robot$library+pullbot:你的token' | base64如果你不想在 hosts.toml 里明文写认证信息,还有另一个办法:使用identitytoken字段,直接把 Harbor 机器人 token 作为 Bearer token:
[host."https://registry.example.com"] ca = "/etc/containerd/certs.d/registry.example.com/ca.crt" identitytoken = "这里填机器人token"两种方式都可以,区别在于authorization是 Basic 认证,identitytoken是 Bearer Token。Harbor 两种都接受,你自己选一种即可。
3.3 为什么我不建议一上来就开 insecure_registries
见过很多同事为了图省事,在 containerdconfig.toml里写:
[plugins."io.containerd.grpc.v1.cri".registry] config_path = "/etc/containerd/certs.d" [plugins."io.containerd.grpc.v1.cri".registry.mirrors."registry.example.com"] endpoint = ["https://registry.example.com"]然后为了跳过证书,又把 hosts.toml 里的skip_verify设成true。诚然,内网环境里这样也许能拉通,但代价是:
- 镜像下载过程变成明文传输,一旦网络被劫持,镜像内容可以被篡改;
skip_verify无法对 Harbor 服务器的身份做校验,很容易成为中间人攻击目标;- 排查问题时,你分不清到底是因为证书、认证还是配置路径。
正确的做法是:使用完整 CA 证书链,尽量别开skip_verify。后面排障部分我会具体讲证书链的问题。
4. 端到端验证链路:登录、推送、拉取、接进 K8s
配置是一回事,能不能真正用起来是另一回事。下面我从头到尾走一遍。
4.1 分发 CA 证书到所有节点
Harbor 使用的 CA 证书可能是自建 CA 签发的,你需要把 CA 文件复制到所有需要拉镜像的节点上。通常放在/etc/containerd/certs.d/registry.example.com/ca.crt。如果你的证书链包含中间 CA,就把根 CA 和中间 CA 按顺序拼接在同一个文件里:
cat root-ca.crt intermediate-ca.crt > ca.crt拼接时注意顺序:先根证书,后中间证书,Harbor 服务端返回的证书链才能被正确验证。
复制完成后,重启 containerd:
systemctl restart containerd4.2 用 nerdctl 完成登录、打 tag、推送、拉取
节点上如果没有 nerdctl,先装一个。它和 Docker CLI 的命令高度相似,用来调测非常顺手。
nerdctl login registry.example.com -u 'robot$library+pushbot' -p '你的token'登录成功后,随便拿一个本地镜像打 tag 并推送:
nerdctl pull nginx:1.25 nerdctl tag nginx:1.25 registry.example.com/library/nginx:1.25 nerdctl push registry.example.com/library/nginx:1.25推送成功后,再把刚才推送的镜像删掉,重新拉一遍:
nerdctl rmi registry.example.com/library/nginx:1.25 nerdctl pull registry.example.com/library/nginx:1.25如果这两条命令都成功,说明 hosts.toml 里的证书信任和 nerdctl 的登录凭据已经能配合工作了。
4.3 让 kubelet 通过 imagePullSecrets 使用机器人账号
到了 Kubernetes 环境,kubelet 通过 CRI 拉镜像时,不会自动读取/root/.docker/config.json。我们可以在节点上的 hosts.toml 里配置header/identitytoken,把凭据写死在里面,这样 kubelet 能直接拉。但我不推荐这么做,原因很简单:节点上的凭据是静态的,一旦机器人 token 轮换,你要去改每一台节点,风险很大。
更干净的做法是:hosts.toml 只负责证书信任和连接参数,认证凭据通过 Kubernetes Secret 交给 kubelet。
先在集群里创建镜像拉取专用 Secret:
kubectl create secret docker-registry harbor-registry \ --docker-server=registry.example.com \ --docker-username='robot$library+pullbot' \ --docker-password='你的token' \ --namespace=default然后在 Pod 或 Deployment 里引用:
spec: template: spec: imagePullSecrets: - name: harbor-registry这样 kubelet 会把这个 Secret 中的凭据传给 containerd 的 CRI 插件,用于 Harbor 认证。而节点上的 hosts.toml 依然提供 CA 证书配置,两件事各管各的。
4.4 验证 crictl 与 kubelet 的拉取
在节点上想手动验证 crictl 能否拉取,可以用:
crictl pull registry.example.com/library/nginx:1.25 \ --creds 'robot$library+pullbot:你的token'注意--creds里的用户名是完整机器人用户名,中间有$和+,所以建议外层用单引号包裹。
如果你已经在 hosts.toml 里配好了identitytoken,也可以直接:
crictl pull registry.example.com/library/nginx:1.25这种情况下 kubelet 不需要 Pod 挂 Secret 也能拉取(前提是机器人账号的权限足够),但我还是建议走 Secret 流程,毕竟明文 token 散落在 hosts.toml 里不是一个长期可维护的方案。
整个链路走通之后,还有一个大家经常忽略的点:Harbor 上删除或覆盖镜像后,containerd 里可能还有缓存。你修改镜像 tag 并重新推送,再到节点拉取时,containerd 可能返回旧镜像。遇到这种情况,手动清理一下节点上的镜像缓存:
crictl rmi registry.example.com/library/nginx:1.25再重新拉。
5. 踩坑实录:Harbor 升级、token 失效、配置冲突的逐级定位
配置集成和排障是两回事。很多问题不是配置一次就能一劳永逸的,我把生产环境里碰到的高频问题按“定位链路”列出来,供你参考。
5.1 升级 Harbor 后镜像拉取 401:先查机器人 token
场景:Harbor 从 2.6 升到 2.8 后,所有节点开始报401 Unauthorized,而之前一切正常。
定位思路:
- 先用 curl 直接测试 Harbor 的 v2 API:
curl -u 'robot$library+pullbot:原token' https://registry.example.com/v2/如果返回 401,基本可以确认是 token 失效或账号状态异常。
登录 Harbor UI,找到指定项目下的机器人账号,重新生成 token,再用新 token 测试 curl。
如果 curl 通了,更新 containerd 侧配置。如果你用的是 hosts.toml 里的
identitytoken,直接替换为新 token;如果你用的是 Kubernetes Secret,就更新 Secret:
kubectl create secret docker-registry harbor-registry \ --docker-server=registry.example.com \ --docker-username='robot$library+pullbot' \ --docker-password='新token' \ --namespace=default \ --dry-run=client -o yaml | kubectl apply -f -- 更新完成后,在节点上重新拉一遍镜像验证。
这个坑的根源是 Harbor 版本升级后,旧机器人 token 的签名算法或存储格式可能已经变化,不要盲目怀疑 containerd 配置。
5.2 nerdctl 正常但 crictl 失败:配置作用域不同
这是我同事遇到最多的问题。明明nerdctl pull registry.example.com/xxx能成功,但是crictl pull registry.example.com/xxx就报unauthorized。
原因很简单:nerdctl用的是~/.docker/config.json里的登录凭据,而crictl走的是 containerd 的 CRI 插件配置。两者读取的认证来源并不同。
验证思路:
- 执行
cat /root/.docker/config.json,确认里面有auths条目; - 查看
/etc/containerd/config.toml,确认config_path是否存在,以及 hosts.toml 路径是否正确; - 如果 hosts.toml 里没有配置认证信息,直接执行
crictl pull就会拉到认证失败。
所以,你选择哪种工具做验证,就要在对应的配置层放好凭据。没有“一处登录,处处拉取”的白嫖选项。
5.3 hosts.toml 与 CRI registry.mirrors 的冲突排查
还有一个比较容易混淆的点:旧配置里用registry.mirrors指定 endpoint,新配置里又加了config_path,两者同时存在时,containerd 的行为可能让人摸不着头脑。
我曾经见过一个节点,config_path已经指向/etc/containerd/certs.d,但config.toml里还残留着:
[plugins."io.containerd.grpc.v1.cri".registry.mirrors."registry.example.com"] endpoint = ["http://registry.example.com:80"]结果 containerd 优先尝试了 HTTP 的 endpoint,导致拉取时报http: server gave HTTP response to HTTPS client。
排查时,先确认配置文件中是否还有endpoint相关的旧字段。思路是:
grep -A5 -B5 "registry.example.com" /etc/containerd/config.toml如果同时存在config_path和mirrors,建议删掉mirrors段,统一用config_path管理。containerd 对这两类配置的优先级在不同版本里并不完全透明,越早统一,越少踩坑。
5.4 证书链不完整,报 unknown authority
最后一个高频问题:明明ca.crt文件存在,路径也写对了,但 containerd 仍然报x509: certificate signed by unknown authority。
排查链路如下:
- 先确认 Harbor 服务端下发的证书链:
openssl s_client -showcerts -connect registry.example.com:443 </dev/null查看输出里的Certificate chain部分,如果只有一层证书,而你的ca.crt里只有一个根 CA,且 Harbor 没有启用中间证书,那么验证链可能不完整。
- 检查
ca.crt内容,尝试用openssl verify验证:
openssl verify -verbose -CAfile /etc/containerd/certs.d/registry.example.com/ca.crt registry.crt- 一般解决方法是把 Harbor 使用的完整证书链文件(含根 CA 和中间 CA)放入
ca.crt。
不要图省事直接改 hosts.toml 里的skip_verify = true,那样等于掩耳盗铃。问题解决之后,你会发现后续验证一切都顺了。
6. 生产环境维护这些配置时,我坚持的几个原则
集成搞定只是开始,真正考验人的是长期维护。下面这些原则是我在实际运维中沉淀下来的,每次换节点、升级组件时都用得上。
6.1 把配置当代码管理
/etc/containerd/certs.d/目录和config.toml的修改,我强烈建议纳入配置管理工具,比如 Ansible、Puppet,或者至少纳入 Git 仓库。
节点少的时候还可以手工登录去改,节点一多,手工分发 CA 证书和 hosts.toml 必然出错。Ansible 的剧本大概长这样:
- name: 分发 Harbor CA 证书到节点 copy: src: files/harbor-ca.crt dest: /etc/containerd/certs.d/registry.example.com/ca.crt owner: root group: root mode: 0644 notify: restart containerd - name: 确保 hosts.toml 配置正确 copy: src: files/registry.example.com.hosts.toml dest: /etc/containerd/certs.d/registry.example.com/hosts.toml notify: restart containerd这样每次调整配置,都有迹可循,也能用 CI/CD 在测试环境先验证。
6.2 机器人账号按项目分,禁止共享
我看到过最危险的操作:把某一个项目的机器人账号同时给 5 个不同业务集群用。一旦其中一个业务需要临时拉取另一个项目的镜像,权限越界,或者有人误删 token,所有业务都会同时挂掉。
我的习惯是:
- push 镜像走单独的机器人账号;
- 集群拉取走单独的 pull-only 账号;
- 每个项目至少两个账号,权限分开;
- 账号命名和用途直接写在 Harbor 备注里。
这样任何一次 token 轮换,影响面都可控。
6.3 定期健康检查和 Harbor GC
containerd 与 Harbor 集成后,镜像拉取链路会长期静默运行。我建议定期做一次健康检查,不一定很复杂:
crictl pull registry.example.com/library/health-check:v1或者用 Prometheus 的 probe 定期请求 Harbor 的/v2/端点。
另外,Harbor 的镜像删除不会立刻释放存储空间。开发环境经常更新镜像 tag,Harbor 里的不可见 tag 会堆积。记得定期让运维在 Harbor UI 或 API 里触发垃圾回收(GC),确保镜像仓库不会因为存储爆满而拒绝 containerd 的推送请求。
6.4 升级前的最小化变更清单
无论是升级 Harbor 还是升级 containerd,我都建议先攥一张清单:
- 确认新版本兼容当前使用的 hosts.toml 语法;
- 备份 Harbor 的配置目录和数据库;
- 记录当前所有机器人账号用途;
- 在测试环境完整跑一遍
nerdctl push/crictl pull/ kubelet 拉取; - 升级完成后,重新生成机器人 token 并同步到 containerd 侧;
- 滚动重启所有节点 containerd,观察日志。
这张清单我每次都会用,虽然看起来繁琐,但能避免很多半夜被叫醒的重复劳动。
这套 containerd 与 Harbor 的集成配置,本质上没有太高深的技术,但它横跨了 CRI、registry、证书、认证好几个层面,任何一个环节不匹配就会让整个链路失灵。我个人的体会是:先把 hosts.toml 和 config.toml 的分工搞清楚,再动手配置,后面那些报错基本上都能一眼定位。希望这份经验能帮你少走点弯路。