零基础堆出一套能发飞书的监控:Grafana、Loki 和几次在 Cursor 里的对话
古董级程序员,大厂出来后一直在创业公司,现在仍活跃在一线做 AI 相关的开发。更完整的更新写在微信公众号「字与码」:工作经历、对新技术的想法,以及这些年走弯路的记录,会不定期发在那里。若觉得博客对你有用,欢迎顺手关注。
先说自己的底子:创业团队里大家都一职多能,专门盯运维的同事当时腾不出手,又赶上线上故障要看监控曲线,这一摊就落到我头上。那时我对 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 调用链(按时间顺序)
- Alert rule 里的查询 A、Reduce B、阈值条件判定「触发」或「恢复」。
- Grafana Notification policy 决定走哪个 Contact point。
- Contact point 类型选 Webhook,填写飞书机器人的 URL,HTTP Body 一栏填你准备好的模板文件内容(或内联同等内容)。
- Grafana 用 Go template 解析这段 Body:传入的数据结构是 Alertmanager 兼容 的那套(根对象上有
.Status、.Alerts、.GroupLabels、.ExternalURL等字段,与 Prometheus 生态一致)。 - 渲染结果必须是 一段合法 JSON 字符串。Grafana 把它 POST 给飞书。
- 飞书按 机器人 / 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 Body | Go 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 小版本界面改版、或飞书卡片字段长度限制,欢迎留言。