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
这个方式开发快,但问题也明显:
- 用户容易填错 namespace、repository、tag。
- 目标仓库策略不容易统一,例如全部写入
public/service:<tag>。 - 无法在提交前做覆盖检测。
- 很难展示源和目标的公网/私网地址。
- 后端拿到的是字符串,不利于审批、审计和后续扩展。
所以最终采用结构化字段:
{
"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 未必存在,也未必表示最新。
更稳妥的方式是:
- 等源仓库、命名空间、镜像都确定。
- 请求当前 repository 的 Tag 列表。
- 每个 Tag 返回
metadata.updated_at。 - 前端按时间倒序取最新。
- 如果用户已经选了 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 镜像同步场景里,它主要解决四个问题:
-
不依赖 Docker daemon
Worker 容器里不需要挂/var/run/docker.sock,也不需要给 Docker daemon 做额外权限。 -
支持 registry 到 registry 复制
源和目标都是docker://...,镜像 layer 从源 registry 读出来,再写入目标 registry。 -
支持多架构镜像
加--all后,会尽量复制 manifest list 和多个架构的镜像内容,适合基础镜像、运行时镜像这种多架构场景。 -
适合做自动化任务
命令输入输出清楚,失败码明确,适合被 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。
当前链路可以拆成四步:
- Worker 根据源/目标 registry 配置调用云厂商 API。
- 阿里云 ACR:获取临时 AuthorizationToken。
- 腾讯云 TCR:创建临时 InstanceToken。
- Worker 把源和目标的临时用户名/密码分别写入 authfile。
skopeo inspect和skopeo copy使用 authfile 认证。- 任务结束后临时目录自动删除。
命令形态是这样:
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 已经变了。
这会带来两个风险:
- 回滚和审计困难:同一个 Tag 前后指向不同内容。
- 运行环境不一致:不同节点缓存不同 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,要把源/目标完整地址一起发出来。

浙公网安备 33010602011771号