Nginx日志JSON化改造:ELK采集链路优化与排坑指南
做ELK采集大半年之后回头看看最值得复盘的一个改动就是我把Nginx的access日志从默认的combined格式改成了JSON格式然后通过Logstash解析后直接写入Elasticsearch。这一篇是ELK系列的第六篇重点讲清楚Nginx侧log_format怎么写、Logstash侧怎么配、以及整套链路跑起来之后遇到的那些坑。适合已经搭好了ELK基础环境、正在做Nginx日志接入或者已经在用grok硬拆但被正则折磨得够呛的运维和开发同学。刚开始做Nginx日志采集的时候很多人第一反应就是上图省事直接在Logstash里用grok把官方默认的combined格式拆一遍。说实话单纯为了拿到clientip、status、bytes这几个字段grok确实够用。但一旦你想在Kibana里看响应时间分布、按UA维度统计浏览器、分析Referer来源甚至想结合GeoIP看地域流量那套正则会越写越长最后变成一堆看着就头疼的调试现场。把日志在源头改成结构化JSON其实就是把复杂度从解析阶段往前挪到了生成阶段Logstash这边只需要一行codec配置后面所有字段都是现成的省下的精力不是一点点。1. 为什么要把Nginx日志改成JSON先捋清楚整个链路1.1 默认日志格式在ELK里的尴尬处境Nginx默认的log_format combined长这样$remote_addr - $remote_user [$time_local] $request $status $body_bytes_sent $http_referer $http_user_agent这个格式人类读起来还算友好日志文件里一行是一条记录信息也完整。但丢给Logstash处理的时候麻烦就来了。你需要写一个grok表达式去匹配整行内容%{IPORHOST:client_ip} - %{NOTSPACE:remote_user} \[%{HTTPDATE:timestamp}\] %{WORD:method} %{URIPATHPARAM:uri} HTTP/%{NUMBER:http_version} %{NUMBER:status} %{NUMBER:bytes} %{DATA:referrer} %{DATA:user_agent}这个表达式看起来也不难但实际情况远比这复杂。请求行里有引号、有空格UA里有分号、括号、还有各种奇怪字符Referer可能为空URL里可能带着JSON转义符……任何一个边缘情况没匹配上grok就会给你打一个_grokparsefailure标签。字段拆出来之后还全是字符串类型你又得用mutate插件一个个convert成integer、float配置文件的体积就这么滚起来了。我自己维护过一段时间的grok方案最痛苦的还不是写正则而是当你想增加一个字段、或者Nginx版本升级改了某个变量格式时整个表达式要重新推倒调试。这种维护成本在日志量上来之后会变得特别具体所以后来果断切换成了JSON方案。1.2 JSON结构化之后采集链路会变成什么样改成JSON格式之后整个链路变得非常清爽。Nginx在写日志的时候直接输出JSON对象Logstash的file input配一个codec json每一行进来就是已经解析好的结构化事件。字段名、类型都在Nginx侧定义好Logstash这边基本不需要再做复杂的grok解析最多就是转换一下类型、处理一下时间。这套方案的直接收益可以从三个维度来看。第一是Kibana的查询体验完全不一样了字段天然存在筛选器、聚合、图表直接拖字段就能用。第二是采集链路变浅Logstash的CPU消耗大幅下降因为正则解析是CPU密集操作而JSON解析快得多。第三是字段扩展变得极其便宜想在日志里多记一个$host、$server_name还是$request_length只需要在log_format里加一个字段Logstash端什么都不用改数据就自动多出这个字段这种扩展性是用grok时代完全不敢想象的。1.3 这篇方案适用的链路假设为了后面叙述不打结先交代一下我假设的采集架构。Nginx部署在应用服务器上日志写到本地文件Logstash通过file插件监听这个日志文件解析后直接输出到ElasticsearchKibana负责展示。这是最典型、也最适合作为起步方案的链路。如果你那边用了Filebeat做轻量采集再把数据交给Logstash整体思路不变只是Filebeat端需要额外处理一下multiline和json解析的衔接这部分本篇不展开但思路是通用的。2. Nginx侧改造log_format改成JSON平滑切换不踩坑2.1 log_format的完整配置与字段选型Nginx的log_format指令支持自定义字段我们可以直接用变量拼接出JSON结构。下面是我在多个环境里使用过的一份配置字段选型对照了日常分析场景http { log_format json_combined {timestamp:$time_iso8601, client_ip:$remote_addr, method:$request_method, uri:$request_uri, protocol:$server_protocol, status:$status, size:$body_bytes_sent, referer:$http_referer, ua:$http_user_agent, request_time:$request_time, x_forwarded_for:$http_x_forwarded_for, server_name:$server_name}; access_log /data/logs/nginx/access_json.log json_combined; }字段类型上我刻意区分了字符串和数值两类。$status、$body_bytes_sent、$request_time这三个一定是数值型所以JSON里不带双引号其余字段全部用双引号包裹成字符串。这里面的设计逻辑后面会详细说有一个核心原则是能确定是数值的字段就不要加引号否则Logstash解析出来全是stringKibana里做聚合的时候会非常难受。$request_time和$upstream_response_time这两个时间字段在排障场景里特别有用。$request_time是Nginx从接收到请求到返回响应的完整耗时$upstream_response_time是后端服务处理的时间两个字段一对比就能快速区分是Nginx层慢还是后端慢。但注意$upstream_response_time在请求没有走upstream时可能输出-而且代理多个后端时还会出现逗号分隔的多值所以我通常把它当字符串处理后续在Logstash里再按需转换或者取第一个值。2.2 引号陷阱字符串加引号数值不要加引号这一节我吃了不少亏专门拿出来说。JSON规范要求字符串必须用双引号包裹数值不可以用双引号。这个看起来很简单但在Nginx的log_format里很容易手滑。比如status:$status是正确的写成status:$status虽然语法上能通过JSON解析也会成功但字段类型就变了。一旦status变成字符串类型Kibana里的Visualization在按status做terms聚合时倒是能出图但你想做数值范围筛选、或者给status做平均值/百分位统计会发现压根没有这些选项因为那是数值字段才有的功能。更麻烦的是如果同一天内有些日志是字符串status有些是数值statusElasticsearch的dynamic mapping会直接冲突导致新数据写入报mapper_parsing_exception。数值型的判断标准很简单这个字段的值是否永远是数字。$status永远是三位数字状态码$body_bytes_sent永远是字节数$request_time永远是浮点秒数。这三个大胆不加引号。$remote_port也是数字。其余大部分变量像$http_user_agent、$http_referer、$request_uri、$remote_addr不管值是空还是有内容都统一用双引号包裹。2.3 特殊字符的隐患URL和UA里的双引号怎么处理JSON格式最经典的坑不是格式写错而是字段值里的特殊字符。比如一个请求URI是/search?qtest$request_uri的值本身就包含双引号拼出来的JSON就直接被破坏了。同样的情况也可能出现在UA和Referer里常见爬虫UA里带引号、带反斜杠的例子并不少见。原生Nginx的log_format是不做JSON转义的它不会帮你把值里的双引号转换成\。所以一旦出现这种请求Logstash的json codec解析这一行会报_jsonparsefailure丢掉这条数据或者打上失败标签。业界常见的解决方案有两种方向。一种是在Nginx侧做转义通常配合Lua模块在记录日志之前对危险字段做转义处理。另一种是在Logstash侧兜底把json解析失败的事件保留到原始message字段里后续有需要再人工清洗。我在生产环境采用的是后一种因为对于绝大多数正常流量URI和UA中出现双引号的概率极低完全可以用Logstash的容错机制来兜底不值得为了低概率场景引入Lua依赖。但如果你的业务里有大量用户生成内容参数里可能带任意特殊字符那就建议认真考虑在Nginx侧做一层转义别指望运气。2.4 平滑切换老日志格式不要急着删改造的时候最忌讳的就是一步到位直接把原有access.log的格式改成JSON然后所有人傻眼。线上Nginx通常会有一个定期巡检脚本、一个解析日志的老采集任务或者其他部门在直接tail文本日志你这边一改格式对面全挂。稳妥的做法是新增一个access_log指令让JSON日志写到独立文件原有日志保留不动。server { access_log /data/logs/nginx/access_json.log json_combined; access_log /data/logs/nginx/access.log combined; }这样两条日志并行写一段时间等Logstash采集稳定、Kibana出图正常、下游消费方确认无影响之后再逐渐下线旧格式。另一个细节是要确保日志目录存在/data/logs/nginx/如果不存在Nginx不会自动创建只会把错误打到error.log里然后access_log文件一直为空这个现象很误导人因为Nginx本身不会报错退出。修改完配置之后先nginx -t验证语法再nginx -s reload平滑生效。reload不会中断请求这点可以放心。但注意如果你改了http层级的log_formatserver块里引用它的access_log会自动沿用新格式如果只是改了某个server块那只有那个server的日志会变。3. Logstash侧json codec加三个filter从解析到入库3.1 input段file插件与json codec的正确姿势Logstash这边file插件加上json codec是最省事的组合。直接看配置input { file { path /data/logs/nginx/access_json.log* codec json type nginx-access start_position beginning sincedb_path /data/logstash/sincedb/nginx_access.sincedb stat_interval 2 discover_interval 15 } }几个参数的作用需要理解清楚。path带通配符的好处是如果日志做了轮转Nginx把文件重命名为access_json.log.1Logstash不会丢新文件。sincedb_path显式指定是为了避免默认路径下的sincedb文件在重启或者环境清理时丢失导致Logstash重新从旧文件头读一遍这个我后面会展开讲。start_position beginning只对第一次发现的新文件有效。这句话的意思是Logstash第一次读取一个从没见过的文件时会从文件头开始读如果sincedb里已经记录了offset即便设为beginning也会从上次的位置继续。很多人对这个有误解以为设置了beginning就会每次重复读全量日志其实不是的。这里要特别提醒一点如果你把这个配置部署到一个已经存在历史日志文件的环境Logstash第一次启动时会把这个文件里的所有历史数据全部灌进ES。有些场景这可能是你想要的但如果历史文件几个GB、或者包含上一个环境留下的脏数据你会看到一个索引瞬间膨胀好几GB。我建议第一次部署前先评估一下历史日志的量不需要历史就直接把日志文件truncate或者给它加一个超过当前时间的offset避免第一次启动就爆量。3.2 filter段时间、UA、GeoIP与字段类型的四个关键步骤filter段是整个管线的核心按顺序拆开说。第一步是时间解析。json codec解析出timestamp字符串之后Logstash内部的timestamp字段仍然是处理时间而不是日志产生时间。如果不管它所有数据在Kibana里的时间线都会偏向采集时间一旦Logstash处理积压、ES写入变慢时间错乱会非常严重。标准解决办法是用date插件filter { if [type] nginx-access { date { match [timestamp, ISO8601] target timestamp } } }这里有个隐藏问题如果timestamp作为输入字段名date插件解析成功后会覆盖Logstash内部的timestamp但解析前的字符串版本有可能残留在事件里导致ES最终出现两个同名字段冲突。我的习惯是把Nginx日志里的时间字段直接命名为log_time然后date解析它再remove掉原字段这样和Logstash内部的timestamp彻底隔离最省心。filter { if [type] nginx-access { date { match [log_time, ISO8601] target timestamp } mutate { remove_field [log_time] } } }Nginx的$time_iso8601输出格式是2024-01-15T10:30:0008:00完全符合ISO8601标准date插件可以直接解析不需要额外的时间格式配置。第二步是字段类型转换。虽然Nginx侧已经保证了status和size不带引号但为了应对极端脏数据Logstash这边再做一道强制转换最保险mutate { convert { status integer } convert { size integer } convert { request_time float } }如果以后有人改了log_format、给数值字段加了引号这步能兜底。convert不会因为字段已经是目标类型就报错可以放心用。第三步按需解析UA。如果你想在Kibana里做浏览器版本、操作系统、设备类型的统计useragent插件非常实用useragent { source ua target ua_parsed }它会自动生成ua_parsed.name、ua_parsed.os、ua_parsed.device等子字段后面Kibana里可以直接拖出来做饼图、柱状图。注意如果日志量很大这个filter会消耗一定CPU只在你确实需要UA维度分析时开。第四步按需做GeoIP。如果Nginx日志要面向公网流量分析geoip插件可以基于client_ip还原地域信息geoip { source client_ip target geo }当client_ip是内网IP时geoip不会产生结果事件会保留原样。内网环境的日志做这步纯粹浪费性能生产上一般用条件判断或者只在公网网关那层Nginx上开启。3.3 output段写入ES时的索引名与生命周期管理output段最简配置这样写output { if [type] nginx-access { elasticsearch { hosts [http://127.0.0.1:9200] index nginx-access-%{yyyy.MM.dd} user logstash_system password your_password } } }index字段里的%{yyyy.MM.dd}是date参考timestamp做日期格式化的内置语法保证每天一个索引。ES 7以上的版本里如果Elasticsearch开启了安全认证这里要提供认证信息如果不加Logstash写入会报unauthorized。生产环境建议加上ILM别让索引无限增长。先在ES侧建一个生命周期策略PUT /_ilm/policy/nginx-access-lifecycle { policy: { phases: { hot: { actions: { rollover: { max_size: 50GB, max_age: 7d } } }, delete: { min_age: 30d, actions: { delete: {} } } } } }然后在Logstash的output里启用ILM。需要留意的是Logstash对自定义索引名的ILM支持容易踩坑因为默认ILM只对logstash-前缀的索引生效。如果坚持用nginx-access-*这种索引名必须先建一个index template把生命周期策略绑定到该索引模式上。PUT /_index_template/nginx-access-ilm { index_patterns: [nginx-access-*], template: { settings: { number_of_shards: 3, number_of_replicas: 1, index.lifecycle.name: nginx-access-lifecycle } } }在索引模板中指定shard数量时要结合你ES集群的节点数和数据量来定。单节点测试环境用3个shard纯属浪费反而会在聚合时增加额外开销如果集群规模不大number_of_shards: 1, number_of_replicas: 0反而更合理。总之模板存在的意义是把那些每次建索引都想人工指定的设置固化下来省得每次滚动索引都要操心配置一致性问题。3.4 Logstash配置的校验与调试三板斧配置写完之后不要急着启动服务先做校验。Logstash自带一个配置检查参数bin/logstash --config.test_and_exit -f /etc/logstash/conf.d/nginx-access.conf配置语法没问题的话会输出Configuration OK有问题会直接给出配置文件的具体行号。这一步能过滤掉一大半的低级错误。但语法通过不代表解析正确。我常用的调试手法是在output里临时加一个stdout输出output { stdout { codec rubydebug } }然后从真实日志里复制一行JSON通过logstash的标准输入模式手动喂进去echo {log_time:2024-01-15T10:30:0008:00,method:GET,uri:/index.html,status:200} | bin/logstash -f /tmp/test.conf --http.host 127.0.0.1观察rubydebug输出的事件结构重点看timestamp是否变成日期对象、status是不是整数、有没有莫名其妙的字段。确认无误后再改回ES输出并启动正式服务。这一套三板斧看起来笨但确确实实能帮你在数据进入ES之前发现问题省得污染索引之后再清理。4. 实战中必然会遇到的坑现象、原因、排查手段4.1 JSON解析失败看到_jsonparsefailure别慌Kibana里搜索_jsonparsefailure标签会搜出一批解析失败的事件。这种标签是json codec在解析失败时自动加上的出现这个标签代表这一行原始日志没能被当成合法JSON解析。最常见的原因就是前面说的特殊字符问题UA、URI、Referer里头掺了双引号破坏了整条JSON结构。排查时先把logstash的日志文件翻出来里面通常记录了最近一次解析失败的具体行内容。生产环境下我建议把logstash事件里的message字段保留下来即使解析失败原始日志内容还在你还能在Kibana里人工看这行到底长什么样不至于悄无声息丢数据。长期主义一点的解法是在Nginx侧加Lua转义或者接受这个失败概率并做好监控告警。我个人的判断是绝大多数正常业务的特殊字符出现率在万分之几的级别做好失败事件标识已经够用了不必为了这个概率把架构复杂度拉高。但如果你的业务日志量一天过亿万分之几都会是一天几千条脏数据那就得认真对待。4.2 字段类型冲突ES mapping为什么老报错用上一两周之后你可能会在Logstash日志里看到这种错误Rejecting mapping update to [nginx-access-2024.01.15] as the final mapping would have more than 1 type: [long] and [text]这就是前面说的字段类型冲突。原因通常有两种一种是log_format里同一个字段有时候带引号、有时候不带Logstash解析出来的类型一会儿string一会儿number另一种是不同Nginx版本部署的配置不一致有些server块用了新格式有些server块还在用旧格式导致同一天索引里混进了两种类型。排查可以借助ES的API直接看mappingGET /nginx-access-*/_mapping/field/status响应里能清楚看到status在不同索引里的类型。解决方案也很直接第一Nginx侧log_format全量统一第二Logstash filter里的mutate convert强制把每个字段转换为指定类型。这两步都做基本能杜绝类型冲突。顺手再建议你在索引模板里把常用字段的mapping固定下来比如状态码直接映射成shortrequest_time映射成float这样ES就不会靠猜来创建类型了。4.3 时间乱序Kibana里的数据时间线为什么是歪的Kibana里看到日志的时间分布忽高忽低或者某个时刻的柱状图被拉到离真实时间很远的位置通常是timestamp没有正确解析的典型表现。原因是我在前文反复提到的没有用date插件把Nginx日志里的时间字段解析成事件时间Kibana展示的用的是Logstash处理时间。这种问题在日志量突发、Logstash消费跟不上的时候尤其明显。比如ES集群性能下降Logstash队列积压了几分钟的日志但处理时间是现在Kibana里所有数据都会排在处理时间附近看起来就像Nginx刚吐了一大波日志实际上这些日志可能是几分钟前产生的。排查手段很简单打开任意一条日志看timestamp字段的值和业务日志里的log_time是否一致不一致就说明date解析没生效。这里再提醒一个细节date插件的match数组里写ISO8601不能写成yyyy-MM-ddTHH:mm:ssZ那样容易出错的自定义格式。ISO8601这个内置模式已经覆盖了带时区、带毫秒等常见变体直接用最省事。4.4 日志重复消费ES数据量莫名其妙暴涨Nginx日志量一天撑死50GB但ES索引一天涨了80GB、100GB多半是某段历史日志被Logstash重复读了一遍。这里最常见的坑就是sincedb文件丢失。默认情况下Logstash会把sincedb写到当前用户home目录下如果你用systemd或者容器方式运行HOME目录可能是不稳定的重启几次就没了Logstash就会把这个日志文件当作全新文件从头再读一遍。规避方式是在input里显式指定sincedb_path把它放到一个稳定的磁盘路径比如/data/logstash/sincedb/nginx_access.sincedb。另一个原因是path通配符写得太宽比如access_json.log*会把轮转出来的旧文件也包进来这些文件每次发生变动都会触发读取。稳妥的命名策略是让Nginx的access_log和logrotate的旧文件命名规则不重叠比如新文件永远是access_json.log轮转文件叫access_json.log.1然后path写成access_json.log配合discover_interval或者保持通配符但把轮转旧文件移动到不受监听的历史目录。4.5 索引数量膨胀和生命周期策略如果没配ILM或索引模板Nginx日志就会一天一个索引地无限增长索引数量到几千个之后集群状态变黄、变红是迟早的事。开篇那个nginx-access-lifecycle策略记得用起来。需要特别注意的是如果你是在Logstash output里直接指定ilm_enabled true它要求索引名遵循logstash-前缀模式才能自动关联策略对自定义前缀容易不生效。我自己遇到的更稳做法是索引名保持nginx-access-%{yyyy.MM.dd}在ES侧通过index template把生命周期策略绑定到nginx-access-*上这样Logstash只是一个纯粹的写入角色索引的管理策略完全由ES控制职责更清晰也便于后续调整。4.6 日志轮转状态下的采集连续性最后说一个和Nginx日志轮转有关的实际问题。线上环境通常用logrotate定期切割日志如果轮转工具直接把文件rename走再创建一个新文件Logstash的file插件会不会漏读新文件大多数情况下不会file插件会周期性地重新检查路径下的文件发现新的文件名后从零开始消费。但这里有一个边界情况如果rename和创建新文件之间出现了时间窗口Logstash恰好在这个窗口内执行了文件状态检查可能对新文件的存在感知延后造成几十秒的日志空白。稳妥的做法是logrotate配置里加copytruncate它对采集场景更友好复制当前文件内容到轮转文件、再清空原文件Logstash监听的始终是同一个inode基本不会漏数据。当然copytruncate在极端高并发时存在极短时间内的日志截断风险不过对于Nginx access日志这种体量实际影响微乎其微。最后再分享一点我的个人体会这套方案从改Nginx配置到Logstash稳定写入前后调整了很多轮。我最深的体会是日志格式的设计其实是整个可观测性链路的地基地基决定了上层建筑。很多人一开始只想着把数据拿回来随便用一个能跑通的格式就上了结果后面每一次加字段、调类型、做聚合都在跟当初的偷懒较劲。Nginx的log_format加十几行JSON配置看起来是一次性投入回报却是后面每一天查日志、做报表、排故障时的通畅感。如果你正准备接入Nginx日志我的建议是直接一步到位用JSON格式别在grok阶段停留太久。如果你已经用grok跑了一段时间也别怕切换按我上面说得平滑过渡方案来新格式独立文件并跑几天观察数据没问题再下线旧逻辑。改完配置记得nginx -t再reload这个习惯能省掉很多线上事故。