返回博客

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... 排在最前,被当成"最旧"的删掉了。

坑:hex SHA 字典序 ≠ 时间序

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
正解:时间前缀排序 + 强制排除当前 tag

docker images --format '{{.CreatedAt}}|...' 拼时间前缀,sort -r 按 ISO 字符串倒序,再用 grep -v -F "|${CURRENT_TAG}" 硬排除当前镜像。两道防御缺一不可——前者保证选对「旧」镜像,后者在前者失效时兜底。

一句话外卖

任何「保留最新 N 个,删其余」的清理脚本,只要 tag/key 是 hex SHA 或随机 ID,必须用时间字段排序,不能用字典序,并且必须显式排除当前正在使用的那一个