运维知识
悠悠
2026年11月4日

别再 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 >= 500

pattern 这东西我挺喜欢的,比写正则好读太多。半年后回头看,<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 > 4KB

JSON 日志在界面上很长,看着眼花,可以用 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 踩过的坑。

公众号:耕云躬行录

个人博客:躬行笔记

文章目录

博主介绍

热爱技术的云计算运维工程师,Python全栈工程师,分享开发经验与生活感悟。
欢迎关注我的微信公众号@运维躬行录,领取海量学习资料

微信二维码