
先说结论我家那台小服务器上跑着一个registry:2镜像仓库,已经攒了13 GB 缓存、95 个仓库。它在局域网里给我所有的 NAS、PVE、Mac、Windows 提供 Docker Hub 镜像加速,冷启动一个 alpine 不到 1 秒,完全不走公网 Docker Hub。这篇文章不讲先装 Docker 然后跑个容器这种一句废话教程。我会把真实在生产环境跑的部署脚本、登录鉴权流程、反向代理最容易踩的 4 个坑、htpasswd 密码文件怎么管理、缓存怎么排错,一次性摊开。看完你应该能做的:① 在 10 分钟内起一个带登录的 registry;② 解释清楚WWW-Authenticate头为啥不能丢;③ 给老板、给家人讲明白这个仓库到底在干什么。图 1:左边那个蓝色大盒子是 Docker Hub 官方,中间那个青色的小盒子就是我自己家里搭的 registry:2,右边紫色是局域网里所有要拉镜像的机器。整篇文章就是讲中间那个小盒子是怎么搭起来的。一、问题背景:为什么我非要在家里搞一个私有仓库?把时间倒回 2023 年。那时候我家里有几台机器:NAS、PVE 集群、Mac mini、偶尔一台 Windows 笔记本。每台机器都需要拉一堆 docker 镜像:nginx、redis、jellyfin、portainer、homeassistant等等。一开始大家各自直接docker pull nginx:latest,各自跑各自的 NAT 出公网,各自受 Docker Hub 那 100 pulls / 6h 的限流:Docker Hub 是按你家的公网 IP 算账的。我和邻居共用一个 C 类 NAT,他在疯狂跑 CI,我这边连docker pull alpine都能撞 429。偶尔家里宽带抖一下,一个 800 MB 的gitlab/gitlab-ce拉到一半失败,要从头再来。不同机器拉同一个镜像,完全没去重——三台机器各自去公网下,白白消耗三倍带宽。我当时想到的解决方案就一个:在局域网里搞一个统一的镜像仓库,所有机器都从它拉,这个仓库再去公网拉一次,然后缓存下来。第二次任何机器再拉,直接走内网。这个东西其实就是Docker 官方提供的registry:2镜像,一个 24 MB 的 Go 程序,跑起来就是一个 HTTP 服务,完全够用。图 2:docker pull的真实流程:客户端 → GET /v2/ → GET manifest → 按层 GET blob → 解压到本地。私有仓库就是把这些 GET 请求拦在自己机器上,优先吐本地缓存,缓存没有才去 upstream 拉。听起来很简单对吧?事实上也确实不复杂,只是坑比较多。下面把我这几年踩过的坑和我现在的最终方案一起讲清楚。二、问题表现:你以为装好了,实际半哑我一开始直接docker run -d -p 5000:5000 registry:2,跑起来,docker pull localhost:5000/alpine,嗯能用。但真要把家里的 NAS 和 PVE 都接进来,马上就冒出 4 个半哑问题:docker push报错 401,因为我没配鉴权。任何能访问 5000 端口的人都能 push,完全没设防。NAS 那边docker pull my-server:5000/xxx一直 connection refused,因为我把端口绑在 127.0.0.1,内网别的主机根本到不了。重启服务器之后,缓存全没了,因为我没把/var/lib/registry挂到宿主机目录。docker login死活提示 unauthorized,但服务端日志说Www-Authenticate: Basic realm...,看上去又是发了的——这是反代 / TLS / 域名解析三个里某一个出的问题,日志看不出谁。这 4 个坑每个都能让你卡半天。下面我按先让 docker login 跑通、再让它持久化、最后做反向代理的顺序讲,这样每一步你都只面对一个变量。小白能听懂的类比:你想自己在家搞一个小区便利店,卖水卖零食。货架就是磁盘(放商品);仓库就是 registry 容器(进货出货);大门口保安就是 htpasswd(没卡不让进);街道入口的反向代理就是小区大门口的传达室(验通行证、把包裹转交给你)。任何一个环节漏掉,要么东西丢了,要么客人进不来。三、问题根因:你看到的 401,可能不是密码错我直接抛结论:docker login在 80% 的明明密码对、为啥还 401的场景里,问题都不在密码,而在 HTTP 头。Docker daemon 用的是 OCI Distribution Spec v2 的认证流程,大概是这样:客户端先发一个不带 token 的GET /v2/给仓库;仓库回401 Unauthorized,并在响应头里写WWW-Authenticate: Basic realm...;客户端看到这个头,就重新发一次请求,这次带上Authorization: Basic base64(user:pass);仓库验证账号,通过就吐真正的 manifest。最关键的第 2 步:那个WWW-Authenticate头必须原封不动回到客户端。如果你前面套了一层 Nginx / Caddy / Traefik 反向代理,这层代理默认会把WWW-Authenticate头吞掉(因为它是WWW-*系列),结果客户端根本不知道这是个需要鉴权的服务,直接报unauthorized。另一个常见原因:你给反代配了 HTTPS,但 docker daemon 端没信任这个证书(docker daemon 只信任系统 CA 和/etc/docker/certs.d/host/ca.crt),于是 TLS 握手阶段就先挂了,根本走不到鉴权那一步。还有一个隐藏原因:docker daemon只允许通过https://或localhost拉镜像。也就是说,如果你仓库跑在内网,但客户端配的是http://192.168.x.x:5000,docker daemon 会直接拒绝,根本不发请求。小学生版解释:你去公司前台领访客卡。前台说:“请出示工牌”。这就是WWW-Authenticate头。如果前台忘了说这句话,你掏出工牌也没用,因为你不知道要掏。如果公司大门是塑料门、没有真锁(就是 HTTP 而不是 HTTPS),保安根本不让你进。四、解决问题:从 1 行 docker run 开始,10 分钟跑通一个完整仓库下面是我现在生产环境在用的部署脚本,经过半年稳定运行。一共 4 个步骤,每一步都加上了为什么要这么写的注释。步骤 1:准备一个长期保存的目录# 在宿主机上建目录,缓存和配置都在这里,重启/迁移都不会丢mkdir-p/docker/registry-dockerhub# 配置文件mkdir-p/docker/registry-cache/dockerhub# blob 缓存(13 GB 都是这里)mkdir-p/docker/registry/auth# htpasswd 密码文件为什么要把缓存放到宿主机目录?Docker 容器默认是 ephemeral(临时)的:容器一删,里面的数据全没了。把/var/lib/registry这个容器目录挂到宿主机的/docker/registry-cache/dockerhub,就相当于给容器装了一块外挂硬盘,容器怎么折腾,硬盘上的数据都不会丢。步骤 2:生成 htpasswd 密码文件# 一次性生成,只跑一次就行dockerrun--rm\--entrypointhtpasswd\httpd:2.4-BbnadminMyS3cretPass!\/docker/registry/auth/htpasswd跑完之后你的密码文件长这样:admin:$2y$05$kxqfBjMmhK6eOZ8m9eRkgeW7FZmkjj8OQ8iCpzJZ1Vb5lpWWGbH1e图 3:htpasswd 用 bcrypt 把密码加密,磁盘上存的是$2y$05$...这种哈希,反推不出原始密码。htpasswd -Bbn三个参数分别是 bcrypt 算法、从命令行读密码、追加到文件末尾。千万不要用-m(md5)!-m是历史悠久的 md5 加密,非常容易被彩虹表撞。Docker daemon 实际上能接受 md5、sha1、bcrypt,但 md5 的弱点不在兼容性,而在你已经 2026 年了,还在用 1995 年的加密,说服力不够。步骤 3:写一份 config.yml(可选,默认配置就能用)cat/docker/registry-dockerhub/config.ymlYAML version: 0.1 log: fields: service: registry-dockerhub-cache storage: filesystem: rootdirectory: /var/lib/registry http: addr: :5000 headers: X-Content-Type-Options: [nosniff] proxy: remoteurl: https://hub1.nat.tf YAML图 4:proxy.remoteurl这一行是关键 —— 它告诉 registry:当本地缓存没有某个 blob 的时候,去这个 URL 拉。不写这一行,registry 就只接受 push,不会主动去 upstream 拉新镜像。proxy.remoteurl是什么?类比:它就是便利店的进货渠道。客人要一瓶水,货架上没有,你就去https://hub1.nat.tf那个批发商那里进货,放进货架,下次客人再来就有货了。注意:https://hub1.nat.tf在我这里工作得很好(详见我之前写的 从 5m14s 到 0.6s 那篇),在你那里可能需要换成别的源,实测一次再说。步骤 4:起容器dockerrun-d\--nameregistry-dockerhub\--restartalways\-p8082:5000\-v/docker/registry-dockerhub/config.yml:/etc/docker/registry/config.yml\-v/docker/registry-cache/dockerhub:/var/lib/registry\-v/docker/registry/auth:/auth\-eREGISTRY_AUTHhtpasswd\-eREGISTRY_AUTH_HTPASSWD_REALMRegistry Realm\-eREGISTRY_AUTH_HTPASSWD_PATH/auth/htpasswd\registry:2图 5:把这行命令复制粘贴就能起一个能用的 registry。-e REGISTRY_AUTH_*这三个环境变量是 htpasswd 鉴权的全部开关,少一个都不行。跑完之后验证一下:$dockerps--formattable {{.Names}}\t{{.Image}}\t{{.Ports}}NAMES IMAGE PORTS registry-dockerhub registry:20.0.0.0:8082-5000/tcp registry-ghcr registry:20.0.0.0:8083-5000/tcp $curl-sShttp://localhost:8082/v2/{}# 注意这里是 401 WWW-Authenticate,不是 200为什么curl /v2/返回 401 而不是 200?这是 Docker 协议故意设计的握手:返回 401 WWW-Authenticate头,告诉客户端我是 v2 仓库,你要鉴权才能访问。大部分监控脚本会用curl /v2/ | grep -q {}判断仓库存活,但配了鉴权之后这条命令永远返回 401,监控会误报挂掉。正确的健康检查是:curl -i https://host/v2/ | head -1,看HTTP/1.1 401就 OK。五、客户端怎么用:从一台机器到整个集群单台客户端(一次性操作):# 1. 登录(在内网里用 IP 也可以,docker daemon 接受内网 HTTP)dockerlogin192.168.x.x:8082# Username: admin# Password: ********# Login Succeeded# 2. 打 tag pushdockertag hello-world:latest192.168.x.x:8082/test/hello-world:v1dockerpush192.168.x.x:8082/test/hello-world:v1# 3. 另一台机器 pulldockerpull192.168.x.x:8082/test/hello-world:v1图 6:先docker login拿到 token,然后docker push把镜像推上去。另一台机器直接docker pull,不再走公网 Docker Hub。整条流程都是局域网内网,延迟 1ms。全集群配置(一次配置,所有机器生效):在每台 docker daemon 的/etc/docker/daemon.json里加:{insecure-registries:[192.168.x.x:8082,192.168.x.x:8083]}为什么不直接用https://?家里内网,自签证书 让所有客户端信任 CA这套流程很麻烦,等于为了一个家庭内部工具要搭一个 mini PKI。Docker 官方提供insecure-registries这个开关,就是给你这种内网非生产的场景用的。生产环境请上 https 反代 CA 证书。整集群生效之后,所有docker pull library/alpine:3.19会自动重定向到192.168.x.x:8082/library/alpine:3.19,完全无感知。类比:daemon.json里的insecure-registries就像小区门口的业主白名单。业主不需要每次回家都刷门禁卡,只要门卫认得这张脸,直接放行。docker login是给门卫加新面孔,docker pull是日常进出。六、缓存到底是怎么回事:13GB 看着多,实际去重后才占 13GB跑了一段时间之后,你可以看一下磁盘:$du-sh/docker/registry-cache/* 13G /docker/registry-cache/dockerhub3.0G /docker/registry-cache/ghcr13GB 是真实的吗?是。但同样的 13GB 物理容量,可能承载了上百个镜像仓库。我们看一下 blob 在磁盘上怎么存的:$find/docker/registry-cache/dockerhub/docker/registry/v2/blobs/sha256/\-typef-printf%s %p\n|sort-rn|head-5934308001.../sha256/de/de4a0c57...cfff/data# alpine 根文件系统 ~890 MB753000000.../sha256/13/13d4f7c2...a51d/data540000000.../sha256/f5/f5fc7c45.../data534000000.../sha256/89/894f7c54.../data422000000.../sha256/9c/9c8a7d83.../data图 7:blob 的存储路径就是它自己的 sha256 前 2 位 完整 hash。这意味着:两个不同的镜像,如果它们共享同一个 base 层(比如都基于library/alpine),那个 layer 在磁盘上只会存一份。“按 sha256 寻址” 用图书馆来打比方:学校图书馆有 100 万本书。如果按位置 1、位置 2、位置 3…编号,完全一样的两本《五年高考三年模拟》会放在不同的位置,占两份地方。但如果按这本书第一句话的 SHA256编号,所有完全相同的书永远只能放一个位置,自动去重。Docker registry 就是这个原理:任何一份内容在磁盘上只存 1 份,无论多少个镜像引用它。下面是我这台 registry 里library/alpine镜像缓存的 tag 数:$curl-sShttp://localhost:8082/v2/library/alpine/tags/list|jq.tags | length219图 8:光是library/alpine一个镜像,我这台仓库就缓存了 219 个 tag,从2.6到latest都有。它们共享大量 base 层,所以实际占盘远小于219 个独立 alpine 镜像的大小。整个仓库里有 95 个独立仓库名,从library/alpine到zyplayer/zyplayer-doc,但物理上只占 13 GB:图 9:/v2/_catalog列出当前仓库里所有镜像名。这是我家那台 registry 的真实输出,可以看到从library/alpine到portainer/portainer-ce到vaultwarden/server,全部在本地缓存里。七、反向代理:最容易踩坑的 4 个配置点如果你想让这个仓库对外可访问(家人从外面拉镜像、CI 触发拉取),就需要在前面套一个反向代理。这是 401 错误最密集的地方。以 Caddy 为例,一份能跑的最小配置:registry.lab.local { reverse_proxy localhost:8082 { # 关键 1: 必须透传 WWW-Authenticate 头 header_up -WWW-Authenticate header_up WWW-Authenticate # 关键 2: 大文件不要缓冲,流式转发 flush_interval -1 } }以 Nginx 为例,同样的事:server { listen 443 ssl; server_name registry.lab.local; ssl_certificate /etc/ssl/lab.crt; ssl_certificate_key /etc/ssl/lab.key; location / { proxy_pass http://127.0.0.1:8082; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # ⚠️ 必须透传这俩头 proxy_pass_request_headers on; proxy_buffering off; proxy_request_buffering off; client_max_body_size 0; } }最容易出错的 4 个点:proxy_buffering off没写。Nginx 默认会把上游响应缓冲到磁盘再发给客户端,导致docker push大镜像超时。一定要off。client_max_body_size 0没写。默认 1 MB,你 push 一个 500 MB 镜像直接报 413。WWW-Authenticate头被吞。Nginx 默认对WWW-*系列会过滤掉一部分,要用proxy_pass_request_headers on显式放行。HTTPS 证书链不完整。Let’s Encrypt 申请的证书有时候是 chain 不全的,要让 docker daemon 信任你 cert chain,最稳的是把chain.pem cert.pem拼成一个文件。这 4 个坑,任一命中,你的现象都是docker login 报 unauthorized,但服务端日志看着又没问题。排错顺序:① curl -i 看 WWW-Authenticate;② 看 Nginx error log;③ 用 openssl s_client 测 TLS 握手。八、3 种部署形态:你属于哪一种?图 10:同一个 registry:2 镜像,在不同规模下要拼的周边不一样。我家用的是最左边那一档;创业团队用中间;公司用最右。维度家庭 / 实验室小团队公司客户端数1 ~ 510 ~ 50几百 ~ 几千反代不要Caddy / Nginx 自签 CANginx / Envoy 商业 CA鉴权htpasswd 单用户htpasswd 读写分组LDAP / OIDC / OAuth存储本机 filesystem本机 filesystem 备份S3 / OSS / MinIO监控du -sh看一下Prometheus exporterGrafana 告警 SLA高可用不需要,坏了就重起单点 备份多副本 跨地域SLA没有没有99.9%我家用的方案就是最左边那一档:单容器 filesystem htpasswd crontab 备份,跑了一年没出问题。没必要为了家庭内网这个场景过度设计。九、实战排错:docker login 一直 unauthorized这是我见过的最常见的问题,也是网上最容易被错误回答的问题。下面是我的标准排错流程。# Step 1: 看服务端是不是真的在监听$ ss-tlnp|grep8082LISTEN040960.0.0.0:80820.0.0.0:* users:((docker-proxy,pid226477,fd8))# Step 2: 不带认证 curl 一下,看 401 WWW-Authenticate$curl-ihttp://localhost:8082/v2/ HTTP/1.1401Unauthorized Content-Type: application/json;charsetutf-8 Docker-Distribution-Api-Version: registry/2.0 Www-Authenticate: BasicrealmRegistry RealmX-Content-Type-Options: nosniff Content-Length:79{errors:[{code:UNAUTHORIZED,message:authentication required,detail:null}]}# Step 3: 带认证再 curl 一次,应该回 200$curl-i-uadmin:MyS3cretPass!http://localhost:8082/v2/ HTTP/1.1200OK Docker-Distribution-Api-Version: registry/2.0 X-Content-Type-Options: nosniff Content-Length:2{}# Step 4: 客户端 docker login$rm~/.docker/config.json# 先把旧凭据清掉,排除缓存干扰$dockerlogin-uadmin-pMyS3cretPass!192.168.x.x:8082 Login Succeeded图 11:从curl -i看 401 头开始,到htpasswd -Bv验证密码,到rm ~/.docker/config.json清缓存,标准的客户端不认服务端的排错 3 步。4 个常见错误信息对照表:错误信息真正的原因no such host域名解析失败 / 写错 IPconnection refused服务端没起来 / 端口错tls: failed to verify certificateHTTPS 但证书不被信任unauthorized: authentication required服务端起来了,但WWW-Authenticate头丢了 / 密码错十、备份和清理:放了一年磁盘会不会爆?会,默认 168 小时(7 天)清理一次没访问的 blob。但实测下来 13 GB 一年没爆过磁盘,因为:内部去重让内容增长和磁盘增长严重脱钩;大部分常用镜像(nginx、redis、alpine、postgres)的 layer 已经被拉过无数次,后续新版本只增加 diff,实际增量很小;默认 scheduler 每 24 小时跑一次 GC,把 7 天没访问的 blob 删掉。如果你想自己控制保留策略,加一行:storage:filesystem:rootdirectory:/var/lib/registrydelete:enabled:truecache:blobdescriptor:inmemory然后用 registry 自带的 GC API:# 标记 7 天没访问的 blob 为待删$curl-XPOST-uadmin:MyS3cretPass!\http://localhost:8082/v2/_catalog# 先 warm up$curl-XPOST-uadmin:MyS3cretPass!\http://localhost:8082/v2/_catalog?n1000# 看 GC 状态$curl-uadmin:MyS3cretPass!\http://localhost:8082/debug/health|jq.备份就更简单:整个/docker/registry-cache目录直接 tar 就行。# 每周日凌晨 3 点打包03* *0tarczf /backup/registry-$(date\%F).tgz /docker/registry-cache类比:GC 超市每周下架过期 7 天的酸奶。备份 给超市拍一张今日货架照,出事了能照着摆回去。你不用每天管它,但别等真丢了才想起来没备份。十一、QAQ:用registry:2跟用 Harbor 有什么区别?A:Harbor 是registry UI 镜像扫描 复制策略 LDAP的一站式平台,适合公司。家用用 Harbor 杀鸡用牛刀,光资源占用就要 4 GB 内存起步,而且 Harbor 自己也是用registry:2做底层。我家用 24 MB 的registry:2完全够,简单是复杂的解药。Q:我不想用 htpasswd,能不能用公司 LDAP?A:可以,registry:2 支持 token-based auth,写一个小 HTTP 服务,接收 registry 发过来的?service...scope...,查 LDAP 后回 token。Harbor / Distribution 官方都给了 sample。但最简单的方式仍然是 htpasswd,多用户管理麻烦的话就用不同的 htpasswd 文件。Q:docker login一直 unauthorized,但 curl -u admin:pass 没问题?A:99% 是反代吞了WWW-Authenticate头。在你反代的 vhost 里强制加:proxy_pass_request_headers on; # Nginx header_up WWW-Authenticate # Caddy然后curl -i https://your.host/v2/,确认响应里有WWW-Authenticate: Basic realm...这一行。Q:缓存会不会让镜像不新?比如我 upstream 拉到 alpine:3.19 了,3.20 发布了我怎么办?A:不会。registry:2 在每次pull时,都会先发一个HEAD /v2/name/manifests/tag给 upstream,问它这个 tag 现在指向哪个 digest?。如果 upstream 返回的 digest 不等于本地缓存的 digest,registry 会主动去 upstream 拉新的 manifest 新的 blob。整个过程对客户端完全无感,客户端只会看到我又拉到最新版了。Q:如何把现有的本地镜像批量推到这个私有仓库?A:写一个小脚本:#!/bin/bashDEST192.168.x.x:8082dockerimages--format{{.Repository}}:{{.Tag}}|grep-vnone|whilereadimg;donew$(echo$img|seds|^|$DEST/|)dockertag$img$newdockerpush$new/dev/null21echopushed:$newdone但强烈不推荐把所有镜像都推到私有仓库 — 私有仓库主要是缓存 内部业务镜像,公开镜像还是直接拉上游更省事。Q:insecure-registries和 HTTPS,我到底选哪个?A:内网用 http insecure-registries,生产用 https。insecure-registries这个开关就是给你内网用的,合规风险为 0(docker daemon 自己把 http 卡在非 loopback 地址会拒,所以默认就安全)。如果你要给外面用,上 Caddy 自动 Let’s Encrypt,一分钟搞定 HTTPS。Q:registry 容器本身需不需要 docker restart policy?A:必须加--restartalways。registry 是个无状态服务,挂了直接重启不会有任何数据损失(数据都在挂载的宿主机目录里)。我家里这台跑了一年多,容器重启过 5、6 次,缓存 0 丢失。十二、参考资料Docker Hub usage and rate limits — Docker 官方关于 100 pulls / 6h / IP 限流的说明Distribution: Configuring a registry —proxy.remoteurl、htpasswd、storage字段的官方文档Distribution: Registry as a pull through cache — 官方 mirror recipe,包括 daemon.json 怎么配Docker daemon: insecure-registries — 内网 HTTP 仓库的开关Distribution: Token Authentication Specification — 为什么WWW-Authenticate头如此重要Caddy reverse_proxy: header_up directives — Caddy 透传认证头的官方说明htpasswd man page (Apache HTTP Server) —htpasswd -Bbn参数的含义我之前写的:从 5m14s 到 0.6s 那篇 — registry 镜像源怎么选、怎么排错、cron fallback 怎么做最后:私有仓库不是什么高深的东西,它本质上就是一个 24 MB 的 Go HTTP 服务 一块外挂硬盘。把它想成小区便利店而不是沃尔玛,你会发现整个事情就只是:装个货架、放点存货、门口站个保安。—— 完 ——