/Docker 镜像不求人:从 0 到 1 在自己家里搭一个 13GB 的私有仓库/
TL;DR:我家那台小服务器上跑着一个 registry:2 镜像仓库,已经攒了 13 GB 缓存、95 个仓库。冷启动一个 alpine 不到 1 秒,完全不走公网 Docker Hub。这篇文章把真实在生产环境跑的部署脚本、登录鉴权流程、反向代理最容易踩的 4 个坑、htpasswd 密码文件怎么管理,一次性摊开。
1
这篇文章解决什么问题
家里有几台机器:NAS、PVE 集群、Mac mini。每台都需要拉 docker 镜像。各自直接 docker pull 走公网,受 Docker Hub 100 pulls/6h 限流,邻居跑 CI 你也跟着挂。
解决方案:在局域网里搞一个统一的镜像仓库,所有机器从它拉,它去公网拉一次缓存下来,第二次任何机器再拉直接走内网。
这个东西就是 Docker 官方提供的 registry:2 镜像,一个 24 MB 的 Go 程序,跑起来就是一个 HTTP 服务。
2
这版为公众号做了什么调整
移除了手工标题编号,让 KNB 转换器自动加; 把 SVG 示意图替换为手机端友好的 PNG 图卡,字号加大; 给长段落加了 ### 子标题,方便手机端阅读; 删除了原文中的图注说明文字,改由图卡自带标题承担; 文末加了抄作业清单。
3
问题背景:为什么要在家里搞私有仓库
3.1
每台机器各自拉镜像的痛点
把时间倒回 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 服务,完全够用。
听起来很简单对吧?事实上也确实不复杂,只是坑比较多。下面把我这几年踩过的坑和我现在的最终方案一起讲清楚。
4
问题表现:你以为装好了,实际半哑
4.1
4 个半哑问题
我一开始直接 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 跑通、再让它持久化、最后做反向代理"的顺序讲,这样每一步你都只面对一个变量。
5
问题根因:你看到的 401 可能不是密码错
5.1
Docker v2 认证流程的 4 步握手
我直接抛结论: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 会直接拒绝,根本不发请求。
6
解决问题:10 分钟跑通一个完整仓库
6.1
步骤 1:准备长期保存的目录
下面是我现在生产环境在用的部署脚本,经过半年稳定运行。一共 4 个步骤,每一步都加上了"为什么要这么写"的注释。
6.2
步骤 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,就相当于给容器装了一块"外挂硬盘",容器怎么折腾,硬盘上的数据都不会丢。
6.3
步骤 2:生成 htpasswd 密码文件
# 一次性生成,只跑一次就行
docker run --rm \
--entrypoint htpasswd \
httpd:2.4 -Bbn admin 'MyS3cretPass!' \
> /docker/registry/auth/htpasswd
跑完之后你的密码文件长这样:
admin:$2y$05$kxqfBjMmhK6eOZ8m9eRkgeW7FZmkjj8OQ8iCpzJZ1Vb5lpWWGbH1e
千万不要用
-m(md5)!-m是历史悠久的 md5 加密,非常容易被彩虹表撞。Docker daemon 实际上能接受 md5、sha1、bcrypt,但 md5 的弱点不在兼容性,而在你已经 2026 年了,还在用 1995 年的加密,说服力不够。
6.4
步骤 3:写一份 config.yml
cat > /docker/registry-dockerhub/config.yml <<'YAML'
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
proxy.remoteurl是什么?类比:它就是便利店的"进货渠道"。客人要一瓶水,货架上没有,你就去https://hub1.nat.tf那个批发商那里进货,放进货架,下次客人再来就有货了。注意:https://hub1.nat.tf在我这里工作得很好(详见我之前写的 从 5m14s 到 0.6s 那篇 ( /post/docker-registry-mirror-rebuild-2026/ )),在你那里可能需要换成别的源,实测一次再说。
6.5
步骤 4:起容器
docker run -d \
--name registry-dockerhub \
--restart=always \
-p 8082: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 \
-e REGISTRY_AUTH=htpasswd \
-e REGISTRY_AUTH_HTPASSWD_REALM="Registry Realm" \
-e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
registry:2
` 这三个环境变量是 htpasswd 鉴权的全部开关,少一个都不行。*
跑完之后验证一下:
$ docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'
NAMES IMAGE PORTS
registry-dockerhub registry:2 0.0.0.0:8082->5000/tcp
registry-ghcr registry:2 0.0.0.0:8083->5000/tcp
$ curl -sS http://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。
7
客户端怎么用:从一台机器到整个集群
7.1
单台客户端(一次性操作)
单台客户端(一次性操作):
# 1. 登录(在内网里用 IP 也可以,docker daemon 接受内网 HTTP)
docker login 192.168.x.x:8082
# Username: admin
# Password: ********
# Login Succeeded
# 2. 打 tag + push
docker tag hello-world:latest 192.168.x.x:8082/test/hello-world:v1
docker push 192.168.x.x:8082/test/hello-world:v1
# 3. 另一台机器 pull
docker pull 192.168.x.x:8082/test/hello-world:v1
7.2
全集群配置(一次配置,所有机器生效)
在每台 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是日常进出。
8
缓存到底是怎么回事
8.1
内容寻址:为什么 13GB 能装 95 个仓库
跑了一段时间之后,你可以看一下磁盘:
$ du -sh /docker/registry-cache/*
13G /docker/registry-cache/dockerhub
3.0G /docker/registry-cache/ghcr
13GB 是真实的吗?是。但同样的 13GB 物理容量,可能承载了上百个镜像仓库。
我们看一下 blob 在磁盘上怎么存的:
$ find /docker/registry-cache/dockerhub/docker/registry/v2/blobs/sha256/ \
-type f -printf '%s %p\n' | sort -rn | head -5
934308001 .../sha256/de/de4a0c57...cfff/data # alpine 根文件系统 ~890 MB
753000000 .../sha256/13/13d4f7c2...a51d/data
540000000 .../sha256/f5/f5fc7c45.../data
534000000 .../sha256/89/894f7c54.../data
422000000 .../sha256/9c/9c8a7d83.../data
只会存一份*。*
"按 sha256 寻址" 用图书馆来打比方:学校图书馆有 100 万本书。如果按"位置 1、位置 2、位置 3..."编号,完全一样的两本《五年高考三年模拟》会放在不同的位置,占两份地方。但如果按"这本书第一句话的 SHA256"编号,所有完全相同的书永远只能放一个位置,自动去重。Docker registry 就是这个原理:任何一份内容在磁盘上只存 1 份,无论多少个镜像引用它。
下面是我这台 registry 里 library/alpine 镜像缓存的 tag 数:
$ curl -sS http://localhost:8082/v2/library/alpine/tags/list | jq '.tags | length'
219
整个仓库里有 95 个独立仓库名,从 library/alpine 到 zyplayer/zyplayer-doc,但物理上只占 13 GB:
9
反向代理:最容易踩坑的 4 个配置点
9.1
Caddy 最小配置
如果你想让这个仓库对外可访问(家人从外面拉镜像、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;
}
}
9.2
最容易出错的 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 握手。
10
3 种部署形态:你属于哪一种
10.1
对比表
| 维度 | 家庭 / 实验室 | 小团队 | 公司 |
|---|---|---|---|
| 客户端数 | 1 ~ 5 | 10 ~ 50 | 几百 ~ 几千 |
| 反代 | 不要 | Caddy / Nginx + 自签 CA | Nginx / Envoy + 商业 CA |
| 鉴权 | htpasswd 单用户 | htpasswd + 读写分组 | LDAP / OIDC / OAuth |
| 存储 | 本机 filesystem | 本机 filesystem + 备份 | S3 / OSS / MinIO |
| 监控 | du -sh 看一下 | Prometheus exporter | Grafana + 告警 + SLA |
| 高可用 | 不需要,坏了就重起 | 单点 + 备份 | 多副本 + 跨地域 |
| SLA | 没有 | 没有 | 99.9% |
我家用的方案就是最左边那一档:单容器 + filesystem + htpasswd + crontab 备份,跑了一年没出问题。没必要为了"家庭内网"这个场景过度设计。
11
实战排错:docker login 一直 unauthorized
11.1
标准排错流程
这是我见过的最常见的问题,也是网上最容易被错误回答的问题。下面是我的标准排错流程。
# Step 1: 看服务端是不是真的在监听
$ ss -tlnp | grep 8082
LISTEN 0 4096 0.0.0.0:8082 0.0.0.0:* users:(("docker-proxy",pid=226477,fd=8))
# Step 2: 不带认证 curl 一下,看 401 + WWW-Authenticate
$ curl -i http://localhost:8082/v2/
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
Docker-Distribution-Api-Version: registry/2.0
Www-Authenticate: Basic realm="Registry Realm"
X-Content-Type-Options: nosniff
Content-Length: 79
{"errors":[{"code":"UNAUTHORIZED","message":"authentication required","detail":null}]}
# Step 3: 带认证再 curl 一次,应该回 200
$ curl -i -u admin:'MyS3cretPass!' http://localhost:8082/v2/
HTTP/1.1 200 OK
Docker-Distribution-Api-Version: registry/2.0
X-Content-Type-Options: nosniff
Content-Length: 2
{}
# Step 4: 客户端 docker login
$ rm ~/.docker/config.json # 先把旧凭据清掉,排除缓存干扰
$ docker login -u admin -p 'MyS3cretPass!' 192.168.x.x:8082
Login Succeeded
11.2
4 个常见错误信息对照表
| 错误信息 | 真正的原因 |
|---|---|
no such host | 域名解析失败 / 写错 IP |
connection refused | 服务端没起来 / 端口错 |
tls: failed to verify certificate | HTTPS 但证书不被信任 |
unauthorized: authentication required | 服务端起来了,但 WWW-Authenticate 头丢了 / 密码错 |
12
备份和清理:放了一年磁盘会不会爆
12.1
默认 GC 策略
会,默认 168 小时(7 天)清理一次没访问的 blob。但实测下来 13 GB 一年没爆过磁盘,因为:
内部去重让"内容增长"和"磁盘增长"严重脱钩; 大部分常用镜像(nginx、redis、alpine、postgres)的 layer 已经被拉过无数次,后续新版本只增加 diff,实际增量很小; 默认 scheduler 每 24 小时跑一次 GC,把 7 天没访问的 blob 删掉。
如果你想自己控制保留策略,加一行:
storage:
filesystem:
rootdirectory: /var/lib/registry
delete:
enabled: true
cache:
blobdescriptor: inmemory
然后用 registry 自带的 GC API:
# 标记 7 天没访问的 blob 为待删
$ curl -X POST -u admin:'MyS3cretPass!' \
http://localhost:8082/v2/_catalog # 先 warm up
$ curl -X POST -u admin:'MyS3cretPass!' \
"http://localhost:8082/v2/_catalog?n=1000"
# 看 GC 状态
$ curl -u admin:'MyS3cretPass!' \
http://localhost:8082/debug/health | jq .
备份就更简单:整个 /docker/registry-cache 目录直接 tar 就行。
# 每周日凌晨 3 点打包
0 3 * * 0 tar czf /backup/registry-$(date +\%F).tgz /docker/registry-cache
类比:GC = 超市每周下架过期 7 天的酸奶。备份 = 给超市拍一张"今日货架照",出事了能照着摆回去。你不用每天管它,但别等真丢了才想起来没备份。
13
抄作业:TL;DR 操作清单
mkdir -p /docker/registry-{dockerhub,cache/dockerhub,auth}docker run --rm httpd:2.4 htpasswd -Bbn admin '密码' > /docker/registry/auth/htpasswd写 config.yml,配好 proxy.remoteurldocker run -d --name registry-dockerhub --restart=always -p 8082:5000 ... registry:2客户端 docker login+docker push/pull每周 tar czf /backup/registry-$(date +%F).tgz /docker/registry-cache
14
Q&A
14.1
部署与选型
Q:用 registry:2 跟用 Harbor 有什么区别?
A:Harbor 是"registry + UI + 镜像扫描 + 复制策略 + LDAP"的一站式平台,适合公司。家用用 Harbor 杀鸡用牛刀,光资源占用就要 4 GB 内存起步,而且 Harbor 自己也是用 registry:2 做底层。我家用 24 MB 的 registry:2 完全够,简单是复杂的解药。
14.2
鉴权方案
Q:我不想用 htpasswd,能不能用公司 LDAP?
A:可以,registry:2 支持 token-based auth,写一个小 HTTP 服务,接收 registry 发过来的 ?service=...&scope=...,查 LDAP 后回 token。Harbor / Distribution 官方都给了 sample。但最简单的方式仍然是 htpasswd,多用户管理麻烦的话就用不同的 htpasswd 文件。
14.3
排错
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="..." 这一行。
14.4
缓存与更新
Q:缓存会不会让镜像不新?比如 alpine:3.20 发布了我怎么办?
A:不会。registry:2 在每次 pull 时,都会先发一个 HEAD /v2/<name>/manifests/<tag> 给 upstream,问它"这个 tag 现在指向哪个 digest?"。如果 upstream 返回的 digest 不等于本地缓存的 digest,registry 会主动去 upstream 拉新的 manifest + 新的 blob。整个过程对客户端完全无感,客户端只会看到"我又拉到最新版了"。
14.5
迁移
Q:如何把现有的本地镜像批量推到这个私有仓库?
A:写一个小脚本:
#!/bin/bash
DEST="192.168.x.x:8082"
docker images --format '{{.Repository}}:{{.Tag}}' | grep -v '<none>' | while read img; do
new=$(echo "$img" | sed "s|^|$DEST/|")
docker tag "$img" "$new"
docker push "$new" >/dev/null 2>&1 && echo "pushed: $new"
done
但强烈不推荐把所有镜像都推到私有仓库 — 私有仓库主要是"缓存 + 内部业务镜像",公开镜像还是直接拉上游更省事。
14.6
安全
Q:insecure-registries 和 HTTPS 我到底选哪个?
A:内网用 http + insecure-registries,生产用 https。insecure-registries 这个开关就是给你内网用的,合规风险为 0(docker daemon 自己把 http 卡在非 loopback 地址会拒,所以默认就安全)。如果你要给外面用,上 Caddy 自动 Let's Encrypt,一分钟搞定 HTTPS。
14.7
运维
Q:registry 容器本身需不需要 docker restart policy?
A:必须加 --restart=always。registry 是个无状态服务,挂了直接重启不会有任何数据损失(数据都在挂载的宿主机目录里)。我家里这台跑了一年多,容器重启过 5、6 次,缓存 0 丢失。
15
参考资料
Docker Hub usage and rate limits ( https://docs.docker.com/docker-hub/usage/pulls/ ) — Docker 官方关于 100 pulls / 6h / IP 限流的说明 Distribution: Configuring a registry ( https://distribution.github.io/distribution/about/configuration/ ) — proxy.remoteurl、htpasswd、storage字段的官方文档Distribution: Registry as a pull through cache ( https://distribution.github.io/distribution/recipes/mirror/ ) — 官方 mirror recipe,包括 daemon.json 怎么配 Docker daemon: insecure-registries ( https://docs.docker.com/engine/reference/commandline/dockerd/#insecure-registries ) — 内网 HTTP 仓库的开关 Distribution: Token Authentication Specification ( https://distribution.github.io/distribution/spec/auth/ ) — 为什么 WWW-Authenticate头如此重要Caddy reverse_proxy: header_up directives ( https://caddyserver.com/docs/caddyfile/directives/reverse_proxy#header_up ) — Caddy 透传认证头的官方说明 htpasswd man page (Apache HTTP Server) ( https://httpd.apache.org/docs/2.4/programs/htpasswd.html ) — htpasswd -Bbn参数的含义我之前写的:从 5m14s 到 0.6s 那篇 ( /post/docker-registry-mirror-rebuild-2026/ ) — registry 镜像源怎么选、怎么排错、cron fallback 怎么做
最后:私有仓库不是什么高深的东西,它本质上就是一个 24 MB 的 Go HTTP 服务 + 一块外挂硬盘。把它想成"小区便利店"而不是"沃尔玛",你会发现整个事情就只是:装个货架、放点存货、门口站个保安。
—— 完 ——
评论 (0)