别再 kubectl logs | grep 了!LogQL 入门到定位线上超时,看这一篇就够
晚上 9 点 40,群里开始有人喊支付回调超时,客服那边说已经有用户在问"钱扣了订单怎么还没好"。
我第一反应还是老习惯,kubectl logs 挨个 Pod 翻,pay-api 有 12 个副本,翻到第 4 个的时候领导已经在群里 @ 我第三次了。
后来换到 Grafana 里查 Loki,结果一个 {namespace="prod"} |= "timeout" 敲下去,转了半天圈,报超时。那一刻真的挺崩溃的,日志明明都在,就是查不出来。
这篇我把 LogQL 从入门到排障能用上的东西都摊开讲,命令和查询都可以直接抄。最后那个支付超时的案例,我也会按时间线把怎么一步步收窄范围写清楚。
日志都进 Loki 了,为什么还是查得这么慢
很多人刚用 Loki 的时候,会把它当成 Elasticsearch 用,觉得反正全文都存了,搜关键字就完事。
其实 Loki 只给标签建索引,就是花括号里那些 app、namespace、pod 之类的东西。日志正文是压缩成块存的,查询的时候得把块拉出来一行一行扫。
所以 {namespace="prod"} |= "timeout" 慢,不是 timeout 这个词难搜,是花括号只圈到了 namespace,整个生产环境所有服务一个小时的日志都要扫一遍。好比你去仓库找一个快递,只告诉人家"在 A 区",那只能一排一排翻。
我自己总结下来就一句话:LogQL 查得快不快,八成看花括号里那一段写得准不准。
后面的过滤、解析、统计都是在花括号圈出来的范围里干活。范围小了,后面怎么折腾都快。
动手之前,先摸清楚你手里有哪些标签
这一步很多教程直接跳过,但我觉得最重要。你不知道有哪些标签,就只能瞎写。
先确认 Loki 版本,不同版本有些语法和默认限制不太一样(下面的写法以 Loki 2.9 和 3.x 为准):
# 只读,确认 Loki 版本
curl -s http://loki-gateway.monitoring:3100/loki/api/v1/status/buildinfo返回里看 version 字段就行。地址换成你们自己的 Loki 或网关地址,有鉴权的话记得带上租户头 X-Scope-OrgID。
然后看有哪些标签,以及某个标签有哪些值:
# 只读,列出最近一段时间出现过的标签名
curl -s http://loki-gateway.monitoring:3100/loki/api/v1/labels
# 只读,看 app 标签有哪些取值
curl -s http://loki-gateway.monitoring:3100/loki/api/v1/label/app/values命令行用得多的话,我更推荐装个 logcli,后面排障也会一直用:
export LOKI_ADDR=http://loki-gateway.monitoring:3100
logcli --version
logcli labels app
logcli series '{namespace="prod", app="pay-api"}' --analyze-labels--analyze-labels 这个参数挺好用的,它会告诉你这个范围里有多少个日志流,每个标签有多少种取值。如果看到某个标签取值上万,比如 trace_id、user_id 被做成了标签,那基本可以确定采集端配错了,这个后面单独说。
举个例子,假如 series 分析结果里,pod 标签 12 个值,level 标签 4 个值,看着都挺正常,唯独有个 request_path 标签有三千多个值。这就是有人在采集配置里把 URL 提成了标签,每个接口每个 Pod 都是一个独立的日志流,Loki 写入那边的压力一直下不来。这种东西平时没人注意,等到查询变慢才去找,就很被动。
有多租户的话,logcli 用 --org-id 指定租户,curl 就带上 -H "X-Scope-OrgID: 你的租户"。不带的话很可能查回来是空的,然后你会怀疑人生。
一条 LogQL 到底长什么样
一条日志查询就是花括号加一串管道,日志从左往右流过去:
{namespace="prod", app="pay-api"} |= "timeout" | json | status >= 500 | line_format "{{.path}} {{.msg}}"花括号叫流选择器,必须有。支持 =、!=、=~、!~ 四种匹配,但至少得有一个条件是不能匹配空值的,你写个 {app=~".*"} Loki 直接拒绝,它不让你全量扫。
|= 这种叫行过滤,就是对整行文本做包含、不包含、正则匹配:
{app="nginx"} |= "GET" != "/healthz"
{app="pay-api"} |~ `(?i)timeout|refused`写正则的时候建议用反引号包起来,双引号里反斜杠要写两遍,我就因为少写一个反斜杠对着空结果发了五分钟呆。
行过滤是最便宜的一步。能用它先筛掉大部分行,就别急着解析。
把日志拆成字段,才能按状态码和耗时筛
光靠关键字只能看"有没有",想按"状态码大于等于 500"或者"耗时超过 1 秒"筛,就得先把日志解析成字段。
我们的 Java 服务打的是 JSON 日志,大概长这样(字段为示例):
{"level":"error","path":"/api/pay/callback","status":500,"duration":"3.812s","msg":"get conn from pool timeout"}| json 一下,level、path、status、duration、msg 就都变成可以用的标签了:
{namespace="prod", app="pay-api"} |= "error" | json | level="error"注意我这里前面还留了一个 |= "error"。看着跟后面的 level="error" 重复,其实是有意的,先用行过滤把 info 日志干掉,剩下的才去做 JSON 解析,省不少 CPU。
Nginx 这种格式固定的文本日志,用 pattern 解析最舒服,复制一行真实日志,要的部分换成 <名字>,不要的换成 <_>:
{app="nginx"}
| pattern `<ip> - - [<_>] "<method> <uri> <_>" <status> <size> "<_>" "<agent>"`
| status >= 500pattern 这东西我挺喜欢的,比写正则好读太多。半年后回头看,<status> <size> 一眼就知道在取什么,换成 (?P<status>\d{3}) (?P<size>\d+) 就得停下来想一会儿。
还有 logfmt 和 regexp 两个解析器,前者对付 key=value 格式,后者是兜底的,最慢,能不用就不用。
解析失败的行会被打上 __error__ 标签,比如有些 JSON 日志里夹着一段 Java 堆栈,那几行就会是 JSONParserErr。只想要解析成功的行,加个 | __error__=""。不过排障的时候我一般先不加,堆栈那几行有时候反而是线索。
数值比较还挺智能的,耗时和大小直接带单位写:
{namespace="prod", app="pay-api"} | json | duration > 1s
{app="nginx"} | pattern `<_> "<_>" <status> <size> <_>` | size > 4KBJSON 日志在界面上很长,看着眼花,可以用 line_format 只留关心的部分:
{namespace="prod", app="pay-api"} |= "error" | json | line_format "{{.status}} {{.path}} {{.duration}} {{.msg}}"光会查日志不够,得把它变成曲线
查日志是看"发生了什么",但排障的时候更想知道"从几点开始的,影响多大"。这就要用指标查询。
最常用的是 rate 和 count_over_time,一个算每秒多少条,一个算窗口里一共多少条:
sum by (path) (
count_over_time({namespace="prod", app="pay-api"} |= "error" | json | level="error" [5m])
)这里那个 sum by (path) 千万别省。不加的话,Loki 会把解析出来的每个字段组合都当成一条独立的曲线,path、status、duration、msg 一排列组合,很快就报 maximum of series (500) reached for a single query。500 是 max_query_series 的默认值。
我见过有人碰到这个报错,第一反应是去改 Loki 配置把上限调大。这个我是不建议的,后面避坑清单里说原因。
错误率直接两个查询相除:
sum(rate({namespace="prod", app="pay-api"} | json | level="error" [5m]))
/
sum(rate({namespace="prod", app="pay-api"}[5m]))有时候光看错误率还判断不了,你不知道这个数字平时就这样,还是今天特别高。这时候 offset 很好用,拿现在和昨天同一时间比:
sum(rate({namespace="prod", app="pay-api"} | json | level="error" [5m]))
/
sum(rate({namespace="prod", app="pay-api"} | json | level="error" [5m] offset 1d))结果是 1 左右说明跟昨天差不多,到了 10 那肯定是出事了。不过周一早高峰拿周日比就不太准,这种时候改成 offset 7d 比上周同一天。
想算耗时的 P99,就要用 unwrap 把字段的值取出来:
quantile_over_time(0.99,
{namespace="prod", app="pay-api"} | json | unwrap duration(duration) | __error__="" [5m]
) by (path)duration(duration) 外面那个是转换函数,把 3.812s 这种字符串转成秒;括号里的是字段名,刚好也叫 duration,第一次看确实容易懵。unwrap 后面那个 __error__="" 是把转换失败的行丢掉,不然整个查询会直接报错。
那次支付回调超时,我是怎么一步步收窄的
下面用一个脱敏后的典型场景说明,时间和数值都是示例数据,但排查的路子是真实可用的。
21:40 左右,支付回调接口开始零星超时,21:50 客服反馈变多。
一上来那条 {namespace="prod"} |= "timeout" 扫了一小时全环境日志,超时了。我把时间范围缩到最近 30 分钟,花括号加上 app:
{namespace="prod", app="pay-api"} |= "timeout" | json | line_format "{{.pod}} {{.path}} {{.msg}}"几秒就出来了,满屏都是 get conn from pool timeout,path 基本都是 /api/pay/callback。
我随便点开一条,在 Grafana 里用"显示上下文"(Show context)看了这条日志前后的几十行。上下文里能看到同一个请求在拿连接之前已经等了 3 秒多,连接池那边的日志写着 active 20,idle 0,waiting 一直往上涨。当时没太在意 20 这个数字,觉得就是池子满了,这里其实已经有线索了,只是我没看出来。
看到连接池超时,我第一反应是数据库扛不住了。去看了数据库的监控,CPU 30% 左右,活跃连接也不高,慢查询日志里没什么异常。数据库这条线先放一放。
那会不会是某个 Pod 有问题?按 Pod 统计一下:
sum by (pod) (count_over_time({namespace="prod", app="pay-api"} |= "pool timeout" [5m]))12 个 Pod 都有,数量也差不多,单点故障也排除了。顺手看了下 Pod 的重启次数和节点分布,没有重启,也没扎堆在某几台节点上。所有副本同时出问题,又不是下游扛不住,我开始怀疑是不是有什么东西统一变了。
再看错误从什么时候开始的,把上面查询外层的 by (pod) 去掉,时间拉到 3 小时,查询类型用 Range。曲线在 21:38 左右从接近 0 直接拉起来。P99 那条查询也拉出来看,/api/pay/callback 从平时 0.3s 左右涨到了 4.8s 上下。
然后去翻发布记录,21:36 有一次配置中心的变更,把 pay-api 的数据库连接池最大连接数从 100 改成了 20,说是"数据库连接太多,收一收"。
时间对得上,报错内容对得上,所有 Pod 同时受影响也对得上。但这还只是相关,我又确认了一下,变更说明里写的就是全量推送,不是灰度。
止损很简单,配置中心回滚这次变更,回滚前把当前配置导出备份了一份。回滚后盯着那条错误数的查询:
sum(count_over_time({namespace="prod", app="pay-api"} |= "pool timeout" [1m]))大概 2 分钟后归零,P99 回到 0.3s 附近,客服那边也说没有新的投诉了。到这一步因果才算闭上:改了之后报错,回滚之后恢复。
复盘的时候需要把当晚的日志留档,我用 logcli 按时间段导出来,方便贴到复盘文档里:
logcli query '{namespace="prod", app="pay-api"} |= "pool timeout"' \
--from="2026-10-28T13:30:00Z" --to="2026-10-28T14:10:00Z" \
--limit=5000 -o raw > pay-api-pool-timeout.log注意 --from 和 --to 要写 UTC 时间,北京时间要减 8 小时,这个我也搞错过,导出来一看全是空的。-o raw 只输出日志原文,不带标签,贴文档干净一些。
回头再看这次排查,最耽误时间的其实是前面那十几分钟:kubectl logs 挨个翻,然后全环境搜关键字。真正用对查询之后,从看到报错到找到变更,也就二十分钟出头。
至于数据库连接到底该给多少,是后面跟 DBA 一起按压测结果定的,跟当晚止损是两件事。
事后我加了一条告警
那天如果有错误率告警,21:40 就该响了,不至于等客服来通知。Loki 的 ruler 可以直接跑 LogQL 告警:
groups:
- name: pay-api-logs
rules:
- alert: PayApiErrorRateHigh
expr: |
sum(rate({namespace="prod", app="pay-api"} | json | level="error" [5m]))
/
sum(rate({namespace="prod", app="pay-api"}[5m]))
> 0.05
for: 5m
labels:
severity: critical
annotations:
summary: "pay-api 错误日志占比超过 5%"上规则之前,我会先用 logcli 把表达式跑一遍,看看平时的值大概多少,阈值别拍脑袋:
logcli instant-query 'sum(rate({namespace="prod", app="pay-api"} | json | level="error" [5m])) / sum(rate({namespace="prod", app="pay-api"}[5m]))'平时在 0.2% 左右的话,5% 就比较合理。规则文件改之前留一份旧的,先发到测试租户跑一天,不乱响再上生产。真出问题了,把旧文件放回去等 ruler 重新加载就行。
顺便说一句,查询慢的时候别光猜。Grafana 查询面板的 Query inspector 里能看到这次查询扫了多少字节、多少行,logcli 加上 --stats 也能打出来。扫了几十 GB 才返回几行,那就是花括号写宽了;扫得不多但还是慢,再去看 querier 是不是资源不够。
我自己的看板上常放这几个面板
告警响了以后,最好别再从空白的 Explore 开始写查询。我一般给核心服务配一个日志看板,变量用 namespace 和 app,下面这几个面板基本够用:
# 面板 1:每秒日志量,按级别拆开,突然暴涨或者掉到 0 都要警惕
sum by (level) (rate({namespace="$namespace", app="$app"} | json [1m]))
# 面板 2:错误日志最多的接口,查询类型选 Instant
topk(10, sum by (path) (count_over_time({namespace="$namespace", app="$app"} |= "error" | json | level="error" [$__range])))
# 面板 3:各接口 P99 耗时
quantile_over_time(0.99, {namespace="$namespace", app="$app"} | json | unwrap duration(duration) | __error__="" [5m]) by (path)
# 面板 4:最新的错误日志原文,面板类型选 Logs
{namespace="$namespace", app="$app"} |= "error" | json | level="error" | line_format "{{.pod}} {{.path}} {{.msg}}"$__range 是 Grafana 自带的变量,等于看板当前选的时间范围。面板 1 里日志量掉到 0 有时候比暴涨还可怕,可能是服务挂了,也可能是采集端断了,两种都得马上查。
这个看板不用一次做得很完美,先把这四个放上去,出过一两次事之后,你自然就知道还缺什么了。比如我后来又加了一个按 Pod 拆开的错误数,就是那次支付超时之后加的,想第一眼就看清楚是全部副本出问题还是个别副本出问题。
这几个坑,建议截图存着
- 花括号别只写 namespace 或 env。 范围太大会把 querier 打满,查不出来还拖慢别人的查询。
- 解析前先加行过滤。
| json是逐行解析的,先用|=筛掉大部分行,查询能快好几倍。 - 指标查询外层一定套 sum by。 不然很容易撞上 500 条曲线的上限,报错还看不太懂。
- 别一报错就调大 max_query_series 和 max_entries_limit_per_query。 上限是在保护 Loki,调大之后一条烂查询就能把 querier 内存打爆。真要调,改之前备份 limits_config,先单租户调,盯着 querier 内存,有问题改回原值。
- 不要把 trace_id、user_id、完整 URL 做成标签。 日志流数量会爆炸,写入和查询都会变慢。这些留在正文里,用
|= "trace_id值"照样能搜。 - 排障先查 15 分钟到 1 小时。 写对了再拉长时间,Loki 默认一次最多返回 5000 行,拉太长也看不全。
- topk 排行用 Instant 查询。 Range 查询下每个时间点都会各取一次前几名,曲线乱成一团。
- unwrap 之后记得加
__error__=""。 有一行值转不了数字,整条查询都会报错。
写在最后
说实话 LogQL 语法本身不难,一下午就能过一遍。难的是排障那会儿脑子乱,想到哪查到哪。
我现在的习惯是固定套路:花括号先圈到具体服务,行过滤筛关键字,解析看字段,按 Pod、按接口统计看分布,最后拉曲线找开始时间。照着走下来,大部分日志层面的问题十几分钟能有方向。
今天就可以做一件事:打开 Grafana Explore,把你负责的那个服务最近一小时的错误日志查出来,再按接口做成一条错误数曲线,存到看板上。下次出事,至少不用从 kubectl logs 开始翻。
如果这篇对你有用,点个赞、点个在看,转给组里还在 grep 日志的同事。也欢迎关注@运维躬行录,后面我会接着写 Loki 采集端的标签设计,和 Promtail 迁到 Alloy 踩过的坑。
公众号:耕云躬行录
个人博客:躬行笔记