想把站点的 Brotli 和 HTTP/3 一起开起来,最省事的做法是找一个已经编好的镜像。但真正落地时会发现两个需求撞在同一个地方:官方 nginx 镜像没有 Brotli,HTTP/3 又卡在 TLS 库的版本上。这篇记录我为什么最后改成自己从源码编译,以及编出来的镜像怎么用、怎么验证、在哪里会踩坑。
官方镜像差在哪两件事
不是官方镜像做得不好,而是它必须保持通用,所以有两件事它永远不会替你做。
1. Brotli 不是 nginx 的内置模块
nginx 自带的是 gzip 和 gzip_static。Brotli 由 Google 的 ngx_brotli 提供,属于第三方模块,必须在编译时用 --add-module 静态加进去,或者编成动态模块。官方的 nginx:stable 镜像里没有它,装包也装不到——Debian/Ubuntu 源里的 libnginx-mod-brotli 只对发行版自己编的那份 nginx 有效,跟官方镜像里的二进制对不上。
2. HTTP/3 不是「打开一个开关」就能用
从 1.25.0 开始 HTTP/3 已经进主线,但打开它的前提是 TLS 库本身支持 QUIC。也就是说,nginx 编译时链接的那份 OpenSSL 必须足够新(3.2 及以上,或者用 BoringSSL、quictls),否则 listen 443 quic 直接配置报错。nginx:stable 基于 Debian,它带的 OpenSSL 版本跟着发行版走,往往达不到要求。
所以想同时要 Brotli 和 HTTP/3,除了自己编,剩下的选择就是用别人的镜像。而用别人的镜像有三个隐性成本:你不知道它的编译参数、不知道它什么时候停更、也不知道某个滚动标签下一次拉下来会变成什么。
我的取舍:两个 TLS 版本,同一套组件
自己编之后,第一个要定的是 TLS 库。BoringSSL 和 OpenSSL 都能支撑 HTTP/3,但取舍不同,所以两个版本都出了,用标签区分。
| 对比项 | BoringSSL 版(latest / boringssl) | OpenSSL 版(openssl) |
|---|---|---|
| TLS 实现 | BoringSSL,固定提交 | OpenSSL 3.5.9 LTS |
| 镜像大小(未压缩) | 94.1 MB | 98.4 MB |
| HTTP/3 / QUIC | 支持,实测通过 | 支持,实测通过 |
| OCSP stapling | 不支持(nginx 官方文档已说明 ssl_stapling 与 BoringSSL 不兼容) | 支持 |
| 生态与排错资料 | 偏少,报错信息不如 OpenSSL 直观 | 多,遇到问题好搜 |
| 适合谁 | 追求握手性能、能接受没有 OCSP stapling | 对外站点、要用 OCSP stapling、希望排错容易 |
我的建议很直接:面向公网的正式站点先用 OpenSSL 版。不是 BoringSSL 不好,而是 OCSP stapling 在证书链不完整或者客户端要求严格的场景下能省掉一次额外请求,而这个功能 BoringSSL 版给不了。等确认 HTTP/3 跑顺了,再考虑换 BoringSSL 版做对比测试。
镜像里到底装了什么
Nginx 1.30.6 stable,PCRE2 10.49 带 JIT,TLS 与 Brotli 都是静态链接。除 TLS 实现不同,两个版本的组件和参数完全一致。
| 组件 | 状态 | 用途 |
|---|---|---|
| HTTP/2、HTTP/3 / QUIC | 已编译,实测通过 | http2 on、listen 443 quic |
| ngx_brotli | 已编译 | brotli on、brotli_static on |
| gzip / gzip_static | 已编译 | 动态压缩默认开启 |
| ngx-fancyindex | 已编译 | 目录索引,fancyindex on |
| substitutions filter | 已编译 | subs_filter |
| ngx_cache_purge | 已编译 + 兼容补丁 | proxy_cache_purge,HIT/PURGE 已验证 |
| ngx_slowfs_cache | 已编译 + 兼容补丁 | 慢速文件系统缓存 |
| stub_status、realip、FLV、MP4、addition | 已编译 | 按站点开启 |
| jemalloc | 已链接 | 内存分配 |
| envsubst 模板 | 支持 | /etc/nginx/templates/*.template |
| Lua / njs / ModSecurity | 未集成 | — |
| Stream / Mail / image_filter / XSLT / GeoIP | 未启用 | — |
两点值得单独说。一是 ngx_slowfs_cache 和 ngx_cache_purge 打了兼容补丁:这两个模块的老代码直接对着新版本编译会挂,前者是主配置初始化的问题,后者是私有结构体对不上,所以补丁跟着 nginx 版本一起维护,不是随便下载个 tar 包就能编过。二是最终镜像不含源码、编译器和构建缓存,运行时是独立的 Ubuntu 24.04 环境,保留了 shell、CA 证书、curl、envsubst 和官方入口脚本,跟官方镜像的使用习惯基本一致。
镜像在哪拿:Docker Hub、GitHub 与离线包
两个版本都已经推在 Docker Hub 上,直接拉就行:
docker pull ticifer/nginx-brotli-quic:openssl # OpenSSL 3.5.9,建议生产先用这个
docker pull ticifer/nginx-brotli-quic:latest # 当前指向 BoringSSL 版
Hub 地址是 hub.docker.com/r/ticifer/nginx-brotli-quic,滚动标签和历史版本的更新时间都在页面上。构建脚本、Dockerfile、编译参数、兼容补丁和组件锁定文件在 GitHub:github.com/ticifer/nginx-brotli-quic。想知道某个标签到底用什么参数编出来的,翻这两处就能对上,不用猜。
如果服务器不能直连 Docker Hub——内网隔离、机房不出网,或者拉大镜像总是断在半路——GitHub 的 Release 提供了离线镜像包。每次发布都会附带两个 docker save 格式的 .tar.gz,靠文件名区分 BoringSSL 版和 OpenSSL 版,单包 38–40 MB,不需要登录、不需要 token,浏览器直接下。
# 以 OpenSSL 版为例,文件名里的版本号会随发布变化
curl -LO https://github.com/ticifer/nginx-brotli-quic/releases/download/v1.30.6-r42774724387/nginx-brotli-quic-1.30.6-openssl3.5.9-20261110-r42774724387.tar.gz
docker load -i nginx-brotli-quic-1.30.6-openssl3.5.9-20261110-r42774724387.tar.gz
同一个 Release 里还有 SHA256SUMS 和记录标签、digest、组件版本的 components.json。整包搬进内网之前先 sha256sum -c SHA256SUMS 校验一遍,比传完之后发现镜像起不来省事得多。Release 列表在 github.com/ticifer/nginx-brotli-quic/releases。
离线包不是另外攒的备份,它和 Hub 上的镜像是同一次构建的产物,components.json 里记着对应的 digest。两条路拿到的是同一个东西,只是分发方式不同。
下面的示例统一用 latest 演示,正式环境把标签换成固定版本即可,配置完全一样。
三步跑起来
第一步:只测 HTTP
docker run -d --name nginx-quic --restart unless-stopped
--ulimit nofile=8192:8192
-p 8080:80
ticifer/nginx-brotli-quic:latest
访问 http://localhost:8080 能看到欢迎页就说明镜像本身没问题。默认只启用 HTTP,HTTPS 必须自己配证书——这点和旧版本镜像不同,下面会专门说。
第二步:上 HTTPS 和 HTTP/3
准备自己的证书,然后写一个站点配置:
server {
listen 443 ssl;
listen 443 quic reuseport;
server_name example.com;
http2 on;
http3 on;
http3_hq on;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
quic_retry on;
add_header Alt-Svc 'h3=":443"; ma=86400' always;
location / {
root /usr/share/nginx/html;
index index.html;
}
}
三个端口都要映射,UDP 那条不能漏:
docker run -d --name nginx-quic --restart unless-stopped
--ulimit nofile=8192:8192
-p 80:80/tcp -p 443:443/tcp -p 443:443/udp
-v "$PWD/sites:/etc/nginx/conf.d:ro"
-v "$PWD/certs:/etc/nginx/certs:ro"
-v "$PWD/html:/usr/share/nginx/html:ro"
ticifer/nginx-brotli-quic:latest
--ulimit nofile 别省。镜像里 worker_rlimit_nofile 是 8192,容器不给对应的 nofile 上限,连接一多就会报 worker 打不开文件。
第三步:外部端口不是 443 的情况
比如把容器的 443 映射到宿主的 8443,那么 Alt-Svc 必须跟着改成 h3=":8443"。这一步忘了不会报错,但浏览器会按 443 去连 UDP,结果就是你看着配置都对、HTTP/3 却永远不生效。
四条命令验证它真的生效
配置写对了不等于功能生效。我固定用这四步确认,缺一个都不算过。
1. 看编译进去的模块
docker exec nginx-quic nginx -V 2>&1 | tr ' ' 'n' | grep -E 'http_v3|brotli|openssl|boringssl'
能看到 --with-http_v3_module、brotli 相关字样,并确认 TLS 库是哪一个。这一步只证明「编进去了」,不证明「跑起来了」。
2. 用 curl 打一次 HTTP/3
curl -I --http3-only https://example.com/
注意两点:curl 本身要 8.x 且编译时带了 HTTP/3,普通发行版的 curl 大多没有,会提示不认识这个参数;--http3-only 表示不做回退,能出 200 就说明 UDP 443 通、QUIC 握手成功。如果只想看能不能协商,用 --http3。
3. 验证 Brotli 协商
curl -sI -H 'Accept-Encoding: br' https://example.com/ | grep -i content-encoding
返回 content-encoding: br 说明动态压缩生效。如果有预压缩的 .br 文件,容器里 brotli_static on 会优先直接发文件,比实时压缩省 CPU。
4. 用日志确认走的是 h3
nginx 提供 $http3 变量,HTTP/3 请求时值为 h3,否则为空。加一个带它的 log_format,抓几条访问日志就能看出比例,比猜靠谱:
log_format quic '$remote_addr "$request" $status proto=$http3 ua="$http_user_agent"';
浏览器端也可以直接看:开发者工具的 Network 面板把 Protocol 列打开,显示 h3 就对了。
五个最容易踩的坑
坑一:listen 443 http3 是旧写法。 现在必须写 listen 443 quic。写旧语法在 1.30 上直接启动失败,而很多老教程和 ChatGPT 给出的答案还是 http3。
坑二:UDP 443 没放通。 这是最常见的一种「看起来成功」。TCP 443 通了,站点能访问,Alt-Svc 也发出去了,但客户端连不上 UDP,浏览器回退到 HTTP/2。表现是站点正常、HTTP/3 永远是 0%,查配置查不出来。宿主机防火墙、云厂商安全组、Docker 的 -p 443:443/udp,三处都要看。
坑三:reuseport 在同一地址端口只能声明一次。 多个站点都写 listen 443 quic reuseport 会冲突。做法是在一个 server 里声明 reuseport,其余站点写 listen 443 quic 即可。
坑四:brotli_static 的语义变了。 旧镜像默认是 always,现在默认 on——只在客户端明确声明支持 br 时才发预压缩文件。另外新版不再默认压缩 JPEG、PNG 这类本来就已经压过的类型,少做无用功。
坑五:镜像不再内置示例证书。 旧版本为了开箱能用塞了一对公共示例证书和私钥,新版去掉了。不挂载自己的证书,443 起不来,这是有意的改动——带着公共私钥的镜像本来就不该出现在生产环境。
版本锁定与自动构建
自己维护镜像最怕两件事:忘了上游更新,和某次更新把线上搞挂。所以这里的做法是三层锁:
第一层,滚动标签用于测试。 latest、boringssl、openssl 三个标签会随构建移动,适合先拉下来验证,不适合直接上生产。
第二层,固定标签用于生产。 形如 1.30.6-openssl3.5.9-20261110-r42774724387,把 nginx 版本、TLS 版本、日期和构建编号都写死在标签里,什么时候编的一目了然。
第三层,digest 用于精确锁定。 需要绝对确定内容时用 @sha256:...,因为标签是可能被重新推送的,digest 不会。
上游版本也不是靠人盯。仓库里 locks/components.json 固定了 ngx_brotli、fancyindex、substitutions filter、cache purge、slowfs 各自的提交,构建脚本按这个文件拉代码;GitHub Actions 每周一北京时间 09:00 检查一次上游是否有新版本,有就构建两个版本、跑完整套功能测试,全部通过才发布到 Hub,同时生成一个带两个离线镜像包的 Release。跨大版本或者入口脚本有改动时,流程会先提一个待审 PR,不会自动合。
构建和发布是绑在一起的:只有两个版本都通过功能测试,才会推 Hub 并生成带离线包的 Release,所以下载到的包永远是「测试通过的那一版」,不会出现半成品。
什么情况下别用这个镜像
说清楚边界比吹功能有用。以下四类需求,这个镜像不合适:
需要 Lua、njs 或 ModSecurity 的,没集成,只能自己再编一层;需要四层代理(stream)的,模块没启用;需要 arm64 的,目前只出 linux/amd64,要在 ARM 机器上跑得改 scripts/build.sh 的平台参数自己重编;需要厂商 SLA 或者合规审计背书的,自建镜像给不了这个。
但边界不是死的。如果你的需求正好落在上面几条里,到 GitHub 仓库提个 issue,或者发邮件到 admin@ticifer.com,说清楚用途和要的版本,我按实际需求酌情加进后续构建——njs 和 arm64 这两样的编译代价都不算大,缺的往往只是「确实有人在用」这个理由。已经在用这个镜像、想加别的模块,同样可以提。写清场景比写「求支持」有用得多,我能判断的是要不要为它长期维护一套构建。
和 2023 年那篇旧文的差异
站上早前那篇《支持 quic+brotli 组件的 Docker 镜像》写的是 1.23.4 / 1.27.2 时代的镜像,里面的参数现在不能直接套用,有三处必须改:
| 旧文写法 | 现在的正确写法 | 不改的后果 |
|---|---|---|
listen 443 http3 reuseport; |
listen 443 quic reuseport; |
nginx 启动失败 |
docker push ticifer/nginx-brotli-quic |
docker pull ... |
命令直接报错,方向反了 |
alt-svc 'h3-29=":443", h3-28=":443"' |
alt-svc 'h3=":443"; ma=86400' |
草案版本号早已废弃,客户端不认 |
另外旧标签(1.23.4、1.27.2 那一批)的组件和编译参数都按历史版本处理,官方支持表只对 1.30.6 的两个版本负责。要升级就换固定标签,不要指望旧标签跟着更新。
小结
Brotli 和 HTTP/3 都不是「装个插件」的事:一个要编译期加模块,一个要 TLS 库支持 QUIC。自己编一次,把这些约束固化进 Dockerfile 和 CI,之后每次升级只是换一个固定标签,比每次重新查教程省事得多。
如果你也在做类似的镜像维护,建议从两件事开始:把组件版本固定成文件而不是写在脚本里,以及把「HTTP/3 真的跑起来了」做成可复现的验证步骤——这两件事决定了半年后你还敢不敢升级。
延伸阅读:
支持 quic+brotli 组件的 Docker 镜像(2023 版,含旧参数对照) | Nginx 开启 TLSv1.3 的配置参数 | 证书续期显示成功,浏览器为什么还是旧证书?




