containerd 2.x 对接 Harbor 私有仓库配置与排错指南 上个月接到一个“看起来很简单的”任务一台 Ubuntu 24.04 上新装的 containerd 2.x怎么把镜像推到内网的 Harbor 仓库里去。本以为是三分钟的事结果一执行就报了一个非常经典的错误——harbor 推送失败get https://192.168.209.133/v2/: dial tcp 192.168.209.133:connect: connection refused。这个报错是不是很眼熟如果眼熟说明大概率也是从 Docker 切到 containerd 之后直接把 Docker 那套“开箱即用”的思维带过来了。Docker 有 daemon.jsoncontainerd 有自己的 config.toml 和 hosts.toml两者完全不是一套体系。这篇文章会把 containerd 2.x 接入 Harbor 的完整方法拆开讲清楚先讲配置体系为什么和 Docker 不一样再讲 hosts.toml 的核心规则然后给出一套 Ubuntu 上可以直接抄的实操命令最后把推送和拉取过程中的高频报错全部列出来附上排查思路。适合三类人正在切 containerd 的 Docker 老用户、需要维护 K8s 节点私有仓库的运维同学、以及第一次在 containerd 环境里对接 Harbor 的新手。1. 先搞清楚containerd 2.x 的仓库配置逻辑和 Docker 根本不是一回事1.1 为什么 Docker 的 daemon.json 在 containerd 上完全不生效很多人在 Docker 环境里配私有仓库第一反应就是去改 /etc/docker/daemon.json把 insecure-registries 加上然后在每台机器上重启 docker。这个思路本身没有错但切到 containerd 后这套方法就完全失效了。本质原因是进程环境不同。Docker 是通过 dockerd 这个守护进程统一下发配置它认识 daemon.json 里的 insecure-registries、registry-mirrors 这些字段而 containerd 是一个独立的容器运行时它不读 daemon.json。containerd 2.x 的配置主文件只有一个就是 /etc/containerd/config.toml镜像仓库相关的细分配置则放在 /etc/containerd/certs.d 目录下。我习惯把这两者做一个类比daemon.json 相当于“手机运营商统一配置”你告诉它“哪些号码免流”它就全局记住而 containerd 的 certs.d 目录更像是“通讯录分组”每个仓库地址单独存一份联系人信息——协议是什么、证书是什么、要不要跳过验证。这个设计看似麻烦实际上更适合多仓库环境因为一台节点往往要对接多个私有仓库每个仓库的协议和证书都不一样全局一份配置反而容易互相冲突。除了配置位置不一样还有一层更容易被忽略的差异Docker 的 insecure-registries 是“全局豁免”思维只要这个地址被列进名单所有对该地址的 HTTPS 校验都会被跳过。containerd 的 hosts.toml 则是“逐仓声明”思维你要明确告诉它这个仓库用什么协议、用哪份证书、需不需要跳过校验。权限粒度不同排查问题的入口也不同。1.2 “get https://IP/v2/” 这句报错里藏着三个关键信息开始动手之前先学会看一句包含 get https://192.168.209.133/v2/ 的报错。这个报错信息可以拆成三层URL 部分/v2/ 是 OCI 分发规范里所有 registry 的 API 固定前缀Harbor 也不例外。containerd 在连接 Harbor 之前会先请求这个路径做能力协商。只要这个请求失败后面所有操作都会报错。传输层部分dial tcp 是 Go 语言网络库的标准报错前缀后面跟着目标地址和端口。比如 dial tcp 192.168.209.133:443: connect: connection refused说明 TCP 层根本没连上。结果部分后面跟着的具体错误类型决定了你到底应该去查网络、查协议还是查证书。很多新手看到 get https://IP/v2/ 就开始怀疑 Harbor 配置但真正的病根可能只是 Harbor 没有启动、端口写错了、或者仓库实际走的是 HTTP 而你写成了 HTTPS。所以我在后面第5章会把这些报错逐一展开。这里先记住一句话containerd 接入 Harbor一共就三件事要确认——目标通不通、协议对不对、认证过不过。先把这三件事梳理清楚再去看具体配置基本不会走弯路。2. hosts.toml 才是接入 Harbor 的核心配置2.1 目录结构一个仓库地址对应一个目录containerd 2.x 的镜像仓库配置核心是 /etc/containerd/certs.d 目录。这个目录下不是一个大配置文件而是按仓库地址分目录/etc/containerd/certs.d/192.168.209.133/hosts.toml /etc/containerd/certs.d/192.168.209.133:8443/hosts.toml目录的命名规则很严格必须和镜像地址的主机名 端口完全一致。比如 Harbor 地址是 192.168.209.133:8443目录就必须叫 192.168.209.133:8443如果端口是默认的 80 或 443一般可以省略但我建议你宁可写上端口也不要偷懒。因为目录名就是 containerd 判断“这份配置到底属于哪个仓库”的唯一依据。写错一个字符配置再漂亮都白搭而且这类错误很难定位因为 containerd 不会主动告诉你“目录名匹配失败”。目录里除了 hosts.toml通常还会放 CA 证书比如我自己习惯把 Harbor 的 ca.crt 直接放在这个目录下和 hosts.toml 放一起。这样整个仓库相关的东西都集中在一个目录里以后排查问题也好找。这个设计对多仓库环境特别友好每个仓库一个独立目录互相不干扰。改一个仓库的配置也不会影响其他仓库。2.2 让 containerd 真正认这个目录config_path 开关建好 certs.d 目录和 hosts.toml 之后还有一个非常容易漏掉的环节确认 config.toml 里有没有显式开启 certs.d 目录的开关。在 containerd 的配置体系里这个开关叫 registry.config_path。不同发行版里这个配置所在的插件段落名称会有差异。K8s 里常见的写法是version 2 [plugins.io.containerd.grpc.v1.cri.registry] config_path /etc/containerd/certs.d而 containerd 2.x 如果采用了新的外部 CRI 插件方案段落名可能变成 io.containerd.cri.v1.runtime段落内部依然是 registry.config_path。我的实际经验是别凭记忆写插件名直接去现网配置文件里搜 registry 关键字最稳妥。grep -n registry /etc/containerd/config.toml如果输出里已经有 config_path 指向了 /etc/containerd/certs.d那就不用再改如果没有需要手动加上。加完 config_path 后一定要重启 containerd 让配置生效。这里强调一下在 K8s 节点上执行 systemctl restart containerd会把节点上所有容器重启一遍建议提前做好工作负载迁移或排维护时间。2.3 hosts.toml 关键字段解读hosts.toml 里最常用到的字段就这么几个我用一个表格整理出来字段作用典型值server声明这个仓库的默认访问地址协议主机端口https://192.168.209.133:8443[host.地址]定义具体 endpoint 的详细配置[host.https://192.168.209.133:8443]ca访问该仓库时使用的 CA 证书路径/etc/containerd/certs.d/192.168.209.133:8443/ca.crtskip_verify是否跳过 TLS 证书校验true / falsecapabilities声明该 endpoint 支持的操作[pull, resolve, push]其中最容易理解错的是 server 字段。它决定了 containerd 默认用什么协议去访问目标仓库。如果 Harbor 是 HTTP 模式server 就写 http:// 开头如果是 HTTPS就写 https:// 开头。我把这个字段理解为“本次对接的协议总开关”很多“推送失败”的根源就在这里——服务端明明是 HTTP客户端却按 HTTPS 去握手自然报错。举个实际的例子。如果 Harbor 是 HTTP 模式hosts.toml 可以这样写server http://192.168.209.133 [host.http://192.168.209.133] capabilities [pull, resolve, push]如果是自签 HTTPS 模式关键是把 Harbor 用的 CA 证书放进 ca 字段server https://192.168.209.133:8443 [host.https://192.168.209.133:8443] ca /etc/containerd/certs.d/192.168.209.133:8443/ca.crt注意官方字段名是 capabilities 复数形式我见过有人写成单数导致配置不生效这个细节非常坑。另外skip_verify 只在 HTTPS 场景下有意义如果 server 已经写成 http这个字段填不填都无所谓因为根本没有 TLS 握手环节。3. 接入之前先确认清楚 Harbor 的协议、证书和权限3.1 一个 curl 命令判断 Harbor 到底是 HTTP 还是 HTTPS在写 hosts.toml 之前最重要的一步是先确认 Harbor 实际提供的协议。很多人在这一步就踩坑Harbor 明明是 HTTP 模式却想当然地写 https结果怎么改都不通。判断方法非常简单在客户端上用 curl 分别测试 http 和 https 两个协议看哪个有正常响应curl -v http://192.168.209.133/v2/ curl -v https://192.168.209.133/v2/如果 http 那个能返回一个 JSON 或 401 之类的正常 HTTP 响应说明 Harbor 的 nginx 入口走的是 HTTP如果 https 那个能返回 401说明走的是 HTTPS。两个都试一下以实际输出为准而不是以 Harbor 安装文档上写的为准。因为 Harbor 在很多内网环境里安装时如果没有提供证书安装器会自动生成一个 http 模式的配置对外端口可能是 80也可能是 8080甚至可能是 443 上的 nginx 直接返回 502。确认协议之后再确认端口。Harbor 通过 docker compose 管理端口映射常见端口是 80、443、8080、8443 等。在 Harbor 服务器上执行 docker ps | grep -E nginx|proxy或者 docker compose ps就能看到实际映射的端口。客户端这边可以用 nc 验证端口连通性nc -vz 192.168.209.133 8443如果端口不通后面无论怎么调 hosts.toml 都没有用。先把网络层这一关过了再去考虑协议和证书。这个检查顺序特别重要我自己吃过亏有次折腾了半小时证书最后发现是 Harbor 升级后端口映射从 443 变成了 8443连网络层都没通纯属白忙。3.2 证书策略内网环境直接用 HTTP还是配自签 HTTPS关于 Harbor 接入 containerd我的观点很明确如果你的环境是完全受信任的内网HTTP 模式完全够用配置最少维护成本也最低。Docker 时代大家习惯配 insecure-registries本质上也是放弃 TLS 校验只是把配置放在一个全局字段里。containerd 时代无非是把同样的意图写进 hosts.toml让 server 指向 http 协议。但有些环境有安全审计要求必须走 HTTPS。这时候建议直接生成一套自签名证书给 Harbor 用然后把 CA 分发给所有需要访问的客户端。自签证书的生成逻辑是先做一个 CA再用 CA 签服务器证书。这里不展开完整的 openssl 配置只提醒几个要点服务器证书的 Common Name 或 Subject Alternative Name 必须包含 Harbor 的访问地址比如 IP 192.168.209.133否则客户端校验时会报证书域名不匹配。Harbor 的 HTTPS 模式需要在安装配置里指定 certificate_path 和 private_key_path。客户端的 hosts.toml 里 ca 字段填的是签发服务器证书的 CA 文件而不是服务器证书本身。填错的话证书校验依然会失败。如果你已经拿到了 Harbor 用的 ca.crt可以直接把它复制到客户端的 /etc/containerd/certs.d/仓库地址/ 目录下。复制完记得校验一下文件内容不要复制了一个空文件或者复制成服务器证书。用 openssl 验证最简单openssl x509 -in /etc/containerd/certs.d/192.168.209.133:8443/ca.crt -noout -subject -issuer3.3 Harbor 项目与账号的最小权限配置Harbor 的仓库路径里项目名是必填的一级目录。比如要推送 nginx 镜像完整路径是 192.168.209.133:8443/library/nginx:1.24其中 library 是项目名nginx 是仓库名1.24 是 tag。项目名不能省略。很多新手第一次接触 Harbor 时把镜像打成 192.168.209.133:8443/nginx:1.24就会在推送时报错因为 Harbor 找不到 nginx 这个项目。权限方面Harbor 的默认项目 library 对所有用户可见但推送需要账号具备对应角色。测试阶段直接用 admin 账号没问题生产环境我建议用机器人账号或者创建一个专门的服务账号只给它加入目标项目并分配“开发人员”角色。开发人员角色可以拉取和推送镜像但无法删项目权限边界比较清晰。需要注意用户名和密码里如果有特殊字符比如 或者 /login 时容易解析出错可以先在终端里输入完整命令确认输出提示登录成功再继续。另外Harbor 的机器人账号在推送时镜像路径前缀依然要带项目名但凭证用户名是一个固定格式字符串不要把账号和密码填反了。4. Ubuntu 完整实操从写配置到成功 push 镜像4.1 确认 containerd 版本与安装 nerdctl动手前先看一下自己手里的 containerd 到底是哪个版本避免拿 1.x 的配置思路套 2.xcontainerd --version ctr versioncontainerd 2.x 的版本号现在类似 2.1.x如果输出是 2.0.x 或 2.1.x那就可以按本文方案操作。接下来确认有没有 nerdctl。ctr 是 containerd 自带的调试客户端但它不支持 login 命令日常推拉私有仓库体验很差所以我又装了 nerdctl。nerdctl 是 containerd 生态里体验最好的 CLI命令风格和 docker 几乎一致支持 login、tag、push、pull、run 都行。安装 nerdctl 很简单从官方 release 页面下载对应的二进制放到 /usr/local/bin给执行权限就行curl -sSL -o /usr/local/bin/nerdctl 下载地址 chmod x /usr/local/bin/nerdctl nerdctl version这里给个建议不要用系统自带的 ctr 来登录 Harbor它没有 login 子命令每次配置凭证都比较绕。老老实实用 nerdctl省心很多。如果你所在环境不允许从外网下载也可以从另一台已经装了 nerdctl 的机器上直接拷贝二进制依赖很少一般能直接运行。4.2 写入证书与 hosts.toml假设 Harbor 地址是 192.168.209.133:8443走 HTTPS并且我已经拿到了 Harbor 的 ca.crt。接下来就是三步第一步创建目录mkdir -p /etc/containerd/certs.d/192.168.209.133:8443 cp /path/to/ca.crt /etc/containerd/certs.d/192.168.209.133:8443/第二步写 hosts.tomlcat /etc/containerd/certs.d/192.168.209.133:8443/hosts.toml EOF server https://192.168.209.133:8443 [host.https://192.168.209.133:8443] ca /etc/containerd/certs.d/192.168.209.133:8443/ca.crt capabilities [pull, resolve, push] EOF如果你的 Harbor 走的是 HTTP比如地址是 192.168.209.133就用这个版本mkdir -p /etc/containerd/certs.d/192.168.209.133 cat /etc/containerd/certs.d/192.168.209.133/hosts.toml EOF server http://192.168.209.133 [host.http://192.168.209.133] capabilities [pull, resolve, push] EOF第三步检查 config.toml 里有没有指向 certs.d 的 config_path。如果 grep 不到就手动追加。追加完成后重启 containerdsystemctl restart containerd重启这步请注意如果是 K8s 计算节点上面所有 Pod 会经历一次重建。如果只是想验证配置也可以先跳过重启直接上手操作但如果后续命令报“配置没生效”大概率就是没有重启或没有正确设置 config_path。4.3 登录 Harbor 并完成第一次推送配置写完后先用 nerdctl login 验证一遍认证链路nerdctl login 192.168.209.133:8443 -u admin它会提示输入密码。登录成功后再执行镜像拉取、打 tag、推送流程。我演示一下完整命令序列直接可以抄# 1. 从 Docker Hub 拉一个官方镜像 nerdctl pull nginx:1.24 # 2. 打成 Harbor 仓库路径的 tag nerdctl tag nginx:1.24 192.168.209.133:8443/library/nginx:1.24 # 3. 推送到 Harbor nerdctl push 192.168.209.133:8443/library/nginx:1.24push 过程中如果有进度条一直在走说明 hosts.toml 的 capabilities 配置没问题如果卡在 login 或者直接报错回到第3节去查协议和证书。第一次推送成功之后最好到 Harbor 的 Web 界面里刷新一下确认 library/nginx 这个仓库真的出现了。这一步不麻烦但能确认数据层面的写入确实成功而不仅仅是 CLI 端认为成功。4.4 从 Harbor 拉取镜像并运行容器验证推送成功只验证了写路径还需要验证读路径。在另外一台只配置了 containerd 的机器上尝试直接拉取刚才推送的镜像nerdctl pull 192.168.209.133:8443/library/nginx:1.24 nerdctl run -d --name nginx-test -p 8080:80 192.168.209.133:8443/library/nginx:1.24如果这台机器没有登录过 Harbor而 library 项目是公开的拉取应该直接成功如果项目是私有的需要先执行 nerdctl login。实际验证一下 curl http://localhost:8080 返回正常 Nginx 页面整个流程就闭环了。这里补充一个经验containerd 的命名空间概念对新手很容易产生困扰。nerdctl 默认工作在 default 命名空间而 kubelet 使用的是 k8s.io 命名空间。如果用 nerdctl run 启动的容器kubelet 是看不到的两者互不干扰。但这也意味着你在命令行拉取的镜像并不会自动出现在 K8s 可用的镜像列表里——除非你也在 k8s.io 命名空间里执行同样的操作。日常排查时别忘了查看当前命名空间nerdctl --namespace k8s.io pull 才能命中 K8s 路径。4.5 如果上面这套配置用在 Kubernetes 节点上很多读者是在 K8s 节点上解决 containerd 接入 Harbor 的问题所以单独说一节。核心配置是一样的config_path 指向 certs.d 目录之后kubelet 通过 CRI 调用 containerd 拉镜像时自然就会使用 hosts.toml 里的协议和证书配置。区别在于认证方式kubelet 不会直接使用你在终端里 nerdctl login 留下的凭证需要在创建 Pod 时显式指定 imagePullSecrets。标准做法是用 docker-registry 类型的 Secret 保存 Harbor 凭证kubectl create secret docker-registry harbor-cred \ --docker-server192.168.209.133:8443 \ --docker-usernameadmin \ --docker-password你的密码 \ --docker-email你example.com然后在 Deployment 或 Pod 里加上spec: imagePullSecrets: - name: harbor-cred containers: - name: nginx image: 192.168.209.133:8443/library/nginx:1.24另外containerd 2.x 使用 K8s 时还要留意 CRI 插件的形态。2.0 以后官方的 CRI 插件已经拆成了独立组件如果你是用官方 release 包手动部署的需要额外安装 cri 插件否则 kubelet 会报 CRI 接口未实现。包管理器装出来的 containerd.io 一般已经包含 CRI 支持但自编译或裸二进制部署很容易漏掉这一环。5. 高频报错实录每个报错我都替你踩过一遍5.1 dial tcp 192.168.209.133:443: connect: connection refused这个报错是网络层没连通和 containerd 的配置没有半毛钱关系。先检查 Harbor 服务器上端口是否监听ss -lntp | grep -E 443|80|8443再检查客户端到服务器的连通性nc -vz 192.168.209.133 8443如果服务器没监听说明 Harbor 没起来或者端口映射不对去 Harbor 服务器上执行 docker compose ps 看一下组件状态。如果客户端连不通要检查防火墙和安全组规则。这里我踩过一个大坑Harbor 服务器本地访问 443 是通的但外部访问不通最后发现是云安全组只放行了 80没有放行 443。排查网络问题时一定要在客户端机器上验证而不是在服务器本机验证。5.2 x509: certificate signed by unknown authority这个报错是证书校验失败包含两种具体情况。第一种是 Harbor 走了 HTTPS但客户端 hosts.toml 里没有配置 ca或者 ca 路径写错了第二种是 ca 文件填成了服务器证书而不是签发它的 CA 证书。解决办法就是回到第3节的证书策略把正确的 CA 文件放到 hosts.toml 指定的路径下然后重启 containerd。还有一种临时性做法在 hosts.toml 里把 skip_verify 设为 true绕过证书校验。这个操作在纯内网测试环境里可以应急但生产环境我不建议长期开着因为敏感流量等于裸奔。如果审计要求高尽量把 ca 配置到位。判断 ca 文件到底对不对用这条命令openssl x509 -in ca.crt -noout -subject -issuer输出里的 subject 要和你生成的 CA 名称一致而 issuer 就是 CA 自身这样才说明这是一个真正的 CA 证书。如果看到的就是服务端证书那就要换。5.3 http: server gave HTTP response to HTTPS client这个报错是协议不匹配的经典症状英文已经说得很直白服务端返回的是 HTTP 响应但客户端发的是 HTTPS 请求。最常发生在 Harbor 明明走 HTTP而 hosts.toml 里 server 却写成 https://或者干脆没写 server 导致 containerd 默认按 https 访问。解决办法是把 hosts.toml 里 server 改成 http:// 对应的地址。改完重启 containerd再重新 push。我测试过这个错误在 push 时特别容易遇到因为推送默认会用 manifest 和 blob 的完整 HTTP 请求一旦协议不匹配整个过程会非常干脆地失败。5.4 unauthorized: unauthorized to access repository这个报错是认证或权限问题命令层面没有任何网络和 TLS 问题。常见原因有三个用户没有登录或者登录的是别的仓库地址。检查一下 nerdctl login 的地址和 push 的镜像地址是否完全一致。用户对目标项目没有 push 权限。Harbor 项目角色需要至少“开发人员”。镜像路径里的项目名不存在或拼写错误。比如 library 写成了 libray或者把项目名漏掉了。另外Harbor 项目如果是私有权限拉取镜像时也要求登录否则会看到类似 pull access denied 的报错。这时候回到 Pod 的 imagePullSecrets 配置确认 Secret 里的 docker-server 字段和镜像地址完全一致包括端口。5.5 常见错误速查表最后给你整理一个速查表遇到问题时先对照表里看一遍八成能直接定位报错关键词大概率原因第一动作connection refusedHarbor 未启动或端口不通nc 验证端口docker compose ps 查组件no route to host网络路由问题检查防火墙、安全组、跨网段访问x509 unknown authorityCA 未配置或配置错误检查 hosts.toml 的 ca 字段server gave HTTP response to HTTPS client协议不匹配server 改成 http://unauthorized未登录或权限不足检查登录、项目角色、路径manifest unknowntag 不存在或仓库名错误到 Harbor Web 确认仓库和 tagproject not found项目名不存在在 Harbor 创建项目再推送这个表我在排障时一直贴在旁边基本覆盖了 containerd 对接 Harbor 的绝大多数问题。还有一些不太常见但会遇到的比如磁盘不足导致 blob 写入失败、超大镜像推送时网络超时、Harbor 的垃圾回收任务锁住仓库导致 push 被拒这些往往要结合 Harbor 服务端的日志来排查属于另一个话题了。最后说点个人体会。接触 containerd 2.x 一段时间之后我发现它和 Docker 的区别并不只在配置格式上更在思维模式上Docker 希望你把所有仓库设置堆在一个全局文件里而 containerd 的 hosts.toml 体系强调的是“每个仓库独立成册”。刚开始会觉得目录多、配置散但维护过几台节点后反而觉得这种方式更清晰因为你永远不会因为改一个仓库的证书而影响其他仓库。再分享一个实用小技巧如果你有多个 Harbor 实例或者想给同一个仓库地址配置多个端点比如先走内网缓存缓存挂了再回源 Harbor可以在同一个 hosts.toml 里堆多个 host 块containerd 会按顺序尝试。这个玩法非常香等于把 Docker 时代要写好几处配置的事浓缩在一个文件里完成。这次我接入 Harbor 的实际过程从报 connection refused 到最终成功 push前后只花了不到半小时熟练之后其实就是一个“写文件 重启 login”的循环。希望这篇文章能帮你跳过我最开始踩的那些坑一次就把 containerd 和 Harbor 打通。