ELK系列走到第五篇,按理说前面已经聊过采集、切分、入ES这些基础操作了,但每次帮别人排查Kibana时间轴错乱的时候,我都要反复强调一个点:@timestamp默认不是日志的产生时间,而是Logstash处理到这条事件的时间。之前做离线日志分析时,我把几天前的访问日志导入Elasticsearch,结果Kibana上所有点全挤在导入那几十秒里,根本看不出流量趋势。从那以后,我的每个Logstash pipeline都会优先处理时间戳,而本文要讲的就是把@timestamp改成日志真实产生时间的完整做法。
1. 为什么默认的@timestamp不靠谱
1.1 Logstash管道中@timestamp的生成时机
Logstash的事件本质上是一个带字段的哈希结构。你输入一条日志后,Logstash会为它构建一个事件对象,并在进入过滤器之前自动打上@timestamp字段。这个字段的默认值“恰好”是Logstash收到这条日志并开始处理的那个时刻。
注意,是Logstash处理它的时刻,不是日志里写的时间。实时管道里这个差值通常只有毫秒级,所以很多人在初期根本感觉不到问题:日志打出来,Filebeat收走,Logstash立刻解析,一切都很快。但一旦出现下面这些场景,误差就会变成灾难:
- 离线导入历史日志,比如把一周前的日志文件重新放进采集目录,Logstash会把当前时间当成所有事件的时间。
- Filebeat从旧文件偏移量恢复读取,或者读取很久没有轮转的日志文件,事件时间会整体后移。
- Logstash处理能力跟不上,事件在内存队列或者持久队列里积压,时间差从几秒变成几十分钟。
- Logstash重启之后从磁盘队列恢复消费,那些在队列里躺了很久的事件全部被打上恢复时刻的时间戳。
一旦@timestamp失真,Kibana默认的时间过滤器就完全不可信了。按天分索引的ILM策略也会把数据写进错误的索引,后面想清理数据都费劲。
1.2 处理思路:用date插件重写@timestamp
Logstash早就考虑到了这件事,内置的date过滤器就是用来干这个的。它的核心逻辑很简单:从事件里取出一个表示时间的字符串或数字,按照你指定的格式解析,把解析结果写进@timestamp字段。如果解析成功,@timestamp就被“覆盖”成日志里的时间;解析失败,它会往事件的tags里加一个_dateparsefailure标签,然后继续往下执行。
所以我们要做的就只有两步:先把日志文本里的时间字符串提取成一个独立字段,然后写一个date过滤器去解析它。难点基本上都隐藏在“日志格式多种多样”“时区处理”“字段名和格式匹配”这些细节里。
2. 动手之前,先把日志里的时间字符串抠出来
2.1 先梳理日志格式,再谈时间解析
在做任何配置之前,我建议你先把自己手头的日志格式列出来。日志里可能自带一个可读的时间字符串,也可能是JSON里的某个字段,有的甚至根本没有时间字段。只有先把“时间在哪”这件事搞清楚,后面的date过滤器才有意义。
拿最常见的文本日志举例,下面这种格式:
2024-05-11 14:23:45.123 INFO [http-nio-8080-exec-1] com.example.service.UserService - user login这种格式用grok提取时间字段非常直接:
filter { grok { match => { "message" => "%{TIMESTAMP_ISO8601:log_time} %{LOGLEVEL:level} %{GREEDYDATA:msg}" } } }TIMESTAMP_ISO8601是Logstash内置的正则模式,能匹配2024-05-11 14:23:45.123这种格式,提取结果放到log_time字段里。如果你面对的是JSON日志,比如这样:
{"timestamp": "2024-05-11T10:23:45.678Z", "level": "INFO", "msg": "user login"}那就不需要grok,用json过滤器解析出timestamp字段,后续直接喂给date就行。
2.2 提取时间字段时的两个实用细节
第一个细节:grok匹配如果失败,会产生_grokparsefailure标签,但事件不会被丢弃。这时候如果后面紧跟一个date匹配,大概率也是失败。所以一个比较稳妥的习惯是,在date之前加一个条件判断:
if "_grokparsefailure" not in [tags] { date { match => [ "log_time", "yyyy-MM-dd HH:mm:ss.SSS" ] } }没有匹配成功的事件就保持原样,别让它们继续叠加一堆解析失败的标签。
第二个细节:很多日志的时间格式并不是标准的ISO8601,比如Nginx访问日志里的:
172.16.0.1 - - [11/May/2024:14:23:45 +0800] "GET /api/v1/user HTTP/1.1" 200 1234 "-" "curl/7.68.0"这里面的时间是一个带英文月份缩写和时区偏移的字符串。此时可以用Grok内置的HTTPDATE模式来提取:
filter { grok { match => { "message" => "%{IPORHOST:client_ip} %{USER:ident} %{USER:auth} \[%{HTTPDATE:req_time}\] \"%{WORD:method} %{URIPATHPARAM:request} HTTP/%{NUMBER:http_version}\" %{INT:status} %{INT:bytes} \"%{DATA:referrer}\" \"%{DATA:user_agent}\"" } } }HTTPDATE会把11/May/2024:14:23:45 +0800完整提取到req_time字段里,之后交给date去解析。这种“先用grok提出来,再交给date解析”的组合,能覆盖绝大多数文本日志。
2.3 日志里根本没有时间字段,怎么办
有些应用生成的日志确实没有时间戳,或者只有日期没有时间。这种情况下,退而求其次用Filebeat的采集时间作为兜底,通常比什么都不做强。Filebeat在采集文件时会给事件加上自己的@timestamp,这是它读取到该行内容的时间,虽然不是业务产生时间,但至少能反映日志到达采集端的先后顺序。
另一个思路是从文件路径里提取时间信息。比如日志文件按天归档在/logs/2024/05/11/app.log这种目录下,可以这样处理:
filter { grok { match => { "[log][file][path]" => "/logs/%{YEAR:year}/%{MONTHNUM:month}/%{MONTHDAY:day}/.*" } add_field => { "log_time_from_path" => "%{year}-%{month}-%{day}T00:00:00Z" } } date { match => [ "log_time_from_path", "yyyy-MM-dd'T'HH:mm:ss'Z'" ] timezone => "UTC" } }需要注意,这种从路径提取的方案精度只能到天。如果一天内的日志也需要排序,建议还是优先找日志内部的时间字段;只有确认日志里确实没有更细粒度的时间信息时,再用路径方案兜底。
3. date插件的核心配置和常见格式对照
3.1 最简配置:解析字符串并覆盖@timestamp
一旦时间字符串被提取成独立字段,比如log_time,下面这个date配置就能完成核心工作:
filter { grok { match => { "message" => "%{TIMESTAMP_ISO8601:log_time} %{GREEDYDATA:msg}" } } date { match => [ "log_time", "yyyy-MM-dd HH:mm:ss.SSS" ] target => "@timestamp" timezone => "Asia/Shanghai" tag_on_failure => "_dateparsefailure" } }match数组的第一个元素是源字段名,后面的字符串是Joda时间格式。date插件默认就会写@timestamp,所以target => "@timestamp"写不写效果一样。但如果你想解析出来的时间不覆盖默认字段,而是放到别的字段里,比如custom_time,把target改掉即可。
这里有一个非常关键的参数:timezone。当日志时间字符串里不带时区偏移时,Logstash会默认按UTC解析。我们国内很多应用日志写的是北京时间,比如2024-05-11 14:23:45,如果不指定timezone => "Asia/Shanghai",解析结果会被当成UTC时间,在Kibana里一看,整整慢了8个小时。这个坑我踩过不止一次,基本上“时间差8小时”的报错,八成都是时区配置问题。
3.2 各类常用时间格式的配置对照
Joda时间格式是Logstash的默认格式体系,它和我们熟悉的Java日期格式很像,但有些细节需要注意。以下是我在实际项目中用到过的一些格式对照:
| 日志里的时间字符串 | date使用的格式 | 说明 |
|---|---|---|
2024-05-11 14:23:45 | yyyy-MM-dd HH:mm:ss | 最常用,注意HH是24小时制 |
2024-05-11 14:23:45.123 | yyyy-MM-dd HH:mm:ss.SSS | 带毫秒,关键是.SSS |
2024-05-11T10:23:45.678Z | ISO8601 | 直接写内置格式名,最省事 |
11/May/2024:14:23:45 +0800 | dd/MMM/yyyy:HH:mm:ss Z | Nginx/Apache风格,Z匹配时区偏移 |
1715412225678(毫秒) | UNIX_MS | 时间戳是毫秒级 |
1715412225(秒) | UNIX | 时间戳是秒级 |
几个容易踩的细节:
yyyy是小写年份,如果写成大写YYYY,代表的是“周年份”(week year),在跨年那几天会解析出错误的年份。MM是月份,mm是分钟,别把月份写成mm,否则要么匹配失败,要么解析出完全错误的时间。- 毫秒是三位数用
SSS,六位数微秒用SSSSSS,但旧版本Logstash对微秒的支持可能有差异,最好先小范围测试。 - 日志里是英文月份缩写,比如
May,date插件默认的locale是英文,问题不大;如果是法文、中文月份,需要设置locale参数。 UNIX和UNIX_MS的默认单位很容易搞混。曾经有人把毫秒时间戳按UNIX解析,结果解析出来的时间距离1970年只有几天,看起来像是回到了上古时代。
3.3 解析失败后的排查方式
date解析失败时,不会中断管道,只会往tags里追加_dateparsefailure。这既是好事也是坏事,好处是管道还能继续跑,坏处是如果没人关注tags,失败会被静默忽略。我建议在测试阶段把失败事件单独输出,或者在Kibana里直接搜索tags: "_dateparsefailure"来定位哪些日志没被正确解析。
顺便说一句,date解析成功之后,原始时间字符串字段默认还会保留。如果你不希望占空间,可以在后面加一个mutate把原始字段删掉:
mutate { remove_field => [ "log_time" ] }但我个人更倾向于把它改名为origin_time保留下来,后面排查时能直接对照,成本也不高。
4. 多日志格式并存时,用条件分支做精准处理
4.1 先想清楚日志类型怎么区分
很多场景下,一个Logstash管道会同时接收多种日志:业务Java日志、Nginx访问日志、系统消息日志等。它们的格式完全不同,不能用同一个datematch去解析。这时候就需要按类型分支处理。
区分类型的方式主要有三种:
- 如果日志已经通过某字段标明类型,比如Filebeat配置里额外加了
fields:
fields: log_type: nginx那么在Logstash里直接用[fields][log_type]判断即可。
- 如果不同类型日志来自不同的索引/主题,可以在输入阶段通过
add_field打上标记。 - 如果前两种都没有,只能靠
grok先匹配message,用_grokparsefailure标签或者正则匹配结果来区分。
4.2 if分支里的date配置示例
假设管道同时接收Nginx访问日志和Java应用日志,我用[fields][log_type]区分,配置大概是这样的:
filter { if [fields][log_type] == "nginx" { grok { match => { "message" => "%{IPORHOST:client_ip} %{USER:ident} %{USER:auth} \[%{HTTPDATE:req_time}\] \"%{WORD:method} %{URIPATHPARAM:request} HTTP/%{NUMBER:http_version}\" %{INT:status} %{INT:bytes} \"%{DATA:referrer}\" \"%{DATA:user_agent}\"" } } date { match => [ "req_time", "dd/MMM/yyyy:HH:mm:ss Z" ] tag_on_failure => "_dateparsefailure" } } else if [fields][log_type] == "java" { grok { match => { "message" => "%{TIMESTAMP_ISO8601:log_time} %{LOGLEVEL:level} %{GREEDYDATA:msg}" } } date { match => [ "log_time", "yyyy-MM-dd HH:mm:ss.SSS" ] timezone => "Asia/Shanghai" tag_on_failure => "_dateparsefailure" } } }这样每种日志都有自己独立的解析和日期格式,互不干扰。注意grok和date要在同一个分支内,顺序不能乱,因为date依赖grok产出的字段。
4.3 分支写多了之后的几个大坑
分支一多,问题往往不是date写错,而是字段被意外覆盖或者判断条件提前失效。
第一个坑:在grok里remove_field删掉了判断字段。比如你为了清理冗余字段,在Java分支里写了remove_field => ["[fields][log_type]"],那后续如果还有其他分支要判断[fields][log_type],这个分支的事件就永远进不去了。所以需要多次使用的判断字段,不要在早期分支里删除。
第二个坑:不同分支的时间字段名尽量统一。如果Nginx分支用req_time,Java分支用log_time,后面你可能会写一个统一的输出模板或者条件判断,就得同时处理两个字段名。更省事的做法是无论哪种日志,grok提取后都统一改成log_time或者各自解析完直接覆盖@timestamp,减少后续复杂度。
第三个坑:grok匹配失败后继续执行date,然后产生误导性的_dateparsefailure。其实只是grok没匹配上,和date格式无关。这种情况下,更合理的写法是加上grok成功判断再执行date:
if "_grokparsefailure" not in [tags] { date { match => [ "req_time", "dd/MMM/yyyy:HH:mm:ss Z" ] } }这样能排除掉很多由于日志格式不符合预期导致的干扰。
5. 验证与排查:配置改了之后如何确认生效
5.1 先看stdout,再谈Kibana
改完配置,我建议先在终端里跑一把,而不是直接往Elasticsearch里灌数据。启动Logstash时,把输出暂时指到stdout:
output { stdout { codec => rubydebug } }然后随便拿一条带时间的日志喂进去,看输出的结果里@timestamp是否变成了日志里的时间。如果@timestamp显示的是当前系统时间,说明date没有匹配成功;如果tags里出现了_dateparsefailure,说明解析失败但事件还在继续。看到原始时间字符串和@timestamp的差距,基本就能定位到问题。
线上环境不方便停管道时,也可以直接用Kibana的Discover页面搜索一段时间的数据,把@timestamp字段和原始时间字符串字段摆在一起看。如果发现8小时差,去检查时区;如果发现数据全挤在同一个时刻,去检查date是否真的解析成功了;如果时间变成1970年附近,去检查UNIX和UNIX_MS是否用混了。
5.2 用具体案例复盘一个排查过程
有一次同事反馈Kibana上某个索引的时间分布不对,明明业务日志是全天产生的,但时间轴上所有点都集中在下班后的两分钟。我排查的链路是这样的:
先看Logstash配置,发现管道里根本没有date过滤器,只有grok和mutate。也就是说所有事件的@timestamp都是Logstash处理时间。我再去看Filebeat的配置,发现它在读一个几天前生成的归档文件,所以Logstash处理这些行的时候,全部打上了“当前时间”。
这种情况怎么修?先在grok里把日志自带的时间字段提取出来,比如2024-05-11 14:23:45.123提取成log_time,然后加date解析。加上之后再用stdout验证,确认@timestamp已经跟着日志时间走了,问题便告一段落。
还有一次是时区问题。日志里的时间是北京时间,但date没配timezone,Logstash默认按UTC处理,结果Kibana里所有时间都比实际晚了8小时。这种情况在stdout里其实很容易发现:原始时间字符串和解析后的@timestamp相差8小时。补上timezone => "Asia/Shanghai"就解决了。
5.3 那些看起来像时间解析问题,其实不是的问题
有些日志的“时间”并不是一个标准字符串,而是多个字段拼出来的。比如你面对这么一种日志:
2024-05-11 14:23:45.123但实际它被grok匹配成三个字段:year、month、day。这时候date不能直接解析多个字段,需要先用mutate把它们拼成一个完整的时间字符串,再交给date:
mutate { add_field => { "log_time" => "%{year}-%{month}-%{day} 00:00:00" } } date { match => [ "log_time", "yyyy-MM-dd HH:mm:ss" ] }另外,有些日志的时间末尾带个Z,比如2024-05-11T10:23:45.678Z,这个Z表示UTC。如果日志写的是本地时间,但末尾却错误地加了Z,解析出来也会有时区问题。这个属于源数据质量问题,最好在写入端修,而不是在Logstash里强行补偿。
最后再分享一个我个人的习惯:我几乎不会删掉原始的时间字符串字段。每一次date解析之后,原始时间字段我会通过mutate rename改名成origin_log_time或origin_req_time保留。这样无论是日常排查还是做数据质量校验,都能快速把“日志里的时间”和“解析后的时间”做对比。ELK这套链路里,时间戳是最容易在数据转换中失真的一环,多在配置里留一手原始信息,后面能省下大把定位问题的时间。