第一章:Go context取消链为何总失效?——深入runtime.g0与goroutine本地存储的底层泄漏根源
Go 中 context.Context 的取消传播看似线性,实则高度依赖 goroutine 生命周期与调度器状态。当 cancel 函数被调用后,下游 goroutine 未如期退出,往往并非用户误用 WithCancel,而是因 runtime 层面的隐式状态逃逸:runtime.g0(系统栈 goroutine)在调度切换时会临时接管当前 goroutine 的 context 链,但若该 goroutine 因阻塞、panic 或被抢占而未执行 defer 或显式检查 ctx.Done(),其关联的 cancelCtx 就会滞留在 g0 的局部栈帧中,形成不可达却未释放的取消监听器。
g0 与 goroutine 本地存储的耦合陷阱
每个 goroutine 在创建时会继承父 goroutine 的 context,但 Go 运行时并不复制 context 结构体,而是通过 g.context 字段(位于 runtime.g 结构体中)持有指针。关键在于:当 goroutine 被挂起(如 select 阻塞或系统调用),其 g.context 可能被 g0 临时覆盖以服务调度逻辑,而恢复时若未重置,原 context 的 done channel 监听将永久丢失。
复现泄漏的最小验证代码
func leakDemo() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
// 模拟 goroutine 未检查 ctx.Done() 即阻塞
select {} // 永久阻塞,不响应 cancel
}()
time.Sleep(10 * time.Millisecond)
cancel() // 此处 cancel 实际已“生效”,但无 goroutine 消费 <-ctx.Done()
// runtime.GC() 后仍可见 leaked cancelCtx 在 heap profile 中存活
}
关键诊断手段
- 使用
go tool trace观察 goroutine 状态迁移,定位未终止的GC标记为Gwaiting但ctx.Done()未被接收; - 运行
GODEBUG=gctrace=1 go run main.go,观察 GC 周期中cancelCtx对象是否持续增长; - 在
runtime/proc.go中添加日志(需编译自定义 runtime),监控g.context赋值/清空时机。
| 现象 | 根本原因 | 修复方向 |
|---|---|---|
| cancel 后内存不下降 | cancelCtx.children map 未被 GC 清理 |
显式调用 delete(parentCtx.children, child) |
| goroutine 泄漏且无 panic 日志 | g0 栈中残留 context.cancelCtx 指针 |
避免无条件 select{},始终 select { case <-ctx.Done(): return } |
真正的取消链健壮性,始于对 g 和 g0 共享状态边界的敬畏。
第二章:context取消机制的理论模型与运行时契约
2.1 context.Context接口的生命周期语义与取消传播契约
context.Context 不是数据容器,而是生命周期信号载体——其核心契约在于:取消信号单向、不可逆、树状广播。
取消传播的不可逆性
ctx, cancel := context.WithCancel(context.Background())
cancel()
// 此后 ctx.Done() 永远返回已关闭的 channel
// 再次调用 cancel() 无副作用(幂等)
cancel() 触发后,ctx.Err() 永久返回 context.Canceled,所有下游 select 监听 ctx.Done() 的 goroutine 将同步退出。这是取消传播的原子性保障。
生命周期树状结构示意
graph TD
A[Background] --> B[WithTimeout]
A --> C[WithValue]
B --> D[WithCancel]
C --> E[WithDeadline]
关键语义对照表
| 行为 | 是否允许 | 说明 |
|---|---|---|
多次调用 cancel() |
✅ | 幂等,仅首次生效 |
ctx 跨 goroutine 复用 |
✅ | 安全,因 Done() 返回只读 channel |
| 向父 Context 注入子取消 | ❌ | 违反“单向传播”契约,需通过新派生链 |
取消必须沿父子链向下传递,不可反向或横向注入。
2.2 cancelCtx结构体的内存布局与原子状态机实现剖析
cancelCtx 是 context 包中实现可取消语义的核心结构体,其设计兼顾内存紧凑性与并发安全性。
内存布局特征
cancelCtx 在 src/context/context.go 中定义为:
type cancelCtx struct {
Context
mu sync.Mutex
done chan struct{}
children map[canceler]struct{}
err error
}
Context字段为嵌入接口,不占额外内存(仅虚表指针);done为惰性初始化的无缓冲 channel,首次调用Done()时创建;children采用map[canceler]struct{}而非[]canceler,避免 slice 扩容带来的 GC 压力与写屏障开销。
原子状态机机制
取消状态通过 err 字段隐式建模:nil 表示活跃,非 nil(如 Canceled)表示终止。所有状态变更均受 mu 保护,不依赖 atomic.Value 或 CAS 操作——这是有意为之的设计权衡:以轻量锁换取状态语义清晰性与调试可观测性。
| 字段 | 类型 | 并发安全方式 |
|---|---|---|
done |
chan struct{} |
一次性关闭(线程安全) |
children |
map[canceler]struct{} |
需 mu 保护 |
err |
error |
仅在 mu 持有下写入 |
状态流转示意
graph TD
A[Active] -->|cancel()| B[Terminating]
B --> C[Done & err set]
C --> D[Children notified]
2.3 goroutine退出时cancelFunc未触发的典型竞态路径复现
竞态根源:context.WithCancel 的生命周期错位
当 cancelFunc 在 goroutine 启动后才被调用,而 goroutine 已因 panic 或 return 提前退出,defer cancel() 将永不执行。
复现代码
func riskyStart() {
ctx, cancel := context.WithCancel(context.Background())
go func() {
defer cancel() // ⚠️ 若此处未执行,则 ctx.Done() 永不关闭
select {
case <-time.After(10 * time.Millisecond):
return // 提前退出,defer 被跳过
}
}()
// 主协程立即返回,无等待 → cancel 未被调用
}
逻辑分析:cancel() 仅在 goroutine 正常抵达 defer 时触发;若 goroutine 因 return、panic 或被抢占而未执行到 defer 行,ctx 将持续存活,造成资源泄漏与下游阻塞。
典型竞态时序(mermaid)
graph TD
A[main: 创建 ctx/cancel] --> B[goroutine 启动]
B --> C{goroutine 执行 select}
C -->|超时返回| D[return → defer 跳过]
C -->|未达 defer| E[cancelFunc 永不调用]
安全实践要点
- 始终确保
cancel()在 goroutine 生命周期内确定性调用(如封装为结构体方法) - 避免依赖
defer作为唯一清理入口,尤其在存在多出口路径时
2.4 基于go tool trace与pprof mutex profile定位取消链断裂点
Go 中的 context.Context 取消链一旦断裂,goroutine 将无法及时响应取消信号,导致资源泄漏或超时失效。精准定位断裂点需协同分析调度行为与锁竞争。
数据同步机制
当 context.WithCancel(parent) 的子 context 被遗忘传入关键 goroutine(如未注入 ctx 到 http.NewRequestWithContext),取消传播即中断。
工具协同诊断流程
# 启用完整追踪并采集 mutex profile
GODEBUG=schedtrace=1000 go run -gcflags="all=-l" main.go &
go tool trace -http=:8080 trace.out
go tool pprof -mutexprofile mutex.prof http://localhost:6060/debug/pprof/mutex
schedtrace=1000:每秒输出 Goroutine 调度快照,暴露长期阻塞的 goroutine;-mutexprofile:捕获持有锁时间 > 1ms 的调用栈,间接反映取消等待(如sync.RWMutex.Lock阻塞在context.Done()channel 上)。
典型断裂模式识别
| 现象 | 对应 trace 标记 | pprof mutex hotspot |
|---|---|---|
| goroutine 持续运行 | Goroutine blocked on chan receive |
runtime.gopark → context.(*cancelCtx).Done |
| 锁等待超时 | Proc status: idle |
(*Mutex).Lock → select { case <-ctx.Done(): } |
func serve(ctx context.Context) {
select {
case <-time.After(5 * time.Second):
return
case <-ctx.Done(): // 若 ctx 未正确传递,此分支永不触发
log.Println("canceled")
}
}
该代码中若 ctx 来自 context.Background()(非 cancelable),则 <-ctx.Done() 永不就绪,serve 成为“僵尸 goroutine”。go tool trace 在 User Events 区域可标记该 goroutine 的生命周期,而 pprof mutex profile 会显示其因等待不可达 channel 导致的隐式锁竞争(通过 runtime.selectgo 内部实现模拟)。
2.5 在HTTP中间件中注入cancel观察器验证上下文泄漏的实证实验
实验目标
构建可复现的上下文泄漏观测链:在 HTTP 中间件中嵌入 context.WithCancel 观察器,捕获 Goroutine 生命周期异常延长。
注入 cancel 观察器的中间件实现
func CancelObserver(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
done := ctx.Done()
go func() {
select {
case <-done:
log.Printf("✅ context cancelled at %v", time.Now().UnixMilli())
case <-time.After(5 * time.Second):
log.Printf("⚠️ context NOT cancelled — potential leak at %v", time.Now().UnixMilli())
}
}()
next.ServeHTTP(w, r)
})
}
逻辑分析:ctx.Done() 返回只读 channel;协程阻塞等待取消信号。若 5 秒未触发 <-done,判定为泄漏候选。time.After 是超时兜底机制,非替代 ctx.Done()。
关键观测维度对比
| 指标 | 正常场景 | 泄漏场景 |
|---|---|---|
ctx.Done() 触发时机 |
请求结束前即时触发 | 延迟 ≥3s 或永不触发 |
| Goroutine 存活时间 | >5s(持续占用堆栈) |
泄漏传播路径(mermaid)
graph TD
A[HTTP Request] --> B[Middleware: CancelObserver]
B --> C[Handler: long-running DB query]
C --> D{Context cancelled?}
D -- Yes --> E[goroutine exits cleanly]
D -- No --> F[goroutine hangs → leak]
第三章:runtime.g0的隐式绑定与goroutine本地存储(GLS)陷阱
3.1 g0栈在goroutine创建/切换中的角色与g0.m.curg的非对称性
g0 是每个 M(OS线程)专属的系统栈,不参与调度器的 goroutine 队列管理,专用于运行运行时关键路径(如栈扩容、GC扫描、系统调用返回等)。
g0 的双重生命周期
- 创建于
mstart(),与 M 绑定,永不销毁 - 不受
gopark/goready影响,无g.status状态机 - 其
g.sched保存的是 M 切换回用户 goroutine 时的恢复上下文
g0.m.curg 的非对称性
| 字段 | g0.m.curg 值 | 普通 goroutine.m.curg |
|---|---|---|
| 指向目标 | 当前运行的用户 goroutine | 总是自身(自指) |
| 更新时机 | 仅在 schedule() 尾部赋值 |
每次 gogo 切入即更新 |
| 调度语义 | 表示“M 正在服务谁” | 无调度意义,冗余字段 |
// runtime/proc.go 中 schedule() 片段
if next == nil {
next = findrunnable() // 寻找可运行 goroutine
}
...
gogo(&next.sched) // 切入 next
_g_ = getg() // 此时 _g_ == g0
_g_.m.curg = next // 关键:g0.m.curg ← 用户 goroutine
逻辑分析:
g0.m.curg是运行时调度桥接的关键指针。它单向记录 M 当前托管的用户 goroutine,但该 goroutine 的g.m.curg并不指向自身——这种不对称设计避免了循环引用,同时支撑mcall/gogocall的栈切换协议。参数next是从全局队列或 P 本地队列获取的可运行 goroutine,其sched已由gopark或newproc1初始化完毕。
graph TD
A[g0 开始执行 schedule] --> B[findrunnable 获取 next]
B --> C[gogo next.sched]
C --> D[g0.m.curg = next]
D --> E[CPU 切换至 next 栈]
3.2 _g_指针如何被误用于模拟“goroutine局部变量”导致context逃逸
Go 运行时中,_g_ 指针指向当前 goroutine 的 g 结构体,部分开发者误将其作为“goroutine 局部存储”挂载 context 实例,引发隐式堆逃逸。
逃逸路径分析
func badWithContext() {
ctx := context.WithValue(context.Background(), key, "val")
// ❌ 错误:通过 _g_.m.ptrace 或自定义字段强转写入
*(*uintptr)(unsafe.Pointer(uintptr(unsafe.Pointer(_g_)) + 0x1a8)) = uintptr(unsafe.Pointer(&ctx))
}
该操作绕过编译器逃逸分析,强制将栈上 ctx 地址写入 g 结构体(偏移 0x1a8 常见于 debug 构建),使 ctx 被视为需长期存活,触发堆分配。
关键事实对比
| 方式 | 是否经逃逸分析 | 是否安全 | 是否可移植 |
|---|---|---|---|
context.WithValue() 链式调用 |
✅ 是 | ✅ 是 | ✅ 是 |
直接写 _g_ 字段 |
❌ 否 | ❌ 否 | ❌ 否(版本敏感) |
正确替代方案
- 使用
runtime.SetFinalizer管理生命周期 - 依赖
context.Context本身传递语义 - 必要时封装为
*sync.Pool归属的 goroutine-local 对象
3.3 使用unsafe.Pointer劫持g0.m.ptrace字段触发取消链静默失效的POC
原理简述
g0 是 Go 运行时的系统协程,其 m.ptrace 字段(int32)本用于调试跟踪标识,但被意外复用为取消链状态同步的隐式信号位。当该字段被非法覆写为非零值,runtime.cancelWork 会跳过 goparkunlock 的链式唤醒逻辑。
关键代码片段
// 将 g0.m.ptrace 强制设为 1,绕过 cancelChain 检查
g0 := (*g)(unsafe.Pointer(uintptr(atomic.Loaduintptr(&getg().m.g0))))
mptr := (*int32)(unsafe.Pointer(uintptr(unsafe.Pointer(&g0.m)) + unsafe.Offsetof(m{}.ptrace)))
*mptr = 1 // 触发静默失效
逻辑分析:
g0.m地址通过unsafe.Offsetof定位ptrace偏移(Go 1.21 中为0x48),写入1后,checkCanPreempt()判定为“被 ptrace 附加”,从而跳过cancelWork的wakep()调用,导致子 goroutine 的select{case <-ctx.Done()}永不返回。
失效路径对比
| 场景 | ptrace 值 | cancelChain 是否执行 | ctx.Done() 是否关闭 |
|---|---|---|---|
| 正常 | 0 | ✅ | ✅ |
| 劫持 | 1 | ❌(early return) | ❌(静默挂起) |
graph TD
A[goroutine 执行 cancel] --> B{m.ptrace == 0?}
B -->|Yes| C[遍历 cancelChain 唤醒]
B -->|No| D[直接返回,无唤醒]
D --> E[ctx.Done() channel 永不 close]
第四章:底层泄漏根源的交叉验证与工程防御体系
4.1 通过go:linkname直接读取runtime.g.m.curg验证context归属错位
Go 运行时中,每个 goroutine 拥有唯一 g 结构体,其字段 m.curg 指向当前正在执行的 goroutine。当 context 在跨 goroutine 传递时若未正确绑定(如误用 context.WithValue 后在新 goroutine 中读取),可能引发归属错位。
数据同步机制
runtime._g_ 是编译器注入的全局符号,可通过 //go:linkname 强制链接:
//go:linkname g_struct runtime.g_struct
var g_struct *struct {
m struct {
curg *struct{ m, g uintptr }
}
}
// 注意:仅限调试/诊断,禁止生产使用
func currentG() *struct{ m, g uintptr } {
return g_struct.m.curg
}
该代码绕过安全抽象,直接访问运行时私有字段;curg 地址即当前 goroutine 的 g 实例指针,可用于比对 context 创建与实际执行 goroutine 是否一致。
验证流程
- 获取 context 创建时的
g(通过debug.ReadBuildInfo或 trace 注入) - 调用
currentG()获取执行时g - 比对二者地址是否相同
| 场景 | curg == 创建g | 风险等级 |
|---|---|---|
| 同 goroutine 执行 | ✅ | 低 |
| goroutine 池复用后执行 | ❌ | 高 |
graph TD
A[Context 创建] -->|记录创建时 curg| B[goroutine A]
C[异步执行] -->|实际运行于| D[goroutine B]
B -->|地址不等| E[归属错位]
D --> E
4.2 构建goroutine-aware context.WithCancelWrapper拦截所有cancel调用链
传统 context.WithCancel 在多 goroutine 并发取消时存在竞态:父 context 取消后,子 goroutine 可能仍持有已失效的 Done() channel,导致资源泄漏或重复 cancel。
核心设计原则
- 所有 cancel 调用必须经由统一 wrapper 拦截
- 每个 goroutine 关联唯一 cancel token,支持细粒度追踪
拦截器实现
func WithCancelWrapper(parent context.Context) (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(parent)
// 包装 cancel 函数,注入 goroutine ID 和调用栈快照
wrappedCancel := func() {
trace := debug.Stack()
log.Printf("goroutine %d canceled: %s", goroutineID(), string(trace[:min(len(trace), 512)]))
cancel()
}
return ctx, wrappedCancel
}
逻辑分析:
goroutineID()通过runtime.Stack提取当前 goroutine 标识;debug.Stack()捕获调用链用于审计;min()防止日志爆炸。该 wrapper 不改变 context 行为语义,仅增强可观测性。
拦截效果对比
| 场景 | 原生 WithCancel | WithCancelWrapper |
|---|---|---|
| 并发 cancel 冲突 | 无感知 | 日志标记 + goroutine ID |
| 取消来源定位 | 困难 | 自动附带调用栈片段 |
graph TD
A[goroutine A] -->|调用 cancel| B(WithCancelWrapper)
C[goroutine B] -->|调用 cancel| B
B --> D[记录 goroutine ID + stack]
B --> E[触发原生 cancel]
E --> F[关闭 Done channel]
4.3 利用GODEBUG=gctrace=1 + runtime.ReadMemStats观测goroutine泄漏关联内存增长
当怀疑存在 goroutine 泄漏时,需同步验证其对堆内存的持续影响。
启用 GC 追踪与内存采样
GODEBUG=gctrace=1 ./your-program
该环境变量每完成一次 GC 就打印一行统计(如 gc 3 @0.234s 0%: 0.012+0.15+0.021 ms clock, 0.048+0.15/0.072/0.021+0.084 ms cpu),其中第三段 0.15 表示标记耗时,持续增长常暗示对象图膨胀。
实时内存快照对比
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("HeapAlloc: %v KB, NumGoroutine: %v\n", m.HeapAlloc/1024, runtime.NumGoroutine())
定期采集可建立 NumGoroutine ↔ HeapAlloc 关联趋势。
| 时刻 | Goroutines | HeapAlloc (KB) | 增长率 |
|---|---|---|---|
| t₀ | 12 | 4,210 | — |
| t₃₀s | 89 | 28,760 | +583% |
内存-协程耦合分析逻辑
graph TD
A[goroutine 持有闭包/通道/定时器] --> B[引用未释放对象]
B --> C[GC 无法回收堆内存]
C --> D[HeapAlloc 持续上升]
D --> E[runtime.NumGoroutine 同步增加]
4.4 在net/http.Server.Serve中注入goroutine ID快照与context.CancelFunc绑定审计
HTTP 服务启动时,http.Server.Serve 启动监听循环,每个连接由独立 goroutine 处理。为实现精细化追踪与生命周期管控,需在连接建立瞬间捕获 goroutine ID 并绑定 context.CancelFunc。
goroutine ID 快照注入点
// 在 conn.serve() 初始化阶段插入(伪代码)
func (c *conn) serve() {
// 获取当前 goroutine ID(需借助 runtime 包或 unsafe)
gid := getGoroutineID()
ctx, cancel := context.WithCancel(c.server.baseContext)
// 绑定快照:gid → cancel 映射用于审计
auditMap.Store(gid, &auditEntry{
Start: time.Now(),
Cancel: cancel,
RemoteAddr: c.rwc.RemoteAddr().String(),
})
defer func() { auditMap.Delete(gid) }()
// ...
}
该逻辑确保每个请求 goroutine 的生命周期与 CancelFunc 强关联,便于后续审计泄漏或超时未取消行为。
审计映射结构对比
| 字段 | 类型 | 说明 |
|---|---|---|
Start |
time.Time |
goroutine 启动时间戳 |
Cancel |
context.CancelFunc |
可触发的取消函数 |
RemoteAddr |
string |
客户端地址,辅助定位 |
生命周期审计流程
graph TD
A[新连接接入] --> B[分配goroutine]
B --> C[快照goroutine ID + CancelFunc]
C --> D[存入auditMap]
D --> E[请求结束/panic/超时]
E --> F[auditMap.Delete]
第五章:从取消链失效到云原生可观测性的范式迁移
在某大型电商中台的订单履约服务重构过程中,团队曾遭遇典型的“取消链静默断裂”问题:当用户取消订单后,下游库存回滚、物流撤单、积分返还等协作者未收到统一取消信号,导致出现超卖、物流单滞留和积分重复发放。根本原因在于基于 context.WithCancel 的手动传播机制在微服务跨语言调用(Go 服务调用 Python 计费服务,再调用 Java 风控服务)中完全失效——Python 和 Java 客户端未实现 context 取消透传,且无统一 trace propagation 标准。
取消语义的崩溃现场还原
通过 Jaeger 追踪发现,一个 order_cancel 请求在 Go 边界生成 trace_id=abc123 后,进入 Python 服务时 span_id 断裂,cancel_requested=true 的业务标记未被序列化进 HTTP header;Java 服务接收到的请求头中仅含基础 X-Request-ID,X-B3-Flags: 1(采样标志)存在,但取消状态丢失。日志中出现大量 inventory_release_skipped_due_to_missing_cancellation_signal 错误码,错误率峰值达 17.3%。
OpenTelemetry 统一信号注入实践
团队将取消动作升级为可观测原语:在网关层拦截 DELETE /orders/{id} 请求,自动注入 otel.status_code=OK、otel.event=cancel_initiated、otel.cancel.reason=user_request 等语义化属性,并通过 otel.propagators 注册 W3C TraceContext + Baggage 组合传播器。关键代码如下:
// 网关中间件注入取消上下文
func CancelPropagationMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if r.Method == "DELETE" && strings.HasPrefix(r.URL.Path, "/orders/") {
ctx = baggage.ContextWithBaggage(ctx,
baggage.Item("cancel.initiated", "true"),
baggage.Item("cancel.source", "user_portal"),
baggage.Item("cancel.timestamp", time.Now().UTC().Format(time.RFC3339)),
)
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}
基于指标驱动的取消链健康看板
构建 Prometheus 自定义指标体系,暴露三类核心指标:
| 指标名称 | 类型 | 说明 | 示例标签 |
|---|---|---|---|
cancel_propagation_success_rate |
Gauge | 取消信号跨服务传递成功率 | source="gateway",target="inventory" |
cancel_event_latency_seconds |
Histogram | 取消事件端到端延迟分布 | service="payment",quantile="0.95" |
cancel_rollback_failure_total |
Counter | 回滚失败次数(按失败原因细分) | reason="timeout",service="logistics" |
告警规则配置为:当 rate(cancel_propagation_success_rate{source="gateway"}[5m]) < 0.98 连续触发 3 次,即触发 PagerDuty 工单,并自动关联最近 10 分钟内所有 cancel.* 日志流与 trace 样本。
跨语言 SDK 对齐验证
强制要求所有服务接入 OpenTelemetry SDK v1.22+,并通过 CI 流水线执行契约测试:使用 OpenTelemetry Collector 的 otlp 接收器捕获各语言服务上报的 span,验证 cancel.initiated baggage 是否完整出现在所有下游 span 的 attributes 中。Python 服务经修改 requests.Session 拦截器,Java 服务升级至 opentelemetry-instrumentation-api 1.31.0,最终达成 100% 取消信号透传覆盖率。
动态熔断策略与可观测闭环
基于 cancel_rollback_failure_total 指标,Prometheus Alertmanager 触发后,自动调用 Istio EnvoyFilter API,在失败服务出口方向插入动态 header X-Cancel-Safety-Level: degraded,下游服务据此降级执行异步补偿任务而非强一致回滚。整个闭环过程在 Grafana 看板中以 Mermaid 序列图实时渲染:
sequenceDiagram
participant G as Gateway
participant I as Inventory
participant L as Logistics
participant P as Payment
G->>I: POST /inventory/release (baggage: cancel.initiated=true)
I->>L: POST /logistics/cancel (propagated baggage)
L->>P: POST /payment/refund (with baggage & traceparent)
alt P refund fails
P->>G: 500 + baggage: cancel.rollback.failed=true
G->>Alertmanager: fire alert
Alertmanager->>Istio: patch EnvoyFilter
Istio->>L: inject X-Cancel-Safety-Level: degraded
end 