Docker 清理脚本用字典序排 SHA,把刚 build 的镜像删了
发布脚本跑到一半,build 明明成功了,下一步 docker run 直接报 Unable to find image ... locally,然后 docker daemon 跑去公共镜像加速器拉,私有镜像当然不在那里,返回一堆 403 Forbidden。日志看着像网络问题,实际上是 build 刚完成就被自己的清理步骤删掉了。
现象
发布流水线输出(脱敏后):
#22 naming to docker.io/library/myapp:ci-996e8d663a done
#22 DONE 2.2s
[remote] cleanup old sha-pinned tags (keep last 5)
Untagged: myapp:ci-996e8d663a ← 当前刚 build 的 SHA!
[remote] docker compose up -d
Unable to find image 'myapp:ci-996e8d663a' locally
docker: ... docker.m.daocloud.io ... 403 Forbidden
ssh exited with 125
daocloud 403 是表象,不是根因。docker daemon 配了国内镜像加速器后,本地找不到 image 会自动 fallback 去 mirror 拉。私有镜像在公共 mirror 上当然不存在,必然 403/404。调试时看到 mirror 403,第一反应应该是「本地为什么没有这个 image」,而不是「mirror 为什么不给我」。
根因
清理脚本长这样:
# 反模式:字典序 sort,选"旧"tag 删除
OLD_TAGS=$(docker images --format '{{.Repository}}:{{.Tag}}' \
| grep -E '^myapp:ci-[0-9a-f]{4,}$' \
| sort -u \ # 字典序排序
| head -n -5) # 删掉字典序"小"的,留字典序"大"的
echo "$OLD_TAGS" | xargs docker rmi -f问题出在 sort -u 这一步。git short SHA 是 hex 字符串,随机分布,字典序与构建时间顺序完全无关:
5e247d9db5 # 最新构建
996e8d663a # 次新
9b0781af3f # 三周前
字典序从小到大:5e... < 99... < 9b...。head -n -5 删掉字典序"小"的——当前最新构建 5e... 排在最前,被当成"最旧"的删掉了。
sort 对 git short SHA 的排序结果与构建时间顺序没有任何关系。最新 build 的 SHA 完全可能字典序最小,被 head -n -5(保留字典序大的)直接干掉。这个坑在 git tag rotation、S3 artifact prune、MinIO 对象清理中同样存在——只要 key 是 hex ID 而非单调时间戳,用字典序截断就会爆雷。
正解
两条防御叠加:
防御 1:用 CreatedAt 时间前缀排序
docker --format 可以输出 {{.CreatedAt}},ISO 时间字符串字典序等于时间倒序,sort -r 即可:
docker images --format '{{.CreatedAt}}|{{.Repository}}:{{.Tag}}' \
| grep -E '\|myapp:ci-[0-9a-f]{4,}$' \
| sort -r \ # CreatedAt 倒序 = 最新在最前
| tail -n +5 \ # 跳过前 4 行(保留最新 4 个 + 防御 2 排除的当前 = 共 5 个)
| cut -d'|' -f2 # 取 tag注意 tail -n +N 语义:+5 表示从第 5 行开始输出,前 4 行保留。别跟 head -n -N(从末尾删 N 行)混淆。
防御 2:显式排除当前 build 的 tag(最重要的一道保险)
| grep -v -F "|${CURRENT_IMAGE_TAG}" # 永远不删正在用的哪怕防御 1 因 CreatedAt 格式变化或时区漂移失效,防御 2 兜底——当前 tag 绝不进删除列表。
完整修复:
CURRENT_TAG="myapp:ci-${GIT_SHA}"
OLD_TAGS=$(docker images --format '{{.CreatedAt}}|{{.Repository}}:{{.Tag}}' \
| grep -E '\|myapp:ci-[0-9a-f]{4,}$' \
| grep -v -F "|${CURRENT_TAG}" \ # 防御 2:排除当前
| sort -r \ # 防御 1:按时间倒序
| tail -n +${KEEP_LAST_TAGS} \
| cut -d'|' -f2 || true)
if [ -n "$OLD_TAGS" ]; then
echo "[cleanup] deleting old tags:"
echo "$OLD_TAGS" # 打印审计日志
echo "$OLD_TAGS" | xargs -r docker rmi -f || true
fi用 docker images --format '{{.CreatedAt}}|...' 拼时间前缀,sort -r 按 ISO 字符串倒序,再用 grep -v -F "|${CURRENT_TAG}" 硬排除当前镜像。两道防御缺一不可——前者保证选对「旧」镜像,后者在前者失效时兜底。
一句话外卖
任何「保留最新 N 个,删其余」的清理脚本,只要 tag/key 是 hex SHA 或随机 ID,必须用时间字段排序,不能用字典序,并且必须显式排除当前正在使用的那一个。