上周接到一个日志采集需求,日志来源是某订单服务的应用文件,每一行都是一个完整的JSON对象,嵌套层级少则三层多则五层。里面既有request_body这种动辄几KB的大字段,也有debug_stack这种只有排障才用得上的调试堆栈,偶尔还会把用户token跟着业务参数一起打进日志。同事一开始的方案特别简单:filebeat原样采集,日志推到Elasticsearch里,字段问题后面再说。我打开索引mapping预览看了一眼,当场就否掉了——几十个嵌套object字段堆在一起,后续做terms聚合、排序、告警,每一步都会很难受。
这就是本文想聊的事:filebeat采集JSON日志时,如何在采集端就把“去掉部分属性”“扁平化嵌套结构”“按需拆分信息”这三件事做干净。换句话说,把原本属于logstash或es ingest pipeline的工作往前挪到filebeat里,少跑一趟中转,ES索引也清爽得多。内容面向的是正在折腾elastic stack日志采集的同学,不管你是刚接触filebeat的新手,还是已经在生产环境跑了一段时间的老手,下面这些配置和踩坑记录都应该用得上。
1. 为什么我坚持把JSON处理放在filebeat采集阶段
先说一个很多团队的通病:日志链路喜欢全堆在logstash里,觉得logstash过滤器丰富什么都能干。这话不假,但代价是logstash会成为链路上最容易被压垮的一环。我经手过的采集链路里,一旦业务日志量上来,logstash的JVM堆、线程池、队列积压都会集体报警。相比之下,filebeat是Go写的,内存占用单位是几十MB,处理器(processors)机制虽然不如logstash filter插件丰富,但处理JSON这种有固定结构的日志足够用了。
三者对比一下就很直观:
| 处理位置 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| filebeat processors | 轻量、就近处理、不增加额外节点 | 内置处理器有限,复杂正则和跨事件处理弱 | 字段裁剪、格式化、简单脱敏、结构化解析 |
| logstash filter | 插件最丰富,split/ruby/grok都强大 | 额外资源开销大,高吞吐易积压 | 复杂解析、事件拆分、多数据源关联 |
| es ingest pipeline | 与ES深度集成,可在索引模板统一挂载 | 写入阶段处理会拖慢索引速度,冲突排查难 | 轻量标准化、统一加字段 |
以我这边的情况为例:日志量大、字段结构相对固定、每行JSON是完整独立的,这种场景放filebeat处理几乎是零成本。唯一要做的是控制好处理器数量,别在一个事件上堆十几个正则脚本。反过来说,如果日志格式混乱到必须用grok反复试、或者一个事件要拆成多个独立文档,那确实更适合交给logstash。
另外还有一层考虑:ES的索引mapping一旦生成,想改字段类型只能reindex。如果在采集端就把字段拍平、把无用字段删干净,相当于从根源上避免了后面mapping爆炸和reindex的麻烦。这也是我一直坚持“处理逻辑尽量前移”的原因。
2. 解析JSON的两条技术路线:ndjson解析器与decode_json_fields处理器
2.1 filestream输入下的ndjson解析器
如果你用的是新版filebeat(7.13+,目前8.x完全没问题),推荐直接用filestream类型的input,它自带ndjson解析器,采集JSON日志时可以少写一个processor。基本配置长这样:
filebeat.inputs: - type: filestream id: app-json-log enabled: true paths: - /data/logs/app/*.log parsers: - ndjson: target: "" overwrite_keys: true add_error_key: true expand_keys: false这几个参数值得逐个说清楚。target控制解析后的JSON对象放到哪里,设为空字符串表示把JSON里的每个key直接提升为事件根字段;如果填json,解析结果会整体放到json这个字段下面,后面再配合rename或drop_fields使用。overwrite_keys决定是否允许JSON字段覆盖filebeat自动生成或前面已经存在的字段,我下面会专门讲它的坑。add_error_key设为true的话,某一行解析失败时会在事件里写error.message和error.type两个字段,这个强烈建议开启,不然解析失败你连日志都找不到。
2.2 老项目里常见的decode_json_fields处理器
如果你的采集器还在用老的log类型,或者日志已经在input层面被做成了其他结构,那就用decode_json_fields处理器把message字段里的JSON字符串解析出来:
filebeat.inputs: - type: log enabled: true paths: - /data/logs/app/*.log processors: - decode_json_fields: fields: ["message"] target: "" overwrite_keys: true add_error_key: true这两套方案的实际效果几乎一样,区别在于执行时机。ndjson解析器是在输入阶段完成解析,decode_json_fields则是在processors阶段跑。如果你需要同时处理多行合并、或者先把message复制一份再解析,用processor更灵活。但日常场景下,filestream + ndjson解析器性能更好,也更符合官方推荐方向。
2.3 解析阶段最容易踩的三个坑
第一个坑是overwrite_keys的冲突问题。JSON日志里如果带了host、agent、ecs这类字段,一旦overwrite_keys设为true,解析结果就可能把filebeat自动生成的ECS字段覆盖掉。我遇到过日志里有个agent字段,结果采集端指标和Kibana里的agent信息全变成了日志里的值,排查了半天。解决方案很简单:要么overwrite_keys保持默认的false,只解析不覆盖;要么解析前先drop_fields把日志里这些同名保留字样的字段删掉。更稳的做法是给target指定一个独立字段名,比如json,再按需rename上去。
第二个坑是换行和多行问题。JSON日志如果只是标准的一行一条,哪怕字段再多,解析都很顺利。但一旦某个字段里带了未转义的换行符、或者日志以pretty-print格式输出,单行解析就会失败或者只截到一半。解决办法是在解析JSON之前先把多行合并成一条完整记录。filestream输入里可以通过配置多个parser实现:
parsers: - multiline: type: pattern pattern: '^\{' negate: true match: after - ndjson: target: "" overwrite_keys: true add_error_key: true这里把以{开头的行作为新事件的起始,后续不匹配的行都合并到当前事件,直到下一个{出现。顺序很关键:multiline解析器必须排在ndjson前面。
第三个坑是原日志丢失的问题。target: ""解析后,原始的message字段默认会被替换掉。有些场景你其实想保留原始日志备查,那就提前复制一份:
processors: - copy_fields: fields: - from: message to: raw_message fail_on_error: false - decode_json_fields: fields: ["message"] target: ""注意copy_fields要在decode_json_fields之前执行,否则message已经被替换成解析后的对象,复制的就是结构而不是原始字符串了。
3. 去掉不需要的JSON属性:drop_fields、条件过滤与脱敏
JSON日志里最常见的需求就是删字段。有些字段太大,比如request_body、response_data,存进去纯浪费磁盘;有些字段太敏感,比如token、密码、手机号;还有些字段只在排障时需要,比如上面说的debug_stack。删字段这件事看着简单,但怎么做干净、不影响业务排查,还是有点讲究的。
3.1 drop_fields直接删除
最直接的方案是用drop_fields处理器,把字段路径一个一个列出来。比如解析后字段都在根层级,想删掉三个字段,配置如下:
processors: - drop_fields: fields: ["request_body", "debug_stack", "client_token"] ignore_missing: true这里有个非常容易忽略的点:如果decode_json_fields或ndjson解析时设置了target: json,那所有字段都在json这个对象下面,drop_fields里就必须写完整路径:
processors: - drop_fields: fields: ["json.request_body", "json.debug_stack", "json.client_token"] ignore_missing: true另外drop_fields不支持正则或通配符,字段多的话只能逐个枚举。如果遇到一批前缀相同但后缀动态变化的字段,比如param_1、param_2这种,drop_fields就无能为力了,只能往下看script方案。
3.2 用when条件整条丢弃
有些日志是要整条扔掉的,而不是删字段。比如服务健康检查的探活请求、DEBUG级别以上的垃圾日志。这种用when条件配合drop_event更高效,事件直接不进输出端,比删字段省事得多:
processors: - drop_event: when: equals: request_path: "/health/check" - drop_event: when: equals: level: "DEBUG"条件判断支持equals、contains、regexp、has_fields等多种。建议把这种高过滤率的条件写在processors列表比较靠前的位置,后续的解析和裁剪就没必要白跑一趟了。
3.3 不能删的字段用script脱敏
有些字段业务上还想用,但不能再以明文存进去,这时候就得做脱敏。filebeat的script处理器基于JavaScript(Goja引擎,语法是ECMAScript 5.1),实现一个递归脱敏的小函数很简单:
processors: - script: lang: javascript source: >- function redact(obj) { for (var k in obj) { if (k === "token" || k === "password" || k === "mobile") { obj[k] = "***"; } else if (typeof obj[k] === "object" && obj[k] !== null) { redact(obj[k]); } } } redact(ctx);这里的ctx是事件对象,遍历所有顶层字段,遇到敏感字段名就置为***,遇到嵌套对象就递归进入。数组也能处理,因为数组在JavaScript里也是object,遍历下标元素即可。要注意的是,script处理器只支持ES5语法,别写箭头函数、let、模板字符串这些新特性,否则启动直接报语法错误。
实际用的过程中我个人的习惯是:能用drop_fields解决的绝不上script。drop_fields走的是字段删除,性能极好;script每次都要解释执行JavaScript,在日志量大的场景下浪费的CPU是实打实的。
4. JSON扁平化的几种落地方式:expand_keys、rename与脚本拍平
日志JSON里嵌套层级一多,ES里的mapping就会出现一堆object类型。object字段在ES里固然能存,但做聚合、排序、可视化的时候很受限制。比如你想按user.name做terms聚合,如果它是嵌套对象下的子字段,写查询的时候就得用点路径,而且一旦上层还有数组对象,行为会和你预期完全不同。所以在采集端把嵌套结构拍平,是我做JSON日志采集默认要干的一件事。
4.1 方案一:ndjson解析器的expand_keys
如果你已经用了filestream + ndjson解析器,扁平化是带原生支持的,只需把expand_keys打开:
parsers: - ndjson: target: "" overwrite_keys: true add_error_key: true expand_keys: true效果是把JSON里的嵌套key全部展开成点分割的key。比如{"order": {"id": 123, "user": {"name": "张三"}}}会变成order.id: 123、order.user.name: "张三"。这里要注意的是,展开后的字段名里带了点,ES在mapping中仍然会按照点路径把它理解成order下的子字段,但至少你可以直接针对order.user.name做聚合和排序了,不会因为object嵌套导致查询逻辑绕来绕去。
expand_keys不会展开数组中的对象。比如{"items": [{"sku": "A"}]},items仍然是一个数组,数组元素的对象不会被拍平。这个限制基本够用——数组一般不需要拍平,保持数组类型反而更适合做nested查询。
4.2 方案二:rename逐个重命名
如果你不想用expand_keys全局展开,或者解析时已经把整个JSON放进了json字段下,那就用rename处理器把需要的子字段提升到根层级,同时用下划线替代点来避免ES路径歧义:
processors: - rename: fields: - from: "json.order.id" to: "order_id" - from: "json.order.user.name" to: "user_name" ignore_missing: true注意rename的ignore_missing必须设true,否则只要有一个事件缺这个字段,整条处理流程就会报错,事件可能被丢弃。这个方案的好处是明确可控,适合只关心日志里少数几个字段的场景,缺点是一百个字段就得写一百条映射,配置会很长。
4.3 方案三:script递归拍平全部字段
如果日志结构变化频繁、字段数量又多,写rename不现实,我建议直接用script递归遍历事件,把嵌套对象全部拍平成parent_child这样的下划线命名:
processors: - script: lang: javascript source: >- function flattenInto(obj, prefix, out) { for (var key in obj) { var val = obj[key]; var newKey = prefix ? prefix + "_" + key : key; if (val !== null && typeof val === "object" && !Array.isArray(val)) { flattenInto(val, newKey, out); } else { out[newKey] = val; } } } var topKeys = Object.keys(ctx); var merged = {}; for (var i = 0; i < topKeys.length; i++) { var k = topKeys[i]; var v = ctx[k]; if (v !== null && typeof v === "object" && !Array.isArray(v)) { flattenInto(v, k, merged); delete ctx[k]; } } for (var mk in merged) { ctx[mk] = merged[mk]; }这个脚本把数组当作叶子处理,不把数组元素撑开,避免一个事件被拍出成百上千个新字段。实际跑下来,对几KB的日志行来说性能完全可接受,真正遇到瓶颈的往往是output端的网络分发。
三种方案怎么选,我列个表总结一下:
| 方案 | 适合场景 | 限制 |
|---|---|---|
| expand_keys | 想保留点路径格式、简单快捷 | 只适用于ndjson解析器,不展开数组 |
| rename | 只关心少数核心字段 | 字段多时代码量大 |
| script | 字段不确定、层级深的日志 | 需要懂一点JavaScript,有执行开销 |
我个人偏好是能开expand_keys就开,取不到再上script。rename一般只在需要做脱敏和字段标准化时用,毕竟它是原子操作,和drop_fields搭配最顺手。
5. 拆分JSON日志的务实做法:能拆的与不能拆的
“拆分JSON日志”这个话题在标题里看着很诱人,但要把话先说清楚:filebeat的处理模型是“一个事件进、一个事件出”,processors链路上没有原生的split处理器,硬要用脚本去拆成多个事件是不现实的。我见过有人试图在script里把原事件删除、再往ctx里塞数组,结果输出端要么报错要么只投递了最后一个元素,问题更严重。
但是“拆分JSON日志”在日常实践中确实有几种真实需求,区别在于拆的维度不同。
5.1 跨行JSON的合并与拆分
最典型的情况是日志把JSON按pretty-print输出,一个对象占了好几行。这时候要做的是把多行先合并成完整的一条JSON,而不是真的“拆开”。前面已经给了multiline配置,强调一下:this pattern匹配必须以唯一能标识事件开头的字符串为准,我用的是^\{,如果你的日志有统一前缀,比如2024-01-01T12:00:00Z INFO {,就把pattern改成匹配时间戳开头更稳。
反过来还有一种场景:一行里塞了多个JSON对象,用逗号或分号分隔。这种情况filebeat没法自动切分,我的建议是在应用打日志的时候就改成一行为一条JSON。如果改不动上游,宁可把日志丢给logstash的split插件处理,也别在filebeat里自己写正则硬拆。
5.2 数组字段拆成多个独立文档
如果你真正想要的是“一条JSON里的数组拆成多个文档”,比如订单日志里items数组有10个商品,希望每个商品变成一条独立日志,那这个活更适合交给logstash:
filter { json { source => "message" } split { field => "items" } }split插件会把items数组里的每个元素复制一份作为独立事件,并且把数组字段替换成单元素。这样下游的ES里就能按商品维度做统计。代价是这条链路必须引入logstash,如果你不想加节点,那就只能回到上游应用去改日志输出逻辑,在写日志时直接按数组元素拆成多行。
5.3 在filebeat里能做的“拆分”其实是字段拆分
对于大多数不需要拆文档的场景,filebeat能做的拆分是“把一个JSON对象拆成多个字段组,分别保留需要的信息、丢弃没用的信息”。前面讲的drop_fields、rename、script已经覆盖了这个能力。我给一个组合示例:解析成根字段后,把订单基础字段提升为order_前缀,把用户字段脱敏保留,其余全部丢弃:
processors: - drop_event: when: equals: event_type: "metrics" - rename: fields: - from: "order.id" to: "order_id" - from: "order.total" to: "order_total" - from: "user.mobile" to: "user_mobile" ignore_missing: true - script: lang: javascript source: >- if (ctx.user_mobile) { var m = String(ctx.user_mobile); ctx.user_mobile = m.substring(0, 3) + "****" + m.substring(7); } - drop_fields: fields: ["order", "user", "request_body", "debug_stack"] ignore_missing: true这样处理之后,事件里的字段数量从几十个降到几个,索引瘦身效果极其明显。用这种方式理解“拆分”,filebeat不仅能做,而且做得非常好。
6. 一份可直接抄的filebeat.yml和压测中的几个发现
说了这么多,最后给一份我在模拟项目X里实际使用的完整配置。这个场景是基于某订单系统日志,目标是:解析JSON、去掉大字段和敏感字段、拍平嵌套结构、只保留核心字段进入ES。
filebeat.inputs: - type: filestream id: app-json-log enabled: true paths: - /data/logs/app/*.log parsers: - multiline: type: pattern pattern: '^\{' negate: true match: after - ndjson: target: "" overwrite_keys: true add_error_key: true expand_keys: true processors: - add_host_metadata: when: not: has_fields: ["host.name"] - drop_event: when: equals: level: "DEBUG" - drop_fields: fields: ["request_body", "response_data", "debug_stack"] ignore_missing: true - script: lang: javascript source: >- function redact(obj) { for (var k in obj) { if (k === "token" || k === "password" || k === "mobile") { obj[k] = "***"; } else if (typeof obj[k] === "object" && obj[k] !== null) { redact(obj[k]); } } } redact(ctx); output.elasticsearch: hosts: ["es-prod:9200"] index: "app-json-%{+yyyy.MM.dd}" pipeline: "app-json-final"配置里用到的变量和主机名按你自己的环境替换。启动前务必先做配置校验:
filebeat test config -c filebeat.yml filebeat test output -c filebeat.yml本地调试的时候,我强烈建议先别接真正的ES,用console输出代替,先看处理器结果对不对:
output.console: pretty: true然后用filebeat -e -d "processors"前台跑,观察每个处理器处理前后的数据变化。这个组合拳能解决绝大多数“字段莫名消失”“解析没生效”的疑难杂症。
关于性能,我用模拟数据压过一轮:本地生成10万行、每行约1.5KB的JSON日志,4核8G的测试机上单实例filebeat采集,开启expand_keys加两个drop_fields加一个script脱敏,整个过程跑完约30秒,峰值CPU在25%左右,常驻内存不到50MB。其中解析和处理的耗时占比低得可以忽略,大头全在输出端的网络投递上。这个结果让我更坚定了一点:只要处理器写得克制,filebeat在采集端做JSON处理完全够用,没必要把日志拉到logstash再折腾。
最后分享一个小习惯:每次改完filebeat配置,我都会先跑一遍filebeat test config,然后用console输出打印前50条事件确认字段全貌,再切回真实output。这套流程看着多花两分钟,实际帮我省下的返工时间远不止两分钟。采集端处理JSON日志这件事,思路理顺了其实很简单:能删的早删,能平的就平,拆不了的事别硬拆,交给合适的工具。