标签: SRE

  • 深入 Jenkins 陷阱排查:共享库全局变量滥用引发的并行构建串量与生产发布雪崩实战

    近期处理了一起极其荒谬且破坏力极强的 P0 级生产事故。核心业务团队在做 Monorepo(单体代码仓库)微服务大版本发布时,15 个微服务通过 Jenkins Pipeline parallel 并发构建部署,结果上线后核心链路全面崩溃,Nginx Ingress 瞬间爆出海量 502。

    排查发现,根本不是 K8s 调度问题或 Harbor 镜像源拉取故障,而是 订单服务(Order)的 Pod 里,居然跑着用户服务(User)的镜像。最终定位的罪魁祸首:Jenkins Shared Library(共享库)中滥用了脚本级全局变量,在 parallel 并发执行下触发了典型的线程安全问题,导致变量相互覆盖(串量)。

    一句话结论:在 Jenkins vars/ 共享库脚本中定义方法外部的全局变量,其实例在同一个 Pipeline 运行期的 parallel 阶段是完全共享的。并发读写这些变量必然导致数据污染。修复方案:必须将所有状态变量严格限定在 call() 方法内部,强制实现共享库的无状态化。

    事故现场与离谱的报错

    排查过程中,我们查看了 K8s 集群的实时状况。订单服务(Order)的 Pod 陷入了 CrashLoopBackOff:

    $ kubectl logs order-deployment-5f6b9c9d-8x2a -n prod-ns
    Error: Invalid or missing Spring Boot configuration. 
    Application 'user-service' failed to start.
    

    日志显示这个 Pod 内部启动的居然是 user-service。去检视 Jenkins 构建日志,发现了令人窒息的一幕:

    [Pipeline] // 并行分支: build-order-service
    [Pipeline] sh (docker build)
    + docker build -t harbor.corp.com/prod/order-service:v1.2.3-45 .
    ...
    [Pipeline] // 并行分支: build-user-service
    [Pipeline] sh (docker build)
    + docker build -t harbor.corp.com/prod/user-service:v1.2.3-45 .
    ...
    [Pipeline] sh (helm upgrade) # 此时 order 分支的 helm 部署命令
    + helm upgrade order-service ./chart --set image=harbor.corp.com/prod/user-service:v1.2.3-45
    

    注意看最后一行,order-service 的分支在执行 Helm 部署时,注入的 Image 参数竟然变成了 user-service 的镜像!

    原理剖析:全局变量是万恶之源

    去翻看负责 CI/CD 脚本的开发人员写的 Jenkins Shared Library 代码,简直触目惊心:

    // 错误代码示例:vars/standardBuilder.groovy
    def currentImage = ""       // 【致命错误】脚本级全局变量
    def deployYaml = ""         // 【致命错误】脚本级全局变量
    
    def call(Map config) {
        currentImage = "${config.imageName}:${config.tag}"
        deployYaml = "${config.appName}-deployment.yaml"
    
        // 模拟耗时构建操作
        sh "docker build -t ${currentImage} ."
        sh "docker push ${currentImage}"
    
        // 部署阶段
        sh "helm upgrade ${config.appName} ./chart --set image=${currentImage}"
    }
    

    而在他们的 Jenkinsfile 中,调用方式是这样的:

    parallel(
        'order': { standardBuilder(appName: 'order', imageName: 'order-service', tag: 'v1.2') },
        'user':  { standardBuilder(appName: 'user',  imageName: 'user-service',  tag: 'v1.2') }
    )
    

    为什么这绝对不可原谅?

    很多刚接触 Jenkins Pipeline 的人把它当成简单的 Bash 脚本写,缺乏基本的“防御性编程”思维。在 Jenkins CPS(Continuation Passing Style)引擎的底层实现中,vars/standardBuilder.groovy 并不是每次调用都重新实例化的纯函数。 整个文件在编译后会成为继承自 CpsScript 的一个类。在同一个 Pipeline Run 中,vars/ 下的单例对象是被缓存的,文件顶层的 def currentImage 变成了这个单例对象的一个实例属性(Field)。

    当 parallel 启动多个分支(在 Jenkins 中是底层的绿色线程 Green Threads)并发调用 standardBuilder() 时,执行时序就变成了:

    1. 分支 A (Order):写入 currentImage = "order-service:v1.2"

    2. 分支 B (User):写入 currentImage = "user-service:v1.2" (此时实例属性被覆盖)

    3. 分支 A (Order):执行完 docker push,准备执行 helm upgrade,读取 currentImage,读到了 "user-service:v1.2"。

    这不仅导致了严重的生产环境发版错乱,如果是带有危险操作(如 DB 变更脚本)的流水线,这种串量能直接把数据库干废。

    进阶踩坑:CPS 序列化风暴与 OOM

    除了上述的并发污染问题,在脚本级定义变量还会引发另一个经典灾难:NotSerializableException 报错与 Jenkins Master 内存泄漏。

    如果有人在这个全局作用域放了一些非序列化对象(比如 JSON 解析结果、HTTP Client 实例):

    import groovy.json.JsonSlurper
    
    def parsedConfig = null // 脚本级变量
    
    def call() {
        def slurper = new JsonSlurper() // JsonSlurper 返回的是非序列化的 LazyMap
        parsedConfig = slurper.parseText(readFile('config.json'))
        sh "echo 'Building...'"
    }
    

    Jenkins 的 CPS 引擎为了保证在 Master 节点重启后能够恢复 Pipeline 的执行状态,会在遇到每一个 Pipeline Steps(如 sh, echo, sleep)时,暂停当前线程,并将整个 CpsScript 对象及其绑定的变量树完整序列化并落盘(存储到 build.xml 中)。 如果 parsedConfig 是一个不可序列化的对象,流水线会当场 Crash。如果是一个体积庞大的 Map,并发数一高,每次执行 sh 都要把几 MB 的数据做深拷贝和 XML 序列化,会导致 Jenkins Master 的 CPU 瞬间打满,触发频繁的 GC 停顿甚至 OOM(OutOfMemoryError)。

    最佳实践与防御性加固

    对于重度依赖 Jenkins 驱动核心 CI/CD 的团队,必须在架构层面掐断这种低级失误。

    1. 强制无状态化共享库(Pure Functions)

    所有共享库 vars/ 下的方法,必须是纯函数,严禁在 call() 之外定义任何非 final 变量。 修正后的代码:

    // 正确代码:vars/standardBuilder.groovy
    def call(Map config) {
        // 变量作用域严格限制在方法内部(闭包内本地变量),每次调用独立分配栈空间
        def currentImage = "${config.imageName}:${config.tag}"
        def deployYaml = "${config.appName}-deployment.yaml"
    
        sh "docker build -t ${currentImage} ."
        sh "docker push ${currentImage}"
        sh "helm upgrade ${config.appName} ./chart --set image=${currentImage}"
    }
    

    2. IO 操作与数据解析严格剥离(@NonCPS)

    如果必须进行复杂的 JSON 解析或遍历操作,必须封装为独立的函数并打上 @NonCPS 注解,确保这部分逻辑由 JVM 原生线程直接执行,不参与 CPS 的序列化。 注意: @NonCPS 方法内部绝对不能调用任何 Jenkins Pipeline 原生步骤(如 sh, readFile)。

    @NonCPS
    def parseMyConfig(String jsonStr) {
        // 纯 Groovy/Java 原生执行,无需考虑序列化问题
        return new groovy.json.JsonSlurperClassic().parseText(jsonStr) 
    }
    
    def call() {
        def jsonContent = readFile('config.json')
        def configMap = parseMyConfig(jsonContent)
        // 继续后续流程...
    }
    

    3. K8s Dynamic Agent 的工作空间硬隔离

    除了内存里的变量,物理层面的 Workspace 同样容易发生并发冲突。在 parallel 执行阶段,尽量确保每个并行任务申请独立的 K8s Pod (Agent),或者显式使用 dir 指令分配子目录,避免同一节点上的 I/O 踩踏:

    parallel(
        'order': { 
            node('k8s-agent') { // 分配独立的 Pod
                checkout scm
                standardBuilder(...)
            }
        }
    )
    

    同类问题速查(排查清单)

    1. Pipeline 并行分支行为异常/串数据:立刻检查 vars/ 目录下的共享库文件,全局搜索是否存在未被包含在方法内的 def、String、Map、List 定义,或误用了 @Field。

    2. 构建日志报 java.io.NotSerializableException:检查脚本中是否将 Matcher(正则匹配对象)、JsonSlurper 返回结果、Connection 等非序列化对象保留在了方法外层,或跨越了 sh/echo 等 Pipeline Step 的边界。

    3. Jenkins Master CPU 极高或 Full GC 频发:检查 Jenkins 系统管理中的线程转储(Thread Dump),搜索 CpsThread 和 xstream 相关的堆栈,大概率是 Pipeline 定义了超大数组/Map并在执行 Steps 时引发了海量序列化开销。

    4. 并发构建文件损坏/拉错代码:检查 parallel 代码块,是否多个并发逻辑复用了同一个 node 并且没有使用 dir('sub-dir') 隔离 Workspace。

  • 深入 OpenTelemetry 陷阱排查:100% 采样引发的 Collector OOM 与微服务 CPU 耗尽雪崩实战

    某次排查过程中,核心交易网关及下游订单服务突然出现大面积请求超时。监控大盘显示,网关集群 P99 延迟从日常的 30ms 飙升至 800ms 以上,Load Average 直接冲破 40,并伴随偶发的 502 错误。

    排查的最终结论让人极度无语:业务逻辑没有 Bug,数据库也没有慢查,纯粹是因为开发团队在上线 OpenTelemetry(以下简称 OTel)Java Agent 时,在 15,000 QPS 的核心链路上开启了 100% 全量采样(always_on),甚至通过自定义拦截器把动辄上百 KB 的 HTTP 请求/响应 JSON Body 全部塞进了 Span Attribute 里。

    结果就是,海量的 Span 数据序列化直接榨干了应用进程的 CPU,频繁触发 JVM Young GC;同时,突发的网络 I/O 洪峰瞬间打挂了后端的 OTel Collector 集群,导致监控流水线全线崩溃。监控系统把被监控业务搞瘫痪,这是架构设计上的奇耻大辱。

    案发现场与硬核排查

    接到告警后,第一时间登机查看宿主机指标。top 显示 Java 进程 CPU 使用率超 900%(宿主机 16C)。 习惯性用 perf top -p 和 jstack 抓取现场,发现大量业务线程并没有在等待 DB 或外部 RPC,而是阻塞在 OTel 的底层方法上:

    # jstack 局部抓取
    "http-nio-8080-exec-15" #45 daemon prio=5 os_prio=0 tid=0x00007f... runnable [0x00007f...]
       java.lang.Thread.State: RUNNABLE
            at java.lang.String.toCharArray(String.java:3000)
            at io.opentelemetry.api.common.ArrayBackedAttributesBuilder.put(ArrayBackedAttributesBuilder.java:55)
            at io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor.onStart(...)
            ...
            at io.grpc.netty.shaded.io.netty.handler.codec.http2.Http2ConnectionHandler.write(...)
    

    很明显,大量 CPU 时钟周期被消耗在了 OTel 提取 Attribute 属性和 gRPC 序列化上。 再去 K8s 集群里看一眼 OTel Collector 的状态:

    kubectl get pods -n observability | grep otel-collector
    otel-collector-7f8b9...   0/1     OOMKilled   14         2d
    

    Collector 疯狂重启,dmesg -T | grep -i oom 显示 Collector 容器因为突破了内存 Limit 被系统无情抹杀。

    翻看业务服务上线时注入的环境变量,果不其然,写着极其草率的配置:

    # 致命配置 1:无脑全量采样
    OTEL_TRACES_SAMPLER=always_on
    # 致命配置 2:拦截并记录所有 Header
    OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST=.*
    # 致命配置 3:业务代码中自定义的 Span Processor,把 Body 全 dump 进去了
    

    为什么说这是愚蠢的设计?

    1. 违背了可观测性的“无损”底线 链路追踪(Tracing)的核心原则是 Out-Of-Band(带外)和低消耗。在 15k QPS 的网关上搞 100% 采样,算一笔简单的数学题: 假设每个请求产生 10 个 Span,每个 Span 携带 5KB 的 Body/Header 垃圾数据。 15,000 * 10 * 5KB = 750 MB/s 的吞吐量。 这不仅意味着应用节点每秒要分配并回收将近 1GB 的字符串对象(疯狂推高 CPU 和 GC 耗时),还意味着集群内部产生了一个高达数十 Gbps 的监控流量洪峰。Collector 不 OOM 才有鬼。

    2. 错把 Trace 当 Log 用 这是一个极度常见且低级的架构认知误区:企图在分布式追踪的 Span 里还原所有的业务细节。 Span 的作用是刻画请求的拓扑结构、耗时分布和关键状态转换(Status Code),它应该尽可能轻量。真正的业务载荷(Request Payload、Response Body、详细报错堆栈)应该落在结构化日志(Log)里,然后通过统一的 trace_id 进行关联。

    正确的工程化落地姿势

    想要既兼顾性能,又不漏掉关键错误的链路,必须从“采样策略”和“Trace-Log 关联”两方面下手整改。

    1. 废弃 Head-Based 全量采样,启用 Tail-Based 尾部采样

    在微服务端(OTel Agent),采用 parentbased_traceidratio 策略,比如只做 5% 的头部采样。 但在 OTel Collector 侧,配置 Tail Sampling Processor。把全量 Span 先缓冲在 Collector 的内存中,当且仅当该链路符合特定条件(如:包含 ERROR 状态、耗时超过 1000ms)时,才强制保留整条链路并上报;否则按比例丢弃。

    微服务侧启动参数修正:

    OTEL_TRACES_SAMPLER=parentbased_traceidratio
    OTEL_TRACES_SAMPLER_ARG=0.05
    

    OTel Collector 核心配置片段(otel-collector-config.yaml):

    processors:
      # 必须配置内存限制防 OOM
      memory_limiter:
        check_interval: 1s
        limit_mib: 4000
        spike_limit_mib: 800
    
      tail_sampling:
        decision_wait: 10s # 等待链路收集的窗口期
        num_traces: 50000
        expected_new_traces_per_sec: 10000
        policies:
          # 策略1:保留所有包含错误的链路
          [
            {
              name: errors-policy,
              type: status_code,
              status_code: {status_codes: [ERROR]}
            },
          # 策略2:保留所有 P99 耗时异常的链路 (例如 > 1000ms)
            {
              name: latency-policy,
              type: latency,
              latency: {threshold_ms: 1000}
            },
          # 策略3:其余正常链路,只做 5% 的兜底采样
            {
              name: probabilistic-policy,
              type: probabilistic,
              probabilistic: {sampling_percentage: 5}
            }
          ]
    

    2. Trace 与 Log 严格关联,卸载 Span 负担

    严禁在 Span Attribute 中记录大型字符串。将业务细节全部转入日志输出,利用 SLF4J/Logback 的 MDC(Mapped Diagnostic Context)机制,自动将当前上下文的 trace_id 和 span_id 注入到日志记录中。

    在 Logback 的 layout 配置中输出关联 ID:

    <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [trace_id=%X{trace_id:-} span_id=%X{span_id:-}] - %msg%n</pattern>
    

    后续在 Kibana 或 Grafana Loki 中排查故障时,先通过 Trace 视图找到慢请求的 trace_id,再带着 trace_id 去日志中心一键捞出所有微服务的业务级打印(包括完整的 Request JSON),这才是现代可观测性架构的标配玩法。

    排查清单

    1. Agent 采样率检查:检查所有接入端环境变量 OTEL_TRACES_SAMPLER,严禁在生产高并发链路使用 always_on。

    2. Span 载荷审计:抓取 OTel 导出的 Span 数据,审查 Attribute 大小。清理未脱敏的、体积过大的 HTTP Body / SQL 绑定参数拦截记录。

    3. Collector 内存兜底:检查 OTel Collector 配置,必须强行启用 memory_limiter Processor,防止流量突刺引发组件 OOM 进而波及整个 K8s Node。

    4. MDC 上下文验证:随机抽查业务日志(ES/Loki),验证是否 100% 覆盖了 trace_id,且在跨线程池异步处理时 MDC 传递没有丢失(如有丢失,需补充线程池包装类)。

  • 深入 Prometheus 告警陷阱排查:静态阈值引发的 On-Call 疲劳雪崩与 Multi-Window 燃烧率 SLO 降噪实战

    抛弃所有基于 CPU、内存利用率的静态阈值告警。基础设施指标飙升不代表业务受损,海量无意义的 Warning 只会带来 On-Call 疲劳,最终导致真实的 P0 故障被淹没。本文直接给出基于 Prometheus (v2.45.0) 的多时间窗口(Multi-Window)Error Budget 燃烧率告警落地代码,将告警噪音降低 80% 以上,实现仅在用户体验真实受损时才触发 Page。

    为什么静态阈值与基础设施告警一定会摧毁 On-Call 体系?

    在近期的某次系统重构排查中,我看到业务线的 Prometheus 里堆满了这类告警规则:

    - alert: NodeCPUUsageHigh
      expr: 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 85
      for: 3m
    

    这种告警逻辑在生产环境简直是一场灾难。Go Runtime GC、大促期间的瞬时突发流量、甚至是某个后台定时批处理任务,都会导致 CPU 短暂飙升。结果就是 On-Call 工程师在深夜被频繁唤醒,看一眼面板发现“已经恢复了”,默默点个 Ack 接着睡。 狼来了的故事上演三次后,墨菲定律就会生效: 当 CPU 飙升是因为死锁或真实的流量雪崩引起时,疲惫的工程师习惯性地认为“等 5 分钟自己就降下去了”,从而错失最佳止血时机。

    监控的本质是度量系统提供服务的质量,而不是度量系统的健康状态。用户根本不在乎你的 CPU 是 90% 还是 10%,他们在乎的是:请求有没有报错(Error Rate)?响应够不够快(Latency)?

    告别直觉:SLI 建模与 Error Budget 燃烧率(Burn Rate)

    真正的防御性告警设计,必须围绕 SLO(服务等级目标)展开。假设我们定了一个核心 API 的 SLO 为 99.9%(30 天为一个周期),这意味着我们在 30 天内允许的错误预算(Error Budget)为 0.1%。

    如果错误率恰好保持在 0.1%,那么预算将在 30 天后耗尽,此时的燃烧率(Burn Rate)就是 1。 如果错误率飙升到 1%,是允许值的 10 倍,燃烧率就是 10。预算将在 30天 / 10 = 3天 内耗尽。

    为了兼顾告警的灵敏度(快速发现大范围故障)与精确度(不被瞬时抖动误导),业界标准做法是引入 Multi-Window(多时间窗口)判断逻辑: 必须同时满足长窗口(评估故障规模)和短窗口(确认故障正在持续)的燃烧率阈值,才触发告警。

    Multi-Window 燃烧率告警在 Prometheus 的落地实战

    不要在告警规则里写几百个字符的复杂 PromQL,这会导致 Prometheus 每次评估告警时产生严重的 CPU 开销和查询超时。必须通过 Recording Rules 提前聚合 SLI。

    1. 定义 SLI 聚合规则 (Recording Rules)

    基于 Prometheus v2.45.0,我们为核心服务配置以下 Recording Rules。这里以 5m, 30m, 1h, 2h, 6h 五个窗口的错误率为例:

    groups:
    - name: sli_http_error_rate
      interval: 1m
      rules:
      # 预聚合 5 分钟窗口错误率
      - record: job:http_requests:error_rate5m
        expr: >
          sum by(job) (rate(http_requests_total{status=~"5.."}[5m]))
          /
          sum by(job) (rate(http_requests_total[5m]))
    
      # 预聚合 1 小时窗口错误率
      - record: job:http_requests:error_rate1h
        expr: >
          sum by(job) (rate(http_requests_total{status=~"5.."}[1h]))
          /
          sum by(job) (rate(http_requests_total[1h]))
    
      # (30m, 2h, 6h 的规则同理,此处省略以保持排版整洁)
    

    2. 构建多窗口燃烧率告警 (Alerting Rules)

    目标:SLO = 99.9%(允许错误率 0.001)。 级别分类:

    • P0 (Page, 电话呼叫): 14.4x 燃烧率(预算将在 2 天内耗尽),长窗口 1h,短窗口 5m。

    • P1 (Ticket, 企微/钉钉/Jira): 6x 燃烧率(预算将在 5 天内耗尽),长窗口 6h,短窗口 30m。

    groups:
    - name: slo_burn_rate_alerts
      rules:
      # P0 级别告警:短时间大量燃烧,必须立即人工介入 (14.4x)
      # 条件:(1h错误率 > 0.001 * 14.4) AND (5m错误率 > 0.001 * 14.4)
      - alert: HighErrorRate_Page
        expr: >
          (
            job:http_requests:error_rate1h > (1 - 0.999) * 14.4
          and
            job:http_requests:error_rate5m > (1 - 0.999) * 14.4
          )
        labels:
          severity: page
        annotations:
          summary: "High Error Budget Burn Rate (14.4x)"
          description: "Service {{ $labels.job }} error budget is burning rapidly. 2% of 30d budget consumed in 1h."
    
      # P1 级别告警:慢性燃烧,不需要半夜起床,但上班后需首要处理 (6x)
      # 条件:(6h错误率 > 0.001 * 6) AND (30m错误率 > 0.001 * 6)
      - alert: HighErrorRate_Ticket
        expr: >
          (
            job:http_requests:error_rate6h > (1 - 0.999) * 6
          and
            job:http_requests:error_rate30m > (1 - 0.999) * 6
          )
        labels:
          severity: ticket
        annotations:
          summary: "Significant Error Budget Burn Rate (6x)"
    

    3. Alertmanager (v0.25.0) 的防御性路由配置

    有了精准的标签打标(severity: page vs severity: ticket),Alertmanager 的路由配置就能彻底隔离噪音,保护 On-Call 人员的睡眠。

    route:
      receiver: 'default_slack'
      group_by: ['job', 'alertname']
      group_wait: 30s
      group_interval: 5m
      repeat_interval: 4h
      routes:
        # 彻底静默没有任何业务影响的基础设施 Warning
        - matchers:
            - severity="warning"
          receiver: 'blackhole'
    
        # 真实的 P0,触发 PagerDuty/电话
        - matchers:
            - severity="page"
          receiver: 'pagerduty_voice'
          group_wait: 10s   # P0 不等聚合,立刻发
          repeat_interval: 1h
    
        # P1 生成 Jira 工单或发工作群,不弹窗、不打电话
        - matchers:
            - severity="ticket"
          receiver: 'jira_and_slack'
    

    经过这套重构,系统告警量通常能直接断崖式下跌。你不再需要去猜测“MySQL Load 飙到 50 到底要不要起夜”,只要 HighErrorRate_Page 没响,业务吞吐和正确率就是稳的,天塌下来也等天亮再排查。

    常见问题

    Q1: 对于异步消费 MQ 的后台任务,没有 HTTP 状态码,怎么定义 SLI 和燃烧率? 不能用队列深度(Queue Depth)作为核心 SLI,因为消费速率和产生速率是动态变化的,队列深不一定影响业务最终时效。 应使用 端到端延迟 (End-to-End Latency) 或 处理成功率 (Pipeline Success Rate)。在入口处记录消息产生的时间戳,在消费者端计算 CurrentTime - ProduceTime。SLI 定义为:“99% 的消息必须在 5 分钟内处理完毕”。用 Histogram 抓取延迟,计算超出 5 分钟的比例作为“Error Rate”来计算燃烧率。

    Q2: 触发了 Page,排查后发现是因为下游第三方 API 挂了导致的,我的 SLO 受损但不是我的锅怎么办? 这体现了容错架构的缺失。你的 SLI 衡量的是你对客户提供的服务质量,下游挂了导致你的用户报错,这就是你的问题。正确的做法不是把告警转给下游,而是进行防御性编程:为下游依赖增加 Circuit Breaker(断路器),并在降级策略中返回默认值或缓存值。只要成功执行了降级返回 200 OK,你的系统就守住了 SLO。

    Q3: 每次发版发布(Deployment)时,Pod 重启总会引发瞬时的 5xx 错误,导致 5m 短窗口的燃烧率报警,怎么处理这种误报? 这是 Multi-Window 的典型副作用。解法有二:

    1. 联动 CI/CD 抑制: 部署流水线在开始前调用 Alertmanager API 注入一个带 matcher: service=XXX 的 Silence,部署结束后删除。

    2. 调优短窗口容忍度: 如果系统实在做不到无损发布(比如没配置 K8s preStop hook 和优雅下线),可以将针对短窗口 5m 的判断改为 job:http_requests:error_rate5m_offset5m,或者直接在 PromQL 中附加部署事件的规避逻辑。但这治标不治本,根本解法还是治理应用层的优雅启停机制。

  • 深入 Raft 幽灵节点排查:单向网络隔离引发的 Term 飞涨与 PreVote 拦截实战

    排查自研分布式 KV(基于 go.etcd.io/etcd/raft/v3 v3.5.0)频繁无故切换 Leader 导致 QPS 跌零时发现,单向网络隔离会导致“幽灵节点”无法接收心跳,从而不断自增 Term 发起选举。高版本 Term 的投票请求会穿透隔离,强制合法 Leader 降级引发选举风暴。核心解法是全量开启 Raft PreVote(预投票)机制,并在配合 CheckQuorum,在自增 Term 前验证网络连通性,从协议层阻断脑裂假象。

    0x00 故障现场:毫无征兆的 Leader Flapping

    排查过程中,监控面板上出现了一个极为诡异的现象:集群整体流量不高,CPU/内存均无压力,但 API Server 报出大量 503 Service Unavailable。

    调出 Prometheus 监控,发现两个核心指标极度异常:

    1. Leader 切换频繁: rate(raft_leader_changes_total[1m]) 出现规律性尖刺。

    2. Term 飞涨: 集群的 raft_term 指标像脱缰的野马,短时间内从 142 飙升到了 15403。

    拉取当前 Leader(节点 A)的核心报错日志,发现其被强制逼退:

    {"level":"info","ts":"...","caller":"raft/raft.go:1004","msg":"[raft] node A stepped down to follower since error or received message with higher term","term":15403}
    

    紧接着,节点 A 重新发起选举,拿回 Leader 身份,但没过几秒,再次被逼退。整个集群陷入了无休止的“选举-当选-被逼退”的死亡循环中,此时 I/O 停滞,业务读写全被阻塞。

    0x01 定位元凶:单向网络隔离引发的“毒药”

    顺着日志,我将目光锁定在节点 C。节点 C 一直处于 Follower 状态,但它的 raft_term 却是全场最高的。

    登录节点 C 宿主机,通过 tcpdump 抓包分析发现了一个典型的单向网络隔离(One-way Partition)现象:

    # 在节点 C 上抓取与节点 A (Leader) 的 Raft 通信
    tcpdump -i eth0 host <Node_A_IP> and port 2380 -nn -vv
    

    抓包结果显示:节点 C 能向外发送数据包,但接收不到任何来自节点 A 的数据包。 检查网络层发现,是某次变更不慎在节点 A 所在宿主机的 iptables 的 OUTPUT 链中,针对节点 C 的 IP 配置了 DROP。

    协议教科书里往往假设网络是完全断开的双向隔离,但在实际物理机房中,非对称路由、交换机单播风暴拦截、iptables 误配引发的单向隔离才是最致命的毒药。

    0x02 为什么单向网络隔离会引发全局选举风暴?

    在标准 Raft 协议中,一切以 Term(任期)为尊。单向隔离彻底击穿了标准 Raft 的防线,其演变过程如下:

    1. 心跳超时与 Term 膨胀: Leader A 正常发送心跳(MsgHeartbeat),但节点 C 收不到。节点 C 的选举定时器超时,根据协议,它将自身转为 Candidate,Term 加 1(变为 143),并向全网广播 MsgVote。

    2. 毒药广播: 因为是单向隔离,节点 C 的 MsgVote 成功发送到了 A 和 B。

    3. 强制降级: Leader A 收到节点 C 的 MsgVote,虽然节点 C 的日志可能不是最新的,但 Raft 的强规则是:一旦收到 Term 大于自身当前 Term 的消息,当前节点必须无条件转为 Follower 并更新自己的 Term。

    4. 无法当选与死循环: A 降级后集群无 Leader,开始新一轮选举。A 和 B 互相通信,A 重新当选(Term=144)。但节点 C 依然收不到心跳,再次超时,Term 变为 145,再次发送 MsgVote 逼退 A。

    节点 C 就像一个幽灵,自己永远无法当选(因为收不到其他节点的投票响应),但却能通过不断自增的 Term 作为“毒药”,把正常运行的 Leader 拉下马。

    0x03 PreVote 源码剖析:在拔剑前先确认身份

    为了解决这个标准 Raft 的缺陷,etcd/raft 引入了 PreVote(预投票)机制。其核心思想非常克制:在正式增加 Term 之前,先发起一次模拟投票;只有在确保自己能获得多数派选票时,才真正增加 Term 发起正式选举。

    翻开 go.etcd.io/etcd/raft/v3 的底层源码(raft.go),我们可以看到状态切换的区别:

    // tickElection 在选举超时后被调用
    func (r *raft) tickElection() {
        // ... 
        if r.preVote {
            // 开启了 PreVote:先进入 PreCandidate 状态,不增加 Term
            r.Step(pb.Message{From: r.id, Type: pb.MsgHup})
        } else {
            // 未开启 PreVote:直接进入 Candidate 状态,Term + 1 (危险行为)
            r.campaign(campaignElection)
        }
    }
    
    func (r *raft) campaign(t CampaignType) {
        // ...
        if t == campaignPreElection {
            r.becomePreCandidate() // 注意:这里调用后,r.Term 不会增加
            voteMsg = pb.MsgPreVote
        } else {
            r.becomeCandidate()    // 这里调用后,r.Term 会 +1
            voteMsg = pb.MsgVote
        }
        // 发送投票请求
        for _, id := range r.prs.Voters.IDs() {
            if id == r.id { continue }
            r.send(pb.Message{Term: term, To: id, Type: voteMsg, ...})
        }
    }
    

    PreVote 拦截的精妙之处在于其他节点的响应逻辑: 当正常节点 A(Leader)收到节点 C 的 MsgPreVote 时,因为 MsgPreVote 携带的是节点 C 当前的 Term(并没有加1),A 会判断自己当前仍然是合法的 Leader(未过 Lease 期/选举超时时间),因此会直接拒绝给节点 C 投预选票。 节点 C 拿不到多数派的预选票,就永远无法进入 Candidate 状态,Term 也永远不会增加,集群脑裂假象被彻底扼杀。

    0x04 落地实战:防御性架构的配置规范

    在自研系统的 Raft 引擎初始化阶段,必须强制开启 PreVote 和 CheckQuorum。这两个配置是高可用集群的“左右护法”。

    import "go.etcd.io/etcd/raft/v3"
    
    func newRaftNode(id uint64, peers []raft.Peer, storage *raft.MemoryStorage) raft.Node {
        config := &raft.Config{
            ID:                        id,
            ElectionTick:              10,
            HeartbeatTick:             1,
            Storage:                   storage,
            MaxSizePerMsg:             1024 * 1024,
            MaxInflightMsgs:           256,
    
            // 【防御性配置一】强制开启 PreVote 拦截网络孤岛引发的 Term 飞涨
            PreVote:                   true,
    
            // 【防御性配置二】强制开启 CheckQuorum
            // 允许 Leader 周期性检查自己是否仍然能连接到多数派,
            // 如果不能,Leader 会主动 stepDown,防止出现双 Leader 假象下的脏读
            CheckQuorum:               true, 
        }
    
        // 启动 Raft 状态机
        return raft.StartNode(config, peers)
    }
    

    配置下发并滚动重启集群后,我们再次通过 iptables 模拟针对单节点的网络隔离。 监控显示:被隔离的节点后台会不断发起 MsgPreVote,但被存活节点拒绝。主集群的 Leader 坚如磐石,raft_term 曲线保持绝对平稳,业务 QPS 0 抖动。

    0x05 常见问题 (Q&A)

    Q1:开启 PreVote 后,如果真实的 Leader 发生硬件宕机,选举耗时会变长吗? 会增加一次 RPC 往返(RTT)的耗时。因为候选者需要先走完 PreElection 阶段,拿到预选票后,再走正式的 Election 阶段。但在同城机房内,一次 RTT 通常在 1ms 以内,相比于默认 1000ms 的选举超时(Election Timeout),这点延迟对可用性的影响微乎其微,换来的却是极高的系统稳定性。

    Q2:如果网络完全断开(双向隔离),PreVote 还能发挥作用吗? 能。在双向隔离中,孤岛节点发不出预投票,自己也会一直处于 Follower/PreCandidate 状态,Term 不会增加。当网络恢复后,它重新接入集群时,其 Term 与主集群一致,通过正常的 MsgApp (AppendEntries) 就能无缝对齐日志,不会对现有 Leader 造成任何冲击。

    Q3:为什么不单纯依靠调大 Election Timeout 来规避网络抖动带来的频繁选举? 单纯调大 Election Timeout 是一种掩耳盗铃的做法。它确实能掩盖短暂的网络抖动,但代价是极大地延长了真实故障发生时的 MTTR(平均恢复时间)。发生真实物理宕机时,集群需要等待漫长的 Timeout 才会开始重选 Leader,这段时间内业务是完全不可用的。Raft 的调优原则是:用协议本身的严谨性(PreVote)去解决逻辑问题,而不是用粗暴的延迟(增大 Timeout)去掩盖问题。

  • 深入 Apache Pulsar 写入雪崩排查:Journal/Ledger 磁盘混用引发的 IO 饱和与 Bookie 假死实战

    某次接手一个号称“完全按照官方最佳实践”部署的 Pulsar 集群,业务方反馈高并发场景下大量 Producer 频繁抛出 PulsarClientException$TimeoutException,P99 写入延迟从常态的 5ms 瞬间飙升至 8000ms+,集群吞吐呈断崖式下跌。直接抛出排查结论:这是典型的底层存储架构无知导致的惨案。部署人员将 BookKeeper 的 journalDirectories(写前日志)和 ledgerDirectories(数据与索引)挂载到了同一块物理磁盘(甚至是同一块云盘)。当 Ledger 触发后台垃圾回收(Garbage Collection)或 RocksDB 刷盘时,海量随机 IO 直接榨干了磁盘 IOPS,导致 Journal 的顺序 fsync 严重阻塞。Bookie 内部线程池大面积挂起,最终因 ZK 心跳超时被踢出集群,引发 NotEnoughBookiesException 全局写入雪崩。

    Pulsar 最大的卖点就是“计算与存储分离”(Broker 与 Bookie 分离),但很多人只停留在节点级别的隔离,完全无视了 BookKeeper 内部极其苛刻的 IO 路径分离要求。

    BookKeeper 的写入模型极其严谨且保守:一条消息到达 Bookie 后,必须强制 fsync 落盘到 Journal(类似 MySQL 的 Redo Log),才会向 Broker 返回 ACK。同时,消息会被写入内存(MemTable),随后异步批量刷入 Ledger 磁盘,并更新 RocksDB 中的索引。 这套设计的初衷非常明确:用 Journal 的极速顺序写保证低延迟和数据可靠性,用 Ledger 的大容量存储应对历史数据读取和高吞吐。

    把 Journal 和 Ledger 混在一块盘上,无异于在高速公路上摆地摊。

    排查期间,登陆故障 Bookie 节点,一条极其普通的 iostat 命令就让问题原形毕露:

    # iostat -dx 1
    Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
    nvme1n1           0.00     0.00  850.00 1200.00 10240.00 45000.00    53.89   145.20   70.83   90.50   56.90   0.49 100.00
    

    磁盘 %util 死死钉在 100%,avgqu-sz(请求队列长度)高达 145,await 飙到 70ms 以上(对于 NVMe 来说,超过 5ms 就已经是不及格了)。

    去翻看 Bookie 的 Prometheus 监控,核心指标 bookkeeper_journal_JOURNAL_SYNC_99_per(Journal 落盘 99 线)与磁盘 IO 延迟高度吻合,出现了巨幅毛刺。此时,Broker 的日志里已经尸横遍野:

    org.apache.bookkeeper.client.BKException$BKNotEnoughBookiesException: Not enough non-faulty bookies available
        at org.apache.bookkeeper.client.LedgerCreateOp.initiate(LedgerCreateOp.java:142)
        ...
    

    为什么会突然爆发?因为 BookKeeper 并非只有简单的追加写。当 Ledger 中的 EntryLog 文件里被删除(或过期)的数据达到一定比例时,Bookie 会触发后台 GC 线程(Minor/Major Compaction)。GC 的动作是读取旧文件、过滤有效数据、重写到新文件。这是一个极其暴力的重度随机读 + 顺序写过程。 如果 Journal 和 Ledger 共享物理 IO 设备,GC 产生的海量 IO 请求会瞬间塞满 OS 的 Block Layer 队列,Journal 线程哪怕只是想追加写入几 KB 数据并调用一次 fsync,也只能在队列里绝望地排队。

    不仅如此,由于 Journal 同步阻塞,Bookie 的 Netty Worker 线程被耗尽,导致 Bookie 连发往 ZooKeeper 的心跳都无法及时响应。ZK 判定 Bookie 宕机,Broker 发现 Ensemble 可用节点不足(例如配置了 3 副本,只剩下 2 个健康节点),直接拒绝写入。由于集群是均衡负载的,随着 GC 在各个节点轮番上演,整个 Pulsar 集群如同多米诺骨牌般倒塌。

    解决这种问题,不要去迷信什么神奇的 JVM 调优参数,核心就是尊重物理拓扑。

    修复手段与防御性配置:

    1. 物理级别的 IO 隔离(最关键) 修改 bookkeeper.conf,强制分离 Journal 和 Ledger 目录到不同的物理磁盘。Journal 给一块极小但极快的高性能 NVMe SSD(几十G即可,写满会自动清理),Ledger 给大容量的普通 SSD 甚至 HDD。

    # 高速 NVMe 挂载点
    journalDirectories=/mnt/nvme_journal/bookkeeper/journal
    # 大容量 SSD/HDD 挂载点
    ledgerDirectories=/mnt/ssd_ledger/bookkeeper/ledgers
    

    2. 对后台 GC 进行冷酷的资源限流 不要让 GC 跑起来像脱缰的野马。在 bookkeeper.conf 中开启 GC 限速,严格控制其对磁盘带宽的占用:

    # 开启按字节限流
    isThrottleByBytes=true
    # 限制 Compaction 最大速率为 50MB/s (根据底层磁盘能力调整)
    compactionRateByBytes=52428800
    # 避免在高峰期触发 Major Compaction
    minorCompactionThreshold=0.2
    majorCompactionThreshold=0.8
    

    3. RocksDB 索引刷盘的平滑处理 Ledger 中的索引默认由 RocksDB 管理,RocksDB 的 MemTable Flush 同样会带来 IO 尖峰。确保配置了合理的 Write Buffer 和并发度:

    dbStorage_rockdb_writeBufferSizeMB=64
    dbStorage_rockdb_numLevels=6
    

    架构设计不是画几个方块就完事了。Pulsar 这种分布式中间件的性能底座,其实都建立在底层 Linux IO 调度和文件系统特性的基础之上。不理解数据的生命周期流转,不看磁盘的 IOPS 和延迟分布,一键部署出来的集群,最终都会在晚高峰教你做人。

    排查清单:BookKeeper IO 阻塞与假死速查

    1. 磁盘物理拓扑核对:执行 df -h 和 lsblk,严格对照 bookkeeper.conf 中的 journalDirectories 和 ledgerDirectories,确认两者绝未落在同一块物理盘、同一个 LVM 卷或同一个共享云盘组上。

    2. Journal Sync 延迟监控:紧盯 bookkeeper_journal_JOURNAL_SYNC 的 P99 和 P999 指标,一旦常态超过 10ms,立刻排查底层的 IO 争抢或硬件寿命衰减问题。

    3. ZooKeeper 会话抖动排查:排查 Bookie 侧日志是否有 Expired session,以及 ZK 侧是否有 Closed socket connection for client。如果是 IO 夯死导致的 CPU 调度迟滞,考虑适当调大 zkTimeout(默认通常为 10s-30s),但治本仍在 IO 治理。

    4. GC 日志与速率审查:搜索 Bookie 日志中的 GarbageCollectorThread 关键字,观察 Compaction 触发频率和耗时。确认 isThrottleByBytes 是否开启并配置了合理的阈值,防止后台合并打挂前台写入。

    5. Direct Memory 泄漏挤压 OS Cache:检查 dbStorage_directIO_entryLogger 是否未正确分配,导致 Bookie OOM 或严重依赖 PageCache。确保为 Bookie 预留充足的 Direct Memory 给 RocksDB Block Cache 和 ReadAhead Cache。

  • 深入 NVMe IO 延迟雪崩排查:默认调度器误用引发的 blk-mq 锁争用与软中断打满实战

    某次核心分布式 KV 存储集群进行底层硬件换代,全面升级至 Gen4 NVMe SSD。本以为硬件红利能让 IO 性能起飞,结果压测一上,QPS 刚到 8 万,99线(p99 latency)就开始周期性从 2ms 剧烈抖动到 300ms 以上。Load Average 狂飙至机器核数的两倍,大量写入线程陷入 D 状态(Uninterruptible Sleep)。 一句话交代结论:这是一起典型的“旧时代运维习惯毒害新硬件”的事故。系统镜像中遗留的 udev 规则将 NVMe 设备的 IO 调度器默认设置成了 mq-deadline。在极高并发下,这个多余的软件调度层在内核 blk-mq(块设备多队列)中引发了极其严重的自旋锁争用(Spinlock Contention),直接打满 ksoftirqd 软中断,导致 IO 请求在 OS 提交队列里排队,根本没送进物理磁盘。 解决方式极其简单:echo none > /sys/block/nvme0n1/queue/scheduler。

    案发现场与指标拆解

    排查过程中,第一直觉是新批次的 NVMe 盘存在固件缺陷或发生了 GC(垃圾回收)拥塞。习惯性敲下 iostat -xz 1,看到的数据却极为诡异:

    Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
    nvme0n1           0.00    12.00  340.00 45000.00  5440.00 720000.00    32.00   145.50  180.20    1.50  182.10   0.01  15.20%
    

    注意看这几个核心指标的矛盾点:

    1. %util 只有 15.20%:说明磁盘底层的物理带宽和 IOPS 根本没跑满,处于极度饥饿状态。

    2. svctm(服务时间)仅为 0.01ms:说明盘的物理响应极快,一旦请求交到硬件手里,瞬间就能处理完。

    3. w_await 高达 182.10ms,avgqu-sz(平均队列长度)达到 145.5:请求虽然处理得快,但排队时间长得离谱。

    这说明一个铁打的事实:IO 根本没有卡在硬件设备上,而是卡在了 Linux 内核的 IO 栈里。

    为了抓现行,直接祭出 perf top 观察内核态的 CPU 热点,结果满屏红彤彤的自旋锁:

      18.45%  [kernel]       [k] native_queued_spin_lock_slowpath
      12.30%  [kernel]       [k] blk_mq_sched_insert_request
       9.15%  [kernel]       [k] sbitmap_get
       7.20%  [kernel]       [k] dd_insert_requests
    

    再看 CPU 状态,top 显示多核的 si(软中断)飙升,对应的进程全是 ksoftirqd/x。

    愚蠢的配置与底层原理解析

    走到这一步,根因已经水落石出:dd_insert_requests 这个函数的出现,意味着系统正在使用 mq-deadline IO 调度器。

    查看设备的调度器配置,果不其然:

    $ cat /sys/block/nvme0n1/queue/scheduler
    [mq-deadline] kyber bfq none
    

    为什么在 NVMe 上用 mq-deadline 是不可原谅的低级错误?

    在 SATA/SAS 机械硬盘时代,磁盘只有一个硬件队列(Queue Depth 通常只有 32 或 256)。为了防止磁头剧烈寻道,Linux 内核设计了 IO 调度器(如 Deadline、CFQ)在软件层面对 IO 请求进行合并(Merge)和重排(Sort)。 但在 NVMe 时代,协议原生支持多达 64K 个硬件队列(Submission/Completion Queues),每个队列深度可达 64K。Linux 内核为此重构了 blk-mq(Block Multi-Queue)架构,将 CPU Core 与 NVMe 硬件队列直接建立映射关系(Software Queue -> Hardware Dispatch Queue)。

    如果在 NVMe 上强行开启 mq-deadline 或 bfq,相当于在原本宽阔的双向 64 车道高速公路上,硬生生设了一个收费站。 所有并发请求走到 blk_mq_sched_insert_request 时,都需要去抢调度器内部的自旋锁,把请求塞进软件队列进行徒劳的合并尝试。在高并发的 KV 存储场景中,小块随机写极多,合并命中率极低,这种操作不仅毫无意义,反而引发了致命的锁争用(native_queued_spin_lock_slowpath)。同时,底层块设备完成 IO 后抛出的中断,在唤醒软中断处理(NET_RX / BLOCK)时又被上层阻塞,最终导致 ksoftirqd 把 CPU 吃干抹净。

    极客实战:彻底打通 IO 栈

    1. 拔掉收费站(关闭调度器)

    直接将调度器设置为 none,让 blk-mq 完全旁路软件调度层,直通硬件队列。

    echo none > /sys/block/nvme0n1/queue/scheduler
    

    修改后,p99 延迟瞬间从 300ms 回落到稳定的 1.5ms,Load Average 下降 80%。

    2. 防御性配置(根治固化)

    永远不要相信手动命令,写进 udev 规则才是 SRE 的基本素养:

    # /etc/udev/rules.d/60-io-scheduler.rules
    ACTION=="add|change", KERNEL=="nvme[0-9]*", ATTR{queue/scheduler}="none"
    ACTION=="add|change", KERNEL=="sd[a-z]*", ATTR{queue/rotational}=="0", ATTR{queue/scheduler}="mq-deadline"
    

    (注:对 SATA SSD 依然保留 mq-deadline 是一种保守策略,但对 NVMe 必须一律为 none)。

    3. 压榨最后一滴性能(中断亲和性)

    即使调度器改成了 none,如果你发现某些 CPU 核的 hi/si 依然不均衡,那是因为 NVMe 的 MSI-X 中断没有打散。现代 NVMe 驱动通常会自动分配 IRQ,但如果运行了老旧的 irqbalance 守护进程,可能会发生“劣化漂移”。 建议针对极度依赖 IO 的节点,关掉 irqbalance,并使用内核源码自带的 set_irq_affinity 脚本将 NVMe 的完成队列中断静态绑定到 NUMA 节点的对应 CPU 核心上。

    同类问题速查清单 (Troubleshooting Checklist)

    1. 检查 IO 调度器状态: 快速排查集群中所有块设备的调度器配置: awk -F'[][]' '{print $2}' /sys/block/*/queue/scheduler 如果 NVMe 设备输出非 none,立刻整改。

    2. 区分 OS 队列延迟与硬件延迟: 不要只看 iostat 的 await。使用 eBPF 工具(如 bcc-tools 的 biolatency)深入观测: biolatency -Q 若输出中 OS Queue Time (Q-time) 远大于 Device Time (D-time),说明阻塞点在 Linux Block Layer。

    3. 检查 NVMe 多队列深度配置: 确认内核是否正确识别并启用了 NVMe 硬件队列: ls -d /sys/block/nvme0n1/mq/* | wc -l 该数值应接近或等于机器的 CPU 核心数。

    4. 观测软中断打散情况: watch -n 1 'cat /proc/interrupts | grep nvme' 观察各 CPU 列的数值增长率。如果只有极少数 CPU 在狂飙,说明 IRQ 绑定策略失效,正在引发单核软中断瓶颈。

  • 深入 K8S CSI 存储雪崩排查:Immediate 模式引发的跨可用区调度死锁与 Finalizer 僵尸惨案

    排查过程中经常能遇到一种让人血压飙升的场景:业务侧跑来报障,说 StatefulSet 扩容卡住了,Pod 一直处于 Pending 状态。为了“快速恢复”,他们熟练地加上 --force --grace-period=0 强删了 Pod 和 PVC,结果不仅新 Pod 没起来,旧的 PV 全变成了 Terminating 僵尸态,底层云盘疯狂计费,CSI Provisioner 的队列被彻底塞爆。

    先抛出结论:在多可用区(Multi-AZ)集群中,StorageClass 绝对不能使用默认的 volumeBindingMode: Immediate。 必须显式声明为 WaitForFirstConsumer。否则,CSI Provisioner 会在 PVC 创建瞬间盲目在一个随机可用区创建底层存储卷,一旦 K8s 调度器受限于节点资源或 Pod 反亲和性(Anti-Affinity),将 Pod 强行调度到另一个可用区,就会触发经典的 volume node affinity conflict 死锁。而无脑的强删操作,只会引发 Finalizer 锁死,导致控制面雪崩。

    案发现场:一次愚蠢的“调度冲突”与强删风暴

    某次核心中间件集群扩容,运维同学反馈新加的两个 Pod 挂死在 Pending 状态。 随手敲下 kubectl describe pod,看到了 K8s 存储排查中最眼熟的报错:

    Warning  FailedScheduling  3m2s  default-scheduler  0/50 nodes are available: 20 node(s) didn't match pod anti-affinity rules, 30 node(s) had volume node affinity conflict.
    

    这个报错的信息量极大。集群一共 50 个节点,其中 20 个节点因为业务配置了强反亲和性(requiredDuringSchedulingIgnoredDuringExecution)被过滤,剩下 30 个节点全部报 volume node affinity conflict。

    去查一眼 PVC 和 PV 的状态,发现 PVC 已经是 Bound 状态了:

    $ kubectl get pvc data-kafka-3
    NAME           STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
    data-kafka-3   Bound    pvc-8f9a2b3c-1234-5678-90ab-cdef12345678   500Gi      RWO            ssd-sc         15m
    

    这就是典型的“盘建好了,但 Pod 过不去”。 此时,业务研发为了自救,执行了经典的毁灭三连: kubectl delete pod kafka-3 --force kubectl delete pvc data-kafka-3 --force kubectl delete pv pvc-8f9a2b3c... --force

    结果灾难发生了:PVC 和 PV 全部卡在 Terminating。CSI Controller 疯狂刷错,external-provisioner 的 Goroutine 数量飙升,API Server 持续收到无用的 Update 请求,整个存储控制面陷入瘫痪。

    核心原理解析:为什么盘和计算节点会劈腿?

    很多半吊子对 Kubernetes 存储生命周期的认知还停留在“建 PVC -> 绑 PV -> 挂载到 Pod”的线性思维上。在 CSI(Container Storage Interface)架构下,多可用区集群的存储拓扑感知(Topology Awareness)是一件极其严谨的事。

    1. Immediate 模式的致命缺陷

    查看当时的 StorageClass 配置:

    apiVersion: storage.k8s.io/v1
    kind: StorageClass
    metadata:
      name: ssd-sc
    provisioner: ebs.csi.aws.com
    parameters:
      type: gp3
    # 致命缺失:没有定义 volumeBindingMode,默认使用了 Immediate
    

    在 Immediate 模式下,当 StatefulSet 创建出 PVC 时,CSI external-provisioner 会立刻调用云厂商 API 创建一块 EBS 盘。由于此时它不知道最终 Pod 会被调度到哪个节点,它只能随机(或根据默认规则)选择一个可用区(假设选了 Zone A)。 盘建好后,生成的 PV 对象里会被硬性打上 nodeAffinity:

    nodeAffinity:
      required:
        nodeSelectorTerms:
        - matchExpressions:
          - key: topology.ebs.csi.aws.com/zone
            operator: In
            values:
            - ap-southeast-1a  # 盘被锁死在了 Zone A
    

    2. 调度器被两头堵死

    接下来 kube-scheduler 开始为 Pod 寻找节点。

    • Pod 自身带有反亲和性,恰好 Zone A 的节点都已经部署了同一个 StatefulSet 的其他 Pod,Zone A 全部被过滤。

    • 调度器试图把 Pod 塞进 Zone B 的节点,但在评估存储卷时,发现 PV 的 nodeAffinity 是 Zone A。

    • 最终结果:计算资源要求去 Zone B,存储资源锁死在 Zone A。死锁形成,Pod 永久 Pending。

    3. 强删引发的 Finalizer 僵尸机制

    K8s 极度推崇“防御性编程”,为了防止数据丢失,设计了 Finalizer 机制。

    • 当你删除正在被 Pod(哪怕是 Pending 但已绑定的 Pod)引用的 PVC 时,kubernetes.io/pvc-protection Finalizer 会拦截删除操作。

    • 当你强制干掉 PV 时,kubernetes.io/pv-protection 会死死拦住。

    • 更要命的是,底层云盘的 Delete 请求依赖 CSI 正常通信。当人为 kubectl patch 暴力清除 Finalizer 时,K8s 里的对象没了,但云厂商那边的物理云盘变成了孤儿资源(Leaked Volume),默默消耗着高昂的云预算。

    破局与自救:如何体面地收拾残局?

    不要一上来就改 etcd 或者无脑 patch finalizer,按顺序执行以下操作:

    第一步:揪出卡死的资源并妥善释放 如果 PVC/PV 已经处于 Terminating,必须先确认底层云盘是否已经删除。如果没删,手动去云控制台删盘。确认盘没用后,再通过 Patch 清理 K8s 对象:

    # 清理 PVC Finalizer
    kubectl patch pvc data-kafka-3 -p '{"metadata":{"finalizers":null}}'
    # 清理 PV Finalizer
    kubectl patch pv pvc-8f9a2b3c-1234-5678-90ab-cdef12345678 -p '{"metadata":{"finalizers":null}}'
    

    第二步:检查是否有残留的 VolumeAttachment 有时候 PV 删了,但 CSI 挂载记录还在,会导致同名节点后续挂载一直报错 VolumeInUse:

    kubectl get volumeattachment | grep pvc-8f9a2b3c
    # 如果有,同样 patch 清掉
    kubectl patch volumeattachment <name> -p '{"metadata":{"finalizers":null}}'
    

    第三步:重建 StorageClass(核心防御) StorageClass 的 volumeBindingMode 是不可变字段(Immutable),只能建新的。

    apiVersion: storage.k8s.io/v1
    kind: StorageClass
    metadata:
      name: ssd-sc-topology
    provisioner: ebs.csi.aws.com
    parameters:
      type: gp3
    volumeBindingMode: WaitForFirstConsumer # 绝对核心
    allowedTopologies: # 可选:显式限制允许创建存储的可用区
    - matchLabelExpressions:
      - key: topology.ebs.csi.aws.com/zone
        values:
        - ap-southeast-1a
        - ap-southeast-1b
    

    原理揭秘:改为 WaitForFirstConsumer 后,PVC 创建时 CSI 不会立即建盘,PVC 会处于 Pending 状态。kube-scheduler 会将 Pod 调度到合适的节点(例如 Zone B),然后将选定的节点拓扑信息传递给 CSI Provisioner,CSI 再拿着 “Zone B” 的确切坐标去调用云 API 建盘。实现了“计算在哪,存储就建在哪”的精准协同。

    排查清单:K8S 存储异常速查表

    1. 查调度模式冲突:检查 StorageClass 是否为 Immediate 且集群为多可用区。只要符合这两条,立刻改成 WaitForFirstConsumer。

    2. 查 PV 拓扑亲和性:kubectl get pv -o yaml,查看 nodeAffinity 中声明的 Zone,是否与 Pod 最终想要调度的 Node 所在的 Zone 完全一致。

    3. 查挂载残留对象:排查 kubectl get volumeattachments 列表中是否有长时间 Attached: true 但实际 Pod 已经销毁的僵尸记录。

    4. 查 CSI 控制平面:抓取 external-provisioner 和 external-attacher 容器的日志,搜索 Failed to attach volume 或 rate exceeded 关键字,确认是否因 API 限流导致状态不一致。

    存储无小事。在基础设施即代码的今天,任何一行缺乏底层逻辑支撑的 YAML,都有可能在深夜掀起一场毁灭性的雪崩。敬畏数据,敬畏拓扑。

  • 深入 Jenkins Pipeline 雪崩排查:CPS 转换引发的 Master JVM OOM 与 Shared Library 全局变量污染实战

    核心结论:Jenkins Pipeline 中的 Groovy 并非标准 Groovy,底层强制执行 CPS(Continuation Passing Style)转换以支持跨节点和重启的断点续跑。在 Shared Library 中滥用大对象、复杂闭包或未实现 Serializable 的原生 Java 类,会使 Master JVM 在状态序列化时直接 OOM。必须通过 @NonCPS 隔离重度计算逻辑,并结合 JCasC 实现基础设施不可变。

    故障现场:Master 的静默死亡

    排查过程中接到告警,某核心业务构建集群的 Jenkins Master(版本 2.440.1 LTS,JDK 17)Load Average 突然飙升至 80+,UI 完全无响应,所有挂载在 K8S 上的动态 Agent 任务卡死在 Pending 或执行态断联。

    登机排查,直接看 JVM 指标:

    # jstat -gcutil <pid> 1000 5
      S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT   
      0.00 100.00 100.00  99.98  95.21  92.14  14521  124.512   512  1421.112 1545.624
    

    Old Gen 打满,FGC 极其频繁且回收率几乎为 0。查看业务监控看板,并发构建数仅为平时的高峰期均值(~150 concurrent builds),排除了纯粹的并发量冲击。

    立刻通过 jmap -dump:format=b,file=heap.hprof 抓取现场,并重启服务恢复业务。 MAT 分析 Heap Dump 显示,com.cloudbees.groovy.cps.Next 和 java.util.LinkedHashMap 对象占据了 85% 的堆内存。进一步展开引用链,发现全部指向 workflow-cps 插件的 ProgramData 对象。这说明:Pipeline 的状态持久化机制正在吞噬内存。

    为什么简单的 Groovy 循环会拖垮 Jenkins Master?

    很多人习惯把 Jenkins 当成一个能够运行 Groovy 脚本的普通 Cron Server,这在 Pipeline As Code 时代是致命的认知误区。

    为了实现 Pipeline 可以在 Master 重启后从中断处继续执行(Resiliency),Jenkins 引入了 CPS(Continuation Passing Style)转换。当你编写一段看似普通的 for 循环时,Jenkins 在编译期会对抽象语法树(AST)进行劫持和重写:

    1. 每执行一行代码,CPS 引擎都会将当前作用域内的所有局部变量、调用栈打包成一个 Continuation 对象。

    2. 这些对象会被序列化(基于 XStream)并持久化到磁盘(通常是 builds//program.dat),同时缓存在内存中。

    问题代码最终定位在业务团队近期提交的 Shared Library vars/deployK8s.groovy 中:

    // 典型的夺命代码:全局作用域的大字典解析 + 跨节点持有
    def call(String env) {
        // 1. 读取并解析一个高达 5MB 的 Kubernetes Manifest 集合字典
        def hugeManifestMap = readYaml(file: "manifests/all-services.yaml")
    
        // 2. 在外层作用域遍历
        hugeManifestMap.each { svcName, config ->
            node('k8s-agent') { // 3. 跨越节点上下文
                sh "echo Deploying ${svcName}"
                // ... 复杂的 YAML 替换与 kubectl apply
            }
        }
    }
    

    底层原理解析: 当上述代码执行到 node('k8s-agent') 触发跨节点调度时,Pipeline 会挂起当前线程。此时,CPS 必须保存当前的环境状态以便稍后恢复。而 hugeManifestMap 是闭包外层的局部变量,CPS 不得不把这个 5MB 的嵌套 LinkedHashMap 及其对应的迭代器对象完整序列化。 在 150 个并发任务叠加下,这导致了极其可怕的写放大和内存膨胀:每次 sh 步骤执行,CPS 都要在内存里克隆并序列化这个巨大的上下文,最终瞬间撑爆 Master 的 JVM 堆。

    防御性架构重构与最佳实践

    针对这种滥用 Shared Library 引发的雪崩,必须在代码规范和基础设施配置两方面做防御。

    1. 使用 @NonCPS 隔离不可序列化与重度逻辑

    对于不需要断点续跑的纯计算、数据转换、大对象解析逻辑,强制使用 @NonCPS 注解。被 @NonCPS 标记的方法会在普通的 Java 线程池中作为原生代码执行,不会进行状态序列化。

    修复后的 Shared Library 实践:

    import com.cloudbees.groovy.cps.NonCPS
    
    def call(String env) {
        // 仅在局部获取所需的小数据集合,避免整个大字典逃逸到 CPS 上下文
        List<String> svcNames = extractServiceNames("manifests/all-services.yaml")
    
        for (int i = 0; i < svcNames.size(); i++) {
            def svc = svcNames[i]
            node('k8s-agent') {
                sh "echo Deploying ${svc}"
                // 每次部署仅传递当前需要的字符串对象
            }
        }
    }
    
    @NonCPS
    List<String> extractServiceNames(String filePath) {
        // 这里使用标准的 Java/Groovy 解析逻辑
        // 不会被 CPS 劫持,执行极快,不占用 Pipeline 持久化内存
        def parser = new org.yaml.snakeyaml.Yaml()
        def rawMap = parser.load(new File(filePath).text)
        return rawMap.keySet().toList()
    }
    

    注:在 @NonCPS 方法中绝对不能调用任何 Pipeline Step(如 sh, echo, node),否则会导致 IllegalStateException 或静默失败。

    2. JCasC 声明式治理 Shared Library

    为了避免通过 Jenkins UI 手工配置 Shared Library 带来的不可追溯和版本混乱,我们全面采用 Jenkins Configuration as Code (JCasC) 来固化基础设施。 将全局 Shared Library 配置下沉到不可变的代码仓库中(jcasc/jenkins.yaml):

    unclassified:
      globalLibraries:
        libraries:
        - defaultVersion: "v1.5.2" # 严禁使用 master/main 分支,必须绑定 Tag
          name: "ops-shared-lib"
          retriever:
            modernSCM:
              scm:
                git:
                  credentialsId: "git-bot-token"
                  id: "shared-lib-scm"
                  remote: "https://gitlab.internal.com/devops/jenkins-shared-library.git"
                  traits:
                  - gitBranchDiscovery()
          # 开启缓存以减轻拉取对 Master 的 I/O 压力
          cachingConfiguration:
            refreshTimeMinutes: 1440
    

    配合 K8S Helm Chart 部署 Jenkins,任何配置变更只能通过提交 MR 修改此 YAML 来触发 Pod 滚动更新,彻底掐断了手工污染配置的可能。

    常见问题 (FAQ)

    Q1:Pipeline 中经常出现 java.io.NotSerializableException: java.util.regex.Matcher 报错,如何根治? 这是由于正则表达式的 Matcher 对象内部包含 native 指针引用,无法通过 XStream 序列化。如果代码写成 def matcher = text =~ /pattern/,且该变量跨越了 CPS 步骤(例如在 sh 之前定义并在其后使用),就会报错。 解决思路:将正则匹配逻辑封装到 @NonCPS 方法中返回基础类型(String/Boolean),或者在需要跨步骤时主动置空:matcher = null。

    Q2:Jenkins Master 异常重启后,K8S 上会有大量状态为 Running 的僵尸 Agent Pod,如何自动清理? 在动态 Agent 架构中,Master 宕机会导致 JNLP 长连接断开。如果不做处理,这些 Pod 将长期挂起。 在 JCasC 的 podTemplate 配置中,务必显式设置 activeDeadlineSeconds,并通过 kubernetes-plugin 的清理策略来兜底:

    jenkins:
      clouds:
        - kubernetes:
            name: "k8s-cluster"
            serverUrl: "https://kubernetes.default"
            # 定义全局 Agent Pod 的最长存活时间(例如 2 小时)
            podRetention: "never" 
    

    同时在业务的 yaml 中确保 activeDeadlineSeconds: 7200 兜底,防止挂起任务长期吃空节点计算资源。

    Q3:如何本地单元测试 Jenkins Shared Library,避免每次都要上生产环境试错? 强推 JenkinsPipelineUnit 框架。可以在本地使用 Spock/JUnit 编写测试用例,框架会模拟 CPS 引擎和所有的内置步骤(sh, node, readYaml)。通过模拟返回结果并断言调用栈,可以在本地完成 90% 的逻辑校验,彻底告别在 Jenkins 上盲目触发几十次构建来 debug 的窘境。