Docker私有仓库离线部署:registry.tar.gz起服务、认证与GC

发布时间:2026/11/1 1:08:02
Docker私有仓库离线部署:registry.tar.gz起服务、认证与GC

简介:面向无法访问外网的内网环境,提供一份可直接导入的 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 registry

docker 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-probe

curl -i /v2/返回HTTP/1.1 200 OK和空 JSON{},是 registry 的健康约定,返回 401 也说明进程活着,只是开了认证。这一步不要跳过:它能把你后面所有“push 失败”的问题范围从“镜像坏了”缩小到“配置或网络”。

正式启动要带持久化和删除开关,这两个是后面所有章节的前提:

# 正式启动:数据落盘、开启删除能力、固定重启策略 docker run -d --name registry \ --restart=always \ -p 5000:5000 \ -v /data/registry:/var/lib/registry \ -e REGISTRY_STORAGE_DELETE_ENABLED=true \ registry:2

参数逐个说清楚:--restart=always保证宿主机重启后仓库自己起来,内网仓库没人盯着,这一条比什么都实用;-v /data/registry:/var/lib/registry是唯一的后悔药,没有它容器一删镜像全没;REGISTRY_STORAGE_DELETE_ENABLED=true打开删除 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就说明配置合法,Ctrl+C退出。确认无误后再删旧容器、起新的:

docker rm -f registry docker run -d --name registry --restart=always \ -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 Registries"

insecure-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

镜像名里的第一部分必须能被解析成仓库地址:有.或:或就是localhost,Docker 才会当成 registry 而不是 Docker Hub 的用户名。所以demo/nginx:v1会推到官方 Hub,192.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 "/CN=registry.lan" \ -addext "subjectAltName=IP: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/htpasswd

openssl passwd -apr1生成的是 MD5 变体哈希,registry 的 htpasswd 实现认这个格式。有httpd-tools的话优先用htpasswd -Bbn user pass,bcrypt 强度更高,但离线机器上不一定有,apr1 在内网够用。文件权限收到 640,别让普通用户读到哈希。

配置里加认证段:

auth: htpasswd: realm: registry-realm path: /etc/registry/auth/htpasswd

realm是 401 响应里返回的提示字符串,客户端登录时会看到,写个能认出环境的词就行。启动时把 auth 目录挂进去:

docker run -d --name registry --restart=always \ -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.json,CI 环境里要单独注入,别把带凭据的配置文件打进镜像。账号策略上,我一般只留两个:一个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 本身是 HTTP,X-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/repositories

blobs/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 -l

4.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.v2+json" \ 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-run

GC 是 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 -l

sort -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 client,docker 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 删了 manifest,curl返回 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 而不是 405,405 就回去改配置;确认删除成功后按 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?n=50" # 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-verify=false --dest-tls-verify=false \ "docker://192.0.2.10:5000/${repo}:${tag}" \ "docker://198.51.100.20:5000/${repo}:${tag}" done done

jq -r '.tags[]?'里的问号是容错,tag 为 null 的仓库不会让整个循环挂掉。--src-tls-verify=false只在自签且没配 CA 的临时场景用,正式环境还是把 CA 放好、把校验打开。这段脚本跑完,用第 2、3 步同样的命令在新机器上对一遍清单,数量一致才算搬完。

搬家最容易被忽略的是 GC 的时机:数据目录整包搬过去之后,先别急着对外服务,在新机器上跑一次 dry-run,把孤儿 blob 清掉再开张。我吃过一次亏——搬完直接切流量,结果新机器磁盘只有源机器的一半,跑了三天就满,回头查发现包里带了近 40% 的历史垃圾。从那以后我每次搬仓库都强制走一遍「dry-run 看量 → 只读 → GC → 恢复写入」这个序列,再忙也不跳步。希望帮到你。

本文还有配套的精品资源,点击获取