Nginx 要同时开 Brotli 和 HTTP/3,官方镜像为什么不够用?一次源码编译与实测记录

9次阅读
没有评论

想把站点的 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 的配置参数 | 证书续期显示成功,浏览器为什么还是旧证书?

正文完
 0
Ticifer
版权声明:本站原创文章,由 Ticifer 于2026-11-12发表,共计6903字。
转载说明:除特殊说明外本站文章皆由CC-4.0协议发布,转载请注明出处。
评论(没有评论)