我们的 CI/CD 流水线每天要构建几十次 Docker 镜像。因为每次构建都是用 Git Commit SHA 作为 tag,而且还会把最新的构建打上 latest 标签。这就导致之前的旧镜像被打掉标签(变成 <none>:<none>,在 Registry 里就是 untagged/null tag),成了无主孤魂。Docker Registry 和 Harbor 的存储后端没有自动清理这些未打标镜像的机制。几个月下来,这几千个历史镜像把我们的私有 OSS 存储撑爆了,磁盘空间告警天天叫。手动去控制台删简直能让人点到手软,所以干脆写了个 Node 脚本,调 Docker Registry V2 API 自动清理这些无用的未打标镜像。
核心痛点:为什么磁盘空间没减少?
首先得科普一个 Docker Registry V2 API 的“深坑”:直接删除 Tag 是不行的,必须删除 Manifest 的 Digest。 而且,即便你调 API 删除了 Manifest,底层的物理文件(Blob)依然躺在硬盘上。你必须在 Registry 容器内部手动跑一次垃圾回收(Garbage Collection),存储空间才会被真正释放。
Node.js 自动化清理脚本实现
为了自动化执行这个操作,我编写了下面这个 Node.js 脚本。它会做三件事:
- 获取仓库下的所有镜像列表;
- 获取每个镜像的所有 Tag,排查出没有 Tag 关联的 Manifest 或者过期的历史构建版本;
- 获取这些 Manifest 的 Digest 并执行 DELETE 请求。
// cleanup-registry.js
import fetch from 'node-fetch';
const REGISTRY_URL = 'http://registry.internal.company.com:5000';
const REPO_NAME = 'my-project/app';
const KEEP_TAG_REGEXP = /^(latest|v\d+\.\d+\.\d+|prod-.*)$/; // 强保留规则
// 辅助函数:生成 API 基础 Headers
const getHeaders = (extra = {}) => ({
'Accept': 'application/vnd.docker.distribution.manifest.v2+json',
...extra
});
async function runCleanup() {
console.log(`[INFO] 开始扫描镜像仓库: ${REPO_NAME}`);
// 1. 获取所有 Tag 列表
const tagsRes = await fetch(`${REGISTRY_URL}/v2/${REPO_NAME}/tags/list`, {
headers: getHeaders()
});
if (!tagsRes.ok) {
throw new Error(`无法获取 Tag 列表: ${tagsRes.statusText}`);
}
const { tags } = await tagsRes.json();
if (!tags || tags.length === 0) {
console.log('[INFO] 没有找到任何 Tag');
return;
}
console.log(`[INFO] 当前库中共有 ${tags.length} 个 Tag`);
// 2. 筛选出不符合保留规则的旧 Tag 进行清理
for (const tag of tags) {
if (KEEP_TAG_REGEXP.test(tag)) {
console.log(`[KEEP] 命中保留规则,跳过: ${tag}`);
continue;
}
console.log(`[DELETE] 准备清理 Tag: ${tag}`);
// 3. 获取 Manifest Digest (注意:必须使用 HEAD 请求或者 GET 请求,从 Response Headers 里拿 Etag/Docker-Content-Digest)
const manifestRes = await fetch(`${REGISTRY_URL}/v2/${REPO_NAME}/manifests/${tag}`, {
method: 'GET',
headers: getHeaders()
});
if (!manifestRes.ok) {
console.log(`[WARN] 无法获取 Manifest 详情: ${tag}, 状态码: ${manifestRes.status}`);
continue;
}
const digest = manifestRes.headers.get('docker-content-digest');
if (!digest) {
console.log(`[WARN] 未能在 Headers 中找到 docker-content-digest: ${tag}`);
continue;
}
// 4. 执行删除
const deleteRes = await fetch(`${REGISTRY_URL}/v2/${REPO_NAME}/manifests/${digest}`, {
method: 'DELETE',
headers: getHeaders()
});
if (deleteRes.status === 202) {
console.log(`[SUCCESS] 成功标记删除 Manifest Digest: ${digest} (${tag})`);
} else {
console.log(`[ERROR] 删除失败: ${tag}, 状态码: ${deleteRes.status}`);
}
}
}
runCleanup().catch(err => {
console.error('[FATAL] 执行清理失败:', err);
process.exit(1);
});
垃圾回收(Garbage Collection)释放物理空间
做完 API 级别的删除后,Registry 的索引数据里已经没有这些 Manifest 了。但物理磁盘空间完全没有变小!这是因为 Registry 采用了两阶段删除(软删除)。
你需要连上 Docker Registry 所在的宿主机,执行以下命令来运行 Registry 自带的 GC 垃圾回收器:
# 进入 Registry 容器内部执行垃圾回收命令
docker exec -it docker-registry bin/registry garbage-collect /etc/docker/registry/config.yml
如果你想清理得更彻底,连同没有被引用的 blobs 也一并删除,可以加上 -m 参数:
docker exec -it docker-registry bin/registry garbage-collect -m /etc/docker/registry/config.yml
[!WARNING] 警告:在运行
garbage-collect时,必须将 Registry 切换为只读模式或者暂停写入。如果在 GC 运行期间有 CI/CD 流水线正在推送(Push)镜像,可能会导致新推送的镜像 Layer 被误删,引发镜像损坏的灾难!
一般我们会把 Node 清理脚本和 GC 命令写进 Jenkins/CronJob 任务,定在每周日的凌晨 3 点低峰期执行,并在这期间通过 Nginx 临时阻断 Push 请求。
你们的私有镜像仓库有没有被撑爆过?大家是用 Harbor 自带的清理策略,还是像我一样自己写 API 脚本精细化控制?