ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Grafana Tempo 架构解析:对象存储驱动的全量分布式追踪实践

Grafana Tempo 架构解析:对象存储驱动的全量分布式追踪实践 1. 破局的起点为什么分布式追踪一直这么“难搞”做后端的人大概率都有过这种经历线上突然告警某个接口的 P99 延迟从 80ms 飙到 800ms你打开监控大盘发现 CPU、内存、QPS 全部正常Prometheus 指标看起来一片祥和。这时候你会特别想骂人——指标是有了但指标只告诉你“哪里慢了”它不告诉你“为什么会慢”。于是你只能一台台服务器登上去翻日志、看慢查询、猜依赖运气好半小时定位运气差搞到凌晨三点。这就是典型的“可观测性陷阱”监控Monitoring告诉你系统坏了日志Logging告诉你系统发生了什么但只有追踪Tracing能告诉你一次请求到底走了哪些服务、每段花了多长时间、瓶颈卡在哪个环节。分布式追踪不是锦上添花而是微服务架构下定位问题的最后一块拼图。但问题来了——分布式追踪系统本身也“很难搞”。用过 Jaeger 的朋友应该深有体会要么自建 Cassandra 或 Elasticsearch 集群要么上云托管的 ES存储成本随采样量线性飙升运维复杂度直接拉满。ZIPKIN 也好不到哪去内存存储扛不住生产压力换到 ES 又是一轮折腾。中小团队往往会在“采全量”和“省成本”之间反复纠结最终妥协成只采 5% 的采样率结果就是真正出问题时关键链路恰好没被采到追踪系统形同虚设。Grafana Tempo 就是在这样的背景下杀出来的。它对存储层做了彻底重构直接使用 S3、GCS、Azure Blob 这类对象存储作为后端用 Parquet 列式格式压缩 trace 数据把存储成本打到了 Jaeger 方案的十分之一甚至更低。同时它不需要额外的索引数据库查询时通过 TraceID 直接检索对象存储里的 Parquet 文件再配合 Grafana 全家桶实现“指标-日志-追踪”三箭齐发。这篇文章我想从 Tempo 的诞生背景、架构设计、落地实操和问题排查四个维度展开把我这半年在生产环境里摸爬滚打的经验一次性倒出来。如果你正在 Jaeger 的高运维成本和全量追踪的梦想之间反复摇摆这篇文章应该能帮你把账算明白。2. 从需求倒推设计Tempo 到底解决了什么问题2.1 既有追踪系统的三大痛点在聊 Tempo 的设计哲学之前必须先搞清楚一件事为什么 Jaeger 这类老牌追踪系统在 2020 年之后逐渐显得力不从心。我把它总结为三个痛点。第一是存储成本失控。Jaeger 的主流生产部署方案是 ES 后端 高磁盘 IOPS 的节点ES 为了支撑 trace 的 tag 查询需要建大量索引而索引本身的内存和磁盘开销甚至比原始数据还要大。数据量上来之后你不得不做冷热分层、索引生命周期管理这些复杂度最终都会转化成人的工作量。我见过一个日请求量在亿级的团队Jaeger 集群光 ES 就用了 12 台 16C64G 的机器每个月的存储成本够再招一个初级运维。第二是采样率和问题定位之间的矛盾。为了控制成本大家习惯把采样率调到 10% 甚至更低。但分布式系统的故障往往是小概率事件低采样率意味着故障链路大概率没被采到真正想排查的时候数据是缺失的。而高采样率尤其是 100% 全采对 Jaeger 来说存储和计算压力又太大直接进入成本无底洞。第三是查询体验割裂。Jaeger 的 UI 虽然能用但它和 Prometheus 指标、Loki 日志是分离的。你在 Grafana 大盘上看到一个延迟异常的服务想顺着 traceID 查这条请求的内部调用链得切换到 Jaeger 的独立页面重新输入 traceID来回切换的效率极低。可观测性讲究“从指标到日志再到追踪”的顺滑跳转老牌系统在这一块做得并不好。2.2 Tempo 的产品哲学把复杂度从“存储”挪到“查询”Tempo 的设计者做了一个很聪明的决定既然 trace 数据的查询路径绝大多数情况下是“先知道 traceID再去查这条 trace 的细节”比如从日志里捞到 traceID或者在 Grafana 的指标面板里点进去那为什么不干脆围绕这个核心场景把存储简化到极致于是 Tempo 直接砍掉了索引数据库把 trace 数据以 Parquet 列式格式写入对象存储。对象存储本身就足够便宜S3 的存储单价大约是 EBS 的十分之一而且不需要你运维任何数据库集群。查询的时候Tempo 会根据 traceID 计算出一个范围直接去对象存储拉取对应的 Parquet 文件块然后做过滤和合并最后把完整的 trace 树返回给前端展示。这就好比传统方案是建了一座图书馆ES每本书都要登记编号、作者、主题标签查询的时候走检索系统找到书Tempo 则把书直接扔进一个巨大的仓库对象存储按区域编号摆放取书的时候直接按编号去对应区域翻翻到哪本算哪本。关键在于——图书管理员不需要维护一本巨大的索引目录了仓库足够便宜所以你可以把所有书都扔进去不用再纠结“采全量还是采 5%”。当然这个设计也带来一个代价Tempo 的查询必须依赖 traceID不支持“按服务名时间段”去模糊搜索所有 trace。这是它在设计上主动做出的取舍——因为模糊搜索场景交给 Prometheus 指标和 Loki 日志去完成Tempo 只负责“拿到 ID 之后把全链路捞出来”。在实际使用中这个取舍完全站得住脚。2.3 TraceQL追着 TraceID 跑的“进阶查询语言”Tempo 在 2.0 版本引入了 TraceQL这算是它区别于所有老牌追踪系统的一大杀器。TraceQL 允许你通过结构化查询语言直接对 trace 数据结构做检索而不只是靠 traceID 点对点查询。比如你想找所有“payment service 调用了 database 且耗时超过 2 秒”的 trace 样本可以用类似 PromQL 风格的表达式直接搜比如{ resource.service.name payment span.db.system postgres } duration 2sTraceQL 解决的场景是“不知道 traceID但想通过属性组合条件找到一批可疑 trace”。这就补上了 Tempo 文件名系统在“模糊查询”上的短板同时仍然保持存储层的极简架构——因为 TraceQL 实际上是扫描 Parquet 文件的列数据在 Parquet 本身的列式存储之上做过滤不需要额外建索引库。我在实际测试里用 TraceQL 搜过“错误码为 500 且持续时间超过 1 秒”的 trace返回速度在千万级 trace 规模下大概是秒级虽然不如 ES 的倒排索引那么快但完全够用。关键是——你省的可是整整一个 ES 集群的运维成本这笔账怎么算都划算。3. 核心架构拆解Tempo 的每个组件都在干什么3.1 从数据流入到可查询的完整链路Tempo 的架构从数据流的角度看非常清晰它把传统 tracing 后端拆分成五个核心角色Distributor、Ingester、Query Frontend、Querier、Compactor。我按一条数据从采集端到可查询的完整生命周期来讲解。客户端通过 OpenTelemetry Collector 或 Jaeger Agent 把 trace 数据推给 Distributor。Distributor 负责对数据做校验和哈希路由它根据 traceID 算出应该由哪个 Ingester 实例处理这份数据。这里注意Tempo 采用一致性哈希环同一个 traceID 的所有 span 会被路由到同一个 Ingester这是保证后续查询能拿到完整 trace 树的前提。Ingester 是数据写入的内存缓冲区它会把接收到的 span 在内存中按 traceID 聚合形成完整的 trace 结构。当满足一定条件比如超过 2 万条 span 或者 15 秒时间窗Ingester 会把内存中的 trace 块 Flush 到对象存储。所以从数据层面看Tempo 的实时性取决于 Ingester 的 Flush 周期最多延迟几十秒完全可接受。查询侧则是另一条链路用户通过 Grafana 发出查询请求Query Frontend 负责接收请求、做并行化拆分然后交给 Querier 去对象存储里拉取 Parquet 文件块。如果 trace 数据还很新、还驻留在 Ingester 内存里Querier 也会去 Ingester 查询一遍。这种“内存 对象存储”的双层检索设计和老牌系统的纯数据库查询形成鲜明对比。Compactor 是后台任务角色它定期扫描对象存储里的块文件把小文件合并成大文件比如把所有块合并成每 2 小时一个的大块并清理已经过期的数据TTL。这个合并动作可以显著降低对象存储中的小文件数量减少查询时的文件拉取开销属于 Tempo 在成本控制上的又一个暗招。3.2 存储层的关键魔法vParquet 格式为什么能省这么多钱Tempo 最开始用的是 JSON 格式存 trace后来在 1.x 版本引入 Parquet 方案以为替代者2.x 全面转向 vParquet。我在这里用最通俗的方式解释 Parquet 在 trace 数据上为什么这么香。trace 数据的本质是一堆结构相同的 span每个 span 有固定的字段集合traceID、spanID、parentSpanID、开始时间、结束时间、服务名、操作名、标签集合。这种强结构化的数据天然适合列式存储。Parquet 把同一列的数据连续存放在一起查询只取需要的列过滤时能直接跳过无关数据块。举个例子你想查 traceID 为abc123的所有 span用 Parquet 格式存储时只需要读取 traceID 这一列的索引信息定位到具体的数据块再反查其他列。行式存储比如 JSON则必须把整个文件都读进来才能找到目标。Tempo 实测可以把 trace 数据的存储体积压缩到原始 JSON 格式的 1/10 到 1/20加上对象存储本身就便宜最终的成本优势就是数量级的差距了。还有一点容易被忽略Parquet 支持内嵌压缩算法Snappy、Zstd。Tempo 默认用 Zstd 压缩我在生产环境里对比过同样的 span 数据量Zstd 比默认配置再节约大约 30% 的存储。设置里有一个storage.trace.block.parquet.compression参数别用默认的 Snappy直接改成zstd一劳永逸。3.3 可扩展性与高可用设计Tempo 的集群模式是无状态设计所有组件都可以水平扩展。Distributor 和 Query Frontend 是无状态的随便加副本Ingester 虽然是有状态的内存数据但可以通过一致性哈希环实现平滑扩缩容Compactor 则是典型的后台任务型工作负载。我搭过的最小生产集群是三节点每台机器同时跑 Ingester Querier Compactor再单挂一台 Distributor。在日均 5000 万 span 的条件下三台 8C16G 的机器绰绰有余对象存储走 S3。整个集群的 CPU 使用率维持在 20% 左右内存大头被 Ingester 的写缓冲占着。这个资源消耗水平比 Jaeger 的 ES 方案至少降了一个量级。高可用方面值得一提因为所有数据最终都持久化在对象存储里Ingester 挂了最多丢失 Flush 周期内的少量数据不会影响历史 trace 的查询。这比 Cassandra 节点挂了要修复数据、ES 集群要重新分片的高危操作简单得多。Tempo 的架构容错性本质上是把“状态”外包给了对象存储这是它的核心逻辑。4. 落地实操从零部署一套生产级 Tempo4.1 部署模式选型单体、微服务还是 Helm ChartTempo 提供了三种部署模式单体模式single binary、微服务模式各组件独立部署和无头模式serverless 化。单体模式适合开发环境和每日亿级 span 以内的小规模生产微服务模式适合大规模集群需要按组件分别扩容无头模式则适合极端弹性场景通过 Lambda 等 serverless 平台跑 Querier 和 Compactor。对于大多数团队我强烈建议从“单体模式 多副本”起步。Tempo 的单体二进制同时跑所有组件但内部走 gRPC 通信对外看起来是一个完整服务。你在 Kubernetes 里部署两个副本加上 Service就已经具备基本的高可用能力。等规模真正上来再拆组件也不迟——迁移成本并不高因为存储层是共享的对象存储。如果你用的是 Kubernetes直接上 Grafana 官方维护的 Helm Chart 最省事。它的 values.yaml 写得很清楚把存储配置、副本数、资源限制都暴露出来。我用的是以下核心配置片段tempo: storage: trace: backend: s3 s3: bucket: my-tempo-data endpoint: s3.cn-north-1.amazonaws.com.cn region: cn-north-1 access_key: ${S3_ACCESS_KEY} secret_key: ${S3_SECRET_KEY} querier: frontend_worker: frontend_address: tempo-query-frontend:9095 compactor: compaction: block_retention: 720h compact: - block_encoding: vparquet3 store: trace: block: parquet: compression: zstd注意上面这段配置我特意标注了block_retention: 720h这是 trace 数据保留 30 天。存储桶的 lifecycle 策略建议同时配置在 S3 侧把超过 35 天的对象自动清理掉双保险防止 Compactor 异常时存储无限增长。block_encoding在存储路径上其实不需要手动配置新版本默认就是 vparquet3但如果你从旧版升级一定要确认 block 格式没有混用。混用不同 Parquet 版本的块会导致查询报错我在升级时就被坑过一次。4.2 数据接入OpenTelemetry Collector 的配置与注意事项Tempo 不直接对接你的应用而是通过 OpenTelemetry Collector 接收数据。这意味着你需要在应用侧接入 OTel SDK 或 Jaeger Agent然后把数据发送给 Collector再由 Collector 做批量转发到 Tempo。应用侧的 OTel SDK 配置直接写在环境变量里就够了OTEL_EXPORTER_OTLP_ENDPOINThttp://otel-collector:4318 OTEL_EXPORTER_OTLP_TRACES_ENDPOINThttp://otel-collector:4318/v1/traces OTEL_RESOURCE_ATTRIBUTESservice.namepayment-service,deployment.environmentproduction OTEL_TRACES_SAMPLERalways_on这里要特别强调always_on意味着 100% 采样这是 Tempo 方案的核心卖点。因为存储成本已经降下来了你完全没必要再做概率采样。如果你担心极端流量下的成本可以用 OTel 的parentbased_traceidratio采样器但不要用传统的固定比例采样——记住Tempo 的价值就在于全量数据可查。Collector 侧的最小配置如下开启 OTLP receiver、批量处理、导出到 Temporeceivers: otlp: protocols: grpc: http: processors: batch: timeout: 5s send_batch_size: 10000 exporters: otlp/tempo: endpoint: tempo-distributor:4317 tls: insecure: true service: pipelines: traces: receivers: [otlp] processors: [batch] exporters: [otlp/tempo]Batch 处理器是性能关键。send_batch_size调的太高会导致内存压力太低则浪费网络开销。5 秒超时 1 万 batch 是我测试过相对平衡的配置在每秒约 2 万 span 的流量下没有出现积压。4.3 Grafana 数据源集成把 Trace 和 Metrics 打通Tempo 的查询入口默认是 Grafana你需要在 Grafana 里添加 Tempo 数据源。这一步看着简单但有个隐藏的坑Grafana 的 Tempo 数据源配置页里需要额外填一个“Trace to Metrics”和“Trace to Logs”的关联配置。Trace to Metrics 的作用是当你在 Tempo 面板里选中某个 span 时Grafana 能自动跳转到 Prometheus 查询这个服务相关的指标。它的配置方式是填一个 Prometheus 数据源和一组标签映射比如- name: Trace to Metrics datasourceUid: prometheus tags: - key: service.name value: service_nameTrace to Logs 的作用则相反从 span 跳转到 Loki 里的相关日志通过 traceID 关联。配置里填 Loki 数据源和 traceID 的标签名。这两组关联配置的价值在你真正排查问题时才会体会到——看到一笔慢 trace一键就能跳到慢服务的 CPU 指标和相关日志不用再切来切去。我曾经在处理一个缓存穿透问题的时候就是通过 Tempo 面板点到 Redis 慢查询日志瞬间定位到热点 key 的。Grafana 数据源配置完成之后强烈建议先验证端到端链路从应用侧手动发一笔测试请求生成一个 trace然后在 Grafana 的 Explore 页面用 TraceID 搜索。如果搜不到先别怀疑 Tempo去查 Collector 有没有报错、Tempo 的 Distributor 有没有收到数据按照“应用 → Collector → Tempo → 对象存储”的顺序排查。4.4 生产环境的基本监控与告警Tempo 自身会暴露 Prometheus 指标端口默认是 3200。你需要把这些指标接入 Prometheus 并配置基础告警。我总结的核心指标有三个tempo_distributor_spans_received_total每秒接收的 span 总量用来判断流量是否突增。tempo_ingester_blocks_flushed_totalFlush 成功率如果这个指标长时间不增长说明数据卡在 Ingester 内存里没写进对象存储这是数据丢失的前兆。tempo_request_duration_seconds_count查询延迟如果 P99 超过 3 秒说明对象存储的读取性能不达标或者查询的 trace 太大。告警规则我建议重点盯tempo_ingester_blocks_flushed_total因为其他指标异常最多是查询慢Flush 失败直接就是数据丢失。有一个比较隐蔽的问题Ingester 的内存上限如果设置的太低Flush 会频繁触发但小文件太多又拖慢 Compactor 的合并效率。我建议给 Ingester 留足内存让块尽量攒大一点再刷盘。5. 填坑实录这半年我在生产环境踩过的 6 个坑5.1 对象存储 Endpoint 配错导致写入失败这个坑很基础但实在太多人踩了。如果你用的是 AWS S3endpoint 可以直接默认但如果用的是 MinIO、华为云 OBS 或者私有对象存储endpoint 必须写成服务地址比如http://minio:9000。我当时部署测试环境时把 endpoint 写成了http://minio:9000/bucket结果把 bucket 路径混进了 endpoint导致所有写入请求都返回 404 Not Found。排查了大半天最后在 Tempo 日志里看到The specified bucket does not exist才反应过来。补充一个经验Tempo 对 S3 兼容性做得很好大部分 S3 兼容存储都能正常用。但在选型时尽量避开那些“S3 协议支持不完整”的小众存储尤其是读取性能差的。因为 Tempo 的查询路径高度依赖对象存储的 GET 延迟存储选不好查询 P99 会直接飙到 5 秒以上。5.2 gRPC 与 HTTP 端口混淆Tempo 的 Distributor 默认同时监听4317gRPC和4318HTTP端口用于接收数据。如果你在 Collector 里配置 exporter 的时候用了4318却写了 gRPC 协议或者反过来 HTTP 却写了4317数据就是传不进去。这类问题表面症状是 Grafana 里搜不到任何 trace但日志里也没有明显报错。我的排查习惯是先看 Collector 的 metrics 里exporter_sent_spans有没有增长再看 Tempo 这边tempo_distributor_spans_received_total有没有增长。前者增长后者不增长说明 Collector → Tempo 这段链路配置有误重点检查协议和端口。5.3 Ingester Flush 周期过长引起查询延迟Tempo 默认的 Ingester Flush 周期是 30 秒意味着刚产生的 trace 最多半分钟后才能查询到。这看起来没什么但如果你在 Grafana 里点了“Live Tail”希望实时看 trace就会觉得特别慢。我把 Flush 周期调到了 10 秒代价是对象存储里的小文件数量增加了Compactor 的合并压力稍大。对于大多数场景30 秒默认值足够了别为了实时性牺牲存储整洁度除非你确实有实时 trace 展示的业务需求。5.4 内存压力与 OOM 的边界控制Ingester 是内存大户。Tempo 的 Ingester 在把数据刷到对象存储之前会把 span 按 traceID 在内存中聚合极端情况下比如某个大巨型 trace 有几十万 span单个 trace 就能吃掉几个 GB 内存。我遇到过 OOM Killed 重启的情况最后是做了两层防范一是给 Ingester 设置合理的内存 limit二是给每个 trace 的 span 数设置上限在 OTel Collector 的memory_limiter处理器里控制。memory_limiter是 OTel Collector 的常用处理器配置如下processors: memory_limiter: check_interval: 1s limit_mib: 2048 spike_limit_mib: 512这玩意能有效避免把超大 trace 无脑灌给 Tempo保护 Ingester 不被打爆。5.5 TraceQL 查询慢到底该不该用TraceQL 确实是神器但不是万能的。我在跑 TraceQL 查询时发现它实际上是去扫描 Parquet 文件里的列数据查询条件越复杂、扫描范围越大耗时越长。一次按status error扫描一个月数据的查询跑了将近 20 秒。所以我的建议是TraceQL 适合“小范围快速检索”比如最近 30 分钟内按某个错误状态过滤跨天甚至跨月的大范围检索别指望秒出给用户做好等待预期或者尽量先用 Prometheus 指标缩小范围再进 TraceQL。5.6 成本预估全量采样到底会不会爆预算很多团队不敢上全量采样怕成本失控。我这边的实测数据单 span 平均大小约 300 字节压缩后日均 5000 万 span 大概产生 15GB 压缩数据一个月是 450GB。S3 标准存储大约 $23/月加上请求费用总共不到 $50/月。这比 Jaeger ES 三节点一个月的成本低了 80% 以上。所以结论很明确全量采样在大规模生产环境是可行的前提是存储介质真的用对象存储而不是自建数据库。6. 与 Grafana 生态的化学反应Tempo Prometheus Loki 实战联动6.1 指标 → 日志 → 追踪的“一键下钻”实践Tempo 最强的地方从来不是它单独好用而是它和 Grafana 生态的深度整合。我实际排查过这样一个问题某天下午订单成功率突然下降我先在 Grafana 的 Prometheus 大盘上看到order-service的错误率从 0.1% 跳到了 5%点击错误率图表选择“View in Explore”切到 Tempo 数据源输入同一时间段用 TraceQL 搜{ resource.service.name order-service } status error秒级返回了所有错误 trace 列表。点进其中一个 trace看到完整的调用链order-service 调用了 inventory-serviceinventory-service 又调用了 MySQL。瓶颈出现在 order-service 和 inventory-service 之间的 gRPC 调用上耗时 1.8 秒错误信息显示context deadline exceeded。再点击这一段 span 的“Logs”按钮跳转到 Loki 里对应 traceID 的日志发现是 inventory-service 的数据库连接池打满了慢查询堆积。整个过程从发现问题到定位根因大约花了 15 分钟。放在以前我用 Jaeger 独立日志平台的组合至少需要 1 小时起步。这就是“指标发现问题 → 追踪定位链路 → 日志确认根因”的可观测性黄金闭环Tempo 把这个闭环的跳转成本降到了最低。6.2 用 Grafana Cloud 还是自建一次理性的选择如果你问我 Tempo 应该自建还是直接用 Grafana Cloud我会根据团队规模给出完全不同的答案。如果你是一个 20 人以下的团队没有专职的运维人员直接上 Grafana Cloud 的免费或付费方案是更明智的选择——你不需要管 Ingester 的内存、Compactor 的合并策略只需要在界面上点几下配置数据源。但如果你已经有比较成熟的 Kubernetes 基础设施或者出于数据安全考虑必须私有化部署自建 Tempo 也并非什么难事。Helm Chart 一条命令就能装起来对象的存储配置好之后日常维护几乎为零。我这边把 Tempo 部署在 EKS 上运行了半年除了升级版本和调整配额基本上没人专门盯它和当年维护 ES 集群的精力投入完全不是一个量级。6.3 Serverless 化的探索对象存储作为唯一状态最后聊一个让我觉得很有意思的方向Tempo 的架构天然适合 Serverless 化。因为所有数据都在对象存储里查询时按需拉取文件块Compactor 也可以用事件驱动的方式触发。Grafana Labs 本身就在推广 Tempo 的 serverless 模式用 Lambda 跑无状态的 Querier 和 Compactor。这个设计对成本控制的意义在于查询频率低的时候你不需要为 Querier 预留常驻资源。实际场景里大部分时间根本没人查 trace只有出故障的那几分钟才会高频查询。用 Serverless 架构那几分钟的冷启动和并发开销才需要付费闲时成本趋近于零。我最近在测试环境试了一把把 Querier 换成了 Lambda 函数整个测试期内 Tempo 的“服务器成本”降到了几乎可以忽略不计只有对象存储在持续产生少量费用。不过这玩法对网络配置和权限管理有一定要求建议先在测试环境验证别直接上生产。如果你喜欢折腾基础设施这会是个挺有意思的玩具如果你只关心业务稳定性那老老实实用常驻部署也不丢人。根据我个人的经验Tempo 最打动我的不是某个单独的特性而是它“做减法”的设计哲学——砍掉索引、砍掉数据库、砍掉不必要的复杂度把 trace 数据的存储和查询简化到对象存储这个单一依赖上。全量采样不再是一个奢侈的梦想而是默认配置故障定位的体验也从“大海捞针”变成了“按图索骥”。如果你正处于 Jaeger 存储成本逼着你反复调采样率的阶段我真心建议你花一个周末的时间把 Tempo 跑起来试试——配置一次你会发现过去纠结的那些存储成本和采样率问题很大概率就这么不复存在了。
返回列表