Skip to content

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

两个注意点:

  1. 分页:Harbor API 默认每页 10 条,page_size=100 是上限。项目/仓库/tag 超过 100 个时必须翻页遍历(见 3.4 节)。
  2. 重复创建返回 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_count

4.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 增加重试
复制策略一直 pendingHarbor 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 工具迁移
同名覆盖目标仓库已有同名项目时,同步会合并写入,不会清空已有内容,但仍建议先备份目标仓库元数据