简介:registry.tar.gz 面向无法连接外网、需要在内网搭建私有镜像仓库的运维与开发人员,提供可离线导入的镜像包,缓解断网环境下镜像拉取困难的问题,便于在隔离网络中完成本地导入与分发。压缩包共18个文件,以7个JSON元数据、5个TAR镜像层、5个VERSION版本标识及1个repositories仓库索引为主,整体约25.15MB,覆盖镜像分层、版本记录与仓库描述等关键数据。已有436人学习,适合作为内网私有仓库部署与镜像归档的基础素材。读者可借助其中的镜像层与元数据,在断网或弱网环境中完成镜像准备、加载与校验,减少对在线源的依赖;也可结合版本标识与仓库索引核对镜像内容,为后续私有仓库启动、镜像推送和迁移提供可复用的离线数据,适合容器平台内网建设场景。
1. registry.tar.gz 是什么:一份能离线跑起来的 Docker 镜像仓库
内网机房、交付现场、边缘节点,这三个地方凑在一起,docker pull就从一条命令变成一件要提前排期的事。registry.tar.gz 就是为这种场景准备的落地包:它把 Docker Registry 的镜像 tar、config.yml、证书目录、认证文件和启动脚本打成一个压缩包,拷到目标机器上解包、load、起容器,半小时内就能拿到一个属于自己的镜像仓库。它解决的不是「能不能用 Docker」,而是「没有外网的时候,镜像怎么分发、怎么留档、怎么按版本回收」。适合做内网交付的运维、要给多台构建机统一镜像源的开发,以及被出口带宽卡过脖子的人。但它是裸的,TLS、认证、存储后端、垃圾回收都得自己接上,下面按这个顺序一层层拆开。
2. 解包与起服务:从 tar.gz 到 5000 端口的完整链路
2.1 拆包先看结构:镜像层、配置、脚本各是什么
拿到压缩包别急着docker load,先看里面有什么。这类离线包的结构大同小异,差异主要在镜像 tag 和默认配置路径上,而这两点恰恰是最容易让人卡住的地方。我一般的习惯是先解到一个固定目录,比如/opt/registry-offline,后面所有挂载路径都以它为基准,迁移时整个目录搬走就行。
# 建目录并解包,-C 指定目标,避免把文件撒在当前目录 mkdir -p /opt/registry-offline tar -zxvf registry.tar.gz -C /opt/registry-offline # 看清单:文件大小和类型决定了后续步骤 cd /opt/registry-offline ls -lh find . -maxdepth 2 -type f | sort解出来常见是这么几类文件,对照着看能少走弯路:
| 文件 / 目录 | 作用 | 落地时要注意什么 |
|---|---|---|
registry.tar | docker save出来的镜像归档 | 用docker load -i导入,tag 以实际导入结果为准 |
config.yml | Registry 主配置 | 路径、存储目录、监听地址都在这里改 |
start.sh/docker-compose.yml | 启动封装 | 里面的镜像 tag 和卷路径要和实际环境对齐 |
certs/ | 证书与私钥 | 没有的话自己签,文件名要和配置里写的一致 |
auth/htpasswd | 基础认证账号文件 | 常见是个空壳或示例账号,必须自己重生成 |
registry.tar是关键。它通常是用docker save打的单镜像归档,导入后本地会多出一个registry:2之类的 tag。这里有个容易忽略的点:离线包里的镜像 tag 可能被改过(比如打了内网前缀),而start.sh里写的是原 tag,直接跑脚本会提示找不到镜像。所以导入完先docker images看一眼,再决定是改脚本还是补 tag。
提示:解包后先
sha256sum registry.tar记一份,跨机器拷贝时用它确认文件没被截断。镜像 tar 传坏一半,docker load报的错往往和文件完整性毫无关系。
2.2 docker load 与 config.yml 最小可用配置
镜像导入本身没什么技术含量,麻烦的是导入之后跑不起来。绝大多数「容器起来就退出」的情况,原因不是镜像坏了,而是config.yml里的存储目录和挂载点对不上,或者配置里的路径在容器内根本不存在。所以顺序应该是:先导入、再看配置、最后起容器,一步都别跳。
# 导入镜像并确认 tag docker load -i /opt/registry-offline/registry.tar docker images --format "{{.Repository}}:{{.Tag}} {{.ID}} {{.Size}}" # 建数据目录,注意属主,容器内默认以 root 写 mkdir -p /data/registry chmod 755 /data/registry最小可用的config.yml大致长这样,可以先按这个改,跑通再往上加 TLS 和认证:
version: 0.1 log: level: info # 首次排错可以临时调成 debug formatter: text storage: filesystem: rootdirectory: /var/lib/registry delete: enabled: true # 不开这个,后面删 manifest 会直接 405 http: addr: 0.0.0.0:5000 # 只在内网监听时也可以写具体网卡地址 headers: X-Content-Type-Options: [nosniff]rootdirectory写的是容器内路径,不是宿主机路径,这一点每年都能坑到人。容器外通过-v /data/registry:/var/lib/registry把它映射出去,两边必须字面一致,否则数据会落在容器的可写层里,容器一删全没。delete.enabled建议一开始就打开,后面想清理旧版本时不用再停机改配置重启。
启动命令如下,参数逐个说一下:
docker run -d \ --name registry \ --restart=always \ -p 5000:5000 \ -v /data/registry:/var/lib/registry \ -v /opt/registry-offline/config.yml:/etc/docker/registry/config.yml:ro \ registry:2--restart=always保证宿主机重启后仓库自己回来;-p 5000:5000是默认端口,改成别的端口时config.yml里的addr和映射要同步改;配置用:ro只读挂载,避免容器里误改文件导致宿主机的副本和运行态不一致。启动后别急着 push,先看日志确认监听成功:
docker logs --tail 30 registry # 正常会看到类似 level=info msg="listening on [::]:5000" 的输出 curl -sS http://127.0.0.1:5000/v2/ -i | head -5 # 返回 200 且 body 为 {} 才算通2.3 首次 push/pull 验证与客户端配置
服务起来只是第一步,客户端能不能推上来才是分水岭。用 HTTP 跑 5000 端口时,Docker 客户端默认会拒绝,报server gave HTTP response to HTTPS client。这不是服务端的问题,是客户端的安全策略:它默认只信任 HTTPS 仓库。内网测试阶段可以在客户端加白名单,生产环境还是应该上证书。
# 客户端 /etc/docker/daemon.json { "insecure-registries": ["192.168.10.20:5000"], "max-concurrent-uploads": 5 }改完必须让 daemon 重新加载配置,systemctl reload docker一般够用;如果没生效就systemctl restart docker,但要清楚这会让没有配 live-restore 的容器一起重启,挑时间做。max-concurrent-uploads顺手调一下,推大镜像时能明显减少超时。
然后做一次完整往返验证,顺序是 tag、push、查 catalog、删本地、pull:
# 用一个小镜像验证链路,别拿几百 MB 的镜像试错 docker pull nginx:1.25 docker tag nginx:1.25 192.168.10.20:5000/library/nginx:1.25 docker push 192.168.10.20:5000/library/nginx:1.25 # 服务端确认:catalog 列出仓库,tags 列出标签 curl -s http://192.168.10.20:5000/v2/_catalog curl -s http://192.168.10.20:5000/v2/library/nginx/tags/list # 删本地再拉,确认数据真的在服务端 docker rmi 192.168.10.20:5000/library/nginx:1.25 docker pull 192.168.10.20:5000/library/nginx:1.25_catalog默认一次最多返回 100 条,仓库多了要翻页,响应头里的Link字段就是下一页地址,脚本里按它循环取,别一次性全量拉。另外注意仓库名大小写:Docker 要求仓库路径全小写,library/Nginx这种写法在 push 阶段就会被拒,报错信息还比较隐晦,排查时容易往网络上想。
3. 让它能上线:TLS、认证、存储后端三件套的配置取舍
3.1 自签证书与 HTTPS 监听
内网仓库要不要上 TLS,争论一直有。我的判断很简单:只要这个仓库不止你一个人用,或者跨了网段,就上。明文传输的镜像层和凭据在内网里同样是敞开的,而且客户端加insecure-registries这件事本身就是个长期负债,机器越多越难管。自签证书的代价只是分发一次 CA,收益是后面所有客户端配置都能回到标准姿势。
# 生成私钥与自签证书,SAN 里同时写 IP 和域名,两种访问方式都能过校验 cd /opt/registry-offline/certs openssl req -newkey rsa:4096 -nodes -sha256 \ -keyout domain.key -x509 -days 3650 -out domain.crt \ -subj "/C=CN/O=Internal/CN=registry.example.internal" \ -addext "subjectAltName=DNS:registry.example.internal,IP:192.168.10.20" # 校验证书内容,重点看 SAN 是否覆盖你实际访问用的地址 openssl x509 -in domain.crt -noout -text | grep -A1 "Subject Alternative Name"-addext里的 SAN 是整件事的核心。现代 Docker 客户端和 Go 的 TLS 栈都不再看 CN,只认 SAN,只写 CN 的证书会报x509: certificate relies on legacy Common Name field。另外证书有效期给 3650 天是内网习惯做法,到期前记得换,换完所有客户端都要更新 CA 文件。
配置侧改动很小,把http段补上host和tls即可:
http: addr: 0.0.0.0:5000 host: https://registry.example.internal:5000 tls: certificate: /certs/domain.crt key: /certs/domain.keyhost这一项容易被当成可选项忽略,但它决定了仓库对外宣称的地址,在反向代理后面尤其重要。如果前面挂了一层 Nginx,host要写外部访问地址,而不是容器内地址,否则客户端拿到的Location头会指向一个访问不到的地方,push 分块上传时表现成莫名其妙的中断。
客户端信任证书有两种做法:把domain.crt放到/etc/docker/certs.d/192.168.10.20:5000/ca.crt(目录名必须和访问地址完全一致,含端口),或者把 CA 装进系统信任库。前者只影响 Docker,粒度更细,我一般用这个,容器编排和构建机都能各管各的。
3.2 htpasswd 基础认证:只认 bcrypt 这件事
端口暴露在内网不等于谁都能推。最小成本的访问控制就是 htpasswd 基础认证,配置三行就能开。但它有个非常具体的坑:Registry 的认证实现只认 bcrypt 格式的密码哈希,用htpasswd -m(MD5)或-d(crypt)生成的文件,账号密码全对也会一直 401,日志里还看不出原因。
# 方式一:有 httpd 镜像时,-B 表示 bcrypt docker run --rm --entrypoint htpasswd httpd:2 \ -Bbn deploy 'ChangeMe_2024' > /opt/registry-offline/auth/htpasswd # 方式二:离线环境没有 httpd 镜像,用 python 的 bcrypt 生成 python3 -c "import bcrypt;print('deploy:'+bcrypt.hashpw(b'ChangeMe_2024', bcrypt.gensalt()).decode())" \ >> /opt/registry-offline/auth/htpasswd # 追加只读账号,读账号只给拉不给推(靠后面的权限策略,不在这里区分) cat /opt/registry-offline/auth/htpasswd生成的文件是用户名:哈希一行一个,注意别有多余空行和 Windows 换行符,后者会让整行解析失败。配置段长这样:
auth: htpasswd: realm: basic-realm # 客户端弹窗里显示的名字,随便起但要固定 path: /auth/htpasswd # 容器内路径,和挂载点对应启动时补一个-v /opt/registry-offline/auth:/auth:ro。改完账号文件不需要重启容器,Registry 每次请求都会重新读文件,这一点比想象中方便——加账号直接追加一行就行。
注意:基础认证是明文凭据加 TLS 传输的组合,凭据本身不做加密,所以 TLS 不是可选项而是前提。只开认证不开 TLS,等于把密码写在 HTTP 头里到处发。
如果要做更细的权限(比如某些仓库只读、某些账号只能推特定前缀),htpasswd 这套就不够了。常见做法是前面挂一个支持 token 认证的网关,把鉴权逻辑外置,Registry 只保留auth: token的配置。动手之前先想清楚规模:三五个人的内网仓库,htpasswd 完全够;要按项目隔离权限,早点上 token 方案,别等到账号文件里躺着几十行再重构。
3.3 存储后端选型:filesystem 与对象存储的边界
存储后端的选择直接决定了后面能不能扩容、能不能备份。filesystem是最省事的:所有 blob 落在本地目录,结构清晰,出问题能直接ls看。代价是绑定单机,容量靠扩盘,迁移靠拷目录。对于镜像总量在几百 GB 以内、构建机数量不多的场景,这就是最优解,没必要为了「看起来专业」去上对象存储。
判断标准我一般看三条:镜像总量是否会突破单盘容量、是否需要多个 Registry 实例共享数据、备份是否需要跨机房。三条里中了两条,就上对象存储。
# 对象存储后端(S3 兼容接口)配置样例 storage: s3: accesskey: registry-writer secretkey: "******" region: us-east-1 regionendpoint: http://192.168.10.30:9000 # 自建对象存储网关地址 bucket: registry-prod forcepathstyle: true # 自建网关基本都要开,否则域名解析不到 v4auth: true chunksize: 5242880 # 5MB 分块,低于这个值大层上传会碎成很多请求 rootdirectory: /registry delete: enabled: true cache: blobdescriptor: inmemory # 元数据缓存,多实例部署时能明显降低后端压力forcepathstyle是最容易漏的一项。开源的 S3 兼容网关大多不支持虚拟主机风格的 bucket 域名,不开这个参数,启动阶段能连上,一 push 就报域名解析失败。chunksize决定分块上传的粒度,设太小请求数暴涨,设太大内存占用高,5MB 到 10MB 是个稳妥区间。
cache.blobdescriptor: inmemory只在单实例或者多实例无共享缓存的情况下简单有效,进程重启后缓存清空但数据不丢。如果后面要做多实例,缓存层要换成外部组件,否则每个实例各缓存各的,命中率上不去。
还有个现实问题:从filesystem切到对象存储,没有官方迁移工具。可信的做法是搭新仓库、批量重推镜像(docker pull再docker tag再push),或者用镜像同步工具按仓库逐个搬。不要直接往 bucket 里拷文件,路径布局对不上,Registry 认不出来。
4. 运维动作:GC、容量与备份的三条铁律
4.1 打开 delete 开关与 manifest 的正确删法
先说结论:删 tag 不等于删数据。docker rmi删的是本地镜像,curl -X DELETE删的是服务端的一个 manifest 索引,真正占空间的 blob 在 GC 之前一直躺在磁盘上。很多人清完一批旧版本,du -sh一看一点没少,就开始怀疑人生。
删 manifest 需要两个前提:storage.delete.enabled: true,以及请求里带上正确的Accept头。第二个条件经常被忽略——不带Accept头去请求 manifest,服务端返回的是 schema1 的内容或者干脆 404,拿到的 digest 也可能是错的。
# 第一步:拿到 manifest 的 digest,注意 Accept 头必须和推送时一致 curl -sI -u deploy:'ChangeMe_2024' \ -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ https://registry.example.internal:5000/v2/library/nginx/manifests/1.25 \ | grep -i docker-content-digest # 第二步:按 digest 删除(按 tag 删是删不掉的,别试) curl -s -o /dev/null -w "%{http_code}\n" -X DELETE \ -u deploy:'ChangeMe_2024' \ https://registry.example.internal:5000/v2/library/nginx/manifests/sha256:9f1c... # 第三步:确认 tag 已消失 curl -s -u deploy:'ChangeMe_2024' \ https://registry.example.internal:5000/v2/library/nginx/tags/list返回 202 表示删除请求被接受。如果返回 405,就是delete.enabled没开;返回 401 是认证没带对;返回 404 多半是 digest 拼错或者 repo 路径写错。多架构镜像要特别注意:manifest list 下面挂着各个架构的子 manifest,删了 list 之后子 manifest 变成孤儿,一样要等 GC 才回收,且新版 Registry 支持--delete-untagged顺手清掉这些孤儿。
4.2 garbage-collect 的执行顺序与只读窗口
GC 是离线动作,它扫全量 blob、比对哪些还被 manifest 引用、把没人引用的删掉。问题在于:扫描期间如果有新的 push 进来,刚上传还没写完引用的 blob 可能被误判成垃圾删掉,结果就是一个好好的镜像突然拉不下来,报blob unknown。这种故障排查起来极其费劲,因为日志里什么都看不出来。
所以正确顺序是:先停写入,再 GC,最后恢复。停写入的方式有两种,按环境选:
# 方式 A:干脆停掉容器,用一次性容器跑 GC,最安全 docker stop registry docker run --rm \ -v /data/registry:/var/lib/registry \ -v /opt/registry-offline/config.yml:/etc/docker/registry/config.yml:ro \ registry:2 garbage-collect /etc/docker/registry/config.yml # 方式 B:不停容器,在上层反代临时拒绝 PUT/PATCH/POST,只放行 GET # 适合不能停服的场景,但要确认反代规则生效后再执行 docker exec registry /bin/registry garbage-collect /etc/docker/registry/config.yml不管哪种方式,先跑一次--dry-run是血泪经验。它会打印「将删除哪些 blob、能回收多少空间」,但不真删。第一次跑的时候拿它对一下预期,如果打印出来的数量远超你估计的删除量,说明有 manifest 引用关系没搞清楚,这时候停下来查比事后恢复便宜得多。
# 预演,只看不删 docker exec registry /bin/registry garbage-collect --dry-run /etc/docker/registry/config.yml # 确认无误后正式执行,输出重定向留档 docker exec registry /bin/registry garbage-collect /etc/docker/registry/config.yml \ > /var/log/registry-gc-$(date +%F).log 2>&1GC 期间 CPU 和 IO 都会明显上升,镜像仓库越大耗时越长,几百 GB 的仓库跑十几分钟很正常。安排在低峰期,别和批量构建撞上。新版 Registry 支持--delete-untagged,把没有 tag 指向的 manifest 一并清掉,配合定时任务用效果不错,但第一次用一定加--dry-run看输出。
4.3 备份与迁移:目录复制能做和不能做的事
filesystem后端的备份看起来很简单,rsync拷目录就完事。但有两个时刻拷出来的东西是不能用的:GC 运行期间,以及有 push 正在写入的时候。前者会出现「manifest 还在、blob 已被删」的悬空引用,后者会出现半截文件。恢复的时候表现为部分镜像拉取失败,而且很难定位是哪一次备份带进来的。
稳妥做法是「停写 + 快照 + 复制」:
# 1. 停掉写入(停容器最彻底) docker stop registry # 2. 用 rsync 做增量复制,-a 保留权限和时间戳,--delete 让目标与源一致 rsync -a --delete /data/registry/ /backup/registry-$(date +%F)/ # 3. 记录校验信息,方便下次对比 find /backup/registry-$(date +%F) -type f | wc -l du -sh /backup/registry-$(date +%F) # 4. 启动服务 docker start registry如果宿主机上有 LVM 或者支持快照的文件系统,用快照替代停机更优雅:打快照、从快照挂载复制、删快照,服务全程不停。代价是需要提前规划卷。
迁移到新机器时,除了数据目录,还有三样东西必须一起带过去:config.yml、证书私钥、htpasswd。少任何一样,新仓库要么起不来,要么起来了但客户端全部校验失败。搬完之后先在新机器上curl /v2/确认服务通,再挑一个镜像完整跑一遍 pull,别等切流量之后才发现问题。
提示:备份目录别放在同一个存储池上。仓库和备份同盘,盘坏了一起没,这种事故不算罕见。
5. 避坑排查:六个翻车现场的现象、原因与处理
5.1 启动阶段:端口、卷、权限
现象一:容器起来几秒就退出,日志里只有一行open /var/lib/registry/...: permission denied。
原因通常不是权限位不够,而是宿主机目录的 SELinux 上下文没打对,或者目录属主和容器内运行用户不匹配。Registry 镜像默认以 root 运行,如果宿主机目录被设成 700 且属于别的用户,容器内照样写不进去。
解决:先ls -ld /data/registry确认属主,再在挂载时加:z或:Z让容器运行时自动重打标签(仅 SELinux 环境需要),或者干脆chown -R 1000:1000后改用非 root 用户跑。改完用docker run --rm -v /data/registry:/var/lib/registry registry:2 sh -c 'touch /var/lib/registry/.probe'单独验证写权限,比反复起停主容器快。
现象二:curl 127.0.0.1:5000/v2/通,但别的机器访问不了。
原因分两类:服务只监听了回环地址,或者中间有防火墙。config.yml里写成127.0.0.1:5000时,端口映射到宿主机也只有本机能连。另一个常被忽略的点是容器网络的-p 5000:5000只绑 IPv4,纯 IPv6 环境要显式写-p [::]:5000:5000。
解决:配置改0.0.0.0:5000,重启后从另一台机器curl -v看卡在哪一跳。如果 TCP 能连但 HTTP 无响应,往反代配置上查;如果连 TCP 都建不起来,就是防火墙或安全组。
5.2 推拉阶段:证书、认证、反代
现象三:push 到一半报x509: certificate signed by unknown authority,但浏览器访问同一个地址是通的。
原因是浏览器里手动点过「继续访问」,而 Docker 客户端没有这个交互,它只认系统或/etc/docker/certs.d/下的 CA。另一个隐蔽原因是证书 SAN 里只有域名,而你是用 IP 访问的。
解决:把服务端证书放到客户端/etc/docker/certs.d/192.168.10.20:5000/ca.crt,目录名必须和实际访问的地址一字不差(含端口)。然后用openssl s_client -connect 192.168.10.20:5000 -showcerts确认服务端吐出来的证书链就是你以为的那张,别信配置文件。
现象四:账号密码明明对,docker login一直 401,日志里只有一条auth failure。
九成是 htpasswd 文件格式问题:用了 MD5 或 crypt 哈希,Registry 只认 bcrypt。剩下的一成是文件里有空行、CRLF 换行,或者容器内路径挂错了导致读了另一个文件。
解决:head -1 auth/htpasswd看前缀,bcrypt 应该是$2y$或$2b$开头。用file auth/htpasswd确认不是 CRLF,是的话sed -i 's/\r$//'处理掉。最后docker exec registry cat /auth/htpasswd确认容器里读到的就是宿主机的这份。
现象五:大镜像推到 90% 报 413 或连接重置,小镜像完全正常。
前面挂了 Nginx 之类的反向代理时,请求体大小限制和超时是两个必查项。默认的 1MB 限制对小 manifest 没影响,一到几百 MB 的层就立刻暴露。
解决:反代里放开client_max_body_size 0;(不限大小),proxy_request_buffering off;让上传流式转发,proxy_read_timeout和proxy_send_timeout调到 900 秒以上。同时服务端config.yml的http.host要写成外部访问地址,否则分块上传返回的Location指向内部地址,客户端跟过去就断了。
5.3 清理阶段:删不掉与回收不了
现象六:删了一批 manifest,du -sh一点没变,磁盘照样告警。
这是最常见的一条。删 manifest 只删了索引,blob 还在。也有人跑了 GC 但空间没释放,原因是那些 blob 还被其他 tag 或子 manifest 引用着——同一个基础镜像层被十个镜像共用,删掉其中一个,层当然还在。
解决:跑garbage-collect --dry-run看实际能回收多少,别拿「删了多少个 tag」估算。另外注意 Windows 或跨平台构建产物里会有大量共享层,回收率天然偏低。真要压容量,得从「保留最近 N 个版本」的策略入手,而不是指望 GC 变魔术。
6. 把清理做成脚本:按保留数自动删 tag 并触发 GC
手工删 manifest 这件事做两次就烦了,尤其当仓库里堆着几百个 CI 产出的临时 tag。我的做法是写一个小脚本,按 tag 的推送时间排序,保留最近 N 个,其余全部按 digest 删掉,最后在维护窗口里统一 GC。下面这版脚本用 Python 写的,只依赖标准库,扔到任何一台能访问仓库的机器上都能跑。
#!/usr/bin/env python3 # registry-prune.py 按保留数清理旧 tag,默认只演练不真删 import json, ssl, sys, urllib.request, base64 REG = "https://registry.example.internal:5000" REPO = "library/app" KEEP = 5 USER, PASS = "deploy", "ChangeMe_2024" DRY_RUN = True # 第一次务必 True,确认清单没问题再改 False ACCEPT = "application/vnd.docker.distribution.manifest.v2+json" # 自签证书环境跳过校验;生产建议换成加载 CA 文件 ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE def api(path, method="GET", headers=None): req = urllib.request.Request(REG + path, method=method) token = base64.b64encode(f"{USER}:{PASS}".encode()).decode() req.add_header("Authorization", "Basic " + token) for k, v in (headers or {}).items(): req.add_header(k, v) with urllib.request.urlopen(req, context=ctx, timeout=30) as r: return r.status, r.headers, r.read() # 1. 列出所有 tag _, _, body = api(f"/v2/{REPO}/tags/list") tags = json.loads(body).get("tags") or [] if len(tags) <= KEEP: print(f"tag 数 {len(tags)} 未超过保留数 {KEEP},无需清理") sys.exit(0) # 2. 按名字里的时间戳排序(CI 打的 tag 通常带 yyyymmdd-HHMM),没有时间信息就按字典序 tags.sort() stale = tags[:-KEEP] print(f"共 {len(tags)} 个 tag,保留 {KEEP} 个,待删 {len(stale)} 个:") for t in stale: print(" -", t) # 3. 逐个取 digest 并删除,删的是 manifest 不是 tag for t in stale: _, hdrs, _ = api(f"/v2/{REPO}/manifests/{t}", method="HEAD", headers={"Accept": ACCEPT}) digest = hdrs.get("Docker-Content-Digest") if not digest: print(f"[skip] {t} 取不到 digest") continue if DRY_RUN: print(f"[dry-run] 将删除 {t} -> {digest}") continue status, _, _ = api(f"/v2/{REPO}/manifests/{digest}", method="DELETE") print(f"[deleted] {t} -> {digest} status={status}")几个参数值得单独说。REPO是仓库路径,不带域名,必须是全小写;KEEP是保留数,按业务节奏定,一天多次构建的项目给 10 比给 5 合理;DRY_RUN是后悔药,第一次跑必须为True,把要删的清单打印出来人工过一遍。api()里的HEAD请求是拿 digest 的标准姿势,注意必须带Accept头,不带的话返回的 digest 可能对应 schema1,删不对东西。
脚本跑完只是把索引删了,空间还得靠 GC 释放。我的习惯是把两件事绑在一个维护窗口里做:脚本执行完,停容器、跑--dry-run看回收量、正式 GC、再启容器,全程留日志。跑顺之后交给定时任务,但每周必须人工看一眼 GC 日志里的回收数字——如果某一周突然回收量暴增,往往说明有人在批量删镜像,或者清理策略误伤了不该删的 tag,这时候还能从备份里捞回来。
还有一条经验值得提前说:清理脚本不要和 CI 的推送共用同一个账号。给脚本单独开一个只有删除权限的账号,出问题时能立刻定位是谁动的,也能一把吊销。顺带把脚本里的明文密码换成环境变量读取,别让它躺在 Git 里。
从那以后我每次动 GC 都强制走一遍「dry-run 看清单、停写、正式跑、抽查 pull」这四步,哪怕只是删三个 tag。仓库这东西平时没人注意,真坏了整条交付链都得停。希望帮到你。
本文还有配套的精品资源,点击获取