我们经常会在 Kubernetes 里碰到一种很尴尬的局面:应用代码、编排模板都准备好了,唯独镜像还在 Docker Hub 或者某个外部仓库里。一旦集群网络限制、拉取超时、版本覆盖,Pod 就卡在ImagePullBackOff,看事件全是和镜像拉取有关。这篇文章里的完整链路就是围绕这个问题展开的:先在内网 Kubernetes 中部署一个registry:2.7.1的私有镜像仓库,再把 containerd 的拉取链路打通,最后让应用 Pod 通过 imagePullSecrets 正常从私有仓库拉镜像并跑起来。整个过程中,我会把关键配置、命令和踩过的坑都摊开来讲,适合已经把 Kubernetes 基础组件玩熟、但对“私有镜像仓库 + containerd 运行时”这套组合还比较陌生的同学参考。
1. 为什么要在 Kubernetes 里自建 Registry
1.1 镜像来源不可控带来的麻烦
大多数项目刚开始都直接image: nginx:latest,开发者机器上随便 pull 一下看起来没啥问题。但真正进入交付或测试环境后,镜像来源就变成了一个隐患:公共仓库的访问可能超时,网络隔离环境根本到不了外网,团队里某个人重新 push 了同名 tag,旧版本就再也找不回来。更不要说有些场景要求镜像内部包含内部依赖、基础包、配置文件,这些东西放在外部仓库既不安全,也不好审计。
在 Kubernetes 的视角里,镜像从哪个仓库来,本质上只取决于两件事:节点上的运行时能不能访问到那个地址,以及拉取时是否携带了合法的凭证。只要把这两个问题解决,镜像从自建仓库拉和从公共仓库拉,对 kubelet 来说没有区别。正因为这样,私有仓库的价值才非常明显:它把“镜像来源”这件事收拢到自己的基础设施里,变成一个内网地址。
1.2 为什么还要坚持“私有库优先”
我身边不少同事会觉得,环境里已经装了 Harbor 或者别的制品仓库,为什么还要单独折腾一个 Docker Registry?其实在很多场景下,一个轻量级的 registry 镜像反而更合适:它只是一个纯 Go 写的 HTTP 服务,内存占用小,部署逻辑简单,也方便团队内部临时起一个隔离仓库给某个专项使用。没有额外的数据库依赖,存镜像就是本地目录里的块文件,配合 Kubernetes 的 PVC 就能完成持久化。
另外,“私有库优先”的好处不只在拉取速度上。当所有镜像都在内网以后,节点上的 containerd 只需要配置一个信任域和一套认证信息,天然减少了对公网的依赖,也让团队能够对镜像做统一的清点、备份和清理。
2. 部署前的架构与版本选择
2.1 containerd、Kubelet 和 Registry 三者如何配合
正常的 Pod 调度流程是:kubelet 收到 Pod 定义后,发现镜像不存在或策略需要拉取,就把这个任务交给位于本机的 CRI 运行时 containerd。containerd 根据config.toml里的 registry 配置决定去哪个地址拉镜像,并根据 kubelet 传过来的镜像拉取 Secret 决定用哪组账号。
这个链路里有两个独立配置:一个是 containerd 对 registry 的全局配置,包括证书、认证、镜像加速或非安全跳过校验;另一个是 Pod 里的imagePullSecrets,它会在 kubelet 调用 containerd 时作为拉取凭证传入。很多人只配置了其中一个,导致另一个环节一直静默失败。
以这次要部署的 registry 2.7.1 为例,图中虽然没有复杂组件,但需要注意的依赖点不少:DNS 能解析仓库地址,节点能连通仓库端口,TLS 证书要受信任,认证信息要和 htpasswd 文件完全一致。这四个条件缺一个,Pod 事件里就会露出对应的报错。
2.2 选用 Registry 2.7.1 的取舍
初看这个版本,会觉得 2.7.1 已经有些年头了。它和后续版本的核心差异主要集中在安全补丁、存储驱动细节和镜像索引处理上。如果完全从零开始,我通常会建议直接上官方当前稳定版,或者用成熟的制品仓库。但这里有一个现实原因:很多正在运行的存量项目、内部依赖脚本、甚至 CI 流水线,都是围绕2.7.1这个固定 tag 设计和验证过的,升级版本意味着要重新过一遍兼容性测试。
所以在下文中,我按 2.7.1 来部署,重点在于把“私有仓库 + containerd 拉取”这条链路跑通。如果你后续要升级,只需要切换镜像 tag,并注意新版本对配置项格式要求更严格,其余思路不受影响。
2.3 硬件、网络和存储准备
部署前,我一般会先确认三件事:
- 集群节点的 /etc/hosts 或内网 DNS 里能不能解析
registry.internal这个地址; - 所有节点是否都能访问仓库的 5000 端口,包括在防火墙和节点安全组里放通;
- 存储层是否有可用的 PVC,因为镜像数据一旦落到本地磁盘,Pod 调度到别的节点就不可见了,也会让灰度发布失去意义。
对于小规模测试,普通的节点磁盘加 PVC 就够了;镜像量大或者并发推送高,就要考虑独立存储或对象存储后端。这里先按常规的 Kubernetes 持久化方案来。
3. 在 Kubernetes 中部署 Registry 2.7.1
3.1 预先准备证书与认证文件
私有仓库一旦在集群内部暴露,至少要解决两个问题:传输加密和访问控制。最简单的方式是生成一个自签名证书,并在所有节点的 containerd 配置里信任这个 CA。证书的 Common Name 要和镜像仓库地址保持一致,否则后续拉取时证书校验会失败。
openssl req -newkey rsa:2048 -nodes -x509 -days 365 \ -subj "/CN=registry.internal" \ -addext "subjectAltName=DNS:registry.internal,DNS:localhost,IP:127.0.0.1,IP:192.168.1.100" \ -keyout tls.key -out tls.crt这里的192.168.1.100是我示例里的某个节点 IP,实际使用时要换成节点地址或虚拟 IP。证书生成好以后,创建两个 Secret:一个给 registry 进程自己用的 TLS 证书,一个给 htpasswd 认证文件。
htpasswd 文件的生成比较常见,可以直接起一个临时的 registry 容器:
docker run --rm --entrypoint htpasswd registry:2.7.1 -Bbn admin 'Admin@123' > htpasswd-B表示使用 bcrypt 算法,这在 Registry 2.7.1 中是被支持的。生成后检查文件内容,不要出现多余的空行或 Windows 的 CRLF,否则后续登录时会出现很难排查的 401 错误。
kubectl create namespace registry kubectl create secret tls registry-tls --cert=tls.crt --key=tls.key -n registry kubectl create secret generic registry-auth --from-file=htpasswd -n registry3.2 持久化存储规划
Registry 的核心数据都写在/var/lib/registry下。如果只是临时验证,可以直接用 emptyDir,但容器重启数据就没了,镜像也要重新 push,非常难受。实际项目里我建议直接声明 PVC,并且尽量把容量规划放宽一点。
一个简单的 PVC 示例:
apiVersion: v1 kind: PersistentVolumeClaim metadata: name: registry-data namespace: registry spec: accessModes: - ReadWriteOnce resources: requests: storage: 100Gi这里的 accessModes 用的是 ReadWriteOnce,因为 Registry 副本数一般不需要太多,单副本加 PVC 在大多数中小规模环境里完全够用。如果你需要多副本,就要考虑共享存储或者对象存储方案。
3.3 Deployment 与相关资源落地
Registry 本身没有特别复杂的配置,核心是几个环境变量。REGISTRY_AUTH 控制认证方式;REGISTRY_HTTP_TLS_CERTIFICATE 和 REGISTRY_HTTP_TLS_KEY 指定证书位置;REGISTRY_STORAGE_DELETE_ENABLED 决定是否允许通过 API 删除镜像。
下面是一个完整的 Deployment 示例,我把它放在 namespaceregistry下:
apiVersion: apps/v1 kind: Deployment metadata: name: registry namespace: registry labels: app: registry spec: replicas: 1 selector: matchLabels: app: registry template: metadata: labels: app: registry spec: volumes: - name: data persistentVolumeClaim: claimName: registry-data - name: certs secret: secretName: registry-tls - name: auth secret: secretName: registry-auth containers: - name: registry image: registry:2.7.1 ports: - containerPort: 5000 name: registry env: - name: REGISTRY_AUTH value: htpasswd - name: REGISTRY_AUTH_HTPASSWD_REALM value: "Registry Realm" - name: REGISTRY_AUTH_HTPASSWD_PATH value: /auth/htpasswd - name: REGISTRY_HTTP_TLS_CERTIFICATE value: /certs/tls.crt - name: REGISTRY_HTTP_TLS_KEY value: /certs/tls.key - name: REGISTRY_STORAGE_DELETE_ENABLED value: "true" volumeMounts: - name: data mountPath: /var/lib/registry - name: certs mountPath: /certs readOnly: true - name: auth mountPath: /auth readOnly: true livenessProbe: tcpSocket: port: 5000 initialDelaySeconds: 15 periodSeconds: 10 readinessProbe: tcpSocket: port: 5000 initialDelaySeconds: 5 periodSeconds: 5Registry 的默认端口是 5000,不要随意改成其他端口,否则后面 Push 和 Pull 的地址要跟着变,容易造成混乱。
再创建一个 Service。这里我用了 NodePort,方便在集群节点上直接测试,也让 containerd 可以通过一个稳定地址访问:
apiVersion: v1 kind: Service metadata: name: registry-svc namespace: registry spec: selector: app: registry ports: - port: 5000 targetPort: 5000 nodePort: 30050 type: NodePort3.4 从集群外访问并验证服务
Deployment 和 Service 创建后,先验证 5000 端口是否正常:
kubectl get pods -n registry -o wide curl -k https://192.168.1.100:30050/v2/返回 JSON 里有{}或者一个空目录列表都属于正常。如果直接返回 401,说明认证已经生效,只是当前还有提供账号。用账号密码再请求一次:
curl -k -u admin:Admin@123 https://192.168.1.100:30050/v2/_catalog看到{"repositories":[]}就说明认证配置没问题。接下来,把测试镜像推上去:
docker login registry.internal:5000 -u admin -p Admin@123 docker tag nginx:1.21.6 registry.internal:5000/demo/nginx:1.21.6 docker push registry.internal:5000/demo/nginx:1.21.6这里的registry.internal在推送时如果解析不到,可以在执行命令的机器上临时写一条 hosts 记录指向节点 IP。
4. 配置 containerd 从私有 Registry 拉镜像
4.1 先在宿主机解决证书信任
containerd 默认不会信任自签名证书。有不少人遇到 Pod 事件里报x509: certificate signed by unknown authority,原因就是节点不认这证书,导致拉取直接失败。
如果你的环境相对固定,可以把证书分发到每个节点,并更新系统证书库:
cp registry.crt /usr/local/share/ca-certificates/registry.crt update-ca-certificates但这里有个细节:containerd 的 gRPC CRI 插件并不完全依赖系统证书库,有些版本还是会校验失败。所以最稳妥的做法,还是在 containerd 的 registry 配置里明确指定 CA 文件路径。
4.2 修改 containerd 配置
先确认 containerd 的配置位置,常见的是/etc/containerd/config.toml。不同版本结构稍有差异,我以 containerd 1.6.x 和 CRI 插件常见配置为例。在config.toml中找到[plugins."io.containerd.grpc.v1.cri".registry]这一段,加入如下配置:
[plugins."io.containerd.grpc.v1.cri".registry] [plugins."io.containerd.grpc.v1.cri".registry.mirrors] [plugins."io.containerd.grpc.v1.cri".registry.mirrors."registry.internal:5000"] endpoint = ["https://registry.internal:5000"] [plugins."io.containerd.grpc.v1.cri".registry.configs] [plugins."io.containerd.grpc.v1.cri".registry.configs."registry.internal:5000".tls] ca_file = "/etc/containerd/registry.crt" insecure_skip_verify = false [plugins."io.containerd.grpc.v1.cri".registry.configs."registry.internal:5000".auth] username = "admin" password = "Admin@123"这里把认证信息直接写进了节点配置,意味着这台节点上的 containerd 进程对所有指向该 registry 的拉取请求都会自动带上这个账号。如果你的安全要求比较高,不希望在节点上明文保存密码,那就可以不配置auth,只配置证书,然后让 Pod 通过 imagePullSecrets 传凭证。
需要注意的是,containerd 1.7 之后更推荐使用/etc/containerd/certs.d/<registry-host>/hosts.toml的方式来管理仓库配置。每个仓库对应一个目录,配置更灵活。下面是一个 hosts.toml 的示例:
server = "https://registry.internal:5000" [host."https://registry.internal:5000"] ca = "/etc/containerd/registry.crt" client = "/etc/containerd/registry-client.crt" key = "/etc/containerd/registry-client.key"如果你还在用旧版本,那上面提到的config.toml里的registry.mirrors和registry.configs就是主要入口。两套方案不建议混着写,否则配置会互相覆盖,难以排查。
4.3 手动用 crictl 验证拉取
改完配置后,我建议先不要直接去创建 Pod,而是先在节点上用crictl手动拉一次镜像。这样能快速把 containerd 侧的问题和 kubelet 侧的问题分开。
crictl pull --creds admin:Admin@123 registry.internal:5000/demo/nginx:1.21.6 crictl images | grep registry.internal如果这一步能成功,说明证书、认证、网络都是通的。如果这里就报错,就没必要立刻去看 Pod 事件,直接在节点上排查更高效。
有些环境里没有 crictl,也可以用ctr验证,但ctr默认不走 CRI 插件,路径不完全一致。我实际调试时更喜欢用 crictl,因为 Kubelet 底层也是通过 CRI 接口调用 containerd,和crictl pull的路径更接近。
4.4 重启 containerd 的注意事项
修改配置后自然要重启 containerd。但这里我要泼一盆冷水:不要在业务高峰期直接对所有节点执行systemctl restart containerd。虽然 containerd 重启后能恢复大部分运行中的容器,但在重启过程中,节点上的已有 Pod 可能会短暂失去响应,如果遇到重启后挂载或网络插件初始化异常,影响范围会扩大。
我通常的做法是:挑一个低峰窗口,先对一个节点操作,验证没问题后再推送到其他节点。如果集群里维护时间有限,可以考虑先让节点进入 cordon 状态,把 Pod 驱赶走,再重启 containerd:
kubectl cordon node-1 systemctl restart containerd kubectl uncordon node-1这个步骤视集群规模和容器数量灵活取舍,但至少要知道:改这台节点的 containerd 配置,影响的是这台节点上所有使用 containerd 的容器拉取行为,而不只是某一个 Pod。
5. 让业务 Pod 使用私有镜像
5.1 创建 docker-registry 类型的 Secret
在 Kubernetes 中,Pod 拉取私有仓库镜像的凭证,主要通过 Secret 传递。Secret 的类型推荐用kubernetes.io/dockerconfigjson,它内部保存的是 Docker 客户端能识别的 config.json 结构。最常见的方式是直接用 kubectl 命令创建:
kubectl create secret docker-registry regcred \ --docker-server=registry.internal:5000 \ --docker-username=admin \ --docker-password='Admin@123' \ --namespace=demo要注意--docker-server必须和镜像地址里的主机部分严格一致,包括端口。比如镜像写的是registry.internal:5000/demo/nginx:1.21.6,那么--docker-server就应该是registry.internal:5000,不能写成https://registry.internal:5000,也不能漏掉端口。否则 containerd 在拉取时找不到匹配的凭证,最终报 401。
如果不太确定 Secret 内容是否正确,可以查看:
kubectl get secret regcred -n demo -o yaml看到 type 是kubernetes.io/dockerconfigjson,并且data[".dockerconfigjson"]有内容,就基本没问题。
5.2 把它挂到一个 Deployment
我现在创建一个简单的 Deployment,镜像直接使用刚才推送上去的应用镜像:
apiVersion: apps/v1 kind: Deployment metadata: name: nginx-demo namespace: demo spec: replicas: 2 selector: matchLabels: app: nginx-demo template: metadata: labels: app: nginx-demo spec: imagePullSecrets: - name: regcred containers: - name: nginx image: registry.internal:5000/demo/nginx:1.21.6 ports: - containerPort: 80这里最关键的一行就是imagePullSecrets。如果不加,即使 containerd 配置了全局认证,某些场景下也会因为 kubelet 没有主动传入 Secret,导致拉取失败。加了以后,kubelet 会把这组凭证透传给 containerd,containerd 才可能在请求私有仓库时完成认证。
创建完成后观察 Pod:
kubectl get pods -n demo -w kubectl describe pod nginx-demo-xxxxx -n demo如果能看到事件里出现Successfully pulled image "registry.internal:5000/demo/nginx:1.21.6",就说明整个链路已经通了。进入容器确认一下应用确实在运行:
kubectl exec -it nginx-demo-xxxxx -n demo -- nginx -v5.3 namespace 隔离与默认 ServiceAccount 的便捷处理
Secret 本身是 namespace 级别的资源,所以regcred在 demo 命名空间创建后,只能给 demo 命名空间里的 Pod 使用。如果 test、prod 多个命名空间都需要从同一个仓库拉镜像,需要分别创建。
为避免每个 Pod 都重复写imagePullSecrets,可以把 Secret 挂到默认 ServiceAccount 上:
kubectl patch serviceaccount default -n demo -p '{"imagePullSecrets": [{"name": "regcred"}]}'这样这个命名空间里新创建的 Pod,只要没有显式覆盖 imagePullSecrets,都会自动携带这份凭证。这个方法在开发环境里很省事,但在多租户隔离要求高的场景,还是建议显式声明,避免权限扩散。
6. 排查专用与踩坑实录
6.1 常见症状与快速定位
实际操作中,最让人头疼的不是配置写错,而是线索分散在多个组件里。我整理了一个排查表格,遇到问题可以按这个顺序查:
| 现象 | 常见根因 | 优先排查位置 |
|---|---|---|
| Pod 一直 ImagePullBackOff | 私有仓库地址无法访问、凭证错误、证书不被信任 | kubectl describe pod;在节点上 crictl pull 复现 |
| 事件里报 x509 unknown authority | containerd 不信任自签证书 | 检查节点的 CA 路径、hosts.toml 或 config.toml 的 tls 配置 |
| 事件里报 401 Unauthorized | 镜像拉取 Secret 和仓库地址不匹配 | 检查 regcred 的 docker-server 是否和镜像地址一致 |
| 事件里报 HTTP response from HTTPS server | registry 用了 HTTP,containerd 默认走 HTTPS | 在 hosts.toml 或 config.toml 里配置 insecure_skip_verify,或统一启用 TLS |
| 明明推送成功,Pod 还是报找不到镜像 | 镜像 tag 写错、namespace 名不对 | 用 crictl images 确认 registry.internal:5000 前缀和 tag 是否存在 |
6.2 最容易踩的三个坑
第一个是证书 SAN 问题。很多人生成了自签证书,Common Name 写对了,但在 containerd 配置里也指定了 CA 文件,拉取还是报证书校验失败。原因往往是证书的 SAN 里没有包含节点访问时使用的地址。比如推送时用registry.internal:5000,但 containerd 目标地址解析成了192.168.1.100:30050,证书里没有这个 IP,自然校验不过。所以生成证书时要提前把会用到的 DNS 和 IP 都加进 SAN。
第二个是 containerd 全局认证和 imagePullSecrets 冲突。有这样一类情况:节点配置里写了 admin 账号,Pod 里也写了 imagePullSecrets,但账号密码并不一致。containerd 会优先使用 Pod 传过来的凭证,如果这组凭证是错的,即使节点上的全局账号是对的,拉取也会失败。调试时不要想当然认为“节点上已经能拉,Pod 肯定也能拉”,先确认 Secret 的账号密码确实匹配。
第三个是 htpasswd 文件的细节。生成 htpasswd 时如果用了不带-B的参数,Registry 2.7.1 在解析 bcrypt 之外的哈希时可能不识别,最终表现为登录时返回 401,而看 registry 容器日志又不会主动报出明确错误。我建议生成后先手动 curl 一下带认证的请求,确认认证信息本身没问题,再往 containerd 和 Secret 上复用。
6.3 独家的实操检查清单
每次遇到 Pod 拉取失败,我会按这个顺序执行:
kubectl describe pod xxx,看 Events 里的原始报错;- 在报错节点上执行
crictl pull registry.internal:5000/demo/nginx:1.21.6,确认是否可以复现; - 如果容器 runtime 报的是证书问题,用
openssl s_client -connect registry.internal:5000 -showcerts验证证书链; - 检查
/etc/containerd/config.toml是不是同时存在 hosts.toml,如果有两套配置,建议只保留一套; - 用
kubectl get secret regcred -o go-template='{{.data.\.dockerconfigjson | base64decode}}'检查 Secret 内容; - 如果还是查不到,就去看 containerd 日志:
journalctl -u containerd --since "10 minutes ago" | grep -i pull。
这套检查其实不复杂,但比胡乱猜测有效得多。很多问题最后落点都不是 Pod 模板,而是节点运行时配置。
7. 最后说点真实的经验
在这个部署过程里,最让我印象深刻的其实是“配置链路远比想象中长”。一个简单的 Pod 拉镜像,涉及证书、DNS、节点配置、镜像前缀、Secret 命名空间,任何一个环节对不上都会报出模棱两可的错误。如果你问我在实际运维中怎么提高成功率,我的体会是:先不要让问题跑到 kubelet 那一层才被发现,而是先把 containerd 侧的拉取用 crictl 验证干净,再谈 Pod 调度。
另外一个小建议:私有仓库的地址尽量固定,不要因为某个节点 IP 变了就频繁改镜像前缀。仓库域名和证书一起锁定,后续所有节点上的 containerd 配置就只需要维护一套。等这套链路稳定以后,再考虑往上叠加镜像清理、备份、制品管理,都会轻松很多。