监控账单贵,多半是你舍不得扔数据
上个季度有个组来找我看账单。机器没加,QPS 也没翻倍,Mimir 和日志的费用却涨了将近四成。打开用量一看,不是业务火了,是某个服务把 user_id、order_id 打进了指标标签,再配上默认的全量 trace。后端很老实,你送多少它就存多少,然后按存储和样本数跟你算账。
我的看法很简单。降监控成本的主战场不在 Grafana 看板,在数据进库之前。 Grafana Agent 负责把指标在边上剃干净,OpenTelemetry Collector 负责把日志和链路里那堆“看起来有用”的噪音丢掉。两边都不做,你在后端写再狠的保留策略,也只是在为已经付过钱的数据办葬礼。
钱到底花在哪
指标贵在基数。一条指标乘上标签组合,就是时间序列。status、method、route 这种低基数标签,留着。user_id、trace_id、完整 URL,留一条就等于给每个用户开一张表。序列一旦写进库,查询慢是小事,存储和内存是按月交钱。
链路贵在量。一条请求十几个 span,再乘实例数,对象存储和索引都会涨。排障时你真正回头看的,是错误、慢请求,再加上一点点正常样本。剩下的百分之九十,进了库也没人点开。
日志同理。健康检查、心跳、循环打印的 debug,白天看着没感觉,月底账单最有感觉。
所以刀要落在采集侧。进库之后再删,省的是明天的盘,不是今天已经产生的写入。
Agent 做指标门卫
Grafana Agent 这几年的位置要说清楚。新集群我会直接上 Grafana Alloy,Agent 已经进入维护期,配置模型也在往 River 靠。但线上还有一大批 Agent 在跑,短期不会一夜换掉。下面这段就是 Agent Flow 模式里我最常留的一种写法:先抓,再按指标名留白名单,最后才 remote_write。
prometheus.scrape "apps" {
targets = [{"__address__" = "app:8080", "service" = "order"}]
forward_to = [prometheus.relabel.keep.receiver]
scrape_interval = "30s"
}
prometheus.relabel "keep" {
rule {
source_labels = ["__name__"]
regex = "http_requests_total|http_request_duration_seconds_bucket|process_cpu_seconds_total|process_resident_memory_bytes"
action = "keep"
}
rule {
regex = "user_id|order_id|trace_id"
action = "labeldrop"
}
forward_to = [prometheus.remote_write.mimir.receiver]
}
prometheus.remote_write "mimir" {
endpoint {
url = "https://mimir.example.internal/api/v1/push"
}
}
这段在干什么:默认不信任应用暴露的全部指标。只留请求量、延迟分布、CPU、内存。再把三个最容易把基数打爆的标签扔掉。
为什么用白名单而不是黑名单:应用一发版就会多出一批“临时调试指标”。黑名单永远在追新名字,白名单是你主动说“看板需要这些”。少抓一点不可怕,看板缺了再加,是一次配置变更。多抓进去的高基数序列,清理起来要等比写入更难受。
生产上我会改三处。抓取间隔别一律 15 秒,在线接口 30 秒够用,批处理任务 60 秒也够。remote_write 要配队列和重试上限,Agent 挂了可以丢一小段,不要在本地把磁盘打满。白名单放到代码库,和看板一起评审。谁要加 user_id,让他先算一下这个服务有多少用户,再决定要不要为这条线付费。
Collector 做链路和日志的过滤器
指标适合在边上直接丢。链路不太一样。如果在应用里就按百分比把头采样做死,你会发现:被采中的全是成功请求,真正的 500 被掷硬币扔了。所以采样放在 Collector 上做尾部采样,先看完整 trace,再决定留不留。
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
processors:
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
filter/health:
error_mode: ignore
traces:
span:
- 'attributes["http.route"] == "/health" or attributes["http.route"] == "/ready"'
tail_sampling:
decision_wait: 10s
policies:
- name: keep-errors
type: status_code
status_code: {
status_codes: [ERROR]}
- name: keep-slow
type: latency
latency: {
threshold_ms: 800}
- name: keep-some-ok
type: probabilistic
probabilistic: {
sampling_percentage: 5}
batch:
timeout: 5s
send_batch_size: 8192
exporters:
otlphttp:
endpoint: https://tempo.example.internal
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, filter/health, tail_sampling, batch]
exporters: [otlphttp]
错误和慢请求全留,正常请求留 5%,健康检查直接丢掉。decision_wait 是在用内存换判断时间,trace 要凑齐再决定,所以 Collector 不能无脑缩成小规格。memory_limiter 放在最前面,内存顶不住就丢,别把节点拖死。丢数据要在日志里看得见,不然你会以为采样策略很稳,其实是进程在 OOM 边缘随机丢。
日志我也走 Collector,不走“全部进 Loki 再在查询里过滤”。查询过滤不省钱,入库前丢掉才省钱。健康检查、低于 info 的调试日志、已知的心跳报文,在 filter 里按属性丢掉。保留周期仍然后端说了算,但写入量已经小一截。
一个假设的账
数字是假设,方便感受量级,不是某家公司的账单。
一个服务每天 2 亿 span。全量进 Tempo,对象存储和索引都按这个量计。改成错误全留、慢请求全留、成功只留 5% 之后,真正落盘的大约是原来的一成出头,具体取决于错误率。错误率如果已经到 20%,尾部采样省不下这么多,这时该修的是服务,不是采样率。
指标这边,假设原来 40 万条活跃序列,其中三成来自一个高基数标签。在 Agent 上把这个标签丢掉,序列回到个位数万。远端存储的内存和查询延迟会一起下来。这不是调优魔法,是你不再为“每个用户一个序列”付费。
几个容易想岔的地方
采样率不是越低越好。 正常流量留太少,延迟分位会漂,发布对比也失真。我的习惯是错误和超时不采样,正常流量按看板能不能看清来定百分比,而不是按“能省多少”来定。
先缩保留天数,往往是安慰剂。 30 天改 15 天,看起来账单腰斩,其实高基数写入还在,近两周的查询一样贵,索引一样大。保留期是第二刀。第一刀是别写进去。
Agent 和 Collector 不要叠两层同样的抓取。 我见过同一组指标,DaemonSet 里的 Agent 抓一遍,应用 sidecar 里的 Collector 再抓一遍,remote_write 打到同一个租户。账单翻倍的原因有时候就这么笨。谁是指标出口、谁是链路出口,画在一张图上,一个信号只允许一条路。
还有一句不太中听的。全量可观测是销售话术,不是工程目标。你要的是出了问题能定位,不是把生产当成数据仓库养着。能回答“这个接口现在慢不慢、错没错、是不是这次发布引入的”,指标加少量链路就够了。剩下的,等真正需要时再临时打开,比全年开着便宜得多。
落地时我实际守的线
Collector 按节点或按命名空间部署,给内存限额,限额到了就丢并告警,不要无限撑。尾部采样的等待时间别超过 10 到 15 秒,不然排障的人会觉得 trace“丢了”,其实还在内存里排队。
Agent 的白名单和 Collector 的丢弃规则进 Git,走评审。线上临时加一个 debug 指标可以,过一周没人认领就收回。这个比任何成本仪表盘都有效。
别把这套说成万能。合规要求全量审计日志的系统,不能用同一把刀去砍。极低流量的核心支付链路,5% 成功样本可能不够复盘,就单独放宽。成本优化是默认策略,关键路径可以买保险,但不能让保险变成全公司的默认套餐。
新环境我不会再新写一套 Agent 配置,会迁到 Alloy,处理器模型是同一套思路。已经跑着的 Agent 不值得为了“新名字”连夜替换,等下次改采集规则时一并迁走就行。工具会换,边上先过滤这件事不会换。
数据进库之前丢掉,才叫省钱。进库之后再删,叫付完钱再后悔。