课程目录(第 25 章 / 共 33 章)
课程/进阶:生产实践

镜像仓库与加速:让拉取又快又稳

25 章 / 共 33·20 分钟·进阶镜像仓库HarborDragonfly缓存

用 Harbor 把镜像拉取收进内网,用 imagePullSecret 打通鉴权,再用 P2P 与节点缓存把「扩容时全员等镜像」变成几秒钟的事。

学完这一章,你将能够

  • 说得出生产集群自建镜像仓库的四个理由
  • 用 Helm 装好 Harbor,完成一次登录、推送与拉取
  • 在 Pod 与 ServiceAccount 上正确引用 imagePullSecret
  • 知道 Dragonfly 与节点预拉取分别在什么场景下有用

为什么生产集群要有自己的镜像仓库

刚开始学习时,image: nginx:1.27 就够了:kubelet 会自己去 Docker Hub 拉。到了生产环境,这条路径会变成一连串问题。

  • 外网依赖:节点出不了公网、或者上游 registry 抖动,整个发布流程就停摆。集群越大,这个依赖越致命。
  • 速率限制:公共仓库对匿名拉取有频率限制,一次扩容几十个节点、或者一批 Pod 同时启动,很容易被限流,表现为 toomanyrequestsImagePullBackOff
  • 安全审计:你不知道镜像里到底有什么、有没有被替换过。自建仓库才有扫描、签名、来源控制的抓手。
  • 离线与内网环境:很多生产集群(尤其是金融、制造、科研)根本不允许访问公网。
一句话:生产集群的镜像来源应该只有一个,就是你自己控制的仓库。

自建仓库不是「多一台服务器」那么简单,它同时承担了分发入口、安全闸门和跨机房同步三个角色:

text
开发机构建 ──push──▶ Harbor(内网唯一入口)

                        ├── 漏洞扫描(Trivy)→ 扫描报告
                        ├── 签名(cosign)→ 供准入策略校验
                        └── 仓库复制 ──▶ 灾备机房 / 另一套集群

                        │ pull
                   节点 containerd

                        └── Dragonfly / Spegel:节点之间 P2P 互相分发

Harbor 能给你什么

自己用 registry 镜像起一个仓库也能用,但它只有一个账号池,没有项目隔离、没有扫描。Harbor 是在 registry 之上加了管理能力的发行版,生产上更常用。

能力说明
项目与用户项目级隔离,用户、组、机器人账号的 RBAC,可对接 LDAP 或 OIDC
镜像复制仓库之间复制,用于跨机房、跨云同步
漏洞扫描内置 Trivy,可按项目配置自动扫描
镜像签名支持 cosign 等签名方式,配合准入策略校验来源
代理缓存把上游 registry 配成 proxy cache,替公网镜像做一层缓存
保留策略与配额自动清理旧 tag,限制项目容量
制品类型OCI 镜像、Helm chart 以及其它 OCI 制品

代价是组件多:core、jobservice、portal、registry 加 PostgreSQL 与 Redis。所以生产环境建议外接数据库与对象存储,而不是全靠内置组件加 PVC。

动手:用 Helm 装 Harbor 并推拉一个镜像

动手练习:装 Harbor 并推一个镜像进去

chart 名是 harbor/harbor,仓库地址 https://helm.goharbor.io

bash
helm repo add harbor https://helm.goharbor.io
helm repo update
helm search repo harbor/harbor --versions | head -5

把关键参数写进 harbor-values.yaml<你的 StorageClass> 换成集群里真实存在的 StorageClass):

yaml
expose:
  type: ingress
  tls:
    enabled: true
  ingress:
    className: nginx
    hosts:
      core: harbor.example.com
externalURL: https://harbor.example.com

persistence:
  enabled: true
  persistentVolumeClaim:
    registry:
      storageClass: <你的 StorageClass>
      size: 200Gi
    database:
      storageClass: <你的 StorageClass>
      size: 20Gi
    redis:
      storageClass: <你的 StorageClass>
      size: 10Gi

harborAdminPassword: "<改成强密码>"
trivy:
  enabled: true
bash
helm install harbor harbor/harbor \
  --namespace harbor --create-namespace \
  -f harbor-values.yaml
kubectl -n harbor get pods
kubectl -n harbor get svc

expose.type 有四个取值:ingressloadBalancernodePortclusterIP。本地实验没有 Ingress 时用 nodePort,再加 expose.nodePort.ports.http.nodePort: 30002externalURL 必须和客户端实际访问地址一致,否则登录回调会失败。

等所有 Pod Running 之后,登录并推一个镜像(library 是 Harbor 自带的默认项目):

bash
echo '<adminPassword>' | docker login harbor.example.com -u admin --password-stdin

docker pull nginx:1.27
docker tag nginx:1.27 harbor.example.com/library/nginx:1.27
docker push harbor.example.com/library/nginx:1.27

docker rmi harbor.example.com/library/nginx:1.27
docker pull harbor.example.com/library/nginx:1.27

推送成功会看到一串 digest: sha256:...Pushed;到 Harbor 的 Web 界面进入 library 项目,能在 Repositories 里看到 nginx能推、能删本地镜像后再拉回来,才算仓库真的可用。

imagePullSecret:让 Pod 有权拉私有镜像

仓库通了,但 Pod 还不知道怎么登录。私有仓库需要一个 docker-registry 类型的 Secret,创建时必须把仓库地址写对

bash
kubectl -n demo create secret docker-registry harbor-cred \
  --docker-server=harbor.example.com \
  --docker-username='robot$kube101' \
  --docker-password='<机器人账号的 token>' \
  --docker-email=dev@example.com

在 Harbor 里给应用建一个机器人账号,而不是用 admin。然后在 Pod 里引用它:

yaml
apiVersion: v1
kind: Pod
metadata:
  name: app
  namespace: demo
spec:
  imagePullSecrets:
    - name: harbor-cred
  containers:
    - name: app
      image: harbor.example.com/library/nginx:1.27

如果每个 Pod 都写一遍太啰嗦,就把它挂到命名空间的 ServiceAccount 上,所有使用该 SA 的 Pod 自动继承:

bash
kubectl -n demo patch serviceaccount default \
  -p '{"imagePullSecrets":[{"name":"harbor-cred"}]}'
kubectl -n demo get serviceaccount default -o yaml | grep -A3 imagePullSecrets

验证方式是拉取失败时的报错能不能自证:kubectl -n demo describe pod app 的 Events 里出现 401 Unauthorizedpull access denied,说明鉴权没通;出现 Pulling 之后 Successfully pulled,说明通了。

Harbor 的生产配置:复制、扫描与配额

装起来只是第一步,「谁能推、推上去要不要拦、拦下来谁来清」这条链才是生产价值所在。三个旋钮最容易被忽略。

镜像复制解决跨机房、跨集群分发:在「仓库管理 → 新建目标」登记对端仓库,再用复制规则让 Harbor 自己同步,别靠人往两边各推一次。两个讲究:规则按项目建而不是全库复制,避免把开发镜像同步到灾备机房;事件驱动加定时兜底,事件让新 tag 尽快到位、定时修补中断的同步。公网镜像不必手工同步,直接把项目做成 proxy cache 项目:

bash
harbor login https://harbor.example.com -u admin -p '<adminPassword>'
for r in docker.io registry.k8s.io quay.io nvcr.io; do
  url="https://$r"; [ "$r" = "docker.io" ] && url="https://mirror.gcr.io"
  harbor registry create --name "$r" --type docker-registry --url "$url"
done
harbor registry list -o json | jq -r '.Payload[] | "\(.id) \(.name)"' | while read id name; do
  harbor project create "$name" --proxy-cache --public --registry-id "$id"
done

漏洞扫描的价值不在 trivy.enabled: true,而在什么时候扫、扫出来怎么办:项目里打开「推送时自动扫描」,并勾选阻止有高危漏洞的镜像被拉取,再让 CI 在推完镜像后查一次扫描结果——高危未修复就不进入部署流程。内网还要单独规划 Trivy 漏洞库的更新源,否则扫描结论越来越旧,看着「全绿」却没有意义。

配额与保留策略必须一起配:配额(项目级存储上限)防止一个团队写满整个仓库,保留策略按 tag 数量或天数清理历史镜像。只配配额,写满时整个项目都推不上去;只配保留策略,则可能在清理前就被写满。为什么生产上非做不可:仓库是全集群唯一的镜像入口,它一满或一慢,表现就是所有人 ImagePullBackOff——这两个配置不是省磁盘,而是把故障半径限制在单个项目内。

参考:reference/k8s-in-action/repo/harbor/README.md

Dragonfly:P2P 分发加速

镜像仓库解决了「从哪拉」,但没解决「拉多快」。一次扩容 50 个 Pod,50 个节点同时去仓库拉同一个 2GB 镜像,仓库出口带宽立刻被打满,镜像拉取变成扩容的瓶颈。

Dragonfly 的思路是 P2P:把镜像切成块,节点之间互相交换已经下载的块,只让少数节点(seed peer)回源仓库。

text
                    ┌──────────────┐
                    │  Harbor 仓库 │
                    └──────┬───────┘
                           │ 只有 seed peer 回源

   ┌────────────┐   ┌────────────┐   ┌────────────┐
   │  节点 A    │◀─▶│  节点 B    │◀─▶│  节点 C    │
   │ (seed peer)│   │            │   │            │
   └────────────┘   └────────────┘   └────────────┘
        P2P 网络:块在节点之间直接交换

组件构成是 manager、scheduler、seed peer 加每个节点上的客户端,另带 MySQL 与 Redis。用 Helm 安装,chart 名 dragonfly/dragonfly

bash
helm repo add dragonfly https://dragonflyoss.github.io/helm-charts/
helm repo update
helm install dragonfly dragonfly/dragonfly \
  --namespace dragonfly-system --create-namespace
kubectl -n dragonfly-system get pods

和 containerd 集成靠 client.dfinit

yaml
client:
  dfinit:
    enable: true
    config:
      containerRuntime:
        containerd:
          configPath: /etc/containerd/config.toml
          registries:
            - {hostNamespace: docker.io, serverAddr: https://mirror.gcr.io, capabilities: [pull, resolve]}

和 containerd 集成靠 client.dfinit:它改写节点上的 containerd 配置,把镜像拉取指向本机的 dfdaemon 代理,再由 P2P 网络决定从哪里取块。早期版本里节点侧组件叫 dfget-proxydfdaemon,v2 之后统一为 dragonfly-client,集成方式也从手改配置变成由 dfinit 自动完成。

验证方式是在节点上直接拉一个镜像,再看客户端日志:

bash
crictl pull harbor.example.com/library/nginx:1.27
kubectl -n dragonfly-system logs -l app=dragonfly-client --tail=50
kubectl -n dragonfly-system port-forward svc/dragonfly-manager 8080:8080

日志里出现 download task started 就说明流量走了 P2P;manager 的控制台能看任务与流量统计。

Dragonfly 与 Spegel 怎么选

两者都做 P2P,取向不同:Spegel 是单个 DaemonSet,让节点互为镜像源、直接复用各节点 containerd 里已经拉取过的镜像层,没有中心节点,但只支持 containerd;Dragonfly 是一套独立的 P2P 网络,由 seed client 统一回源,能力更多、组件也更多。

维度SpegelDragonfly
组件单个 DaemonSetmanager / scheduler / seed client / client + MySQL + Redis
原理节点互为镜像源,复用本地已有层独立 P2P 网络,seed client 回源
预热不支持,首次拉取仍要回源支持,首次拉取也能加速
分发对象容器镜像镜像,以及模型权重、数据集等任意文件
资源开销高,生产建议外接 MySQL 与 Redis

Spegel 有个节点侧前提最容易漏:containerd 不能丢弃已解包的层,否则节点之间没有可复用的内容,P2P 直接失效。滚动改完再重启,然后看它的自检页面:

bash
# 在 /etc/containerd/config.toml 的 overlayfs 段设 discard_unpacked_layers = false 后滚动重启:
systemctl restart containerd
kubectl -n spegel port-forward svc/spegel 9090   # 打开 http://localhost:9090/debug/web

判断标准很直接:UI 里 Last Mirror Success 显示时间而不是 Pending,说明镜像源发现正常。选型记一句话:只是「同一个镜像在多个节点重复拉」,选 Spegel;要预热新镜像、要分发模型权重这类非镜像文件、或节点运行时不是 containerd,才上 Dragonfly。

参考:reference/k8s-in-action/base/spegel/README.mdreference/k8s-in-action/repo/dragonfly/README.md

节点级镜像缓存:预拉取与 kube-fledged

P2P 解决的是「多个节点同时拉」。还有一类场景是「镜像已经确定,但不想等它拉」:比如大版本发布前,先把镜像预热到所有节点上。

最简单的办法是用一个 DaemonSet 在每台节点上把镜像拉一遍:

yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: preload-nginx
  namespace: kube-system
spec:
  selector: {matchLabels: {app: preload-nginx}}
  template:
    metadata: {labels: {app: preload-nginx}}
    spec:
      initContainers:
        - name: preload
          image: harbor.example.com/library/nginx:1.27
          command: ["/bin/true"]
      containers:
        - name: pause
          image: registry.k8s.io/pause:3.10
          resources: {requests: {cpu: 1m, memory: 8Mi}}

initContainer 的作用只是「让 kubelet 在每台节点上拉一次这个镜像」,拉完立刻退出。如果镜像很多、或者想按标签批量管理缓存,可以用 kube-fledged:它用一个 ImageCache 资源声明要缓存的镜像列表,再由控制器负责在节点上预拉并保持。

预拉取会把镜像钉在节点上

预拉取的镜像会一直占用节点磁盘。镜像很大、节点磁盘很小时,预拉取可能直接把节点写满(kubelet 会触发镜像回收,把没在用的镜像删掉,预拉取就白做了)。先算清磁盘容量,再决定预拉多少。

tag、digest 与多架构镜像

  • tag 是可变的app:1.2.3 可以被重新推送,所以生产上更推荐 image@sha256:... 固定 digest,这一点在 安全加固 里讲过。
  • 多架构镜像用 manifest list 承载:同一个 tag 下面挂着 amd64、arm64 等多个架构的镜像,节点拉取时自动选择匹配的那一份。
bash
docker buildx build --platform linux/amd64,linux/arm64 \
  -t harbor.example.com/library/app:1.2.3 --push .
docker buildx imagetools inspect harbor.example.com/library/app:1.2.3

imagetools inspect 会列出所有平台与各自的 digest。只在 arm64 笔记本上构建、直接推给 amd64 节点用,会遇到 exec format errorno matching manifest——这就是多架构没做对的表现。

离线依赖源:PyPI 与 Conda 镜像

镜像只是一半。构建训练镜像时要 pip install、运行时要用 conda 环境,这些依赖同样出网,集群内网化之后它们是最先断掉的一环。做法是在内网跑按需缓存的镜像源:第一次请求回源并落盘,之后命中缓存。

Python 用 devpi 建一个 mirror index(服务端执行 devpi index -c root/pypi type=mirror mirror_url=https://pypi.org/simple/,上游建议指向 pypi.org,部分公共镜像源在 devpi 下有兼容问题;索引设成 volatile=False,已缓存的版本才不会被顶掉),客户端改一行就切过去:

bash
pip config set global.index-url https://pypi.example.com/root/pypi/+simple/

Conda 用 quetz 建 proxy 模式的 channel,客户端写 ~/.condarc

bash
curl --request POST --url https://conda.example.com/api/channels \
  --header 'Content-Type: application/json' --header 'X-API-Key: <API Key>' \
  --data '{"name":"main","private":false,"mirror_mode":"proxy","mirror_channel_url":"https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main"}'
yaml
# ~/.condarc
channels:
  - defaults
default_channels:
  - https://conda.example.com/get/main
  - https://conda.example.com/get/r

改完执行 conda clean -i 清掉索引缓存,否则客户端还在用旧 channel。存储只随「实际拉取过的包」增长(PyPI 全量在 20Ti 量级,按需缓存远小于此)。适用场景:PyPI 镜像给 Python 应用与训练镜像构建用,收益是断网可用加构建可复现;Conda 镜像给数据科学与 GPU 环境用,包体积大、缓存收益更明显。它们替代不了制品库——内部私有包仍要发到同一套 devpi / quetz 的私有索引里。容器内注意 pip config 只对当前用户生效,Dockerfile 里要用 pip install --index-url https://pypi.example.com/root/pypi/+simple/,或者往镜像里写全局 /etc/pip.conf

参考:reference/k8s-in-action/repo/pypi/README.mdreference/k8s-in-action/repo/conda/README.md

常见坑与速查表

镜像拉取最常踩的五个坑

  • HTTP 仓库没配 insecure-registries:Docker 报 http: server gave HTTP response to HTTPS client,containerd 报类似的 TLS 错误。生产上请签证书,实验环境才用 insecure-registriesinsecure_skip_verify
  • 证书 SAN 不匹配:用 IP 访问但证书只签了域名,报 x509: certificate is valid for ..., not ...。把访问用的地址加进证书 SAN,或者改用域名访问。
  • imagePullSecret 在错误的命名空间:Secret 是命名空间级的,Pod 只认自己所在命名空间的 Secret。跨命名空间引用会直接报 secret not found
  • Harbor 的容量规划:镜像层放对象存储比放 PVC 更稳,数据库生产上外接 PostgreSQL;另外默认单层上传上限是 128GiB,超大镜像要调 core.extraEnvVars 里的 BEEGO_MAX_UPLOAD_SIZE_BYTES
  • P2P 加速跨网段失效:P2P 依赖节点之间互通且带宽充足。跨可用区、跨网段时,节点间传输可能比直接回源还慢,反而拖慢拉取。
现象原因怎么确认
ImagePullBackOff,Events 里 401imagePullSecret 缺失、错误或过期kubectl describe pod 看 Events,核对 Secret 所在命名空间与 --docker-server
no such host节点 DNS 解析不到仓库域名在节点上 nslookup harbor.example.com
x509: certificate signed by unknown authority仓库用了自签证书,节点不信任把 CA 装到节点信任库,或配 containerd 的 CA 配置
拉取很慢但仓库带宽没满节点到仓库链路或 P2P 跨网段问题对比单节点 crictl pull 耗时与仓库出口带宽曲线
扩容时大量 Pod 卡在 ContainerCreating镜像太大、仓库限流或节点磁盘不足kubectl describe podPulling image 与磁盘告警
镜像在开发机能跑、节点报 exec format error架构不匹配,镜像不是多架构的docker buildx imagetools inspect <镜像> 看平台列表

自测题

自测:为什么说「镜像来源应该只有一个」,多配几个 mirror 不是更稳吗?(点击展开答案)

多配 mirror 看起来提升了可用性,但会破坏三件事:来源可控(你不知道 Pod 最终从哪个镜像源拉到了什么)、审计可追溯(扫描和签名只在某一个源上做过)、一致性(同一个 tag 在不同 mirror 上可能是不同内容)。正确的做法是保持「唯一入口」:所有镜像先进内网仓库,再由仓库做复制、代理缓存和多副本,把对外网的依赖收敛到仓库这一层,而不是分散到每个节点。

自测:`imagePullSecrets` 为什么有时明明配了还是拉不到?(点击展开答案)

最常见的原因是位置不对:Secret 是命名空间级的,写在 demo 命名空间却让 prod 命名空间的 Pod 引用,必然失败。其次要检查 --docker-server 是否和镜像地址的域名完全一致——harbor.example.comharbor.example.com:443 在凭据匹配上会被当成不同的地址。

还有一个容易忽略的点:Secret 里的 token 过期或被删除后,Pod 新建时才会重新鉴权,已经在跑的 Pod 不受影响,所以「突然拉不到」往往发生在扩容或重建时。

自测:什么时候 P2P 加速反而会拖慢拉取?(点击展开答案)

P2P 的收益来自「多个节点同时需要同一个镜像」。只有一个节点拉、或者每个节点拉的镜像都不一样时,P2P 反而增加了调度开销。更明显的是网络拓扑:如果节点分散在多个网段或可用区,节点之间的带宽和延迟比直接回源更差,块交换就成了瓶颈,所以部署前要先确认节点间的网络质量,必要时把 P2P 限定在同一网段内,跨网段走回源。

小结

  • 生产集群自建镜像仓库的理由是外网依赖、速率限制、安全审计与离线环境,镜像来源应当收敛到唯一入口。
  • Harbor 在 registry 之上提供项目 RBAC、复制、扫描、签名、代理缓存与保留策略;生产上要配好按项目的复制规则、推送时自动扫描与项目配额,代价是组件多、建议外接数据库与对象存储。
  • 私有镜像要在 Pod 或 ServiceAccount 上引用 imagePullSecret,Secret 必须和目标 Pod 在同一个命名空间;固定 digest 比固定 tag 更可靠,多架构镜像用 buildx 构建 manifest list。
  • P2P 解决「多节点同时拉」:Spegel 用一个 DaemonSet 复用节点已有层,Dragonfly 支持预热与非镜像文件但组件多;节点预拉取适合发布前预热,注意磁盘容量。
  • 镜像只是一半,内网还要有 PyPI / Conda 的按需缓存镜像源(devpi / quetz),否则内网化之后依赖是最先断掉的一环。

练习

  1. 给 Harbor 配一个上游 docker.io 的 proxy cache 项目,从它拉一个公网镜像,观察缓存命中前后的耗时差异。
  2. demo 命名空间删掉 harbor-cred Secret,再重建一个 Pod,用 kubectl describe pod 记录完整的鉴权失败报错。
  3. 给一个常用镜像构建多架构版本并推到 Harbor,然后在 amd64 节点上确认它跑得起来。

到这里,「进阶:生产实践」模块就讲完了:集群怎么装、网络怎么通、数据怎么落、状态怎么看、安全怎么加固、交付怎么自动化、数据怎么恢复、镜像怎么分发——这八件事撑起了在生产环境里管集群的底气。