把Nginx日志改成JSON输出,并不是简单地把花括号拼进log_format,因为访问日志中的请求路径、User-Agent、Referer等变量经常包含空格、双引号或反斜杠。真正可靠的做法是启用escape=json参数,让Nginx在写盘前完成安全的JSON转义。下面从配置和采集两端展开,说明如何落地一套稳定的结构化日志方案。

一、为什么要把Nginx日志改成JSON
Nginx默认的combined日志格式已经在生产环境使用了很多年,它的字段顺序固定,看起来也很紧凑。但问题在于,这种格式本质上是一种约定俗成的文本协议,字段之间用空格分割,而不同字段内部本身也可能出现空格。例如请求行中的URL参数、User-Agent字符串、Referer地址,都可能让日志分析程序产生歧义。
当这些日志被Filebeat、Logstash或Promtail采集后,通常需要写一长串正则表达式来提取字段。一旦某个字段顺序调整、新增自定义变量,或者某个请求参数里混入了异常字符,解析就可能失败。更麻烦的是,Nginx变量中偶尔会出现双引号或反斜杠,文本日志无法表达这些字符的边界,解析器很难判断哪里是真实字段值。
JSON格式天然带有字段名和类型边界,采集端可以使用现成的JSON解析器直接还原结构,无需再维护脆弱的正则。Nginx从1.11.8版本开始支持escape=json,这让JSON日志的输出变得可行,并且可以安全处理变量值中的特殊字符。
二、基础配置:log_format与escape=json
Nginx的日志格式化由log_format指令完成。要输出JSON,需要在http块中定义一个新的格式,并在access_log中引用它。关键在于加上escape=json参数,这样Nginx会负责把变量内容中的双引号、反斜杠、换行符等转换成合法的JSON转义序列。
下面是一份常见的JSON访问日志配置,字段类型上做了区分:字符串字段用双引号包裹,状态码和请求耗时等数值字段不加引号。
http {
log_format json_combined escape=json
'{"timestamp":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"request_method":"$request_method",'
'"request_uri":"$request_uri",'
'"status":$status,'
'"body_bytes_sent":$body_bytes_sent,'
'"request_time":$request_time,'
'"http_referer":"$http_referer",'
'"http_user_agent":"$http_user_agent",'
'"http_x_forwarded_for":"$http_x_forwarded_for"}';
access_log /var/log/nginx/access.json.log json_combined;
}
重启或重新加载Nginx后,访问站点就能看到类似下面的一行JSON日志。
{"timestamp":"2025-04-09T10:20:30+08:00","remote_addr":"203.0.113.7","request_method":"GET","request_uri":"/api/v1/health?x=1","status":200,"body_bytes_sent":42,"request_time":0.001,"http_referer":"","http_user_agent":"Mozilla/5.0","http_x_forwarded_for":"192.168.1.10"}
这里有一个细节需要注意:并不是所有变量都适合不加引号。status和body_bytes_sent在正常请求中几乎一定存在,因此可以当作数字输出。但如果某个变量可能为空,并且你又没有给它加默认值,那么不带引号就可能产生无效JSON,比如字段后半段直接缺失。这个问题会在下一节详细展开。
三、空值、数值类型与特殊字符处理
Nginx变量为空时,默认输出空字符串。如果变量处在JSON字符串字段中,输出结果就是空字符串,这是合法JSON。但如果变量被当作数值字段输出,例如写成"upstream_response_time":$upstream_response_time,那么当上游没有响应时间时,日志会变成"upstream_response_time":,后面的值直接消失,整行JSON立即失效。
解决空值问题有两种方式。第一种是对可能为空的数值字段统一加引号,让它们成为字符串,再由下游日志平台做类型转换。第二种是使用map指令给变量提供默认值。比如上游响应时间变量为空时,让它输出0,再继续以数字形式写入JSON。
map $upstream_response_time $upstream_rt_json {
default $upstream_response_time;
"" 0;
}
log_format json_upstream escape=json
'{"timestamp":"$time_iso8601",'
'"upstream_addr":"$upstream_addr",'
'"upstream_status":"$upstream_status",'
'"upstream_response_time":$upstream_rt_json}';
除了空值,特殊字符也必须重视。User-Agent中可能出现双引号,URL参数中可能出现反斜杠,Referer中可能出现空格。如果直接拼进JSON而不做转义,这些字符会提前闭合字符串或引入非法转义。启用escape=json后,Nginx会按照JSON标准处理这些内容,生成的日志可以安全交给JSON解析器。
另外,如果使用了map自定义变量,并且这些变量来自于用户请求内容,也应当继续依赖escape=json处理。map本身不会做JSON转义,真正发挥作用的是log_format中的escape参数。
四、条件日志、syslog与Filebeat采集实践
全量JSON日志虽然结构清晰,但高流量站点每天可能产生几十GB数据。通过条件日志可以有选择地记录,例如只记录非2xx和3xx状态码的请求,减少无关数据量。Nginx的access_log指令支持if参数,配合map可以灵活控制。
map $status $loggable {
~^[23] 0;
default 1;
}
access_log /var/log/nginx/access.json.log json_combined if=$loggable;
如果希望把JSON日志直接发送到syslog,由集中式日志系统接收,可以使用syslog输出。这样本机不落盘,适合容器化环境或统一日志管道。
access_log syslog:server=127.0.0.1:514,facility=local7,tag=nginx,severity=info json_combined;
在Filebeat侧,JSON日志可以省去大量解析配置。使用filestream输入并指定ndjson解析器,每一行JSON会被自动展开成字段。示例配置如下。
filebeat.inputs:
- type: filestream
id: nginx-json-access
paths:
- /var/log/nginx/access.json.log
parsers:
- ndjson:
target: ""
overwrite_keys: true
output.elasticsearch:
hosts: ["127.0.0.1:9200"]
这种组合方式省去了Filebeat中复杂的正则处理器,也能避免字段解析顺序变化带来的维护成本。对于已经使用JSON日志的团队,接入Loki、ClickHouse或ELK的难度都会明显降低。
五、性能开销与字段裁剪建议
JSON日志的主要开销来自三方面:escape=json的转义扫描、字符数量增加带来的磁盘写入、以及采集端的解析成本。在高QPS场景下,每个请求多执行一次JSON转义,CPU消耗会略有上升。如果日志量特别大,应该只保留真正有用的字段。
建议根据业务需要裁剪掉高基数且低价值的字段,例如完整的User-Agent和Referer通常只用于营销或安全分析,如果不使用,就不要写入。可以通过定义不同的log_format来区分核心日志和扩展日志,核心日志只记录请求状态、耗时、上游地址等关键字段,扩展日志按需开启。
还可以为access_log配置缓冲写入,减少磁盘IO频率。比如使用buffer=64k和flush=5s,在高并发时能够合并大量小写入。日志轮转仍然建议保留,避免单个JSON文件无限增长。
access_log /var/log/nginx/access.json.log json_combined buffer=64k flush=5s;
总体来看,Nginx输出JSON日志的额外成本可控,换来的是日志解析稳定性和下游系统维护成本的大幅下降。对于正在整合日志管道的团队,直接让Nginx输出结构化JSON,通常比后期在采集端做文本解析更划算。