ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

容器微服务安全监控:eBPF事件驱动捕获亚秒级运行时事件

容器微服务安全监控:eBPF事件驱动捕获亚秒级运行时事件 凌晨两点被电话叫醒说某台节点上有异常行为告警结果打开监控面板——CPU 曲线平的内存曲线平的进程数曲线也是平的。翻日志那个容器在 47 分钟前就已经退出了镜像还在现场没了。这就是容器和微服务时代做安全监控最憋屈的地方容器和微服务的快速启停使得传统监控工具几乎不可能捕捉到瞬时安全事件。这不是工具不够好是整套采集模型的假设变了。这篇文章我想把这件事彻底讲透为什么传统监控会漏漏在哪一层以及怎么用事件驱动的方式把亚秒级的运行时行为抓下来。适合做平台、SRE、云原生安全、以及正在把业务往容器上迁的同学看。文中会给出可复现的部署片段、规则写法、关联代码和排查表也能直接当落地参考。1. 传统监控为什么抓不住秒级的安全事件1.1 生命周期从月压缩到秒采样模型直接失效我们先把问题量化一下不然容易被监控不够强这种模糊结论带偏。传统主机时代的进程存活周期通常以天甚至月计。在这种前提下Prometheus 每 15 秒或 30 秒抓一次指标完全没有问题——被监控对象是长期稳定的采样点之间的空窗无所谓因为对象不会消失。容器把这件事彻底改了。一次 CI/CD 触发的构建任务、一个定时清理脚本、一次 Job 型的批处理容器从拉起、执行、到退出的整个过程可能只有几百毫秒到几秒。极端一点某些编排场景下 Pod 的完整生命周期不到 500 毫秒。用采样定理的直觉来算一笔账一个存活 2 秒的容器如果抓取间隔是 15 秒那么单次抓取命中它的概率大约是 2/15也就是 13% 左右。假设每天集群里有 5000 次这种短命容器启动看起来应该能碰到 650 次但问题是——碰到的那一次抓到的只是一个瞬时快照你看到的是这个容器当时 CPU 用了 3%而不是它执行了哪条命令、打开了哪个文件、连了哪个地址。安全事件的判定几乎 100% 依赖行为序列而不是资源数值。这就是第一个致命点采样模型即使碰巧命中拿到的信息量也不足以定性。1.2 三个被悄悄打破的默认假设我把传统监控内建的假设列成表对比一下容器时代的现实会更直观。默认假设主机时代的表现容器与微服务下的现实直接后果被监控对象是稳定的固定 IP、固定主机名、进程长期存在Pod IP 每次重建都变Service 背后是一组随时变化的 Endpoint容器名带随机后缀告警无法归因历史数据无法关联日志天然落盘可查应用写本地文件日志轮转保留应用写 stdout由日志驱动接管容器一删默认什么都不剩事后取证时现场已经消失有时间慢慢分析事件发生后可以上机排查进程树、内存、句柄容器销毁后进程树、内存映射、打开的文件描述符全部随之消失响应窗口被压缩到秒级第三个假设的打破最要命。主机上发现异常进程你可以strace、可以 dump 内存、可以看/proc/pid下的所有细节。容器里等你登录上去那个进程大概率已经不存在了甚至整个 Pod 已经被 ReplicaSet 的滚动更新替换掉了。我见过不止一个团队在这里吃过亏告警是通过宿主机上残留的审计日志发现的但去追的时候容器已经重建了两轮container_id全变了根本串不起来。1.3 瞬时安全事件到底长什么样讲抽象概念没用我列几类我们在实际环境里反复遇到的形态你可以对照自己的集群看看有没有类似的行为第一类是短命进程执行。一个进程在几百毫秒内被拉起执行完一串命令立刻退出。父进程可能是某个长期运行的服务进程中间还夹着一次 fork/exec 跳转。这类行为在主机时代几乎必然留下痕迹在容器里如果只靠指标采集基本无感。第二类是临时性权限提升或异常挂载尝试。表现为容器内出现对挂载点、设备文件、内核接口的访问尝试失败后进程迅速退出不留持久化痕迹。第三类是敏感文件短读。进程打开配置文件、凭据文件、令牌目录读取内容后立刻关闭并退出行为本身不产生持续的资源占用。第四类是异常外联的短连接。容器内向某个地址发起连接完成一次极短的数据交互后断开。这类行为在连接数指标上可能只体现为一个瞬间的凸起甚至完全被平均掉。第五类是资源异常占用型进程的瞬态运行。比如典型的算力占用程序启动后跑几十秒被 OOM Killer 干掉或者自己因为环境不适配退出。指标上看是一段被截断的尖峰日志里什么都没有。第六类属于编排层的异常。短时间内大量 Pod 被创建又删除或者同一个镜像在多个命名空间被批量拉起。这类事件的单次持续时间可能是秒级靠 Prometheus 抓kube-state-metrics的 15 秒快照很容易整段错过。这些事件的共同特征就一句话存活窗口小于采集周期。我们在内部管它叫亚采样周期事件。理解了这一点后面的架构选择就顺理成章了。2. 技术拆解把抓不到拆成四个可解的子问题2.1 从四个维度定位真正的瓶颈不要一上手就换工具。先看清楚传统方案到底在哪几个维度上失分。我把它拆成时长、粒度、上下文、留存四层。维度传统方案的默认做法瞬时安全事件的真实要求时长周期性轮询间隔 15s 到 60s事件驱动产生即上报延迟要求百毫秒级粒度进程级、主机级指标系统调用级、文件与网络行为级上下文以主机为归属单元必须关联到 container_id、Pod、Namespace、镜像 digest留存本地日志轮转3 到 7 天高写入吞吐、低成本、可按镜像和容器快速检索时长问题靠把轮询调快解决是不现实的。你把抓取间隔从 15 秒压到 1 秒采集端 CPU 和存储成本会指数级上升而且 1 秒仍然抓不到 800 毫秒的进程这是方向性错误。真正的解法是把采集从拉改成推内核里发生了execve立刻产生一条事件产生了连接立刻产生一条事件。没有事件就没有开销有了事件就是完整的不存在错过采样点这回事。2.2 采集侧选型为什么最后落到内核态可观测性我把几个常见候选方案放在一起对比这是我们在选型阶段真实做过的表。方案原理对短命进程的捕获能力相对开销主要局限容器运行时事件接口订阅运行时的生命周期事件流只能看到容器起停看不到容器内行为极低无法覆盖内部进程、文件、网络行为主机审计子系统内核审计框架按规则记录系统调用能抓到 execve 类事件高高并发下容易丢事件规则粒度粗事件缺少容器身份信息指标采集器周期性读取 cgroup 与系统统计几乎抓不到亚采样周期对象低模型本身不匹配无法定性应用侧链路埋点应用内 SDK 上报只能覆盖应用自身逻辑中对非应用进程、异常进程完全无效内核态追踪eBPF在内核挂载追踪点事件驱动上报强可到系统调用与文件路径级别可控可按需过滤依赖内核版本与 BTF 支持结论很清晰以内核态追踪eBPF为主干用运行时事件和编排层事件做上下文拼图。选它有三个理由。第一它是事件驱动的天然解决了时长维度的问题。第二它挂在内核里无论进程用什么语言写、是不是你部署的应用只要发生系统调用就会被看到这对异常进程场景是决定性的。第三它可以从任务结构里直接读到 cgroup 信息进而换算出 container_id这样事件天生带容器身份不用事后拼接。需要提醒的是eBPF 的能力跟内核版本强相关。老内核比如 4.x 早期很多特性和 BTF 支持都不完整选型时一定要先确认你的节点内核矩阵。这个坑我在第 4 节会展开。2.3 分层架构采集、关联、存储、处置各管一段确定了采集主干之后整体架构可以按四层来设计每层职责要分清不然后期会互相耦合。采集层是每个节点上一个常驻组件负责在内核侧过滤、富化、直出事件。这里有个关键设计原则不要在节点本地落盘。容器删了本地日志就没了事件必须走流式管道直接推给下一跳。关联层负责把裸的系统调用事件和 Kubernetes 元数据做 join。这一层是价值放大最明显的地方——同样的execve事件如果只写某进程执行了某命令价值极低如果写成命名空间 A 下 Pod B镜像是 Cdigest 是 D执行了某命令父进程是 E这条事件才真正可用。存储层按热短温长设计热数据留 7 天支撑实时排查温数据留 90 天支撑回溯和合规。索引至少要在 container_id、镜像 digest、命名空间三个维度上建好因为事后追查基本都是顺着这三个维度串的。处置层是最后一环命中高危规则后可以触发隔离、快照、通知。这里我强烈建议早期只做通知和快照不要直接上自动阻断原因在 4.2 节会讲。3. 实操落地搭一条能抓住瞬时事件的链路3.1 先算容量再选参数别拍脑袋配资源这一步最容易被跳过但配错了要么丢事件要么资源浪费一倍。先估事件速率。在集群里挑一台典型业务节点用一段临时脚本统计 30 秒内的系统调用量就能拿到数量级。我们当时的实测是一台跑 60 个 Pod 的 16 核节点峰值大约每秒钟产生 6000 到 9000 个系统调用事件。粗算一下带宽如果全量事件不做过滤直接外发按每条原始事件约 200 字节算单节点每秒就是 1.2 到 1.8 MB200 个节点就是 240 到 360 MB/s。这个量级直接落库是不现实的成本会失控。所以必须坚持采全、存精的原则过滤动作放在内核侧完成只有命中规则的事件才被送到后端。实际过滤后的事件量通常能降到原始量的千分之一到百分之一200 个节点大约 0.3 到 3 MB/s这就完全在可控范围内了。再算环形缓冲区。内核态采集组件通常给每个 CPU 分配一个独立的环形缓冲区避免多核之间的锁竞争。按 16 核节点、每个缓冲区配置在几 MB 量级估算整体内存占用大约在几百 MB实际数值请以你所选组件的官方文档为准因为不同版本的预设差异比较大。这个内存是必须给的缓冲区开小了在高突发时会直接丢事件而且是静默丢非常难发现。3.2 采集端部署DaemonSet 的几个关键配置采集组件要以 DaemonSet 形式部署到每个节点。下面是我们精简后的部署片段重点看那几个决定能不能看到全部进程的配置。apiVersion: apps/v1 kind: DaemonSet metadata: name: runtime-sensor namespace: security spec: selector: matchLabels: app: runtime-sensor template: metadata: labels: app: runtime-sensor spec: hostPID: true hostNetwork: true tolerations: - operator: Exists containers: - name: sensor image: registry.internal/security/runtime-sensor:1.9.0 securityContext: privileged: true resources: requests: cpu: 300m memory: 384Mi limits: cpu: 1 memory: 1Gi volumeMounts: - name: host-proc mountPath: /host/proc readOnly: true - name: sensor-config mountPath: /etc/sensor readOnly: true volumes: - name: host-proc hostPath: path: /proc - name: sensor-config configMap: name: sensor-config三个点必须解释清楚不然你照抄了也不知道为什么。hostPID: true是为了让采集组件能看到宿主机命名空间下的全部进程。不开这个它只能看到自己所在 Pod 的进程那采集范围就废了。privileged: true是因为加载 eBPF 程序、读取内核追踪点需要较宽的能力集。理论上可以用更细粒度的 capability 组合比如只给追踪相关的几项但不同内核版本、不同容器运行时对能力集的支持差异很大兼容性调起来非常耗时间。我的建议是先用 privileged 把链路跑通验证有效之后再逐步收权不要一上来就为了最小权限卡在部署阶段。挂载/proc为只读是必需项采集组件需要读宿主机的进程信息做富化但它绝不应该有写权限。3.3 规则设计把瞬时翻译成可判定特征这是整条链路里最需要经验的部分。核心思路是单条系统调用本身几乎不构成告警要把多条弱信号组合成强特征。我总结了四个可用的判定维度。时间维度上父子进程的启动时间差、进程从execve到退出的存活时长都是很强的信号。正常业务进程不会在 300 毫秒内走完 fork、exec、退出这一套。终端维度上容器内出现带 TTY 的交互式 shell这在生产环境里几乎总是异常。业务容器不应该有人为打开的交互终端。身份维度上镜像白名单、命名空间白名单、ServiceAccount 白名单这三个是最有效的降噪手段。内容维度上命令行的异常特征比如超长的 base64 串、编码过的执行片段、异常的解释器组合某个进程直接拉起一个解释器并传入编码字符串这些都是高价值信号。下面是一组规则示例语法是 Falco 风格你换成其他引擎思路是一样的。- rule: Transient Interactive Shell In Container desc: 容器内出现生命周期极短的交互式 shell condition: spawned_process and container and proc.tty ! 0 and proc.name in (bash, sh, dash, zsh, ash) and not k8s.ns.name in (debug, ci-runner) and not proc.pname in (crond, supervisord, tini) output: transient_shell ns%k8s.ns.name pod%k8s.pod.name image%container.image.repository digest%container.image.digest cmd%proc.cmdline parent%proc.pname priority: WARNING tags: [container, shell, transient] - rule: Sensitive Path Read By Short Lived Process desc: 短命进程读取凭据类路径 condition: open_read and container and fd.name startswith /var/run/secrets and not proc.name in (kubelet, vault-agent, sidecar-injector) output: secret_read pod%k8s.pod.name image%container.image.repository file%fd.name cmd%proc.cmdline priority: NOTICE tags: [container, credential, transient]写完规则之后存活时长这一层不要指望规则引擎自己搞定。它擅长匹配单事件的属性不擅长做跨事件的时间配对。这一层的正确做法是交给关联层用一个带时间窗的流式处理器把execve和exit事件按(节点, pid, 进程启动时间)配对算出真实存活毫秒数。下面是我写的一个最小可用版本Python 实现可以直接嵌进你的消费端。import json from collections import OrderedDict WINDOW_MS 3000 # 判定为瞬时的存活时长阈值 EVICT_FACTOR 3 # 清理水位避免内存无限增长 class TransientCorrelator: 把 exec/exit 事件按 pid 配对找出存活时间低于阈值的进程。 def __init__(self, window_msWINDOW_MS): self.window window_ms self.buf OrderedDict() def _key(self, evt): # 用 (节点, pid, 进程启动时间) 做键避免 pid 复用导致的错配 return (evt[node], evt[pid], evt[proc_start_ms]) def _evict(self, now_ms): while self.buf: key, val next(iter(self.buf.items())) if now_ms - val[time_ms] self.window * EVICT_FACTOR: self.buf.pop(key) else: break def on_exec(self, evt): self._evict(evt[time_ms]) self.buf[self._key(evt)] evt def on_exit(self, evt): self._evict(evt[time_ms]) start self.buf.pop(self._key(evt), None) if start is None: return None alive_ms evt[time_ms] - start[time_ms] if alive_ms self.window: return None return { alive_ms: alive_ms, cmd: start[cmdline], pod: start[pod], ns: start[ns], image: start[image], node: start[node], } if __name__ __main__: corr TransientCorrelator() # 假设 events 是从消息队列消费出来的事件流 for raw in consume_events(): evt json.loads(raw) if evt[type] exec: corr.on_exec(evt) elif evt[type] exit: hit corr.on_exit(evt) if hit: print([瞬时应答], json.dumps(hit, ensure_asciiFalse))这段代码有两个细节值得说。用(pid, 进程启动时间)而不是单独用pid做键是因为 pid 会被内核复用短时间窗内如果只按 pid 配对很可能把 A 进程的 exit 事件错配到 B 进程的 exec 上产生完全虚假的告警。清理水位设为窗口的三倍是为了兼顾内存和乱序到达因为分布式环境下事件顺序不保证严格。3.4 验证环节自己造一个瞬时事件看它能不能被抓住链路搭完一定要做靶场验证否则你不知道到底是真没有事件还是事件被静默丢了。最简单的验证方式是用一个循环快速起停容器for i in $(seq 1 20); do kubectl run transient-probe-$i \ --imagebusybox:1.36 \ --restartNever \ --command -- sh -c head -c 64 /proc/self/status /dev/null; sleep 0.2 done跑完之后去后端检索验收标准有四条事件条数应该是 20 条一条不少每条的alive_ms应该落在 200 到 400 毫秒区间每条都能正确归因到对应的 Pod 名和命名空间采集组件的丢包指标应该保持为零。如果条数不对先别怀疑规则去看丢包指标八成是缓冲区不够。这个验证我建议做成常态化的定期巡检因为内核升级、采集组件版本更新都可能悄悄改变行为。另外提一句如果你们的业务在迁移上云之后要用高并发压测来验证承载能力比如用 JMeter 压一轮记得把压测窗口和采集组件的资源曲线对照着看。压测期间事件速率会显著上升正好可以检验采集链路的余量够不够。4. 常见问题与排查技巧实录4.1 事件收不到按顺序查这五个地方排障最怕乱试我整理了一个固定顺序按这个走基本能定位到。第一步查内核能力。确认节点上/sys/kernel/btf/vmlinux是否存在这个文件是 BTF 信息载体。如果不存在说明内核对现代追踪方式支持不完整要么升级内核要么回退到较老的采集模式功能会打折。第二步查 cgroup 版本。cgroup v1 和 v2 下容器 ID 的解析路径完全不同采集组件如果只适配了一种在混合集群里就会出现部分节点完全没事件的现象。混合集群建议分批统一。第三步查丢包指标。采集组件一般会暴露类似syscall_event_drops_total的计数器这个值只要在涨就说明缓冲区不够调大缓冲区大小并适当增加每 CPU 的分配量。第四步查权限。hostPID和privileged如果没生效比如被某个准入策略改写了采集组件启动不会报错但只能看到自己现象是事件量极少但不为零很有迷惑性。第五步查时间对齐。检查所有节点的时间同步状态这个坑下一小节单独讲。4.2 误报太多白名单要宽进严出阻断要慢误报是这类系统最大的杀手。我见过太多团队因为第一天上了阻断策略第二天把生产环境搞出事故然后整个项目被叫停。我的建议是分三个阶段推进。第一阶段只记录不告警跑满两周把所有事件拉出来人工归类看清楚自己集群的真实基线长什么样。第二阶段开告警但不阻断用三层白名单收敛镜像白名单、命名空间白名单、行为白名单注意白名单要宽进严出宁可多留一点噪声也不要漏掉真实信号。第三阶段才对极少数高危规则开启自动动作而且要先在小范围命名空间灰度。再做一层时间窗抑制同一个 Pod、同一个镜像在 5 分钟内出现同类事件只报第一条。这一招能砍掉大部分重复噪声。4.3 性能开销用实测数据说话不要凭感觉这类采集组件最常被质疑的就是开销。我把我们一次实测记录贴出来注意这只是数量级参考你的实际情况跟内核版本、事件速率、规则复杂度都有关。节点规格业务 Pod 数峰值事件速率采集组件 CPU采集组件内存丢包率8 核 16G约 253000 事件/秒0.15 核约 320 MB016 核 32G约 608000 事件/秒0.35 核约 480 MB032 核 64G约 13018000 事件/秒0.8 核约 700 MB0可以看出来CPU 开销跟事件速率基本线性内存主要吃在缓冲区上。resource limit 里给 1 核和 1G 是个比较安全的配置能扛住突发。4.4 三个我踩过的坑希望你别再踩坑一时间戳用了墙钟导致事件时间倒挂。采集组件在容器内运行时如果事件时间戳取的是墙钟时间节点之间 NTP 有几十毫秒漂移关联层做时间窗配对时就会出现负的时间差算出来的alive_ms是负数。正确做法是事件里同时带单调时钟的相对时间和节点墙钟配对用单调时钟展示用墙钟。坑二把采集组件本身变成了最大的风险点。一个 privileged、hostPID、hostNetwork 的 DaemonSet如果镜像被替换后果不堪设想。我们后来做了三件事给采集组件单独划定节点池或者至少单独命名空间加严格的 NetworkPolicy只允许它连后端镜像用 digest 固定并开启签名校验挂载全部只读。坑三日志走本地文件容器一删全没了。早期我们为了省事让采集组件把事件写节点本地文件再收集。结果发现短命容器退出后相关的元数据文件已经被清掉了事件在本地文件里变成了一堆没有归属的裸记录。改成直接 stdout 走流式管道之后问题消失。这个教训很直白在容器世界里任何依赖本地持久化的设计都要重新审视一遍。4.5 常见问题速查表现象可能原因排查动作处理某些节点完全没有事件内核不支持或 BTF 缺失检查/sys/kernel/btf/vmlinux升级内核或切换采集模式事件量明显低于预期hostPID未生效检查 Pod spec 与准入策略修正配置确认权限未被改写丢包计数持续增长环形缓冲区过小查看丢包指标曲线调大缓冲区与每 CPU 分配有事件但无法归因到 Pod元数据关联失败检查 cgroup 解析路径统一 cgroup 版本或补适配告警量爆炸白名单缺失、无抑制拉取事件做基线分析三层白名单 时间窗抑制关联结果时间差为负节点时钟漂移检查时间同步状态统一时间源改用单调时钟容器已退出但事件后到消息队列积压查看消费端 lag扩容消费者下调处理耗时5. 影响范围与防御侧配套抓住事件之后做什么5.1 这件事会波及哪些团队很多人以为运行时监控是安全团队一家的事实际上它的影响面比想象中宽。安全团队是最直接的受益方告警来源从边界和主机扩展到了运行时行为能看到的攻击面明显变大。但同时也要承担规则运营的长期成本规则不是写完就完了需要跟着业务变化持续维护。平台和 SRE 团队要额外承担采集组件的运维责任包括内核兼容矩阵的维护、资源配额的预留、以及组件本身的升级节奏控制。这一块的隐性工作量不小立项的时候要算进去。研发团队会被要求提供镜像白名单和行为基线。这个诉求一开始通常会有阻力因为研发会觉得多了一堆事实际做法是把白名单这件事尽量自动化比如从 CI 阶段自动生成镜像清单而不是让研发手工填表。合规侧会关注运行时事件的留存周期。90 天的温数据留存是个比较常见的起点具体周期看你们所在行业的实际要求。5.2 从源头减少瞬时事件让攻击面本身变小监控是看得见但还有一半工作是把可疑行为本身变少。这两件事要一起做只做监控会很累。第一给容器配 seccomp profile优先用运行时默认档案它对常见的高风险系统调用做了限制。业务特殊需求再单独定制不要图省事设成不受限。第二容器一律非 root 运行根文件系统设为只读能力集全部丢弃。只读根文件系统这一条能挡掉大量写入后执行的行为模式成本几乎为零。第三做镜像签名和准入控制。用准入控制器在 Pod 创建阶段校验镜像来源没签名的直接拒绝。这一步做好了很多从头构建的恶意镜像根本进不来。第四网络策略默认拒绝只放通必要的出入方向。短连接外联的行为一旦网络层就走不通就不需要应用层再去判断了。第五短命任务用 Job 而不是长期 Deployment 去跑让短命变成一种显式的、可追踪的状态而不是藏在某个服务进程里的偶发行为。5.3 从抓到到闭环事件之后的三步抓到事件只是起点完整的闭环需要三步。第一步是告警分级。把事件分成通知级、警告级、严重级不同级别对应不同的响应动作和响应时限。不要所有事件都按最高级别处理那样响应团队会疲劳。第二步是在容器销毁之前做取证快照。这是我认为最有价值的一步。命中高危规则时立刻对目标容器执行一组采集动作导出当前进程列表、打开的文件描述符、网络连接状态、容器镜像的 digest 与配置。这些信息如果在容器退出后才去取就永远取不到了。实现上可以做成一个轻量的响应组件收到高危事件后立即在对应节点上执行快照把结果打包上传。第三步是自动隔离与复盘。隔离要谨慎建议只对明确的高危场景开启而且要支持快速解除。每一次隔离都应该产生一条复盘记录用来反哺规则优化——哪些规则一直不触发可以考虑下线哪些规则误报率高需要调整条件。我个人在实际操作中的体会是这套东西最难的不是技术选型而是规则运营的耐心。刚上线那一两周你会觉得效果惊人抓到一堆平时看不到的行为第三周开始抱怨误报多一个月后如果没人管规则就烂在那儿了。真正能跑下去的团队都是把它当成一个需要持续投入的产品而不是一次性的项目。最后再分享一个小技巧把每次真实排查的案例都回灌成一条规则或者一条白名单一年下来你会得到一套跟自己业务高度贴合、外面买不到的检测体系。
返回列表