HTTP/2 改变 HTTP 传输分帧和多路复用方式;gzip 与 Brotli 编码的是表示数据。启用 HTTP/2 不会自动压缩响应正文,启用 gzip 也不能证明客户端协商出了 HTTP/2。HTTP/2 的头部压缩又是另一种机制。可靠检查应分别记录协商协议、响应 Content-Encoding、传输正文字节数,以及实际解码后得到的内容,不能用其中一项代替其他结果。
坏掉的样子
发送 Accept-Encoding: gzip, br,然后把结果保存成 .br,并不能证明使用了 Brotli。服务器可能选择 gzip,也可能发送仍被请求接受的原始表示。真实编码由响应头说明。根据请求列表或输出文件名猜测,会导致使用错误解码器,也可能让报告声称获得了实际上根本没有发生的压缩结果。文件名只是本地命名,不是协议证据。
Brotli 支持还属于部署能力,并不是安装 nginx 后自然具备的功能。Brotli 过滤和预压缩文件处理由独立模块提供,需要针对实际二进制与配置确认可用性、构建兼容性。添加不受支持的指令可能让配置验证失败,而客户端声明接受 Brotli 不会替服务器安装功能。检查模块时,应以当前程序行为为准,不能从另一台机器的配置推断。
客户端自动解码会制造另一种测量陷阱。curl --compressed 收到较小编码正文后,可以保存成较大的解码文件。把这个文件大小与原始压缩输出比较,实际比较的是不同对象。反过来,只使用 --http2 却不检查协商结果,也可能把允许的协议回落误当成 HTTP/2 已经成功验证。命令选项表达请求意图,结果字段才说明本次发生了什么。
快速验证
将脚本保存为 check-h2-coding.py,准备 Docker、Python、OpenSSL 和支持 HTTP/2 的 curl 后运行。记录客户端为带 nghttp2 的 curl 8.7.1,服务器为 Linux arm64 上的 nginx 1.28.0。脚本生成短期本地证书,并通过 --cacert 显式信任该证书,只发布宿主回环端口,不需要连接真实公网服务:
from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import http.client,json,socket,subprocess,time,uuid
NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609'
PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True):
result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120)
if check:assert result.returncode==0,result.stderr
return result
def wait(predicate):
deadline=time.monotonic()+15
while time.monotonic()<deadline:
try:
if predicate():return
except OSError:pass
time.sleep(.1)
raise AssertionError('fixture did not become ready')
@contextmanager
def lab(configuration,files=None,backend=None):
name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend']
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration)
for path,content in (files or {}).items():
target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True)
target.write_bytes(content if isinstance(content,bytes) else content.encode())
docker('network','create',name)
if backend is not None:
(root/'backend.py').write_text(backend)
(root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n')
docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root))
docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1])
wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0)
(root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n')
docker('build','--network','none','-t',tags[0],str(root))
docker('run','--rm','--network',name,tags[0],'nginx','-t')
docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',tags[0])
details=json.loads(docker('inspect',containers[0]).stdout)[0]
binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort'])
def ready():
connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True
wait(ready)
def request(path,method='GET',headers=None,body=None):
connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5)
try:
connection.request(method,path,body=body,headers=headers or {})
response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read()
finally:connection.close()
request.port=port
yield containers[0],request
finally:
docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False)
def config(locations,extra=''):
return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n'
import gzip,hashlib
payload=(b'{"message":"'+b'HTTP content coding and transport are separate. '*1000+b'"}\n')
with TemporaryDirectory(prefix='xbcatm-local-tls-') as directory:
root=Path(directory);cert=root/'cert.pem';key=root/'key.pem'
result=subprocess.run(['openssl','req','-x509','-newkey','ec','-pkeyopt','ec_paramgen_curve:prime256v1',
'-nodes','-keyout',str(key),'-out',str(cert),'-days','1','-subj','/CN=localhost',
'-addext','subjectAltName=DNS:localhost,IP:127.0.0.1'],capture_output=True,text=True)
assert result.returncode==0,result.stderr
configuration=config('location = /app.json {} location / { return 404; }',
'gzip on; gzip_types application/json; gzip_vary on; types { application/json json; }')
configuration=configuration.replace('listen 8080;', 'listen 8080 ssl; http2 on; ssl_certificate /srv/cert.pem; ssl_certificate_key /srv/key.pem;')
files={'cert.pem':cert.read_bytes(),'key.pem':key.read_bytes(),'app.json':payload,
'brotli.conf':'events {} http { brotli on; server { listen 8080; } }'}
with lab(configuration,files) as (proxy,request):
probe=docker('exec',proxy,'nginx','-t','-c','/srv/brotli.conf',check=False)
assert probe.returncode!=0 and 'unknown directive "brotli"' in probe.stderr
results={'brotli_directive_supported':False}
for name,version,accept,decode in [
('h2_gzip','2','gzip, br',False),('h1_gzip','1.1','gzip',False),
('h2_br_only','2','br',False),('h2_identity','2','identity',False),
('h2_decoded','2','gzip',True)]:
output=root/(name+'.body');header=root/(name+'.headers')
args=['curl','--silent','--show-error','--fail','--noproxy','*','--cacert',str(cert),
'--http2' if version=='2' else '--http1.1','--header','Accept-Encoding: '+accept,
'--output',str(output),'--dump-header',str(header),'--write-out','%{http_version}\n%{size_download}\n',
f'https://127.0.0.1:{request.port}/app.json']
if decode:args.insert(1,'--compressed')
run=subprocess.run(args,capture_output=True,text=True,timeout=15);assert run.returncode==0,run.stderr
actual,download=run.stdout.strip().splitlines();assert actual==version,(name,run.stdout)
headers={k.lower():v.strip() for line in header.read_text().splitlines() if ':' in line for k,v in [line.split(':',1)]}
body=output.read_bytes();encoded='gzip' in accept
assert headers.get('content-encoding')==('gzip' if encoded else None)
assert (gzip.decompress(body) if encoded and not decode else body)==payload
assert headers['vary']=='Accept-Encoding'
results[name]={'protocol':actual,'content_encoding':headers.get('content-encoding','identity'),
'download_body_bytes':int(download),'saved_file_bytes':len(body)}
assert results['h2_gzip']['download_body_bytes']<len(payload)
assert results['h2_decoded']['saved_file_bytes']==len(payload)
rejected={}
for path in ['/key.pem','/cert.pem','/brotli.conf']:
run=subprocess.run(['curl','--silent','--show-error','--noproxy','*','--cacert',str(cert),
'--http2','--output',str(root/'rejected.body'),'--write-out','%{http_code}',
f'https://127.0.0.1:{request.port}'+path],capture_output=True,text=True,timeout=15)
assert run.returncode==0 and run.stdout=='404',(path,run.stdout,run.stderr)
rejected[path]=404
results['non_asset_statuses']=rejected
results['original_sha256']=hashlib.sha256(payload).hexdigest()
print(json.dumps(results,sort_keys=True))
证书的主题备用名称包含回环 IP,因此本地 HTTPS 请求可以同时验证信任和请求身份。实验没有关闭证书校验。路由只允许提供 JSON 资源,请求密钥、证书和探测配置都必须返回 404。临时密钥只存在于可丢弃测试文件和镜像中,不输出密钥内容;结束时清理自己的容器、镜像标签、网络和临时文件。这些设置用于建立可验证的本地 TLS 条件,不代表替生产域名完成了证书部署。
合成 JSON 表示共有 48,015 字节。在实际协商出的 HTTP/2 上,请求同时声明 gzip 和 Brotli,收到 Content-Encoding: gzip,记录到正文为 381 字节。解码能逐字节还原原内容。显式使用 HTTP/1.1 时也得到 gzip。两组结果说明正文编码可以跨这两种已测传输使用,而不是只有选中 HTTP/2 才会发生压缩。
HTTP/2 的 identity 请求得到 48,015 字节未编码正文。只声明 br 的请求在此配置中也得到 identity,因为该请求头仍接受原始表示。实验另外用包含 brotli on 的配置执行检查,并要求 nginx 以未知指令拒绝它。这两个观察共同说明:本次被测配置没有交付 Brotli,不能把客户端声明和服务器实际能力混为一谈。
使用 --compressed 时,curl 仍报告下载正文为 381 字节,但保存文件包含原来的 48,015 字节解码内容。脚本同时核对两种计量和实际内容。下载正文数字不包含其他传输开销,它不是完整抓包,也不是该连接所有 TLS 与 HTTP/2 分帧字节的总量。比较压缩收益时,应让计量边界保持一致。
先确认能力,再比较性能
示例在 TLS 监听上显式开启 HTTP/2,并检查 curl 返回的真实协议值。它在本地提供静态内容,因此不对上游应用采用什么传输协议作结论。反向代理可以与客户端协商一种协议,又用另一种协议连接后端;如果这个区别影响容量或行为,应分别检查两条连接,不能把入口协议直接推广到整个调用链。
部署 Brotli 时,先建立实际模块或预压缩文件交付路径,再要求得到真实 br 响应并成功 Brotli 解码,之后才讨论节省量。这里提供的是负向能力检查,不是 Brotli 性能基准。安装模块或重新构建 nginx 属于另一项配置变更,需要针对兼容性和实际交付重新验证,而不是把未经执行的安装步骤写成已有结果。
最后,要用有代表性的内容和一致计量比较效果。高度重复的测试正文证明编码与解码正确,不提供通用压缩比,也不证明页面一定加速多少。通过 Vary 表达的缓存选择、编码正文字节、解码正确性、CPU 使用和客户端延迟,应作为不同观察记录。协议版本或编码器名称本身,都不足以证明页面变快。
延伸阅读
来源
- nginx gzip module,响应内容编码及 Vary 配置,已核验 2026-09-12。
- nginx HTTP/2 module,HTTP/2 启用与 TLS ALPN 要求,已核验 2026-09-12。
- Google ngx_brotli module README,独立 Brotli filter、static 模块及构建要求,已核验 2026-09-12。
- RFC 9110 HTTP Semantics,内容编码协商及 identity 的可接受性,已核验 2026-09-12。