私有 Docker Registry 排障实录:两个隐蔽的坑

我在一台公网服务器上部署了私有 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:2proxy 是"拉取缓存"而非"普通上游"。它假设你通过它拉取的每个名字都在 Docker Hub 上存在。把本地构建的镜像 push 进一个 pull-through 缓存 registry,等于让一个代理去帮你找一个不存在的远端资源。教训:先想清楚 registry 的角色定位------缓存代理和私有仓库,是两种配置,不是一种

一些通用排查心得

  1. invalid character '<' 大概率不是 JSON 的问题,而是收到了 HTML 。先确认链路每一跳的响应:容器内直连 → 宿主机直连 → 反代,用 curl -si 逐层二分,哪一层开始变成 HTML,问题就在哪。
  2. "push 成功"不等于"真的传上去了"。push 成功后立刻在另一台机器 pull 回来验证,能拉下来才算数。这次如果不是 pull 验证,坑一不知道要藏多久。
  3. 注意配置的"视角" 。挂载进容器的配置按容器内路径/地址写;宿主机进程的配置按宿主机写。同样的 127.0.0.1,两个世界。
  4. 上游错误会被包裹 。回源类组件经常把上游的 4xx 包成自己的 5xx。看到 500 先看完整 error body,DENIED + StatusCode: 401 这种嵌套结构直接指向上游。
相关推荐
云川之下1 小时前
【k8s】SR‑IOV‑Network‑Operator 详解
云原生·容器·kubernetes
名字还没想好☜4 小时前
Kubernetes NetworkPolicy 实战:默认全通的坑与用标签做零信任隔离
运维·云原生·容器·kubernetes·networkpolicy
瑞码空间4 小时前
Web应用的多端部署之道:浏览器 · Electron · Docker
前端·docker·electron·浏览器·web
badhope5 小时前
Docker镜像从2GB压到80MB——我被运维追着打了三天才学会的容器瘦身术
docker
wangjing_05226 小时前
通过CentOS 7虚拟机的Docker服务部署Node-RED及气象数据显示界面搭建完整教程
linux·docker·centos
风曦Kisaki7 小时前
# Kubernetes(K8s)笔记Day13 :工作负载资源概述与 Job (CronJob)控制器
linux·笔记·云原生·容器·kubernetes
Patrick在香港16 小时前
Python Docker镜像从1.2GB到89MB:多阶段构建的完整优化实录
java·python·docker·信息可视化·容器·数据分析·ai编程
风曦Kisaki1 天前
# Kubernetes(K8s)笔记Day12 :K8s 七层代理(Ingress 和 Ingress Controller)
linux·笔记·云原生·容器·kubernetes
Lyra_Infra1 天前
Docker OCI Runtime 启动失败问题排查与解决
后端·docker·架构