标签: 云原生

  • 深入 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-provisionerexternal-attacher 容器的日志,搜索 Failed to attach volumerate exceeded 关键字,确认是否因 API 限流导致状态不一致。

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

  • Jenkins K8S 动态 Agent 疯狂重启劫难:被隐式降级击穿的 JNLP 通信防线

    某次排查过程中,核心业务线的 CI/CD 流水线彻底瘫痪,Jenkins 任务队列(Queue)积压突破 500。与此同时,底层 Kubernetes 集群告警群炸锅,API Server 出现严重的请求限流(Throttling),P99 延迟飙升至 3 秒以上。

    最终排查结论:架构团队在做 Jenkins 迁移与高可用改造时,仅配置了 Layer 7 的 Ingress 规则,却遗漏了 Jenkins Remoting 通信依赖的 Layer 4 TCP(50000)端口。导致 K8S 动态 Agent Pod 启动后无法与 Master 建立 JNLP 连接。Jenkins Kubernetes 插件因此陷入了致命的“申请 Pod -> Agent 注册超时 -> 销毁 Pod -> 无限重试”死循环,硬生生把集群 API Server 给打穿了。

    把 Jenkins 当成一个普通的无状态 Web 服务去搞云原生改造,而不去深究其底层 Master-Agent 的心跳与通信模型,这种粗暴的操作在生产环境中是极其致命的。

    案发现场:失控的调度器与死亡循环

    接到报障后,第一时间登录集群查看资源状态。终端里的现象令人窒息:

    $ kubectl get pods -n jenkins | grep jnlp-agent | wc -l
    842
    
    $ kubectl get pods -n jenkins | grep jnlp-agent | head -n 5
    jnlp-agent-8f73b-5x9qp   0/1     ContainerCreating   0          12s
    jnlp-agent-8f73b-9m2kx   1/1     Terminating         0          1m45s
    jnlp-agent-8f73b-p2v1l   0/1     ContainerCreating   0          8s
    jnlp-agent-8f73b-x8c4d   1/1     Terminating         0          1m45s
    

    数百个 Agent Pod 处于 ContainerCreatingTerminating 状态。再去查看 Jenkins Master 的系统日志,满屏都是类似下面的报错:

    INFO: Kubernetes pod jnlp-agent-8f73b-9m2kx started
    WARNING: Failed to connect to agent jnlp-agent-8f73b-9m2kx within 100 seconds. 
    INFO: Terminating node jnlp-agent-8f73b-9m2kx
    INFO: Queue task #4023 still pending, provisioning a new agent...
    

    转头查看其中一个 Agent Pod 的内部日志,终于抓到了真凶:

    INFO: Locating server among [https://jenkins.company.com/]
    WARNING: Failed to connect to https://jenkins.company.com/tcpSlaveAgentListener/: Connection refused
    java.net.ConnectException: Connection refused
        at sun.nio.ch.SocketChannelImpl.checkConnect(Native Method)
    ...
    INFO: Retrying in 10 seconds
    

    深度剖析:为什么缺少一个端口会导致雪崩?

    要理解这个故障,必须理清 Jenkins Kubernetes Plugin 的工作状态机。这绝不只是一个“网络不通”的简单 Bug,而是一个典型的分布式状态机不同步导致的雪崩。

    1. Remoting 协议的固执:Jenkins Master 与 Agent 之间的通信基于 Jenkins Remoting 协议,这是一个重度依赖序列化与长连接的 Java 二进制协议。默认情况下,Agent 启动后,会先通过 HTTP(S) 请求 Master 的主入口,获取 X-Jenkins-CLI-Port 或相关 TCP 端口信息(通常是 50000),随后尝试建立直连 TCP 通道。

    2. L7 Ingress 的拦截:改造期间,Jenkins Master 被放到了 Nginx Ingress 后端。Ingress 默认只处理 HTTP/HTTPS 协议(L7)。当 Agent 尝试向 jenkins.company.com:50000 建立 TCP 握手时,流量直接在网关层被丢弃或拒绝。

    3. 致命的机制错位(State Mismatch)

    4. K8S 视角:Pod 已经成功拉起,容器状态是 Running,K8S 认为任务完成。
    5. Jenkins 视角:向 K8S 发送了 Pod 创建请求,且等待 Agent 进程发起 JNLP 注册回调。
    6. 死循环触发:等待 100 秒后(默认超时时间),Jenkins Master 依然没收到 Agent 的 JNLP 注册心跳。它不仅不会认为是自己的网络配置问题,反而会固执地判定:“这个 Pod 死掉了,为了满足队列里等待的构建任务,我必须销毁它,并向 K8S 申请一个新的 Pod。”

    当并发构建任务达到 50 个,每个任务都在触发这种“申请 -> 等待 -> 销毁 -> 再申请”的循环时,K8S 的 kube-apiserver 就成了重灾区。大量的 POST /api/v1/namespaces/jenkins/podsDELETE 请求瞬间填满了 API Server 的队列,触发限流,进而影响整个集群内其他核心业务 Pod 的调度与扩缩容。

    解决方案与防御性配置

    针对此类问题,修复网络通信只是第一步,更重要的是在架构层面加上防御性兜底限制。

    1. 拥抱 WebSocket,抛弃底层 TCP 直连

    既然 L4 暴露配置繁琐且容易在各种负载均衡器上踩坑,最优雅的做法是直接让 JNLP 流量复用 HTTP(S) 的 L7 通道。从 Jenkins 2.217 开始,Remoting 已经原生支持 WebSocket。

    在 JCasC (Jenkins Configuration as Code) 的配置中,必须在 K8S Cloud 配置项里显式开启 webSocket: true

    jenkins:
      clouds:
        - kubernetes:
            name: "kubernetes"
            # 直接走集群内部 DNS 通信,绕过外部 Ingress,降低网络开销与故障点
            serverUrl: "https://kubernetes.default"
            namespace: "jenkins-agents"
            jenkinsUrl: "http://jenkins-master.jenkins.svc.cluster.local:8080"
            # 开启 WebSocket,彻底解决 TCP 50000 端口穿透问题
            webSocket: true
            # 【防御性编程核心】设置全局容量上限,哪怕死循环也不会打穿 API Server
            containerCapStr: "100" 
    

    2. 配置 Kubernetes Plugin 的防雪崩限制

    永远不要假设外部系统会乖乖按预期工作。必须给 Jenkins 向 K8S 索要资源的行为加上硬性枷锁:

    • containerCapStr: 限制整个 K8S Cloud 并发存活的 Agent 总数。

    • 在每个 podTemplate 级别设置 instanceCap:防止单一异常的 Pipeline 把所有集群资源耗尽。

    3. 剥离通信链路(Cluster Internal Routing)

    如果你只是在同一个 K8S 集群内部署 Jenkins Master 和调度 Agent,Agent 连接 Master 绝对不应该 绕一圈跑到外网 Ingress 再进来。不仅增加延迟,还多引入了一层网络设备的故障风险。 强制在 jenkinsUrl 中使用 K8S 内部的 FQDN:http://..svc.cluster.local:

    排查清单与同类问题速查

    如果你也遇到了 Jenkins Agent 疯狂重启或一直在 Pending/Terminating 之间横跳,请核对以下清单:

    1. 排查 JNLP 握手阻断:查看 Agent Pod 的日志。如果出现 Connection refusedConnection timed out,且指向 Master 的 50000 端口,立刻检查安全组、网络策略 (NetworkPolicy) 或 LoadBalancer 的 L4 暴露情况,或者直接开启 WebSocket。

    2. 检查 Jenkins Master URL 配置:如果 Manage Jenkins -> System -> Jenkins URL 配置错误,Agent 会拿到一个无法解析的地址。在 K8S 环境下,尽量在 Cloud 配置的 jenkinsUrl 中覆盖并强制指定 ClusterIP 或内部 DNS。

    3. 监控 ContainerCap 触顶情况:如果在 Jenkins 侧看到任务一直卡在 ‘Jenkins’ doesn’t have label ‘xxx’ 或者 Waiting for next available executor,但没有看到新 Pod 创建,检查系统日志确认是否触发了 containerCap 上限。

    4. 防御性兜底检查:确认有没有恶意的 Groovy 脚本在无限触发重试。检查 Pipeline 里的 retry() 块逻辑是否包含了环境构建阶段,避免因业务代码逻辑错误引发基础设施级别的 Ddos 攻击。