1. 链路拆解从 registry 镜像仓库到 Pod 运行的全路径先说结论在 Kubernetes 集群里部署一个 docker registry 2.7.1 私有镜像仓库然后用 containerd 作为节点运行时去拉取这个仓库里的镜像最后把镜像跑成应用 Pod。这条链路看上去只有三步但实际踩过坑的人都知道真正的难点不在“部署 registry”也不在“写一个 Deployment YAML”而在于 containerd 那一层对私有仓库的认证与地址识别机制和大多数人熟悉的 docker 时代完全不兼容。docker registry 2.7.1 是什么它是镜像仓库服务的一个经典版本Docker 官方开源的 registry 镜像负责存储、分发容器镜像。2.7.1 这个版本号在 x86_64 架构下能稳定运行接口行为清晰不太有“新版本迁移”的负担非常适合私有化场景。containerd 是节点上的容器运行时——如果你用的是近几年默认安装的 Kubernetes 集群节点上的运行时大概率是 containerd而不是 docker。kubelet 需要拉取镜像时并不是自己连 docker daemon而是通过 CRIContainer Runtime Interface插件调用 containerd 来完成拉取动作。这一步是整条链路里用户最容易掉坑的地方因为很多人在排障时还在习惯性地看 docker 的配置而实际上 containerd 的配置路径和策略完全独立。整个镜像拉取动作的物理流程可以这样描述当你提交一个 Pod 到集群kubelet 经过 Admission 等控制面流程后发现本地没有对应镜像于是通过 CRI 向 containerd 发出 PullImage 请求。containerd 根据/etc/containerd/config.toml中的全局配置解析镜像地址。如果我们写的是registry.example.com/myapp:v1这种自定义地址containerd 会按照 host 匹配规则找到对应配置例如config.toml中的 registry 配置目录/etc/containerd/certs.d/registry.example.com读取该目录下的hosts.toml拿到仓库的 endpoint、tls 配置和认证信息最终发出 HTTP/HTTPS 请求拉取镜像。镜像落到 containerd 的内容存储后kubelet 用 CRI 创建 sandbox 和容器镜像被挂载为容器 rootfs 的只读层应用才真正跑起来。这条链路里每个节点都有讲究下面我按部署顺序把每一环掰开讲清楚。2. 私有仓库部署的选型思考与落地细节2.1 为什么要选 registry 2.7.1 而不是直接上新版本在生产环境里很多团队在选择镜像仓库时直接拉registry:2的最新版。但我在实际项目中见过不少用 2.7.1 的存量环境——它们保存在内部离线包、老脚本或是某个私有制品库的固定版本里。registry 2.7.1 的 API 版本是 v2支持 manifest list、分层上传、Bearer token 认证基础功能非常稳定。对于没有大规模高并发、不需要垃圾回收机制频繁调优的企业内部场景这个版本完全够用。另一个现实原因是从 2.7.x 往后的 registry 对配置项和存储驱动的行为有过调整比如对 filesystem 驱动下的目录权限要求更严格升级后可能要额外处理历史数据的兼容。如果你不是非要用新特性锁定 2.7.1 并在部署时固定镜像 digest反而更可控。我在某公司的离线环境里就是这么干的interior network 隔离根本拉不了 docker hub 的镜像只能在本地 load 一份registry:2.7.1的 tar 包再用 containerd 导入并打 tag然后单独跑一个容器作为仓库服务。2.2 部署 registry 前的几个关键决策点部署 docker registry 2.7.1 时第一个决策是存储方式。单机测试或小规模环境用默认的 filesystem 驱动挂一个本地目录即可多副本高可用则需要把存储切到对象存储或共享文件系统但这就引入了额外组件。这里只讲单机部署最直接的方案是用 docker 或 containerd 来跑 registry 本身。如果你是想验证“从零到一”的闭环直接用 containerd 来跑 registry 也完全可以——ctr images import导入镜像ctr run起容器这个思路反而更顺手因为我后面配置的就是 containerd不需要在节点上额外装 docker。第二个决策是 TLS 方案。私有仓库如果走 HTTPcontainerd 侧默认会拒绝非加密连接必须显式配置skip_verify或明文 endpoint。如果走 HTTPS又分两种情况一种是用受信任 CA 签发的证书另一种是自签名证书。这里我强烈建议即使是在内网也不要图省事把所有节点都配成skip_verify true因为一旦你后面接入其他组件、日志采集器或 CI 流水线这些组件直接访问仓库镜像时跳过校验会导致一堆难以排查的连接失败。真正稳妥的路径是给仓库配一个自建 CA 或内网 CA 签发的证书然后在每个节点的 containerd 配置里指定ca_file。后面我会把这两种方式的配置差异都写清楚。第三个决策是端口和权限。registry 容器默认监听 5000 端口很多公司内部喜欢用 5000 或 443。端口不是核心问题但要注意 SELinux 或防火墙策略会拦截非标准端口的对外访问。另外一个常见失误是忘记设置数据目录的属主——registry 容器进程以非 root 身份运行如果挂载目录chmod为 755 且属主是 rootregistry 会报 permission denied。我见过太多人在这一步卡住实际上只需要确保目标目录对容器内用户可写即可例如chown 1000:1000或chmod 777测试环境就能解决。2.3 docker registry 2.7.1 的完整部署过程先把 registry 跑起来。假设你已经把离线镜像包里的registry:2.7.1导入到了 containerd 中。用 ctr 启动它# 创建数据目录 mkdir -p /data/registry-data # 用 containerd 启动 registry 容器demo 环境不走 systemd 管理 ctr images import registry-2.7.1.tar ctr images tag docker.io/library/registry:2.7.1 registry.local:5000/library/registry:2.7.1 ctr run --net-host \ --mount typebind,src/data/registry-data,dst/var/lib/registry,optionsrbind:rw \ registry.local:5000/library/registry:2.7.1 \ registry-server这个命令用--net-host直接共享宿主机网络registry 默认监听在 5000 端口。如果你想让数据更可控其实更推荐的还是写一个 docker-compose 或者原生容器启动脚本但无论如何跑起来之后先验证curl http://127.0.0.1:5000/v2/正常会返回{}。这个响应表示 registry 的 v2 API 可用。接下来要做的不是急着推送镜像而是先构造一个测试镜像推到私有仓库因为后面 Pod 拉取的镜像必须真实存在于仓库里。找一个基础镜像比如busybox:1.36打个私有仓库的 tagctr images pull docker.io/library/busybox:1.36 ctr images tag docker.io/library/busybox:1.36 registry.local:5000/demo/busybox:1.36 ctr images push --plain-http registry.local:5000/demo/busybox:1.36注意--plain-http是因为当前 registry 还没上 TLS如果这条命令执行成功就说明仓库存储和 API 都正常。3. containerd 侧的配置决定了 Pod 能否拉取镜像3.1 containerd 配置的基本结构认知这一节是整个主题的核心多花点篇幅讲透。containerd 的配置文件在/etc/containerd/config.toml如果是用发行版仓库装的通常默认存在。如果你是用 kubeadm 或二进制方式装的 Kubernetes基本也会生成默认配置。这个文件内容比较长但我们需要关心的核心段落是[plugins.io.containerd.grpc.v1.cri.registry]。从 containerd 1.4 之后的版本开始CRI 插件里的 registry 配置推荐使用config_path而不是老的mirrors和auths字段。config_path指定一个目录containerd 会在这个目录下按照镜像仓库的主机名去查找对应的子配置文件子配置文件名是hosts.toml。这种设计跟 docker 那套单一 daemon.json 的思路完全不同docker 是全局一个大 JSON 文件containerd 是按主机名分目录管理一个仓库一个配置互不干扰排障时定位很快。我们要修改的内容大致是这个样子version 2 [plugins.io.containerd.grpc.v1.cri] [plugins.io.containerd.grpc.v1.cri.registry] config_path /etc/containerd/certs.d注意config_path本身和老的[plugins.io.containerd.grpc.v1.cri.registry.mirrors]是互斥的。如果你同时配置这两个字段containerd 会启动报错或者直接忽略其中一个。我见过不少老教程还在让用户按 mirrors 方式配置那是 containerd 1.3 前后的做法现在的新版本更推荐config_path两者选一种别混用。3.2 hosts.toml 文件怎么写endpoint 和 TLS 策略假设我们的私有仓库地址是registry.local:5000那么在配置目录下要新建mkdir -p /etc/containerd/certs.d/registry.local:5000注意这个目录名必须包括端口号因为镜像地址里的 host 是完整的registry.local:5000。如果你写的是registry.local而镜像里带端口号containerd 会匹配不上直接按默认 docker hub 行为处理接口报 404 或 timeout。在这个目录下创建hosts.toml文件# 用于 http 明文传输的私有仓库 server http://registry.local:5000 [host.http://registry.local:5000] capabilities [pull, resolve, push] skip_verify false这里server字段定义了这个“逻辑仓库”的默认协议前缀。设置skip_verify false有点误导因为对于明文 http 地址containerd 其实不需要跳 TLS 校验直接用http://前缀它就会走明文。不过如果仓库分别有http和https两种访问方式需要额外定义[host]条目。更常见的演示场景是server https://registry.example.com [host.https://registry.example.com] ca_file /etc/containerd/certs.d/registry.example.com/ca.crt这表示访问该仓库走 HTTPS并且使用自签 CA 证书。如果仓库没有可信任的证书而你坚持要用 HTTPS 但不想配 ca_file可以把skip_verify true写上但这属于调试用配置不建议长期放在生产节点。还有一个细节容易被忽略[host....]里可以配置client和client_key字段用于 mTLS 双向认证也可以配置headers字段附加自定义头但一般用不到。我们在这个场景里只需要关心 endpoint、ca_file、skip_verify 这三个字段就够了。3.3 配置完成后如何让 containerd 平滑加载修改完config.toml和hosts.toml需要重启 containerd 才能生效。但要注意如果节点上已经运行很多业务 Pod直接systemctl restart containerd会导致节点上所有容器重启——这一点在 Kubernetes 环境里几乎是不可接受的。所以正确做法是systemctl reload containerd让 containerd 重新加载配置而不重启已有容器。不过 reload 有一个前提就是 containerd 版本要足够新且配置文件本身没有语法错误。如果 reload 报错说明 config.toml 有解析问题需要先修正再 reload。这里有一个实操中的小技巧在 reload 之前先用containerd config dump看一下当前生效配置确认config_path已经生效再去验证 hosts.toml 是否被读取。containerd 不会在日志里主动打“我加载了哪个 hosts.toml”所以如果你发现配置不生效先检查文件名是否和镜像地址完全一致。我本人的习惯是 reload 后立刻用ctr --namespace k8s.io images pull registry.local:5000/demo/busybox:1.36做一次手动拉取验证。注意命名空间kubelet 向 containerd 发出的请求默认在k8s.io命名空间直接用ctr不带 namespace 的话操作的是默认命名空间拉下来的镜像 kubelet 根本看不到。这个错误我在刚开始用 containerd 时踩过好几次。4. Pod 部署实操从镜像验证到业务应用运行4.1 手动拉取验证别急着创建 Pod配置好 containerd 之后先用 ctr 在k8s.io命名空间下验证一次拉取ctr --namespace k8s.io images pull registry.local:5000/demo/busybox:1.36如果这一步成功说明 containerd 层面的私有仓库连接、认证、TLS 策略全部没问题。此时再交给 Kubernetes 去创建 Pod排障范围就缩小到了 Kubernetes 对象的配置问题上。如果 ctr 这一步就失败那么先不要碰任何 YAML回上面检查 hosts.toml。这里强调一下为什么必须手动验证。很多人在配置完 containerd 后直接去kubectl apply一个 Deployment发现 ImagePullBackOff然后开始怀疑私有仓库怀疑网络怀疑 DNS排查一圈回来发现是 hosts.toml 里文件名少写了端口号。这类问题如果先在 ctr 层面拦截一次五分钟就能定位。这是我在给团队做培训时反复强调的模式链路排障永远从最底层向下游一层验证而不是从最上层的 Pod 事件开始猜。4.2 imagePullPolicy 与镜像 tag 策略的关系Pod 要拉取的镜像地址可以写在 Deployment 或 Pod 的 spec 里。比如apiVersion: apps/v1 kind: Deployment metadata: name: demo-app spec: replicas: 1 selector: matchLabels: app: demo-app template: metadata: labels: app: demo-app spec: containers: - name: demo-container image: registry.local:5000/demo/busybox:1.36 command: [sleep, 3600] imagePullPolicy: IfNotPresent这里有一个所有初学者都要注意的坑。当镜像 tag 不是latest并且节点本地已经有同名同 tag 的镜像时默认的imagePullPolicy是IfNotPresentkubelet 会直接使用本地镜像根本不会去仓库检查。也就是说你改了镜像内容重新推了一个相同 tag 到仓库Pod 重建后还是会用旧镜像。这种情况下必须显式设置imagePullPolicy: Always或者更新镜像 tag。私有仓库场景下的镜像 tag 策略我建议要么用 commit hash 作为 tag要么统一走Always拉取策略避免本地缓存导致一致性问题。Always策略会带来微小性能损耗但对私有仓库来说完全可以接受尤其是内网带宽充足时多拉一次只有几毫秒到几十毫秒的开销远小于排障成本。4.3 部署业务应用镜像的完整演练接下来进入正题从私有仓库部署一个真实应用镜像到 Pod。假设我们的业务镜像已经推到registry.local:5000/library/nginx-demo:1.0.0构建和推送的步骤可以用 docker 完成也可以用ctrctr --namespace k8s.io images pull registry.local:5000/library/nginx-demo:1.0.0如果镜像在本地已经有则直接打 tag。创建一个 Deployment YAMLapiVersion: apps/v1 kind: Deployment metadata: name: nginx-demo spec: replicas: 2 selector: matchLabels: app: nginx-demo template: metadata: labels: app: nginx-demo spec: containers: - name: nginx image: registry.local:5000/library/nginx-demo:1.0.0 ports: - containerPort: 80 imagePullPolicy: Always应用之后观察状态kubectl apply -f nginx-demo.yaml kubectl get pods -w正常情况下 Pod 会从 Pulling 进入 Running。此时再用kubectl describe pod nginx-demo-xxx看 Events 里应该有一行类似Pulled image registry.local:5000/library/nginx-demo:1.0.0的记录。这个事件的出现在整个链路里的意义是kubelet 通过 CRI 调 containerd → containerd 根据 config.toml 的 config_path 找到 hosts.toml → 从 registry.local:5000 拉取分层 → 写入 k8s.io 命名空间 → 创建容器成功。每一个环节都打通了。4.4 验证镜像确实来自私有仓库而不是缓存集群环境里很难从 Pod 内部直接判断镜像是来自哪个仓库的但可以从 containerd 的镜像列表里看到拉取时间再比对 Pod 创建时间。更直接的方法是清空本地缓存后重建 Pod但生产环境不建议这么干。稳妥的验证方式是查看 containerd 日志或者用crictl pull拉一次再看ctr --namespace k8s.io images ls里的镜像 digest 是否与仓库里一致。我通常的做法是在仓库端开启 access log然后出发一次 Pod 重建看 access log 里有没有对应镜像名的 GET 请求。如果你没开 log也可以用registry容器标准输出来验证当 containerd 在拉镜像时registry 容器打出的日志会包含http.response.duration之类的字段。这个日志在验证链路时特别有用能直接证明“request 确实到达了 registry”而不是被 DNS 或 containerd 缓存拦住了。5. 常见问题与排障实录那些坑我替你踩过了5.1 问题一Pod 一直 ImagePullBackOff事件显示 manifest unknown这个报错的含义是 containerd 成功从仓库获取到了 API 响应但仓库返回 404表示它不认这个镜像名。最常见的原因是镜像 tag 在仓库层不存在。你本地推了一个1.0.0但 registry 存储在另一个节点上或者推送时 tag 写错。解决方案是先回到 push 环节用 curl 直接查询 registry APIcurl http://registry.local:5000/v2/library/nginx-demo/tags/list如果返回{name:library/nginx-demo,tags:null}或者tags列表里没有目标 tag那就说明推送环节就有问题。很多时候不是 containerd 的锅是镜像根本没推到仓库里。我见过有人ctr images push成功却看不到 tag原因是 Ctrl 推送时使用了--plain-http但仓库后面其实被防火墙隔离了导致 push 操作实际发生到了错误地址。5.2 问题二http: server gave HTTP response to HTTPS client这个报错几乎是 containerd 配 http 私有仓库时最经典的问题。报错信息翻译过来是containerd 在用 HTTPS 客户端去请求一个 HTTP 仓库。产生原因很简单hosts.toml里没写server http://...而镜像地址里又没有显式协议前缀containerd 默认用https://去连。解决办法有两个。一个是在hosts.toml中把server明确写成http://registry.local:5000另一个是在 containerd 全局配置中为该仓库 old_style 地配置[plugins.io.containerd.grpc.v1.cri.registry.mirrors]不推荐在 config_path 模式下混用。最直接的验证方式先 curl 一下仓库地址看它确实是明文 HTTP再回头检查 hosts.toml。要注意的是[host.http://...]的 key 也必须带http://前缀否则 containerd 把它当成 HTTPS 处理。这个细节很多人抄配置时漏掉最后用crictl pull手动拉一次立刻报同样的错。5.3 问题三拉取成功但 Pod 创建失败挂载时报找不到 snapshot这个问题发生在镜像下载完成后、容器启动时。containerd 需要为镜像创建 snapshot如果节点磁盘空间不足或者 overlay 文件系统出问题会报failed to create task或snapshotter: not found。这类问题跟 credentials 无关首先要看df -h确认/var/lib/containerd所在分区有足够 inode然后检查内核是否加载了 overlay 模块lsmod | grep overlay。在没加载的情况下要先modprobe overlay否则 containerd 无法创建 overlayfs snapshot。还有一个容易被忽略的场景如果你在 config.toml 里改了 snapshotter比如从 overlayfs 改成 native但之前创建过使用旧 snapshotter 的镜像新 Pod 拉新镜像后无法复用旧层。此时最保守的方法是让每个节点保证存储引擎统一不要混用 snapshotter。5.4 问题四自签名证书配置失败报 x509 certificate signed by unknown authority自签名证书的场景最绕。有些人在 hosts.toml 里写了ca_file但 containerd 还是报 unknown authority。原因通常是证书的 SANSubject Alternative Name和镜像地址不匹配。比如证书只签了registry.local但 containerd 访问的是registry.local:5000或者沙箱环境里你通过 IP 访问证书上没有这个 IP。解决方式是为仓库生成一张覆盖registry.local和对应 IP 的证书而不是在 containerd 里转圈调试。另外一个隐患是 CA 文件格式必须是 PEM。如果从 Windows 导出的证书是 DER 格式需要先转成 PEMopenssl x509 -inform DER -in ca.der -out ca.crt我见过一个团队的排障现场就卡在这里两天最后发现 ca.crt 第一行不是-----BEGIN CERTIFICATE-----。这种低级错误在日志里的表现非常隐蔽因为 containerd 不会提示“证书格式错误”只会报 unknown authority。5.5 问题五多个节点拉取时部分成功部分失败这个问题通常指向 hosts.toml 分发不完整。很多运维只在 master 或某个工作节点上改了 containerd 配置但 Kubernetes 集群里每个节点都有独立的 containerd任何节点拉取镜像都要走自己那套配置。如果你用的是 kubeadm 或二进制部署集群最稳妥的做法是写一个小脚本把/etc/containerd/certs.d/registry.local:5000/hosts.toml同步到所有节点然后逐台 reload。不要只同步/etc/containerd/config.toml因为 config_path 指向的目录里的 hosts.toml 才是真正按仓库名生效的内容一旦漏掉某台机器这台机器上的 Pod 就会 ImagePullBackOff。5.6 问题六registry 容器频繁重启排障时发现 registry 这个容器本身反复退出最典型的原因就是挂载目录权限不对。registry 2.7.1 的进程在容器内以非 root 用户运行默认 UID 是 1000对/var/lib/registry需要有写权限。启动时如果宿主机目录是 root:root 0755registry 进程写不了目录立马退出。最简单的解决办法是chown -R 1000:1000 /data/registry-data或者把宿主机目录权限设置成 0777。这个问题在 Kubernetes 集群里如果也用 Deployment 跑 registry同样适用。6. 一点个人体会流程先想清楚比工具更重要我自己在做这个部署时最大的体会是所有看起来“玄学”的问题最后都能归因到某一个环节的配置不匹配。registry 2.7.1 本身很稳定containerd 的行为也很确定真正需要认真对待的是“仓库地址解析”这个过程。你只要把镜像地址、hosts.toml 文件名、server 字段、TLS 策略这四个要素都对齐整个链路就非常清晰排障时间能从小时级降到分钟级。最后送一个建议给读者配置 containerd 的私有仓库时先把ctr --namespace k8s.io images pull当成标配验证手段不要直接跳到 kubectl 层。这个习惯能帮你把问题精确地划定在 containerd 配置层或 Kubernetes 编排层而不是在这两层之间来回猜。配合前面提到的 registry access log每次修改配置后做一次完整验证后面接入 CI/CD、镜像扫描、日志采集这类组件时你会发现自己省下了大量从“仓库不通”里排查系统问题的精力。