零基础堆出一套能发飞书的监控:Grafana、Loki 和几次在 Cursor 里的对话
原创 · 约 43 分钟阅读 · 阅读 --
最后更新于

零基础堆出一套能发飞书的监控:Grafana、Loki 和几次在 Cursor 里的对话

作者: Alex Xiang


先说自己的底子:创业团队里大家都一职多能,专门盯运维的同事当时腾不出手,又赶上线上故障要看监控曲线,这一摊就落到我头上。那时我对 Grafana 的印象无非是「别人浏览器里画曲线的网站」;Prometheus 知道是拉指标的;Loki 只晓得和日志沾边;LogQL 从没写过;告警规则、Contact point、Webhook 这些菜单全靠点开试。业务跑在现成的 K8s 上,stdout 已经进 Loki,不可能为了几张图再去啃三天官方文档,大量细碎问题就分散在 Cursor 里、对着仓库问。下文里的 prompt 多是当时原话或只改了标点;也不是要树什么「人机协作样板」——query 抄错、Explore 里一片空、飞书通知里飘着 [no value],后文都会按顺序交代清楚。

为什么要凑这三件套

Prometheus 适合接口延迟、CPU、QPS 这类天然是数值的指标,告警规则也成熟。Loki 主要从容器标准输出采集日志,索引以标签为主、不对正文做重型存储,成本相对可控,用来从现有日志里还原趋势和异常是正合适的选择——我们这条路线就是 日志里已经打了 [REQ] <-- 和 elapsed_ms=,就不必立刻再造一整套 metrics,先把图和告警跑起来。Grafana 当一个壳:数据源、Dashboard、Unified Alerting 全在一起,不用维护三四套 UI。三件都不完美:用日志算 p95、多副本合成一条曲线,数学上要妥协;真要签死 SLO,后面多半还要 histogram 或 trace。但对「先看见、先能叫到人」来说够了。

下文假设的服务叫 tool-gateway(虚构名),路径写成 /api/v1/query(检索)和 /api/v1/invoke(执行)。所有 LogQL 里的 {namespace="prod", app="tool-gateway"} 只是示例:你要在 Grafana Explore → Label browser 里看到自己集群里真实存在的标签键值,整段替换掉,否则永远是 No data。


一、日志长什么样:先约定「一行代表什么」

tool-gateway 的中间件对每个 HTTP 请求打两行。入口只有方法和路径,没有状态码:

2026-03-31 06:55:03.913 | INFO | 7c2a…e441 | …middleware:206 - [REQ] --> POST /api/v1/query

出口才有 status 和 elapsed_ms,这才是一次请求从进到出闭合的时间:

2026-03-31 06:55:04.412 | INFO | 7c2a…e441 | …middleware:210 - [REQ] <-- POST /api/v1/query status=200 elapsed_ms=498.7

执行类接口同理:

[REQ] <-- POST /api/v1/invoke status=200 elapsed_ms=341.0

后面做 QPS、延迟,都锚定在 [REQ] <-- 这一行:一行对应一次已完成的 HTTP 请求。若你只看到 -->、没有看到同一 trace_id 下的 <--,要么是请求还在飞,要么是并发日志交错,不能拿两行不同路径硬拼成「一条请求缺半句」。

检索链路里还有与应用相关的耗时日志,字段里带毫秒数,后面用 regexp 抽数字:

[向量召回] query embedding 完成: model=text-embedding-3-small, duration_ms=31.22
[向量召回] 向量检索 DB 完成: limit=200, exact_search=False, results=100, total_ms=31.22 (set_local=13.32, execute=17.83, …)

弄清以上格式之后,再往下读 LogQL,每一条管道在过滤什么、为什么要这么写,才有落脚点。

文中和 Cursor 的往返用 <blockquote class="post-dialog"> 包一层:左边是蓝灰细条,和站内普通引用块样式不同;同步到微信公众号草稿时,脚本会给同一块加上一致的内联样式,读者在手机里也能看出「这是对话题」。

我:动手配 Loki 之前,我先把仓库打进 Cursor,问:「每个 HTTP 接口是不是都有一条肯定会打的日志,拿来统计 QPS?要接 Grafana / Loki 告警。」

Cursor:最稳的是中间件的 [REQ] --> / [REQ] <--;不是每个 handler 都额外打 INFO(例如健康检查可能只有中间件那两行在 INFO)。统计完成的请求,锚在 [REQ] <-- 即可。

这一步定了我后面所有 rate 的锚点,没再去业务代码里猜「哪句代表一次调用」。

我:我分不清「少半句日志」和「两个请求绞在一起」,在 Cursor 里贴了两行路径不一致的 [REQ],问:「[REQ] --> POST /api/v1/query 和 [REQ] <-- POST /api/v1/invoke status=200 … 为什么格式不一样?」

Cursor:这是中间件刻意设计的一进一出两行;若路径对不上,多半是并发请求交错,要靠同一 trace_id 配对。不必去怀疑 Loki 丢字段。


二、进 Grafana 之后第一步:确认标签,不要用猜的

Loki 不像全文检索数据库,查询必须先收窄标签 {key="value"},再在管道里 |=、|~ 过滤正文。

我踩的第一个坑是照搬别人截图里的 {namespace="acme-demo"},贴上去 No data。自己环境里的 Promtail 可能根本没打这个键,或者值叫别的。正确顺序是:打开 Explore,选好 Loki 数据源,左侧 Label browser 点开,看实际有哪些 namespace、app、pod、container 之类,把示例里的占位符换成你的。

第二个坑是 Query type。要画时间序列曲线,必须选 Range;Instant 只在某一个时刻取样,贴到 Dashboard 上经常像「断成一截」,我在这儿浪费了挺久。

下面所有「成品查询」都假定你已经能用 纯过滤 在 Explore 里搜出原始日志行。若搜不到,先把 | regexp 去掉,只留 {...} |= "[REQ] <--",确认时间范围和标签无误,再接 rate 或 unwrap。

我:「Explore 里明明能搜到日志,加上 rate 就空了,是不是 Loki 坏了?」

Cursor:先查 Query type 是不是 Range;标签要从 Label browser 抄,不要猜 namespace。rate 方括号里是一段时间窗口,结果是按秒的速率,不是「每分钟一条数」。


三、例题 A:全站「完成请求」的总 QPS

目的:统计所有接口合起来,每秒有多少次请求正常走完(以出口日志条数为准)。

思路:标签选好;正文必须包含 [REQ] <--;对匹配到的日志条数用 rate 求每秒增长率;所有 Pod 的数据用 sum 加总。

完整 LogQL:

sum(
  rate(
    {namespace="prod", app="tool-gateway"}
      |= "[REQ] <--"
    [1m]
  )
)

说明:

  • |= 表示日志行必须包含该子串。
  • [1m] 是 滑动窗口:用最近一段时间内的样本估算速率,不是「每分钟只算一次」。
  • rate(...) 在 Loki 里给出的就是 大致的条/秒,也就是你想要的 QPS 量级;Y 轴单位在面板里设成 ops/s 或自行在标题写「req/s」。
  • 若结果为空,先去掉外层 sum(rate(...)),只跑内层,确认有数据再加 rate。

四、例题 B:只统计「检索」或「执行」接口的 QPS

目的:把总流量拆开,分别看图。

不要写 |= "search" 之类:Logger 模块名里往往带 …search… 字样,几乎所有行都匹配,QPS 会虚高。过滤应落在 URL 路径 上。

检索(路径里带 /api/v1/query,含可能的内部变体时可再收紧正则):

sum(
  rate(
    {namespace="prod", app="tool-gateway"}
      |= "[REQ] <--"
      |~ "/api/v1/query"
    [1m]
  )
)

执行(固定路径时可用 |= 精确子串):

sum(
  rate(
    {namespace="prod", app="tool-gateway"}
      |= "[REQ] <--"
      |= "/api/v1/invoke"
    [1m]
  )
)

|~ 后面可以写正则;|= 是 必须包含 该字面量。若你担心 /api/v1/query 误配别的路由,可改成更死的写法,例如只匹配 POST /api/v1/query (注意行尾空格),那就要用 |~ 写完整正则,此处不展开,原则是 先 Explore 看真实路径再写过滤器。

我:在查询里写过 [REQ] <-- 再 or "search",曲线突然顶天,并贴了一行带 logger 模块名的日志。

Cursor:模块路径里常有 search 字样,or 后半会把几乎所有行都匹配进来。要按接口拆流量,只能卡 URL,不能靠正文里随手一个子串。


五、例题 C:按 HTTP 状态码分组的 QPS

目的:例如同时看 200、4xx、5xx 各有多少条/秒。

思路:日志行里的 status=200 不是 Loki 的标签,要用 regexp 管道抽出命名捕获组,Loki 会把它提升为临时标签 status,再 sum by (status)。

完整 LogQL:

sum by (status) (
  rate(
    {namespace="prod", app="tool-gateway"}
      |= "[REQ] <--"
      | regexp `status=(?P<status>\d+)`
    [1m]
  )
)

这一条会返回多条时间序列,每条曲线的标签里有一个 status 值。Dashboard 里适合做堆叠或分色折线。


六、例题 D:多副本时,检索接口的「全集群加权平均延迟」

目的:从 elapsed_ms=498.7 里抽出数值,得到 所有 Pod 合在一起 的平均延迟(毫秒),一条曲线。

直接 avg_over_time 的问题:Loki 会按 日志流(通常每个 Pod 一条流)分别算平均,你会看到十几条曲线,误以为查询写错。

正确做法:在每个时间窗口内,对各流 延迟之和 / 请求条数 先算流内平均,再对分子、分母分别 sum,全局还是「总耗时 / 总请求数」。

完整 LogQL(检索 /api/v1/query):

sum(
  sum_over_time(
    {namespace="prod", app="tool-gateway"}
      |= "[REQ] <--"
      |~ "/api/v1/query"
      | regexp `elapsed_ms=(?P<elapsed_ms>[\d.]+)`
      | unwrap elapsed_ms
    [5m]
  )
)
/
sum(
  count_over_time(
    {namespace="prod", app="tool-gateway"}
      |= "[REQ] <--"
      |~ "/api/v1/query"
      | regexp `elapsed_ms=(?P<elapsed_ms>[\d.]+)`
    [5m]
  )
)

说明:

  • regexp 命名组 elapsed_ms 与 unwrap 连用,把时间序列当成数值聚合。
  • 分母的 count_over_time 不要再 unwrap,数的是「匹配到的日志行数」,即请求数。
  • [5m] 可改成 [1m],曲线更抖;窗口越大越平滑。

执行接口只要把第二、第三行的 |~ "/api/v1/query" 换成 |= "/api/v1/invoke" 即可,其余不动。

我:「avg_over_time 加 unwrap 之后出来 12 条曲线,我只要一条 query 接口的平均延迟。」

Cursor:每个 Pod 一条日志流,avg_over_time 对流各自算平均。要合成全集群一条线,用 sum(sum_over_time … | unwrap …) 除以 sum(count_over_time …),分子分母都对所有流先求和。

我把这段 LogQL 粘回 Explore,曲线并拢,才确认不是查询写炸而是副本维度没聚合。


七、例题 E:检索接口的 p95 延迟(多副本折中)

目的:看「较慢的那部分请求」到什么量级。

限制:严格意义上的 全集群 request 级 p95 要把所有副本上的耗时合在一起再排序,单靠 Loki 多流查询做不到。工程上常用折中:每个 Pod 在窗口内算 p95,再聚合。max by () (...) 取各 Pod p95 的最大值,偏保守,适合当作「最差副本的尾部」参考。

完整 LogQL:

max by () (
  quantile_over_time(
    0.95,
    {namespace="prod", app="tool-gateway"}
      |= "[REQ] <--"
      |~ "/api/v1/query"
      | regexp `elapsed_ms=(?P<elapsed_ms>[\d.]+)`
      | unwrap elapsed_ms
    [5m]
  )
)

若你更关心「典型副本」而不是「最坏副本」,可把 max by () 换成 avg by (),含义随之变化,这一点要在团队里对齐口径。invoke 的 p95 明显高于平均值,多半是长尾分布,不一定是面板配错。


八、例题 F:从业务日志里统计「query embedding」耗时

目的:不只看 HTTP 一整段,还要看向量编码这一小段耗时(日志里 duration_ms=)。

加权平均(一条曲线):

sum(
  sum_over_time(
    {namespace="prod", app="tool-gateway"}
      |= "[向量召回]"
      |= "query embedding 完成"
      | regexp `duration_ms=(?P<duration_ms>[\d.]+)`
      | unwrap duration_ms
    [5m]
  )
)
/
sum(
  count_over_time(
    {namespace="prod", app="tool-gateway"}
      |= "[向量召回]"
      |= "query embedding 完成"
      | regexp `duration_ms=(?P<duration_ms>[\d.]+)`
    [5m]
  )
)

同一类日志的 p95(多副本折中,写法同前):

max by () (
  quantile_over_time(
    0.95,
    {namespace="prod", app="tool-gateway"}
      |= "[向量召回]"
      |= "query embedding 完成"
      | regexp `duration_ms=(?P<duration_ms>[\d.]+)`
      | unwrap duration_ms
    [5m]
  )
)

total_ms(向量库阶段)加权平均示例,与 duration_ms 同样写法,只换关键字和捕获名:

sum(
  sum_over_time(
    {namespace="prod", app="tool-gateway"}
      |= "[向量召回]"
      |= "向量检索 DB 完成"
      | regexp `total_ms=(?P<total_ms>[\d.]+)`
      | unwrap total_ms
    [5m]
  )
)
/
sum(
  count_over_time(
    {namespace="prod", app="tool-gateway"}
      |= "[向量召回]"
      |= "向量检索 DB 完成"
      | regexp `total_ms=(?P<total_ms>[\d.]+)`
    [5m]
  )
)

九、例题 G:告警里为什么出现 [no value],以及规则该怎么接

我:告警 Description 里写了「当前接口 QPS:{{ $values.A.Value }}」,飞书推送却是「当前 QPS:[no value]」。我贴了整条 Alert 查询(当时还有多余的 sum by (status))和截图,问:「Description 里 value 不对。」

Cursor:两件事可能叠在一起:查询侧只滤了 status=200 却没有用 regexp 抽出 status 标签时,sum by (status) 没有意义;告警侧 Loki 的 rate 常返回多条序列,A 不是标量,要加 Reduce 得到 B,注解里改用 $values.B。界面左侧的 A/B/C 就是文档里的 refId。

现象(与上面对话同类,便于后文照抄规则):Alert 的 Description 里写了 {{ $values.A.Value }},飞书收到 「当前 QPS:[no value]」。

原因通常有两条叠在一起:

第一,查询写成了没有意义的 sum by (status)。例如既用正文过滤了 status=200,又没有用 regexp 抽出 status 标签,by (status) 分组是空的,序列怪异,后面模板取不到值。

下面是一条 不推荐、仅作反例 的查询(不要照抄进生产):

sum by (status) (
  rate(
    {namespace="prod", app="tool-gateway"}
      |= "[REQ] <--"
      |= "status=200"
    [1m]
  )
)

若你只想监控 200 的 QPS,直接 sum(rate(...)) 即可:

sum(
  rate(
    {namespace="prod", app="tool-gateway"}
      |= "[REQ] <--"
      |= "status=200"
    [1m]
  )
)

第二,Loki 的 rate 往往返回多条时间序列(多个 Pod)。告警条件需要的是一个数:在 Grafana Unified Alerting 里给查询 A 后面再加一行 Expression,类型选 Reduce,Input 选 A,Function 用 Last 或 Mean,得到 B。阈值条件挂在 B 上;Description 里写 {{ $values.B.Value | printf "%.2f" }} 之类的,字母以你界面上 Reduce 那一行的 refId 为准(左侧的 B,官方界面不总写着 refId 三个字)。

我在对话里问过「找不到 refId」——搞明白 A/B/C 就是 refId 之后,在规则页 Preview 里确认 B 真有数字,再谈飞书,否则永远是盲盒。


十、飞书卡片:从告警到群里一条消息,中间发生的事

这一节要把「谁渲染谁」说死,不然很容易把 Contact point 里的 Body 和 告警规则里的 Description 糊成一锅粥。

10.1 调用链(按时间顺序)

  1. Alert rule 里的查询 A、Reduce B、阈值条件判定「触发」或「恢复」。
  2. Grafana Notification policy 决定走哪个 Contact point。
  3. Contact point 类型选 Webhook,填写飞书机器人的 URL,HTTP Body 一栏填你准备好的模板文件内容(或内联同等内容)。
  4. Grafana 用 Go template 解析这段 Body:传入的数据结构是 Alertmanager 兼容 的那套(根对象上有 .Status、.Alerts、.GroupLabels、.ExternalURL 等字段,与 Prometheus 生态一致)。
  5. 渲染结果必须是 一段合法 JSON 字符串。Grafana 把它 POST 给飞书。
  6. 飞书按 机器人 / interactive 卡片 协议展示:我们用的是 msg_type: "interactive",正文块用 lark_md 方便加粗、换行、链接。

所以你脑子里的模型应当是:规则里的 $values / $labels 只管告警判定和写进 .Annotations;Webhook 模板里用的是另一套变量(.Alerts 等),通过 .Annotations.description 间接显示你在规则里写好的长文。

10.2 两套「模板」各管哪一段

写在哪儿用的语法典型变量用途
Alert rule → Annotations(Summary / Description)Grafana 告警模板$labels.*、$values.A / $values.B(视 Reduce 而定)、humanize 等生成每条告警 human-readable 说明,会进 .Annotations,最后出现在飞书正文里
Contact point → Webhook BodyGo template.Status、.Alerts、.ExternalURL、range拼出 飞书接受的 JSON,控制卡片颜色、标题、是否循环多条 Alert

常见错误:在 Webhook 的 JSON 里手滑留下半截 {{,或把 $values.B 写进 Webhook(那是规则域的变量,这里不认)。我第一次在测试群看到 整段原始 JSON 或 422,多半是 Content-Type 不是 application/json,或 渲染结果根本不是 JSON。

10.3 卡片结构在干什么

  • msg_type: "interactive":飞书交互卡片。
  • card.header.template:firing 时用 红色条,恢复用 绿色,让人扫一眼知道是不是还在烧。
  • elements 数组:一块块 div,text.tag 为 lark_md 才能吃 Markdown 风格的加粗、反引号、[链接](url)。
  • range .Alerts:一次通知里可能有多条 firing,循环拼进同一张卡片;summary / description 从每条 alert 的 Annotations 来——也就是你在规则里写模板的地方。
  • {{ .ExternalURL }}:点「打开控制台」进 Grafana。前提:grafana.ini 里 [server] root_url 与浏览器里实际访问的 Origin 一致,否则链接跳到错误主机或路径。

10.4 示例 tmpl(由生产用文件改写,便于扫读)

下面不是仓库里的逐字拷贝:删掉了文件尾部大段「给人类看的注释说明」、去掉具体内网 IP;range 里拼 lark_md 的那一长串改写成多行示意,部署时仍须压成飞书接受的一行字符串(换行用 \n)。字段名、逻辑与当时调通的版本一致,可按需再改文案和 emoji。

{{/* Grafana Webhook Body:Go template → 渲染结果为 JSON,POST 给飞书机器人 */}}
{
  "msg_type": "interactive",
  "card": {
    "header": {
      "template": "{{ if eq .Status `firing` }}red{{ else }}green{{ end }}",
      "title": {
        "tag": "plain_text",
        "content": "{{ if eq .Status `firing` }}告警触发{{ else }}已恢复{{ end }}"
      }
    },
    "elements": [
      {
        "tag": "div",
        "text": {
          "tag": "lark_md",
          "content": "**告警:** {{ .GroupLabels.alertname }}\n**状态:** {{ .Status | toUpper }} **条数:** {{ len .Alerts }}\n**Grafana:** [打开控制台]({{ .ExternalURL }})"
        }
      },
      {
        "tag": "div",
        "text": {
          "tag": "lark_md",
          "content": "{{ range .Alerts }}**规则** {{ .Labels.alertname }}\n**实例** `{{ .Labels.instance }}`{{ if .Labels.node }}\n**节点** {{ .Labels.node }}{{ end }}\n**摘要:** {{ if .Annotations.summary }}{{ .Annotations.summary }}{{ else }}—{{ end }}\n{{ if .Annotations.description }}**详情:** {{ .Annotations.description }}\n{{ end }}**时间:** {{ .StartsAt | tz `Asia/Shanghai` | date `2006-01-02 15:04:05` }}(北京时间)\n---\n{{ end }}"
        }
      }
    ]
  }
}

规则侧(仍写在 Grafana Alert 的 Annotations,不要贴进上面这段 JSON)里才可能写:当前 QPS:{{ $values.B.Value | printf "%.2f" }},低于阈值 {{ $values.C.Value }} 一类——humanize、printf 以你当前 Grafana 版本文档为准。

我:飞书侧卡住了。打开仓库里的 Webhook 模板文件,问:「这段是发通知用的还是告警规则里写?ExternalURL 和飞书 JSON 有没有漏字段?」

Cursor:Body 是 Contact point 渲染用的 Go template,规则和注解里是另一套 $values。grafana.ini 里 root_url 要对齐浏览器访问地址,ExternalURL 才会指对。JSON 里给飞书的结构和反引号比较 Status 的写法别和手改乱了。

我对照飞书 interactive 文档把 header.title.tag、lark_md 核对了一遍,Test contact point 才在小群里第一次刷出卡片而不是一坨 raw 文本。

Cursor 能做的是:对字段名、对循环语法、帮你盯住两套模板别串台;Webhook URL、机器人权限、内网出网 仍要自己在环境里点通。


十一、Cursor 帮了什么

它不能替你拍板阈值,也不能替你承担误告警。省时间的地方主要是:把一整行日志 + 一段报错的 LogQL 丢进对话,比对 仓库里的中间件实现,快速得到「该锚定 <--」「别用 or search」「多副本要分子分母双 sum」这类可执行结论,减少在文档里翻 rate 窗口语义、unwrap 用法的时间。

若你也是业务出身被拉来顶监控,顺序建议不变:Explore 里先看到原文字,再套 rate;面板稳定了再加告警;飞书先在测试群把卡片跑通,再挂生产 Contact point。


你在「字与码」里如果也踩过 Grafana 小版本界面改版、或飞书卡片字段长度限制,欢迎留言。

打开原图 ↗