推镜像到私有仓库
仓库地址从哪来(不在这个 skill 里)
仓库域名属内部信息,没有硬编码在这里。脚本按顺序找第一个存在的配置文件:
1) --config <path> 或 $XGENT_REGISTRY_CONFIG
2) ./.xgent-registry.env ← 项目内,要加进 .gitignore
3) ${XDG_CONFIG_HOME:-~/.config}/xgent/registry.env
4) ~/.xgent-registry.env
5) <skill 目录>/registry.env ← 同目录 registry.env.example 是模板
文件是 KEY=value,必填 REGISTRY(域名,不带协议)和 PROJECT(Harbor 项目名)。
同名环境变量优先,所以 CI 里直接注入 REGISTRY / PROJECT 即可,不必落盘。
找不到配置时脚本会停下来并告诉用户怎么建 —— 这时不要猜域名,也不要从别处翻,
让用户去问运维要,或指给他们 registry.env.example。
| 完整引用 | <REGISTRY>/<PROJECT>/<app>:<tag> |
| repository 名 | 就是 App 名,问运维要,别自创 |
| 推送账号 | robot$<PROJECT>+<app>,密钥在 CI secret 里 |
推的人只推一个地方:配置里的那个 <REGISTRY>。写入口只有这一个,
别自己另外指一个地址,也不要为了"保险"往第二个地方推一遍。
⚠️ 拉取方看到的 host 不一定和你推的这个一样,但路径部分完全相同
(<PROJECT>/<app>:<tag>)。分发是运维侧的事,对推送方没有任何影响:
一次 push、一个 tag 就够,你不需要知道对方从哪拉。
⚠️ 推完到部署侧能拉到之间可能有一段延迟(通常几十秒,大镜像更久)。
这段时间里对方拉到的是 manifest unknown —— 是"还没到",不是"慢"。
⇒ 别 push 完立刻催部署;更不要因为立刻拉不到就重推、或者换个 tag 再推一次,
那只会在仓库里留下一堆无人使用的 tag,还会挤占保留策略的名额(见 ④)。
工作流
1. 先把三件事问清楚(不要猜)
- 哪个 App → 决定 repository 名,写错会报
denied - 什么 tag → 规范是
v<MAJOR>.<MINOR>.<PATCH>-<7位 git sha>(例v1.4.0-1a2b3c4,详见 ③)。 sha 那半截是机械的,git rev-parse --short=7 HEAD就行; 版本号必须问用户 —— 不要从 git tag、package.json、VERSION里猜,也不要自作主张 给 patch 位 +1。版本号进的是他们的发布历史,由他们决定;你猜错了没人会当场发现。 用户说不清就直接问"这次发 v 几"。tag 不可变,推错了只能换一个新的 - 凭证从哪来 →
ROBOT_USER/ROBOT_SECRET环境变量,或 CI secret。不要让用户把密钥贴进对话;已经贴了就提醒他们事后轮换
2. 用脚本推(首选)
脚本就在这个 skill 目录下的 scripts/push-image.sh(用绝对路径调用,别假设当前工作目录):
ROBOT_USER='robot$<project>+<app>' ROBOT_SECRET="$SECRET" \
<skill 目录>/scripts/push-image.sh <app> <tag>
地址与项目名由上面的配置提供,命令行里不用写。
脚本按顺序做:链路预检 → tag 冲突检查 → 保留策略预警 → login → build(锁 amd64)→ 架构核对 → push → 验证 → logout。 任何一项不过就停在那里,不会把时间浪费在注定失败的 push 上。
常用参数:--config <path> · --context <dir> · --dockerfile <path> · --no-build(镜像已在本地)· --dry-run · --skip-link-check
(先跑 --dry-run:只做只读检查,不 login/build/push。)
这个 skill 是自包含的:SKILL.md + scripts/push-image.sh + registry.env.example,
除了 docker 和 curl 不依赖任何外部脚本或仓库。整目录复制到别处,再补一份本地配置即可使用。
⚠️ 填好的 registry.env / .xgent-registry.env 不要提交(skill 目录自带 .gitignore 挡了这两个名字)。
3. 没有脚本时的等价手工步骤
先从配置里取到 REGISTRY / PROJECT(别把域名写死进 CI 脚本):
IMAGE="${REGISTRY}/${PROJECT}/${APP}:${TAG}"
# 注意单引号 —— robot 名里的 $ 会被 shell 展开成空
echo "$ROBOT_SECRET" | docker login "$REGISTRY" -u 'robot$<project>+<app>' --password-stdin
docker build --platform linux/amd64 -t "$IMAGE" .
docker image inspect "$IMAGE" --format '{{.Os}}/{{.Architecture}}' # 必须 linux/amd64
docker push "$IMAGE"
docker logout "$REGISTRY"
用 --password-stdin,不要用 -p —— 后者会把密钥留在 shell history 和 CI 日志里。
4. 报告结果
推完告诉用户:完整镜像引用、digest、当前 repo 里有几个 tag(接近 10 个要提醒)。 不要替用户去改部署侧的「镜像仓库地址」或碰编排层 —— 那是部署/运维侧的事,见下方边界。
四条硬约束
这四条是实测踩出来的,前两条的失败表现都不像"推失败",所以值得在动手前就挡住。
① CI 必须与仓库同地域,不能跨境推
跨境链路上的推送实测 49 KB/s、RTT 440ms、丢包 35% —— 一个 2.93GB 的镜像要 17 小时。
(2026-08-11 在生产机上实测。⚠️ 这是那一天、那条路的数字:仓库搬迁或链路调整后要重测,
否则它会从论据变成过时的断言。)
表现是 docker push 卡在 Retrying in N seconds 直到超时,日志里看不出原因,重试也没用。
这条没有绕过办法,只能换 runner 位置。
脚本量 TLS 握手耗时做判断(默认 >300ms 视为跨境)。用 TLS 而不是 TCP 握手是因为
挂代理时 TCP 只连到本地代理、量不出真实距离,而 TLS 握手是与仓库本体完成的,穿过隧道也照样反映距离。
它是代理指标不是真吞吐,但把 440ms 和同地域的几十毫秒分开绰绰有余。
用户坚持要试可以 --skip-link-check,但要先把上面的数字讲清楚。
拉取方向是好的(同一条跨境链路上 7.2 MB/s),所以"我能 pull 说明网络没问题"不成立 —— 推拉不对称。
② 镜像必须是 linux/amd64
生产机是 x86_64。Apple Silicon 上直接 docker build 出来是 arm64,
推得上去、拉得下来,容器起不来(exec format error)—— 看着像仓库坏了,其实不是。
所以构建要显式 --platform linux/amd64,推之前再 inspect 核对一次实际架构。
③ tag 不可变,且要符合规范
部署侧按镜像引用的变化触发重建,覆盖同一个 tag 不会触发重建 —— 线上还跑着旧的那份,
而用户以为发上去了;出问题时也没有可回退的目标。所以脚本发现 tag 已存在会直接拒绝。
真要覆盖得 --force-tag,并且要让用户明白重建不会自动发生。
规范:v<MAJOR>.<MINOR>.<PATCH>-<7位 git sha>,例 v1.4.0-1a2b3c4。
正式发版可以省掉 sha 那半截(v1.4.0)。
一个 tag 要同时扛三件事,这个格式是能同时满足的最简形式:
| 要求 | 谁来满足 | 不满足会怎样 |
|---|---|---|
| 唯一 —— 每次构建都是新 tag | sha | 覆盖旧 tag ⇒ 不触发重建(见上) |
| 可追溯 —— 能倒查回 commit | sha | 线上出事,查不到这版是哪份代码 |
| 可读可排序 —— 一眼看出谁新谁旧 | 版本号 | 保留策略删最老的、回滚要指名上一个,光看 sha 判断不了顺序 |
版本号怎么排是推送方自己的事 —— 这个 skill 只要求 tag 的形状(唯一、可追溯、可排序),不规定什么时候进 minor、什么时候进 patch。别替他们决定。
不带 tag 参数跑脚本,它会把当前 HEAD 的 sha 打出来让你拼——版本号那半截它不猜。
禁止的 tag(脚本硬拒):latest prod stable dev test main master rc 等。
这些是可变指针语义 —— 一旦有人推了 latest,「引用没变但内容变了」就成立,
整条不可变的前提当场失效。
例外:不经部署侧的项目可以开 latest(2026-08-27)
上面那条推理链的每一环都挂在「部署侧按镜像引用触发重建」上。给人手工 docker pull
的镜像(本地调试环境那一类,通常放在单独的项目里)没有部署侧、没有重建、没有回滚目标 ——
链断了,禁令的前提也就不在了。反过来还缺了点东西:新人第一次拉的时候,
他还没有渠道知道有哪些 tag,latest 补的正是这个洞。
配置里逐个列名打开(不是总开关,打错的 prod / main 仍然被拦):
MUTABLE_TAGS=latest
⚠️ 开了也不许 build 完直接推成 latest,脚本会拦。可变 tag 只能"移":
push-image.sh <app> v1.2.3-1a2b3c4 # ① 先推不可变的那一份(真镜像)
push-image.sh <app> latest --point-at v1.2.3-1a2b3c4 # ② 再把指针移过去
第 ② 步是 OCI 分发规范里的重打 tag:把 v1.2.3-1a2b3c4 那一份的 manifest 原始字节取下来,
一个字节不改地 PUT 到 latest 上。digest 由字节算出来 ⇒ 必然与源相同 ⇒ 落在
同一个 artifact 上(不新增 artifact、不占保留策略名额、不动任何 blob,跨境也是一瞬)。
纯 registry API,不需要 docker,也不 login。移完会再拉一次服务端的 digest 核对。
⚠️ 别拿这两条顶替:
docker pull + docker tag + docker push—— 要把镜像字节拉下来再传上去(跨境 49KB/s), 而且pull只取本机架构那一份,多架构 manifest list 会被压扁成单架构。docker buildx imagetools create—— 源是单架构 manifest 时它会包一层 index, digest 变了、Harbor 里多出一个 artifact。本脚本推的正是单架构镜像,会踩上。
为什么不许直接推:仓库的 GC 开着 delete_untagged。latest 一移走,上一个 digest
如果没有别的 tag 就成了 untagged,下一次 GC 连底层 blob 一起回收 ⇒ 这个 repository
永远只剩一份镜像,既没有历史也没有可回退的目标。这个后果不可逆,所以那条 die
没有留 --force 之类的绕过口子。
⚠️ 还要提醒拉取方:latest 变了他们不会知道。本地已有同名 latest 时
docker run / compose 默认不回仓库查 —— 让他们开工前先 docker pull,
要钉住某一版就用带版本号的 tag。
脚本还会核对 git 状态,两条都是踩过的坑:
- 工作区不干净直接拒绝(
--allow-dirty放行)。带着未提交的改动构建,tag 指向的 commit 复现不出这个镜像 —— 出事时你会对着一个查不到的版本。 - tag 里的 sha 与 HEAD 不符时告警。推错 commit 比推错 tag 更难发现:tag 看着挺对。
团队统一格式之后,把 TAG_PATTERN(正则)写进配置就从"提醒"升级为"强制"。
不配的时候只提醒不拦,是因为各团队可能还在用历史格式,不该由这个脚本单方面掐断发布。
④ 每个 repository 只保留最近推送的 10 个 tag
保留策略每天 02:00 跑,GC 每周日 03:00 回收存储。判据是"最近 push 的 10 个",
不是"最近在用的 10 个" —— 连着推 11 个测试 tag,会把生产正在跑的那个挤掉,
当时毫无征兆,等到 Pod 重调度、扩容或新节点加入那天才 ImagePullBackOff。
⇒ 调试镜像不要往生产 repo 推。tag 数接近 10 时提醒用户确认生产在跑的 tag 不在最老的那几个里。
需要更大的保留数要找运维改 RETAIN_LATEST_K,不要自己想办法绕。这个 registry 不是归档仓库。
报错对照
| 症状 | 是什么 | 怎么办 |
|---|---|---|
push 卡住 / 一直 Retrying in N seconds / 几十 KB/s |
runner 与仓库跨境(①) | 换同地域 runner。不是仓库故障,重试无效 |
unauthorized: unauthorized to access repository |
没 login / 密钥错 / robot 被吊销或轮换过 | 重新 login;仍失败找运维查 robot 状态 |
denied: requested access to the resource is denied |
路径写错 —— 项目名必须是配置里的 PROJECT,repository 必须是 App 名 |
核对 <REGISTRY>/<PROJECT>/<app>:<tag> |
x509: certificate signed by unknown authority |
本机 CA 库或系统时间 | 服务端是正式证书(含中间证书),正常机器不需要 --insecure-registry;配了是掩盖问题 |
拉得到但 exec format error |
架构不对(②) | --platform linux/amd64 重建重推 |
| 之前推过的老 tag 不见了 | 被保留策略挤掉(④) | 重推。长期留存另想办法 |
| 推到一半断了 | 重推即可,已推上去的层会跳过 | ⚠️ 别反复中断大镜像:每次中断在对象存储留未完成分片(运维侧配了 7 天清理) |
密钥怎么处理
- 只从环境变量/CI secret 取,不写进文件、不打印、不进 git
- 用完
docker logout—— 别把凭证留在共享 runner 的~/.docker/config.json里 - ⚠️ Harbor 的 robot 权限粒度是项目级(已知偏差,做不到只到一个 repository): 密钥泄露 = 项目下所有 App 的 repository 都能被推/拉。所以不要放公共 runner、不要写进共享脚本。 能推别人的 repository 不等于可以推 —— 只推自己那一个
- 轮换/吊销找运维;轮换会让旧密钥立刻失效,CI 换上新密钥前会一直红,要约时间
边界:这些不归推镜像的人管
- 部署侧的「镜像仓库地址」设置、切换时机与停机窗口 —— 部署侧
- 部署侧的拉取凭证 —— 运维侧。你手里那份是推的,两回事
- registry 的可用性、证书、容量、GC —— 运维侧
- 运维侧原有的镜像分发方式(如果之前有):切到 registry 之后不是降级路径 —— 仓库地址一非空,镜像名就成了完全限定名,旧的那条分支被永久跳过。别拿它当兜底
已经在生产跑着的 App(现在从别处拉镜像,只是要把仓库地址切到这里),第一次接入有一步容易漏: 除了推新版本,还要把当前在跑的那个 tag 原样推一份进来, 否则部署侧把镜像源切过来的那一刻拉不到同名 tag,切换即故障。
还没上过生产的新 App 没有这一步 —— 不存在"在跑的 tag"要补,推你自己的版本即可。