10可观测性工程

可观测性驱动开发

在开发周期中内建可观测性

可观测性左移可观测性即代码部署验证混沌工程

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第10章 可观测性驱动开发

在开发周期中内建可观测性

章节导读

前面九章讨论的可观测性,几乎都发生在「代码已经上线之后」:埋点、采集、存储、查询、告警、诊断。但一个越来越清晰的共识是:可观测性最昂贵的失败,是那些「本该在开发阶段就避免」的失败。本章把镜头前移到软件开发生命周期(SDLC)内部,讨论一个被称为「可观测性驱动开发」(Observability-Driven Development,ODD)的实践范式——像测试驱动开发(TDD)用测试塑造代码结构一样,用可观测性需求塑造系统设计与实现。

先理解「左移」(shift left)这个词在可观测性语境下的确切含义。它不是「把监控配置提前写好」这么简单,而是三层递进的改变。第一层是埋点左移:开发者在写业务逻辑的同时写遥测代码,而不是等上线后由 SRE「补埋点」。第二层是设计左移:在架构评审阶段就问「这个设计会产生什么样的遥测数据?我们能从中诊断出什么?」——如果一个新服务的接口设计使得关键状态无法被外部观察,那么它在设计阶段就应该被修改。第三层是验证左移:把可观测性数据本身作为持续集成与持续部署的判据,用生产环境的真实指标决定「这次发布是否成功」,而不是用「单元测试是否通过」决定。

为什么这件事在 2020 年代变得紧迫?因为部署频率与故障可诊断性之间出现了剪刀差。DORA(DevOps Research and Assessment)的多年研究反复证明:高绩效团队的部署频率是低绩效团队的数百倍,变更前置时间从数月缩短到数小时,而变更失败率反而更低。这个看似矛盾的结果有一个关键解释——高绩效团队把「快速发现问题」和「快速回滚」作为核心能力来建设,而这两件事都依赖可观测性。换句话说,部署频率的提升不是靠「更小心」实现的,而是靠「更快地知道自己错了」实现的。Netflix 每天部署数千次,其工程文化的核心之一就是「每一次部署都必须可观测、可验证、可回滚」;如果一次部署无法在几分钟内通过指标判断其健康度,那么这次部署本身就是不合规的。

「可观测性即代码」(Observability as Code)是本章的第二个支柱概念。传统上,仪表盘、告警规则、SLO 定义是在 Web 界面上点击配置的——它们不受版本控制、不经过代码评审、不能回滚、无法在环境间复制。这与「基础设施即代码」革命前的服务器配置管理如出一辙。可观测性即代码把这些配置搬进 Git 仓库,用 Terraform、Jsonnet、CUE 或专用的 SLO 工具(Pyrra、Sloth)声明式管理,从而获得完整的工程属性:变更有历史、有评审、有测试、有回滚,可以在 dev/staging/prod 之间用同一份定义加不同的参数化生成。Grafana 的 Terraform Provider、Datadog 的 Terraform Provider、以及各家可观测性平台的 API 都为此提供了基础。更重要的是,它让「可观测性配置的漂移」变得可见——生产环境的告警规则被某人手工改过?Git 与真实状态的 diff 会立刻暴露这一点。

第三个支柱是「可观测性的可测试性」。这听起来像是同义反复,但它指向一个真实问题:你怎么知道你的埋点是有效的?传统做法是上线后等故障发生,然后发现「关键路径没有埋点」或「埋点的字段不足以定位问题」——这是最昂贵的验证方式。更成熟的做法有三类。其一是埋点的单元测试:断言某个函数在被调用时会产生预期的 Span 与属性,OTel 提供了 in-memory exporter 让这类测试变得简单。其二是混沌工程与可观测性验证的结合:主动注入故障,然后检验「告警是否在预期时间内触发」「仪表盘是否显示了正确的症状」「追踪数据是否足以定位根因」——这把可观测性本身变成了被测系统。其三是生产环境的持续验证:用合成流量(synthetic monitoring)定期触发关键路径,确保遥测管道端到端可用。Google SRE Workbook 中提到的「如果告警系统本身坏了,你怎么知道?」正是这个问题的极端形式。

第四个支柱是开发者体验(Developer Experience,DX)。可观测性平台有一个内部用户群体——开发者,而内部工具的失败模式往往是「技术上可用但体验上难用」,最终导致绕过与影子实践。本章会讨论如何让本地开发环境拥有可观测性(docker-compose 一键启动 Collector + Jaeger + Prometheus,让开发者在笔记本上就能看到自己的 Trace)、如何让查询变得可发现(保存的查询、模板化的仪表盘、从代码跳转到相关遥测的深链接)、以及如何避免「可观测性税」——如果每次加一个埋点都要走审批流程、填三张表单,工程师就会选择不加。

最后要澄清一个边界:可观测性驱动开发不是「用可观测性替代测试」。单元测试验证的是「代码是否按规格工作」,可观测性验证的是「系统是否在真实环境中按预期工作」——前者处理已知的正确性,后者处理未知的行为。两者是互补的:一个有完善单元测试但零可观测性的系统,会在第一次遭遇未预料的分布式故障时陷入瘫痪;一个有完善可观测性但零测试的系统,会把大量时间花在诊断本可预防的 bug 上。ODD 的真正主张是:把可观测性纳入「完成的定义」(Definition of Done)——一个功能没有遥测、没有仪表盘、没有告警、没有 SLO 贡献,就不算完成。这与 TDD 主张「没有测试的代码不算完成」在结构上完全同构,也是本章标题的来源。

核心概念

  • 可观测性左移
  • 可观测性即代码
  • 部署验证
  • 混沌工程

要点精析

可观测性左移的三层含义与「完成的定义」(Definition of Done)。「左移」在可观测性语境下常被简化为「早点配监控」,但它的完整含义是三层递进的改变。第一层是埋点左移:遥测代码与业务代码由同一个人在同一次提交中完成,而不是上线后由 SRE 或平台团队「补埋点」。补埋点的成本远高于同步埋点——它需要重新理解代码意图、重新走一次发布流程、并且往往在故障已经发生之后才完成,这意味着那次故障的诊断能力永久缺失。第二层是设计左移:在架构评审阶段引入可观测性检查项——这个服务的状态能否从外部输出推断?关键的业务决策点(重试、降级、缓存命中、限流触发)是否会产生可查询的事件?跨服务的因果链是否有传播机制?如果一个设计使得关键状态不可观测,那么修改设计通常比增加埋点更根本。第三层是验证左移:把可观测性数据作为发布判据,用生产环境的真实指标而非测试环境的断言决定「这次变更是否成功」。把这三层落到流程上的具体做法是修改团队的「完成的定义」:一个功能被认为完成,需要同时满足「代码通过测试」「有覆盖关键路径的遥测」「有对应的仪表盘或查询入口」「有 SLO 贡献或告警覆盖」「有 runbook 链接」。这个清单看起来繁重,但它的每一项都在为未来的某次故障节省数小时。反模式提示:不要把 DoD 变成审批门禁(需要可观测性团队签字才能合并),而应该变成自助工具(脚手架自动生成埋点模板、CI 自动检查关键指标是否存在)——强制的清单会被绕过,顺手的工具会被采用。

可观测性即代码:把仪表盘、告警与 SLO 纳入版本控制。传统可观测性配置的最大问题不是「配得不好」而是「不可管理」:仪表盘在 Web 界面上点击生成,谁改的、为什么改、改之前是什么样,全都无从追溯;生产环境的告警规则与预发环境的定义悄悄漂移;一次误操作删掉了半年积累的看板。可观测性即代码(Observability as Code)用软件工程的成熟手段解决这些问题:配置以声明式文件(Terraform HCL、Jsonnet、CUE、YAML)存放在 Git 仓库,变更走 Pull Request 与代码评审,CI 中做语法校验与语义测试(如「告警规则能否被 Prometheus 正确加载」「仪表盘 JSON 是否引用了不存在的指标」),部署通过 GitOps 或 CI 流水线自动化,回滚就是 git revert。工具链上,Grafana 的 Terraform Provider 与 grafonnet(Jsonnet 库)是最成熟的组合,前者管理数据源、文件夹、看板与告警规则,后者提供看板的可编程抽象(把「一个服务的 RED 看板」封装成一个函数,传入服务名即可生成,避免几百个看板的复制粘贴);Datadog、New Relic、Honeycomb 均有官方 Terraform Provider;SLO 领域有 Pyrra(Kubernetes CRD)与 Sloth(YAML 生成 Prometheus 规则)。实施路径建议分三步:先把新建的配置全部代码化(增量收编),再用 API 导出现有配置进仓库(存量收编),最后开启漂移检测(定期对比 Git 定义与真实状态,发现手工修改即告警或自动纠正)。一个关键的工程判断是「抽象层级」:过度抽象的 Jsonnet 库会让新人无法理解生成的看板,完全不抽象则导致维护地狱;经验法则是抽象「所有服务都相同的部分」(布局、颜色、变量),保留「每个服务特有的部分」(具体查询)为显式代码。

埋点是设计约束:可观测性如何反向塑造架构。ODD 与「加监控」最深刻的区别在于因果方向:不是「先有系统,后有可观测性」,而是「可观测性需求参与决定系统长什么样」。这体现在几个具体的设计决策上。第一,接口设计要暴露状态:一个只返回 200/500 的 API 几乎不可诊断,而返回结构化错误码、携带请求 ID、在响应头中暴露服务端耗时分解(如 Server-Timing)的 API,其可观测性天然是好的。第二,关键决策点必须可观测:重试逻辑、熔断器状态转换、缓存命中/未命中、限流触发、降级路径的选择——这些是分布式系统故障的高发区,如果它们在代码中是「静默」的,那么故障发生时你只能看到「延迟升高」而看不到「为什么」。设计要求是:每一次这类决策都产生一个带上下文的 Span 事件或结构化日志。第三,异步与批处理需要显式的关联设计:消息队列消费者、定时任务、流处理作业没有天然的「请求」边界,需要在设计时决定如何构造 Trace(从消息头传播上下文?为每个批次创建根 Span?),这个决定如果留到实现阶段临时做,往往导致追踪链断裂。第四,多租户与分片系统要把「路由决策」作为一等公民遥测:请求被路由到哪个分片、哪个租户的配额被消耗、哪个副本被选中——这些是「部分用户受影响」类故障的唯一线索。一个实用的评审方法是「故障演练纸面推演」:在设计评审时,构造三个假想的故障场景(下游依赖变慢、某个分片数据倾斜、新版本引入的边界条件 bug),然后问「用当前设计产生的遥测数据,我们能在多久内定位它?」如果答案是「不能」,那么需要修改的是设计而不只是埋点计划。

开发环境的可观测性:把反馈回路从「天」缩短到「秒」。可观测性通常被认为是生产环境的能力,但它在开发阶段的价值被严重低估。设想两种开发体验:第一种,开发者修改了一段涉及三个服务调用的代码,本地跑单元测试通过,提交后在预发环境部署,发现延迟异常,但预发环境的追踪采样率是 1%,等了半小时才抓到一条相关 Trace,最终发现问题出在一个未预料的重试循环——总耗时半天。第二种,开发者在本地 docker-compose 中启动了 Collector + Jaeger + Prometheus,代码修改后本地运行,浏览器打开 localhost:16686 立刻看到自己刚才那次请求的完整调用链,重试循环一目了然——总耗时五分钟。第二种体验的技术门槛极低:一个 docker-compose.yml 文件、几行 OTel SDK 初始化代码(用环境变量控制 exporter 端点)、一个 Makefile 目标 make observability-up。工程要点有四条。第一,本地与生产使用完全相同的埋点代码,只通过配置切换后端(OTEL_EXPORTER_OTLP_ENDPOINT 指向本地 Collector 或生产网关),绝不允许出现「本地专用的调试日志」这种分叉。第二,本地环境要默认全量采样(100%),因为流量极小,采样只会增加困惑。第三,提供「一键深链接」:本地开发服务器在启动时打印出 Jaeger/Grafana 的 URL,或者在错误响应中直接附带 trace_id 与查询链接,让开发者无需手动搜索。第四,把本地可观测性纳入新人入职流程——一个新工程师能在入职第一天看到自己代码的 Trace,比读十页文档更能建立「可观测性是日常工具」的心智模型。额外的收益是:开发阶段的 Trace 常常暴露出生产环境因为采样或聚合而被掩盖的低效模式(如 N+1 查询、串行调用本可并行),这使得可观测性成为性能优化的日常工具而不只是故障诊断工具。

CI/CD 中的可观测性:部署验证、金丝雀分析与基于指标的自动回滚。变更是故障的首要来源(业界经验数据长期显示 60%–80% 的生产事故与最近的变更相关),因此「验证这次变更是否健康」是可观测性最高价值的应用场景之一。这个能力有三个成熟度层级。第一级是人工验证:部署后工程师盯着仪表盘看十分钟,凭经验判断「好像没问题」。它的问题是不可扩展(每天几十次部署时没人盯得过来)且不可靠(人会疲劳、会漏看)。第二级是自动化门禁:在流水线中加入一个验证步骤,用 PromQL 或平台 API 查询关键指标,与阈值或基线对比,不通过则阻断后续阶段或触发回滚。典型的门禁查询包括:错误率是否超过 SLO 允许的燃烧率、P99 延迟是否相对部署前基线恶化超过 20%、饱和度指标(队列深度、连接池使用率)是否异常。第三级是统计化金丝雀分析:把流量按比例切分给新版本(金丝雀)与旧版本(基线),持续对比两组的关键指标分布,用统计检验(如 Mann-Whitney U 检验、贝叶斯分析)判断差异是否显著,显著恶化则自动回滚。开源实现有 Flagger(与 Istio/Linkerd/Nginx 集成,支持 A/B、蓝绿、金丝雀三种策略)与 Argo Rollouts(Kubernetes 原生的渐进式交付控制器,支持 AnalysisTemplate 声明式指标分析);商业实现有 Harness CV、Datadog Watchdog 等。工程要点:门禁的指标必须是「用户视角」的(成功率、延迟)而非「系统视角」的(CPU、内存),因为后者对真实伤害不敏感;门禁需要有「预热期」(新版本刚启动时 JIT 未热、缓存未填充,指标天然偏差)与「最小样本量」(流量太低时统计无意义,应跳过而非误判);自动回滚必须有明确的触发条件、执行路径与人工介入的逃生舱,且回滚本身要被演练过——从未演练的回滚机制在真实故障时通常会失败。

可观测性的可测试性:埋点单元测试、混沌验证与合成监控。「你怎么知道你的埋点是有效的?」这个问题的传统答案是「等故障发生时看看够不够用」——这是最昂贵的验证方式。成熟的做法分三类。第一类是埋点的单元测试:用 OTel 提供的内存导出器(如 Go 的 tracetest.NewInMemoryExporter、Java 的 OpenTelemetryExtension、Python 的 InMemorySpanExporter)捕获测试过程中产生的 Span,然后断言其名称、属性、父子关系、事件是否符合预期。这类测试的价值在于防止「埋点回归」——重构代码时不小心删掉了一个关键属性,或者把 http.status_code 写成了字符串类型,单元测试能在合并前捕获。第二类是混沌工程与可观测性验证的结合:注入故障(延迟、错误、资源耗尽、依赖不可用)后,不仅检验系统是否优雅降级,还检验可观测性体系是否「看见」了它——告警是否在预期时间内触发?触发的是哪条规则?仪表盘是否显示了正确的症状?追踪数据是否足以让一个不熟悉该服务的人定位根因?这把可观测性本身变成了被测系统,是发现「监控盲区」最有效的手段。Netflix 的 ChAP(Chaos Automation Platform)与 LinkedIn 的 LinkedOut 都采用了类似思路:在受控实验中对比「有可观测性数据」与「无可观测性数据」两组工程师的诊断表现,量化可观测性的价值。第三类是生产环境的持续验证:合成监控(synthetic monitoring)用定时脚本模拟真实用户行为(登录、搜索、下单),既检验业务路径可用性,也检验遥测管道端到端通畅——如果合成请求的 Trace 没有出现在后端,说明采集链路断了。此外还应监控「可观测性系统自身的健康」:Collector 的接收/导出速率比、后端写入延迟、告警规则的最后求值时间、以及「心跳指标」(每个服务定期发送一个固定值的指标,若停止则说明该服务的遥测管道已死)。

开发者体验与「可观测性税」:让正确的做法成为最容易的做法。可观测性平台有一群内部用户——开发者,而内部工具的典型失败模式是「技术可用但体验糟糕」,最终导致绕过、影子实践与数据质量崩塌。「可观测性税」指工程师为了获得可观测性而必须付出的额外摩擦:手工填写接入表单、等待平台团队审批、阅读五十页文档才能发出第一个 Span、查询时需要记住二十个字段名、从代码无法直接跳转到相关遥测。税率越高,逃税越普遍——表现为关键路径不埋点、埋点字段随意命名、日志级别乱用。降低税率的工程手段有五类。第一,脚手架与模板:新服务的项目模板中预置 OTel 初始化、标准资源属性、RED 指标暴露、日志桥接,开发者创建服务时即可观测性就绪,零额外工作。第二,约定优于配置:统一的属性命名规范(基于 OTel 语义约定)、统一的 service.name 推导规则(从 K8s 标签自动获取)、统一的采样策略下发,让开发者不需要做任何决策。第三,可发现性:从代码仓库的 README 自动生成「该服务的遥测入口」链接;在 IDE 插件或 CLI 中提供 service observability checkout-service 一键打开看板;在告警通知中直接附带预置好时间窗口与过滤条件的查询链接。第四,自助与即时反馈:本地开发环境一键启动、查询结果秒级返回、无需申请权限即可看到自己服务的数据。第五,把可观测性能力产品化:像对待外部产品一样对待内部平台——有明确的用户画像(新入职工程师、值班 SRE、性能优化专家)、有可用性测试、有 NPS 或满意度调研、有版本发布说明。判断标准很简单:一个新工程师从「克隆代码」到「在本地看到自己请求的完整 Trace」需要多少分钟?如果超过十分钟,税率就太高了。

遥测 Schema 的演进与兼容性管理:像对待 API 一样对待事件。宽事件与结构化日志本质上是一种数据契约:生产方(服务代码)承诺发出特定字段,消费方(仪表盘、告警、SLO 计算、下游数据管道)依赖这些字段的存在与语义。契约一旦破坏,下游会静默失效——仪表盘显示空数据、告警永不触发、SLO 计算错误,而这些故障往往在很久之后才被发现。因此遥测 schema 需要与 API 同等的治理强度。第一,显式声明:为关键事件维护 schema 定义文件(JSON Schema、Protobuf、或简单的 Markdown 表格),记录每个字段的名称、类型、单位、取值范围、语义说明、是否必填、负责人。第二,兼容性规则:新增字段永远安全;重命名字段、改变类型、改变单位(毫秒改秒)、收窄取值范围都是破坏性变更,必须走废弃流程——先双写(新旧字段并存)、通知消费方、监控旧字段的使用量、确认归零后删除。第三,自动化校验:在 CI 中加入 schema 检查,对比本次提交产生的事件样本与已声明的 schema,发现未声明的新字段(提示补充文档)或缺失的必填字段(阻断合并);在 Collector 管道中用 transform processor 做运行时校验与规范化,把不合规的数据打上质量标记而非直接丢弃(丢弃会让问题隐形)。第四,消费方依赖追踪:记录「哪个仪表盘/告警使用了哪个字段」,这样在计划变更时能主动通知受影响的所有者。Grafana 的 dashboard linting、Datadog 的 Monitor 依赖分析、以及自建的「字段使用统计」(基于查询日志分析)都是这类工具。核心观念:遥测数据的消费者数量随组织规模超线性增长,任何「我改个字段名应该没人用」的假设在超过二十人的工程组织中几乎必然是错的。

特性开关与可观测性的结合:把「变更意图」变成可查询的维度。特性开关(Feature Flag)与可观测性的结合是一个被低估的高杠杆实践。传统上,特性开关用于渐进发布与快速关停;但从可观测性视角看,它的真正价值是「为遥测数据打上变更意图的标签」。当每个 Span 与事件都携带当前生效的 flag 状态(如 feature.new_checkout_flow=true、feature.pricing_algo=v3)时,一类原本极难诊断的问题变得 trivial:「新版本上线后某个指标恶化了」可以直接用 flag 维度切分对比,无需依赖部署时间窗口的模糊推断;「某个用户群体行为异常」可以立刻排查是否与某个 flag 的定向投放相关。实施要点有四条。第一,flag 评估结果要作为 Span 属性记录,但必须控制基数——只记录「本次请求实际评估过的 flag」(通常几个),而不是「系统中所有 flag 的状态」(可能几百个),否则每个事件都会膨胀数倍。第二,flag 的元数据(名称、变体、投放规则、负责人)应作为独立的低基数维度进入指标系统,使得「按 flag 分组的错误率」成为廉价的预聚合查询。第三,把 flag 变更本身作为事件记录(谁在何时把哪个 flag 从 A 改成 B),并在 Grafana 中作为 annotation 叠加到指标曲线上——这让「指标突变」与「flag 变更」的因果关联一目了然,是变更归因的关键。第四,与部署验证结合:金丝雀分析时,把「新版本 + flag 开启」与「旧版本 + flag 关闭」作为对比组,可以分离「代码变更的影响」与「配置变更的影响」。LaunchDarkly、Unleash、Flagsmith 等平台均提供 OTel 集成或 webhook 机制;自研 flag 系统则应在 SDK 层统一注入属性,避免每个业务开发者手工添加。

ODD 的组织落地路径与典型反模式。可观测性驱动开发不是一次工具采购,而是一组工程习惯的改变,因此落地路径比技术方案更关键。推荐的渐进路径分四步。第一步「让数据可见」:先确保所有服务都有基础的 RED 指标与 Trace,且开发者能自助查询——没有这一步,后续所有实践都缺乏地基。第二步「让配置可管理」:把仪表盘与告警搬进 Git,建立评审流程与漂移检测——这一步的收益立竿见影(配置丢失、误改的问题消失),且阻力最小。第三步「让发布可验证」:在 CI/CD 中加入指标门禁与金丝雀分析,从最关键的一两个服务开始试点,用数据证明「门禁捕获了几次本会逃逸到生产的故障」,再逐步推广。第四步「让设计可观测」:把可观测性检查项纳入架构评审模板、把遥测要求写入 DoD、把埋点测试纳入 CI——这一步最难,因为它触及设计习惯,需要资深工程师示范与团队共识。四步之间不要跳跃:在没有基础数据的服务上推行「设计可观测」只会产生空洞的清单勾选。典型反模式有五个。其一是「平台团队包办」:可观测性团队替业务团队写埋点、配看板,结果业务团队不理解也不使用,平台团队成为瓶颈;正确做法是平台提供能力与标准,业务团队拥有自己的遥测。其二是「工具先行」:先采购昂贵的平台再想怎么用,结果功能利用率不足 20%。其三是「指标崇拜」:把「埋点覆盖率」「看板数量」当作 KPI,导致大量无用的遥测数据(与第 9 章的成本治理直接冲突)。其四是「一次性运动」:搞一个「可观测性冲刺」集中补埋点,之后无人维护,半年后回到原点;ODD 必须是持续的日常实践。其五是「忽略值班反馈闭环」:值班工程师在故障中发现的诊断盲区没有被记录与跟进,同样的盲区在下次故障中重现;应该建立「每次事故必须产出至少一条遥测改进项」的硬性规则,这是 ODD 自我强化的核心机制。

代码示例

/* 示例 1:可观测性即代码的仓库结构与 Jsonnet 看板抽象 */
/* 目录结构(observability-config 仓库) */
/*
observability-config/
├── README.md
├── Makefile                     /* lint / test / plan / apply 四个入口 */
├── ci/
│   ├── promtool-test.yml        /* 告警规则的单元测试 */
│   └── dashboard-lint.sh        /* 看板 JSON 校验:引用不存在的指标即失败 */
├── lib/
│   ├── red-dashboard.libsonnet  /* 可复用的 RED 看板生成函数 */
│   ├── slo-alerts.libsonnet     /* 多窗口多燃烧率告警生成函数 */
│   └── naming.libsonnet         /* 指标与标签命名规范常量 */
├── services/
│   ├── checkout/
│   │   ├── dashboard.jsonnet
│   │   ├── alerts.yml
│   │   ├── slo.yml
│   │   └── OWNERS               /* 该服务遥测配置的负责人 */
│   ├── inventory/
│   └── payment-gateway/
├── platform/
│   ├── datasources.tf           /* Grafana 数据源(Terraform 管理) */
│   ├── folders.tf
│   └── collector/
│       ├── base.yaml
│       └── overlays/{dev,staging,prod}.yaml
└── environments/
    ├── dev.jsonnet              /* 参数化:保留期、采样率、告警接收人 */
    ├── staging.jsonnet
    └── prod.jsonnet
*/

/* lib/red-dashboard.libsonnet:把「一个服务的 RED 看板」封装成函数 */
local g = import 'grafonnet/grafana.libsonnet';

{
  /* 参数:服务名、命名空间、环境;返回完整的看板 JSON */
  new(service, namespace='production', env='prod')::
    g.dashboard.new(
      '%s — RED Overview' % service,
      uid='%s-red' % service,
      tags=[service, namespace, 'auto-generated'],
      refresh='30s',
      time_from='now-6h',
    )
    .addPanel(
      g.panel.timeseries.new(
        title='Request Rate (req/s)',
        gridPos={ h: 8, w: 8, x: 0, y: 0 },
      )
      .addTarget(
        g.prometheus.target(
          expr='sum by (status_class) (rate(http_requests_total{job="%s",namespace="%s"}[$__rate_interval]))' % [service, namespace],
          legendFormat='{{status_class}}',
        )
      ),
      title='Request Rate (req/s)',
    )
    .addPanel(
      g.panel.timeseries.new(
        title='Error Ratio',
        gridPos={ h: 8, w: 8, x: 8, y: 0 },
        thresholds=[{ value: 0.01, color: 'red' }],
      )
      .addTarget(
        g.prometheus.target(
          expr=|||
            sum(rate(http_requests_total{job="%s",namespace="%s",status_class="5xx"}[$__rate_interval]))
              /
            sum(rate(http_requests_total{job="%s",namespace="%s"}[$__rate_interval]))
          ||| % [service, namespace, service, namespace],
          legendFormat='error ratio',
        )
      ),
    )
    .addPanel(
      g.panel.timeseries.new(
        title='Latency Percentiles',
        gridPos={ h: 8, w: 8, x: 16, y: 0 },
      )
      /* 注意:基于预聚合的 bucket 序列计算分位数,遵守分位数不可加原则 */
      .addTarget(
        g.prometheus.target(
          expr='histogram_quantile(0.50, sum by (le) (rate(job:http_request_duration_seconds_bucket:rate5m{job="%s"}[5m])))' % service,
          legendFormat='p50',
        )
      )
      .addTarget(
        g.prometheus.target(
          expr='histogram_quantile(0.99, sum by (le) (rate(job:http_request_duration_seconds_bucket:rate5m{job="%s"}[5m])))' % service,
          legendFormat='p99',
        )
      ),
    ),
}

/* services/checkout/dashboard.jsonnet:三行生成一个完整看板 */
local red = import 'lib/red-dashboard.libsonnet';
local env = import 'environments/prod.jsonnet';

red.new(service='checkout', namespace=env.namespace, env=env.name)
这份代码展示了可观测性即代码的核心价值:抽象与参数化。lib/red-dashboard.libsonnet 把「一个服务的 RED 看板」封装成一个函数,services/checkout/dashboard.jsonnet 只需三行就能生成一个包含请求速率、错误率、延迟分位数三个面板的完整看板。当组织有二百个服务时,这意味着二百个结构一致、命名规范、维护成本极低的看板,而不是二百个手工点击出来的、风格各异的、其中一半已经过时的看板。修改一处(比如给所有 RED 看板加一个饱和度面板)只需改 lib 文件并重新生成,这是手工配置永远做不到的。几个实现细节值得注意。uid 用服务名确定性生成(checkout-red),这使得从代码、告警通知、文档中深链接到看板成为可能,且不会因为重新创建而产生新 uid 导致链接失效。tags 中包含 auto-generated 标记,配合漂移检测可以识别「哪些看板是代码管理的、哪些是手工创建的」,后者是治理的对象。$__rate_interval 是 Grafana 的内置变量,它会根据面板时间范围与数据源的 scrape interval 自动计算合适的 rate 窗口,避免手写 [5m] 在短时间范围下出现数据空洞——这是看板质量的一个常见细节差异。延迟面板刻意使用预聚合的 bucket 序列(job:http_request_duration_seconds_bucket:rate5m)而非原始序列,既降低查询成本,也保证跨实例聚合时分位数的数学正确性。environments/prod.jsonnet 的参数化让同一份定义在 dev/staging/prod 生成不同配置(命名空间、告警接收人、保留期),避免了「三套环境三份配置」的漂移问题。最后,ci/ 目录中的 dashboard-lint.sh 与 promtool-test.yml 是「可观测性配置的测试」——它们在看板与告警规则部署前验证其正确性,这是把软件工程纪律引入运维配置的关键一步,也是示例 5 的主题。
/* 示例 2:Terraform 管理 Grafana 数据源、看板与告警规则 */
/* main.tf */
terraform {
  required_providers {
    grafana = {
      source  = "grafana/grafana"
      version = "~> 3.0"
    }
  }
  backend "s3" {
    bucket = "acme-observability-tfstate"
    key    = "grafana/terraform.tfstate"
    region = "cn-hangzhou"
  }
}

provider "grafana" {
  url  = var.grafana_url
  auth = var.grafana_api_key   /* 通过 TF_VAR_grafana_api_key 注入,禁止硬编码 */
}

/* 文件夹:按团队组织,权限边界与 OWNERS 对齐 */
resource "grafana_folder" "team_checkout" {
  title = "Checkout Team"
  uid   = "folder-checkout"
}

/* 看板:内容由 Jsonnet 生成,Terraform 只负责同步 */
resource "grafana_dashboard" "checkout_red" {
  folder      = grafana_folder.team_checkout.id
  config_json = file("${path.module}/generated/checkout-red.json")
  message     = "managed by terraform — do not edit in UI"
  allow_destroy = false          /* 防止 terraform destroy 误删生产看板 */
}

/* 数据源:Prometheus / Tempo / Loki 统一由代码定义 */
resource "grafana_data_source" "prometheus" {
  type = "prometheus"
  name = "Prometheus-Prod"
  uid  = "ds-prometheus-prod"
  url  = "http://thanos-query.monitoring.svc:9090"
  json_data_encoded = jsonencode({
    httpMethod     = "POST"
    prometheusType = "Thanos"
    prometheusVersion = "2.45.0"
    timeInterval   = "30s"
    maxConcurrentShardRequests = 64
    exemplarTraceIdDestinations = [
      { name = "traceID", datasourceUid = "ds-tempo-prod" }   /* 指标 -> 追踪 的关联跳转 */
    ]
  })
}

/* 告警规则:SLO 燃烧率告警,通知中携带 runbook 与预置查询链接 */
resource "grafana_rule_group" "checkout_slo" {
  name             = "checkout-slo-burn-rate"
  folder_uid       = grafana_folder.team_checkout.uid
  interval_seconds = 60
  org_id           = 1

  rule {
    name           = "CheckoutSLOFastBurn"
    condition      = "C"
    for            = "0s"
    annotations = {
      summary     = "Checkout 可用性 SLO 正在快速燃烧(14.4x),预计 2 天内耗尽 30 天预算"
      runbook_url = "https://runbooks.internal/checkout/slo-fast-burn"
      description = "错误率已超过 SLO 允许速率的 14.4 倍。立即检查最近部署与下游依赖。"
    }
    labels = {
      severity = "page"
      team     = "checkout"
      slo      = "checkout-availability"
    }
    is_paused = false

    data {
      ref_id     = "A"
      query_type = ""
      relative_time_range { start_seconds = 3600  end_seconds = 0 }
      datasource_uid = "ds-prometheus-prod"
      model = jsonencode({
        expr = "job:slo_error_ratio:burn_rate1h{job=\"checkout\"} > 14.4"
        refId = "A"
        type  = "instant"
      })
    }
    data {
      ref_id     = "B"
      relative_time_range { start_seconds = 300 end_seconds = 0 }
      datasource_uid = "ds-prometheus-prod"
      model = jsonencode({
        expr = "job:slo_error_ratio:burn_rate5m{job=\"checkout\"} > 14.4"
        refId = "B"
        type  = "instant"
      })
    }
    /* 双窗口条件:1h 与 5m 同时超阈值才触发,防止瞬时抖动误报 */
    data {
      ref_id     = "C"
      datasource_uid = "__expr__"
      relative_time_range { start_seconds = 0 end_seconds = 0 }
      model = jsonencode({
        type      = "math"
        expression = "$A && $B"
        refId     = "C"
      })
    }
  }
}

/* 通知策略:按 severity 路由到不同接收器 */
resource "grafana_notification_policy" "checkout" {
  group_by      = ["team", "slo"]
  contact_point = "pagerduty-checkout"
  matcher { label = "severity" match = "=" value = "page" }
  group_wait      = "30s"
  group_interval  = "5m"
  repeat_interval = "4h"
}
Terraform 在这里扮演的角色是「可观测性配置的部署器」,它与示例 1 的 Jsonnet 形成分工:Jsonnet 负责生成内容(看板 JSON、规则 YAML),Terraform 负责把内容同步到平台并维护状态一致性。这个分工很重要——Terraform 的 HCL 不适合表达复杂的看板布局逻辑,而 Jsonnet 不擅长管理远端资源的状态。几个配置细节体现了生产环境的经验。allow_destroy = false 是一个防御性设置:terraform destroy 或资源块被误删时,Terraform 会拒绝删除真实看板,避免一次 CI 配置错误清空生产环境的所有仪表盘。message 字段在 Grafana 的看板历史中留下「managed by terraform」的标记,配合 UI 上的「不可编辑」提示,减少手工修改导致的漂移。数据源配置中的 exemplarTraceIdDestinations 是三大支柱关联的关键接线:它告诉 Grafana「Prometheus 指标上的 exemplar 中的 traceID 字段应该跳转到 uid 为 ds-tempo-prod 的数据源」,配置正确后,工程师在延迟曲线上点击一个数据点就能直接跳到那条具体的 Trace——这个「一键跳转」能力是诊断效率的分水岭,而它极易被遗漏。告警规则部分实现了第 8 章讨论的多窗口多燃烧率机制:A 查询 1 小时窗口的燃烧率、B 查询 5 分钟窗口、C 用 math 表达式做逻辑与,只有两者同时超阈值才触发,这样既能在持续故障时及时 page,又不会被单次抖动误报。annotations 中的 runbook_url 与 summary 是告警可用性的核心——一条只说「错误率高」的告警会让值班工程师从零开始排查,而携带 runbook 链接与「预计 2 天内耗尽预算」这类翻译后语义的告警,能让响应时间缩短数倍。最后的 notification_policy 用 group_by = [team, slo] 确保同一团队的同一 SLO 的多条告警被聚合成一次通知,避免告警风暴;repeat_interval = 4h 在「未确认的告警需要持续提醒」与「不要每 5 分钟骚扰一次」之间取得平衡。
/* 示例 3:CI/CD 部署验证门禁(PromQL 指标门禁 + 自动回滚) */
#!/usr/bin/env bash
/* deploy-gate.sh — 部署后验证:对比金丝雀与基线的关键指标 */
set -euo pipefail

PROM_URL="http://thanos-query.monitoring.svc:9090"
SERVICE="${1:?usage: deploy-gate.sh <service> <canary-version> <baseline-version>}"
CANARY="${2:?}"
BASELINE="${3:?}"
WARMUP_SECONDS="${WARMUP_SECONDS:-180}"   /* 预热期:跳过 JIT/缓存冷启动阶段 */
WINDOW="5m"
MIN_REQUESTS="${MIN_REQUESTS:-500}"       /* 最小样本量,低于此值判定为「流量不足」 */

query() {
  /* 执行即时查询并提取标量结果 */
  curl -sfG "$PROM_URL/api/v1/query" \
    --data-urlencode "query=$1" \
  | jq -r '.data.result[0].value[1] // "0"'
}

echo "[gate] 等待预热期 ${WARMUP_SECONDS}s ..."
sleep "$WARMUP_SECONDS"

/* 1. 样本量检查:流量不足时不做统计判断,直接跳过(避免误判) */
CANARY_COUNT=$(query "sum(increase(http_requests_total{job=\"$SERVICE\",version=\"$CANARY\"}[$WINDOW]))")
echo "[gate] 金丝雀请求数: $CANARY_COUNT"
if (( $(echo "$CANARY_COUNT < $MIN_REQUESTS" | bc -l) )); then
  echo "[gate] SKIP: 流量不足 $MIN_REQUESTS,无法做出统计判断"
  exit 0
fi

/* 2. 错误率对比:金丝雀错误率不得超过基线的 2 倍且绝对值不超过 1% */
CANARY_ERR=$(query "sum(rate(http_requests_total{job=\"$SERVICE\",version=\"$CANARY\",status_class=\"5xx\"}[$WINDOW])) / sum(rate(http_requests_total{job=\"$SERVICE\",version=\"$CANARY\"}[$WINDOW]))")
BASE_ERR=$(query "sum(rate(http_requests_total{job=\"$SERVICE\",version=\"$BASELINE\",status_class=\"5xx\"}[$WINDOW])) / sum(rate(http_requests_total{job=\"$SERVICE\",version=\"$BASELINE\"}[$WINDOW]))")
echo "[gate] 错误率 金丝雀=$CANARY_ERR 基线=$BASE_ERR"

FAIL=0
if (( $(echo "$CANARY_ERR > 0.01" | bc -l) )); then
  echo "[gate] FAIL: 金丝雀绝对错误率超过 1%"; FAIL=1
fi
if (( $(echo "$CANARY_ERR > $BASE_ERR * 2 + 0.001" | bc -l) )); then
  echo "[gate] FAIL: 金丝雀错误率超过基线 2 倍"; FAIL=1
fi

/* 3. P99 延迟对比:不得超过基线的 1.3 倍 */
CANARY_P99=$(query "histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket{job=\"$SERVICE\",version=\"$CANARY\"}[$WINDOW])))")
BASE_P99=$(query "histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket{job=\"$SERVICE\",version=\"$BASELINE\"}[$WINDOW])))")
echo "[gate] P99 延迟 金丝雀=$CANARY_P99 基线=$BASE_P99"
if (( $(echo "$CANARY_P99 > $BASE_P99 * 1.3" | bc -l) )); then
  echo "[gate] FAIL: P99 延迟恶化超过 30%"; FAIL=1
fi

/* 4. SLO 燃烧率检查:不得触发快速燃烧 */
BURN=$(query "job:slo_error_ratio:burn_rate5m{job=\"$SERVICE\"}")
echo "[gate] 当前燃烧率: $BURN"
if (( $(echo "$BURN > 6" | bc -l) )); then
  echo "[gate] FAIL: 燃烧率 $BURN 超过 6x 阈值"; FAIL=1
fi

/* 5. 饱和度检查:连接池与队列不得接近上限 */
SAT=$(query "max(pool_connections_in_use{job=\"$SERVICE\",version=\"$CANARY\"} / pool_connections_max{job=\"$SERVICE\",version=\"$CANARY\"})")
if (( $(echo "$SAT > 0.85" | bc -l) )); then
  echo "[gate] FAIL: 连接池使用率 $SAT 超过 85%"; FAIL=1
fi

if (( FAIL == 1 )); then
  echo "[gate] 门禁未通过,触发自动回滚"
  kubectl argo rollouts abort "$SERVICE" -n production || true
  helm rollback "$SERVICE" "$(helm history "$SERVICE" -n production --max 5 -o json | jq -r '[.[] | select(.status=="deployed")][0].revision')" -n production
  exit 1
fi

echo "[gate] PASS: 所有指标检查通过,继续渐进式发布"
这个脚本是「基于指标的自动回滚」的最小可用实现,它的设计中有五个细节决定了它在生产环境是否可靠。第一是预热期(WARMUP_SECONDS=180):新版本刚启动时 JIT 编译未完成、本地缓存未填充、连接池未建立,此时的延迟与错误率天然偏高,不做预热会导致几乎每次部署都误判失败。预热时长应基于服务的实际启动特性测量得出,Java 服务通常需要 3–5 分钟,Go 服务 30 秒即可。第二是最小样本量检查:低流量服务在 5 分钟窗口内可能只有几十个请求,此时错误率的统计波动极大(一个错误就是 2%),任何阈值判断都是噪音;脚本选择 SKIP 而非 FAIL,这是一个重要的设计决策——门禁在不确定时应保持沉默,而不是制造虚假信号阻碍发布。第三是「绝对阈值 + 相对阈值」的双重判断:只看相对值(金丝雀 vs 基线)会在基线本身很差时放过问题(基线错误率 5%,金丝雀 9%,相对只恶化 1.8 倍但绝对不可接受);只看绝对值则无法发现「相对基线的显著退化」。两者结合才完整。第四是检查维度的选择:错误率、P99 延迟、SLO 燃烧率、饱和度四类,覆盖了「正确性、性能、可靠性预算、资源」四个视角;注意延迟用 P99 而非平均值,因为平均值对长尾退化不敏感——一次让 1% 用户延迟增加十倍的变更,在平均值上几乎不可见。第五是回滚路径的健壮性:脚本先尝试 Argo Rollouts 的 abort(渐进式发布的原生中止),失败则回退到 helm rollback,并从 helm history 中动态解析上一个 deployed 版本号而非硬编码——硬编码版本号在连续部署场景下会回滚到错误的版本。生产化改进方向:把阈值参数化到服务级配置文件(不同服务容忍度不同)、把每次门禁的判定结果作为事件写入可观测性后端(形成「门禁有效性」的元数据,用于回顾「有多少次失败被门禁捕获、多少次误报」)、以及引入统计检验替代固定倍数阈值(Flagger 与 Argo Rollouts 的 AnalysisTemplate 内置了这类能力,见示例 6)。
/* 示例 4:本地开发环境的可观测性栈(docker-compose + 应用侧零改动接入) */
/* docker-compose.observability.yml */
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.110.0
    command: ["--config=/etc/otelcol/config.yaml"]
    volumes:
      - ./observability/collector-local.yaml:/etc/otelcol/config.yaml:ro
    ports:
      - "4317:4317"     /* OTLP gRPC */
      - "4318:4318"     /* OTLP HTTP */
      - "8888:8888"     /* Collector 自身指标 */
    depends_on: [jaeger, prometheus]

  jaeger:
    image: jaegertracing/all-in-one:1.60
    environment:
      COLLECTOR_OTLP_ENABLED: "true"
    ports:
      - "16686:16686"   /* UI */

  prometheus:
    image: prom/prometheus:v2.54.0
    volumes:
      - ./observability/prometheus-local.yaml:/etc/prometheus/prometheus.yml:ro
    ports:
      - "9090:9090"

  grafana:
    image: grafana/grafana:11.2.0
    environment:
      GF_AUTH_ANONYMOUS_ENABLED: "true"
      GF_AUTH_ANONYMOUS_ORG_ROLE: "Admin"
      GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH: "/var/lib/grafana/dashboards/local-dev.json"
    volumes:
      - ./observability/generated/local-dev.json:/var/lib/grafana/dashboards/local-dev.json:ro
      - ./observability/grafana-datasources.yaml:/etc/grafana/provisioning/datasources/ds.yaml:ro
    ports:
      - "3001:3000"

/* observability/collector-local.yaml:本地管道,全量采样、无脱敏、输出到本地后端 */
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 1s          /* 本地要求低延迟,批量窗口设小 */
  memory_limiter:
    check_interval: 1s
    limit_percentage: 80
    spike_limit_percentage: 25

exporters:
  otlp/jaeger:
    endpoint: jaeger:4317
    tls:
      insecure: true
  prometheus:
    endpoint: 0.0.0.0:8889
  debug:
    verbosity: basic     /* 在 Collector 日志中打印 Span 摘要,便于确认埋点是否生效 */

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlp/jaeger, debug]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [prometheus]

/* Makefile:一条命令启动,一条命令打开看板 */
/*
observability-up:
	docker compose -f docker-compose.observability.yml up -d
	@echo "Jaeger:    http://localhost:16686"
	@echo "Grafana:   http://localhost:3001"
	@echo "Prometheus: http://localhost:9090"

observability-down:
	docker compose -f docker-compose.observability.yml down -v

dev: observability-up
	OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 	OTEL_SERVICE_NAME=checkout 	OTEL_RESOURCE_ATTRIBUTES=deployment.environment=local,team=checkout 	OTEL_TRACES_SAMPLER=always_on 	go run ./cmd/checkout
*/

/* 应用侧:生产与本地共用同一份初始化代码,仅端点由环境变量决定 */
/* 错误响应中附带 trace_id 与本地查询深链接,实现「一键跳转」 */
func writeError(w http.ResponseWriter, r *http.Request, code int, err error) {
    span := trace.SpanFromContext(r.Context())
    traceID := span.SpanContext().TraceID().String()
    w.Header().Set("X-Trace-Id", traceID)
    if os.Getenv("OTEL_RESOURCE_ATTRIBUTES") != "" {
        w.Header().Set("X-Trace-Url",
            "http://localhost:16686/trace/"+traceID)
    }
    http.Error(w, `{"error":"`+err.Error()+`","trace_id":"`+traceID+`"}`, code)
}
本地可观测性栈的目标是把「看到自己代码的 Trace」的成本降到接近零,这份配置通过四个设计达成目标。第一,一条命令启动:make observability-up 拉起 Collector + Jaeger + Prometheus + Grafana 四个容器,Grafana 通过 provisioning 自动加载数据源与默认首页看板,开发者无需任何手工配置——匿名 Admin 登录在本地环境是合理的便利取舍(生产环境绝不可如此)。第二,应用侧零改动:make dev 目标通过环境变量注入 OTLP 端点、服务名、资源属性与采样器,应用代码中的 OTel 初始化逻辑与生产环境完全相同,只是端点指向 localhost。这一点至关重要——如果本地用一套埋点、生产用另一套,那么本地看到的 Trace 结构就不能代表生产行为,本地可观测性的价值会大打折扣。第三,本地全量采样(always_on)与低延迟批量(batch timeout 1s):本地流量极小,采样只会带来「为什么我的请求不见了」的困惑;而默认的 batch 超时(200ms–10s)会让开发者在发出请求后等几秒才能在 Jaeger 看到,1 秒的窗口在「即时反馈」与「批量效率」之间取得平衡。debug exporter 以 basic 详细度打印 Span 摘要到 Collector 日志,这是排查「埋点没生效」问题的第一手段——如果 Collector 日志里看不到 Span,问题在应用侧;看得到但 Jaeger 里没有,问题在后端。第四,深链接闭环:writeError 函数在错误响应中同时返回 trace_id(响应头与 JSON 体)和本地 Jaeger 的查询 URL,开发者在浏览器里看到 500 错误时可以直接点击链接跳到那条 Trace,无需手动复制 ID 再搜索。这个微小的体验改进会显著提高开发者主动查看 Trace 的频率,而频率正是习惯养成的关键。推广建议:把这个 compose 文件与 Makefile 放进服务模板仓库(cookiecutter / 内部脚手架),让所有新服务天生具备本地可观测性;并在入职文档的第一页就写「运行 make dev,然后打开 localhost:16686 看你的第一个 Trace」——第一印象决定了工程师对可观测性工具的态度。
/* 示例 5:埋点的单元测试与告警规则的测试(可观测性的可测试性) */
/* Go:用 in-memory exporter 断言 Span 结构,防止埋点回归 */
package checkout_test

import (
    "context"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    "go.opentelemetry.io/otel/sdk/trace/tracetest"
)

func TestCreateOrder_EmitsExpectedTelemetry(t *testing.T) {
    /* 内存导出器:捕获测试期间产生的全部 Span */
    exporter := tracetest.NewInMemoryExporter()
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithSyncer(exporter),   /* Syncer 而非 Batch,确保断言前 Span 已导出 */
        sdktrace.WithSampler(sdktrace.AlwaysSample()),
    )
    defer func() { _ = tp.Shutdown(context.Background()) }()

    svc := checkout.NewService(tp, newFakeInventoryClient())

    order, err := svc.CreateOrder(context.Background(), checkout.OrderRequest{
        UserID:   "u-123",
        TenantID: "t-acme",
        Items:    []checkout.Item{{SKU: "sku-1", Qty: 2}},
    })
    require.NoError(t, err)
    require.NotNil(t, order)

    spans := exporter.GetSpans().Snapshots()
    require.NotEmpty(t, spans, "CreateOrder 必须产生至少一个 Span")

    root := findSpan(spans, "checkout.CreateOrder")
    require.NotNil(t, root, "缺少根 Span checkout.CreateOrder")

    /* 断言关键属性存在且类型正确 —— 这些属性是生产诊断的依赖 */
    assertAttribute(t, root, "tenant.id", "t-acme")
    assertAttribute(t, root, "user.id", "u-123")
    assertAttribute(t, root, "order.item_count", int64(2))
    assertAttribute(t, root, "order.total_cents", int64(4000))

    /* 断言子 Span 的父子关系:库存调用必须是根 Span 的孩子 */
    inv := findSpan(spans, "inventory.Reserve")
    require.NotNil(t, inv, "缺少库存调用 Span,跨服务因果链将断裂")
    assert.Equal(t, root.SpanContext.SpanID(), inv.Parent.SpanID(),
        "inventory.Reserve 的父 Span 应为 checkout.CreateOrder")

    /* 断言业务事件:重试必须作为 Span Event 记录,否则重试风暴不可见 */
    events := inv.Events
    assert.GreaterOrEqual(t, len(events), 1, "库存调用应记录至少一个事件")
}

func TestCreateOrder_DownstreamFailure_RecordsErrorAndAttributes(t *testing.T) {
    exporter := tracetest.NewInMemoryExporter()
    tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter))
    defer func() { _ = tp.Shutdown(context.Background()) }()

    svc := checkout.NewService(tp, newFailingInventoryClient(errTimeout))
    _, err := svc.CreateOrder(context.Background(), checkout.OrderRequest{UserID: "u-1"})
    require.Error(t, err)

    spans := exporter.GetSpans().Snapshots()
    root := findSpan(spans, "checkout.CreateOrder")
    require.NotNil(t, root)

    /* 错误必须反映在 Span Status 上,否则尾部采样的 status_code 策略抓不到它 */
    assert.Equal(t, codes.Error, root.Status.Code,
        "失败路径必须设置 Span Status 为 Error")
    assertAttribute(t, root, "error.type", "InventoryTimeout")
    assertAttribute(t, root, "retry.count", int64(3))
}

/* promtool:告警规则的单元测试(ci/promtool-test.yml) */
/*
rule_files:
  - ../services/checkout/alerts.yml

evaluation_interval: 1m

tests:
  - interval: 1m
    input_series:
      /* 场景:错误率突增,燃烧率应超过 14.4x */
      - series: 'job:slo_error_ratio:burn_rate1h{job="checkout"}'
        values: '0.5 0.8 1.2 15.0 16.0 15.5 15.2 15.8 16.1 15.9 15.4 15.6'
      - series: 'job:slo_error_ratio:burn_rate5m{job="checkout"}'
        values: '0.4 0.9 1.5 18.0 17.0 16.5 16.2 18.8 16.1 15.9 15.4 15.6'
    alert_rule_test:
      - eval_time: 10m
        alertname: CheckoutSLOFastBurn
        exp_alerts:
          - exp_labels:
              severity: page
              team: checkout
              slo: checkout-availability
            exp_annotations:
              summary: "Checkout 可用性 SLO 正在快速燃烧(14.4x),预计 2 天内耗尽 30 天预算"

  - interval: 1m
    /* 反例:只有 5m 窗口超阈值(瞬时抖动),不应触发 page */
    input_series:
      - series: 'job:slo_error_ratio:burn_rate1h{job="checkout"}'
        values: '1.0x20'
      - series: 'job:slo_error_ratio:burn_rate5m{job="checkout"}'
        values: '2.0 2.0 20.0 22.0 1.0 1.0x15'
    alert_rule_test:
      - eval_time: 10m
        alertname: CheckoutSLOFastBurn
        exp_alerts: []
*/
这两组测试分别覆盖了可观测性的两类回归风险:埋点回归与告警回归。埋点单元测试的价值常被低估,直到某次重构悄悄删掉了一个关键属性——生产环境故障发生时,工程师发现「按 tenant.id 过滤」返回空结果,才意识到这个字段三个月前就不存在了,而这三个月里所有依赖它的看板与告警都在静默失效。测试中的几个技术选择值得说明:使用 WithSyncer 而非默认的 BatchSpanProcessor,因为批处理是异步的,测试断言可能在 Span 导出前执行,导致 flaky test;AlwaysSample 确保采样器不会干扰测试(生产配置可能是概率采样);断言父子关系(inventory.Reserve 的 parent 必须是 checkout.CreateOrder)而不只是断言 Span 存在,因为上下文传播断裂是可观测性最隐蔽的故障之一——Span 都在,但拼不成调用链。第二个测试用例尤其重要:它断言失败路径必须把 Span Status 设为 Error。这直接关联到第 9 章的尾部采样——tailsamplingprocessor 的 status_code 策略依赖 Span Status 判断是否保留,如果代码只记录了 error 属性却没有设置 Status,那么所有错误 Trace 都会被采样丢弃,形成「越是故障越没有数据」的灾难性组合。告警规则测试用 promtool 的 unit test 框架,它通过 input_series 构造合成的时间序列输入,在指定的 eval_time 断言哪些告警应该触发。示例包含正反两个用例:正例验证持续的高燃烧率会触发 page;反例验证只有 5 分钟窗口超阈值(瞬时抖动)时不触发——这正是多窗口机制的防误报设计,如果没有这个反例测试,未来某人「优化」规则时去掉双窗口条件不会被发现。把这两类测试放进 CI(make test 中执行 go test 与 promtool test rules),可观测性配置就获得了与业务代码同等的工程质量保障。
/* 示例 6:Argo Rollouts 声明式金丝雀分析(AnalysisTemplate) */
/* analysis-template.yaml:可复用的指标分析定义 */
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
  name: slo-canary-analysis
spec:
  args:
    - name: service-name
    - name: prometheus-addr
      value: http://thanos-query.monitoring.svc:9090
  metrics:
    /* 指标 1:错误率成功率检验,每 60s 一次,共 10 次,允许 0 次失败 */
    - name: success-rate
      interval: 60s
      count: 10
      successCondition: result[0] >= 0.995
      failureLimit: 0
      provider:
        prometheus:
          address: "{{args.prometheus-addr}}"
          query: |
            sum(rate(http_requests_total{job="{{args.service-name}}",status_class!="5xx"}[5m]))
              /
            sum(rate(http_requests_total{job="{{args.service-name}}}[5m]))

    /* 指标 2:P99 延迟不得超过 800ms,允许 2 次失败(容忍偶发抖动) */
    - name: latency-p99
      interval: 60s
      count: 10
      successCondition: result[0] <= 0.8
      failureLimit: 2
      provider:
        prometheus:
          address: "{{args.prometheus-addr}}"
          query: |
            histogram_quantile(0.99,
              sum by (le) (rate(http_request_duration_seconds_bucket{job="{{args.service-name}}"}[5m])))

    /* 指标 3:SLO 燃烧率不得进入快速燃烧区间 */
    - name: slo-burn-rate
      interval: 2m
      count: 5
      successCondition: result[0] < 6
      failureLimit: 1
      provider:
        prometheus:
          address: "{{args.prometheus-addr}}"
          query: |
            max(job:slo_error_ratio:burn_rate5m{job="{{args.service-name}}"})

/* rollout.yaml:渐进式发布策略,分析失败自动回滚 */
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: checkout
spec:
  replicas: 20
  strategy:
    canary:
      canaryService: checkout-canary
      stableService: checkout-stable
      trafficRouting:
        istio:
          virtualService:
            name: checkout-vsvc
            routes:
              - primary
      analysis:
        templates:
          - templateName: slo-canary-analysis
        args:
          - name: service-name
            value: checkout
        startingStep: 1        /* 从第二个步骤开始分析(第一步流量太小无统计意义) */
      steps:
        - setWeight: 5         /* 5% 流量 */
        - pause: { duration: 5m }
        - setWeight: 20
        - pause: { duration: 10m }
        - setWeight: 50
        - pause: {}            /* 无限暂停,需人工确认(关键服务的最后闸门) */
        - setWeight: 100
  template:
    metadata:
      labels:
        app: checkout
        team: checkout
      annotations:
        /* 部署元数据作为遥测维度:让「哪个版本」成为可查询字段 */
        deploy.version: "v2.14.3"
        deploy.commit: "a1b2c3d"
        deploy.change_id: "CHG-8821"
Argo Rollouts 的 AnalysisTemplate 把示例 3 的 shell 脚本门禁提升为声明式、可复用、由控制器持续执行的发布策略。它的核心优势有三点。第一是声明式与可复用:slo-canary-analysis 这个模板定义一次,所有服务的 Rollout 都可以引用,只需传入 service-name 参数;修改阈值时改一处即可全局生效,这是 shell 脚本难以做到的(脚本往往在每个服务的流水线里各有一份副本,逐渐漂移)。第二是持续执行而非单点检查:metrics 中的 interval=60s、count=10 意味着控制器会在整个金丝雀阶段每分钟查询一次、共十次,任何一次失败都会计入 failureLimit;这比「部署后 5 分钟检查一次」的门禁更能捕获延迟出现的问题(如内存泄漏、连接池耗尽、缓存穿透)。第三是与流量控制的原生集成:分析失败时控制器自动执行回滚(把流量切回 stable 版本并缩容 canary),无需外部脚本调用 kubectl/helm,回滚路径经过控制器状态机保证,比脚本更可靠。配置中的几个经验细节:failureLimit 的差异化设置体现了对不同指标噪音水平的理解——成功率是硬指标,一次低于 99.5% 就应停止发布(failureLimit=0);P99 延迟天然抖动较大,允许 2 次失败避免误判;燃烧率允许 1 次失败。startingStep=1 跳过第一个步骤的分析,因为 5% 流量在最初几十秒内样本量不足,统计无意义——这与示例 3 的 MIN_REQUESTS 检查是同一个问题的不同解法。steps 中最后的 pause: {} 是无限暂停,需要人工执行 argo rollouts promote 才能继续到 100%,这是关键服务的「最后闸门」:自动化负责发现明显问题,人类负责判断「指标正常但业务语义可能有误」的情况(如价格计算逻辑变更导致收入结构改变,这类问题指标看不出来)。Pod 模板的 annotations 中注入 deploy.version、deploy.commit、deploy.change_id,配合 OTel 的 k8sattributes processor 提取为资源属性,使得「按版本切分指标」成为可能——这是金丝雀分析能够对比 canary 与 stable 的前提,也是事后「哪次变更引入了这个问题」归因的基础。

实验笔记

实验 10:可观测性驱动开发全流程演练——从本地 Trace 到自动化发布门禁

一、实验目标

以一个小服务为载体,完整实践 ODD 的四个环节:(1) 在本地开发环境中获得即时可观测性反馈;(2) 为关键路径编写埋点单元测试并故意制造一次「埋点回归」验证测试的捕获能力;(3) 用 Jsonnet + Terraform 把看板与告警配置代码化,并验证漂移检测;(4) 在 CI/CD 中加入指标门禁,注入一次真实的性能退化,观察门禁是否捕获并自动回滚。完成后你应当能够回答:「我的团队要落地 ODD,第一周该做什么、第一个月该做什么」。

二、环境准备

需要:Docker + Docker Compose、Go 1.22+(或改用 Java/Python 的等价 OTel 测试库)、jsonnet 与 grafonnet 库、terraform 1.5+、一个 Kubernetes 集群(kind 或 minikube 即可,用于 Argo Rollouts 部分;若无集群,示例 6 可改为「阅读 + 干跑 yaml 校验」)、Prometheus + Thanos Query + Grafana + Jaeger(可用示例 4 的 compose 栈)。被测服务使用第 2 章实验的 checkout demo,需扩展两个能力:通过环境变量 LATENCY_INJECT_MS 在库存调用处注入延迟、通过环境变量 BREAK_SPAN_ATTR 模拟「埋点回归」(删除 tenant.id 属性)。实验总时长约 5 小时。

三、实验步骤

步骤 1(本地即时反馈):按示例 4 搭建本地可观测性栈,运行 make dev,用 curl 发起一次下单请求。验证四件事:Jaeger UI 中能看到完整调用链(含 inventory.Reserve 子 Span);错误响应头中包含 X-Trace-Id 与可点击的 X-Trace-Url;Prometheus 中能查到该服务的 RED 指标;Grafana 首页看板有数据。记录从「启动服务」到「看到第一条 Trace」的实际耗时——这个数字就是你团队的「可观测性接入税」,目标是低于 5 分钟。

步骤 2(埋点单元测试与回归捕获):按示例 5 编写两个测试用例(正常路径断言属性与父子关系、失败路径断言 Span Status 与 error.type)。运行测试确认通过。然后设置 BREAK_SPAN_ATTR=true 重新运行测试,验证测试失败并输出「缺少 tenant.id」的断言信息。再制造第二种回归:把失败路径中的 span.SetStatus(codes.Error, ...) 注释掉,验证第二个测试用例失败——并思考:如果这个回归逃到了生产环境,会对第 9 章的尾部采样策略造成什么后果?(提示:status_code 策略将抓不到任何错误 Trace。)

步骤 3(配置代码化与漂移检测):用示例 1 的 red-dashboard.libsonnet 为 checkout 生成看板 JSON,用示例 2 的 Terraform 部署到本地 Grafana。验证看板可访问、三个面板有数据。然后做漂移实验:在 Grafana UI 中手工修改看板标题,运行 terraform plan,观察 diff 是否检测到该修改;运行 terraform apply 验证配置被恢复到 Git 定义的状态。最后修改 lib 文件(给所有 RED 看板增加一个「饱和度」面板),重新生成并 apply,验证「一处修改、全局生效」。

步骤 4(告警规则测试):编写 checkout 的 SLO 燃烧率告警规则(可复用第 8 章实验的规则),并按示例 5 的 promtool 测试格式编写正反两个用例(持续高燃烧率应触发、仅 5m 窗口抖动不应触发)。运行 promtool test rules 验证。故意把双窗口条件改成单窗口,验证反例测试失败——这证明测试能防止「规则被无意弱化」。

步骤 5(部署门禁与自动回滚):部署示例 3 的 deploy-gate.sh(或示例 6 的 Argo Rollouts,若有集群)。先做一次正常发布,验证门禁 PASS 且输出的各项指标数值合理(记录金丝雀与基线的错误率、P99、燃烧率)。然后设置 LATENCY_INJECT_MS=600 发布一个新版本,观察门禁在哪个检查项失败、失败发生在部署后多久、回滚是否自动完成、回滚后指标是否恢复。把整个过程的时间线记录下来(部署开始 → 预热结束 → 首次检查 → 判定失败 → 回滚完成 → 指标恢复),这就是你的「变更失败恢复时间」,可与 DORA 的精英级基准(小于 1 小时)对比。

步骤 6(门禁有效性回顾):调整门禁阈值做敏感性分析:把 P99 的 1.3 倍阈值分别改为 1.1 倍与 2.0 倍,重跑步骤 5 的正常发布(无注入)三次,统计误报次数;再用 LATENCY_INJECT_MS=100 的「轻微退化」测试,统计漏报次数。据此讨论:这个服务的门禁阈值应该设在哪里?误报与漏报的相对成本是什么?

四、检查清单

□ 步骤 1 的「启动到看到 Trace」耗时低于 5 分钟,且深链接可点击

□ 步骤 2 的两种埋点回归都被测试捕获,能解释 Span Status 与尾部采样的关联

□ 步骤 3 的漂移被 terraform plan 检测到,lib 修改能全局生效

□ 步骤 4 的反例测试能防止告警规则被弱化

□ 步骤 5 有完整的失败-回滚时间线记录

□ 步骤 6 产出了基于误报/漏报数据的阈值建议

五、进阶挑战

(1) 把步骤 5 的门禁判定结果本身作为遥测事件写入可观测性后端(含服务名、版本、各项指标值、判定结论),建立一个「门禁有效性」看板:过去 30 天门禁执行了多少次、拦截了多少次、误报率多少——用数据驱动门禁阈值的持续调优。(2) 实现「可观测性 DoD 检查器」:写一个 CI 脚本,检查一个服务的代码仓库是否满足完成的定义(是否有 OTel 初始化、是否暴露 RED 指标、observability-config 仓库中是否有对应的 dashboard/alerts/slo 文件、是否有 OWNERS 与 runbook 链接),不满足则在 PR 上发表评论清单——把示例 1 的仓库结构变成可自动审计的规范。(3) 用混沌工程验证可观测性盲区:用 toxiproxy 在 checkout 与 inventory 之间注入 20% 的丢包(而非延迟),观察现有的看板、告警、Trace 是否足以让一个不熟悉该服务的人在 15 分钟内定位到「网络丢包导致重试放大」;如果不能,列出缺失的遥测项并补齐后重跑实验,量化「补齐前后」的诊断时间差异。

延伸阅读

  • 《Observability Engineering》第 8 章 "Observability-Driven Development" 与第 9 章 "Observability for Productivity and Development"(本书原文,O'Reilly 免费在线阅读)——ODD 概念的原始出处:作者用 Parse 与 Honeycomb 的亲身经历论证「可观测性应该内建于开发流程而非事后补充」,并给出「用可观测性数据做开发决策」的具体案例(如用宽事件驱动性能优化、用 Trace 指导重构)。本章的框架是对这两章的工程化展开,务必对照阅读原书的叙事与案例。
  • DORA(DevOps Research and Assessment)的历年《Accelerate State of DevOps Report》(cloud.google.com/devops)——部署频率、变更前置时间、变更失败率、恢复时间四大关键能力的量化基准与多年趋势数据。报告中关于「高绩效团队如何同时做到快与稳」的分析,是论证「可观测性是快速发布的前提」的最有力数据来源;示例 3 与实验步骤 5 的时间线记录方法直接对应 DORA 的指标定义。
  • Grafana 的 grafonnet 库文档与「Grafana as Code」系列博客(grafana.com/oss/grafonnet、grafana.com/blog)——看板即代码的实操参考:grafonnet 的 API 结构、如何封装可复用的看板函数、与 Terraform Provider 的配合方式、大规模看板库的组织与生成流水线。文档中的「mixins」概念(把服务的告警规则与看板打包成可复用单元)与本章示例 1 的 lib 抽象思路一致,是 Kubernetes 社区监控配置的事实标准做法。
  • Argo Rollouts 与 Flagger 的官方文档(argoproj.github.io/argo-rollouts、flagger.app)——渐进式交付与自动化金丝雀分析的两大开源实现:AnalysisTemplate 的全部 provider 类型(Prometheus、Datadog、NewRelic、CloudWatch、Web)、统计检验方法(Flagger 支持的自定义 metric template 与 A/B 测试)、与 Istio/Linkerd/Nginx/SMI 的流量路由集成。重点读 Argo Rollouts 的「Analysis」章节与 Flagger 的「How it works」,两者对「什么时候该自动回滚」给出了不同的工程答案,对比阅读能建立对门禁设计的完整认识。
  • OpenTelemetry 各语言 SDK 的测试文档与 tracetest 包源码(Go 的 sdk/trace/tracetest、Java 的 opentelemetry-extension-testing、Python 的 opentelemetry.sdk.trace.export.in_memory_span_exporter)——埋点单元测试的官方支持:内存导出器的 API、断言 Span 属性的辅助方法、与主流测试框架的集成方式。这些包的存在本身就说明「遥测代码需要测试」已成为社区共识;文档中的示例可直接作为团队埋点测试规范的起点。
  • 《Continuous Delivery》(Jez Humble & David Farley,Addison-Wesley)第 5 章「部署流水线」与第 12 章「自动化测试」——CI/CD 门禁设计的理论基础:阶段划分、快速失败原则、「部署流水线应该能在几分钟内给出可信的通过/失败信号」。虽然成书早于可观测性运动,但其关于「自动化验证的分层与反馈速度」的论述完全适用于指标门禁设计,是理解示例 3 与示例 6 中「预热期、最小样本量、失败容忍度」等参数背后逻辑的经典材料。
  • Netflix Technology Blog 关于 ChAP(Chaos Automation Platform)与 Kayenta 的文章,以及 Google SRE Workbook 第 22 章 "Addressing Cascading Failures" 与第 10 章 "Postmortem Culture"——「用受控实验验证系统与其可观测性」的工业级实践:ChAP 如何用少量真实流量做 A/B 实验检测微小退化、Kayenta 如何做自动金丝雀分析、以及复盘文化如何把「诊断盲区」转化为遥测改进项。这些材料提供了本章「可观测性可测试性」论点的最强证据,也是实验进阶挑战 (3) 的方法论来源。
  • Backstage 官方文档的「Software Templates」与「TechDocs」章节(backstage.io/docs)——开发者门户如何承载可观测性能力的落地参考:用脚手架模板预置 OTel 接入(对应本章「降低可观测性税」的第一手段)、在服务目录页聚合该服务的看板/告警/日志/Trace 入口(对应「可发现性」)、用 Scorecards 插件度量各服务的可观测性成熟度。Backstage 的 Spotify 内部实践数据(开发者自助率、接入时间下降幅度)是论证「开发者体验投资有回报」的实证材料,与第 11 章的平台团队主题衔接。
10/12可观测性
0%