Nginx日志格式改造与ELK采集实战
发布日期: 2026/08/08 阅读总量: 0

一、真实场景:一场排障事故让我重写日志模块

三个月前,运营反馈「首页活动页接口P95延迟从800ms飙到3.2s」,我打开Kibana想按URI聚合看耗时分布——结果发现nginx的access.log里压根没有$request_time字段。默认的combined格式只有IP、时间、请求行、状态码、UA,连最基本的耗时都没有。

更尴尬的是,我把日志接入ES后,message字段是整条字符串,Kibana里只能看原始日志,想按URI统计、按状态码分组、按耗时排序,统统做不了。最后靠grep + awk手工统计了半小时,才定位到是某个大商户的查询接口没加索引。

这事之后我彻底重构了Nginx日志体系:自定义JSON格式 + Filebeat采集 + ELK分析。本文把这套方案完整写出来,包含可复制的配置、压测数据和5个我实际踩过的坑。

二、方案对比:三种日志采集架构

方案A:默认combined格式 + Logstash Grok解析

项目说明
Nginx配置不改,直接用默认格式
采集端Logstash + grok插件
优点不改业务配置,Logstash侧解析灵活
缺点grok正则CPU开销大;解析失败率高;字段类型需单独映射

方案B:自定义JSON格式 + Filebeat直接输出ES

项目说明
Nginx配置log_format改成JSON,日志本身就是结构化数据
采集端Filebeat(轻量,内存占用约30MB)
优点字段天然结构化,无需解析;性能损耗极小;Kibana直接可用
缺点需要改Nginx配置并reload;日志体积变大(多20%~30%);需要先建索引模板

方案C:自定义JSON + Filebeat + Logstash加工

项目说明
Nginx配置同上
采集端Filebeat → Logstash → ES
优点可以在Logstash里做字段丰富(比如IP归属地)、数据清洗
缺点多一层管道,延迟增加约10~20ms;多维护一个组件,故障点增加

我的选择:方案B。理由有三:

  1. Nginx日志本来就是半结构化数据,直接在源头格式化成JSON,比事后用grok硬解析靠谱得多
  2. Filebeat比Logstash轻量得多,不占JVM内存(Logstash默认堆内存1GB起步)
  3. Kibana对嵌套JSON字段天生友好,不需要额外处理

如果你已经有Logstash做其他数据管道,或者需要IP归属地、UA解析,那可以升级到方案C。否则别为自己的场景增加复杂度。

三、完整实现:从Nginx到Kibana

3.1 环境版本

# 所有组件部署在同一台4C8G的测试机上
Nginx 1.24.0(编译安装,/usr/local/nginx)
Elasticsearch 8.11.0(tar包安装)
Filebeat 8.11.0(tar包安装)
Kibana 8.11.0(tar包安装)
操作系统:CentOS 7.9 / kernel 3.10.0-1160

3.2 第一步:改造Nginx日志格式

编辑/usr/local/nginx/conf/nginx.conf,在http块内新增log_format配置:

http {
    # 原有配置...

    # JSON格式日志:字段名可自定义,但要保持类型稳定
    log_format elkserver escape=json
        '{"@timestamp":"$time_iso8601",'
        '"server_addr":"$server_addr",'
        '"remote_addr":"$remote_addr",'
        '"request_method":"$request_method",'
        '"request_uri":"$request_uri",'
        '"server_protocol":"$server_protocol",'
        '"status":$status,'
        '"body_bytes_sent":$body_bytes_sent,'
        '"request_time":$request_time,'
        '"upstream_response_time":"$upstream_response_time",'
        '"http_referer":"$http_referer",'
        '"http_user_agent":"$http_user_agent",'
        '"http_x_forwarded_for":"$http_x_forwarded_for",'
        '"host":"$host"}';

    # 在server块中引用
    server {
        access_log /data/logs/nginx/access.log elkserver;
    }
}

注意:$upstream_response_time在upstream未配置时为空,用双引号包起来不会破坏JSON结构。escape=json会把引号、换行符转义,避免UA里的特殊字符搞坏JSON。

reload生效:

/usr/local/nginx/sbin/nginx -t && /usr/local/nginx/sbin/nginx -s reload
# 验证输出格式:
tail -n 2 /data/logs/nginx/access.log | python3 -m json.tool

3.3 第二步:配置Filebeat采集

Filebeat配置/etc/filebeat/filebeat.yml

filebeat.inputs:
  - type: log
    enabled: true
    paths:
      - /data/logs/nginx/access.log
    # 关键:让Filebeat直接识别JSON行,自动解析字段
    json.keys_under_root: true
    json.add_error_key: true
    json.message_key: request_uri
    # 多行合并(错误日志需要,access.log一般不需要)
    # multiline.type: pattern
    # multiline.pattern: '^\{'
    # multiline.negate: true
    # multiline.match: after

output.elasticsearch:
  hosts: ["localhost:9200"]
  index: "nginx-access-%{+yyyy.MM.dd}"

setup.template.name: "nginx-access"
setup.template.pattern: "nginx-access-*"
setup.ilm.enabled: true

# 如果是首次部署,需要先跑一次 setup
# 建议手动执行:filebeat setup --index-management

启动Filebeat:

# 测试配置
filebeat -e -c /etc/filebeat/filebeat.yml -d "publish"
# 后台启动
nohup filebeat -e -c /etc/filebeat/filebeat.yml > /data/logs/filebeat.log 2>&1 &
# 验证数据是否进入ES
curl -s 'http://localhost:9200/nginx-access-*/_count' | python3 -m json.tool

3.4 第三步:ES索引模板优化

Filebeat的默认模板会把所有字段映射为text类型,这意味着request_time没法做数值比较(比如>1s的请求)。必须自定义索引模板,覆盖字段类型:

# 创建索引模板:nginx-template.json
cat > /tmp/nginx-template.json << 'EOF'
{
  "index_patterns": ["nginx-access-*"],
  "template": {
    "settings": {
      "number_of_shards": 3,
      "number_of_replicas": 1,
      "index.refresh_interval": "5s"
    },
    "mappings": {
      "properties": {
        "@timestamp": { "type": "date" },
        "server_addr": { "type": "ip" },
        "remote_addr": { "type": "ip" },
        "request_method": { "type": "keyword" },
        "request_uri": { "type": "keyword" },
        "server_protocol": { "type": "keyword" },
        "status": { "type": "integer" },
        "body_bytes_sent": { "type": "long" },
        "request_time": { "type": "float" },
        "upstream_response_time": { "type": "float" },
        "http_referer": { "type": "keyword" },
        "http_user_agent": { "type": "text" },
        "http_x_forwarded_for": { "type": "ip" },
        "host": { "type": "keyword" }
      }
    }
  }
}
EOF

curl -X PUT 'http://localhost:9200/_index_template/nginx-access-template' \
  -H 'Content-Type: application/json' \
  -d @/tmp/nginx-template.json

3.5 第四步:Kibana配置索引模式

Kibana可视化前需要创建Index Pattern:

// 在Kibana Dev Tools里执行(本机Kibana地址:http://localhost:5601)
// 1. 创建Index Pattern
POST /kibana/api/saved_objects/index-pattern
{
  "attributes": {
    "title": "nginx-access-*",
    "timeFieldName": "@timestamp"
  }
}

// 2. 设置默认Index Pattern
POST /kibana/api/saved_objects/index-pattern/nginx-access-*
{
  "attributes": {
    "title": "nginx-access-*",
    "timeFieldName": "@timestamp"
  }
}

// 3. 查看字段列表,确认request_time类型为number
GET /nginx-access-*/_mapping/field/request_time

实际操作更简单:Kibana界面 → Stack Management → Data Views → Create data view → 输入nginx-access-* → 选@timestamp时间字段 → 创建。

3.6 实际排障示例

数据进入Kibana后,排查「哪个接口最慢」只需要一条查询:

// Kibana Discover搜索:
// 查找P95耗时超过2秒的请求
{
  "query": {
    "bool": {
      "filter": [
        { "range": { "request_time": { "gte": 2 } } }
      ]
    }
  },
  "aggs": {
    "slow_uris": {
      "terms": { "field": "request_uri", "size": 10 },
      "aggs": {
        "avg_time": { "avg": { "field": "request_time" } },
        "max_time": { "max": { "field": "request_time" } },
        "count": { "value_count": { "field": "request_time" } }
      }
    }
  }
}

这条聚合在1亿条日志的ES集群上,响应时间不到300ms。之前用grep+awk得5分钟以上。

四、效果数据:改造前后实测对比

我用自己的测试机压了一组数据,4核8G,Nginx单worker,PHP-FPM处理请求。

4.1 压测条件

# 压测工具:ab(ApacheBench 2.3)
# 并发200,总数5万
ab -c 200 -n 50000 http://127.0.0.1:8080/api/test.php

4.2 Nginx写入日志的性能影响

日志格式QPSP95延迟(ms)P99延迟(ms)CPU占用率
默认combined10,32432.148.742%
自定义JSON9,87633.450.246%

结论:JSON格式日志导致QPS下降约4.3%,P95延迟增加约1ms。核心原因是JSON字段更多,且每次写入日志多约80字节IO开销。对于大多数业务场景,这个损耗完全可接受。

4.3 方案A vs 方案B采集端开销

采集方案内存占用CPU占用日志解析延迟字段解析失败率
Logstash+Grok1.5GB (JVM堆)120% (单核)~20ms/条8.7%(UA含特殊字符时)
Filebeat+JSON31MB28%~1ms/条0.1%(escape=json处理后)

Grok解析失败率8.7%是因为Nginx默认日志的$request里的URI包含未转义引号时,grok正则匹配会中断。JSON方式直接规避这个问题。

4.4 日志体积对比

# 同样100万条请求
combined格式:2.7GB
JSON格式:3.5GB(增加了29.6%)
# ES存储(含副本,3分片)
# 原始日志转ES后压缩比约1:0.4
# 1000万条JSON日志(35GB) → ES占用约14GB

五、避坑指南:我实际遇到的5个坑

坑1:Nginx日志写到一半变多行导致Filebeat解析失败

现象:应用报错时在access.log里输出了多行堆栈,Filebeat把每一行当成JSON解析,ES里出现大量json_parse_failure

解决:在Filebeat配置里加多行合并规则:

  multiline.type: pattern
  multiline.pattern: '^\d{4}-\d{2}-\d{2}'  # 以日期开头才算新行
  multiline.negate: true
  multiline.match: after

注意:这种错误日志不适合和access.log混在一个文件里,最好让业务方把错误日志单独输出。

坑2:$request_uri里的逗号或日期被误解析

现象:ES里有些文档的request_uri字段被截断或类型污染。

原因:Nginx的escape=json只转义了引号和反斜杠,但$request_uri本身可能包含未编码的逗号。去掉escape=json后逗号会直接破坏JSON结构。

解决:生产环境必须加上escape=json,同时把$uri$args替代$request_uri(后者包含原始请求行,容易有脏字符)。

坑3:字段类型映射失败导致数值统计失效

现象:Kibana里对request_time做聚合时,报错Text fields are not optimised for operations that require per-document field data

原因:Filebeat模板默认把request_time映射成text,而不是float。

解决:先删除坏索引,再重新写入。如果线上不能删,用别名(alias)切换:新索引用正确模板,然后做reindex。

坑4:时间字段时区偏移8小时

现象:Kibana里的日志时间比实际时间晚8小时。

原因:Nginx的$time_iso8601是UTC时间,而ES默认使用UTC存储。

解决:在Nginx配置里直接写本地时间:

log_format elkserver escape=json
    '{"@timestamp":"$time_iso8601",'
    # 如果想让ES显示东八区时间,用date类型加timezone参数:
    # 但更推荐保持UTC,在Kibana里设置时区显示为Asia/Shanghai
    ...

Kibana设置:Management → Advanced Settings → 搜索dateFormat:tz → 设为Asia/Shanghai。但注意,如果你的业务方都在国内,建议直接用$time_local(格式为29/Nov/2024:14:30:22 +0800),在Logstash里解析成带时区的date类型。

坑5:Filebeat一直重复推送同一条日志

现象:ES的文档数远大于Nginx日志行数,日志增速异常。

原因:Filebeat的registry文件(/var/lib/filebeat/registry)记录读取偏移。如果磁盘写满导致registry写入失败,Filebeat会重头开始推送。

解决:监控磁盘空间,为registry目录单独划分分区;另外Filebeat支持close_inactive参数,默认5分钟,如果日志文件超过5分钟没更新,Filebeat会关闭文件句柄。

六、全文总结:这套方案能省你多少事

改造成本:Nginx配置20行 + Filebeat配置30行,两个小时搞定。之后排查耗时问题,从以前的手工grep变成Kibana可视化,单次排障时间从平均40分钟降到3分钟。

这套方案不是银弹:如果你的日志量每天超过10亿条,需要引入Kafka削峰;如果需要复杂的ETL,需要加回Logstash。但80%的业务场景,方案B足够,而且它足够简单,简单意味着容易维护,不容易出故障。

最后放上全部配置文件,可以直接复制到项目里用。