看起来像 JSON 的 log_format,不一定能可靠地产生结构化事件。nginx 把变量值代入文本,而变量可能包含引号、反斜杠、控制字符,也可能完全没有数据。正确字符串转义与正确字段类型是两个独立要求。用真实解析器检查输出,能发现肉眼查看配置或 nginx -t 成功无法证明的问题,避免上线后才发现部分日志无法进入查询系统。
坏掉的样子
把 User-Agent 原样插入带引号的 JSON 字段,遇到请求头中的引号就会失败。关闭转义不会更安全,只会让请求字符改变外层记录语法。nginx 默认日志转义则有另一种问题:它可能使用十六进制 \xXX 序列,而这不是合法 JSON 字符串转义。日志适合人阅读,不代表它符合另一种数据格式的语法。
加入 escape=json 可以处理 JSON 字符串转义,但不会自动添加引号、提供 null,或把变量转换成数字。对于本地生成的响应,上游耗时可能根本不存在。如果没有定义可选值的表示方式,就直接不加引号输出,字段可能在语法上缺值;即使实际存在的字符全部转义正确,整个 JSON 仍然无效。
缺失值表示也取决于实测版本的转义路径。nginx 1.28.0 默认转义会为找不到的变量写入短横线,JSON 转义则不写变量文本,所以外面的双引号最终成为空字符串。解析器还必须把这种情况与变量本身确实包含短横线、或包含多次上游尝试列表的情况区分开,不能把所有形式都硬当成同一个数字。
快速验证
使用宿主 Python、Docker 和固定 nginx 镜像运行脚本。实测为 Linux arm64 上的 nginx 1.28.0、Docker 29.3.1。它构建包含四种日志格式的临时配置,验证语法,发送两次真实 HTTP 请求,解析生成文件,最后清理自己的容器、镜像标签和网络。请求头都是公开测试字符串,不读取真实凭据或业务日志:
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_text(content)
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()
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'
extra=r'''
log_format default_escape '{"agent":"$http_user_agent","upstream_time":"$upstream_response_time"}';
log_format raw escape=none '{"agent":"$http_user_agent"}';
log_format numeric escape=json '{"upstream_time":$upstream_response_time}';
log_format structured escape=json '{"agent":"$http_user_agent","upstream_time":"$upstream_response_time","status":$status,"request_id":"$request_id"}';
'''
locations=r'''
access_log /tmp/default.json default_escape;
access_log /tmp/raw.json raw;
access_log /tmp/numeric.json numeric;
access_log /tmp/structured.json structured;
location / { return 204; }
'''
with lab(config(locations,extra)) as (container,request):
agent='reader "quoted" '+chr(92)+' path'+chr(9)+'tab'
assert request('/local',headers={'User-Agent':agent})[0]==204
assert request('/local',headers={'User-Agent':'plain'})[0]==204
def lines(path):return docker('exec',container,'cat',path).stdout.splitlines()
wait(lambda:len(lines('/tmp/structured.json'))==2)
default=lines('/tmp/default.json');raw=lines('/tmp/raw.json');numeric=lines('/tmp/numeric.json');structured=list(map(json.loads,lines('/tmp/structured.json')))
for line in [default[0],raw[0]]:
try:json.loads(line)
except json.JSONDecodeError:pass
else:raise AssertionError('non-JSON escaping unexpectedly formed valid JSON')
assert r'\x22' in default[0]
assert json.loads(default[1])=={'agent':'plain','upstream_time':'-'}
assert json.loads(raw[1])=={'agent':'plain'}
for line in numeric:
try:json.loads(line)
except json.JSONDecodeError:pass
else:raise AssertionError('unquoted missing timing unexpectedly formed valid JSON')
assert [row['agent'] for row in structured]==[agent,'plain']
assert all(row['status']==204 and row['upstream_time']=='' for row in structured)
identifiers=[row['request_id'] for row in structured]
assert len(set(identifiers))==2 and all(len(value)==32 and int(value,16)>=0 for value in identifiers)
print('escape=json preserves quotes, backslashes and tabs as valid JSON strings; both default and no escaping break JSON on the same public header')
print('default escaping writes a dash for the absent timing, while JSON escaping writes an empty string in the quoted field')
print('an unavailable upstream timing is empty under JSON escaping, so emitting it unquoted still creates invalid JSON')
print('structured records parse with a numeric status, explicit empty timing string and distinct nginx-generated request IDs')
第一个请求的 User-Agent 包含引号、一个反斜杠和制表符,第二个只包含 plain。两者都得到本地 204 响应,不发生上游请求。这有意把特殊字符串与缺失耗时组合起来,分别检验转义和可选值处理,不只依赖普通字母数字头部。测试输入需要能触及格式边界,才能暴露看起来正常的配置缺陷。
第一个请求的默认转义记录包含 \x22,无法通过 JSON 解析;不转义记录也因原始引号破坏外层字符串而失败。普通请求头对照说明,一个格式可以在常见输入下看似正确,却对另一个有效头部值失败。因此,少量肉眼可读日志不是充分验证,尤其无法覆盖客户端能够控制的字符串内容。
默认格式的普通记录,可以解析出带引号的短横线耗时;JSON 转义的结构化格式则得到空耗时字符串。这些实际观察与指定版本日志源码一致。区别对于采集器很重要:如果它假定 nginx 无论使用哪种格式选项,都用同一个缺失值标记,就可能错误解析或丢弃合法记录。版本与格式属于解析契约的一部分。
故意不给耗时加引号的格式,对两个请求都无效。JSON 转义无法把不存在的变量变成数字,所以冒号后面缺少值。结构化格式给可选耗时加引号,保持记录有效,同时不把缺少测量伪装成耗时零秒。缺失与零具有不同含义,不能为了让图表好看而在采集时悄悄合并。
两条结构化记录都成功解析,解码后的 User-Agent 与输入完全相同,status 是数值 204,可选耗时为空字符串。两个 nginx 生成的请求 ID 是不同的三十二位十六进制字符串。实验核对解析后的含义,而不是只检查一行有没有花括号;这样才能证明转义既保留内容,又没有破坏字段结构。
定义采集器应该接受什么
选择稳定字段名和类型,并说明缺失值。上游测量可能包含多个尝试值,保留字符串有时比强行写成单个数字更准确。如果采集器要转换成结构化尝试列表,应为转换定义明确语法和错误处理,不要把异常记录静默归零。原始记录与转换结果都需要足以定位解析差异的证据。
在采集边界同时验证文本编码与 JSON 转义。本实验覆盖包含特殊 ASCII 字符的普通文本,不声称任意无效字节序列都会变成合法 Unicode JSON。解析失败应可观测,避免事故期间有问题的记录悄悄消失,导致查询结果看似完整,实际已经漏掉最需要分析的一批请求。
只记录必要请求数据,并设置合理保留期和访问权限。正确转义的字段仍可能包含敏感信息或不可信身份声明。日志传送和查询全链路需要单独验证:本地 JSON 有效建立了有用契约,却不证明轮转、采集、传输和保留能够保存每一条事件。格式正确只是可靠日志处理的一部分。
延伸阅读
来源
- nginx HTTP log module,日志格式、转义模式与请求完成记录,已核验 2026-09-12。
- nginx 1.28.0 log implementation,默认与 JSON 转义对缺失变量的不同处理,已核验 2026-09-12。
- nginx core variables,请求标识与 HTTP 请求变量,已核验 2026-09-12。