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 使用和客户端延迟,应作为不同观察记录。协议版本或编码器名称本身,都不足以证明页面变快。

延伸阅读

来源