在排查Nginx回源异常时,不少工程师都有过这样的困惑:明明客户端发过来的请求头是明文,可是在Nginx与上游服务器之间抓包,看到的却是一串无法直接阅读的字节。这其实是HTTP/2协议中HPACK头部压缩在起作用。理解HPACK的字符串编码机制,是看懂回源抓包数据、定位头部丢失或乱码问题的关键。

HPACK到底是什么,为什么抓包看到的是乱码
HTTP/2为了减少请求头在网络上的传输开销,引入了HPACK这一专用的头部压缩格式。它由RFC 7541定义,核心思路是让客户端和服务端各自维护一份索引表,把常见的头部名称和值预先编码成编号,传输时直接引用索引,只有不在表内的内容才需要传输原始字符串。
HPACK的索引表分两部分:静态表和动态表。静态表包含61个最常见的头部,比如user-agent、accept-encoding等都有固定编号。动态表则是一个先进先出的缓冲区,双方在通信过程中把新出现的头部加进去。问题在于,动态表的内容取决于通信双方的协商状态,一旦某一方的理解出现偏差,后续所有头部都会解出错误的内容,这也是HTTP/2协议设计上比较脆弱的地方。
至于乱码的直接原因,是HPACK对字符串值提供了两种编码方式:不压缩的原始字节串,以及Huffman编码后的字节串。如果采用了Huffman编码,抓包工具不做解码时看到的就不是可读文本。比如下面这个十六进制序列,实际表示的是字符串www.ippipp.com:
# HPACK中Huffman编码后的 www.ippipp.com f1e3 c2e5 f23a 6ba0 ab90 f4ff # 首位的0xf1中,最高位是1表示Huffman编码启用 # 剩余7位 0x71 = 113 是Huffman编码后的字节长度
Nginx回源时的头部编码流程
很多人以为Nginx做反向代理时只是简单地转发头部,实际情况要复杂一些。当客户端以HTTP/2连到Nginx,而Nginx通过proxy_pass回源时,Nginx内部会先把客户端的HTTP/2请求完整解码成内部表示,再根据上游连接的协议重新编码。如果上游走的是HTTP/1.1,头会以明文形式重新序列化;如果上游启用了HTTP/2,则会走一遍HPACK编码。
这就带来一个容易忽略的现象:客户端发来的HPACK头部经过Nginx解压后,在回源时可能以完全不同的编码形式呈现。比如客户端用Huffman编码发送的某个自定义头,Nginx回源时可能选择以字面量形式发送,反之亦然。具体采用哪种方式,取决于Nginx所用HTTP/2库的实现策略,Nginx官方的nghttp2库倾向于对短字符串直接用原始字面量,对较长的值启用Huffman编码。
一个典型的回源配置如下,注意proxy_http_version只在HTTP/1.1回源时有意义,走HTTP/2回源需要借助第三方模块或较新版本的支持:
upstream backend {
server 10.0.0.8:8443;
# 长连接复用会影响HPACK动态表的状态
keepalive 32;
}
server {
listen 443 ssl http2;
server_name example.ipipp.com;
location /api/ {
proxy_pass https://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 回源时移除压缩相关的头,避免上游误解码
proxy_set_header Accept-Encoding "";
}
}
动态表漂移与upstream连接复用的坑
HPACK动态表是按连接维护的,这就和Nginx的连接复用机制产生了交互。当keepalive开启后,Nginx会复用到上游的长连接。同一条连接上,之前请求中出现过的头部会被加入动态表,后续请求只需引用索引即可。这在正常情况下是性能优化,但排查问题时会造成困扰:同一个URL的两次请求,抓包里第二次的头部可能短得离谱,因为全部变成了索引引用。
更麻烦的是动态表的上限由SETTINGS_HEADER_TABLE_SIZE帧控制。如果上游服务器调整了这个值,Nginx必须相应地清理动态表并发送动态表更新指令。Nginx社区曾报告过在某些非标准上游实现下,对端没有正确处理表大小更新导致解码错误,表现为回源请求502,同时错误日志中出现upstream sent invalid header之类的记录。遇到这类问题,可以尝试在Nginx侧禁用上游连接复用来做对比测试:
# 临时关闭回源长连接,观察问题是否消失
upstream backend {
server 10.0.0.8:8443;
keepalive off;
}
# 同时开启更详细的日志便于定位
# error_log /var/log/nginx/error.log debug;
此外要注意,HTTP/2协议规范严格禁止在上游返回中出现连接级别的头部错误后继续复用该连接。Nginx在遇到HPACK解码错误时会立即关闭这条upstream连接,如果日志中频繁出现连接重置记录,多半是头部编码协商出了问题,而不是网络层面的故障。
抓包分析与日志排查的实用方法
排查回源头部问题,最直接的手段是用tcpdump配合Wireshark分析。Wireshark内置了HPACK解码器,但它需要看到完整的HTTP/2协商过程才能正确维护动态表状态。如果只抓了中间一段流量,动态表的引用无法还原,显示的就是无法理解的索引值。正确的做法是从TLS握手开始抓包,并在Wireshark中配置SSLKEYLOGFILE来解密流量:
# 让curl或浏览器导出TLS会话密钥 export SSLKEYLOGFILE=/tmp/keys.log # 在Nginx服务器上抓取回源流量 tcpdump -i eth0 -w /tmp/upstream.pcap host 10.0.0.8 and port 8443 # 在Wireshark中: 编辑 -> 首选项 -> Protocols -> TLS # 配置 (Pre)-Master-Secret log filename 为 /tmp/keys.log
在Nginx日志层面,$upstream_http_*变量只能拿到上游响应头,请求头是否正确编码转发则需要借助debug级别的错误日志。开启error_log ... debug后,nghttp2会输出每一帧的收发记录,包括HEADERS帧的解码结果,这是判断HPACK问题最有效的信息来源。另外,log_format中的$http2变量可以确认客户端侧是否真的协商到了HTTP/2,避免在协议版本上误判。
总结一下排查思路:先确认客户端到Nginx、Nginx到上游各自使用的协议版本,再用完整抓包加密钥的方式还原HPACK内容,最后通过对比Nginx解码后的内部头部与上游实际收到的头部,定位问题发生在解压阶段还是重新编码阶段。掌握这套方法后,HPACK的乱码不再是黑盒,而是可以逐字节分析的正常协议行为。