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 aioquicaioquic 测试成功时会看到类似输出:
QUIC negotiated h3
connected
H3 headers [(':status', '200'), ...]
ALPN h3其中 QUIC negotiated h3 和 ALPN 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。