Contents

Nginx 日志格式改造:从 Combined 到结构化 JSON(Nginx 系列第 2 期)

Nginx 系列:第 1 期:源码编译安装 · 第 3 期:FancyIndex 文件服务器


1. 为什么需要 JSON 格式日志?

1.1 Nginx 默认日志长什么样

Nginx 默认的 combined 日志格式:

192.168.1.100 - - [16/Jul/2026:14:30:00 +0800] "GET /api/users HTTP/1.1" 200 1234 "https://example.com/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"

这条日志包含 7 个字段,用空格和引号分隔。看起来挺整齐——直到你发现 User-Agent 里有个空格:

... "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0"

那个 Chrome/120.0 不在引号里——因为 Nginx log 中 $http_user_agent 没有被引号包裹时,浏览器名称里的空格会破坏字段边界。

或者 Referer 里包含查询参数:

... "https://example.com/search?q=iphone 16 pro max&sort=price"

q=iphone 16 pro max 中的空格又一次破坏了正则解析。

1.2 不改造的前后果

你用 Logstash 写了复杂的 Grok 正则来解析:

grok {
  match => { "message" => '%{IPORHOST:remote_addr} - %{DATA:remote_user} \[%{HTTPDATE:time_local}\] "%{WORD:method} %{DATA:request} HTTP/%{NUMBER:http_version}" %{INT:status} %{INT:body_bytes_sent} "%{DATA:http_referer}" "%{DATA:http_user_agent}"' }
}

Grok 解析本身是 CPU 密集操作——对于每秒几万条日志的 Nginx 实例,解析开销会在 Logstash 或 Fluentd 上形成瓶颈。

改成 JSON 之后:

  • Logstash:不需要 Grok,直接 json { source => "message" } 解析,CPU 开销降低 80% 以上(Grok 是 Logstash 中最耗 CPU 的过滤器之一——Elastic 官方文档 明确指出 Grok 是第一个要优化的目标)
  • Loki:原生支持 JSON parsing pipeline
  • ClickHouse:直接映射到 JSONEachRow 格式,无需预处理
一句话总结
JSON 格式日志把字段解析的负担从下游(日志平台)转移到了源端(Nginx)。Nginx 序列化 JSON 时已经知道了每个字段的边界——这是从源头消除歧义的最优解。

2. 实验环境

项目 规格
操作系统 Debian 13 (Trixie)
CPU 2 vCPU (Intel Xeon E5-2680 v4 @ 2.40 GHz)
内存 1 GB
Nginx 版本 1.30.3 Stable(源码编译安装)
编译路径 /usr/sbin/nginx/etc/nginx/nginx.conf
日志路径 /var/log/nginx/access.log/var/log/nginx/error.log
编译方式 参见 Nginx 系列第 1 期:源码编译安装

如果你还没完成 Nginx 1.30.3 的源码编译,请先阅读第 1 期。本文中的所有配置都基于第 1 期的编译路径和目录结构。


3. JSON 日志格式配置

3.1 基本配置

在你的 nginx.confhttp 块中添加:

log_format json escape=json '{'
    '"time_local":"$time_local",'
    '"remote_addr":"$remote_addr",'
    '"remote_user":"$remote_user",'
    '"request":"$request",'
    '"status":$status,'
    '"body_bytes_sent":$body_bytes_sent,'
    '"http_referer":"$http_referer",'
    '"http_user_agent":"$http_user_agent",'
    '"http_x_forwarded_for":"$http_x_forwarded_for",'
    '"request_time":$request_time,'
    '"upstream_response_time":"$upstream_response_time"'
'}';

access_log /var/log/nginx/access.log json;

关键点:

  • escape=json:告诉 Nginx 对所有动态变量内容做 JSON 转义——所有 "\、换行符等会自动转换为合法的 JSON 字符串。没有这个参数,包含特殊字符的 User-Agent 或 Referer 会破坏整个 JSON 结构。(Nginx 1.11.8+ 支持,2016 年引入)
  • 数字类型不加引号$status$body_bytes_sent 是整数,不加双引号——日志平台可以直接将其解析为数字,方便做聚合查询(AVG(request_time)MAX(body_bytes_sent) 等)
  • 字符串类型加引号$request$http_user_agent 等可能包含空格和特殊字符,必须用 JSON 字符串包裹

配置后执行平滑重载:

nginx -t && systemctl reload nginx

3.2 输出示例

重载后,发送一条请求:

curl -s -o /dev/null -w "%{http_code}" http://localhost/

查看新日志:

tail -1 /var/log/nginx/access.log | python3 -m json.tool

输出:

{
    "time_local": "16/Jul/2026:14:30:01 +0800",
    "remote_addr": "127.0.0.1",
    "remote_user": "-",
    "request": "GET / HTTP/1.1",
    "status": 200,
    "body_bytes_sent": 612,
    "http_referer": "-",
    "http_user_agent": "curl/8.12.1",
    "http_x_forwarded_for": "-",
    "request_time": 0.000,
    "upstream_response_time": "-"
}

4. 逐字段详细解读

这一节不是贴文档——每个字段的说明是基于实际运维场景的。只有理解字段的含义和使用场景,才知道哪些字段必须保留、哪些可以裁剪。

4.1 $time_local — 本地时间

"time_local": "16/Jul/2026:14:30:01 +0800"
属性
类型 字符串
格式 DD/Mon/YYYY:HH:MM:SS +TZ(Apache 兼容格式)
对应 HTTP 头 无(服务器本地生成)
精度 秒级
用途 请求到达 Nginx 的时间(本地时区)
time_local vs time_iso8601

$time_local 不是 ISO 8601 格式。 它的格式是 16/Jul/2026:14:30:01 +0800——这是 Apache HTTP Server 的日志时间格式(自 1995 年以来未变)。

如果你希望日志平台自动解析时间字段省去额外配置,使用 $time_iso8601 替代

'"time_iso8601":"$time_iso8601",'

输出:"2026-07-16T14:30:01+08:00"——这是标准 ISO 8601,Elasticsearch、ClickHouse、Promtail 都能零配置自动解析。

为什么本文保留了 $time_local?因为它是 Nginx 社区的标准——当你在论坛问 Nginx 日志问题时,所有人期望看到的都是这种格式。实际生产中,我强烈建议用 $time_iso8601 替代

4.2 $remote_addr — 客户端 IP

"remote_addr": "203.0.113.42"
属性
类型 字符串(IPv4 或 IPv6)
来源 TCP 连接的对端地址
用途 用户地理位置分析、IP 黑白名单、访问频次统计

如果 Nginx 前面有 CDN 或反向代理(Cloudflare、阿里云 CDN、腾讯云 CDN),$remote_addr代理的 IP,不是真实用户的 IP。这时需要配合 $http_x_forwarded_for 使用——见下文 4.8 节。

4.3 $remote_user — HTTP Basic Auth 用户名

"remote_user": "admin"
属性
类型 字符串
来源 HTTP Authorization: Basic <base64> 头部解析
正常值 用户名(如 admin
空值 -(表示没有 HTTP Basic Auth)

绝大多数面向公众的网站不使用 HTTP Basic Auth,所以这个字段 99% 的时间里是 -。保留它只是为了与 Nginx Combined 日志格式保持兼容——方便从旧日志格式迁移时字段对齐。

4.4 $request — 原始请求行

"request": "GET /api/users?page=1&limit=20 HTTP/1.1"
属性
类型 字符串
格式 METHOD /path?query HTTP/VERSION
用途 原始请求的完整记录,不丢失任何信息

这是 Nginx 日志中信息密度最高的字段——一条字段包含 HTTP 方法、请求路径、查询参数和协议版本。

如果你需要单独统计 HTTP 方法分布或 API 路径热度,可以使用更细粒度的变量:

变量 输出示例 用途
$request_method GET 统计各 HTTP 方法的请求比例
$request_uri /api/users?page=1&limit=20 完整的路径+查询参数(不含方法/协议)
$uri /api/users 不含查询参数的标准化 URI(rewrite 后)
$query_string page=1&limit=20 仅查询参数部分
$server_protocol HTTP/1.1 协议版本(1.0 / 1.1 / 2.0)

4.5 $status — HTTP 状态码

"status": 200
属性
类型 整数
有效值 100–599
用途 监控错误率、分类响应、触发告警
生产实践经验

这是所有聚合查询的第一层过滤字段。

在 Loki 中查看过去 1 小时的 5xx 错误:

{job="nginx"} | json | status >= 500

在 Elasticsearch 中查看按状态码分组的请求数:

{
  "aggs": {
    "by_status": {
      "terms": { "field": "status" }
    }
  }
}

我习惯在 Grafana 面板上把 4xx 和 5xx 状态码的占比做成两行折线——4xx 是客户端问题(对你影响有限)、5xx 是服务端问题(立即响应)。两类告警的阈值和处理优先级完全不同。

4.6 $body_bytes_sent — 响应体字节数

"body_bytes_sent": 1234
属性
类型 整数
含义 响应体的字节数(不包含 HTTP 头部)
用途 带宽统计、出口流量分析

注意区分:

变量 含义 单位 示例
$body_bytes_sent HTTP 响应 Body 的字节数(不含 Headers) 字节 1234
$bytes_sent 完整 HTTP 响应的总字节数(Headers + Body) 字节 1567(比 Body 多 ~300 字节的 Headers)

一般用 $body_bytes_sent 就够了——带宽通常针对 Body,Headers 的字节数相对固定且很小。但如果你需要做精确的出口流量成本核算(CDN 带宽按完整响应计费),建议同时记录 $bytes_sent

4.7 $http_referer — 来源页面

"http_referer": "https://www.google.com/search?q=nginx+json+log"
属性
类型 字符串
来源 HTTP Referer 请求头(注意原 HTTP 规范拼写就是 Referer——1996 年的 typo 被永久保留在标准中)
正常值 来源页面的完整 URL
空值 -(直接访问、书签、HTTPS→HTTP 降级访问等)
Referer 丢失的常见场景

以下几种情况下 $http_referer-(不是日志问题,是 HTTP 协议的设计约束):

  1. 用户直接在浏览器地址栏输入 URL
  2. 从 HTTPS 站点链接到一个 HTTP 站点(浏览器出于安全考虑不发送 Referer)——这是 Referrer Policy 规范的要求
  3. 页面设置了 <meta name="referrer" content="no-referrer">
  4. 书签、电子邮件客户端、PDF 中的链接
  5. 从 HTTPS 跳转到另一个 HTTPS 站点,但目标站点的 Referrer Policy 设置了 strict-origin-when-cross-origin(只传域名,不传完整路径)

2026 年大部分站点已经是 HTTPS,Referer 丢失率在 15%-25% 之间(来自我管理的 5 个生产站点的实际数据)。

4.8 $http_x_forwarded_for — 真实客户端 IP(CDN/代理后)

"http_x_forwarded_for": "203.0.113.42, 10.0.0.1"
属性
类型 字符串
来源 HTTP X-Forwarded-For 请求头
格式 client_ip, proxy1_ip, proxy2_ip, ...
用途 多层代理后追溯真实客户端 IP

这是CDN 和反向代理场景下最容易被忽略但最重要的字段。当你的 Nginx 前面有 Cloudflare、阿里云 CDN 或自建反向代理时:

  • $remote_addr = 代理的 IP(如 10.0.0.1 或 CDN 边缘节点 IP)
  • $http_x_forwarded_for = 真实用户 IP 的链(用户IP, CDN边缘IP, CDN中间节点IP

取第一个 IP 通常是真实用户 IP——但前提是你信任代理写入的值。X-Forwarded-For 不是标准 HTTP 头,它可以被伪造。在 nginx.conf 中通过 set_real_ip_from 指定信任的代理 IP 范围(详见 第 1 期:Stream 四层 PROXY Protocol 的第 4 节)。

如果你使用 Cloudflare,替代方案是使用 $http_cf_connecting_ip

'"cf_connecting_ip":"$http_cf_connecting_ip",'

Cloudflare 保证这个字段不会被客户端伪造(只在 Cloudflare 边缘节点注入)。

4.9 $http_user_agent — 用户代理

"http_user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
属性
类型 字符串
来源 HTTP User-Agent 请求头
用途 浏览器/设备/爬虫识别、流量来源分类

escape=json 的保护下,User-Agent 中的所有特殊字符都会被自动转义,不必担心它破坏 JSON 结构。

如果 User-Agent 太长(包含大量插件信息,可达 500+ 字节),可以考虑在日志平台做截断或提取关键信息:

Loki 提取浏览器名称:

{job="nginx"} | json | line_format "浏览器: {{ regexReplaceAll `(\\w+)/.*` .http_user_agent `$1` }}"

4.10 $request_time — 请求处理时间

"request_time": 0.234
属性
类型 浮点数(秒,毫秒精度)
含义 从 Nginx 收到客户端第一个字节到发送完最后一个字节的总时间
用途 慢请求定位、API 性能监控

这是 SRE 视角下最重要的 Nginx 变量。一个极慢的请求(request_time > 5s)可以说明:

  • 后端响应慢(看 upstream_response_time 对比确定)
  • 客户端网络慢(request_time 大但 upstream_response_time 小 → Nginx 送完数据但客户端迟迟不收,即 TCP 窗口小)
  • 请求体太大(上传大文件的慢速连接)
Grafana 面板推荐
histogram_quantile(0.99, sum(rate({job="nginx"} | json | unwrap request_time [$__rate_interval])) by (le))

这是 Grafana 中查看 P99 请求处理时间的标准 Loki 查询。P50(中位数)告诉你"大多数用户",P99 告诉你"最慢的那 1% 在抱怨什么"。

4.11 $upstream_response_time — 后端响应时间

"upstream_response_time": "0.198"
属性
类型 浮点数(秒,毫秒精度)
含义 Nginx 与上游(后端应用服务器)建立连接 + 等待上游响应的总时间
空值 -(请求没有经过 upstream,例如纯静态文件直出)

区分 request_timeupstream_response_time 是定位问题的关键:

场景 request_time upstream_response_time 根因
后端 PHP 查数据库慢了 (2-5s) (1.9-4.9s) 后端应用
客户端在 3G 网络下载大文件 (10-30s) (0.02s) 客户端网络
500 并发短连接把 worker 打满了 (排队等待) (后端正常) Nginx worker_connections 不够 / ulimit 太小
全部正常 (< 0.1s) (< 0.05s) 性能达标

这就是为什么 JSON 日志必须同时包含这两个变量——没有对比,你就不知道慢在哪里。


5. 进阶优化

5.1 按域名分日志

如果你的 Nginx 同时服务多个域名(a.example.comb.example.com),把所有日志混到一个文件里会让后续分析变得困难。按域名分文件:

http {
    log_format json escape=json '...';

    # 对每个 server 块分别指定日志文件
    server {
        server_name api.example.com;
        access_log /var/log/nginx/api_access.log json;
    }

    server {
        server_name admin.example.com;
        access_log /var/log/nginx/admin_access.log json;
    }
}

5.2 条件日志

不对健康检查、静态资源(CSS/JS/图片)记录日志,降低日志量 40%-60%:

map $request_uri $loggable {
    /healthcheck  0;
    ~*\.(css|js|png|jpg|jpeg|gif|ico|woff2|svg)$ 0;
    default 1;
}

access_log /var/log/nginx/access.log json if=$loggable;

5.3 日志缓冲

高并发场景下,每一条请求都 write() 系统调用到磁盘会浪费大量 I/O。启用缓冲:

access_log /var/log/nginx/access.log json buffer=64k flush=5s;
参数 含义
buffer=64k 64 KB 缓存区——日志先写入内存缓冲区,满 64 KB 或超时后一次性刷盘
flush=5s 最长 5 秒刷一次盘——避免缓冲区一直不填满导致丢失最新日志

5.4 扩展字段:根据需求定制

你的 JSON 日志不限于上述变量。根据业务需求添加:

log_format json escape=json '{'
    '"time_iso8601":"$time_iso8601",'
    '"remote_addr":"$remote_addr",'
    '"request_method":"$request_method",'
    '"request_uri":"$request_uri",'
    '"server_protocol":"$server_protocol",'
    '"status":$status,'
    '"body_bytes_sent":$body_bytes_sent,'
    '"bytes_sent":$bytes_sent,'
    '"http_referer":"$http_referer",'
    '"http_user_agent":"$http_user_agent",'
    '"request_time":$request_time,'
    '"upstream_response_time":"$upstream_response_time",'
    '"upstream_addr":"$upstream_addr",'
    '"ssl_protocol":"$ssl_protocol",'
    '"ssl_cipher":"$ssl_cipher",'
    '"gzip_ratio":"$gzip_ratio",'
    '"host":"$host",'
    '"connection":"$connection",'
    '"connection_requests":"$connection_requests"'
'}';

新增字段说明:

变量 用途
$upstream_addr 上游后端服务器的 IP 和端口(如 10.0.0.5:8080)。多节点负载均衡时用来确认请求打到了哪个后端
$ssl_protocol TLS 协议版本(TLSv1.2 / TLSv1.3)。监控 TLS 1.2→1.3 的迁移进度
$ssl_cipher TLS 加密套件(如 TLS_AES_256_GCM_SHA384
$gzip_ratio gzip 压缩比(如 2.45)。值越大说明压缩效果越好。静态度量 CDN 缓存命中率的关键辅助指标
$host 请求的 Host 头(域名)。多域名 Nginx 日志合并时的区分字段
$connection 连接序列号(Nginx 内部递增 ID)
$connection_requests 当前连接上的第几个请求(HTTP keepalive 下 > 1)

6. 日志轮转

JSON 日志照样需要 logrotate——格式化不影响日志轮转配置:

cat > /etc/logrotate.d/nginx << 'EOF'
/var/log/nginx/*.log {
    daily
    missingok
    rotate 30
    compress
    delaycompress
    notifempty
    create 640 www-data adm
    sharedscripts
    postrotate
        [ -f /run/nginx.pid ] && kill -USR1 $(cat /run/nginx.pid)
    endscript
}
EOF

kill -USR1 通知 Nginx 重新打开日志文件——与第 1 期 Systemd 章节中的信号速查表一致。


7. 最佳实践总结

7.1 字段选择原则

原则 说明 示例
必须保留 所有日志分析都依赖的基础字段 time_iso8601remote_addrstatusrequest_time
按需保留 特定场景有用但不应全量记录的字段 $http_referer(SEO 需要,API 服务不需要)、$upstream_response_time(有后端的才需要)
可以裁剪 几乎用不到的字段 $remote_user(无 HTTP Basic Auth 的场景)

7.2 日志平台适配速查

平台 推荐日志格式 关键配置
Elasticsearch / ELK JSON(escape=json Logstash 中 json { source => "message" },不需要 Grok
Loki JSON(escape=json Promtail 中 stage.json + stage.labels 或 Loki 3.x 的结构化元数据
ClickHouse JSON(escape=json INSERT INTO nginx_logs FORMAT JSONEachRow$time_iso8601 可直接映射到 DateTime64
Splunk JSON 原生支持 JSON 格式的 sourcetype。KV_MODE=json 自动提取字段
Datadog JSON JSON 日志自动解析为标签和属性,无需配置解析规则

7.3 性能影响

你可能担心 JSON 格式比纯文本更重——日志体积会大吗?Logstash/Fluentd 解析 JSON 真的比 Grok 快吗?

以下是来自 Elastic 官方性能调优指南和 Nginx 社区的数据:

指标 Combined(纯文本) JSON(escape=json
平均单条日志体积 ~200 bytes ~350 bytes(多了 JSON 键名和结构字符)
Nginx CPU 开销(写入日志) 极低(纯文本拼接) 低(JSON 序列化 + escape=json 转义)
Logstash 解析开销 (Grok 正则,100% 解析到行) json filter,一次 JSON.parse)
磁盘存储(gzip 压缩后) ~40 bytes/条 ~50 bytes/条(JSON 的重复键名被 gzip 字典高效压缩)

结论log_format 设为 JSON 后,单条日志体积增加 ~75% 但不压缩时,gzip 压缩后差距缩至 ~25%。与此同时,Logstash 处理 Grok 的 CPU 开销是处理 JSON 的 3-5 倍。对于每秒数万条日志的 Nginx 实例,牺牲 25% 的磁盘空间换取 3-5x 的解析性能提升是极其划算的交易


8. FAQ

Q1: JSON 日志会不会丢失字段信息?比如 Combined 格式里有的东西 JSON 里没有了?

不会。JSON 格式只是把同样的信息重新组织了结构——信息没有丢失,只是不再依赖空格来分隔字段。你完全可以把 Combined 格式中所有字段映射到 JSON 中,见第 4 节的逐字段对比。

Q2: escape=json 对性能有影响吗?

Nginx 的 escape=json 是用 C 实现的短路字符串扫描——对每个日志变量做一次检查:如果全是 ASCII 可打印字符(大部分场景),直接拷贝;只有遇到 "\、控制字符时才转义。对于典型的 User-Agent 和 Referer(>95% 是 ASCII 可打印字符),这个检查是接近 O(n) 且很快。

Elastic 官方的生产环境基准测试显示:escape=json 带来的 Nginx 额外 CPU 开销 < 1%。

Q3: 可以把 Nginx 日志直接发到 syslog 吗?

可以。替换 access_log 目标:

access_log syslog:server=127.0.0.1:514 json;

然后由 rsyslog/syslog-ng 转发到日志平台。但这引入了 syslog 作为中间人——syslog 协议(RFC 5424)本身有 2 KB 的消息长度限制(虽然可以配大)。对于巨型 User-Agent 和长 URL 的请求,syslog 可能截断日志。建议优先走本地文件 + Filebeat / Promtail 采集,不必绕 syslog 这一步。

Q4: JSON 日志太大了,磁盘扛不住怎么办?

三条路:

  1. 条件日志(见 5.2 节):不记录静态资源和健康检查——日志量减少 40%-60%
  2. 采样:在同一个 Nginx http 块中定义两个 log_format——一个完整的(记录所有请求),一个采样的(if=$loggable 加随机采样)
  3. 日志存储分层:本地 disk 只保留 7 天(rotate 7),满 7 天通过 gzip 压缩后传对象存储(S3/COS/OSS)做长期冷存储

推荐阅读