上一篇(04)讲清了 input 插件如何把外部数据拉进管道并交给 codec 生成 event。这一篇进入 filter 层的核心插件:Grok。集中回答三个问题:%{PATTERN:field} 是怎么展开成正则的、Grok 匹配失败时 _grokparsefailure tag 从哪里来、以及 Grok 的性能瓶颈在哪里、怎么避免灾难性回溯。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
input event
message: "203.0.113.5 GET /api 200"


┌───────────────────────────────────────────┐
│ grok filter │
│ │
│ match => { "message" => "%{IP:client} │
│ %{WORD:verb} │
│ %{NOTSPACE:path} │
│ %{NUMBER:status:int}" } │
│ │
│ pattern alias resolution: │
│ %{IP} → (?<client>IP_regex) │
│ %{WORD} → (?<verb>\b\w+\b) │
│ ... │
│ │
│ Oniguruma regex engine │
└─────────────┬─────────────────────────────┘
│ match?
┌───────┴───────┐
YES NO
│ │
add fields add tag:
to event _grokparsefailure

Grok 不是新语言

Grok 经常被当成 Logstash 特有的"配置 DSL"。实际上它的全部机制只有两层:

第一层是命名捕获组。Grok 的 %{PATTERN:field} 语法在运行时展开成一个正则命名捕获组 (?<field>正则表达式)。字段 field 从捕获组的名字来,字段的值从捕获内容来。这是标准的 Oniguruma 正则语法,不是 Logstash 自定义的。

第二层是 pattern 别名库。Logstash 自带一套 pattern 文件(位于安装目录的 patterns/ 下),里面定义了 IPWORDNUMBERHTTPDATE 等几百个名字,每个名字对应一段正则字符串。%{IP} 在展开时就是把 IP 这个名字替换成它在 pattern 文件里对应的正则。pattern 可以互相引用,HTTPDATE 的定义里引用了 MONTHDAYMONTHYEARTIMEINT 等更基础的 pattern。

把这两层合起来看:Grok 的整个处理过程就是"递归展开 pattern 别名直到全部变成原生正则,然后对 message 字段做一次正则匹配,把命名捕获组的结果提取成 event 字段"。没有任何超出正则范畴的东西。

%{PATTERN:field:type} 的三段结构

Grok 语法里一个完整的 token 最多有三段:

1
2
3
4
5
6
7
%{PATTERN_NAME : field_name : type}
│ │ │
│ │ └── 可选:int 或 float
│ │ 匹配结果强制转换成对应类型
│ └── 可选:提取到的字段名
│ 不写则只做匹配,不存字段
└── 必填:pattern 库里的名字(或自定义 pattern 名)

type 字段目前支持 intfloat。不指定 type 时,所有提取结果都是字符串。%{NUMBER:bytes:int} 把匹配到的数字字符串直接转成整数,省去后续用 mutate 做类型转换的步骤。

只写 %{PATTERN} 不带 field_name 时,该 pattern 仍然参与匹配(可以用来跳过不关心的部分),但不会产生新字段。

自定义 pattern

当内置 pattern 不满足需求时,有两种方式添加自定义 pattern:

方式一:在 patterns_dir 指向的目录里放文件,文件里每行一个 pattern 定义:

1
2
3
# 文件:/etc/logstash/patterns/custom
ORDERID [A-Z]{2}-\d{8}
TXSTATUS (SUCCESS|FAILED|PENDING)

方式二:在 grok 配置里用 pattern_definitions 内联定义:

1
2
3
4
5
6
7
8
9
filter {
grok {
pattern_definitions => {
"ORDERID" => "[A-Z]{2}-\\d{8}"
"TXSTATUS" => "(SUCCESS|FAILED|PENDING)"
}
match => { "message" => "%{ORDERID:order_id} %{TXSTATUS:status}" }
}
}

内联定义适合只用一两次的局部 pattern;如果多个管道或多个 grok block 复用同一批 pattern,放文件更清晰。

多 pattern 顺序匹配

grok 的 match 接受一个字段对多个 pattern 的列表:

1
2
3
4
5
6
7
8
9
10
11
filter {
grok {
match => {
"message" => [
"%{COMBINEDAPACHELOG}",
"%{SYSLOGLINE}",
"%{GREEDYDATA:raw}"
]
}
}
}

Logstash 按列表顺序逐一尝试,第一个匹配成功就停止,不再尝试后续 pattern。%{GREEDYDATA:raw} 通常作为"兜底 pattern"放在列表末尾,确保任何一行都能被捕获(哪怕只是作为整体放进 raw 字段),避免大面积产生 _grokparsefailure

多 pattern 匹配有性能代价:每个 pattern 都是一次完整的正则匹配,列表越长匹配失败时开销越大。把最常见的日志格式放在列表靠前的位置,可以减少平均尝试次数。

_grokparsefailure:触发条件与处理策略

当所有 pattern 都没有匹配成功时,grok filter 向 event 的 tags 数组添加字符串 _grokparsefailure,event 的其他字段保持原样(包括 message)。event 继续向下流,不会被丢弃——丢弃是 output 或显式 drop {} 的职责,不是 grok 的默认行为。

几种常见处理策略:

策略一:在 output 里按 tag 路由,把 _grokparsefailure 的 event 发到单独的索引或文件,便于后续人工检查:

1
2
3
4
5
6
7
output {
if "_grokparsefailure" in [tags] {
file { path => "/var/log/logstash/grok-failures-%{+YYYY-MM-dd}.log" }
} else {
elasticsearch { ... }
}
}

策略二:用 tag_on_failure 覆盖默认 tag 名,在多个 grok block 并存时便于区分是哪个 block 失败:

1
2
3
4
5
6
filter {
grok {
match => { ... }
tag_on_failure => ["_grok_access_failure"]
}
}

策略三:用 Grok 调试器(Kibana Dev Tools 的 Grok Debugger,或 https://grokdebugger.com)先离线调试 pattern,确认匹配后再放进生产配置。调试时把实际日志样本直接粘进去,比反复重启 Logstash 快一个数量级。

最小实验:观察 Grok 展开

用 stdin/stdout 管道验证 Grok 的行为:

1
2
3
4
5
6
7
8
9
10
# grok-demo.conf
input { stdin {} }
filter {
grok {
match => {
"message" => "%{IP:client_ip} %{WORD:http_verb} %{NOTSPACE:request_path} %{NUMBER:status_code:int}"
}
}
}
output { stdout { codec => rubydebug } }
1
bin/logstash -f grok-demo.conf

输入一行测试数据:

1
203.0.113.5 GET /api/v1/orders 200

期望输出(关键字段):

1
2
3
4
5
6
7
8
{
"client_ip" => "203.0.113.5",
"http_verb" => "GET",
"request_path" => "/api/v1/orders",
"status_code" => 200, ← 整数,不是字符串
"message" => "203.0.113.5 GET /api/v1/orders 200",
...
}

再输入一行无法匹配的文本(比如 hello world),观察输出里 tags 字段包含 _grokparsefailure,且 message 字段保留原始内容。

把上述实验对应到内部对象:grok filter 在初始化阶段把 %{IP:client_ip} 展开成完整正则(包含 IPv4 和 IPv6 的命名捕获组),编译成 Oniguruma 正则对象并缓存。每条 event 到达时,直接用已编译的正则对象对 message 字段执行一次匹配,把命名捕获组的结果写入 event 字段。

性能:Oniguruma 与灾难性回溯

Grok 使用 Oniguruma 正则引擎(Ruby 的默认正则引擎)。Oniguruma 支持回溯(backtracking),这是正则表达式处理非确定性匹配的标准机制,但在某些 pattern 组合下会导致指数级的回溯深度,让单条 event 的处理时间从微秒跳到秒级。

常见的触发场景:嵌套量词,例如 (a+)+b 对无法匹配的输入会产生灾难性回溯。内置的 GREEDYDATA(展开成 .*)在与其他贪婪量词嵌套时同样危险。

几个实践原则:

原则一:把 pattern 锚定到行首行尾。用 ^$(或 \A/\Z)限定匹配范围,防止正则引擎在无法匹配时从每个字符位置重新尝试。

原则二:优先用非贪婪量词或明确的字符集替代 .*%{NOTSPACE} 展开成 \S+,比 .*? 在有边界字符时快得多,因为每步都能快速判断是否命中边界。

原则三:用 dissect 处理有固定分隔符的结构化日志(下一篇主题),把 Grok 保留给真正需要正则匹配的非结构化部分。

原则四:用 Grok 调试器检查匹配步骤数(Kibana Grok Debugger 会显示 Oniguruma 的捕获次数),发现步骤数异常高的 pattern 及时重写。

模式提炼

1
2
3
4
5
6
7
模式:别名展开 + 命名捕获 = 无新语法的结构提取

- 给复杂正则起一个语义名字(pattern alias),让使用者写 %{HTTP_METHOD}
而不是 (?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)
- 命名捕获组把匹配结果和字段名绑定,消除"第 3 个捕获组是什么意思"的问题
- 别名可以递归组合,从基础 pattern(NUMBER、WORD)构建高层 pattern(COMBINEDAPACHELOG)
- 失败路径显式标记(_grokparsefailure),event 不丢,下游可按 tag 路由处理异常数据

这个模式在其他工具里也有对应: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 错误分类
Oniguruma 回溯 同(Ruby 引擎) 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 就必须手写正则。两者适用场景不重叠。

误解四:“一个 grok block 只能有一个 match”。match 的值可以是多个 pattern 的列表(按顺序尝试),也可以对多个字段同时匹配(match => { "field1" => "...", "field2" => "..." },各字段独立匹配)。一个配置文件里也可以有多个串联的 grok block,每个 block 处理不同的字段。

练习

  1. 取一行真实的 Nginx access log,在 Kibana Grok Debugger(或 https://grokdebugger.com)里用 %{COMBINEDAPACHELOG} 匹配,观察所有命名捕获组的结果。找出 %{COMBINEDAPACHELOG} 的 pattern 定义(Logstash 安装目录 vendor/bundle/jruby/*/gems/logstash-patterns-core-*/patterns/ecs-v1/grok-patterns),追踪它引用了哪些子 pattern。

  2. 写一个 grok pattern 匹配形如 ORDER-AB-12345678 SUCCESS 99.50 的自定义日志格式,提取 order_id(字符串)、status(字符串)、amount(float)。用本文的 stdin/stdout 实验配置验证。

  3. 思考题:写一个会触发 _grokparsefailure 的输入(格式不匹配),观察 event 的 tags 字段和 message 字段。在 filter 里加一个条件块,对带 _grokparsefailure tag 的 event 用 mutate 加一个字段 parse_error => true。解释为什么 Grok 不直接丢弃不匹配的 event,而是加 tag 继续流转。

系列导航

序号 主题 状态
00 导读:核心对象是 event,骨架是三段管道 已发布
01 Logstash 架构:JRuby、JVM 与 pipeline 的运行形态 已发布
02 event 模型:@timestamp@metadata 与字段引用 已发布
03 codec:字节流与 event 的边界转换 已发布
04 input 插件:拉取、监听与 Beats 接入 已发布
05 Grok 的本质:命名正则加预定义 pattern 本篇
06 dissect 与结构化 filter:放弃回溯换吞吐 下一篇
07 date、mutate、geoip:常用 filter 的精确用法
08 Elasticsearch output:bulk 写入与索引路由
09-12 管道执行与可靠性(worker·batch·背压 / 队列 / DLQ / Multiple Pipelines)
13-14 运维、监控与调优
15-17 演进、生态与对比

参考资料