深入 Logstash 05 - Grok 的本质:命名正则加预定义 pattern
Grok 的全部机制可以用一句话交代:%{PATTERN:field} 在管道启动时被递归展开成一段纯正则,字段名就是命名捕获组的组名,匹配交给一个回溯式正则引擎执行一次。它的性能上限、失败语义、调试手段都能从正则的性质直接推出来,不需要额外的心智模型。
这条线索往下追会落到三个具体问题:展开时哪些默认值决定了"到底有没有字段产出"、匹配失败与匹配超时为什么打的是两个不同的 tag、以及灾难性回溯除了"写 pattern 时小心"之外还有没有运行时的保险。
1 | |
Grok 不是新语言
Grok 经常被当成 Logstash 特有的"配置 DSL"。实际上它的全部机制只有两层:
第一层是命名捕获组。Grok 的 %{PATTERN:field} 语法在运行时展开成一个正则命名捕获组 (?<field>正则表达式)。字段 field 从捕获组的名字来,字段的值从捕获内容来。这是标准的 Oniguruma 正则语法,不是 Logstash 自定义的。
第二层是 pattern 别名库。Logstash 自带一套 pattern 文件(位于安装目录的 patterns/ 下),里面定义了 IP、WORD、NUMBER、HTTPDATE 等几百个名字,每个名字对应一段正则字符串。%{IP} 在展开时就是把 IP 这个名字替换成它在 pattern 文件里对应的正则。pattern 可以互相引用,HTTPDATE 的定义里引用了 MONTHDAY、MONTH、YEAR、TIME、INT 等更基础的 pattern。
把这两层合起来看:Grok 的整个处理过程就是"递归展开 pattern 别名直到全部变成原生正则,然后对 message 字段做一次正则匹配,把命名捕获组的结果提取成 event 字段"。没有任何超出正则范畴的东西。
%{PATTERN:field:type} 的三段结构
Grok 语法里一个完整的 token 最多有三段:
1 | |
type 字段目前支持 int 和 float。不指定 type 时,所有提取结果都是字符串。%{NUMBER:bytes:int} 把匹配到的数字字符串直接转成整数,省去后续用 mutate 做类型转换的步骤。
只写 %{PATTERN} 不带 field_name 时,该 pattern 仍然参与匹配(可以用来跳过不关心的部分),但不会产生新字段。
"不带 field name 就不产生字段"是两个默认值共同造成的结果,不是语法层的必然。named_captures_only 默认 true,只把命名捕获写进 event,设成 false 之后匿名捕获也会落成字段。keep_empty_captures 默认 false,捕获到空字符串时那个字段直接不出现;pattern 里带可选段((?:...)?)时,这个默认值就是"字段有时在有时不在"的来源,改成 true 能让字段稳定存在、值为空串。
自定义 pattern
当内置 pattern 不满足需求时,有两种方式添加自定义 pattern:
方式一:在 patterns_dir 指向的目录里放文件,文件里每行一个 pattern 定义:
1 | |
方式二:在 grok 配置里用 pattern_definitions 内联定义:
1 | |
内联定义适合只用一两次的局部 pattern;如果多个管道或多个 grok block 复用同一批 pattern,放文件更清晰。
两套内置 pattern:legacy 与 ecs-v1
内置 pattern 库不是一份而是两份。logstash-patterns-core 的 patterns/ 下有 legacy/ 和 ecs-v1/ 两个平行目录,同名 pattern 在两边都存在,正则骨架一样,捕获组名不一样。加载哪一份由 grok filter 的 ecs_compatibility 决定:disabled 用 legacy,v1 或 v8 用 ECS 版。
对基础 pattern(IP、WORD、NUMBER)这个区别看不出来,它们本身不带字段名。区别集中在复合 pattern 上,因为它们的定义里写死了字段名,换目录等于换掉整套输出字段。COMBINEDAPACHELOG 展开后的字段名对照如下:
ecs_compatibility => disabled |
ecs_compatibility => v1 / v8 |
|
|---|---|---|
| 客户端地址 | clientip |
[source][address] |
| 请求方法 | verb |
[http][request][method] |
| 请求路径 | request |
[url][original] |
| 状态码 | response(字符串) |
[http][response][status_code](定义里已内联 :int) |
| 响应字节数 | bytes(字符串) |
[http][response][body][bytes](同样内联 :int) |
| User-Agent | agent |
[user_agent][original] |
同一个 pattern 名、同一行输入,两种模式下 Kibana 里能查到的字段名完全不同,建在上面的 dashboard 与告警规则也就不通用。这个开关的默认值取决于 pipeline.ecs_compatibility,随 Logstash 版本而变,所以生产配置里应当显式写出 ecs_compatibility 的取值,不要依赖默认。
pattern 目录内部还有一层切分:基础 pattern 集中在 grok-patterns 文件里,按软件分组的复合 pattern 放在同级的 httpd、java、linux-syslog 等文件里。COMBINEDAPACHELOG 在 httpd,grok-patterns 里搜不到它。这个切分本身就是"别名可递归组合"的实物证据:从 COMBINEDAPACHELOG 展开到 IPORHOST、HTTPDATE 这类基础 pattern,中间要过 HTTPD_COMBINEDLOG 和 HTTPD_COMMONLOG 两跳,而 HTTPDATE 自己还要再往下展开一层。
多 pattern 顺序匹配
grok 的 match 接受一个字段对多个 pattern 的列表:
1 | |
Logstash 按列表顺序逐一尝试,第一个匹配成功就停止。"停止"这个行为由 break_on_match 控制,默认 true。%{GREEDYDATA:raw} 通常作为"兜底 pattern"放在列表末尾,确保任何一行都能被捕获(哪怕只是作为整体放进 raw 字段),避免大面积产生 _grokparsefailure。
默认值下开销这样累积:每个 pattern 都要跑一次完整匹配,命中时前面失败的尝试已经付掉了,全不命中时整个列表都要付一遍。所以列表越长,失败时越贵。把最常见的日志格式放在靠前位置能减少平均尝试次数。
把 break_on_match 设成 false,行为变成"所有 pattern 全跑一遍,各自的捕获结果合并进同一个 event"。适用场景是一行日志里混着几段互不重叠的结构,需要分头提取。此时上面那条排序优化失效:不管顺序如何,每条 event 都要付满全部 pattern 的开销。
match 的值也不止是"一个字段配一个 pattern 列表"这一种形态。它可以是多个字段各配自己的 pattern(match => { "field1" => "...", "field2" => "..." }),各字段独立匹配;一个配置文件里更可以串联多个 grok 块,每块处理不同字段。
_grokparsefailure:触发条件与处理策略
当所有 pattern 都没有匹配成功时,grok filter 向 event 的 tags 数组添加字符串 _grokparsefailure,event 的其他字段保持原样(包括 message)。event 继续向下流,不会被丢弃——丢弃是 output 或显式 drop {} 的职责,不是 grok 的默认行为。
这个 tag 只覆盖"跑完了但没匹配上"这一种失败。还有一种是"没跑完就被掐了",即匹配超时,打的 tag 是 _groktimeout,机制见下面的性能一节。两者的语义不同,按 tag 路由时要分开处理。
几种常见处理策略:
策略一:在 output 里按 tag 路由,把 _grokparsefailure 的 event 发到单独的索引或文件,便于后续人工检查:
1 | |
策略二:用 tag_on_failure 覆盖默认 tag 名,在多个 grok block 并存时便于区分是哪个 block 失败:
1 | |
策略三:用 Grok 调试器(Kibana Dev Tools 的 Grok Debugger,或 https://grokdebugger.com)先离线调试 pattern,确认匹配后再放进生产配置。它省掉的是每改一次 pattern 就要重启 Logstash、重新预热 JVM 与 JRuby 的那份固定成本,而这份成本与 pattern 本身的复杂度无关。
最小实验:观察 Grok 展开
用 stdin/stdout 管道验证 Grok 的行为:
1 | |
1 | |
输入一行测试数据:
1 | |
期望输出(关键字段):
1 | |
再输入一行无法匹配的文本(比如 hello world),观察输出里 tags 字段包含 _grokparsefailure,且 message 字段保留原始内容。
把上述实验对应到内部对象:grok filter 在初始化阶段把 %{IP:client_ip} 展开成完整正则(包含 IPv4 和 IPv6 的命名捕获组),编译成正则对象并缓存。每条 event 到达时,直接用已编译的对象对 message 字段执行一次匹配,把命名捕获组的结果写入 event 字段。
性能:Joni 引擎与灾难性回溯
Grok 的正则语法族是 Oniguruma。但 Logstash 跑在 JRuby 上,实际执行匹配的引擎是 Joni,也就是 Oniguruma 的 Java 移植(MRI Ruby 用的是另一个 fork Onigmo)。三者同属回溯式 NFA,所以回溯风险与具体落在哪个实现上无关。
回溯是正则处理非确定性匹配的标准机制,但在某些 pattern 组合下回溯深度会指数增长,把单条 event 的处理时间从微秒推到秒级。常见触发场景是嵌套量词,例如 (a+)+b 遇到无法匹配的输入。内置的 GREEDYDATA(展开成 .*)与其他贪婪量词嵌套时同样危险。
回溯风险有一道运行时保险,它不依赖人事先猜到哪个 pattern 危险,因此比任何写法层的技巧都可靠。grok filter 有 timeout_millis,默认 30000:超过这个时长就中止匹配,给 event 打上 tag_on_timeout 指定的 tag(默认 _groktimeout)。判定按 250ms 量化,只会晚不会早;设成 0 才是彻底关掉超时。
配套的 timeout_scope 默认 pattern,含义是超时按每个 pattern 单独计。多 pattern 列表下,这个默认值让每一次尝试都各自付一份超时管理开销,开销随列表长度叠加。官方建议改成 event,让整条 event 只有一个总预算,保护力度相同而开销明显更低。它和 break_on_match 的默认值是叠加的:列表越长,失败时不仅要付满全部匹配开销,还要付满全部超时管理开销。
_groktimeout 和 _grokparsefailure 应当分开路由。前者说明这条 pattern 在这类输入上有回溯风险,是"重写 pattern"的信号;后者只说明 pattern 与日志格式对不上,是"更新 pattern"的信号。混进同一个错误索引里,回溯问题会被日常格式漂移的噪声埋掉。
写法层的规避手段都在做同一件事:缩小正则引擎的搜索空间。用 ^ 和 $(或 \A/\Z)把 pattern 锚定到行首行尾,防止引擎在无法匹配时从每个字符位置重新起跳;用明确的字符集替代 .*,例如 %{NOTSPACE} 展开成 \S+,每一步都能靠边界字符快速判定成败,在有分隔符的输入上比 .*? 快得多。Kibana Grok Debugger 会显示匹配的捕获与尝试次数,数字异常高的 pattern 就是重写候选。
这些手段降低回溯的概率,但不消除回溯。日志有固定分隔符时还有一条更彻底的路:换掉正则引擎本身,用线性扫描的 dissect 做提取,把 Grok 留给真正需要正则的非结构化部分。这是下一篇的主题。
模式提炼
1 | |
这个模式在其他工具里也有对应:Fluentd 的 parser 插件支持命名捕获正则;Python 的 regex 库支持 (?P<name>...);ClickHouse 的 extractAll 函数支持命名组。把正则和字段名绑定的思路是通用的。
工程迁移表
| Logstash Grok 概念 | Fluentd 对应 | 通用正则处理对应 | 结构化日志替代方案 |
|---|---|---|---|
%{PATTERN:field} |
(?<field>regex) in parser |
Python re 命名组 |
JSON 直接解析,无需正则 |
| pattern 别名库 | Fluentd 内置 parser(apache2 等) | 预编译正则常量 | schema 字段定义 |
_grokparsefailure tag |
emit_invalid_record_to_error |
异常捕获 + 错误队列 | 解析报错 + 死信路由 |
tag_on_failure |
error_label |
自定义异常标记 | ETL 错误分类 |
| Joni 回溯(Oniguruma 语法族) | 同族(Onigmo,CRuby 引擎) | PCRE 回溯 | 无回溯(dissect / RE2) |
| patterns_dir 自定义 | 自定义 parser 类 | 正则常量模块 | schema 注册表 |
常见误解
误解一:“Grok pattern 匹配失败就意味着日志格式错了”。匹配失败通常意味着 pattern 写得不够准确,而不是日志本身有问题。_grokparsefailure 最常见的原因是日志格式有细微变体(多一个空格、时间格式不同、新版本服务改了日志布局),pattern 没有跟着更新。调试时先看日志样本,再看 pattern,不要先怀疑日志。
误解二:“用 %{GREEDYDATA} 兜底就不会有 _grokparsefailure”。%{GREEDYDATA} 对任意字符串都能匹配(包括空字符串),确实能消除 _grokparsefailure,但代价是所有没被前面 pattern 匹配到的内容都会被塞进一个字段,失去结构化提取的意义。兜底 pattern 适合用在"能结构化就结构化,不能就先存原文"的分级处理策略里,而不是掩盖 pattern 写错的问题。
误解三:“Grok 比 JSON codec 慢,所以要尽量避免用 Grok”。这个对比没有意义。JSON codec 解析的是结构化的 JSON 输入;Grok 处理的是非结构化文本。如果日志本身就是 JSON,当然直接用 json codec,完全不需要 Grok。如果日志是 Apache access log 这类文本格式,没有 Grok 就必须手写正则。两者适用场景不重叠。
误解四:“把 timeout_millis 调大或关掉,最坏也就是慢一点”。grok 的匹配是在 pipeline worker 线程上同步跑完的,一条触发灾难性回溯的输入会把这个 worker 独占到匹配结束。pipeline.workers 是 8 的机器上,一条这样的日志就吃掉八分之一的处理能力;同类日志成批到达时,整条管道会被拖到近乎停滞,队列随之填满、背压一路传回 input。默认那 30 秒不是"慢一点"的上限,而是单条 event 能占住一个 worker 的时长上限。
练习
-
取一行真实的 Nginx access log,先在配置里显式固定
ecs_compatibility(分别试disabled和v8各跑一次),用%{COMBINEDAPACHELOG}匹配,对比两次拿到的字段名。再去 Logstash 安装目录里找它的定义:vendor/bundle/jruby/*/gems/logstash-patterns-core-*/patterns/{legacy,ecs-v1}/httpd。在grok-patterns文件里 grep 这个名字会一无所获,按上一节说的目录切分想一下为什么。顺着定义往下追,把它展开到基础 pattern 为止。 -
写一个 grok pattern 匹配形如
ORDER-AB-12345678 SUCCESS 99.50的自定义日志格式,提取 order_id(字符串)、status(字符串)、amount(float)。用本文的 stdin/stdout 实验配置验证。 -
思考题:写一个会触发
_grokparsefailure的输入(格式不匹配),观察 event 的tags字段和message字段。在 filter 里加一个条件块,对这个 tag 和_groktimeout分别加不同的字段值,让两类失败在下游可区分。解释为什么 Grok 不直接丢弃不匹配的 event 而是加 tag 继续流转,以及为什么这两个 tag 不该合并成一个。
系列导航
参考资料
- Logstash Grok filter 文档:https://www.elastic.co/guide/en/logstash/current/plugins-filters-grok.html(match、pattern_definitions、tag_on_failure)
- Logstash 内置 pattern 库源码:https://github.com/logstash-plugins/logstash-patterns-core/tree/main/patterns(所有内置 pattern 的原始正则定义)
- Kibana Grok Debugger:https://www.elastic.co/guide/en/kibana/current/grokdebugger-getting-started.html(离线调试工具)
- Oniguruma 正则文档:https://github.com/kkos/oniguruma/blob/master/doc/RE(Grok 的正则语法族,回溯规则)
- Joni 源码仓库:https://github.com/jruby/joni(Oniguruma 的 Java 移植,JRuby 实际使用的引擎)
- 灾难性回溯参考:https://www.regular-expressions.info/catastrophic.html(触发条件与规避方法)
- Fluentd parser 插件文档:https://docs.fluentd.org/parser(用于工程迁移表中 Fluentd 对应的交叉验证)
