Docker离线镜像仓库:部署、TLS认证与GC清理实践
简介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.tardocker save出来的镜像归档用docker load -i导入tag 以实际导入结果为准config.ymlRegistry 主配置路径、存储目录、监听地址都在这里改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 \ --restartalways \ -p 5000:5000 \ -v /data/registry:/var/lib/registry \ -v /opt/registry-offline/config.yml:/etc/docker/registry/config.yml:ro \ registry:2--restartalways保证宿主机重启后仓库自己回来-p 5000:5000是默认端口改成别的端口时config.yml里的addr和映射要同步改配置用:ro只读挂载避免容器里误改文件导致宿主机的副本和运行态不一致。启动后别急着 push先看日志确认监听成功docker logs --tail 30 registry # 正常会看到类似 levelinfo msglistening 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 /CCN/OInternal/CNregistry.example.internal \ -addext subjectAltNameDNS: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这一项容易被当成可选项忽略但它决定了仓库对外宣称的地址在反向代理后面尤其重要。如果前面挂了一层 Nginxhost要写外部访问地址而不是容器内地址否则客户端拿到的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 -mMD5或-dcrypt生成的文件账号密码全对也会一直 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(bChangeMe_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.v2json \ 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 21GC 期间 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 清理阶段删不掉与回收不了现象六删了一批 manifestdu -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.v2json # 自签证书环境跳过校验生产建议换成加载 CA 文件 ctx ssl.create_default_context() ctx.check_hostname False ctx.verify_mode ssl.CERT_NONE def api(path, methodGET, headersNone): req urllib.request.Request(REG path, methodmethod) 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, contextctx, timeout30) 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(ftag 数 {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}, methodHEAD, 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}, methodDELETE) 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。仓库这东西平时没人注意真坏了整条交付链都得停。希望帮到你。本文还有配套的精品资源点击获取