主题
Harbor 双仓库全量与增量镜像同步实战
在生产环境中,经常需要在两个 Harbor 仓库之间做镜像同步:测试环境仓库(下文称 Harbor-A,harbor-a.example.com:60000)向生产环境仓库(下文称 Harbor-B,harbor-b.example.com:60000)迁移全部镜像,或两个站点之间做灾备复制。镜像规模通常达到数千个仓库、上万个 tag,手工操作不现实。本文整理一套经过生产验证的同步方案:先用 Harbor 自带复制策略做基线评估,再用 Harbor API 遍历 + skopeo 批量同步脚本完成全量同步,最后配合 manifest 对比实现增量同步,并给出同步结果的校验方法。
适用对象:Harbor v2.x(API v2.0),源仓库允许 HTTP 或 HTTPS 访问,执行机可安装 skopeo 或 docker。
一、方案对比与选型
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Harbor 自带复制策略(Replication) | 目标仓库配置 Registry 端点和复制规则,事件驱动或定时触发 | 界面化配置、支持过滤规则、有执行日志和重试 | 需要目标仓库管理员权限;规则粒度有限;大批量首次同步时任务排队慢;跨版本兼容性偶有坑 | 长期持续复制、新增镜像自动同步 |
| Harbor API 遍历 + skopeo 脚本 | 调 API 枚举项目/仓库/tag,逐个 skopeo copy 复制 | 不依赖目标仓库的复制配置,源仓库只读即可;可控性强,容易做断点续传和增量对比 | 需要自己写脚本;无任务队列,需自行处理并发与失败重试 | 一次性全量迁移、源仓库只读、需要精确控制同步范围 |
实践建议:首次全量迁移用脚本方案(可控、可校验),迁移完成后如需长期保持同步,再在 Harbor-B 上配置复制策略接管增量。
二、前置准备
在执行机上准备凭据(全部通过环境变量注入,不要写进脚本文件):
bash
# 源仓库(Harbor-A)
export SOURCE_HARBOR="http://harbor-a.example.com:60000"
export SOURCE_USERNAME="xxx"
export SOURCE_PASSWORD="xxx"
# 目标仓库(Harbor-B)
export TARGET_HARBOR="https://harbor-b.example.com:60000"
export TARGET_USERNAME="xxx"
export TARGET_PASSWORD="xxx"确认依赖工具:
bash
# skopeo(推荐,免 docker daemon,直接 registry 到 registry 复制)
skopeo --version
# jq 用于解析 API 返回
jq --version
# curl 用于调用 Harbor API
curl --version使用 skopeo 登录两个仓库(生成 authfile,避免每次命令带明文密码):
bash
skopeo login "$SOURCE_HARBOR" -u "$SOURCE_USERNAME" -p "$SOURCE_PASSWORD"
skopeo login "$TARGET_HARBOR" -u "$TARGET_USERNAME" -p "$TARGET_PASSWORD"若目标仓库是自签证书,skopeo copy 时加 --dest-tls-verify=false,登录时加 --tls-verify=false。
三、脚本详解
3.1 第一步:在目标仓库创建同名项目
同步镜像前,目标仓库必须已存在对应项目,否则 push/copy 会报 project not found。通过 API 枚举源仓库项目并在目标仓库创建:
bash
#!/bin/bash
# create-projects.sh —— 将源仓库所有项目同步创建到目标仓库
PROJECTS=$(curl -s -u "$SOURCE_USERNAME:$SOURCE_PASSWORD" \
"$SOURCE_HARBOR/api/v2.0/projects?page=1&page_size=100&sort=creation_time" \
| jq -r '.[].name')
for PROJECT in $PROJECTS; do
echo "creating project: $PROJECT"
curl -s -k -u "$TARGET_USERNAME:$TARGET_PASSWORD" \
-X POST -H "Content-Type: application/json" \
"$TARGET_HARBOR/api/v2.0/projects" \
-d "{\"project_name\": \"${PROJECT}\", \"public\": false}"
echo
done两个注意点:
- 分页:Harbor API 默认每页 10 条,
page_size=100是上限。项目/仓库/tag 超过 100 个时必须翻页遍历(见 3.4 节)。 - 重复创建返回 409:项目已存在时 API 返回 409 Conflict,属于正常情况,脚本可忽略该错误继续执行。
3.2 第二步:枚举仓库与 tag(含 %2F 编码坑)
Harbor 的仓库名可以包含多级路径,例如 base/ecm/ecm-open-console。调用单仓库的 artifacts 接口时,仓库名中的 / 必须做 URL 编码为 %2F,否则 API 返回 404。这是脚本里最容易踩的坑:
bash
# REPO 形如 base/ecm/ecm-open-console(含项目名前缀)
# 先去掉项目名前缀
REPO_ITEM_TMP=$(echo "$REPO" | sed -e "s|^$PROJECT/||")
# 再把剩余路径中的 / 编码为 %2F
REPO_ITEM=$(echo "$REPO_ITEM_TMP" | sed 's/\//%2F/g')
# 结果:ecm%2Fecm-open-console
TAGS=$(curl -s -u "$SOURCE_USERNAME:$SOURCE_PASSWORD" \
"$SOURCE_HARBOR/api/v2.0/projects/$PROJECT/repositories/$REPO_ITEM/artifacts?page=1&page_size=100" \
| jq -r '.[].tags[].name')注意 sed 表达式用 | 做分隔符,避免与路径中的 / 冲突。
另一个变体是双重编码:部分场景(如通过反向代理访问 API)需要 %252F。如果 %2F 仍返回 404,可尝试 %252F:
bash
REPO_ITEM=$(echo "$REPO_ITEM_TMP" | sed 's/\//%252F/g')3.3 第三步:skopeo 批量同步主脚本
相比 docker pull → tag → push → rmi 的传统流程,skopeo copy 直接在两个 registry 之间流式复制,不落地本地镜像层,速度快、不占本地磁盘,也不需要本地 docker daemon:
bash
#!/bin/bash
# harbor-sync.sh —— 全量同步源仓库所有项目/仓库/tag 到目标仓库
set -uo pipefail
sync_project() {
local PROJECT="$1"
echo "===== 开始同步项目: $PROJECT ====="
# 确保目标项目存在(已存在返回 409,忽略)
curl -s -k -u "$TARGET_USERNAME:$TARGET_PASSWORD" \
-X POST -H "Content-Type: application/json" \
"$TARGET_HARBOR/api/v2.0/projects" \
-d "{\"project_name\": \"${PROJECT}\", \"public\": false}" > /dev/null
local PAGE=1
while true; do
REPOSITORIES=$(curl -s -u "$SOURCE_USERNAME:$SOURCE_PASSWORD" \
"$SOURCE_HARBOR/api/v2.0/projects/$PROJECT/repositories?page=$PAGE&page_size=100" \
| jq -r '.[].name')
[ -z "$REPOSITORIES" ] && break
for REPO in $REPOSITORIES; do
echo " 同步仓库: $REPO"
# 去掉项目名前缀,并将 / 编码为 %2F
local REPO_ITEM_TMP REPO_ITEM
REPO_ITEM_TMP=$(echo "$REPO" | sed -e "s|^$PROJECT/||")
REPO_ITEM=$(echo "$REPO_ITEM_TMP" | sed 's/\//%2F/g')
local TAGS
TAGS=$(curl -s -u "$SOURCE_USERNAME:$SOURCE_PASSWORD" \
"$SOURCE_HARBOR/api/v2.0/projects/$PROJECT/repositories/$REPO_ITEM/artifacts?page=1&page_size=100" \
| jq -r '.[].tags[].name')
for TAG in $TAGS; do
local SRC_IMAGE DST_IMAGE
SRC_IMAGE="${SOURCE_HARBOR#*://}/$REPO:$TAG"
DST_IMAGE="${TARGET_HARBOR#*://}/$REPO:$TAG"
echo " $SRC_IMAGE -> $DST_IMAGE"
skopeo copy --src-tls-verify=false --dest-tls-verify=false \
"docker://$SRC_IMAGE" "docker://$DST_IMAGE" \
|| echo " [FAILED] $SRC_IMAGE" >> sync-failed.log
done
done
PAGE=$((PAGE + 1))
done
}
# 全量模式:遍历所有项目;指定项目模式:sync_project rancher
if [ $# -ge 1 ]; then
sync_project "$1"
else
PROJECTS=$(curl -s -u "$SOURCE_USERNAME:$SOURCE_PASSWORD" \
"$SOURCE_HARBOR/api/v2.0/projects?page=1&page_size=100&sort=creation_time" \
| jq -r '.[].name')
for PROJECT in $PROJECTS; do
sync_project "$PROJECT"
done
fi
echo "镜像同步完成!"要点说明:
- 仓库枚举处已做翻页循环(
PAGE递增直到返回空),解决单项目下仓库超过 100 个时漏同步的问题。tag 数超过 100 的仓库同样需要翻页,如有此类仓库按同样方式处理。 - 失败的镜像追加到
sync-failed.log,脚本不中断,全部跑完后针对失败清单重跑。 - 自签 HTTPS 目标仓库加
--dest-tls-verify=false;源为 HTTP 时加--src-tls-verify=false。
3.4 增量同步策略:manifest 对比跳过已存在镜像
全量同步跑完后,日常增量同步的核心是跳过目标仓库已存在且内容一致的镜像。判断依据是镜像 manifest 的 digest:用 skopeo inspect 分别取源和目标的 digest,一致则跳过。
bash
#!/bin/bash
# sync-one.sh —— 增量同步单个镜像,已存在且 digest 一致则跳过
SRC="$1" # 例如 harbor-a.example.com:60000/base/nginx:1.25
DST="$2"
SRC_DIGEST=$(skopeo inspect --tls-verify=false "docker://$SRC" 2>/dev/null | jq -r '.Digest')
if [ -z "$SRC_DIGEST" ] || [ "$SRC_DIGEST" = "null" ]; then
echo "[SKIP] 源镜像不存在: $SRC"; exit 0
fi
DST_DIGEST=$(skopeo inspect --tls-verify=false "docker://$DST" 2>/dev/null | jq -r '.Digest')
if [ "$SRC_DIGEST" = "$DST_DIGEST" ]; then
echo "[SKIP] 已存在且一致: $DST"
exit 0
fi
echo "[COPY] $SRC -> $DST"
skopeo copy --src-tls-verify=false --dest-tls-verify=false \
"docker://$SRC" "docker://$DST"将该函数嵌入 3.3 的主循环(在 skopeo copy 前先对比 digest),即可把全量脚本改造成增量脚本。二次运行时只复制新增和变化的 tag,耗时从小时级降到分钟级。
补充说明:
- digest 对比基于 manifest,tag 相同但内容不同(镜像被覆盖推送)时会识别为不一致并重新复制,这正是期望行为。
- 如果只需要同步最近变化的镜像,也可以在 artifacts 接口按
push_time排序后只取最近 N 条。
四、同步结果校验
4.1 数量比对
分别统计两边仓库的 artifact 总数:
bash
# 源仓库各项目 artifact 数
curl -s -u "$SOURCE_USERNAME:$SOURCE_PASSWORD" \
"$SOURCE_HARBOR/api/v2.0/projects?page=1&page_size=100" \
| jq -r '.[] | "\(.name)\t\(.repo_count)"'
# 目标仓库同样执行,逐一比对 repo_count4.2 digest 抽样比对
从失败日志和同步日志中抽样,逐条执行 3.4 节的 digest 对比脚本(只比对不复制,去掉 copy 行即可),确认源与目标一致。
4.3 拉取验证
在目标仓库侧找一台节点实际拉取关键镜像:
bash
docker pull harbor-b.example.com:60000/base/nginx:1.25
docker run --rm harbor-b.example.com:60000/base/nginx:1.25 nginx -v五、故障速查
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
| 获取 tag 列表返回 404 | 仓库名中的 / 未编码 | 按 3.2 节将 / 编码为 %2F;经反代仍 404 时尝试 %252F |
| 项目数/仓库数明显偏少 | 未翻页,API 默认只返回前 10 条 | 请求加 page_size=100 并按 page 递增遍历至空 |
目标 push/copy 报 project not found | 目标仓库无对应项目 | 先执行 3.1 节项目创建脚本;注意项目名大小写与源一致 |
skopeo copy 报 certificate signed by unknown authority | 目标仓库自签证书 | 加 --dest-tls-verify=false,或将 CA 导入系统信任链 |
skopeo copy 报 401 Unauthorized | 未登录或凭据错误 | 重新 skopeo login;确认账号对源有读权限、对目标项目有写权限 |
| 同步大量镜像后本地磁盘暴涨 | 用了 docker pull/tag/push 方式 | 改用 skopeo;或每轮后 docker rmi 清理并 docker system prune |
| 部分镜像反复同步失败 | 镜像层损坏或网络中断 | 记录到失败清单单独重试;skopeo copy --retry-times 3 增加重试 |
| 复制策略一直 pending | Harbor jobservice 队列积压 | 重启 jobservice 容器;大批量首次同步建议改用脚本方案 |
| 二次全量同步耗时依然很长 | 未做 digest 跳过 | 按 3.4 节加入 manifest 对比,跳过已存在镜像 |
注意事项
| 事项 | 说明 |
|---|---|
| 凭据管理 | 账号密码一律通过环境变量注入,脚本文件中不得出现明文;执行机的 ~/.docker/config.json 会留存 base64 凭据,用完及时清理 |
| 同步窗口 | 全量同步占用大量带宽,建议在业务低峰期执行;可加 --src-blob-cache 或限速避免打满线路 |
| chartmuseum/helm chart | 上述 API 只覆盖镜像;Harbor v2.0 后 chart 以 OCI artifact 存储可一并枚举,老版本 chartmuseum 需单独用 helm 工具迁移 |
| 同名覆盖 | 目标仓库已有同名项目时,同步会合并写入,不会清空已有内容,但仍建议先备份目标仓库元数据 |