标签: 容器安全

  • 深入 Falco 规则雪崩排查:高频 Syscall 拦截引发的 eBPF RingBuffer 溢出与 Node 假死实战

    排查过程中最让人血压升高的,往往不是底层的内核 Bug,而是安全策略的“盲目自信”。近期处理了一起严重的生产事故:某高吞吐的 Kafka 与 Elasticsearch 混合部署集群,在安全团队下发新版容器运行时合规规则后,多台 Node 节点相继出现 Load Average 飙升至 200+,Kubelet 心跳超时导致节点变成 NotReady,业务大面积断流。

    一句话总结排查结论:安全工程师在 Falco 规则中写了一个监听 writepwrite64 系统调用的规则,但漏掉了针对文件路径的宏过滤(Macro Filter)。这导致底层 eBPF 探针毫无节制地拦截节点上每秒数十万次的高频 I/O 系统调用,海量事件瞬间撑爆 eBPF Perf Ring Buffer,引发严重的 CPU Sys 态占用与 Soft Lockup,最终饿死 Kubelet 进程。

    防御性安全加固是必须的,但脱离业务压测的规则下发,本质上就是对生产环境的自杀式 DDoS。

    案发现场:系统态 CPU 的狂欢

    监控系统发出刺耳的告警,Kafka 集群的 P99 延迟从 10ms 飙升到了 5000ms。切到终端,尝试 SSH 登录故障 Node,光是建立连接就卡了近十秒。

    好不容易敲下 top 命令,看到的数据令人极度不适:

    %Cpu(s):  5.2 us, 88.4 sy,  0.0 ni,  1.1 id,  0.2 wa,  0.0 hi,  5.1 si,  0.0 st
    Load average: 214.35, 180.12, 110.45
    

    用户态(us)CPU 只有 5%,而系统态(sy)竟然高达 88%,软中断(si)也有 5%。这说明 CPU 根本没在处理业务逻辑,全在内核态里打转。

    pidstat -p ALL 1 查看具体是谁在消耗 CPU,排在第一的是 falco 进程,单进程跑满了约 400% 的 CPU(4核),但这还不足以解释整个 64 核宿主机的瘫痪。

    真正致命的信息藏在 dmesg 里:

    [ 3451.123456] NMI watchdog: BUG: soft lockup - CPU#12 stuck for 22s! [java:14562]
    [ 3451.123470] RIP: 0010:bpf_prog_3a2b1c4d_falco_sys_enter+0x124/0x500
    [ 3451.123485] Call Trace:
    [ 3451.123490]  <TASK>
    [ 3451.123492]  trace_call_bpf+0x9a/0x150
    [ 3451.123495]  perf_trace_sys_enter+0x140/0x200
    [ 3451.123500]  syscall_trace_enter.constprop.0+0x1a8/0x220
    [ 3451.123505]  do_syscall_64+0x15/0x80
    

    内核调用栈清晰地指明了真凶:bpf_prog_..._falco_sys_enter。Kafka 进程(Java)在发起系统调用时,被 Falco 的 eBPF 程序钩住,由于处理逻辑极其繁重,直接触发了 CPU 软锁死(Soft Lockup)。

    与此同时,查看 Falco 自身的日志: {"level":"warning","msg":"Falco internal: drop event. Total drops: 850392019"} 系统正在以每秒数百万的量级丢弃事件。

    抽丝剥茧:愚蠢的规则与 eBPF 的阿喀琉斯之踵

    为了快速恢复业务,第一反应是直接介入现场,执行 systemctl stop falcokubectl delete ds falco -n falco。拔掉这个“安全探针”后,节点 Load 瞬间掉回个位数,Kafka 恢复正常。

    业务稳住了,接下来就是扒安全团队的“底裤”。调出引发故障的那个自定义规则配置文件 falco_rules.local.yaml,找到了罪魁祸首:

    - rule: Detect Suspicious File Modifications
      desc: Monitor write operations to system directories
      condition: >
        evt.type in (write, pwrite64, pwritev) 
        and container.id != host 
        and proc.name != "fluentd"
      output: "Suspicious write detected (user=%user.name file=%fd.name)"
      priority: WARNING
    

    看懂了吗?写规则的人忘记加上目标目录的限制。 他们的本意可能是监控对 /etc/bin 的写操作,但由于漏掉了类似 fd.name startswith "/etc/" 的前置过滤宏,这条规则的语义变成了:拦截所有容器内除 fluentd 外的任意写操作

    要理解为什么这会导致系统雪崩,必须弄懂 Falco eBPF 探针的工作原理。

    Falco 的架构分为内核态的 eBPF 探针和用户态的规则引擎。内核探针挂载在 raw_tracepoint/sys_entersys_exit 上。 当 Falco 启动时,它会解析所有启用的规则,提取出需要监听的系统调用类型(Syscall ID)。一旦任何一条规则声明了对 write 系统调用的监听,eBPF 探针就会在内核中对全局所有的 write 操作进行上下文采集。

    在这个 Kafka/ES 集群中,I/O 是极其密集的。

    1. 每次 write,eBPF 程序都会被触发。

    2. eBPF 需要从内核空间提取进程信息、文件描述符、参数,打包成事件结构体。

    3. 将事件推入 Perf Ring Buffer 或 BPF Ringbuf。

    4. 如果用户态 Falco 引擎处理速度跟不上,Ring Buffer 就会满。

    5. Buffer 满后,eBPF 程序内部自旋锁或丢弃逻辑会产生极大的 CPU 开销,严重拉长了 write 系统调用的耗时。

    原本一个只需几微秒的内核写操作,被强行注入了高昂的 eBPF 观测开销。海量的事件上下文切换不仅榨干了 CPU Sys 资源,更直接饿死了 Kubelet 的 PLEG(Pod Lifecycle Event Generator)循环,导致集群管控面认为节点宕机,开始触发 Pod 驱逐,最终演变成全局雪崩。

    修复与避坑:防御性观测的底线

    事后复盘,我给安全团队划定了三条绝对不可触碰的红线:

    1. 绝对禁止无限制拦截高频 I/O 系统调用。 像 read, write, recvfrom, sendto, epoll_wait 这些系统调用,在任何生产环境的运行时安全监控中,除非在 eBPF 层面有极度严苛的 In-Kernel 过滤机制,否则坚决不能放入全局监控规则中。

    2. 修正规则语义,利用内核态过滤。 如果非要监控关键文件的修改,不应该去 Hook 宽泛的 write。更好的方式是使用 openat 系统调用配合 O_TRUNC / O_RDWR 标志位,或者利用 eBPF 的 LSM(Linux Security Modules)钩子针对特定 inode 进行监控。 修改后的合理规则应该尽量将条件收敛: yaml condition: > open_write and container and fd.name startswith "/etc/"

    3. 容器安全探针必须做资源硬隔离。 Falco DaemonSet 必须配置严格的 CPU Limit 和优先级。如果它的性能跟不上,宁可让它 OOM 或被 Cgroup 限制,也不能让它拖垮整台宿主机的内核栈。

    排查清单:Falco/eBPF 性能雪崩同类问题速查

    如果你怀疑容器安全组件引发了性能问题,请按以下步骤快速确认:

    1. CPU 态势观测:使用 topmpstat 观察 sy(系统态)指标,如果 sy 持续异常偏高(>50%),且 wa(I/O等待)不高,大概率是系统调用被大量 Hook 或频繁陷入内核态引发。

    2. 内核阻塞确认:通过 dmesg -T | grep -i "soft lockup" 检查是否出现 CPU 死锁。如果 Call Trace 中包含 bpf_prog_trace_call_bpfperf_trace_sys_enter,直接锁定 eBPF 探针。

    3. Drop 指标核对:检查 Falco 或其他安全探针的 Metrics / 日志,搜索 Total dropsRing buffer full 关键字。事件大量丢弃是规则过于宽泛的铁证。

    4. 探针快速止血:情况危急时,不要花时间分析规则,直接停止安全组件服务(systemctl stop 或删减 DS),若系统负载在 5 秒内骤降恢复,即可定性为该组件引发的故障。

    安全不仅要防范外部的黑客,更要防范内部失控的代码。在操作系统底层,哪怕是一行看似无害的过滤遗漏,也会在流量洪峰下化作摧毁整个集群的核弹。

  • 深入 Seccomp 白名单陷阱排查:glibc 升级引发的 clone3 拦截与容器无限 CrashLoop 实战

    某次核心业务重构,开发团队将基础镜像从 CentOS 7 迁移至 Debian 11 后,发布到生产环境的全量 Pod 瞬间陷入 CrashLoopBackOff。业务端一口咬定是 K8S 底层挂载卷权限配置错误,因为 Java 应用日志仅留下一句干瘪的 java.io.IOException: Cannot run program: error=1, Operation not permitted,随后进程崩溃。

    最终结论直接抛出:这是一起典型的容器运行时安全策略(Seccomp)误杀事件。新版镜像内置的 glibc >= 2.34 默认启用了 clone3 系统调用,而集群默认下发的自定义 Seccomp 白名单(Profile)严重老化,未包含此 Syscall。内核按照策略配置返回 EPERM(Operation not permitted),导致进程创建直接被拒。

    解决方案:在 Seccomp Profile 中补齐 clone3(435)及 faccessat2(439),或在预发环境先将 defaultAction 降级为 SCMP_ACT_LOG 进行系统调用采样。

    案发现场:被误导的权限报错

    排查过程中,开发拿着 Operation not permitted 的日志找过来,要求运维检查容器的 runAsUser 和 Volume 的 fsGroup

    这是一个非常经典的排查误区。在 Linux 系统中,EPERM 错误码(Error Number 1)不仅代表文件系统权限不足,当进程触发了被内核拦截的动作(如被 Seccomp、AppArmor 或 SELinux 阻断)时,同样会抛出这个错误。

    盲目去排查文件系统权限纯粹是浪费时间。容器起不来,且报底层权限错误,直接下钻到 Node 节点看内核审计日志。

    执行以下命令:

    # 在 Pod 所在宿主机上直接抓取 Seccomp 审计日志
    ausearch -m SECCOMP --just-one
    # 如果没有 auditd,直接看内核环形缓冲区
    dmesg -T | grep -i seccomp
    

    立刻拿到实锤证据:

    audit: type=1326 audit(1690000000.123:4567): auid=4294967295 uid=1000 gid=1000 ses=4294967295 subj=unconfined pid=123456 comm="java" exe="/opt/java/bin/java" sig=0 arch=c000003e syscall=435 compat=0 ip=0x7f8a9b123456 code=0x50000
    

    拨云见日:Syscall 435 的身世

    看懂上面这行内核日志,是搞容器安全的基操。 核心关注三个字段:

    1. type=1326:标准的安全计算模式(Seccomp)拦截事件。

    2. arch=c000003e:表示当前架构是 x86_64(AUDIT_ARCH_X86_64)。

    3. syscall=435:被拦截的具体系统调用号。

    不知道 435 是什么?用 ausyscall 查一下:

    $ ausyscall x86_64 435
    clone3
    

    为什么仅仅升级了基础镜像,就会突然触发 clone3 拦截?

    底层原理在于:在 Linux 5.3 内核中,引入了更具扩展性的 clone3 系统调用来替代传统的 clone/fork/vfork。而从 glibc 2.34 开始,标准库的进程创建封装函数(如 posix_spawn)默认优先尝试调用 clone3。如果在容器内执行了类似 Runtime.getRuntime().exec() 的代码,底层就会触发该系统调用。

    当时集群下发的 Seccomp Profile 还是两年前从某个 GitHub 仓库 Copy 下来的“业界最佳实践”。这个陈旧的白名单里只有 clone,压根没有 clone3

    当未在白名单中的 Syscall 被触发时,由于策略的默认行为被设置为 SCMP_ACT_ERRNO

    {
      "defaultAction": "SCMP_ACT_ERRNO",
      "architectures": [
        "SCMP_ARCH_X86_64"
      ],
      "syscalls": [ ... ]
    }
    

    内核不会杀掉进程(如果是 SCMP_ACT_KILL,应用会直接收到 SIGSYS 信号并 Dump Core,反而好查),而是向调用方返回一个 errno,默认就是 EPERM。这就是业务层看到 Operation not permitted 的直接原因。

    治本之策:防御性安全与可观测性

    安全加固决不能以牺牲可用性为代价。直接在生产环境强推严苛的 Seccomp 白名单,无异于在高速公路上埋地雷。

    正确的修复与落地路径:

    1. 更新 Seccomp 规则:在 syscalls 列表中补齐现代 glibc 常用的系统调用。除了 clone3,通常还需要加上 faccessat2(439)和 statx(332),这几个是基础镜像升级引发血案的常客。
    {
      "names": [
        "clone3",
        "faccessat2",
        "statx"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
    
    1. 灰度与审计模式(Audit Mode): 任何新的 Seccomp 规则上线前,必须先将 defaultAction 设置为 SCMP_ACT_LOG。这样内核只会记录审计日志,而不会真正阻断请求,跑一周后回收日志,确认无误再切回 SCMP_ACT_ERRNO

    2. 引入 Falco 规则引擎监控: 靠人工看 dmesg 是低效的。应当利用 Falco 这类基于 eBPF 的运行时安全工具,将 Seccomp 和内核事件提取为结构化告警。 编写一段简单的 Falco 规则,专门捕捉异常进程创建:

    - rule: Unexpected Syscall Attempt (Seccomp missing)
      desc: Detect processes trying to use modern syscalls blocked by legacy seccomp profiles
      condition: >
        evt.type=syscall and evt.dir=< and evt.rawres=-1 
        and (evt.arg.error=EPERM or evt.arg.error=ENOSYS)
      output: >
        Syscall blocked (user=%user.name pod=%k8s.pod.name container=%container.name syscall=%evt.type)
      priority: WARNING
    

    一旦有被拦截的系统调用,SRE 团队能第一时间收到告警,而不是等业务反馈“我的 Pod 起不来了,存储坏了”。

    同类问题速查排查清单

    1. 确认安全组件拦截层级:看到 EPERM / Operation not permitted,首查 dmesg/audit。确认是 Seccomp (Syscall 阻断) 还是 AppArmor/SELinux (MAC 文件/能力阻断)。

    2. 翻译 Syscall 号:拿到 syscall=XXX 后,必须结合 arch 使用 ausyscall 进行转译。不同架构下同一个编号代表的系统调用完全不同。

    3. 查验 glibc/内核版本匹配度:应用基础镜像升级跨度较大时(如 Alpine 3.12 -> 3.16,或 Debian 10 -> 12),大概率会引入新的系统调用封装,需提前审核 Seccomp 白名单。

    4. 抓取 Core Dump 判断:若应用直接闪退且退出码为 159,通常是被触发了 SIGSYS (Bad system call)。这说明 Seccomp 配置成了 SCMP_ACT_KILL,检查 dmesg 即可破案。