Caddy 反向代理 HTTP/3 排查记录
太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处

Caddy 反向代理 HTTP/3 排查记录

问题背景

站点使用 Caddy 作为反向代理,容器镜像是 caddy:2.11.4-alpine。Caddyfile 配置大致如下:

example.com, www.example.com {
    encode gzip zstd
    reverse_proxy http://backend:12001
}

Docker 对外暴露了 TCP 80、TCP 443 和 UDP 443:

ports:
  - "0.0.0.0:80:80/tcp"
  - "0.0.0.0:443:443/tcp"
  - "0.0.0.0:443:443/udp"

浏览器访问站点时,响应头里已经能看到:

alt-svc: h3=":443"; ma=2592000
via: 1.1 Caddy

但浏览器开发者工具的 Protocol 仍然显示 h2,不是 h3

先区分两件事

Alt-Svc: h3=":443" 只说明服务端通过当前 HTTP 响应告诉浏览器:“这个来源可以尝试使用 HTTP/3”。

它不等于当前这一次请求已经使用了 HTTP/3。典型过程是:

浏览器先用 TCP/TLS 访问站点
收到 Alt-Svc: h3=":443"
后续尝试用 UDP 443 建立 QUIC 连接
如果 QUIC 失败,就回退到 HTTP/2

所以看到 alt-svc 和看到 h2 并不矛盾。真正要确认的是 UDP 443 上的 QUIC 是否能连通。

服务端证据

先看 Docker 端口映射:

docker port server_caddy 443/udp
docker inspect server_caddy --format '{{json .NetworkSettings.Ports}}'

关键结果是 UDP 443 已经映射到宿主机:

0.0.0.0:443
"443/udp":[{"HostIp":"0.0.0.0","HostPort":"443"}]

再看 Caddy 启动日志:

docker logs server_caddy 2>&1 | grep -Ei 'http3|quic|udp|h3|listen'

关键日志:

server running {"protocols": ["h1", "h2", "h3"]}
enabling HTTP/3 listener {"addr": ":443"}

这说明 Caddy 进程已经启用了 HTTP/3 listener。此时继续改 reverse_proxy 没有意义,反向代理到后端的协议和浏览器到 Caddy 的 HTTP/3 不是同一段链路。

关键异常

宿主机上查看 UDP 监听:

ss -lunp | grep ':443'

能看到 Docker proxy 正在监听 UDP 443:

UNCONN 0 0 0.0.0.0:443 0.0.0.0:* users:(("docker-proxy",...))

再看防火墙和 Docker NAT 计数:

ufw status
iptables -S | grep 443
nft list ruleset | grep 443

关键现象是 TCP 443 有计数,但 UDP 443 计数为 0:

tcp dport 443 counter packets 514 bytes 29647
udp dport 443 counter packets 0 bytes 0

这表示 TCP 请求能进来,所以 HTTPS 和 HTTP/2 正常;但没有任何 UDP 443 包进入 Docker 的 DNAT 规则。浏览器即使看到 Alt-Svc,尝试 QUIC 时也无法到达 Caddy,于是回退到 HTTP/2。

客户端验证

从外部环境测试 TCP/TLS,ALPN 协商结果是 h2

TCP ALPN h2
authorized true

用 HTTP/2 请求 HEAD,也能看到 Caddy 返回 HTTP/3 广告:

HTTP2 ALPN h2
':status': 200
'alt-svc': 'h3=":443"; ma=2592000'

修复 UDP 443 之前,用 QUIC/HTTP3 客户端直连会超时:

ConnectionError
TimeoutError

这和服务端 udp dport 443 counter packets 0 bytes 0 对得上:不是 Caddy 没启用,而是 UDP 路径没进来。

使用的测试工具

客户端侧测试用了三类工具,它们验证的是不同层面的事实。

第一类是 Node.js 的 tls 模块,用来测试 TCP/TLS 上的 ALPN 协商:

node -e "const tls=require('tls'); const s=tls.connect({host:'example.com',port:443,servername:'example.com',ALPNProtocols:['h3','h2','http/1.1']},()=>{console.log('TCP ALPN',s.alpnProtocol); s.end();});"

这个测试只能说明 TCP/TLS 链路协商到什么协议。结果是 h2 时,不代表 HTTP/3 没启用,因为 HTTP/3 不走 TCP/TLS,而是走 UDP/QUIC。

第二类是 Node.js 的 http2 模块,用来确认 HTTP/2 响应头里是否有 Alt-Svc

node -e "const http2=require('http2'); const client=http2.connect('https://example.com'); client.on('connect',()=>{console.log('HTTP2 ALPN',client.socket.alpnProtocol); const req=client.request({':method':'HEAD',':path':'/'}); req.on('response',headers=>{console.log(headers);}); req.on('end',()=>client.close()); req.end();});"

这个测试确认的是 Caddy 是否通过 HTTP/2 响应告知浏览器可以尝试 HTTP/3。

第三类是 Python 的 aioquic,用来真正发起 QUIC/HTTP3 请求:

python -m pip install --quiet --target $env:TEMP\codex-aioquic aioquic

aioquic 测试成功时会看到类似输出:

QUIC negotiated h3
connected
H3 headers [(':status', '200'), ...]
ALPN h3

其中 QUIC negotiated h3ALPN h3 才是 HTTP/3 真正连通的客户端证据。

修复

修复点是放通公网 UDP 443。

常见遗漏位置包括:

  • 云厂商安全组只放行了 TCP 443,没有放行 UDP 443。
  • 路由器、NAT、面板防火墙只转发了 TCP 443。
  • 站点前面有代理或 CDN,但没有把 UDP 443 转发到源站。
  • 客户端网络、代理或 VPN 屏蔽了 QUIC。

这次放通 UDP 443 后,再从外部环境测试 QUIC/HTTP3,结果变为:

QUIC negotiated h3
connected
H3 headers [(':status', '200'), ...]
ALPN h3
QUIC terminated 0

这说明 HTTP/3 已经真正连通。

关于 UDP buffer 日志

Caddy 启动时还出现了这条日志:

failed to sufficiently increase receive buffer size

这是 QUIC UDP buffer 的性能提示,不是这次“不显示 h3”的主因。真正的主因是 UDP 443 根本没有包进来。

不过可以顺手优化:

cat >/etc/sysctl.d/99-quic.conf <<'EOF'
net.core.rmem_max=7500000
net.core.wmem_max=7500000
EOF

sysctl --system
docker restart server_caddy

验证方法

服务端可以观察 UDP 443 计数是否增长:

nft list ruleset | grep 'udp dport 443'

也可以抓包确认:

tcpdump -ni any udp port 443

浏览器侧打开开发者工具 Network 面板,显示 Protocol 列。如果 UDP 443 已经通,后续请求应能看到 h3

如果刚修好仍然显示 h2,先排除浏览器缓存和网络策略:

  • 关闭标签页后重新打开。
  • 使用隐身窗口访问。
  • 重启浏览器。
  • 确认没有走会禁用 QUIC 的代理或 VPN。

结论

这次问题不是 Caddyfile 的 reverse_proxy 写法,也不是 Next.js 响应头问题。

完整证据链是:

Caddy 日志显示已启用 h3
Docker 已映射 443/udp
HTTP 响应返回 Alt-Svc: h3=":443"
但防火墙/NAT 计数显示 UDP 443 为 0
外部 QUIC 客户端连接超时
放通 UDP 443 后 QUIC negotiated h3

所以最终结论是:HTTP/3 需要 UDP 443。只开放 TCP 443 时,HTTPS 和 HTTP/2 会正常,但 HTTP/3/QUIC 不会工作,浏览器会自动回退到 HTTP/2。