DevOps 平台实现容器镜像同步工单:远程下拉、默认最新 Tag、内网同步和 Digest 校验

这篇记录一次 DevOps 平台里的“容器镜像同步”工单实现。目标不是做一个简单表单,而是把镜像仓库发现、远程下拉、依赖字段默认值、审批后执行、覆盖保护、内网同步和 Digest 校验串成一条稳定链路。

核心结论:这类能力最好拆成三层。

  • 前端只负责交互状态:远程下拉、依赖清空、默认值填充、完整镜像地址预览。
  • 后端负责资源目录和规则:发现 ACR/TCR、返回结构化 options、校验字段、规划目标镜像。
  • Worker 负责执行:审批后用 skopeo copy 复制镜像层,并用 Digest 验证结果。

1. 环境和组件

本文讲的是一个内部 DevOps 平台的实现模式,技术栈可以替换,但职责边界建议保持一致。

层级 示例技术 作用
前端 Vue 3、Element Plus 工单表单、远程下拉、依赖字段、默认值
后端 Django、REST API 工单类型、字段配置、镜像仓库 catalog
云厂商 阿里云 ACR、腾讯云 TCR 镜像仓库、命名空间、仓库、Tag 查询
执行层 独立 Worker、skopeo 镜像复制、覆盖检查、Digest 校验
网络 VPC/CEN/专线/内网 DNS 避免镜像 layer 走公网

2. 为什么不用一个“完整镜像地址输入框”

最开始做镜像同步,很容易想到让用户直接填:

source.example.com/prod/public:latest
target.example.com/public/service:latest

这个方式开发快,但问题也明显:

  1. 用户容易填错 namespace、repository、tag。
  2. 目标仓库策略不容易统一,例如全部写入 public/service:<tag>
  3. 无法在提交前做覆盖检测。
  4. 很难展示源和目标的公网/私网地址。
  5. 后端拿到的是字符串,不利于审批、审计和后续扩展。

所以最终采用结构化字段:

{
  "source_registry": "aliyun-acr-beijing",
  "source_namespace": "prod",
  "source_repository": "public",
  "source_tag": "20260730-001",
  "target_registry": "tencent-tcr-beijing",
  "overwrite_existing": false
}

表单上看起来多了几个字段,但后端拿到的是可校验、可审计、可扩展的数据。

3. 前端:远程下拉和依赖字段

源镜像字段之间有明确依赖:

源镜像仓库
  -> 源命名空间
    -> 源镜像
      -> 源 Tag

因此每个下拉都不是静态选项,而是远程查询。

一个通用 RemoteSelectField 组件需要处理几件事:

  • 上级依赖没选完时禁用当前字段。
  • 下拉打开时再加载,避免页面初始全量 loading。
  • 支持搜索和刷新。
  • 上级依赖变化时清空下级值,避免提交旧组合。

伪代码如下:

const dependenciesReady = computed(() => {
  return dependencyKeys.value.every((key) => !isBlank(formValues[key]))
})

watch(dependencySignature, (next, previous) => {
  options.value = []
  loaded.value = false

  if (previous !== undefined && next !== previous && !isBlank(modelValue)) {
    emit('update:modelValue', '')
    emit('change', '')
  }
}, { immediate: true })

这里的清空逻辑很重要。比如用户先选了 prod/public,后来把命名空间切到 devops,如果源镜像还保留 public,提交时可能是一个不存在的组合。

4. 默认值不能乱填:必须按依赖顺序

这次踩到的一个点是:默认值不是简单地给字段赋值。

需求是新建工单时默认:

源镜像仓库 = 阿里云-北京
源命名空间 = prod
源镜像 = public
目标镜像仓库 = 腾讯云-北京

如果代码直接同时写入这些字段,可能会被依赖字段的清空逻辑反向清掉。因为 source_repository 依赖 source_registry + source_namespace,当上级字段刚变化时,通用下拉组件会认为“依赖变了”,于是清空下级。

正确做法是:先写上级,再等一轮 UI 状态更新,再写下级。

const applyDefaults = async () => {
  await Promise.all([
    applyDefaultRegistry('source_registry', isDefaultSourceRegistry),
    applyDefaultRegistry('target_registry', isDefaultTargetRegistry),
  ])

  if (isBlank(formValues.source_namespace)) {
    updateField('source_namespace', 'prod')
  }

  await nextTick()

  if (isBlank(formValues.source_repository)) {
    updateField('source_repository', 'public')
  }
}

这个改动只影响初始化状态,不会覆盖用户手动选择的值。

5. 源 Tag 默认取最新:用 metadata.updated_at 排序

源 Tag 也可以给默认值,但不要写死 latest。很多基础镜像仓库里,latest 未必存在,也未必表示最新。

更稳妥的方式是:

  1. 等源仓库、命名空间、镜像都确定。
  2. 请求当前 repository 的 Tag 列表。
  3. 每个 Tag 返回 metadata.updated_at
  4. 前端按时间倒序取最新。
  5. 如果用户已经选了 Tag,不覆盖。

前端逻辑可以写成:

const timestampOf = (option) => {
  const value = option?.metadata?.updated_at || option?.metadata?.created_at || ''
  const timestamp = Date.parse(value)
  return Number.isFinite(timestamp) ? timestamp : 0
}

const latestOption = (items) => {
  return [...(items || [])]
    .sort((left, right) => timestampOf(right) - timestampOf(left))[0] || null
}

const applyLatestSourceTag = async () => {
  if (!isBlank(formValues.source_tag)) return
  if (!formValues.source_registry || !formValues.source_namespace || !formValues.source_repository) return

  const result = await getFieldOptions({
    source: 'image_sync.source_tags',
    source_registry: formValues.source_registry,
    source_namespace: formValues.source_namespace,
    source_repository: formValues.source_repository,
    page: 1,
    page_size: 50,
  })

  const matched = latestOption(result.items)
  if (matched) updateField('source_tag', matched.value)
}

这里有一个前提:后端必须把 Tag 的更新时间带出来。

阿里云 ACR 类似:

{
    "name": item.tag,
    "metadata": {
        "digest": item.digest,
        "size": item.image_size,
        "updated_at": item.image_update,
    },
}

腾讯云 TCR 类似:

{
    "name": item.get("ImageVersion"),
    "metadata": {
        "digest": item.get("Digest"),
        "size": item.get("Size"),
        "updated_at": item.get("UpdateTime"),
    },
}

6. 后端:统一 option 结构

前端不应该理解每个云厂商 API 的差异。后端把云厂商返回统一成一种结构:

{
  "items": [
    {
      "value": "public",
      "label": "public",
      "metadata": {
        "updated_at": "2026-07-30T10:00:00+08:00",
        "digest": "sha256:..."
      }
    }
  ],
  "total": 1,
  "has_more": false
}

这个结构有几个好处:

  • value 是提交值。
  • label 是展示值。
  • metadata 给前端做排序、预览和提示。
  • 后端仍然保留最终校验权。

字段 options 可以统一走一个入口:

/orders/field-options/?source=image_sync.source_tags

根据 source 分发到不同 handler:

OPTION_HANDLERS = {
    "image_sync.source_registries": source_registries,
    "image_sync.source_namespaces": source_namespaces,
    "image_sync.source_repositories": source_repositories,
    "image_sync.source_tags": source_tags,
    "image_sync.target_registries": target_registries,
}

这样新加一个远程字段,不需要前端新增一套接口,只要配置 option_source

7. 执行层:审批后再同步

镜像同步不是普通查询,应该放在审批之后执行。

典型状态流:

提交工单
  -> 审批通过
  -> 创建 ImageSyncExecution
  -> Worker 执行 skopeo copy
  -> inspect/校验 Digest
  -> 写完成说明

执行命令本质是:

skopeo copy --all \
  docker://source-registry/namespace/repository:tag \
  docker://target-registry/namespace/repository:tag

为什么执行层选 skopeo,下一节单独展开。

8. skopeo 是什么:原理、安装和部署方式

skopeo 是 containers 项目里的一个镜像操作工具。它和 docker pull && docker tag && docker push 最大的区别是:skopeo 可以直接在 registry 之间复制镜像,不需要本机 Docker daemon,也不一定要把镜像完整拉进本地镜像仓库。

最常用的命令就是:

skopeo copy --all \
  docker://source.example.com/prod/public:tag \
  docker://target.example.com/public/service:tag

8.1 skopeo 适合解决什么问题

在 DevOps 镜像同步场景里,它主要解决四个问题:

  1. 不依赖 Docker daemon
    Worker 容器里不需要挂 /var/run/docker.sock,也不需要给 Docker daemon 做额外权限。

  2. 支持 registry 到 registry 复制
    源和目标都是 docker://...,镜像 layer 从源 registry 读出来,再写入目标 registry。

  3. 支持多架构镜像
    --all 后,会尽量复制 manifest list 和多个架构的镜像内容,适合基础镜像、运行时镜像这种多架构场景。

  4. 适合做自动化任务
    命令输入输出清楚,失败码明确,适合被 Worker 包装成可重试、可记录日志、可审计的后台任务。

8.2 基本原理

可以把 skopeo copy 理解成一个“镜像搬运工”:

1. 登录源 registry
2. 读取源镜像 manifest
3. 根据 manifest 找到 layer blob
4. 登录目标 registry
5. 上传目标仓库缺失的 layer
6. 写入目标 manifest / manifest list
7. 再 inspect 目标镜像,确认 Digest

它不会帮你决定业务规则,比如“目标 Tag 存在能不能覆盖”“目标 namespace 要不要创建”“失败后通知谁”。这些应该由 DevOps 后端和 Worker 控制。

所以职责边界是:

组件 负责什么
DevOps 后端 校验字段、覆盖策略、目标地址规划、创建执行记录
Worker 拿登录凭证、调用 skopeo copy、记录日志、更新状态
skopeo 真正复制 manifest 和 layer
Registry 存储镜像内容并返回 Digest

8.3 安装方式

如果是在普通 Linux 机器上跑 Worker,可以直接安装系统包。

Debian / Ubuntu:

sudo apt-get update
sudo apt-get install -y skopeo
skopeo --version

CentOS / RHEL / Rocky Linux:

sudo yum install -y skopeo
skopeo --version

如果系统包版本太旧,可以用容器镜像跑:

docker run --rm quay.io/skopeo/stable:latest skopeo --version

生产环境建议固定版本,不要直接用 latest

quay.io/skopeo/stable:v1.15.2

版本号按你们的基础镜像和安全扫描策略固定即可。

8.4 Worker 部署方式

这里不建议让 Web 进程直接执行同步。更稳妥的方式是单独部署一台或一组 Linux Worker,让它们专门处理镜像同步任务。

推荐部署形态:

DevOps Web
  -> 写入 ImageSyncExecution
  -> 独立 Linux Worker 轮询任务
  -> Worker 调用 skopeo copy
  -> Worker 更新执行状态和日志

Worker 机器需要满足:

  • 能访问 DevOps 后端数据库或任务队列。
  • 能访问源 registry 和目标 registry 的私网地址。
  • 已安装 skopeo
  • 已配置云厂商临时登录凭证获取方式。
  • 有独立日志目录和进程守护。

一个 systemd 服务示例:

[Unit]
Description=DevOps Image Sync Worker
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=devops
WorkingDirectory=/opt/devops_backend
Environment=IMAGE_SYNC_REQUIRE_PRIVATE_ROUTE=true
Environment=IMAGE_SYNC_WORKER_ID=%H
ExecStart=/opt/devops_backend/.venv/bin/python manage.py image_sync_worker
Restart=always
RestartSec=5
StandardOutput=append:/var/log/devops/image-sync-worker.log
StandardError=append:/var/log/devops/image-sync-worker.log

[Install]
WantedBy=multi-user.target

启动:

sudo systemctl daemon-reload
sudo systemctl enable --now devops-image-sync-worker
sudo systemctl status devops-image-sync-worker

如果你们倾向把 Worker 打成容器镜像,也可以把它当成普通进程容器运行。镜像可以这样构建:

FROM python:3.11-slim

RUN apt-get update \
    && apt-get install -y --no-install-recommends skopeo ca-certificates \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY . /app

CMD ["python", "manage.py", "image_sync_worker"]

用 Docker 运行时可以这样:

docker run -d --name image-sync-worker \
  --restart always \
  --env-file /etc/devops/image-sync-worker.env \
  -v /var/log/devops:/var/log/devops \
  example.com/devops/image-sync-worker:v1

这里有几个生产建议:

  • Worker 和 Web 分开部署,避免镜像同步大任务影响用户页面。
  • Worker 所在机器必须能解析并访问 registry 私网地址。
  • 不要挂 Docker socket。
  • 云厂商 AK/SK 不要写进镜像,用环境变量、配置中心或已有凭证网关注入。
  • skopeo copy 的 stdout/stderr 要落执行记录,失败时方便排查。
  • 大镜像同步要设置任务超时和重试次数,避免 Worker 卡死。

8.5 登录凭证怎么处理

生产实现不建议用 skopeo login 作为主路径,也不建议把密码放到命令行参数里。更稳妥的方式是:Worker 通过云厂商 OpenAPI 获取短期登录凭证,写入临时 authfile,再把 authfile 传给 skopeo

当前链路可以拆成四步:

  1. Worker 根据源/目标 registry 配置调用云厂商 API。
    • 阿里云 ACR:获取临时 AuthorizationToken。
    • 腾讯云 TCR:创建临时 InstanceToken。
  2. Worker 把源和目标的临时用户名/密码分别写入 authfile。
  3. skopeo inspectskopeo copy 使用 authfile 认证。
  4. 任务结束后临时目录自动删除。

命令形态是这样:

skopeo inspect \
  --authfile /tmp/image-sync-auth/source-auth.json \
  docker://source.example.com/prod/public:tag

skopeo copy --all \
  --retry-times 3 \
  --src-authfile /tmp/image-sync-auth/source-auth.json \
  --dest-authfile /tmp/image-sync-auth/target-auth.json \
  docker://source.example.com/prod/public:tag \
  docker://target.example.com/public/service:tag

skopeo inspect \
  --authfile /tmp/image-sync-auth/target-auth.json \
  docker://target.example.com/public/service:tag

为什么源和目标要分开两个 authfile?因为跨云同步时,源 ACR 和目标 TCR 的账号体系不同,分开写更清楚,也避免把不相关凭证混在一个文件里。

authfile 内容本质是 Docker config 的 auths 结构,示意如下:

{
  "auths": {
    "source.example.com": {
      "auth": "base64(username:password)"
    }
  }
}

生产注意点:

  • authfile 必须放临时目录,用完删除。
  • 文件权限建议设为 0600
  • 日志里不要打印 authfile 内容。
  • Worker 日志要对用户名、token、password 做脱敏。
  • 不要把云厂商 AK/SK 写进镜像;通过环境变量、配置中心或凭证网关获取。
  • 临时 token 过期后重新获取,不要长期缓存。

9. 覆盖保护:默认不覆盖

“是否允许覆盖”默认应该是“否”。

原因很简单:目标仓库同名 Tag 可能已经被业务使用。覆盖相同 namespace、repository、tag 后,后续拉镜像的人看到的还是同一个 Tag,但 Digest 已经变了。

这会带来两个风险:

  1. 回滚和审计困难:同一个 Tag 前后指向不同内容。
  2. 运行环境不一致:不同节点缓存不同 Digest 时,表现可能不一致。

因此审批执行前需要查目标 Tag:

目标 Tag 已存在 + 不允许覆盖 -> 拒绝执行
目标 Tag 已存在 + 允许覆盖 -> 继续执行
目标 Tag 不存在 -> 继续执行

10. 内网同步:控制面 API 和镜像层流量要分开看

镜像同步里容易混淆两条链路。

第一条是控制面 API:

Worker -> 云厂商 OpenAPI

它用于拿临时 token、查仓库、查 Tag。这个链路流量小。

第二条是镜像层传输:

Worker -> 源 Registry 拉 layer
Worker -> 目标 Registry 推 layer

真正的大流量在第二条。判断是不是内网同步,不能只看 OpenAPI endpoint,要看 skopeo copy 使用的 registry host。

后端配置里建议同时保存:

host         实际同步用的地址,通常选私网
private_host 私网地址
public_host  公网地址,用于展示和通知
private_ips  私网解析结果

执行时使用 host,通知里展示公网和私网两个地址:

源镜像地址:(公网/私网)
source-public.example.com/prod/public:tag
source-vpc.example.com/prod/public:tag

目标镜像地址:(公网/私网)
target-public.example.com/public/service:tag
target-vpc.example.com/public/service:tag

11. 完成通知:不要只发 Digest

只发:

镜像同步完成,目标 Digest:sha256:...

对使用者不够友好。更好的通知应该包含:

  • 源镜像公网/私网完整地址。
  • 目标镜像公网/私网完整地址。
  • 同步结果。
  • 目标 Digest。

这样研发可以直接复制目标地址使用,运维也能确认数据路径。

12. 这套方案的边界

这不是一个通用镜像仓库管理平台,只解决“工单驱动的镜像同步”。

已覆盖:

  • 仓库、命名空间、镜像、Tag 远程下拉。
  • 新建工单默认值。
  • 源 Tag 默认取最新。
  • 目标地址统一规划。
  • 覆盖保护。
  • 审批后执行。
  • Digest 校验。
  • 完成通知展示公网/私网地址。

没有覆盖:

  • 镜像安全扫描。
  • 生命周期清理。
  • 跨账号复杂权限审批。
  • 多目标批量同步。
  • Registry 高可用和限流治理。

这些应该独立建能力,不要塞进一个工单表单里。

13. 快速参考

前端默认值规则

1. 先设置源仓库和目标仓库
2. 再设置源命名空间
3. await nextTick()
4. 再设置源镜像
5. await nextTick()
6. 请求源 Tag 列表,按 updated_at 取最新

远程下拉返回结构

{
  "value": "tag-name",
  "label": "tag-name",
  "metadata": {
    "updated_at": "2026-07-30T10:00:00+08:00",
    "digest": "sha256:..."
  }
}

执行层命令

skopeo copy --all docker://源镜像 docker://目标镜像

易错点

  • 不要把完整镜像地址作为唯一输入,后端很难校验。
  • 不要在依赖字段还没稳定时填下级默认值。
  • 不要默认用 latest,应该用 Tag 的更新时间。
  • 不要只看云厂商 OpenAPI endpoint 判断内网,镜像 layer 走的是 registry host。
  • 不要默认允许覆盖同名 Tag。
  • 不要只通知 Digest,要把源/目标完整地址一起发出来。
posted @ 2026-07-30 15:54  Hello_worlds  阅读(11)  评论(0)    收藏  举报