• 容器逃逸与提权路径阻断:深入 PSS 底层机制与 Admission Webhook 拦截实践

    早上例行过了一遍核心集群的 API Server 审计日志,发现某个业务 Namespace 下出现了一连串被拒绝的 pods/execpods/create 请求。顺着链路查下去,发现是开发侧为了图方便,直接在一个 CI/CD 专用的 ServiceAccount 上绑定了过于宽松的 RBAC 权限。 在 K8S 环境里,安全边界从来不是靠口头约定的。今天刚好借着这个案例,把 K8S 底层的权限提权路径拆解一下,并谈谈如何通过 Pod Security Standards (PSS) 和 Admission Webhook 构建实质性的防御闭环。

    一、RBAC 最小权限的伪命题与提权路径

    很多人配置 RBAC 时,觉得只要不给 cluster-admin,不给 secrets 的读取权限就是安全的。但实际上,在 Kubernetes 中,只要拥有创建 Pod 的权限,如果没有严格的准入控制,就等同于拥有了 Node 的 Root 权限,进而可以接管整个集群。 看一眼开发试图下发但被拦截的 Pod YAML:

    apiVersion: v1
    kind: Pod
    metadata:
      name: debug-pod
    spec:
      hostNetwork: true
      hostPID: true
      containers:
      - name: shell
        image: alpine:latest
        securityContext:
          privileged: true
        volumeMounts:
        - mountPath: /host
          name: host-root
      volumes:
      - name: host-root
        hostPath:
          path: /
          type: Directory
    

    如果集群只依赖基础的 RBAC 且允许下发上述资源,会发生什么?

    1. hostPID: true 打破了 PID Namespace 的隔离。

    2. privileged: true 让容器内的进程获取了 Node 宿主机上的所有 Capabilities。

    3. hostPath 直接将宿主机的根目录 / 挂载到了容器的 /host。 攻击者进入容器后,只需要执行一条命令: chroot /host nsenter -t 1 -m -u -n -i sh 这直接切换到了宿主机 init 进程(PID 1)的 Mount、UTS、Network、IPC 命名空间。此时,攻击者已经拿到了 Node 的 Root 权限,可以随意读取 /etc/kubernetes/pki/ 下的证书,或者复用 kubelet 的凭证与 API Server 通信,完成对整个集群的控制。 这就是为什么 RBAC 的最小权限原则(Least Privilege)必须配合更底层的资源控制。

    二、从 PSP 到 PSS:内置的准入拦截机制

    早年间我们用 PodSecurityPolicy (PSP) 来防范这种越权,但由于 PSP 的 API 设计过于复杂且容易导致权限混淆,官方在 1.25 版本彻底将其移除,替换为了 Pod Security Admission (PSA/PSS)。 PSS 不是一个独立运行的组件,它本质上是 kube-apiserver 内部的一个 Admission Controller。官方预定义了三个安全等级:PrivilegedBaselineRestricted。 为了在特定的 Namespace 阻断上述提权行为,我通常会在系统层面直接打 Label 强制启用 Restricted 模式:

    kubectl label --overwrite ns cicd-pipeline \
      pod-security.kubernetes.io/enforce=restricted \
      pod-security.kubernetes.io/enforce-version=latest \
      pod-security.kubernetes.io/audit=restricted \
      pod-security.kubernetes.io/warn=restricted
    

    底层拦截逻辑分析: 当 API Server 接收到创建 Pod 的请求时,请求会经历 Authentication -> Authorization (RBAC) -> Mutating Admission -> Object Schema Validation -> Validating Admission 这个标准管道。 PSA 作用于 Validating Admission 阶段。它会提取目标 Namespace 的 Label,当检测到 enforce=restricted 时,会按严格的策略遍历 Pod Spec。只要发现 hostPID: true 或者 privileged: true,直接返回 HTTP 403。 在 API Server 的审计日志中,你可以清晰看到拦截过程产生的记录:

    {
      "kind": "Event",
      "apiVersion": "audit.k8s.io/v1",
      "level": "Metadata",
      "stage": "ResponseComplete",
      "requestURI": "/api/v1/namespaces/cicd-pipeline/pods",
      "verb": "create",
      "user": {
        "username": "system:serviceaccount:cicd-pipeline:deployer"
      },
      "responseStatus": {
        "metadata": {},
        "status": "Failure",
        "reason": "Forbidden",
        "code": 403
      },
      "annotations": {
        "pod-security.kubernetes.io/enforce-policy": "restricted"
      }
    }
    

    三、Admission Webhook:弥补 PSS 的盲区

    PSS 解决了 “Pod 自身特权逃逸” 的问题,但它的粒度太粗了。如果我的需求是:

    1. 强制所有镜像必须从公司内部的私有仓库(例如 harbor.internal.com)拉取。

    2. 阻止未经授权的用户修改特定的 ConfigMap

    3. 动态检查某些资源配置是否符合安全合规标准。 这三个需求 PSS 都做不到,必须通过自定义的 ValidatingAdmissionWebhook 来实现。 Webhook 的本质是一个 HTTPS Server。API Server 在 Validating 阶段,会将请求封装成一个 AdmissionReview 对象,通过 POST 请求发给你的 Webhook,然后根据返回的 allowed: true/false 决定是否放行。 下面是一个拦截非私有仓库镜像的核心代码片段(Go 语言):

    func servevalidate(w http.ResponseWriter, r *http.Request) {
        var body []byte
        if r.Body != nil {
            if data, err := io.ReadAll(r.Body); err == nil {
                body = data
            }
        }
        // 解析 API Server 发来的 AdmissionReview
        ar := v1.AdmissionReview{}
        json.Unmarshal(body, &ar)
        // 反序列化具体的 Pod 对象
        var pod corev1.Pod
        json.Unmarshal(ar.Request.Object.Raw, &pod)
        allowed := true
        var resultMsg string
        // 检查镜像前缀
        for _, container := range pod.Spec.Containers {
            if !strings.HasPrefix(container.Image, "harbor.internal.com/") {
                allowed = false
                resultMsg = fmt.Sprintf("Image %s is strictly forbidden. Use harbor.internal.com.", container.Image)
                break
            }
        }
        // 构造响应
        admissionResponse := v1.AdmissionResponse{
            UID:     ar.Request.UID,
            Allowed: allowed,
            Result: &metav1.Status{
                Message: resultMsg,
            },
        }
        ar.Response = &admissionResponse
        respData, _ := json.Marshal(ar)
        w.Write(respData)
    }
    

    要让这个 Webhook 生效,我们需要向集群提交 ValidatingWebhookConfiguration 配置:

    apiVersion: admissionregistration.k8s.io/v1
    kind: ValidatingWebhookConfiguration
    metadata:
      name: image-policy-validator
    webhooks:
      - name: image-policy.k8s.internal
        clientConfig:
          service:
            name: security-webhook
            namespace: security-system
            path: "/validate"
          caBundle: 
        rules:
          - operations: ["CREATE", "UPDATE"]
            apiGroups: [""]
            apiVersions: ["v1"]
            resources: ["pods"]
        failurePolicy: Fail
        namespaceSelector:
          matchExpressions:
          - key: kubernetes.io/metadata.name
            operator: NotIn
            values: ["kube-system", "security-system"]
    

    四、配置 Webhook 的高危陷阱

    在上述 YAML 中,我特意写了两个关键配置,这也是无数人在生产环境踩过大坑的地方:

    1. namespaceSelector: 必须排除掉 kube-system 甚至你部署 Webhook 所在的 Namespace。

    2. failurePolicy: Fail: 代表如果 Webhook 服务不可用,API Server 会拒绝所有发往该 Webhook 的请求。 如果你的 Webhook 所在节点宕机,或者证书过期,且你没有排除核心 Namespace,会导致连 kube-system 下的组件(比如 CoreDNS 扩容、CNI 组件重启)都无法创建 Pod,整个集群陷入死锁。到那时,你连部署一个新的 Webhook 实例去恢复它都做不到,只能通过直接修改 API Server 的静态 Pod 配置文件绕过拦截。 安全体系的构建是一环扣一环的。RBAC 控制谁能访问,PSS 限制能运行什么形态的实体,Admission Webhook 则补齐了业务侧定制化的安全约束。把这三者结合好,才能在日常运维中从容应对各种不规范的操作和潜在的恶意越权。收拾完这堆审计告警,该去跟研发团队对齐新的权限管控流程了。

  • CRD状态更新引发的死循环:从Controller-Runtime源码拆解Informer机制

    下午两点半,监控大屏准时弹出了一条飞书告警。K8S集群的 API Server 请求延迟突增,apiserver_request_total 指标显示 UPDATEWATCH 请求的 QPS 在过去十分钟内翻了近一百倍。 顺着链路查下去,源头是一个业务团队刚灰度上线的自定义 Operator。查看该 Pod 的资源消耗,CPU 已经被打满到 4 核,内存也在持续攀升。 抓了个 pprof 现场:

    go tool pprof http://localhost:8080/debug/pprof/profile?seconds=30
    

    在火焰图中,CPU 时间大量消耗在 k8s.io/apimachinery/pkg/util/json.Unmarshal 以及 k8s.io/client-go/tools/cache.(*DeltaFIFO).Pop 上。这症状太典型了——标准的 Controller Reconcile 死循环引发的自杀式 DDoS。 把他们的代码拉下来看了一眼,核心逻辑精简后如下:

    func (r *MyCustomReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
        var instance myv1.MyCRD
        if err := r.Get(ctx, req.NamespacedName, &instance); err != nil {
            return ctrl.Result{}, client.IgnoreNotFound(err)
        }
        // ... 执行业务逻辑,比如创建 Deployment 等 ...
        // 更新状态
        instance.Status.Phase = "Running"
        instance.Status.LastUpdateTime = metav1.Now() // 罪魁祸首之一
        if err := r.Status().Update(ctx, &instance); err != nil {
            return ctrl.Result{}, err
        }
        return ctrl.Result{}, nil
    }
    

    这段代码看起来符合直觉:“获取对象 -> 处理逻辑 -> 更新状态”。但在 K8S 声明式 API 和 Informer 机制的底层运作下,这就是一颗定时炸弹。

    拆解死循环的底层逻辑

    controller-runtime 的框架下,Controller 的运作高度依赖 Informer 机制。开发者很容易将其当作一个普通的 CRUD 模型,却忽略了底层的事件流。 当 r.Status().Update 被调用时,究竟发生了什么?

    1. API Server 的响应:更新 Status 成功后,API Server 会持久化这个对象到 etcd。在这个过程中,对象的 metadata.resourceVersion 会不可避免地发生递增。

    2. Reflector 捕获变更:kube-apiserver 会通过 chunked 编码的 HTTP 长连接,将 Watch Event 推送给 Informer 的 Reflector。Reflector 看到 resourceVersion 变了,认为对象发生了更新。

    3. DeltaFIFO 入队:Reflector 将一个 Updated 类型的 Delta 压入 DeltaFIFO

    4. 触发 Event Handler:Informer 的 Controller(不是你写的 Reconciler,是 client-go 内部的 controller)从 DeltaFIFO Pop 出这个 Delta,更新本地的 ThreadSafeStore(也就是你平时 r.Get 命中的本地缓存),并回调注册的 ResourceEventHandler。

    5. WorkQueue 积压controller-runtime 默认的 Handler 会将这个发生变更的对象的 NamespacedName 压入限速队列(RateLimitingQueue)。

    6. Reconcile 再次执行:你的 Reconciler 从队列取出这个请求,再次执行逻辑。因为代码里每次都无条件更新 LastUpdateTime,导致 Status 再次发生实质性改变。 这就形成了一个完美的闭环:Reconcile -> Update Status -> ResourceVersion++ -> Watch Event -> Enqueue -> Reconcile。 每秒几百次的循环,不仅把 Operator 自身的 CPU 榨干,还连带把 API Server 的带宽和处理能力吃爆。

    阻断风暴:Generation 与 Predicate 机制

    K8S 在设计之初就考虑到了这个问题。为了区分“用户期望状态的变更(Spec)”和“系统当前状态的变更(Status)”,引入了 metadata.generation 这个元数据。 按照规范,只有当 CRD 的 Spec 部分发生变更时,API Server 才会递增 generation。而 Status 的更新,只会导致 resourceVersion 递增,generation 保持不变。 前提是你必须在 CRD 的定义中显式启用了 status 子资源:

    //+kubebuilder:subresource:status
    type MyCRD struct {
        metav1.TypeMeta   `json:",inline"`
        metav1.ObjectMeta `json:"metadata,omitempty"`
        Spec   MyCRDSpec   `json:"spec,omitempty"`
        Status MyCRDStatus `json:"status,omitempty"`
    }
    

    如果没有启用 /status 子资源,r.Status().Update 底层会回退到全量更新对象,这会导致 generation 错误地递增,问题会更难排查。 要阻断上述死循环,最标准的第一道防线是使用 GenerationChangedPredicate。在 Controller Setup 时进行过滤:

    import "sigs.k8s.io/controller-runtime/pkg/predicate"
    func (r *MyCustomReconciler) SetupWithManager(mgr ctrl.Manager) error {
        return ctrl.NewControllerManagedBy(mgr).
            For(&myv1.MyCRD{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
            Complete(r)
    }
    

    我们翻开 GenerationChangedPredicate 的源码看看它是怎么工作的:

    func (GenerationChangedPredicate) Update(e event.UpdateEvent) bool {
        if e.ObjectOld == nil {
            return false
        }
        if e.ObjectNew == nil {
            return false
        }
        // 只有当对象的 generation 发生变化时,才允许事件进入 WorkQueue
        return e.ObjectNew.GetGeneration() != e.ObjectOld.GetGeneration()
    }
    

    通过这个拦截器,单纯的 Status 更新引发的 Watch Event,在推送到 WorkQueue 之前就会被直接丢弃。

    状态机的最终一致性:Semantic Equality

    仅仅加了 Predicate 就可以高枕无忧了吗?并非如此。 在真实的业务场景中,我们可能不仅要监听自己的 CRD,还会 Owns() 其他资源(比如 Deployment)。当底层的 Deployment 状态变化时,我们需要将其反映到 CRD 的 Status 中。此时,更新 Status 依然可能导致不必要的 API Server 写入压力。 正确的做法是,在向 API Server 发起更新请求之前,比较内存中计算出的期望 Status 与本地缓存(或者当前获取到)的 Status 是否存在语义上的差异。 这里不要用标准的 reflect.DeepEqual,因为 K8S 对象在序列化和反序列化过程中,可能会产生一些微小的不可见变化(比如空切片变 nil)。应当使用 apimachinery 提供的 apiequality.Semantic.DeepEqual

    import "k8s.io/apimachinery/pkg/api/equality"
    // ... 业务逻辑执行完毕 ...
    newStatus := myv1.MyCRDStatus{
        Phase: "Running",
        // 尽量避免将时间戳直接写在每次更新的逻辑中,除非业务状态确实发生了改变
    }
    if !equality.Semantic.DeepEqual(instance.Status, newStatus) {
        instance.Status = newStatus
        if err := r.Status().Update(ctx, &instance); err != nil {
            // 这里需要注意冲突处理,Update 可能抛出 Conflict 错误
            return ctrl.Result{}, err
        }
    }
    

    结合这两种手段:入口处用 Predicate 阻挡无效事件,出口处用 Semantic Equality 掐断无意义的写放大,才能算是一个合格的 Kubernetes 控制器。 把这段修复逻辑提交、构建、重新部署之后,看着 Grafana 上 APIServer 的 QPS 曲线像瀑布一样跌回正常水位,Pod 的 CPU 使用率回落到 5m,内存停止了泄漏。 K8S 的架构设计确实优雅,但这种优雅建立在极其严密的声明式边界之上。在 Operator 开发中,当你敲下 Update() 或者 List() 的时候,脑子里必须浮现出背后那条从 Apiserver 到 ETCD,再流转回 Informer DeltaFIFO 的漫长链路。任何对状态的无节制修改,最终都会在系统层面上成倍地偿还。

  • 容器环境下 CFS Quota 导致的 P99 延迟抖动:从内核调度机制到应对方案

    上午刚处理完核心交易链路的一个偶发性延迟问题。开发团队看着 Grafana 面板上的数据陷入了自我怀疑:容器的 CPU 使用率一直在 30% 到 40% 左右徘徊,内存也很平稳,但接口的 P99 延迟却会毫无规律地出现数百毫秒的毛刺。他们排查了 JVM GC 日志、数据库慢查询,甚至怀疑到了宿主机的物理网卡丢包上,依然毫无头绪。
    如果你在 K8s 环境下运维过高并发、对延迟敏感的微服务(尤其是多线程模型的 Java 或 Go 应用),这种现象其实并不陌生。表象是网络或代码问题,根本原因通常潜伏在 Linux 内核的 CPU 调度机制与 K8s 的资源配额(Quota)模型之间的冲突中。

    现场勘测:被掩盖的节流(Throttling)

    对于这种 CPU 使用率不高但延迟突增的场景,我的第一反应通常是去看 cgroup 的状态。进入出问题的 Pod,查看 CPU 的统计信息:

    # cat /sys/fs/cgroup/cpu/cpu.stat
    nr_periods 543210
    nr_throttled 18432
    throttled_time 943201500000
    

    数据不会撒谎。nr_throttled 表示在这 54万多个调度周期中,有 1.8 万个周期应用被内核强行“限流”了。throttled_time 的单位是纳秒,换算下来,这个容器在运行期间被硬生生按住了 943 秒不能执行任何 CPU 指令。
    在 Grafana 上看到的 40% CPU 使用率,是普罗米修斯按 15 秒或 30 秒抓取一次算出来的平均值。而在 Linux 内核微秒级的调度世界里,这个容器早就因为触碰了红线而被频繁挂起了。

    深入内核:CFS 调度器与 100ms 陷阱

    要理解这个问题,必须剥开 K8s 的 YAML,看看底层的 Linux 完全公平调度器(CFS, Completely Fair Scheduler)。
    在 K8s 中,当我们为 Pod 配置了 resources.limits.cpu 时:

    resources:
      requests:
        cpu: "1"
      limits:
        cpu: "2"
    

    这实际上在宿主机的 cgroup 中映射成了两个关键参数:
    * cpu.cfs_period_us:默认 100,000 微秒(100毫秒)。这是 CFS 统计 CPU 使用量的一个周期。
    * cpu.cfs_quota_us:由 K8s limits 计算得出。如果你限制了 2 个 CPU,这里就是 200,000 微秒(200毫秒)。
    陷阱就在这里。
    假设你的应用是一个处理高并发请求的 Java 服务,线程池里有 10 个活跃线程。当一波突发流量打过来时,这 10 个线程会被操作系统调度到 10 个物理核上并行执行。
    每个周期你总共有 200ms 的 CPU 时间配额。如果 10 个线程同时满负荷运转,仅仅需要 20毫秒(20ms * 10 = 200ms),你的容器就会把这 100ms 周期内的配额全部耗尽。
    接下来的 80毫秒 会发生什么?
    内核会无情地将这个 cgroup 下的所有进程剥夺 CPU 执行权(Throttled),直到下一个 100ms 周期到来。
    在这漫长的 80 毫秒里,你的应用处于“假死”状态。网络包已经到了网卡,但内核不会唤醒你的应用去读;外部看起来,就是一次毫无征兆的 P99 延迟飙升。而从 1 秒的宏观维度来看,20ms 运行 + 80ms 暂停,CPU 监控曲线上看到的利用率极有可能连 30% 都不到。

    历史包袱与内核缺陷

    如果你使用的是比较旧的内核版本(比如 4.19 早期或者 3.10 系列),情况会更糟。早期的 CFS 调度器在处理多核环境下的 quota 唤醒时存在设计缺陷(著名的 CFS quota bug)。由于 tg->cfs_rq 的全局时钟同步和本地时钟漂移,常常会导致不必要的限流。
    即使后来内核引入了 burst 机制,或者修复了上述时钟漂移 bug,只要你使用了 CPU Limit,这种“微观上的配额耗尽导致宏观上的延迟”的物理定律依然存在。

    应对方案与架构抉择

    明确了原理,解决起来就有了方向。上午排查完后,我给开发团队提供了以下几种调整策略:

    方案一:取消 CPU Limit (当前业界最推崇的最佳实践)

    对于延迟极度敏感的核心微服务,不要设置 CPU Limit。

    resources:
      requests:
        cpu: "2"
        memory: "4Gi"
      limits:
        # 不设置 CPU limit
        memory: "4Gi"
    

    底层逻辑: 当没有 CPU Limit 时,应用只受 cpu.shares(由 requests.cpu 控制)的影响。在宿主机 CPU 资源充足时,应用可以无限压榨空闲 CPU;在宿主机资源紧张时,内核会根据 shares 比例进行相对公平的降级分配,而绝对不会出现强制的暂停(Throttle)。
    代价: 对容量规划和调度器的要求更高。你需要确保节点不会过度超卖(Overcommit),或者结合 K8s VPA / HPA 做及时的扩容。

    方案二:调小 CFS Period

    K8s 默认将 cfs_period_us 写死为 100ms。如果你有权限修改 Kubelet 配置,可以通过开启 CustomCPUCFSQuotaPeriod 并修改 --cpu-cfs-quota-period 参数。
    如果我们将周期从 100ms 调小到 10ms,Quota 也成比例缩小。虽然应用依然会耗尽配额,但每次被挂起的时间可能从 80ms 变成了 8ms。P99 延迟的抖动幅度会显著降低。
    代价: 调度周期变短意味着系统上下文切换(Context Switch)的开销增加。这是用宿主机的整体吞吐量去换取单个容器的延迟稳定性。

    方案三:启用 CPU Manager(绑核)

    对于真正的硬核低延迟服务(如网关、高频交易组件),直接绕过 CFS Quota,走 K8s 的 CPU Manager 特性。
    在 Kubelet 开启 --cpu-manager-policy=static 后,将 Pod 的 Request 和 Limit 设为相等的整数(Guaranteed QoS):

    resources:
      requests:
        cpu: "4"
      limits:
        cpu: "4"
    

    底层逻辑: Kubelet 会在底层的 cpuset cgroup 中,将这 4 个物理核独占分配给这个容器。其他进程根本无法调度到这 4 个核上,彻底消除 CFS 调度的干扰。

    总结

    很多时候,业务开发同学容易陷入“代码没问题 = 系统没问题”的误区。在容器化和云原生时代,应用与操作系统之间隔着 cgroup、namespace 甚至虚拟化的层层黑盒。排查性能瓶颈不能仅仅停留在 JVM 层或者代码逻辑层,理解 Linux 内核底层的调度、内存管理与网络栈,是从现象看透本质的唯一路径。
    上午的问题最终通过移除该核心组件的 CPU Limit 并调整 HPA 阈值解决,P99 延迟重新拉成了一条平直的直线。这就是运维架构的魅力——在复杂的系统齿轮中,精准地找到那颗卡住的螺丝。

  • 凌晨三点的SoftIRQ风暴与一个愚蠢的内核调优

    凌晨三点半,周围安静得只能听到机房通风管道的低鸣。我刚把监控大盘上的P0级红色警报按下去,桌上的咖啡早凉透了。
    就在两个小时前,生产环境的Kubernetes集群爆发了诡异的级联故障。最先是API Gateway的P99延迟毫无预兆地飙升到了5秒以上,接着几台承载核心流量的Ingress节点相继进入 NotReady 状态。
    第一时间登录堡垒机,切到其中一台出问题的节点。敲下 top,发现用户态CPU负载极低,但 si(SoftIRQ,软中断)直接吃满了单个核心,ksoftirqd 进程赫然顶在最上面。
    网络层出问题了。
    敲下 sar -n ETCP 1,并没有看到异常规模的TCP连接重试或SYN Flood攻击。接着用 perf top 看了一下内核态的热点函数,屏幕上跳出的结果直接让我血压飙升:
    __nf_conntrack_find_get 占用了将近 60% 的CPU开销。
    看到这个函数,我心里基本有底了。连接跟踪(conntrack)在处理包时卡住了。但我很纳闷,如果是正常的并发激增导致表满,系统日志里应该会狂刷 nf_conntrack: table full, dropping packet。但我切过去看 dmesg -T,干干净净,一条丢包日志都没有。
    既然没丢包,为什么查表开销会这么大?
    顺手查了一下当前的连接数和内核参数:

    # sysctl net.netfilter.nf_conntrack_max
    net.netfilter.nf_conntrack_max = 8388608
    # cat /sys/module/nf_conntrack/parameters/hashsize
    65536
    

    看到这两个数字的瞬间,我几乎对着屏幕气笑了。这绝对是人为制造的灾难。
    查阅了一下昨天下午的工单记录,果不其然。某位同事在常规巡检时,发现日志里偶尔出现了零星的 conntrack table full 告警。为了“一劳永逸”地解决问题,这位老兄直接去网上抄了一行配置,把 nf_conntrack_max 从默认的 262144 暴力拉大到了 8388608。
    他以为他做了一次完美的性能调优,实际上他在内核里埋了一颗滴答作响的炸弹。
    为什么说这种操作愚蠢得不可原谅?只要稍微懂一点基础的数据结构,或者翻过一点内核源码就应该知道,Linux的连接跟踪机制底层维护的是一个哈希表。
    hashsize 决定了哈希桶(buckets)的数量,而 nf_conntrack_max 决定了表中能容纳的条目总数。每一个进入协议栈的数据包,都要经过这个哈希表去确认是否属于已有连接。
    当发生哈希冲突时,内核会怎么做?它会用链表把冲突的条目串起来。
    这位同事把最大条目数调到了 8388608,却根本没有去动默认的哈希桶数量(65536)。这意味着什么?
    这意味着当系统连接数真正涨上来的时候,每个哈希桶后面的链表平均长度会达到:
    8388608 / 65536 = 128
    对于高并发的网关节点,每秒钟有上百万个数据包穿过网卡。每一个数据包到达网络层,内核都要在软中断上下文中,持有自旋锁(spinlock),去遍历一条长度为 128 的链表。
    这种把 O(1) 的哈希查找,生生劣化成 O(N) 的长链表遍历的操作,直接让CPU在软中断里原地爆炸。网络包处理不过来,网卡的Ring Buffer被打满,后续的包被静默丢弃,导致TCP严重重传,Kubelet与APIServer的心跳包也发不出去,节点直接被判定为离线。
    解决这个烂摊子只需要一行命令:

    echo 2097152 > /sys/module/nf_conntrack/parameters/hashsize
    

    把哈希桶的数量拉上来,将链表长度控制在理想的 1:4 甚至 1:1 左右。敲下回车不到两秒钟,ksoftirqd 的CPU占用率断崖式下跌,集群节点的 Ready 状态陆续恢复,API的延迟曲线也重新平贴到了底线。
    故障是排除了,但这种盲目复制粘贴的运维习惯实在让人厌恶。
    技术结论其实很简单:永远不要在不理解底层数据结构的情况下,去随意拨弄内核参数。
    nf_conntrack 的调优从来不是单维度的扩容,maxhashsize 必须成比例配置。Linux内核是一台精密咬合的机器,你以为你在给它加油,实际上你往它的齿轮里倒了一把沙子。
    天快亮了,写完这份RCA(根因分析)报告,发到工作群里。希望能让一些人长点记性。

  • 凌晨两点的 DiskPressure 与消失的磁盘空间

    凌晨两点半,工位旁边的窗外连一辆车都没有,只有机房冷风机组低频的嗡嗡声透过墙壁传过来。我端着半杯已经完全冷透的咖啡,看着屏幕上飞速滚动的告警。
    手机里的 PagerDuty 刚才疯狂叫唤,生产环境 K8s 集群的核心计算节点接连报出 NodeNotReadyDiskPressure,随后就是大规模的 Pod 驱逐(Eviction)。这是一个典型的级联故障前兆。
    我登入其中一台报警的节点,习惯性地敲下 df -Th,结果并不意外:/var/lib/containerd 所在的根分区使用率 100%。
    但在敲下 du -sh /* --exclude proc --exclude sys --exclude dev 之后,有趣的事情发生了——整个文件系统所有目录加起来的真实占用,连磁盘总容量的 30% 都不到。
    磁盘满了,但文件找不到。
    对于任何一个摸过几年 Linux 的人来说,这个现象几乎是条件反射般的常识:有被删除的文件依然被进程占用着句柄(File Descriptor)。
    我顺手敲下排查命令:

    lsof +L1 | grep deleted | sort -n -k 7 | tail -n 20
    

    屏幕上齐刷刷地列出了几百个属于 containerd 的进程状态,每个后面都带着一个大大的 (deleted) 标记,占用空间从几个 G 到十几 G 不等。文件路径全都指向 /var/log/pods/
    顺藤摸瓜,我翻看了一下 /var/spool/cron/root,一行带着浓烈“聪明才智”的定时任务赫然在列:

    */10 * * * * find /var/log/pods/ -name "*.log" -type f -size +5G -exec rm -f {} \;
    

    看着这行命令,我不仅揉了揉眉心,甚至有点被气笑了。
    去问了下负责这个微服务集群的某位同事,果然,他下午发现某个业务 Pod 疯狂吐日志,导致节点磁盘告警。为了“快速解决问题”,他没有去改应用的日志级别,也没有去调整 Kubelet 的日志轮转配置,而是直接在节点上挂了个 Cronjob,暴力 rm 掉大于 5G 的日志文件。
    在这个技术点上犯错,且直接应用在生产环境,不仅是愚蠢,更是对操作系统底层原理的极度匮乏。
    在 Linux 的 VFS(虚拟文件系统)机制中,文件是以 Inode(索引节点)和 Block(数据块)两部分存在的。当我们在终端里执行 rm 命令时,底层调用的是 unlink() 系统调用。这个操作仅仅是删除了文件在目录项(dentry)里的名字,并把 Inode 的硬链接数(i_nlink)减一。
    但是,要想真正释放磁盘空间,Linux 内核有两个硬性条件:
    1. 文件的硬链接数为 0。
    2. 没有任何进程打开这个文件(即 i_count 为 0)。
    在这个场景下,容器引擎(containerd/dockerd)正在源源不断地把容器的标准输出流(stdout/stderr)写入这些 .log 文件。进程手里死死攥着这个文件的 File Descriptor。你用 rm 把文件名字扬了,骗过了 lsdu,但只要容器不重启,写入操作就不会停,数据块依旧被占用,而且由于目录项被删除,这些空间变成了名副其实的“暗物质”。
    最终的结果就是:节点磁盘被这些看不见的文件彻底撑爆,触发 Kubelet 的硬驱逐阈值(evictionHard: imagefs.available<15%),导致节点上正常运行的其他业务 Pod 被无辜连坐,集群出现雪崩。
    解决眼下的问题不难。由于原文件已经被 rm,用 > file 或者 truncate 清空已经不可能了,唯一的办法只能是让持有句柄的进程释放它们。
    我写了个简单的脚本,把那些挂载了被删文件的容器找出来,通过 API 删掉 Pod 重建;对于实在太多的节点,挑着业务低谷期直接重启了 containerd 服务:

    systemctl restart containerd
    

    看着 df -h 里的使用率瞬间从 100% 跌回 22%,警报终于解除。
    在这个行业里,遇到日志打满磁盘不是什么罕见事,但解决问题的方式决定了系统的稳定性。如果非要手动干预,正确的做法是用 truncate

    truncate -s 0 /var/log/pods/namespace_pod_uid/container/*.log
    

    这不仅清空了文件内容,释放了 Block,同时也保留了 Inode 和 File Descriptor,容器运行时完全无感,可以继续正常写入。
    但从架构的视角来看,任何需要登入节点去手动清日志的设计都是不合格的。根本的解决路径,要么在 Kubelet 的配置里把门焊死:

    # /var/lib/kubelet/config.yaml
    containerLogMaxSize: "500Mi"
    containerLogMaxFiles: 3
    

    要么在应用层引入成熟的日志收集与轮转机制(如 Filebeat/Fluent-bit 配合 Logrotate)。
    凌晨三点一刻,集群状态全绿。我关掉终端,合上电脑。
    不要总试图用外挂脚本去对抗操作系统的底层逻辑。在 Linux 面前耍小聪明,内核迟早会在你睡得最熟的深夜,狠狠给你一巴掌。

  • 容器化MySQL的隐秘杀手:Cgroup内存阈值与Page Cache回写风暴

    凌晨三点,机房所在园区的路灯已经熄了大半。我刚关掉终端里的几个排查窗口,顺手把工单状态改成了“已解决”。
    这是一个潜伏了半个多月的幽灵故障。业务端反馈,迁移到K8S环境后的MySQL集群,偶尔会出现极端的P99延迟毛刺。平时毫秒级的响应,一天里总有那么几次会突然飙升到1秒甚至2秒以上。此时QPS并没有明显波动,慢查询日志里也只是一些普通的更新语句。
    很多时候,容器化数据库的性能问题,答案往往不在数据库本身,而藏在操作系统内核与容器隔离机制的夹缝里。

    现场还原与初步排查

    接到问题后,我首先看了一眼Prometheus监控。在延迟飙升的那个时间点,CPU使用率平稳,但iowait有一个短暂的尖峰。到底层宿主机看,NVMe SSD的iostat显示await并不高,说明磁盘硬件本身没有遇到瓶颈。
    MySQL的配置是老规矩:

    innodb_flush_log_at_trx_commit = 1
    sync_binlog = 1
    innodb_flush_method = O_DIRECT
    

    数据文件走O_DIRECT绕过了内核Page Cache,直接落盘;redo log和binlog虽然用了双1,但也只是每次事务提交时fsync。按理说,这种配置下的IO行为应该是平滑且可预测的。
    为了抓取现场,我写了一个简单的bpftrace脚本,挂载在宿主机上,专门盯防MySQL进程在内核态的延迟。既然怀疑是IO阻塞,那就直接看进程因为什么内核函数被挂起。

    # 抓取 mysqld 进程阻塞超过 50ms 的内核调用栈
    bpftrace -e '
    tracepoint:sched:sched_switch
    /args->prev_comm == "mysqld"/
    {
        @start[args->prev_pid] = nsecs;
    }
    tracepoint:sched:sched_switch
    /args->next_comm == "mysqld" && @start[args->next_pid]/
    {
        $dur = nsecs - @start[args->next_pid];
        if ($dur > 50000000) {
            @[kstack] = count();
        }
        delete(@start[args->next_pid]);
    }
    '
    

    蹲守了几个小时,毛刺再次出现时,终端打印出了一段非常关键的内核栈:

    @[
        io_schedule+65
        bit_wait_io+17
        __wait_on_bit+91
        wait_on_page_bit+132
        shrink_page_list+2233
        shrink_inactive_list+548
        shrink_node_memcg+1002
        shrink_node+192
        do_try_to_free_pages+253
        try_to_free_mem_cgroup_pages+268
        try_charge+567
        mem_cgroup_try_charge+123
        __add_to_page_cache_locked+165
        add_to_page_cache_lru+76
        generic_file_buffered_read+522
        ...
    ]: 12
    

    看到shrink_node_memcgtry_charge,案件的轮廓已经清晰了。这是典型的Cgroup内存达到上限,触发了同步的直接内存回收(Direct Reclaim)。

    深入底层:Cgroup v1 与 Page Cache 的错位

    在传统的物理机或虚拟机上,Linux内核通过vm.dirty_ratiovm.dirty_background_ratio来控制Page Cache的脏页回写。当系统整体脏页达到后台阈值时,kflushd线程开始异步刷盘;达到硬限制时,触发写操作的进程才会被阻塞,执行同步刷盘(balance_dirty_pages_ratelimited)。
    但Cgroup v1在设计上有一个致命缺陷:它没有隔离脏页回写的阈值。
    系统级别的dirty_ratio是对整个宿主机物理内存生效的。假设宿主机有256GB内存,Pod的Limit限制为16GB。
    对于宿主机来说,脏页远没有达到触发后台异步回写的阈值。
    但对于这个16GB的Pod来说,它内部的进程(比如MySQL写binlog,因为binlog走的是Buffered IO,会产生Page Cache)不断产生脏页,这些脏页被记在Pod的memory.usage_in_bytes账上。
    当Pod的内存使用量逼近16GB的Limit时,由于宿主机的脏页阈值没到,后台并没有去主动清理属于这个Pod的脏页。直到Pod内存触顶,内核别无选择,只能在MySQL进程申请内存(或者写入新缓存页)的上下文中,当场触发Direct Reclaim
    如果回收的是干净的页还好,但如果全是脏页,MySQL进程就必须同步等待这些脏页刷入磁盘。这就是为什么拥有强悍NVMe硬盘的系统,依然会因为内核的调度逻辑产生长达数秒的延迟毛刺。

    破局与最佳实践

    定位到根因后,解决路径就明确了。这不是调优几下MySQL参数就能解决的,需要从系统资源分配与内核机制入手。
    方案一:为Buffer IO预留充足的水位线(当前采用的快速止血方案)
    之前这个Pod的资源配置是 Request=Limit=16GB,而 innodb_buffer_pool_size 设为了 12GB(占了75%)。加上MySQL内部的其他内存开销,留给OS作为Page Cache的空间极小。
    我修改了MySQL的配置,将BP降到10GB,强行在Pod Limit内留出更大的空隙,降低直接回收触发的频率;同时结合K8s环境,调整了宿主机的系统参数,把基于百分比的脏页控制改为绝对字节数:

    # sysctl.conf
    vm.dirty_background_bytes = 134217728  # 128MB
    vm.dirty_bytes = 536870912             # 512MB
    

    强迫宿主机内核更勤快地在后台刷脏页,防止单个Pod攒下过多脏页。
    方案二:升级 Cgroup v2(根本解法)
    Cgroup v1中 memory hierarchy 和 blkio hierarchy 是分离的,这导致了Page Cache隔离的一系列烂摊子。而Cgroup v2采用了统一层级(Unified Hierarchy),内核终于能够正确感知到特定Cgroup下的脏页比例,并且实现了基于Cgroup的异步脏页回写(cgroup writeback)。
    如果基础设施允许,将宿主机OS升级到支持Cgroup v2的版本(Kernel 4.20+ 开始完善,5.x时代成熟),并在K8S节点开启Cgroup v2支持,这种“隐秘的毛刺”将彻底从底层被消除。

    结语

    容器技术给应用的部署和编排带来了极大的便利,但也用一层虚拟的边界遮蔽了操作系统的本来面目。在这个边界之上,一切看起来都符合逻辑;但在边界之下,内核的机制和容器的配额常常在暗中角力。
    排查这类问题没有捷径可走。只看应用的日志,或者只看监控面板上的平均水位,永远无法触及真相。唯一的办法就是沉下心来,带着工具潜入内核的调用栈,去看CPU指令在那些微秒和纳秒级的瞬间,到底在等待什么。
    时间不早了,喝完杯底凉掉的咖啡,今天就到这里。有些复杂的架构问题,明天天亮再继续。

  • 记一次被“性能优化”背刺的深夜排障:别把十年前的 sysctl 当成银弹

    凌晨两点半,连着敲了几个小时键盘,屏幕的光刺得眼睛发酸。刚才的一小时里,生产环境的 K8s 集群经历了一场诡异的局部断网。
    告警邮件像雪花一样飞进邮箱:API Gateway 报 504 Gateway Timeout,Ingress Controller 侧出现大量连接超时。诡异的是,这并不是全局瘫痪,而是处于一种“薛定谔的连通”状态——同一个客户端发起的请求,上个请求秒回,下个请求就一直卡到超时。
    起初排查方向定在网络插件上。我登上出问题的 Node 节点,看了一下 Calico 的状态,一切正常。接着去抓包:

    tcpdump -i any -nn tcp and port 8080
    

    抓包结果显示了一个极其经典的现象:Client 端(在这个场景里是 Ingress Pod)发出了 SYN 握手包,后端的业务 Pod 所在的 Node 网卡明明收到了这个 SYN,但就是不回 SYN-ACK。内核直接把包悄无声息地吞了。
    如果不是防火墙拦截,能让 Linux 内核在收到 SYN 时不响应、也不发 RST 的情况并不多。全连接队列满了?查了一下 ss -lnt,并没有溢出。半连接队列满了?netstat -s | grep "SYNs to LISTEN" 也没有异常增长。
    直觉告诉我,得看看网络协议栈的丢包统计。

    nstat -az | grep PAWS
    

    输出的结果让人心梗:TcpExtPAWSActive 这个计数器正在以每秒几十次的速度疯狂飙升。
    PAWS(Protection Against Wrapped Sequence numbers)被触发了。为什么会触发 PAWS?我顺手敲了一条命令:

    sysctl -a | egrep 'tcp_tw_recycle|tcp_timestamps'
    

    屏幕上赫然显示:
    net.ipv4.tcp_tw_recycle = 1
    net.ipv4.tcp_timestamps = 1
    破案了。
    我翻了一下配置管理的 Git 提交记录,就在今天下午,某位研发同事为了“优化高并发下的 TIME_WAIT 过多问题”,向运维提了个 MR,在 Ansible 剧本里加上了这行 net.ipv4.tcp_tw_recycle = 1,并在晚高峰前推到了全网节点。他甚至还在提交注释里贴了一篇 2014 年的 CSDN 博客链接。
    这就是典型的“知其然不知其所以然”导致的灾难。在单机裸奔的年代,开启 tcp_tw_recycle 确实能快速回收 TIME_WAIT 状态的套接字。但在这个充斥着 NAT 和容器的微服务时代,这个参数就是一枚定时炸弹。
    技术原理解析:为什么在 K8s 中绝不能开启 tcp_tw_recycle?
    tcp_tw_recycletcp_timestamps 同时开启时,Linux 内核会启用一种被称为 per-host 的 PAWS 机制。内核会记录每个来源 IP 地址最后一次发来数据包的 TCP 时间戳(Timestamp)。如果同一个来源 IP 发来的新 SYN 包,其时间戳小于内核记录的该 IP 的上一次时间戳,内核就会认为这是一个重放的旧包,直接丢弃。
    在普通的直连网络里,这没问题,因为同一台机器的 CPU 时钟是单调递增的。但是,K8s 里的流量是经过 NAT 的。
    当外部请求通过 NodePort 进来,或者在集群内部通过 Service IP(kube-proxy 的 iptables/IPVS 规则)进行转发时,通常会发生 SNAT(源地址转换)。这就导致后端的业务 Node 看来,所有的请求都来自同一个 IP(比如网关 Node 的 IP,或者 Flannel/Calico 的网段网关 IP)。
    然而,这些请求实际上是由成百上千个不同的真实的 Client 发出的。每个 Client 的系统启动时间不同,它们生成的 TCP 时间戳自然也不同,毫无顺序可言。
    结果就是:后端 Node 刚收到 Client A(时间戳 10000)的请求并记录下来,紧接着收到了 Client B(时间戳 5000)的请求。因为它们经过 SNAT 后源 IP 一样,内核一看,时间戳居然倒退了(5000 < 10000),于是默默把 Client B 的 SYN 包丢进黑洞。Client B 只能干等超时,而业务代码里连个报错的影子都看不到。
    解决过程毫无波澜,直接通过 DaemonSet 刷了一波内核参数,关闭它:

    sysctl -w net.ipv4.tcp_tw_recycle=0
    

    敲下回车,几秒钟后,监控大盘上的红色断崖式下跌,504 归零,世界清静了。
    面对 TIME_WAIT 焦虑症,很多人喜欢在网上乱搜“高并发 Linux 内核优化大全”,然后不加思索地把那堆参数一把梭。但技术这东西,脱离了架构拓扑谈参数,就是耍流氓。事实上,因为 tcp_tw_recycle 在 NAT 环境下的表现太糟糕,Linux Kernel 社区在 4.12 版本中已经彻底将其废弃移除了。如果你们的宿主机内核还在用 3.10(比如某著名的 CentOS 7),请对这些老旧的参数保持最起码的敬畏心。
    真正合理的 TIME_WAIT 优化,绝不是靠暴力的 recycle 机制,而是开启 tcp_tw_reuse(只对发起连接的客户端有效),调大 tcp_max_tw_buckets,或者更彻底一点——在应用层用好连接池,开启 HTTP Keep-Alive,从源头上减少短连接的创建。
    系统调优不是背书,哪怕是抄,也请先搞清楚你的网络拓扑里有没有 NAT。关了终端,收工。

  • 凌晨三点半的CPU限流惨案:别再无脑设 limits.cpu 了

    凌晨三点半。咖啡早就凉了,屏幕的冷光打在脸上,我刚把这次线上故障的RCA(根因分析)报告发进团队群。
    整个排查过程其实不到半小时,但看着故障群里之前的讨论记录,我觉得有必要把这件事记录下来。不是为了指责谁,而是因为在2024年的今天,还在同一个底层逻辑上翻车,确实让人感到遗憾。
    事情的起因是午夜时分的一波流量小高峰。核心API网关的P99延迟平时稳定在50ms以内,今晚却毫无规律地飙升到了2秒,甚至偶尔出现超时。
    我被电话叫醒,登进VPN,看了一眼群里的排查进度。
    某位同事的推断逻辑是这样的:“节点CPU利用率不到20%,数据库活跃连接数正常,网络IO没满。P99延迟这么高,肯定是Calico CNI底层丢包了,或者是宿主机网卡队列出了问题。”
    紧接着,他的操作是:把网关的Pod副本数从10个直接扩容到了40个。
    结果?延迟依然像心电图一样剧烈抖动,毫无改善。
    这种病急乱投医的操作,在没有任何确凿证据支撑的情况下直接把锅甩给网络层,甚至试图用横向扩容来掩盖单点性能问题,是排查系统瓶颈时的大忌。
    我没有理会群里的猜测,直接挑了一台网关Pod所在的宿主机,敲下了几个基础命令。
    先看系统层面的负载:

    top -b -n 1 | grep "Cpu(s)"
    

    %Cpu(s): 15.2 us, 3.1 sy, 0.0 ni, 80.5 id, 0.1 wa
    宿主机确实很闲。
    再拿到那个Pod的容器ID,直接去看cgroup的统计数据:

    # 假设是 cgroup v1 环境,QoS 为 Burstable
    cat /sys/fs/cgroup/cpu/kubepods.slice/kubepods-burstable.slice/pod//cpu.stat
    

    输出结果刺眼得像夜里的远光灯:

    nr_periods 154320
    nr_throttled 128450
    throttled_time 9485230000000
    

    这就是真相。nr_throttled 的比例高得离谱,容器经历了极其严重的 CPU Throttling(限流)。
    我去翻了一眼这个网关服务的Deployment YAML,看到了一段经典的配置:

    resources:
      requests:
        cpu: "500m"
        memory: "1Gi"
      limits:
        cpu: "1"
        memory: "1Gi"
    

    就是这个 limits.cpu: "1" 酿成了今晚的惨案。
    为什么说在这个技术点上犯错是不可原谅的?因为只要你真正理解Linux内核的CFS(完全公平调度器)配额机制,就知道把一个高并发的Golang微服务硬限制在1个CPU核心上,无异于给一辆跑车装上限速器,还指望它能跑赢比赛。
    在K8S中,limits.cpu: "1" 到底意味着什么?
    它在底层的转换是:设置 cpu.cfs_period_us 为 100000(100毫秒),设置 cpu.cfs_quota_us 为 100000(100毫秒)。意思是,在这个100毫秒的周期内,这个容器最多只能使用100毫秒的CPU时间。
    听起来很合理,对吧?100毫秒配额对100毫秒周期,刚好是1个核。
    但是,网关是Golang写的,而且这位同事并没有在镜像里配置 uber-go/automaxprocs。这意味着Go runtime在启动时,会读取宿主机的物理核心数来设置 GOMAXPROCS。假设宿主机是32核,Go就会默默拉起32个P(Processor)和对应的OS线程来处理并发请求。
    当一波并发请求打过来,32个线程同时被唤醒工作。
    100毫秒的CPU配额,被32个线程同时消耗,需要多久用完?
    100 / 32 = 3.125 毫秒。
    也就是说,在每一个100毫秒的时间窗口里,这个Pod只工作了3毫秒,然后配额耗尽。接下来的97毫秒里,Pod内的所有线程被内核调度器强行挂起(Throttled),处于完全假死状态。
    这97毫秒的停顿,反映在监控上,就是P99延迟的剧烈毛刺。反映在调用链上,就是各种莫名的超时。
    宿主机的CPU明明空闲着80%,但你的Pod却在痛苦地窒息。你就算扩容到100个副本,只要流量稍微一集中,每个Pod依然会在自己的100毫秒周期里反复上演“工作3毫秒,休眠97毫秒”的闹剧。
    解决办法简单粗暴,两行操作:
    1. 删掉 limits.cpu
    2. 确保 requests.cpu 设置合理,用于调度时的容量规划。
    应用更新后,P99延迟瞬间回落到平滑的20ms,一切恢复死寂。
    技术结论:
    在分布式计算资源管理中,不要把 Kubernetes 当成魔法黑盒,它所有的资源隔离最终都要落实到 Linux Kernel 的 cgroup 机制上。对于延迟敏感型、且基于多线程/协程模型的服务(如 Java, Golang),盲目设置过低的 limits.cpu 是性能杀手。保障此类服务稳定性的最佳实践是:基于 requests 保证基础算力,移除 limits 允许CPU突发(Burst),并辅以 HPA 进行弹性伸缩,而不是试图用僵化的静态上限去锁死一个高并发系统。

  • 凌晨两点的IO风暴:谈谈那个写进Crontab的drop_caches

    凌晨2点40分。刚把几台Kafka Broker所在的物理机节点恢复,顺手掐掉了手机里还在每隔5分钟响一次的PagerDuty报警。盯着屏幕上终于降回10ms以内的P99延迟曲线,我点了一根烟(虽然只是在脑子里点了一下,毕竟机房和家里都禁烟)。
    这种深夜档的突发故障,通常不是因为底层组件出了什么史诗级的Bug,而是大概率有人在白天埋了颗雷,到了夜里流量低谷或者某种特定触发条件下,轰然炸响。
    但今天这个雷,不仅蠢,而且蠢得毫无技术底线。
    故障发生在1点15分。监控大盘上一路飘红,几个核心Topic的消费延迟瞬间飙升到2秒以上,紧接着这几个Broker节点的CPU iowait 毫无征兆地拉到了 80% 以上。
    我登进其中一台Broker,敲下第一条命令:

    iostat -xz 1
    

    屏幕上跳动的数据很吓人:数据盘的 %util 死死卡在 100%,await 突破了 3000ms,而且全是读IO。
    Kafka在正常消费堆积情况下的确会有磁盘读,但这几个核心Topic的消费者都是实时tail读,数据按理说应该全在内存(Page Cache)里,命中率极高,根本不可能产生这么恐怖的底层物理磁盘随机读。
    我接着敲了第二条命令看内存:

    free -g
    

    输出结果让我以为自己连错了机器:

                  total        used        free      shared  buff/cache   available
    Mem:            251          30         219           0           2         219
    Swap:             0           0           0
    

    一台256G内存的物理机,Kafka JVM堆只分了30G,这没问题。但这 buff/cache 只有区区 2G?剩下的 219G 全是 free
    系统内核的Page Cache就像是被什么东西给活生生抽干了。没有发生OOM(dmesg -T | grep -i oom 毫无动静),没有异常进程吃内存。这意味着有人或者有程序在主动释放缓存。
    我本能地扫了一眼定时任务和系统日志:

    grep CRON /var/log/syslog | tail -n 10
    

    屏幕上赫然出现了一行让我血压飙升的日志:

    Oct 24 01:15:01 broker-03 CRON[12435]: (root) CMD (echo 3 > /proc/sys/vm/drop_caches)
    

    顺藤摸瓜,我在 /etc/cron.d/ 下找到了一段新鲜配置。通过堡垒机操作审计发现,这是白天某位刚接手这块监控体系的同事用 Ansible 批量推上去的。
    我大概能猜到他的脑回路:他在Grafana上看到这批机器的“内存使用率”长期处于 95% 以上,觉得系统要崩了,Kafka要OOM了,于是自作聪明地搞了个脚本,每隔一段时间强行清空一次缓存,美其名曰“释放系统资源”。
    在这个技术点上犯错,是不可原谅的。这已经不是经验问题,而是对 Linux 操作系统和中间件底层运行机制的彻底无知。
    任何一个稍微深入过架构的人都应该知道,Linux的内存哲学就是“Free RAM is wasted RAM”(空闲的内存就是浪费)。内核会极度贪婪地将所有空闲内存用来缓存文件系统的数据(VFS Page Cache)。
    更致命的是,Kafka 的高性能架构,命脉恰恰就系在这个 Page Cache 上。
    Kafka 不像传统数据库那样自己维护庞大的 Buffer Pool,它直接将数据写入操作系统的文件系统,并完全依赖内核的 Page Cache 来进行读写加速。当消费者实时拉取数据时,Kafka 会调用内核的 sendfile() 系统调用。
    这是一个典型的 Zero-Copy(零拷贝)过程:

    ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
    

    在底层,数据直接从 Page Cache 拷贝到网卡驱动的 Scatter/Gather 缓冲区,全程不需要 CPU 参与数据在用户态和内核态之间的来回搬运。这就是为什么 Kafka 能在单机跑出几GB/s吞吐量的原因。
    但是,当你敲下 echo 3 > /proc/sys/vm/drop_caches 的那一瞬间,你把整个架构的基石砸得粉碎。
    缓存被强行清空后,所有消费者的读取请求瞬间引发海量的 Major Page Fault(硬缺页中断)。内核发现数据不在内存里,只能被迫将请求下发给底层的块存储设备。如果是几千个消费者并发读,这就相当于瞬间向磁盘发起了数以万计的随机读取指令。
    磁盘的 Queue Depth 瞬间被打满,IO 发生严重阻塞。Kafka 的网络线程在处理读请求时被死死卡在等待磁盘返回数据的系统调用上,进而导致请求队列积压,连接超时,最终导致了从底层OS到上层业务的全面雪崩。
    把 Page Cache 当成系统负担,看到剩余内存少就去清空,这是一种极其危险的“Windows XP 时代使用清理大师”的思维。
    我把那个愚蠢的 Cronjob 彻底删掉,随后静静地看着 free -g 里的 buff/cache 指标随着时间推移,从 2G 慢慢爬升到 150G 以上。磁盘的 await 降回了微秒级,世界重新恢复了清净。
    技术结论留在这里:
    在现代基于 Linux 的服务端架构中,监控和告警的内存指标永远不应该看总量使用率(Used = Total – Free)。正确的做法是监控 MemAvailable(在 Prometheus node_exporter 中对应 node_memory_MemAvailable_bytes)。Available 才是系统在不发生 Swap 交换的情况下,真正能挪出来给新进程使用的内存总量。
    不要去对抗内核的内存管理机制。当你觉得自己比 Linus Torvalds 更懂得如何分配物理内存时,你大概率正在制造一个 P0 级的灾难。

  • 深度解析:基于 Netfilter 源码剖析 K8S 环境下 DNS 5秒超时的底层逻辑

    凌晨两点半,办公室安静得只剩下机房排风扇隐约的低频共振。刚把一个长期潜伏在核心链路里的幽灵问题揪出来,趁着思路还清晰,把排查过程和底层逻辑沉淀下来。
    问题表现很简单但极其折磨人:高并发的微服务集群中,业务监控偶尔会出现长尾请求,耗时精准地落在 5 秒出头。应用日志里没有任何异常报错,只是单纯的慢。
    遇到这种特征极为明显的“5秒超时”,我的第一反应通常是网络协议栈底层的机制问题。由于微服务大量依赖 Kubernetes 内部的 CoreDNS 进行服务发现,这个 5 秒,大概率与 Linux 系统的 DNS 解析机制和 K8S 的网络模型脱不了干系。

    现场抓包与症状剥离

    在业务 Pod 所在的 Node 节点上,直接用 tcpdump 抓取对应的 DNS 报文:

    tcpdump -i any udp port 53 -nn -w dns_trace.pcap
    

    分析抓包文件,看到了经典的一幕:
    1. 00.000000 业务 Pod 向 CoreDNS 发起 A 记录和 AAAA 记录解析请求。
    2. 00.000000 两个 UDP 报文使用了相同的源端口(Ephemeral Port)。
    3. 05.000512 业务 Pod 重新发起请求。
    4. 05.000831 CoreDNS 正常返回响应。
    为什么是 5 秒?这是 Linux 下 glibc 库中 resolv 模块的默认超时时间。
    为什么丢包?这就得潜入到 Linux Kernel 的 netfilter 机制里去寻找答案。

    潜入深水区:Conntrack 的并发竞争

    在 K8S 环境中,Pod 访问 CoreDNS 的 ClusterIP,必然要经过 kube-proxy 维护的 iptablesIPVS 规则,进行 DNAT/SNAT 转换。而无论是哪种模式,底层都严重依赖 Linux 内核的连接跟踪模块:nf_conntrack
    UDP 是无连接的,但 netfilter 为了实现 NAT,依然会为 UDP 维护一个虚拟的连接状态表(Conntrack Table)。
    问题就出在 glibc 并发发送 A 和 AAAA 记录请求的设计上。这两个 UDP 报文在毫秒级的时间内从同一个 Socket(同源 IP、同源端口)发出。当它们到达宿主机内核的网络栈时,会触发如下流程:
    1. 报文进入 nf_conntrack_in:内核发现这是一个新的 UDP “连接”,为其创建一个非确认状态的 conntrack 表项(unconfirmed tuple)。
    2. 经过 NAT 模块:如果涉及到跨节点或者特定网络插件,报文会进行源地址或目的地址转换。
    3. 调用 __nf_conntrack_confirm:在报文离开本机(POSTROUTING 链之后),内核尝试将刚才创建的表项正式插入到全局的 Conntrack Hash 表中。
    此时,内核源码 net/netfilter/nf_conntrack_core.c 中的隐患被触发了:

    /* net/netfilter/nf_conntrack_core.c (部分逻辑简化) */
    int __nf_conntrack_confirm(struct sk_buff *skb)
    {
        struct nf_conn *ct;
        struct nf_conntrack_tuple_hash *h;
        // ...
        // 计算 Hash 桶并加锁
        spin_lock_bh(&nf_conntrack_locks[hash]);
        // 核心竞争点:检查全局表中是否已经存在相同的 Tuple
        if (unlikely(nf_conntrack_tuple_taken(&ct->tuplehash[IP_CT_DIR_REPLY].tuple, net))) {
            // 如果冲突,直接跳转到丢包逻辑
            goto out;
        }
        // 正常插入 Hash 表
        __nf_conntrack_hash_insert(ct, hash, reply_hash);
        // ...
    out:
        spin_unlock_bh(&nf_conntrack_locks[hash]);
        // 隐式丢包,不返回 ICMP,应用层毫无察觉
        nf_ct_drop_unconfirmed(ct);
        return NF_DROP;
    }
    

    当 A 和 AAAA 两个报文在多个 CPU 核心上并行处理时,第一个报文成功 confirm 并插入了全局 Hash 表。第二个报文在执行 nf_conntrack_tuple_taken 检查时,发现期望的五元组(Tuple)已经被第一个报文占用了(因为它们源端口相同,经过 NAT 后的端口也极大可能冲突)。
    结果就是:内核判定状态冲突,默默将第二个报文 NF_DROP。应用层的 glibc 傻傻地等不到 AAAA 记录的响应,直到 5 秒后触发重试。
    可以通过查看节点的 conntrack 统计数据来验证这个推论:

    cat /proc/net/stat/nf_conntrack | awk '{print $8}' | grep -v insert_failed
    

    如果发现 insert_failed 的计数在不断增加,基本就能坐实这个问题。

    架构与配置级的规避方案

    解决这个问题,不能指望去改内核的 conntrack 锁机制,那会引发更严重的性能退化。我们需要从架构和配置层面进行规避。
    方案一:应用层规避(治标)
    在 Pod 的部署配置中,修改 DNS 解析的行为。强制 glibc 顺序发送 A 和 AAAA 请求,或者强制使用不同的源端口。
    在 Kubernetes 的 Deployment 中注入 dnsConfig

    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: core-service
    spec:
      template:
        spec:
          containers:
          # ...
          dnsConfig:
            options:
              - name: single-request-reopen
    

    single-request-reopen 会让 glibc 在发送 A 和 AAAA 请求时,使用不同的 Socket(即不同的源端口),从而完美避开 conntrack 的 Tuple 冲突。
    方案二:架构层规避(治本)
    如果集群规模庞大,修改所有业务的 Yaml 显然不现实且容易遗漏。根本的解决之道是减少或彻底消除 DNS 请求在宿主机上的 NAT 行为。
    引入 NodeLocal DNSCache。这已经是当前大规模 K8S 集群的标配架构。
    通过在每个 Node 上运行一个 DaemonSet 级别的 DNS 缓存组件,并监听一个本地的 Link-local IP(如 169.254.20.10)。Pod 的 DNS 请求会直接发往这个本地 IP。
    1. 本地请求不经过 kube-proxy 的全局 NAT 规则,避免了跨节点的 SNAT/DNAT 转换。
    2. 即使发生 A 和 AAAA 的并发,由于不再经过复杂的 NAT 引擎,conntrack 冲突的概率被降到最低(甚至可以通过 iptables NOTRACK 直接绕过连接跟踪)。
    对应的 iptables 规则通常会被 NodeLocal DNS 自动配置如下:

    iptables -t raw -I PREROUTING -d 169.254.20.10/32 -p udp -m udp --dport 53 -j CT --notrack
    iptables -t raw -I OUTPUT -d 169.254.20.10/32 -p udp -m udp --dport 53 -j CT --notrack
    

    绕过 conntrack--notrack)是处理高并发无状态 UDP 报文最优雅的底层手段。

    结语

    很多时候,排查高并发系统下的偶发问题,就像在迷宫里摸黑拼图。监控面板上的一个毛刺,背后往往隐藏着内核机制、网络协议栈与容器架构的复杂碰撞。
    不要畏惧底层代码,当所有表象都无法解释时,源码往往是最后也是最诚实的答案。
    该合上笔记本了。重启了几个核心节点的组件,看着监控大盘上的 P99 延迟曲线平滑地降到了 10ms 以内,今晚的活儿算干完了。