
简介面向无法访问外网的内网环境提供一份可直接导入的 Docker Registry 镜像离线包帮助运维与开发人员在隔离网络中搭建私有镜像仓库免去在线拉取镜像、补齐依赖和反复配置下载源的麻烦。压缩包共 18 个文件整体约 25.15MB主要由 json 清单与元数据、tar 分层镜像数据以及 version、repositories 等版本与仓库索引文件组成保留镜像分层与索引信息便于按 Docker 镜像格式导入、迁移和部署。已有 437 人学习下载说明其在离线仓库搭建与镜像准备场景中具有一定参考价值。读者可用于内网 CI/CD、离线交付、镜像归档和私有仓库验证等任务减少寻找离线镜像与排查依赖缺失的时间。也可将其作为内网镜像仓库初始化的素材便于核对镜像组成、完成基础环境准备并支持后续维护。1. registry.tar.gz 不是安装包是 Docker 私有仓库的“离线底座”手里有 registry.tar.gz 的人多半正站在一台连不上外网的机器前Docker 已经装好镜像却拉不下来想先把私有仓库立起来。这个包就是干这个的——它要么是官方 registry 镜像的docker save归档要么是一份现成的/var/lib/registry数据目录打包落地之后你就有一个能 push、能 pull 的端点。它适合内网构建、离线交付、CI 缓存这几类场景不适合当成带 Web 界面、带多租户权限的成品仓库来用。先搞清楚它属于哪一种形态后面的动作完全不一样。2. 解包与起服务让 registry.tar.gz 变成 5000 端口上的可用仓库这一章的目标很单一从压缩包解到一个能响应/v2/的进程。很多人卡住不是因为命令难而是没先判断压缩包身份就急着docker load结果报一句open /var/lib/docker/tmp/docker-import-xxx/bin/json: no such file or directory然后开始怀疑包坏了。先看内容再决定路径。2.1 先判身份tar -tzf 看顶层目录拿到包的第一件事不是解压是列目录。只用看前 20 行两种形态的指纹非常明显。# 只看前 20 个条目足够判断包的类型 tar -tzf registry.tar.gz | head -20如果输出里出现manifest.json、repositories以及一堆形如a1b2c3.../layer.tar、a1b2c3.../VERSION的路径这是docker save出来的镜像归档走 2.2。如果输出以docker/registry/v2/blobs/sha256/...或registry/v2/repositories/...开头那就是仓库数据目录的打包走 2.4。还有一种少见情况包里同时有registry二进制和config.yml那是某个环境手工拼的发行目录直接看 2.3 的配置结构和 2.4 的还原方式把二进制路径换成--entrypoint /path/to/registry即可。参数上没什么可调的-t是列出、-z是按 gzip 解、-f指定文件。真要省事可以加-v看权限位判断原数据目录的属主是不是 1000:1000——如果是说明原环境里 registry 是以非 root 跑的还原时chown要对上。# 看权限位和属主判断原环境的运行用户 tar -tvzf registry.tar.gz | head -5这一步的判断价值在于镜像归档只能给你一个 registry 程序仓库里是空的数据目录包给你的是历史镜像但程序还得另外起。两者经常被混为一谈尤其是别人转手发过来的包文件名一模一样。2.2 镜像归档形态docker load 与首次启动确认是镜像归档后导入是最短的一步。# 导入镜像输出会列出 Loaded image: registry:2 docker load -i registry.tar.gz # 确认镜像和 tag注意 tag 可能不是 2而是 2.8.3 之类的具体版本 docker images | grep -i registrydocker load会把归档里的所有层按顺序落到本地存储-i指定输入文件不需要解压。如果输出里 tag 是none说明导出时用的是镜像 ID 而不是名字起容器时用IMAGE ID那一列即可顺手docker tag id registry:2打回去后面配置里写名字更清楚。首次启动建议先不要挂载任何配置文件用默认参数验证程序本身能不能跑# 最小启动验证镜像可用验证完立刻删掉别让它占着 5000 docker run -d --rm --name registry-probe -p 5000:5000 registry:2 # 正常返回 {} 和 200 就说明进程是活的 curl -i http://127.0.0.1:5000/v2/ docker rm -f registry-probecurl -i /v2/返回HTTP/1.1 200 OK和空 JSON{}是 registry 的健康约定返回 401 也说明进程活着只是开了认证。这一步不要跳过它能把你后面所有“push 失败”的问题范围从“镜像坏了”缩小到“配置或网络”。正式启动要带持久化和删除开关这两个是后面所有章节的前提# 正式启动数据落盘、开启删除能力、固定重启策略 docker run -d --name registry \ --restartalways \ -p 5000:5000 \ -v /data/registry:/var/lib/registry \ -e REGISTRY_STORAGE_DELETE_ENABLEDtrue \ registry:2参数逐个说清楚--restartalways保证宿主机重启后仓库自己起来内网仓库没人盯着这一条比什么都实用-v /data/registry:/var/lib/registry是唯一的后悔药没有它容器一删镜像全没REGISTRY_STORAGE_DELETE_ENABLEDtrue打开删除 API不打开的话你后面删 manifest 会一直收到 405。注意这个环境变量只是临时覆盖一旦你用挂载的config.yml启动容器会以配置文件为准环境变量里的同名项不一定生效——这是很多人“明明加了变量还是删不掉”的来源。2.3 用 config.yml 接管默认配置默认启动只适合验证。真正上生产要把配置落成文件否则每次调参都得重建容器还说不清改了什么。registry 的配置默认读/etc/docker/registry/config.yml挂载覆盖这个路径就行。# /etc/registry/config.yml version: 0.1 log: level: info fields: service: registry storage: filesystem: rootdirectory: /var/lib/registry maxthreads: 100 delete: enabled: true cache: blobdescriptor: inmemory maintenance: uploadpurging: enabled: true age: 168h interval: 24h dryrun: false http: addr: :5000 secret: change-this-to-a-random-string headers: X-Content-Type-Options: [nosniff] health: storagedriver: enabled: true interval: 10s threshold: 3这份配置的关键项storage.delete.enabled决定删除 API 是否可用和 2.2 里的环境变量是同一件事写在这里更可控cache.blobdescriptor: inmemory把 blob 描述符缓存在内存里仓库里镜像上千之后tag 列表和 manifest 查询会明显快一截代价是进程重启后缓存冷启动maintenance.uploadpurging清理中断的上传残留age: 168h表示超过 7 天的未完成上传会被扫掉interval: 24h是扫描周期这个功能对磁盘友好别关http.secret用于上传状态签名多实例部署时必须一致单机随便填但别用默认值。重启容器加载配置# 先测试配置能不能被解析起不来会直接报错比看日志快 docker run --rm \ -v /etc/registry/config.yml:/etc/docker/registry/config.yml:ro \ registry:2 /bin/registry serve /etc/docker/registry/config.yml这条命令会占住终端并打印启动日志看到listening on [::]:5000就说明配置合法CtrlC退出。确认无误后再删旧容器、起新的docker rm -f registry docker run -d --name registry --restartalways \ -p 5000:5000 \ -v /etc/registry/config.yml:/etc/docker/registry/config.yml:ro \ -v /data/registry:/var/lib/registry \ registry:2挂配置文件一定加:ro只读挂载能挡掉一类“容器里进程误写配置”的怪问题。另外配置里写的rootdirectory是容器内路径宿主机对应的是-v左边那一半两者别写串了。2.4 数据目录形态还原路径与属主如果 2.1 判断出来是数据目录包那你的任务是把docker/registry/v2这一套结构原样放回/var/lib/registry下面再起一个空配置的 registry 去读它。# 先建目标目录注意不要提前塞任何东西进去 mkdir -p /data/registry # 解压到目标目录保留权限位 tar -xzf registry.tar.gz -C /data/registry # 看一眼解出来的顶层是什么 ls -la /data/registry这里有个高频翻车点包内顶层目录不统一。有的包解开直接是docker/registry/v2/...有的包多套了一层registry/docker/registry/v2/...还有的套了主机名或日期目录。判断标准是找blobs目录的真实位置# 找 blobs 在哪一层输出路径就是 registry 期望的 rootdirectory find /data/registry -maxdepth 4 -type d -name blobs假设输出是/data/registry/docker/registry/v2/blobs那rootdirectory就该是/data/registry如果输出是/data/registry/registry/docker/registry/v2/blobs要么把配置里的rootdirectory改成/data/registry/registry要么把内容上移一层。前者更快后者更干净看你心情但别两边都改。属主也得对。registry 镜像默认以 root 跑如果你的数据是从一个用 uid 1000 跑的环境里打包出来的直接起容器可能遇到permission denied# 递归修正属主和容器的运行用户对齐 chown -R 1000:1000 /data/registry # 确认一下最深层文件也能读 sudo -u #1000 test -r /data/registry/docker/registry/v2/blobs echo readable还原完不要急着对外先本地验一下目录能被识别curl -s http://127.0.0.1:5000/v2/_catalog能列出仓库名说明repositories索引和 blobs 是对得上的如果返回空 JSON 但目录里明明有东西八成是rootdirectory指错了一层回到find那一步重看。这个形态的好处是历史镜像全在坏处是如果原环境跑过很久没做 GC包里会带一大堆孤儿 blob解出来先看体积别把一台小盘机器直接撑爆。3. 客户端打通insecure-registry、TLS 与登录认证三选一仓库进程起来只是服务端的事。客户端能不能 push取决于三件事的匹配传输层是 HTTP 还是 HTTPS、端口有没有写对、认证有没有配。三者任一处不对报错信息都很像——x509、connection refused、unauthorized——但改法完全不同。这一章按“先跑通再收紧”的顺序来。3.1 明文 HTTP 的最小闭环内网临时用最省事的是先不开 TLS让客户端接受明文。注意这是临时手段跑通之后应该往 3.2 走。客户端要改的是 Docker 守护进程配置不是 registry 的配置{ insecure-registries: [192.0.2.10:5000], max-concurrent-uploads: 5 }写到/etc/docker/daemon.json然后重载systemctl daemon-reload systemctl restart docker docker info | grep -A2 Insecure Registriesinsecure-registries的写法有几个硬规矩必须带端口192.0.2.10和192.0.2.10:5000是两个不同的条目不支持通配符和网段192.0.2.0/24写了也没用如果用域名访问就写域名加端口。max-concurrent-uploads是并发上传数默认 5大镜像多的时候调到 10 能快一些但别盲目拉高服务端maxthreads只有 100客户端一多会互相挤。改完验证docker tag nginx:latest 192.0.2.10:5000/demo/nginx:v1 docker push 192.0.2.10:5000/demo/nginx:v1 docker pull 192.0.2.10:5000/demo/nginx:v1镜像名里的第一部分必须能被解析成仓库地址有.或:或就是localhostDocker 才会当成 registry 而不是 Docker Hub 的用户名。所以demo/nginx:v1会推到官方 Hub192.0.2.10:5000/demo/nginx:v1才会推到你的机器。这条规则不熟的人会绕着它转很久。3.2 自签证书SAN 不写全就是白干TLS 的目的是让客户端不用改insecure-registries靠系统信任链完成校验。内网没有公网证书常规做法是自签一张然后把 CA 发给客户端。# 生成带 SAN 的自签证书IP 和域名都写进去 openssl req -x509 -nodes -newkey rsa:4096 -days 3650 \ -keyout /etc/registry/tls/registry.key \ -out /etc/registry/tls/registry.crt \ -subj /CNregistry.lan \ -addext subjectAltNameIP:192.0.2.10,DNS:registry.lan-addext subjectAltName是这条命令的核心现代客户端只看 SAN不看 CN。只写 CN 不写 SAN 的证书浏览器和 Docker 都会报x509: certificate relies on legacy Common Name field。IP 和域名两种访问方式都写上省得以后换访问入口再重签。-days 3650是十年内网够用但别忘了它确实会过期。服务端配置里加 TLS 段http: addr: :5000 secret: change-this-to-a-random-string tls: certificate: /etc/registry/tls/registry.crt key: /etc/registry/tls/registry.key客户端把证书放到约定目录目录名必须是host:port# 客户端执行目录名带端口文件名习惯用 ca.crt mkdir -p /etc/docker/certs.d/192.0.2.10:5000 cp registry.crt /etc/docker/certs.d/192.0.2.10:5000/ca.crt systemctl restart docker放好之后把insecure-registries里的对应条目删掉重启 Docker再 push 一次。如果此时报certificate signed by unknown authority检查三件事目录名有没有漏端口、文件名是不是ca.crt、证书是不是 DER 而不是 PEM。三个都对了还报错用openssl s_client -connect 192.0.2.10:5000 -showcerts看服务端实际吐出来的是哪张证书经常是挂载路径写错容器里还是默认自签的那张。3.3 htpasswd 认证与账号收敛能 push 的人越多越需要区分谁在推。registry 自带 htpasswd 认证够用不引入额外组件。离线环境没有现成的htpasswd工具时用 openssl 生成 apr1 格式的条目mkdir -p /etc/registry/auth printf deploy:%s\n $(openssl passwd -apr1 ChangeMe_2024) \ /etc/registry/auth/htpasswd chmod 640 /etc/registry/auth/htpasswdopenssl passwd -apr1生成的是 MD5 变体哈希registry 的 htpasswd 实现认这个格式。有httpd-tools的话优先用htpasswd -Bbn user passbcrypt 强度更高但离线机器上不一定有apr1 在内网够用。文件权限收到 640别让普通用户读到哈希。配置里加认证段auth: htpasswd: realm: registry-realm path: /etc/registry/auth/htpasswdrealm是 401 响应里返回的提示字符串客户端登录时会看到写个能认出环境的词就行。启动时把 auth 目录挂进去docker run -d --name registry --restartalways \ -p 5000:5000 \ -v /etc/registry/config.yml:/etc/docker/registry/config.yml:ro \ -v /etc/registry/auth:/etc/registry/auth:ro \ -v /etc/registry/tls:/etc/registry/tls:ro \ -v /data/registry:/var/lib/registry \ registry:2客户端登录一次docker login 192.0.2.10:5000 -u deploy # 密码交互输入不要在命令行里带 -p会进 shell history登录成功后凭据存在~/.docker/config.jsonCI 环境里要单独注入别把带凭据的配置文件打进镜像。账号策略上我一般只留两个一个deploy用于 CI 推送一个只读账号用于拉取靠不同的 htpasswd 文件配合不同的 registry 实例实现——单实例的 registry 没有细粒度权限想按仓库授权就得上别的组件这个包本身不提供。3.4 反向代理body 限制与 chunked前面挂 Nginx 做统一入口很常见但 registry 的上传行为和普通 Web 服务不一样默认配置会直接把它掐死。server { listen 443 ssl; server_name registry.lan; ssl_certificate /etc/nginx/tls/registry.crt; ssl_certificate_key /etc/nginx/tls/registry.key; # 关键关闭请求体大小限制镜像层动辄几百 MB client_max_body_size 0; chunked_transfer_encoding on; location /v2/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 关键不要缓冲上传请求体否则大层会先落盘再转发 proxy_request_buffering off; proxy_read_timeout 900s; proxy_send_timeout 900s; } }client_max_body_size 0是关闭限制不是设成 0 字节这个语义很多人会看错。proxy_request_buffering off让 Nginx 边收边转发否则一个 2 GB 的层会先在 Nginx 临时目录落一份磁盘瞬间翻倍。proxy_read_timeout调到 900 秒是因为大层上传可能持续几分钟默认 60 秒会在传输中途断开客户端报blob upload unknown。代理层还有一个隐性问题如果你的入口是 HTTPS 而 registry 本身是 HTTPX-Forwarded-Proto必须传对否则 registry 生成的 upload URL 会是http://客户端拿到之后按 HTTPS 去连会失败。传$scheme一般是安全的前提是 Nginx 这一层确实终结了 TLS。4. 存储布局与垃圾回收blob、manifest 和那堆删不掉的空间到这一步仓库能用了接下来是运维问题。registry 最反直觉的地方在于docker rmi和 API 删除解决的是“标签不可见”不解决“磁盘没释放”。不理解blobs和repositories的关系就会陷入“删了一晚上du -sh一点没变”的循环。4.1 /var/lib/registry 下到底存了什么看目录结构别看文档# 三个核心子目录各司其职 ls /data/registry/docker/registry/v2/ # blobs 是内容寻址的真实数据repositories 是索引 du -sh /data/registry/docker/registry/v2/blobs du -sh /data/registry/docker/registry/v2/repositoriesblobs/sha256/下面按 digest 前两位分桶每个文件就是一层或一个 manifest 或一个 config文件名即内容哈希。repositories/name/_manifests/revisions/sha256/digest/link里存的是一行文本内容就是指向的那个 digest。也就是说真实数据只有一份标签和 revision 都是指针。两个不同 tag 指向同一层时blobs 里只存一份这是 registry 省空间的核心机制。这个设计带来两个后果。第一删 tag 只是删指针blob 还在因为可能还有别的 manifest 引用它。第二同一个镜像被打了十个 tag删掉九个不会释放任何空间。判断一个 blob 是否真的没用了靠的是 GC 的 mark 阶段从所有_manifests和_layers里的 link 出发标记可达的 digest剩下没被标记的才是垃圾。# 看某个仓库的 revision 数量数字大说明 tag 迭代很频繁 find /data/registry/docker/registry/v2/repositories/demo/nginx/_manifests/revisions -name link | wc -l # 看 blob 总数和总大小和 revision 数对比能判断垃圾比例 find /data/registry/docker/registry/v2/blobs -type f | wc -l4.2 删除镜像的正确顺序删除要走 API不能直接rm -rf目录直接删会让索引和 blobs 对不上后续 push 同名镜像可能出现blob unknown。第一步拿 manifest 的 digest。注意必须带Accept头否则 registry 返回的 digest 和实际存储的可能不是同一个# 取 digest注意 Accept 头必须匹配 manifest 类型 DIGEST$(curl -sI \ -H Accept: application/vnd.docker.distribution.manifest.v2json \ http://192.0.2.10:5000/v2/demo/nginx/manifests/v1 \ | awk -F: tolower($1) ~ /docker-content-digest/ {print $2} | tr -d \r) echo $DIGEST-I只取响应头Docker-Content-Digest就是内容哈希。tr -d \r去回车Windows 换行混进来会让后面的 DELETE 拿到一个带空格的 URL。awk里用tolower是为了兼容不同版本大小写不一致的响应头。第二步删 manifest# 返回 202 表示接受删除404 说明 digest 不对或仓库名写错 curl -s -o /dev/null -w %{http_code}\n -X DELETE \ http://192.0.2.10:5000/v2/demo/nginx/manifests/$DIGEST第三步才是 GC。此时磁盘还没变这是正常的。4.3 GC 的时机与 dry-runGC 是 mark-and-sweep扫描期间如果有 push 进来可能出现新写入的 blob 还没被索引就进了 sweep 阶段被误删。所以顺序必须是“先只读或停写再 GC”。# 先空跑只看会删多少、删哪些 docker exec registry /bin/registry garbage-collect --dry-run \ /etc/docker/registry/config.yml--dry-run只打印不执行输出形如blob eligible for deletion: sha256:...先看条数和体积符合不符合预期。如果 dry-run 打印的删除量远超你预期说明有别的地方还在引用这些 blob先别急着真跑。# 确认无误后真跑输出会打印释放的 blob 数量 docker exec registry /bin/registry garbage-collect \ /etc/docker/registry/config.yml实际执行时建议把仓库切到只读。registry 支持在配置里加一段然后发信号让它重载maintenance: readonly: enabled: true# 改完配置后让进程重载不用重建容器 docker kill -s HUP registry只读状态下 push 会被拒绝pull 不受影响。GC 跑完把enabled改回false再 HUP 一次。GC 的耗时和 blob 数量成正比十万级 blob 可能要几十分钟放在业务低峰做别在 CI 高峰跑。还有一个开关值得知道--delete-untagged会连带删掉没有 tag 指向的 manifest适合那些“删了 tag 但 manifest 还留着”的场景。但对多架构镜像要谨慎因为它可能把某个平台清单当成无标签项删掉导致别处拉取报manifest unknown。用之前先 dry-run 看清单。4.4 upload 残留与容量巡检除了镜像本身还有一类空间消耗容易被忽略中断的上传。客户端传一半断了blobs/uploads/下会留下一个临时目录默认不会自己消失除非 2.3 里配了uploadpurging。# 看未完成上传占了多少 du -sh /data/registry/docker/registry/v2/blobs/uploads find /data/registry/docker/registry/v2/blobs/uploads -type f | wc -l数量多说明客户端网络不稳或者超时设置太短。除了开 purging还可以定期跑一次巡检把容量、仓库数、最大仓库列出来#!/usr/bin/env bash # 每周跑一次的容量巡检输出直接发到运维群 ROOT/data/registry/docker/registry/v2 echo total du -sh $ROOT echo top repos du -sh $ROOT/repositories/*/* 2/dev/null | sort -rh | head -10 echo uploads du -sh $ROOT/blobs/uploads echo blobs count find $ROOT/blobs -type f | wc -lsort -rh按人类可读单位倒序head -10只留前十。这个脚本的价值不在多聪明而在于把“磁盘什么时候会满”变成一条能看见的曲线。我见过最典型的翻车是某台构建机每天推几百个latest没人删三个月后/var/lib/registry涨到 800 GB而实际被引用的只有不到 100 GB剩下全是无标签历史。5. 避坑排查五条踩过的记录这一章全是现象、原因、解决三段式能对上号就直接改对不上号就当排查清单过一遍。registry 的报错普遍偏短客户端那侧的信息往往比服务端日志更有用遇到问题先docker logs registry --tail 100再回头看客户端完整输出。5.1 push 报 http: server gave HTTP response to HTTPS client现象docker push 192.0.2.10:5000/demo/app:v1直接失败报http: server gave HTTP response to HTTPS clientdocker info里能看到 registry 条目curl http://192.0.2.10:5000/v2/也正常。原因Docker 客户端默认按 HTTPS 连服务端只提供 HTTP握手阶段就断了。insecure-registries没配、配错端口、或者配了但没重启 Docker都会是这个现象。还有一种隐蔽情况配置文件写在了/etc/docker/daemon.json但 systemd 的启动参数里带了--insecure-registry两处冲突时以启动参数为准。解决先确认 daemon.json 里条目带端口且和访问地址完全一致再systemctl daemon-reload systemctl restart docker用docker info确认 Insecure Registries 列表里真的有这一条。如果用了 systemd drop-in 覆盖检查/etc/systemd/system/docker.service.d/下有没有残留参数。最稳的做法是只保留一处配置来源。5.2 删了标签磁盘一点没降现象按 4.2 删了 manifestcurl返回 202/v2/_catalog里仓库也少了但du -sh /data/registry纹丝不动。原因删除 API 只删索引blob 要等 GC 才回收或者storage.delete.enabled没开DELETE 请求其实返回了 405被当成成功忽略了还有一种是自己rm -rf过 repositories 下的目录索引和 blobs 脱节GC 反而不敢删。解决先curl -o /dev/null -w %{http_code} -X DELETE ...确认返回 202 而不是 405405 就回去改配置确认删除成功后按 4.3 跑 dry-run看候选数量对不对最后真跑 GC。不要手工删 blobs 下的文件也不要手工删 repositories 下的 manifest 目录所有删除都走 API 加 GC 这条链。5.3 容器重建一次镜像全没了现象docker rm -f registry之后按同样的命令重起/v2/_catalog返回空push 过的镜像全消失。原因启动时没挂/var/lib/registry数据写在了容器可写层容器一删就跟着没了或者挂了但挂到了别的路径配置里rootdirectory指向的还是容器内的/var/lib/registry两边对不上。解决docker inspect registry --format {{json .Mounts}}看实际挂载点和配置里的rootdirectory对齐。重建容器前养成习惯先docker inspect看挂载再docker rm。数据目录建议放在独立分区或独立盘上别和系统盘混在一起GC 和备份都会方便很多。5.4 x509: certificate is valid for X, not Y现象配了 TLS 之后 push 报x509: certificate is valid for registry.lan, not 192.0.2.10或者certificate signed by unknown authority。原因前者是证书 SAN 里没写你实际使用的访问地址CN 写了没用后者是客户端没拿到 CA或者放的位置、文件名不对。解决用openssl x509 -in registry.crt -noout -text | grep -A1 Subject Alternative Name看证书里到底写了什么缺什么补什么重新签发。客户端侧确认/etc/docker/certs.d/host:port/ca.crt存在且是 PEM 格式目录名带端口改完重启 Docker。如果前面还有一层 Nginx 终结 TLS那 Docker 客户端信的是 Nginx 的证书不是 registry 的两张别搞混。5.5 大镜像推到一半报 blob upload unknown现象小镜像正常几百 MB 以上的层推到 60% 左右断开报blob upload unknown或unexpected EOF。原因前面有 Nginx 或负载均衡默认client_max_body_size 1m直接 413或者开了请求缓冲导致临时目录写满或者proxy_read_timeout太短在传输中途断开连接。直连 registry 时则可能是服务端maxthreads太小并发上传互相挤。解决代理层按 3.4 改client_max_body_size 0、proxy_request_buffering off、超时拉到 900 秒直连场景把storage.filesystem.maxthreads调到 100 以上同时控制客户端并发。排查时先用curl -v -T传一个大文件到/v2/下的上传端点把代理层和 registry 层分开定位比反复 push 快得多。6. 验证与搬家确认这份包里的东西真能用包能用不等于用得对。上线前我会跑三条命令做验收顺序固定先看进程活着再看清单能列最后做一次完整的 push-pull 往返。# 1. 健康检查200 或 401 都算活着 curl -s -o /dev/null -w %{http_code}\n http://192.0.2.10:5000/v2/ # 2. 列仓库带分页参数避免仓库多时输出过长 curl -s http://192.0.2.10:5000/v2/_catalog?n50 # 3. 列某个仓库的 tag curl -s http://192.0.2.10:5000/v2/demo/nginx/tags/list第 2 步返回的 JSON 里如果有Link响应头说明还有下一页按头里的 URL 继续取别以为只有这么多。第 3 步的 tag 列表要和源环境对得上这是判断数据目录包完整性的最直接证据。跨机搬仓库有两条路选哪条取决于你要搬的是“历史”还是“当前”方式适用场景优点代价打包/var/lib/registry整目录同版本迁移、全量历史都要快一次搬完所有 tag 和历史 revision体积大带孤儿 blob路径和属主要对齐skopeo copy按镜像逐个搬只搬在用的镜像、跨版本干净只搬可达内容可脚本化慢需要逐仓库列清单第二条路适合懒人写法# 按仓库名批量搬源和目的都跳过 TLS 校验内网自签场景 for repo in $(curl -s http://192.0.2.10:5000/v2/_catalog | jq -r .repositories[]); do for tag in $(curl -s http://192.0.2.10:5000/v2/${repo}/tags/list | jq -r .tags[]?); do skopeo copy --src-tls-verifyfalse --dest-tls-verifyfalse \ docker://192.0.2.10:5000/${repo}:${tag} \ docker://198.51.100.20:5000/${repo}:${tag} done donejq -r .tags[]?里的问号是容错tag 为 null 的仓库不会让整个循环挂掉。--src-tls-verifyfalse只在自签且没配 CA 的临时场景用正式环境还是把 CA 放好、把校验打开。这段脚本跑完用第 2、3 步同样的命令在新机器上对一遍清单数量一致才算搬完。搬家最容易被忽略的是 GC 的时机数据目录整包搬过去之后先别急着对外服务在新机器上跑一次 dry-run把孤儿 blob 清掉再开张。我吃过一次亏——搬完直接切流量结果新机器磁盘只有源机器的一半跑了三天就满回头查发现包里带了近 40% 的历史垃圾。从那以后我每次搬仓库都强制走一遍「dry-run 看量 → 只读 → GC → 恢复写入」这个序列再忙也不跳步。希望帮到你。本文还有配套的精品资源点击获取