我在一台公网服务器上部署了私有 Docker Registry,方案是:
registry:2容器,htpasswd 认证,数据落在宿主机/srv/registry/data- 配置了
proxy.remoteurl: https://registry-1.docker.io,即 Docker Hub 回源缓存模式 - Nginx 反代 443 →
127.0.0.1:5000,仅暴露/v2/路径
域名用 registry.example.com 代替,服务器 IP 用 203.0.113.10 代替。整个排查过程踩了两个坑,一个在部署脚本,一个在配置设计,都值得记下来。
坑一:push 显示成功,pull 却报 HTML 解析错误
现象
从开发机推送镜像:
docker push registry.example.com/library/debian:12
输出里层(layer)显示 Already exists,并且拿到了 digest,看起来一切正常。但换一台机器拉取:
docker pull registry.example.com/library/debian:12
Error response from daemon: error unmarshalling content: invalid character '<' looking for beginning of value
< 是 HTML 的开头。也就是说,客户端期望收到 registry 的 JSON 响应,实际收到的却是 HTML。这通常意味着请求被某个 web 服务(Nginx 默认页、反代 404 页等)拦掉了,根本没到 registry。
排查过程
先看本机直连:
curl -si http://127.0.0.1:5000/v2/
结果空响应 。加 -v:
* Recv failure: Connection reset by peer
连接被对端重置。这是典型的"端口有人监听,但请求被转发到一个没有服务的地址"。
再看容器:
docker ps
# private-registry Up 2 weeks 127.0.0.1:5000->5000/tcp
docker exec private-registry wget -qO- http://127.0.0.1:5000/v2/
# HTTP/1.1 401 Unauthorized <- 容器内部是正常的
容器内的 registry 进程返回了 401(要求认证),说明进程本身健康。问题在监听地址。
翻看挂载进容器的配置:
yaml
http:
addr: 127.0.0.1:5000
根因
config.yml 是被 -v 挂载进容器的(-v /srv/registry/config.yml:/etc/docker/registry/config.yml)。所以这里的 127.0.0.1 指的是容器内部的 loopback,不是宿主机。
而端口映射是 -p 127.0.0.1:5000:5000,docker-proxy 会把宿主机 loopback 的流量转发到容器的 eth0 IP。容器内 registry 只监听自己的 loopback,eth0 上没人服务,于是连接被重置。
于是整个链路变成:
Nginx(443) -> 127.0.0.1:5000(宿主机) -> docker-proxy -> 容器 eth0:5000 = 无人监听 -> reset
Nginx 拿到连接重置后,命中 error_page 500 502 503 504 = /404.html,把静态 404 页以 HTTP 200 返回。客户端收到 HTML,于是报 invalid character '<'。
之前 push"成功"也是假象:服务端每个端点都返回同一个 HTML 页,客户端把层标记为 Already exists 并照单全收,其实一个字节都没传上去。
修复
容器内监听改为 0.0.0.0:5000。安全性并不因此降低,因为 -p 127.0.0.1:5000:5000 仍然把端口限制在宿主机 loopback,外部只能走 Nginx。
yaml
http:
addr: 0.0.0.0:5000
改完重启容器,直连返回 401,Nginx 链路也返回 401,链路打通。
复盘
脚本作者想当然地把 127.0.0.1 写进了"容器内"的配置,却忘了这份配置是挂载进去的。同一个 127.0.0.1 在宿主机和容器内是两个完全不同的网域。教训:凡是挂载进容器的配置,地址要按容器视角写。
坑二:自定义镜像 push 报 500,官方镜像却没事
现象
registry 修好之后,推 Docker Hub 官方镜像(library/debian:12)一切正常。但推一个自建的镜像:
docker push registry.example.com/yourname/opencode:latest
报错:
unknown: unexpected status from HEAD request to
https://registry.example.com/v2/yourname/opencode/blobs/sha256:...: 500 Internal Server Error
换一个 namespace 再推,依然 500,排除了镜像名问题。
排查过程
直接对 blob 发请求,拿到完整错误体:
sh
AUTH=$(python3 -c "import json;print(json.load(open('$HOME/.docker/config.json'))['auths']['registry.example.com']['auth'])")
curl -s -m 20 -H "Authorization: Basic $AUTH" \
-w "\nHTTP %{http_code}\n" \
"https://registry.example.com/v2/yourname/opencode/blobs/sha256:1cb37751f6b6b5ec6361666c993ecd39c6246128c9b0ce0f019e94889c4dfebf"
几点说明:
- digest 从哪来:push 报错里那一串 sha256:1cb3... 就是 blob 的 digest,直接粘过来用。
- 为什么用 HEAD 变 GET:push 阶段是 HEAD 请求,但 HEAD 不返回 body。要拿到错误 JSON 细节就得用 GET,错误体内容一样。
- Authorization 头:docker login 后凭证存在 ~/.docker/config.json,字段 auths.<域名>.auth 是 user:pass 的 base64,直接取出来当 Basic 头用,不用手动拼。
- 换域名/镜像时:只改 URL 里 registry.example.com、yourname、opencode、digest 四处。
json
{"errors":[{"code":"UNKNOWN","message":"unknown error",
"detail":{"errors":[
{"code":"DENIED","message":"requested access to the resource is denied"},
{"code":"UNKNOWN","message":"unknown error","detail":{"StatusCode":401,"Response":""}}
]}}]}
关键信息:这个 401 不是来自我们的 registry,而是来自上游。配合配置一看:
yaml
proxy:
remoteurl: https://registry-1.docker.io
根因
proxy 让 registry 变成 Docker Hub 的拉取缓存 。对 library/ 下的官方镜像,blob 不存在时会自动回源 Docker Hub 拉取------debian 在 Docker Hub 上存在,所以 push 时层检查能命中,一切正常。
但 opencode 是本地构建的镜像,Docker Hub 上根本没有这个名字。每次 blob 的 HEAD 检查,registry 都尝试回源 Docker Hub,上游返回 401,registry 把这个上游错误包成 500 吐给客户端。
也就是说:pull-through 缓存模式下,只能推/拉 Docker Hub 上真实存在的镜像。自建镜像在这种配置下推不上去。
修复
按需求二选一:
- 只存私有镜像 :去掉
proxy段,registry 退化为纯私有仓库。 - 两者都要 :拆两个 registry,一个带回源缓存(只放
library/官方镜像),一个纯私有(放自建镜像)。
我选的是第一种,去掉 proxy 后自建镜像推送恢复。
复盘
registry:2 的 proxy 是"拉取缓存"而非"普通上游"。它假设你通过它拉取的每个名字都在 Docker Hub 上存在。把本地构建的镜像 push 进一个 pull-through 缓存 registry,等于让一个代理去帮你找一个不存在的远端资源。教训:先想清楚 registry 的角色定位------缓存代理和私有仓库,是两种配置,不是一种。
一些通用排查心得
invalid character '<'大概率不是 JSON 的问题,而是收到了 HTML 。先确认链路每一跳的响应:容器内直连 → 宿主机直连 → 反代,用curl -si逐层二分,哪一层开始变成 HTML,问题就在哪。- "push 成功"不等于"真的传上去了"。push 成功后立刻在另一台机器 pull 回来验证,能拉下来才算数。这次如果不是 pull 验证,坑一不知道要藏多久。
- 注意配置的"视角" 。挂载进容器的配置按容器内路径/地址写;宿主机进程的配置按宿主机写。同样的
127.0.0.1,两个世界。 - 上游错误会被包裹 。回源类组件经常把上游的 4xx 包成自己的 5xx。看到 500 先看完整 error body,
DENIED+StatusCode: 401这种嵌套结构直接指向上游。