我们的 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 脚本。它会做三件事:

  1. 获取仓库下的所有镜像列表;
  2. 获取每个镜像的所有 Tag,排查出没有 Tag 关联的 Manifest 或者过期的历史构建版本;
  3. 获取这些 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 脚本精细化控制?