单二进制 + SQLite 搭私有镜像仓库:stashhub 兼做 Docker Hub 拉取缓存
stashhub 是一个自托管镜像仓库,当前版本 v0.1,MIT 协议,定位是 Harbor / Zot 的轻量替代。项目地址:https://www.stash-hub.one/(官网未直接给出仓库地址)。
它的形态是一个 Go 二进制,内嵌 distribution v3,内嵌 React 19 前端,状态全部落在 SQLite 里,docker compose up 就能跑。协议层兼容 OCI Distribution v2,客户端可用 Docker / Podman / Skopeo;存储侧支持 SQLite 与 S3、R2、MinIO、B2、DO Spaces 等 6 种以上后端。部署不需要单独腾一台服务器,一台 VPS、Raspberry Pi 或办公电脑都够,从零到能 push 大约 30 秒,常驻内存约 60MB。
主要能力可以归成几块:
- 单二进制零配置:一个 Go 二进制,内嵌 distribution v3 与 React 19 前端,SQLite 持久化全部状态。
- OCI 协议原生:没有代理层、没有协议翻译,
docker push/pull/login直连标准 v2 协议。 - 拉取代理缓存:可以配成 Docker daemon 的 registry-mirror,支持多上游路由、Manifest TTL、LRU sweeper、tag 白名单淘汰,这些都在管理面板可调。
- 鉴权、会话、审计:bcrypt + 旋转刷新 JWT;每个用户的 PAT 可以当 docker 密码用;有实时会话列表;每次 push/pull/delete 都有审计行落库。
- 管理后台:用户、存储、GC、保留策略 dry-run、SMTP 测试、镜像加速策略都在 Web UI,改完即时生效。
- 存储:默认 filesystem 驱动,改配置即可切到 S3/R2/MinIO/B2/DO Spaces,同一份二进制、同一套 UX。
拉取代理的工作流程大致是:客户端 docker pull alpine:3.19 → stashhub 的 manifest 命中(ttl 6h)→ cache layer 命中 sha256:e07f… → 未命中则从 registry-1.docker.io 流式回源 → 回源数据写入 filesystem/S3/R2 → 返回客户端,下次走本地命中。
Web UI 用 React 19 + Tailwind 4 + Radix UI,中英 i18n,暗色/亮色两套主题。
官方给了一张与 Harbor、Zot 的对比表,标注为 fresh deploy、1 用户、约 100 tags 的实测数据,仅供参考:
| 能力 | stashhub | Harbor | Zot |
|---|---|---|---|
| 单二进制部署 | 是 | 否,多服务 | 是 |
| 内置 Web UI | 是,React 19 | 是 | 极简 |
| 拉取代理缓存 | 多上游 | proxy 项目 | 基础 |
| 管理后台 | 用户·GC·保留·SMTP | 企业级 | 无 |
| 审计日志 | 用户 + 仓库 | 是 | 无 |
| 部署时间 | ~30s | ~1h(helm) | ~2min |
| 内存占用 | ~60MB | ~1GB+ | ~80MB |
| 气质 | 小团队·自托管 | 企业·银行 | CLI 优先 |
需要留意这是 v0.1 早期版本,生产采用前请自行评估。
快速开始
单二进制 + SQLite 默认就能跑。docker compose up 之后浏览器打开 http://localhost:8080,按引导设置第一个 admin。
docker-compose.yml:
services:
stashhub:
image: stashhub/stashhub:latest
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./data:/var/lib/stashhub
environment:
# 必填:外部访问地址(决定 mirror endpoint 的 scheme)
EXTERNAL_URL: https://stash-hub.one
# 必填:JWT secret(≥ 32 字节随机串)
JWT_SECRET: "$(openssl rand -hex 32)"
生产 compose 的启动方式:
$ docker compose -f docker-compose.prod.yml up -d
$ docker compose logs stashhub | grep "bootstrap admin"
level=info bootstrap admin = admin / k3-7gZ-9pX-2vQ
level=info → open http://localhost:8080
第一次启动访问 /,Web UI 引导创建第一个 admin 账号。登录后进入 /admin,所有运行期配置都在这里,改完即时生效,不需要重启。右上角创建第一个仓库,然后用 docker login + docker push 验证链路:
$ docker compose up -d
$ docker login stash-hub.one
Username: alice / Password: ****
Login Succeeded
$ docker push stash-hub.one/alice/api:v1.4.0
The push refers to repository [stash-hub.one/alice/api]
v1.4.0: digest: sha256:9b3a…7c2e size: 1789
$ curl -s $REG/api/v1/repositories/alice/api/tags | jq -r '.[].name'
v1.4.0 / v1.3.2 / latest
要注意 EXTERNAL_URL 会影响到生成的镜像 endpoint、setup-mirror.sh 里的 URL 以及 CORS 行为,必须写最终用户访问的协议 + 域名,例如 https://stash-hub.one。
存储后端
默认是 filesystem,所有 blob/manifest 落到 DATA_DIR 下的子目录,适合单机或测试环境。需要弹性扩容时切到对象存储,同一份二进制,热重载不重启。
S3 / R2 / MinIO / B2 / Spaces 的入口在管理后台 → System Settings → Storage,切到 s3 后填这些字段:
region:S3 区域;MinIO/R2 填auto或留空。bucket:桶名。access_key/secret_key:secret_key在 PATCH 时传空字符串表示保留原值。region_endpoint:非 AWS S3 必填,例如https://.r2.cloudflarestorage.com。force_path_style:MinIO / 旧版 S3 需要true;R2 / AWS 留false。root_directory:桶内前缀,多环境共享一个桶时建议设置,例如prod/。
S3 模式下还有一个本地 L1 缓存:把热 layer 缓存到本机磁盘,命中后跳过远端往返。Storage 页底部的 Local Cache 卡片可以调上限与 LRU 天数,0 表示关闭。
Storage 页还有 Probe 按钮(test before save),不写库、不切 manager,按「假如保存」的配置跑一次 put/stat/get/delete。
镜像加速
stashhub 可以同时作为私有 registry 和 pull-through cache。做法是配一个独立域名给镜像加速用,docker daemon 把它当 mirror,第一次拉穿透回源,之后在局域网内命中。
1)配镜像加速域名
管理后台 → Site Settings → mirror_domain,填一个不同于 registry_hostname 的裸域名,例如 mirror.stash-hub.one。两个域名都解析到同一台 stashhub。
这里为什么要两个域名:镜像加速是把 Docker Hub / GHCR 的内容反向代理到自己域下,如果和私有 registry 共用 host,会出现命名冲突,攻击者可以推送同名仓库覆盖上游内容。结构性隔离能直接杜绝这类攻击。
2)加上游
Admin → Mirror → Upstreams。一个上游 = 一个 path prefix + remote URL + 可选凭据,客户端访问 /v2/[prefix]/[image] 时按 prefix 路由。
alias:展示名,如dockerhub。path_prefix:URL 前缀,匹配^[a-z0-9][a-z0-9_-]{0,62}$。remote_url:上游 v2 端点,如https://registry-1.docker.io。username/password:可选,AES-256-GCM 加密落盘,密钥派生自JWT_SECRET。is_default_mirror:客户端不写 prefix 时落到哪个上游,最多 1 个。
3)客户端 daemon 配置
每台需要加速的机器上执行:
curl -fsSL https://stash-hub.one/setup-mirror.sh | sudo bash
脚本会自动检测 Docker Desktop / Linux+systemd / Colima / OrbStack,备份原 daemon.json 后把 mirror endpoint 合并进 registry-mirrors。
缓存策略
Admin → Mirror → Policy,四维淘汰字段:
lru_days:未访问超过 N 天淘汰。max_cache_bytes:总容量上限。min_hits_to_cache:首次拉不缓存,到 N 次命中才落盘。tag_whitelist_regex:只缓存符合正则的 tag,屏蔽 nightly/dev 之类的噪音。
Manifest TTL(manifest_ttl_hours)参考 Artifactory:到期后 HEAD 上游比对 digest,没变就只刷时间戳,开销接近零。
运行期配置
几乎所有可调项都在 Web 管理面板,改一个字段立即生效。
- Site Settings:
site_name、registry_hostname(私有 registry 域名)、mirror_domain(镜像加速专用域名,必须 ≠registry_hostname,留空表示不开启)、public_signup_enabled、default_visibility(public/private)。 - SMTP:配置完成后有 Send Test Email 按钮,向当前 admin 的注册邮箱发测试邮件,收件人锁死为操作者自己,避免账号被攻陷后变成 SMTP 跳板。
- Retention:全局默认 + 仓库级 override,GC 提供 dry-run。
- Trusted Proxies / Real IP:站在反向代理后面时配置 CIDR 列表与 header 名,让审计日志记录真实客户端 IP。空列表等于关闭。
管理 API
所有 Web UI 操作都走 /api/v1,可以直接对接 CI 或自动化脚本。鉴权用 session cookie 或 API token。
GET /api/v1/config:公开信息,站点名、注册开关、镜像加速 endpoint。GET /api/v1/admin/settings:完整运行期配置,secret 字段以*_set布尔位返回。PATCH /api/v1/admin/settings:局部更新;nil 字段不变;Storage 改动触发热重载,失败自动回滚。POST /api/v1/admin/storage/test:用提交的 patch 跑 put/stat/get/delete,不写库、不切 manager。GET /api/v1/admin/upstreams:列出镜像加速上游。POST / PATCH / DELETE:上游 CRUD,完成后自动 reload proxy 集合。POST /api/v1/admin/mirror/sweep:手动触发缓存清理 sweeper。GET /api/v1/admin/mirror/stats:按上游聚合的实时命中率与容量占用。
完整 OpenAPI schema 见仓库 internal/webapi/ 目录。
常见问题
忘了 admin 密码?
在服务端执行:
./stashhub admin reset-password
会生成一次性令牌,走标准重置流程。
docker login 的密码栏填什么?
推荐填 PAT,不要用登录密码。Docker 26+ 默认走 containerd image store,containerd 的凭据路径与 moby legacy 不一致;PAT 是后端校验的明文 token,pull 走 containerd resolver 时认证更稳。PAT 前缀是 sthb_。
怎么生成 PAT?
Web UI 右上角头像 → 设置 → Tokens → 新建。可以选过期时间,不填表示永不过期。token 只在创建那一次完整显示,离开页面后只能看到前几位。当前实现里 PAT 不分 scope,一个 token 等同该账号全部权限,按主密码的强度来托管。
公有仓库需要 login 才能 pull 吗?
不需要。visibility=public 的仓库允许匿名 pull,docker pull host/repo:tag 直接生效。但 push、delete、查看协作者始终强制鉴权。
怎么共享仓库?
改可见性(public)或加协作者:reader 只 pull,writer 可 push + pull,admin 含改设置和删仓库。owner 默认隐含最高权限。
怎么删 tag 或仓库?
Web UI 仓库页的 tag 行操作菜单可以删 tag;仓库设置最底部「危险区」可以整库删除。CI/脚本用:
DELETE /api/v1/repositories/{owner}/{name}/tags/{tag}
DELETE /api/v1/repositories/{owner}/{name}
注意元数据立即消失,但底层 blob 占用要等垃圾回收才释放——手动触发 Admin → Mirror → Sweep,或等定时任务。
registry 必须 HTTPS 吗?
是。Docker daemon 默认按 HTTPS 跟 registry 握手,裸 HTTP 部署在 docker login 或 GitLab CI 的 docker push 时会撞到:
Error response from daemon: Get "https://your.host/v2/": http: server gave HTTP response to HTTPS client
两种修法:
方案 A · HTTPS 反代(推荐长期):在 stashhub 前挂 Caddy/nginx/Traefik 做 TLS 终端,EXTERNAL_URL 改成 https://...。Caddy 一行:
your.host { reverse_proxy stashhub:8080 }
内网自签 CA(mkcert/cfssl)把 CA 装到 /etc/docker/certs.d//ca.crt。
方案 B · insecure-registries(应急,仅内网):每台跑 docker login 的机器(包括 GitLab Runner)改 /etc/docker/daemon.json:
{"insecure-registries": ["your-stashhub-host:port"]}
然后 systemctl restart docker。host 字段不带 http:// / https:// 前缀。Runner 跑 dind 时 flag 要传给 dind service:
services:
- name: docker:dind
command: ["--insecure-registry=your-stashhub-host:port"]
这条路径下 PAT / 登录密码走明文,只在测试或隔离环境用。
反代后 docker push 大镜像报 413?
nginx 默认 client_max_body_size 是 1m,blob 上传几乎一定超。server/location 块改成 client_max_body_size 0;(不限)或至少 4G。Caddy 默认不限;Traefik 看 buffering.maxRequestBodyBytes。
从 filesystem 切到 S3,旧镜像怎么办?
旧数据不会自动迁移。Storage 切换是热重载新 manager,旧目录里的 blob/manifest 仍在磁盘上,但 registry 不再读,等于变孤儿。要保留就先迁后切:
aws s3 sync /registry/ s3:////
保持同名前缀,确认新桶能列到对应路径后再切换。反向同理。Storage 页的 Probe 只测连通性,不验证内容是否搬过去。
mirror 缓存策略默认值合理吗?
默认值对多数团队够用:lru_days=30、max_cache_bytes=50 GiB、min_hits_to_cache=1(首次拉就缓存)、manifest_ttl_hours=2。需要调的场景:
- 磁盘紧 → 调小
max_cache_bytes与lru_days。 - 上游 tag 频繁变动(
:latest、:edge)→ 把manifest_ttl_hours缩短到 1 或更短。 - 不想被 nightly/dev 噪音塞满 → 用
tag_whitelist_regex只放行关心的 tag。 - 只想缓存真正热的镜像 → 把
min_hits_to_cache提到 2~3,首次拉穿透不落盘。
为什么 docker pull 仍走外网?
检查三件事:
docker info | grep -A1 'Registry Mirrors'是否包含你的 mirror endpoint;mirror_domain的 DNS 是否解析到 stashhub;EXTERNAL_URL的 scheme 与边缘 TLS 终端是否一致——错配会导致 docker daemon 静默回退到 docker.io。
© stashhub · MIT