☰
Filebeat JSON日志处理:字段裁剪、扁平化与拆分实操指南
2026/10/24 3:10:05 网站建设 项目流程

维护日志链路的朋友大概率都遇到过这种场景:应用明明打的是标准JSON,到了Kibana里一看,有的字段变成了一长串嵌套路径,有的关键字段被Filebeat自带字段覆盖了,还有一堆调试用的冗余字段把索引空间撑得很大。标题里拆出来的本质上是三个需求:去掉JSON日志里的部分属性、把嵌套JSON拍平、按业务需求拆分JSON里的内容。这篇就围绕这三个问题,用Filebeat完整走一遍配置、排错和落地思路。

先说清楚这篇内容解决什么:不依赖Logstash、不写ES端复杂管道,尽可能用Filebeat自带的解析能力和处理器(processor)完成JSON日志的采集、字段裁剪、扁平化与拆分。适合正在用Elastic Stack做日志平台、被同事吐槽“Kibana里字段没法看”的开发者,也适合刚接手Filebeat、想知道json.keys_under_root和decode_json_fields到底怎么选的新手。

1. 项目概述与需求拆解

1.1 标题背后的三个核心需求

表面看这只是“Filebeat采集JSON日志”,但结合实际运维场景,诉求其实非常具体。

第一是“去掉部分属性”。业务日志里经常混入大量不必要的内容:调试用的堆栈、内部上下文字段、请求链路里的大对象。如果全部写入Elasticsearch,存储成本会涨,索引mapping也会被这些脏字段撑出一堆动态映射。所以要做字段裁剪。

第二是“JSON日志扁平化”。默认情况下,Filebeat把JSON解析后,如果内容里还有嵌套对象,进入ES后会形成嵌套结构,比如context.orderId、context.userId。这带来的问题是:查询时要写很长路径,Kibana可视化里字段展示很别扭,Painless脚本里取字段也要写完整路径。把嵌套路径拍平成orderId、userId这类顶层字段,就是所谓的扁平化。

第三是“拆分JSON日志”。这个词最容易产生歧义。一种理解是把嵌套对象里的字段拆出来变成独立顶层字段,这其实和扁平化是一回事;另一种理解是日志内容里有一个数组,数组里每个元素代表一条独立业务事件,要把一条日志拆成多条记录。这两种“拆分”的实现方式完全不同,后面会分别说清楚。

1.2 为什么这件事要用Filebeat做

日志从产生到进入Elasticsearch,一共有四个可以干预处理的位置:应用自身、Filebeat、Logstash、ES的Ingest Pipeline。

应用自身处理是最理想的,但现实很骨感:日志格式由业务代码决定,采集链路的维护者往往改不动别人的代码,或者改一发版要排很久。Logstash虽然处理能力强,但中间多一跳组件,部署、监控、维护都多一摊事。ES的Ingest Pipeline要占ES节点CPU,日志量一大容易拖慢写入。

Filebeat的优势在于:资源占用极小,配置灵活,自带的processor已经能覆盖绝大多数JSON处理需求。对中小规模业务来说,直接在Filebeat里把解码、裁剪、扁平化都做完,是性价比最高的方案,数据在传输前就瘦身了,带宽和存储都省。

1.3 明确处理链路与输出目标

为了后面演示不跑偏,先把目标链路定下来:Filebeat读取特定目录下的日志文件;按行解析JSON;删除明确不需要的冗余字段;对嵌套对象做扁平化;将业务时间戳转换为@timestamp;最后输出到Elasticsearch。整条链路只依赖Filebeat本身,不引入Logstash。

这个链路里最关键的是搞清楚每个处理动作的顺序,因为processor是按顺序执行的,前一个的输出就是后一个的输入。顺序错了,结果会千奇百怪。这个点我会在后面的实战配置里反复强调。

2. Filebeat环境与配置基础

2.1 filebeat.yml的配置层级

展开配置之前,先把Filebeat的配置模型讲清楚。filebeat.yml里四个核心层级:filebeat.inputs定义从哪读、parsers或input配置项定义怎么解析、processors定义事件进入输出之前怎么加工、output定义最终发到哪。

一个不错的类比是工厂流水线:input是原料入口,parser是拆箱机,processor是流水线上的一组工位(去杂质、贴标签、改包装),output是出货口。每个工位按顺序工作,前面工位的产物就是后面工位的输入。

2.2 两种JSON解码方式对比

Filebeat处理JSON日志,常见的解码方式有三种,很多人一上来就混用,导致结果莫名其妙。

第一种是log input的json配置项,经典但偏老。配置写法是:

filebeat.inputs: - type: log paths: - /var/log/app/*.log json.keys_under_root: true json.overwrite_keys: true json.add_error_key: true json.message_key: "message"

json.keys_under_root: true表示把JSON最外层字段直接放到事件的根级别;json.overwrite_keys: true表示如果业务JSON里有字段和Filebeat自带字段同名,允许覆盖;json.add_error_key: true表示解析失败时加一个error字段,便于排查。

第二种是filestream input的ndjson parser,这是新版本推荐的方式:

filebeat.inputs: - type: filestream id: app-json-logs paths: - /var/log/app/*.log parsers: - ndjson: target: "" overwrite_keys: true add_error_key: true

filestream input是Filebeat 7.x开始重点发展的事件流式采集方式,但它不支持log input的json.*配置,改用parsers里的ndjson。target设为空字符串,效果等同于json.keys_under_root: true。

第三种是用decode_json_fields processor,这是最灵活的方式,也是我比较推荐的方式:

processors: - decode_json_fields: fields: ["message"] target: "" overwrite_keys: true

它做的事情是把事件里message字段的内容当作JSON解析,然后按target参数决定放哪里。与input级json配置最大的区别是:decode_json_fields是processor,可以和其他processor任意组合,也可以用条件判断控制它只对某些事件生效。

三种方式的取舍,用表格看更直观:

对比项log input的json配置filestream的ndjson parserdecode_json_fields processor
适用inputlogfilestream两者都可用
作用时机input读取阶段input读取阶段事件处理阶段
能否与其他processor自由组合受限受限可以
能否对部分事件生效否否可以
版本趋势老项目常见新项目推荐通用

实际项目中我的习惯是:老版本存量配置用json.keys_under_root不动它,新写的配置统一用filestream input加decode_json_fields processor,这样处理逻辑都集中在一个地方,排查问题不用翻好几个配置段。

2.3 processor执行顺序的关键性

processors数组里的顺序就是执行顺序,这一点极其重要。举个例子,如果你先做drop_fields删掉message字段,再做decode_json_fields去解message,这时message已经没了,decode会跳过或报错。反过来,先decode把message里的内容解析成字段,再drop_fields删掉冗余字段,逻辑才是通的。

我在真实项目里见过最典型的错误是:在decode_json_fields之前加了一个rename,把message改名了,结果decode_json_fields找不到字段,整个JSON解析静默失败,原始字符串原封不动进了ES。排查流程走了好久,最后发现就是一行配置顺序的问题。

2.4 字段保留策略的隐藏风险

当JSON字段被放到事件根级别后,事件里会同时存在三类字段:Filebeat内部字段(@timestamp、@metadata、host、log、agent等)、业务字段、可能存在的ECS字段。做字段裁剪前,必须清楚哪些字段是ES和Kibana运行依赖的。

比如include_fields白名单,如果fields列表里忘了@timestamp,那么@timestamp会在处理中被过滤掉,日志写入ES后时间轴直接乱掉。这个坑很多人踩过,而且排查难度高,因为数据能进ES,看起来一切正常,就是Kibana时间不对。做白名单保留时,默认把@timestamp、log、host这类系统字段带上再删业务字段,会稳妥很多。

3. JSON日志扁平化:核心实操

3.1 为什么日志要扁平化

拿一条实际日志举例,原始JSON长这样:

{ "timestamp": "2024-01-15T10:30:00Z", "level": "INFO", "logger": "OrderService", "message": "订单创建成功", "context": { "orderId": "ORD-12345", "userId": 10086 } }

如果不做扁平化,context整体会作为一个嵌套对象进入ES,Kibana字段列表里看到的就是context.orderId、context.userId,查询要写完整点号路径,建看板时要一层层展开。如果业务里嵌套三层四层,mapping会越来越乱,动态映射产生的字段也会暴涨。

扁平化的目标是让context.orderId变成orderId这样的顶层字段,或者至少变成带前缀的扁平字段,让查询和展示都直观。

3.2 target: ""实现的是“浅扁平化”

很多人误以为decode_json_fields的target设为空字符串,就能把所有嵌套层级都拍平。实际上target: ""只是把JSON最外层的字段提升到事件根级别。打个比方,它拆掉了外包装箱,但里面的小盒子还在。

对于只有一层嵌套的场景,例如JSON里所有字段本来就在顶层,那target: ""确实够用。但如果JSON里本身有嵌套对象,比如context字段下还有orderId,target: ""之后context还是嵌套对象,并没有真正展开。这是“浅扁平化”和“深度扁平化”的区别。

3.3 深度扁平化:script processor递归展平

要实现真正的深度扁平化,Filebeat的script processor可以派上用场。这个processor支持JavaScript,可以对事件做任意操作。下面是我在项目里验证过的一段递归展平脚本:

processors: - script: lang: javascript source: > function flattenNode(node, prefix, output) { for (var key in node) { if (!node.hasOwnProperty(key)) continue; var fullKey = prefix ? prefix + '_' + key : key; var value = node[key]; if (value !== null && typeof value === 'object' && !Array.isArray(value)) { flattenNode(value, fullKey, output); } else { output[fullKey] = value; } } } function process(event) { var doc = event.Get(); if (doc['context'] !== undefined) { var flat = {}; flattenNode(doc['context'], 'ctx', flat); for (var k in flat) { event.Put(k, flat[k]); } event.Delete('context'); } }

这段脚本的意图是:把context对象里的所有嵌套字段递归展开,新字段名用下划线拼接,比如context.orderId变成ctx_orderId,context.userId变成ctx_userId,然后把原始的context字段删除。

注意:Filebeat script processor的JavaScript API和Elasticsearch的script processor不一样,这里用的event.Get、event.Put、event.Delete是Filebeat事件对象的原生方法。写脚本时先看对应版本的文档,不同大版本之间API可能有调整。

3.4 关于扁平化字段名的关键认知

这里有个特别容易踩的知识点:如果你想拍平字段,新字段名里千万别带点号。比如把context.orderId改名为context.orderId(还是原来那个名字),这在ES里依然会被当作嵌套路径处理,等于白干。真正要做到扁平化,字段名里不能有点号分隔符,要么用下划线,要么用自定义前缀。

这个道理类似文件路径:a/b/c是路径分隔,a_b_c就是一个普通文件名。ES的字段名机制也一样,点号就是路径分隔符。理解了这一点,你就知道为什么很多人“扁平化”之后Kibana里还是嵌套结构。

3.5 扁平化后的字段名冲突处理

扁平化时最常见的冲突是:两条嵌套路径展平后使用了同一个字段名。例如context.orderId和data.orderId,如果都直接展平成orderId,后写的事件会覆盖先写的字段。

稳妥的做法是带上前缀。我上面的脚本生成ctx_orderId,就是把所有字段都加了ctx前缀,这样从context里出来的字段天然不会和日志顶层的orderId冲突。如果业务上有多个嵌套对象需要展开,建议分别为它们设置不同的前缀,干脆避免冲突,别指望ES能帮你智能合并。

4. 去掉部分属性:三种方案的对比与选型

4.1 黑名单方案:drop_fields直接删

最简单的裁剪手段是drop_fields,写法很直接:

processors: - drop_fields: fields: ["extra_verbose_data", "context.items"] ignore_missing: true

ignore_missing: true表示字段不存在时不会报错,这在处理多格式日志时很重要,不然某个字段在这个事件里没有,整个processor会报异常。

这个方案的适用场景是:字段总量可控,你明确知道哪些字段是多余的。比如日志里有一个巨大的extra_verbose调试字段,直接删掉最省事。

需要注意,drop_fields删嵌套字段时,要写的是事件里的实际路径。如果前面已经做了扁平化,原来的context.orderId已经变成ctx_orderId了,这时候再写context.orderId肯定删不到东西。

4.2 白名单方案:include_fields只留需要的

字段特别多、真正用的没几个时,相反的做法更高效:只保留关心的字段。

processors: - include_fields: fields: ["@timestamp", "log", "host", "level", "logger", "message", "ctx_orderId", "ctx_userId"]

白名单的好处是:业务日志里以后新增了字段,也不会自动进入ES,索引mapping非常干净,不会出现动态字段泛滥。代价是每次想看一个新字段,都要改配置,灵活性差一些。

这里我必须再次强调:include_fields的列表一定要带上@timestamp、log、host这些系统字段。我自己就吃过亏,第一次配白名单时只想着留业务字段,结果时间字段被过滤掉了,Kibana里所有日志的时间全乱了,排查了很久才定位到是这个原因。

4.3 服务端方案:ES Ingest Pipeline

如果处理逻辑希望集中在ES端,可以在Elasticsearch里建一个ingest pipeline:

PUT _ingest/pipeline/app-log-json { "processors": [ { "remove": { "field": "extra_verbose_data" } }, { "rename": { "field": "context.orderId", "target_field": "ctx_orderId" } } ] }

然后在Filebeat的output里引用:

output.elasticsearch: hosts: ["http://localhost:9200"] index: "order-app-%{+yyyy.MM.dd}" pipeline: "app-log-json"

这个方案的优势在于:逻辑集中在ES端,多个采集器过来都能复用同一套处理规则,改一次全链路生效。劣势也明显:冗余字段已经通过网络传到了ES,白白占用带宽,而且ingest节点在高峰期的CPU开销不可忽视。

4.4 方案对比与选型建议

方案执行位置节省传输带宽新增字段自动保留适合场景
drop_fieldsFilebeat是是明确知道要删哪些字段
include_fieldsFilebeat是否,需改配置字段极多且只需要少数几个
ES Ingest PipelineES否是多采集器复用同一套规则

我的经验是:中小规模日志链路优先全用Filebeat处理。先decode,再drop_fields删明确冗余的字段;如果字段确实太杂,改用include_fields白名单,但一定把系统字段带上。单日志量非常大的场景,建议把复杂的字段重命名和类型转换放到ES pipeline里,Filebeat只做最基础的解码和裁剪,降低Beat端的CPU压力。

5. JSON日志拆分:字段拆分与事件拆分

5.1 先搞清楚“拆分”指什么

“拆分JSON日志”这个词确实容易让人犯迷糊。我基于实际需求把它拆成两类。

第一类是字段拆分:把嵌套对象里的字段拆出来变成顶层字段。比如context里的orderId拆成ctx_orderId,这个本质上就是前文讲的扁平化,只是换了种说法。

第二类是事件拆分:日志里有一个JSON数组,数组中每个元素代表一条独立业务事件,希望一条日志变成多条记录进入ES。比如订单日志里orders数组包含多笔订单,你想让每笔订单成为一条独立日志。

这两种需求的技术路径完全不同,下面分开讲。

5.2 字段拆分的实现思路

字段拆分完全可以在Filebeat内用script processor完成。比如下面这段脚本把orders数组里每个元素展开成order_0_id、order_0_amount这样的字段:

processors: - script: lang: javascript source: > function process(event) { var orders = event.Get('orders'); if (orders && Array.isArray(orders)) { for (var i = 0; i < orders.length; i++) { event.Put('order_' + i + '_id', orders[i].orderId); event.Put('order_' + i + '_amount', orders[i].amount); } event.Delete('orders'); } }

这样做的前提是数组长度相对固定。因为数组一旦变成order_0_id、order_1_id这种固定字段,ES的mapping会随数组长度动态增加字段,如果长度不可控,mapping会很难看,字段会无限膨胀。所以字段拆分只适合数组长度有限且稳定的场景。

5.3 事件拆分的正确姿势:Filebeat加Logstash

把一条日志拆成多条记录,Filebeat本身没有这个能力,这是它的边界。要完成事件拆分,标准方案是Filebeat把原日志原样发给Logstash,由Logstash的split filter处理。Logstash配置示例:

input { beats { port => 5044 } } filter { json { source => "message" target => "body" } split { field => "[body][orders]" target => "order" } mutate { remove_field => ["body", "message"] } } output { elasticsearch { hosts => ["http://localhost:9200"] index => "order-detail-%{+yyyy.MM.dd}" } }

split filter的工作原理可以这样理解:父事件进来了,orders数组有几个元素,它就复制出几个事件副本,每个副本里把orders数组的某个元素赋值到order字段上,同时保留父事件的其他字段。最后用mutate把body、message这些中间字段删掉,输出的就是干净的独立订单事件。

这里有一个业务上的取舍:事件拆分后,每条日志携带的上下文(比如请求ID、用户ID)会被复制到每条记录中,这会让存储膨胀,但查询体验好很多。如果业务上查询单笔订单时不想关联回父日志,这个代价是值得的。

5.4 拆分后的索引设计建议

事件拆分后,数据量通常比原来多好几倍。写入ES时要注意两点:一是给拆分后的事件设置合理的索引分片数,不要沿用父日志的索引配置;二是在输出端尽量让ES做幂等写入,用业务订单号做文档ID,避免重复采集时产生重复文档。

如果不想为拆分额外维护一套Logstash,也可以在应用端直接修改日志结构,把数组在日志产生时就打成多条独立JSON行。这个方案最干净,但前提是你能改业务代码。

6. 完整实战配置:一个可复现的Demo

6.1 演示日志样例

为了把配置讲具体,模拟一份订单应用日志,存到/var/log/app/order-app.log:

{"timestamp": "2024-01-15T10:30:00Z", "level": "INFO", "logger": "OrderService", "message": "订单创建成功", "context": {"orderId": "ORD-12345", "userId": 10086}, "items": ["SKU001", "SKU002"], "extra_verbose": {"traceId": "trace-01", "stack": "..."}}

这份日志包含了我们要演示的所有处理点:timestamp需要转成@timestamp,context需要扁平化,extra_verbose需要删除,items数组要保持原样。

6.2 filebeat.yml完整配置

下面是一份可直接复用的完整配置:

filebeat.inputs: - type: filestream id: order-app-log paths: - /var/log/app/order-app.log parsers: - ndjson: target: "" overwrite_keys: true add_error_key: true processors: - timestamp: field: timestamp layouts: - '2006-01-02T15:04:05Z' - '2006-01-02T15:04:05.999Z' timezone: Asia/Shanghai ignore_failure: true - drop_fields: fields: ["extra_verbose"] ignore_missing: true - script: lang: javascript source: > function flattenNode(node, prefix, output) { for (var key in node) { if (!node.hasOwnProperty(key)) continue; var fullKey = prefix ? prefix + '_' + key : key; var value = node[key]; if (value !== null && typeof value === 'object' && !Array.isArray(value)) { flattenNode(value, fullKey, output); } else { output[fullKey] = value; } } } function process(event) { var doc = event.Get(); if (doc['context'] !== undefined) { var flat = {}; flattenNode(doc['context'], 'ctx', flat); for (var k in flat) { event.Put(k, flat[k]); } event.Delete('context'); } } output.elasticsearch: hosts: ["http://localhost:9200"] index: "order-app-%{+yyyy.MM.dd}"

配置里要解释几个关键点。

timestamp processor的layouts用的是Go时间布局格式,不是Java的yyyy-MM-dd格式。Go格式里,2006代表年、01代表月、02代表日、15代表小时、04代表分钟、05代表秒。这是Filebeat和Logstash时间格式最大的不同,很多人第一次用都会在这里翻车。

ignore_failure: true的含义是:时间解析失败时,processor不会让整个事件处理中断,而是继续往下走。这在实际生产环境里很有用,因为偶尔会出现一条格式异常的日志,你不能因为一条脏数据把整批事件都堵住。

drop_fields放在timestamp之后,没有依赖关系,但放在decode之后是关键,因为extra_verbose是解析后的字段,不是原始日志行里的路径。

script processor的flattenNode递归函数会生成ctx_orderId、ctx_userId这些带前缀的扁平字段,然后删除原始的context对象。如果context里还有更深层的嵌套,这个递归函数会继续往下展开,直到所有非对象字段都变成顶层字段。

6.3 启动与验证步骤

写完配置先别急着上生产,本地跑通再说。三个命令很实用:

filebeat test config -c filebeat.yml filebeat test output -c filebeat.yml filebeat -e -c filebeat.yml

第一条检查配置语法,第二条检查output连接是否可用,第三条前台运行并输出日志。如果处理逻辑复杂,我习惯先把output改成console做本地调试:

output.console: pretty: true

这样可以直观看到每个事件经过processors之后长什么样,确认字段都对了,再把output切回elasticsearch。这个调试手法帮我省了大量去Kibana刷index的时间。

6.4 验证结果与字段对比

经过上面配置处理后,原始日志字段会变成这样:

原始字段路径处理后字段处理方式
timestamp@timestamp、timestamptimestamp processor转换
context.orderIdctx_orderIdscript processor扁平化
context.userIdctx_userIdscript processor扁平化
extra_verbose已删除drop_fields
itemsitems原样保留
level、logger、message原样保留根级字段

在Kibana的Discover页面里,如果索引模板已经生成,打开字段列表就能看到ctx_orderId、ctx_userId这些顶层字段,不再有context这个嵌套对象。items作为数组字段保留,方便后续做聚合分析。

7. 常见问题与排查技巧实录

7.1 日志解析失败却没有任何报错

最常见的现象是:原始日志明明是JSON,写入ES后message字段还是完整字符串,或者干脆多了一个error字段。原因通常是add_error_key没开启,解析失败的信息被Filebeat吞掉了。

排查时先做的不是改配置,而是用console output直接看Filebeat内部事件长什么样:

output.console: pretty: true

如果事件里的message还是原始字符串,说明decode_json_fields根本没匹配到那个字段,检查fields写的对不对。如果事件里有很多error字段,说明JSON解析失败,把日志原文复制出来用格式化工具检查一下,多半是日志里混了多行JSON或者非JSON文本。

7.2 时间字段被覆盖或丢失

Kibana日志时间全是Filebeat采集时刻的实时时间,而不是业务日志本身的时间。这个问题的根源是:没有配置timestamp processor,或者配置了但field写错了,又或者字段在include_fields时被误删。

把业务时间字段转成@timestamp是JSON日志处理的标准动作。同时记得,如果业务字段里也有一个@timestamp或类似字段,decode时overwrite_keys开启后,业务字段也可能反过来覆盖系统时间,导致时间错乱。这种情况建议把业务时间字段rename成一个业务专属名,比如event_time,再用timestamp processor转系统时间。

7.3 嵌套字段依然存在,扁平化没生效

我在实际项目中经历过这种场景:按照网上教程配置了decode_json_fields的target: "",以为能完全展平,结果context还是嵌套在ES里。原因前文已经说过,target: ""只是拆外包装,要完全展开必须用递归脚本。

还有一个隐蔽问题:如果脚本生成的字段名里带了点号,比如直接写成ctx.orderId,那么ES仍然会把它当作嵌套路径。字段名里只要有点号,在ES里就是层级结构。检查脚本生成的字段名,确认没有点号。

7.4 processor顺序导致的诡异结果

processor顺序的问题是排查成本最高的,因为很多错误很隐蔽。比如先做了include_fields,把message以外的业务字段全删了,然后decode_json_fields再去解析message,解析出来的字段当然也没了,因为事件只剩下message一个字段。

经验法则是:先做decode,再做rename,再做字段裁剪,再做时间转换,最后做输出相关处理。按这个顺序排,能避开80%的processor顺序问题。

7.5 script processor的性能隐患

最后讲一个很多人忽略的点:script processor用的是JavaScript引擎,解释执行开销远高于原生processor。日志量小无所谓,但一天几亿条日志时,如果每条都要跑一段递归展平脚本,Filebeat的CPU会明显吃紧。

我踩过这个坑之后的实践原则是:能用原生processor(decode_json_fields、drop_fields、rename、timestamp)解决的,绝不写脚本;脚本只处理真正无法用原生能力完成的逻辑。如果日志量真的很大,把复杂处理尽量往ES Ingest Pipeline或Logstash移,让Filebeat只做最轻量的采集和裁剪。

还有一种更彻底的思路:找业务方沟通,让他们在输出日志前就把JSON结构拍平。比如日志里直接输出orderId,而不是输出context对象让采集端再去拆。很多团队绕了一大圈做扁平化,最后发现让业务改一下日志结构,比任何采集端处理都干净高效。

我这里多分享一句个人体会:Filebeat的processor能力很强,但不代表所有事情都适合在Beat端做。学会判断什么该在Filebeat做、什么该给Logstash、什么该留给ES pipeline,比背一堆配置参数重要得多。日志链路每多一个处理环节,排查链路就多一分复杂度,能用最简单方案解决的,别为了炫技堆复杂处理。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询