1. 先把需求说透:把 Kolla 镜像搬到云上仓库到底在解决什么
标题里说的这件事,说白了就一句话:把 Kolla 项目那套用来部署 OpenStack 的 Docker 镜像,从公共镜像源拉到自己的云上镜像仓库里存起来,让内网的机器从自己家的仓库拉镜像。由于合规要求,下文中凡是涉及具体云厂商容器镜像服务的地方,我统一用“云上镜像仓库”来指代,具体的产品名你在控制台里能看到,操作逻辑各家大同小异——命名空间、仓库、登录凭证、公网/内网两套访问地址、生命周期规则,这几样东西是所有云厂商镜像服务的共同骨架,把通用套路吃透,换哪家都能照搬。
为什么这件事值得单独写一篇?因为 Kolla 这套东西的镜像体量实在太大。一个能跑起来的 OpenStack 最小部署,涉及的容器镜像在三十个往上,全量镜像清单几百个,单个镜像从几百 MB 到一两个 G 不等,全量拉一遍轻松吃掉上百 GB 磁盘和几个小时的带宽。更要命的是,真正做部署的内网环境往往连不上公共镜像源,或者连得上但速度慢到让人怀疑人生,一个nova-api镜像拉二十分钟是家常便饭。所以“预先把镜像搬到离部署机器最近的仓库”不是可做可不做的优化项,而是生产部署流程里的必经环节。
这篇内容适合三类人看:第一类是刚开始接触 OpenStack 容器化部署、手上有个测试环境想跑通的运维新手,你需要的是能直接抄的完整命令和踩坑清单;第二类是负责企业内部镜像治理的平台工程师,你关心的是批量同步、增量更新、容量规划和仓库生命周期管理;第三类是已经跑起来但每次升级都被镜像问题折腾的老手,你可以重点看增量同步和 digest 校验那几节,能省掉不少重复劳动。不管你在哪一层,下面的内容都按“先讲清为什么,再给出怎么做,最后交代怎么不踩坑”的顺序来组织,你可以按需跳读。
2. 整体方案设计与选型:同步这件事有几条路可走
2.1 三种同步路径的取舍与对比
把镜像从一个仓库搬到另一个仓库,本质上就是“读一个 registry 的 manifest 和 blob,写到另一个 registry”。区别只在于中间要不要落到本地磁盘、用什么工具做搬运、以及能不能保持多架构信息。我实际用过并且推荐评估的有三条路,各有各的适用场景:
| 方案 | 核心命令/工具 | 是否需要本地 Docker | 多架构支持 | 断点续传 | 适用场景 |
|---|---|---|---|---|---|
| A. 本地中转 | docker pull / tag / push | 需要,且占用本地存储 | 差,默认只取当前平台 | 依赖 Docker 层缓存 | 镜像少、机器性能好、临时应付 |
| B. 直连复制 | skopeo copy | 不需要 | 完整支持(--all) | 支持(可重试) | 批量同步、跨架构、生产推荐 |
| C. 仓库侧同步 | 云上仓库的远程同步/镜像回源功能 | 不需要 | 取决于产品实现 | 平台侧保证 | 长期维护、希望零本地资源 |
方案 A 是最容易理解的:机器上装好 Docker,docker pull把镜像拉到本地,docker tag改成云上仓库的地址,docker push推上去。它的好处是零学习成本,坏处是每一个镜像的每一层都要在本地磁盘落一遍再读出来,一百多 GB 的镜像写两遍盘,磁盘 IO 和空间都是压力;而且docker pull默认只拉当前 CPU 架构的那一份,多架构镜像的 manifest list 信息会丢。我第一次做这件事就是用的这条路,跑到第七个镜像发现根分区只剩 3G,只能停下来清缓存,那个酸爽我至今记得。
方案 B 用skopeo,它把镜像当“远程对象”处理,两个 registry 之间直接对着搬,中间不落地也不需要 Docker daemon。skopeo copy --all可以完整保留 manifest list,多架构镜像一次搬完,还能用--retry-times做失败重试。缺点是要额外装工具,且对私有仓库的认证方式需要单独配置认证文件。批量同步的场景我基本只用它。
方案 C 是云厂商镜像仓库自带的“远程同步”或者“镜像加速”能力,你在控制台配一条规则,平台帮你去公共源拉。它的好处是本地零资源消耗、有平台侧的带宽保障;限制是同步规则通常按仓库或按 tag 前缀配置,精细度不如脚本,而且出问题时排查链路更长。我一般把它当成“长期兜底”而不是“一次性批量迁移”的手段。
提示:如果你的镜像数量超过 20 个,或者有 ARM 与 x86 混合的节点,直接上方案 B,不要用方案 A 硬扛。省下来的时间和磁盘够你写好几版脚本了。
2.2 仓库命名空间与镜像路径的规划
动手之前先想清楚“镜像在云上仓库里叫什么名字”。Kolla 的官方镜像默认路径形态是kolla/<镜像名>:<tag>,比如kolla/nova-api:2024.1-ubuntu-22.04。搬到云上之后,路径会变成<云仓库域名>/<命名空间>/<镜像名>:<tag>。
这里有个很关键的决策:命名空间要不要跟 OpenStack 版本绑定。我见过两种做法。一种是用一个固定命名空间,靠 tag 区分版本;另一种是按版本开多个命名空间,比如kolla-2023-2、kolla-2024-1。前者的好处是镜像路径简单,kolla-ansible的全局配置里docker_namespace只填一次就行;坏处是同一个仓库下 tag 会越来越多,容易撞车,权限也不好按版本隔离。后者路径略长,但版本隔离干净,升级时回滚也方便,旧版本镜像直接按命名空间整体设生命周期规则删掉就行。
我个人的选择是按版本开命名空间,理由很实际:OpenStack 每次大版本升级,Kolla 镜像的基础操作系统层、Python 依赖层几乎全变,镜像之间几乎没有可复用的层,放在一个仓库下只是把 tag 列表撑长,没有任何收益;而按版本隔离之后,删旧版本这件事变成一次性的命名空间清理,比逐个删 tag 安全得多。
配置落地的时候,kolla-ansible侧只需要改/etc/kolla/globals.yml里两行:
docker_registry: "registry.example.com" docker_namespace: "kolla-2024-1"改完这两行,kolla-ansible pull和kolla-ansible deploy就会去你指定的位置拉镜像,不用动其他任何配置。这个设计很贴心,也是 Kolla 值得用的原因之一。
2.3 容量与带宽的估算方法
估算这件事不能拍脑袋,我给你一个可以照着算的公式。先拿到镜像清单,算出镜像总数 N;再抽样docker image inspect或者skopeo inspect看几个代表性镜像的压缩后大小,取平均值 S;考虑到多架构镜像会存两份以上的层,乘一个系数 K(只同步单架构取 1.0,同步双架构取 1.8 左右);最后加上仓库侧的开销系数 1.1。
总容量 ≈ N × S × K × 1.1
举个实测的例子:某次同步的是 42 个镜像,抽样平均压缩大小约 620 MB,只同步 x86 单架构,K 取 1.0,算下来 42 × 0.62 × 1.1 ≈ 28.6 GB。实际推完在云上仓库看到的用量是 27.4 GB,误差在 5% 以内,这个公式够用。如果是全量 Kolla 镜像(两百多个),按同样的均值粗估就是 130 GB 往上,双架构直接奔着 240 GB 去,这时候磁盘和仓库配额都必须提前申请,不要等推到一半报配额不足。
带宽方面,同步耗时基本等于“总容量 ÷ 有效带宽 × 2”。乘 2 是因为同一份数据要下载一次、上传一次,两段链路各走一遍。10 MB/s 的有效带宽下推 28 GB,光传输就要 1.6 小时左右,加上重试和层校验的开销,按 2 小时规划比较稳。这个数字决定了你要不要用多并发,也决定了是否值得在云上用一台按量计费的临时机器专门跑同步——跑完就释放,比在办公网里挂着跑一整天更划算。
3. Kolla 镜像体系的关键细节解析
3.1 镜像清单从哪里来,别靠猜
同步的第一步不是敲命令,而是拿到一份准确的镜像清单。很多人上来就docker pull kolla/nova-api,拉到一半发现 tag 不对,或者发现自己漏了horizon、keystone这些必装的。Kolla 的镜像清单其实是有明确来源的,我常用的有三个,按可靠性排序:
第一,kolla-ansible仓库里的ansible/group_vars/all.yml中定义的容器清单,以及各服务的defaults/main.yml里声明的镜像名。这是最权威的,因为部署时就是按这个清单去拉的。你可以直接从这个仓库里 grep 出所有{{ container_registry }}/{{ container_namespace }}/xxx的引用,去重之后就是完整清单。
第二,kolla仓库里的kolla/common/config.py,里面有一个BASE_IMAGE_NAME或者各服务的images定义,构建镜像时用的就是它,清单最全,但会包含一些不常用的可选组件。
第三,偷懒的办法:在已经部署成功的环境上执行docker images --format '{{.Repository}}:{{.Tag}}' | grep kolla | sort,跑出来什么就是什么。这个方法最贴近实际,但有个前提——你得先有一个能跑的环境,属于“鸡生蛋”的路子,适合做交叉验证。
我的习惯是第一种和第三种结合:从仓库里 grep 出理论清单,再从已有环境导出实际清单,两边 diff 一下,多出来的通常是我用不到的可选组件,少掉的就是配置里被注释/禁用的服务。这样既不会漏,也不会白搬一堆用不上的镜像。
清单整理成文件之后,长这样:
# images.txt nova-api nova-conductor nova-scheduler nova-novncproxy neutron-server neutron-linuxbridge-agent keystone glance-api horizon mariadb rabbitmq memcached ...注意这里只存镜像短名,registry 前缀和 tag 在脚本里拼,这样换版本的时候只改一个变量,不用动清单文件。
3.2 tag 的构成规则与版本对应关系
Kolla 的 tag 不是随便起的,它的典型形态是<OpenStack版本>-<发行版>-<版本号>,比如2024.1-ubuntu-22.04。这个 tag 里包含三个信息:OpenStack 的发行序列、基础容器镜像的操作系统、以及可选的构建序号。理解这一点很重要,因为你在同步时如果 tag 拼错一个字符,拉取就会报 manifest unknown,而报错信息不会告诉你“你少写了一个 22.04”。
版本对应关系这块,新手最容易迷糊的是 OpenStack 的版本命名和 Kolla 的 tag 前缀之间的关系。Kolla 从某个版本开始,tag 前缀直接对齐 OpenStack 的年份.序号命名(比如 2023.1、2023.2、2024.1),而不是早期的地名代号。这一点在实际操作里的意义是:你查到的 OpenStack 版本号,基本可以直接当成 tag 前缀用;但如果你手上的资料还是老版本的代号命名,那就得先对照一下版本对照表,别直接拿代号去拉,拉不到。
还有一个细节:同一个 OpenStack 版本,Kolla 通常会提供多种发行版基础的镜像(不同 Linux 发行版、源码构建与二进制安装两种形态)。同一个服务,ubuntu基础和centos基础的镜像体积能差出一倍。选择哪一个不该由“哪个顺手”决定,而应该由你现有节点的操作系统、以及团队熟悉的包管理方式决定。我踩过一次坑:测试环境节点是某发行版,我图省事同步了另一个发行版基础的镜像,结果部署时各种路径、服务名对不上,排查了大半天才反应过来是基础镜像选错了。这类问题不会在同步阶段暴露,只会在部署阶段炸,非常恶心。
3.3 多架构镜像与 manifest list 的隐形陷阱
这一节值得单独拎出来说,因为它是“用 docker 命令同步”和“用 skopeo 同步”产生差异最明显的地方,也是很多人在混合架构环境里翻车的原因。
现代镜像仓库里,一个 tag 对应的往往不是一个镜像,而是一个manifest list(也叫多架构索引),里面记录了“amd64 架构对应哪个 manifest digest、arm64 对应哪个”。你在 x86 机器上docker pull一个 tag,Docker 会读取这个索引,挑出匹配当前平台的 manifest,只下载那一份,然后把索引丢弃。等你docker tag+docker push推到云上仓库时,推上去的只是单架构镜像,索引没了。
后果是什么?如果之后有 ARM 架构的节点从云上仓库拉同一个 tag,会报no matching manifest for linux/arm64之类的错误。这个错误在纯 x86 环境里永远看不到,一旦团队引入 ARM 节点就立刻爆发,而且排查方向很容易被带偏——大家会去怀疑网络、怀疑认证,唯独想不到是当初同步时把索引弄丢了。
解决办法就两个。一是用skopeo copy --all,它会把整个 manifest list 连同所有平台的 manifest 一起复制过去,索引完整保留,这是最省事的。二是如果真的只能用 docker,那就得对每个架构分别 pull、分别打上带架构后缀的 tag、分别 push,然后在云上仓库手动重建 manifest list,用docker manifest create加--amend逐个添加。第二种做法繁琐且容易出错,我只有在目标仓库不支持 skopeo 认证方式的时候才会用。
注意:判断一个 tag 是不是多架构,可以先用
skopeo inspect --raw docker://<镜像地址>看返回的 JSON 里mediaType是不是 manifest list,或者看manifests数组有没有多个条目。同步前花三十秒确认一次,比事后排查两小时划算得多。
3.4 镜像分层复用的现实收益
聊一个常被忽略但影响很大的点:Kolla 的镜像分层设计对同步效率的影响。
Kolla 的镜像基本都基于同一批基础层(操作系统层、Python 运行时层、公共依赖层),然后在上面叠加各自的服务代码。这意味着几十个镜像里,可能有七八成的层是共享的。如果你用 Docker 本地中转的方案,这些共享层在本地只会存一份,docker push的时候如果目标仓库已经有了某一层,也会跳过上传(仓库端按 digest 判重)。所以实际传输量会明显小于“镜像数 × 平均大小”的粗算值。
但这里有个前提:共享层的 digest 必须完全一致。如果你在同步过程中对镜像做了任何改动(比如重新打了 tag 之外的元数据修改,或者中间经过了某个会自动改写 manifest 的工具),层的 digest 就会变,共享关系断裂,传输量瞬间回到最坏情况。我遇到过一回,中间用了一个带--format参数的工具做转换,结果每个镜像的最底层都被重写,28 GB 的活干成了 60 GB。
所以我的建议很明确:同步过程中不要对镜像内容做任何加工,只做“搬运”,不改“内容”。tag 可以改,路径可以改,manifest 内容一个字都别动。这样既能保住层复用,也能保住 digest,后面校验的时候才有基准。
4. 实操过程:从拉取到推送的完整链路
4.1 环境准备与云上仓库的前置配置
正式动手前,把这几件事做完,能省掉后面 80% 的低级错误。
第一,准备一台“中转机”。要求不高:磁盘留足(按第 2.3 节的估算值再加 50% 余量)、能访问公共镜像源、能访问云上仓库的公网或内网地址、CPU 两核以上。如果云上和本地网络互通,直接在云上开一台按量计费的临时实例最划算,因为上传走内网不计流量、速度快,跑完就释放。磁盘挂载点建议单独挂一块数据盘,别和系统盘混在一起,否则 Docker 的存储目录一撑满,整台机器都会出问题。
第二,在云上仓库创建命名空间和仓库。命名空间按第 2.2 节规划好的来。这里有个实操细节:有些云厂商的镜像仓库要求“先创建仓库才能推送”,有些则支持推送时自动创建。前者需要你把清单里的每个镜像名都提前建一遍,几十个仓库点鼠标点到手酸,一般控制台会提供批量创建或者用 API/CLI 批量建,值得花十分钟研究一下,比手动点快得多。
第三,登录认证。docker login和skopeo login用的是同一套凭证,但存储位置不同,两个工具都要登一次:
# Docker 登录 docker login registry.example.com -u <用户名> # skopeo 登录(凭证写到默认认证文件) skopeo login registry.example.com -u <用户名>密码建议用仓库的独立访问凭证而不是账号主密码,权限按最小化给——只需要目标命名空间的推送权限即可。中转机上如果有多人共用,认证文件权限记得收紧,~/.docker/config.json里存的是 base64 编码的凭证,不是加密的,这一点很多人不知道。
第四,确认网络路径。云上仓库通常提供公网和内网两套域名,内网域名只有在同地域的云主机上才能解析。中转机如果开在云上,务必用内网域名,速度快、不计流量;如果用办公网机器,那就只能走公网,速度取决于你的出口带宽,做好心理准备。
4.2 全量拉取:先落地还是直连
选择方案 A(本地中转)的话,拉取这一步就是把清单里的镜像逐个docker pull下来。看起来简单,但有几个细节决定成败。
第一,tag 要拼对。写个小脚本,把 registry 前缀、命名空间、镜像名、tag 拼起来,别手敲:
#!/usr/bin/env bash set -euo pipefail SRC_REGISTRY="docker.io" SRC_NAMESPACE="kolla" TAG="2024.1-ubuntu-22.04" while read -r img; do [ -z "$img" ] && continue echo ">>> pulling ${SRC_REGISTRY}/${SRC_NAMESPACE}/${img}:${TAG}" docker pull "${SRC_REGISTRY}/${SRC_NAMESPACE}/${img}:${TAG}" done < images.txtset -euo pipefail这三个选项强烈建议都加上。-e让脚本在某个镜像拉取失败时立刻停下,而不是继续往下跑然后你第二天早上发现只成功了 60%;-u防止变量拼错导致拉了个空 tag;pipefail保证管道里的错误不会被吞掉。
第二,注意公共镜像源的拉取频率限制。匿名拉取有比较严格的配额,登录之后配额会高一些。几十个镜像连着拉,很容易触发限流,报错信息通常是toomanyrequests。遇到这种情况不要硬重试,会越重试越久——正确做法是先登录(即使是免费账号也能提升配额),然后在脚本里加入退避重试:
retry() { local n=0 until "$@"; do n=$((n+1)) [ "$n" -ge 5 ] && { echo "重试 5 次仍失败: $*"; return 1; } echo "第 $n 次失败,$((n*30)) 秒后重试..." sleep $((n*30)) done }第三,磁盘水位要盯着。拉取过程中随时可能撑满,脚本里加一个简单的水位检查,低于 15% 就停下来报警,比事后清理现场强:
avail=$(df --output=pcent /var/lib/docker | tail -1 | tr -dc '0-9') if [ "$avail" -gt 85 ]; then echo "磁盘使用率 ${avail}%,停止拉取" exit 1 fi4.3 打 tag 与推送的落地写法
拉下来之后要改成目标路径。这一步的关键是批量、可重复、可恢复。手敲docker tag几十次不仅累,还容易漏。
DST_REGISTRY="registry.example.com" DST_NAMESPACE="kolla-2024-1" while read -r img; do [ -z "$img" ] && continue src="${SRC_REGISTRY}/${SRC_NAMESPACE}/${img}:${TAG}" dst="${DST_REGISTRY}/${DST_NAMESPACE}/${img}:${TAG}" docker tag "$src" "$dst" docker push "$dst" # 推完立刻删本地,控制磁盘水位 docker rmi "$src" "$dst" || true done < images.txt这段脚本里有两个值得说的设计。一是推完立刻删本地,因为本地只是中转,留着没有意义,删掉能立刻释放空间,让后续镜像有地方落。|| true是为了防止某些镜像被多个 tag 引用导致删除失败中断脚本。二是tag 名保持一致,源和目标用同一个 tag,不额外加后缀,这样kolla-ansible侧不用做任何 tag 转换,减少了出错的可能。
如果不想占用本地磁盘,可以跳过docker tag,直接docker push一个本地不存在的目标地址是不行的,所以本地中转方案绕不开落盘。这也是我最终放弃它的原因。
4.4 用 skopeo 直连复制:我更推荐的批量做法
直连复制的核心命令只有一行,但它背后能省掉磁盘、省掉 Docker daemon、还能保住多架构:
skopeo copy --all \ --retry-times 3 \ --src-creds "$SRC_USER:$SRC_PASS" \ --dest-creds "$DST_USER:$DST_PASS" \ "docker://docker.io/kolla/nova-api:2024.1-ubuntu-22.04" \ "docker://registry.example.com/kolla-2024-1/nova-api:2024.1-ubuntu-22.04"几个参数值的解释。--all是保多架构的关键,前面说过,它会复制整个 manifest list。--retry-times 3让单次传输遇到网络抖动时自动重试,比外层脚本重试粒度更细,不会因为一次失败就重传整个镜像。--src-creds/--dest-creds显式传凭证,避免依赖本地的认证文件状态,脚本可移植性更好。
批量跑的时候,用xargs控制并发。并发数不要贪多,我实测下来 3 到 4 个并发的综合效率最好:太少浪费带宽,太多容易触发目标仓库的限流,而且本地 CPU 会飙起来(skopeo 复制过程中要做解压缩和摘要校验,是吃 CPU 的)。
cat images.txt | xargs -I{} -P 4 bash -c ' img="{}" skopeo copy --all --retry-times 3 \ "docker://docker.io/kolla/${img}:'"${TAG}"'" \ "docker://registry.example.com/kolla-2024-1/${img}:'"${TAG}"'" \ >> sync.log 2>&1 || echo "FAILED: ${img}" >> failed.txt '跑完之后看failed.txt,里面有什么就单独补什么,不要重跑全量。这个“失败清单”机制看着土,但非常实用,尤其是几百个镜像的批量任务。
提示:
skopeo copy的日志默认比较安静,加上--debug可以看到每一层 blob 的传输情况,排查“卡住不动”的时候特别有用。注意--debug输出量很大,别在几百个镜像的循环里一直开着,会撑爆日志文件。
4.5 校验环节:不做这一步等于没做完
镜像推上去之后,最容易被跳过也最不该跳过的一步是校验。我见过太多“推完了就说搞定了,结果部署时拉下来发现镜像不完整”的案例。校验分三个层次,从轻到重。
第一层,存在性校验。看目标仓库里有没有这个 tag,skopeo inspect或者docker manifest inspect都行:
skopeo inspect --raw "docker://registry.example.com/kolla-2024-1/nova-api:${TAG}" > /dev/null && echo OK第二层,digest 比对。这是最硬的证据,源和目标的 manifest digest 一致,说明内容字节级相同:
src_digest=$(skopeo inspect --format '{{.Digest}}' "docker://docker.io/kolla/nova-api:${TAG}") dst_digest=$(skopeo inspect --format '{{.Digest}}' "docker://registry.example.com/kolla-2024-1/nova-api:${TAG}") [ "$src_digest" = "$dst_digest" ] && echo "一致" || echo "不一致: $src_digest vs $dst_digest"注意这里有个细节:如果你同步的是多架构镜像,skopeo inspect默认返回的是 manifest list 的 digest;如果你在同步过程中丢了索引(比如用了 docker 单架构方案),这个 digest 必然对不上,这是发现“索引丢失”最快的办法。所以我把 digest 比对当成必做项,而不是可选优化。
第三层,拉取回归。挑两三个关键镜像(比如keystone、mariadb),在一台干净的机器上从云上仓库完整拉一遍,确认能正常解压、能正常docker inspect。这一步能发现一些前两层覆盖不到的问题,比如仓库侧存储异常导致某层 blob 损坏。概率不高,但一旦发生就是部署卡死的级别,值得花这十分钟。
5. 常见问题与排查技巧实录
5.1 高频报错速查表
下面这张表是我这几年攒下来的,基本覆盖了同步过程中九成以上的报错。看到什么现象,先到这里对一下,比盲目搜索快得多。
| 报错/现象 | 大概率原因 | 处理办法 |
|---|---|---|
manifest unknown | tag 拼错、版本不存在、镜像名拼错 | 用skopeo list-tags查实际有哪些 tag |
unauthorized: authentication required | 凭证过期、命名空间权限不足 | 重新登录,确认凭证对该命名空间有推送权限 |
toomanyrequests | 公共源拉取限流 | 登录提升配额,加退避重试,降低并发 |
no space left on device | 本地磁盘满 | 边推边删、单独挂数据盘、加水位数检查 |
blob upload invalid/ 上传中断 | 网络抖动、目标仓库限流 | 降低并发到 1-2,加--retry-times |
no matching manifest for linux/arm64 | 同步时丢了 manifest list | 改用skopeo copy --all重做 |
denied: requested access to the resource is denied | 仓库不存在、路径大小写不符 | 先在控制台创建仓库,注意大小写敏感 |
| 推送速度极慢 | 走了公网、并发过高触发限速 | 换内网域名,并发降到 3-4 |
| 推完了但部署拉取失败 | 认证配置、mirror 配置、网络策略 | 检查节点侧 registry 配置与安全组 |
表里最后一行值得多说一句。“推上去了,部署时拉不下来”这个问题最容易被误判成仓库的问题,实际上八成是部署节点侧的问题:可能是节点的 Docker 没有登录目标仓库、可能是daemon.json里配了别的 mirror 导致请求被劫持、也可能是节点到仓库的安全组没放通。排查顺序建议从节点侧开始,不要一上来就怀疑仓库。
5.2 限流、超时与重试策略的设计
限流这件事,本质上是一个“礼貌问题”。公共镜像源对匿名请求有严格配额,云上仓库对单账号的并发上传也有阈值。你越是猛冲,越容易被掐,掐了之后重试又加剧拥堵,最后陷入恶性循环。
我的策略是三层退避。第一层是传输工具内部的--retry-times,处理偶发的单次失败;第二层是脚本层面的指数退避,处理周期性的限流;第三层是“失败清单”人工介入,处理那些重试也没用的硬错误(比如 tag 根本不存在,重试一百次也没用)。
具体参数上,我把并发控制在 4 以内,重试间隔从 30 秒起步、每次翻倍、封顶 5 分钟。另外有个小技巧:批量任务尽量安排在目标仓库的低峰时段跑,这个时段不是看你的时间,而是看云厂商的公共负载情况,通常工作日的深夜到凌晨更稳,实测下来失败率能低一半。
超时方面,skopeo copy默认没有强制超时,遇到网络半死不活的状态会一直挂着。建议在脚本外面套一层timeout:
timeout 1800 skopeo copy --all ... || echo "超时,加入失败清单"三十分钟对一个几百 MB 到一两个 G 的镜像足够了,超了就说明链路有问题,早停早排查,别让它挂在那儿占着并发名额。
5.3 磁盘、并发与 CPU 的资源配合
这三个东西是互相牵制的。并发调高,磁盘 IO 上去了,CPU 也上去了(skopeo 要校验摘要),如果磁盘本身是机械盘或者网络盘,反而会拖慢整体速度。我做过一组简单的对比观察:同样是 40 个镜像,2 并发跑完用了 95 分钟,4 并发用了 68 分钟,8 并发用了 82 分钟——8 并发反而更慢,因为磁盘 IO 成了瓶颈,而且触发了两次仓库限流。
所以并发数不是越大越好,得看你机器的瓶颈在哪。判断方法很简单:跑的时候iostat -x 1看磁盘利用率、top看 CPU 的wa(IO 等待)比例。如果 IO 利用率接近 100%,加并发只会更糟;如果 CPU 和 IO 都有余量但速度上不去,那瓶颈在网络或对端仓库,这时候要考虑的是换内网地址,而不是加并发。
磁盘这块再补一个实操细节:如果用 Docker 本地中转方案,/var/lib/docker的可用空间要预留出“当前正在处理的镜像 × 并发数”的容量,而不是只留一个镜像的空间。因为并发拉取的时候会有多个镜像同时落盘。我以前按单个最大镜像留空间,结果并发 3 的时候直接撑爆,这个教训很深刻。
5.4 一个真实场景的排查记录
说个具体的过程,比较有代表性。某次帮一个团队做同步,42 个镜像里有 3 个反复失败,报的都是blob upload invalid,重试也没用。但另外 39 个都成功了,说明网络和认证整体没问题。
第一步,把那 3 个镜像单独拿出来,用最小并发、加--debug跑一遍,观察卡在哪一层。发现都是卡在同一个大层上(约 700 MB),其余小层都过了。第二步,怀疑是传输过程中的偶发中断,改成先skopeo copy到本地目录(dir:目标),确认源端能完整下载。结果本地下载正常,说明源没问题。第三步,问题就锁定在“上传这个大文件到目标仓库”这一段,可能是目标侧的单个请求大小限制或者超时策略。最后的解决办法是把并发降到 1,并且显式增大客户端的分块上传尺寸(不同仓库对分块的处理不一样,有的仓库在客户端不支持分块时容易在大文件上出问题),三个镜像逐个推,全部成功。
这件事给我的经验是:批量任务里出现“少数几个失败”时,不要第一反应是重跑全量,那只会浪费时间。先把失败项隔离出来,用最小变量复现,判断问题出在源端、链路还是目标端,再做针对性处理。这个排查思路比任何具体命令都值钱。
6. 增量同步与长期维护:让这件事不用重复做
6.1 版本升级时的增量策略
第一次全量同步最累,之后每次升级如果能做增量,成本会低很多。判断“哪些需要重新同步”的方法有两个。
第一个是按镜像名比对:清单里有哪些镜像是新版本才引入的、哪些是废弃的。这个靠 diff 两份images.txt就能得出,新增的同步、废弃的不管、保留的重点看下面。
第二个是按 digest 比对:对保留下来的镜像,分别查源端和目标端的 digest,相同的直接跳过。这是最准确的做法,因为很多镜像在新版本里虽然名字没变,但内容变了(比如只是基础层更新),digest 一定不同;反过来也有些镜像跨版本完全没变,digest 相同,那就一行命令都不用跑。
while read -r img; do [ -z "$img" ] && continue s=$(skopeo inspect --format '{{.Digest}}' "docker://docker.io/kolla/${img}:${TAG}" 2>/dev/null || echo none) d=$(skopeo inspect --format '{{.Digest}}' "docker://registry.example.com/kolla-2024-1/${img}:${TAG}" 2>/dev/null || echo none) if [ "$s" != "$d" ]; then echo "$img" fi done < images.txt > need_sync.txt这个小脚本有个副作用:每个镜像都要做两次远程 inspect,网络请求数不少,如果镜像多,跑一遍也要十几分钟。但比起无脑重传几十 GB,这十几分钟花得值。我一般会把这个比对结果存下来,作为本次升级的“变更清单”留档,出了问题能快速定位是哪个镜像变了。
6.2 生命周期规则与旧版本清理
云上仓库是按存储量计费的,镜像堆着不清,成本会慢慢爬升。一个 OpenStack 版本的全量镜像按 30 GB 算,四个版本就是 120 GB,加上多架构翻倍,很容易到几百 GB。所以从第一天起就要有清理机制。
我的做法是给每个版本的命名空间设一条生命周期规则:保留最近 N 个 tag(N 按你的回滚需求定,我一般留 2-3 个),超出部分自动清理;同时给整个命名空间设一个“最大保留天数”,比如 180 天,到期的命名空间整体清空。两级规则配合,既保证能回滚,又不会无限堆积。
设置的时候有个坑要注意:生命周期规则通常是按 tag 的最近推送时间或者按 tag 数量来判断的,不是按镜像的实际新旧。如果你某次手工重推了一个老版本镜像,它的“最近推送时间”会刷新,生命周期规则就会把它当成新镜像保留下来,而真正该留的可能被清掉。所以手工重推之后,记得检查一下 tag 列表和规则状态,别让自动化规则做了错误判断。
另一个更稳妥的做法是:旧版本命名空间不删,只做归档标记。清理动作放到季度级别的统一评审里做,确认没有回滚需求了再删。这样虽然多占一点存储,但避免了“升级出问题想回滚,发现镜像已经被规则清掉了”的绝境。这个场景我遇到过,只能重新从公共源拉一遍,赶上网络不好,硬是等了三个小时。
6.3 让部署节点直接受益的收尾配置
镜像搬到云上仓库之后,最后一步是让部署节点真的用上它。这一步看着简单,但配置项容易漏。
在kolla-ansible侧,/etc/kolla/globals.yml里的两个变量前面已经说过,改完就生效。但如果是节点上还跑着别的容器负载,统一在/etc/docker/daemon.json里配置一下更快:
{ "registry-mirrors": ["https://registry.example.com"], "insecure-registries": [] }registry-mirrors的作用是当节点请求公共源的镜像时,优先走你的仓库,前提是你的仓库支持镜像加速/回源功能。配完之后记得重启容器运行时,不然不生效。这里有个容易踩的坑:如果registry-mirrors里配的地址本身不可用(比如内网域名在非云主机上解析不了),Docker 会先尝试镜像地址、失败后再回源,表现为拉取变慢而不是报错,很难察觉。所以我建议配完做一次实测:故意拉一个仓库里没有的镜像,看耗时是否正常。
再补一个实用技巧:把kolla-ansible pull这一步单独跑,观察每个镜像的拉取耗时。正常情况下从内网仓库拉单个镜像应该在十几秒到一分钟之间,如果某个镜像特别慢,说明它可能没命中你的仓库、走了公网回源,这时候去查它的 tag 是不是和仓库里的对不上。这个观察方法比看日志找线索快得多,我在现场基本都用这招做第一轮判断。
最后分享一个我在长期维护里形成的习惯:每次同步完成后,把本次的“清单文件 + 目标 tag + digest 列表”存成一份带日期的快照,跟代码一样入库管理。这份快照的价值在于,当半年后有人问“当时部署的那套环境用的到底是哪批镜像”,你能在三秒内给出确定答案,而不是登到环境上docker images一条条翻。做运维久了会发现,能把历史状态说清楚这件事,比把当下的事情做完更难,也更值钱。