news 2026/9/30 6:18:59

seccomp 系统调用过滤:从 BPF 原理到容器白名单落地与排障

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
seccomp 系统调用过滤:从 BPF 原理到容器白名单落地与排障

1. seccomp 到底解决了什么问题

第一次在生产环境里被 seccomp 绊倒,是因为一个跑在容器里的服务莫名其妙开始返回 EPERM,日志里连堆栈都没有。折腾了半天才发现,是运行时的默认 seccomp 配置把我们内部一个依赖unshare做隔离的小模块给卡死了。那次之后我才认真把 seccomp 从头到尾捋了一遍——它不是那种"配一下就行"的东西,你如果不知道它在哪一层拦截、拦截之后表现成什么样子,排查成本会高得离谱。

seccomp 是 Linux 内核提供的一套系统调用过滤机制,全称 secure computing mode。它的核心思路非常朴素:把一个进程能发起的系统调用限制在一个明确的集合里,出了集合的调用要么被拒绝,要么被直接干掉进程。系统调用是用户态程序和内核打交道的唯一入口,把入口收窄,等于把一个被攻破的进程能做的事情砍到最低限度。所以它最常见的使用场景是沙箱、容器运行时、浏览器渲染进程、代码执行服务这类"代码不完全可信"的地方。

这篇内容适合三类人看:一是在做容器、沙箱、Serverless 这类隔离基础设施,需要给运行时加一层防护的工程师;二是遇到容器里系统调用被拒、想搞清楚到底是谁在拦的开发;三是单纯想理解 Linux 安全机制怎么从内核原语一步步落到生产配置上的技术爱好者。我会从手写第一段 BPF 过滤器开始,到生产环境怎么画像、怎么切白名单、怎么灰度上线,再到踩过的坑和排查手册,一路写下来。全程是实操向,能直接抄的部分我会给完整代码。

要强调的是,seccomp 不是万能钥匙。它只约束系统调用这一个维度,管不了文件权限、管不了网络策略、管不了资源耗尽。它更像一道门槛很低的闸机,装起来不费事,但装错了会把自己的腿夹住。

2. 动手之前必须搞清楚的底层机制

2.1 三种模式,其实常用的只有一种

内核里的 seccomp 一共有三种运作模式,通过prctl或者seccomp系统调用切换。

严格模式(SECCOMP_MODE_STRICT)是最早的形态,限制极其粗暴:进程只能调用read、write、_exit和sigreturn这四个。注意是_exit而不是exit_group,多线程程序在里面连正常退出都费劲。这个模式基本只适合做极小型的计算沙箱,实际生产里很少直接用。

过滤模式(SECCOMP_MODE_FILTER)才是真正被广泛使用的那一个。它允许你挂一个 BPF 程序上去,每次系统调用发生前,内核会先跑一遍这个程序,根据返回值决定放行、拒绝还是杀掉进程。这个 BPF 是"经典 BPF"(cBPF),不是现在 eBPF 那一套,指令集简单得多,但限制也更多。

日志模式严格来说不算独立模式,而是过滤模式的一种动作——你可以让某个系统调用照常执行,但往内核审计日志里记一笔。这个在灰度阶段极其有用,后面会专门讲。

切换模式的时候有个绕不开的前置条件:加载过滤器要求当前线程设置no_new_privs,或者持有CAP_SYS_ADMIN。绝大多数场景下我们选前者,因为它不需要特权,普通用户也能用。no_new_privs一旦设置就不可撤销,它的含义是"这个进程及其后代在执行execve之后,不能获得比现在更多的权限"——也就是说 setuid 二进制对它们失效了。这个语义正好和 seccomp 配套,因为如果进程能通过执行 setuid 程序提权,那过滤器就形同虚设。

# 看一眼当前 shell 有没有加载过滤器 grep -E 'Seccomp|NoNewPrivs' /proc/self/status # Seccomp: 0 -> 没启用 # Seccomp: 2 -> 过滤模式 # Seccomp_filters: 1 -> 挂了几个过滤器

注意:Seccomp_filters这个字段是 Linux 4.9 之后才有的,老内核上看不到。排查老环境时要靠别的手段。

2.2 为什么必须检查 arch 字段

第一次手写过滤器的时候,我很自然地只判断了系统调用号,跑起来也没问题。直到有人提醒我:在 x86-64 上,进程完全可以切到 32 位模式执行代码,那时候走的是 i386 的系统调用表,编号和 x86-64 完全不同。如果你只按 x86-64 的编号做判断,攻击者只要用 32 位 ABI 发起调用,就能绕过整个过滤器。

这就是为什么几乎所有靠谱的 seccomp 过滤器,第一条指令都是读seccomp_data.arch,比对不上就直接杀进程。struct seccomp_data的结构长这样:

struct seccomp_data { int nr; /* 系统调用号 */ __u32 arch; /* 架构标识,如 AUDIT_ARCH_X86_64 */ __u64 instruction_pointer; /* 触发调用的指令地址 */ __u64 args[6]; /* 六个参数 */ };

除了 arch,instruction_pointer偶尔会被用来做更细的校验,但因为它和地址空间布局强相关,做可移植的过滤器时基本不用。真正会用到的是args,当你需要"允许socket但不允许创建某些协议族"这种参数级控制时,就得把 64 位参数拆成高低两个 32 位来比。

2.3 经典 BPF 的三个硬限制

写 seccomp 过滤器时必须接受三个事实,它们会直接影响你的方案设计。

第一,每个过滤器的指令数上限是 4096 条。白名单方式给几百个系统调用各写一条,看起来离上限很远,但如果你还要对参数逐个比对,指令数会迅速膨胀。我见过一个项目为了精确控制ioctl的请求码,光这一条规则就写了两百多条指令。真要突破这个数量级,只能拆成多个过滤器叠加——因为Seccomp_filters是可以大于 1 的,多个过滤器是"全部都要通过"的关系。

第二,累加器是 32 位的。经典 BPF 里只有 A 和 X 两个寄存器,都是 32 位。想比较一个 64 位参数,只能先读低 32 位、再读高 32 位,用跳转组合起来判断。这写起来很啰嗦,也是很多人转向 libseccomp 的直接原因。

第三,动作返回值只有 16 位数据位。SECCOMP_RET_*的高 16 位是动作类型,低 16 位是附带数据,用SECCOMP_RET_DATA这个掩码取。比如SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA),就是让这个系统调用直接返回 EPERM。千万别忘了和掩码做与运算,否则返回值会变成一个内核不认识的怪东西,行为不可预期。

动作的优先级是固定的,内核会从所有匹配的规则里挑级别最高的那个执行,顺序大致是:杀进程 > 杀线程 > 触发 SIGSYS > 返回 errno > 用户态接管 > ptrace 接管 > 记日志 > 放行。

3. 手写第一个 seccomp 过滤器

3.1 从二十行代码开始

光看文档容易飘,直接写一个能跑的最小例子最实在。下面这段代码的作用是:只拦unshare这一个系统调用,让它返回 EPERM,其余全部放行。

#define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <errno.h> #include <stddef.h> #include <sys/prctl.h> #include <sys/syscall.h> #include <linux/seccomp.h> #include <linux/filter.h> #include <linux/audit.h> #define MY_ARCH AUDIT_ARCH_X86_64 static int install_filter(void) { struct sock_filter filter[] = { /* 1. 先校验架构,不匹配直接杀 */ BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)), BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, MY_ARCH, 1, 0), BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS), /* 2. 读系统调用号 */ BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), /* 3. 命中 unshare 就返回 EPERM,否则跳过 */ BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_unshare, 0, 1), BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA)), /* 4. 兜底放行 */ BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW), }; struct sock_fprog prog = { .len = (unsigned short)(sizeof(filter) / sizeof(filter[0])), .filter = filter, }; if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) { perror("prctl(PR_SET_NO_NEW_PRIVS)"); return -1; } if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog)) { perror("prctl(PR_SET_SECCOMP)"); return -1; } return 0; } int main(void) { if (install_filter() != 0) return 1; /* 这里应该失败,errno 为 EPERM */ if (unshare(CLONE_NEWNS) == -1) { perror("unshare"); } printf("still alive, filter is working\n"); return 0; }

编译运行:

gcc -O2 -o seccomp-demo seccomp-demo.c ./seccomp-demo # unshare: Operation not permitted # still alive, filter is working

3.2 逐条拆解这段 BPF

很多人第一次看BPF_JUMP(..., 1, 0)这种写法是懵的,1和0到底是跳几条。规则是:条件为真时跳过jt条指令继续执行,为假时跳过jf条指令继续执行。这里的"跳过 N 条"是相对当前指令的下一条开始数,跳 0 就是执行紧接着的下一句。

所以第二条指令BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K, MY_ARCH, 1, 0)的意思是:如果 arch 相等,跳过下一条(也就是跳过那个 KILL),直接去读系统调用号;不相等就往下走,执行 KILL。这个"真跳过、假落下"的写法在 seccomp 里出现频率特别高,因为它能把"异常处理"紧贴在判断后面,读起来顺一点。

第三条KILL_PROCESS我故意用了杀进程而不是返回错误。架构不匹配这种情况,正常程序根本不会触发,一旦触发就说明有人在尝试换 ABI 绕过,这种时候没有任何理由让它继续跑。

SECCOMP_RET_KILL_PROCESS和SECCOMP_RET_KILL_THREAD的区别值得记一下:前者从 Linux 4.14 开始提供,整个线程组一起死;后者(老名字就是SECCOMP_RET_KILL)只杀掉触发调用的那个线程,同一个进程里的其他线程还活着。多线程服务里如果不想出现"半个进程还活着"的诡异状态,用 KILL_PROCESS 更省心。

3.3 NO_NEW_PRIVS 和 TSYNC,两个必须理解清楚的开关

PR_SET_NO_NEW_PRIVS这个调用看起来像是额外负担,但它是 seccomp 能在无特权场景下使用的关键。内核的逻辑是:只有在你保证"这个进程不会通过 execve 获得额外权限"的前提下,才允许你给它加过滤器,否则过滤器可能被一个 setuid 程序绕过。设置之后不可撤销,而且会被子进程继承。如果你的服务本身需要执行 setuid 的辅助程序,那就得走CAP_SYS_ADMIN那条路,代价是要么给容器特权,要么精细地只给这一个 capability。

另一个开关是SECCOMP_FILTER_FLAG_TSYNC。默认情况下,prctl(PR_SET_SECCOMP, ...)只给调用它的那一个线程装过滤器,其他线程不受影响。如果程序是多线程的,而你在某个线程里装了过滤器,就会出现"同一进程内部分线程受约束、部分不受约束"的割裂状态,这种漏洞非常隐蔽。TSYNC 的作用是把过滤器同步到线程组里的所有线程,如果其中有线程已经装过不兼容的过滤器,调用会返回 EINVAL 而不是静默失败。

/* 用 seccomp() 系统调用,配合 TSYNC 标志 */ int rc = syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_TSYNC, &prog); if (rc == -1 && errno == EINVAL) { /* 说明有线程带着不兼容的过滤器,需要先统一 */ perror("seccomp TSYNC"); }

实操心得:如果你打算在启动早期就装过滤器,最好在创建任何线程之前完成。这样既不用处理 TSYNC 的兼容性问题,也能保证所有后续线程天然继承过滤规则。我现在的做法是在main函数最开头、任何pthread_create之前就把过滤器装好。

4. 生产落地:从系统调用画像到白名单

4.1 先画像,再动刀

给已有服务加 seccomp,最容易翻车的做法是"凭经验列一个白名单"。人对系统调用的直觉几乎必然是错的——你以为一个简单的 HTTP 服务只会用read/write,实际上它可能因为 DNS、NSS、locale、日志库、随机数种子而在启动阶段调用了上百个系统调用。

正确的姿势是先画像。用strace把服务在一段时间内的系统调用全量抓下来,按频次和名称统计:

# 跟住所有子进程,统计各类系统调用的次数 strace -f -c -o trace-summary.txt ./your-service # 想要更细的,把完整调用名抓下来去重 strace -f -e trace=all -o trace-full.txt ./your-service awk -F'(' '{print $1}' trace-full.txt | awk '{print $NF}' | sort -u > syscalls.txt

这里有几个关键点必须注意,否则画像结果是残缺的:

  • 一定要跟子进程(-f)。很多服务会 fork 出 worker,漏掉子进程等于漏掉一大半调用面。
  • 一定要覆盖完整生命周期。启动、热加载、优雅退出、错误分支走的路完全不同。只抓稳态运行的十分钟,你会漏掉execve、restart_syscall、rt_sigreturn这些"一次性但必需"的调用。
  • 一定要覆盖异常路径。如果服务有降级逻辑,比如主链路失败后去读本地缓存文件,这条路径平时根本不会跑,上线后一旦触发就被 seccomp 拦死。

我一般的做法是抓三类场景:正常请求压测一轮、触发一次配置热加载、触发一次优雅重启。三轮合起来的系统调用集合,才是白名单的起点。

4.2 白名单和黑名单,怎么选

这是 seccomp 落地绕不开的决策。两种思路的差别不是风格问题,而是安全模型的差别。

维度白名单(默认拒绝)黑名单(默认放行)
默认动作SCMP_ACT_ERRNO/SCMP_ACT_KILLSCMP_ACT_ALLOW
安全强度高,新出现的攻击面默认被封低,只能封已知危险调用
维护成本高,每次依赖升级都可能要加规则低,基本不用动
上线风险高,漏一个调用就报 EPERM低,误伤概率小
适用场景容器运行时、代码沙箱、暴露面大的服务内部服务、快速加一层兜底

如果你做的是多租户的代码执行环境,没有任何理由用黑名单——用户代码能调用的系统调用应该是极其有限的一个子集,白名单是唯一合理的选择。反过来,如果你只是想给一个内部服务加一层防护,黑名单拦掉ptrace、kexec_load、bpf、userfaultfd、perf_event_open这类东西,性价比更高,也不容易出事。

现实中更常见的是混合策略:以白名单为骨架,但对确实难以枚举的调用(比如ioctl、fcntl)用参数级规则或直接放行,同时接受这部分残余风险。

4.3 用 libseccomp 把可读性拉回来

手写 BPF 适合理解原理,不适合维护。真到生产环境,基本都会用 libseccomp,它把系统调用名、参数比较、多架构处理都封装好了。

#include <seccomp.h> #include <errno.h> #include <stdio.h> int main(void) { /* 默认动作设为拒绝,返回 EPERM */ scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_ERRNO(EPERM)); if (!ctx) return 1; /* 明确声明允许的架构,顺带处理 32 位兼容 */ seccomp_arch_add(ctx, SCMP_ARCH_X86_64); seccomp_arch_remove(ctx, SCMP_ARCH_NATIVE); /* 视情况调整 */ seccomp_arch_add(ctx, SCMP_ARCH_X86); /* 基础 IO */ seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(close), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(fstat), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(lseek), 0); /* 内存管理 */ seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(mmap), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(munmap), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(mprotect), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(brk), 0); /* 线程与同步 */ seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(futex), 0); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(clone), 0); /* 想观察某个调用但不拦截,用 LOG */ seccomp_rule_add(ctx, SCMP_ACT_LOG, SCMP_SYS(socket), 0); if (seccomp_load(ctx) != 0) { perror("seccomp_load"); seccomp_release(ctx); return 1; } seccomp_release(ctx); /* 剩下的业务代码 */ return 0; }

编译的时候记得链接:

gcc -O2 -o svc svc.c -lseccomp

seccomp_rule_add的第四个参数是参数比较规则的数量,传 0 表示不比较参数。后面那句seccomp_arch_add(ctx, SCMP_ARCH_X86)是很多人会漏掉的:如果进程可能执行 32 位代码,必须显式把 32 位架构也加进去,libseccomp 会为每个架构生成对应的规则。不加的话,32 位调用会落到默认动作上——如果你默认是 KILL,那会直接崩;如果你忘了设默认动作,行为会更混乱。

避坑提醒:seccomp_arch_remove(ctx, SCMP_ARCH_NATIVE)和seccomp_arch_add的顺序会影响最终规则集。我的习惯是先把所有需要的架构加进去,再删掉不需要的,最后调seccomp_export_bpf导出来看一眼,确认生成的指令数量和架构覆盖符合预期。

4.4 参数级过滤:别只看调用号

只判断调用号的过滤器有个明显短板:socket是合法的,但socket(AF_NETLINK, ...)和socket(AF_PACKET, ...)在很多沙箱里就不该被允许。libseccomp 支持参数级比较,写法是这样:

/* 允许 IPv4 TCP,其他协议族一律拒绝 */ seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(socket), 1, SCMP_A0(SCMP_CMP_EQ, AF_INET)); seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(socket), 1, SCMP_A0(SCMP_CMP_EQ, AF_INET6));

SCMP_A0到SCMP_A5对应六个参数,SCMP_CMP_EQ、SCMP_CMP_NE、SCMP_CMP_LT、SCMP_CMP_MASKED_EQ是比较方式。这里有个非常重要的细节:libseccomp 的规则匹配是"任意一条匹配规则生效",而不是"最具体的规则优先"。也就是说,上面这两条加上默认的 ERRNO,socket(AF_NETLINK, ...)会落到默认动作被拒绝,这正是我们要的。但如果你先写了一条不带参数的SCMP_ACT_ALLOW, SCMP_SYS(socket), 0,那参数级规则就永远不会被触发了——因为无参数规则放行了一切。

参数比较也不是免费的。每加一条参数规则,生成的 BPF 指令都会增加,而且因为 64 位参数要拆成两次读取,一条参数规则通常要消耗好几条指令。我实测过,把一个两百条白名单的过滤器全部加上参数比较之后,指令数从一千多涨到了三千多,逼近 4096 的上限。所以参数级过滤要挑重点用,别贪。

4.5 灰度节奏:LOG 先行,ERRNO 跟进,KILL 兜底

给线上服务加 seccomp,最忌讳一步到位直接上 KILL。我的标准节奏分三步:

第一步,全量 LOG。把默认动作设成SCMP_ACT_LOG,或者对不在白名单里的调用记日志但不拦截。跑一到两周,把日志里出现的"意外调用"全部收进白名单。这一步的产出是:你知道自己的服务到底用了哪些系统调用,而且覆盖了真实流量下的所有分支。

第二步,切 ERRNO。把默认动作换成SCMP_ACT_ERRNO(EPERM)。这时候被拦的调用会让业务代码看到一个明确的错误码,而不是进程直接消失。观察监控里的 EPERM 错误率,如果某个调用被频繁命中,要么是白名单漏了,要么是业务逻辑真的有问题——两种情况都需要人来看一眼。

第三步,对真正的危险调用上 KILL。白名单之外的调用返回 EPERM,而像ptrace、process_vm_readv、kexec_load、bpf、userfaultfd这几个,直接SCMP_ACT_KILL_PROCESS。这类调用正常业务代码永远不该碰,一旦出现基本可以判定是攻击行为,没有给错误码的必要。

/* 危险调用直接杀 */ seccomp_rule_add(ctx, SCMP_ACT_KILL_PROCESS, SCMP_SYS(ptrace), 0); seccomp_rule_add(ctx, SCMP_ACT_KILL_PROCESS, SCMP_SYS(process_vm_readv), 0); seccomp_rule_add(ctx, SCMP_ACT_KILL_PROCESS, SCMP_SYS(process_vm_writev), 0); seccomp_rule_add(ctx, SCMP_ACT_KILL_PROCESS, SCMP_SYS(kexec_load), 0);

这套节奏跑下来,绝大多数服务在两到三周内可以从 LOG 走到稳定状态。急着一天上线也行,但要接受"凌晨被告警叫醒"的概率。

5. 踩坑记录与排查速查手册

5.1 症状、原因、处置对照表

下面这张表是我这两年攒下来的,按症状查基本能覆盖八成问题。

现象常见原因处置方式
进程莫名消失,日志无堆栈命中 KILL,SIGSYS 无处理函数临时把默认动作改 LOG,复现后看审计日志
某个系统调用固定返回 EPERM白名单漏了该调用,或参数比较写反用strace -f抓失败点,确认是哪个调用
收到 SIGSYS 信号命中SECCOMP_RET_TRAP注册 SIGSYS handler,读si_syscall字段
多线程服务行为不一致过滤器只装在了单线程上改用SECCOMP_FILTER_FLAG_TSYNC
32 位程序绕过限制未校验 arch 或未添加 32 位架构加 arch 检查,libseccomp 里补SCMP_ARCH_X86
execve 后过滤器失效执行了 setuid 程序且未设no_new_privs设置PR_SET_NO_NEW_PRIVS,或避免 setuid
容器启动报 EPERM运行时默认 profile 拦截用--security-opt seccomp=unconfined对比验证
过滤器加载返回 EINVALTSYNC 遇到不兼容的已有过滤器统一在启动最早阶段安装,避免分散加载
规则数没超但加载失败生成的 BPF 指令超过 4096拆成多个过滤器,或减少参数级规则

抓 SIGSYS 信息的写法大概是这样,配合SECCOMP_RET_TRAP用:

#include <signal.h> #include <sys/syscall.h> static void sigsys_handler(int sig, siginfo_t *info, void *ctx) { (void)sig; (void)ctx; /* si_syscall 是被拦下的系统调用号 */ fprintf(stderr, "blocked syscall: %d\n", info->si_syscall); _exit(128 + SIGSYS); } /* 注册方式 */ struct sigaction sa = { .sa_sigaction = sigsys_handler, .sa_flags = SA_SIGINFO, }; sigaction(SIGSYS, &sa, NULL);

5.2 容器运行时里的 seccomp 长什么样

大部分人是通过容器第一次接触 seccomp 的,所以这一层必须讲清楚。Docker 和 containerd 都自带一份默认 profile,默认动作是SCMP_ACT_ERRNO,里面有一张很长的白名单,把大约三百多个常规系统调用放行,另外有一小撮被显式禁止,还额外挡掉了若干带参数的危险调用。

这份默认 profile 的存在感极强,因为它的默认动作是拒绝。你如果往容器里塞一个用了冷门系统调用的程序,报错就是 EPERM,而容器日志里可能什么线索都没有。定位方式很简单:

# 排除法:先关掉 seccomp 跑一次 docker run --security-opt seccomp=unconfined your-image # 如果关掉就好了,说明是 seccomp 拦的 # 导出自定义 profile 再逐条加回来

生产环境上,正确做法是导出默认 profile,把你的程序需要但被拒的调用加进白名单,然后用自定义 profile 启动,而不是长期用unconfined。在 Kubernetes 里对应的是securityContext.seccompProfile,1.25 之后默认走 RuntimeDefault,也就是说默认就是开着的,这一点很多人不知道。

apiVersion: v1 kind: Pod spec: securityContext: seccompProfile: type: Localhost localhostProfile: profiles/app-profile.json containers: - name: app image: your-image:latest

自定义 profile 的 JSON 结构不复杂,核心就是defaultAction加一组syscalls数组,每个元素包含names、action,需要的话再加args。我一般会从默认 profile 拷一份出来改,而不是从零写,因为默认 profile 里那些参数级规则的细节很容易被漏掉。

5.3 几个只有踩过才知道的细节

过滤器是不可撤销的。一旦seccomp_load成功,当前进程再没有任何办法摘掉它,子进程还会继承。这意味着你的调试窗口只在加载之前。所以我现在的习惯是加一个环境变量开关,开发环境默认不装,需要验证的时候手动打开。

execve会保留过滤器,但有例外。正常情况下过滤器跟着进程走,fork、clone、execve都不会丢。但如果执行的是一个带 setuid 或 setgid 位的二进制,而且进程没有设置no_new_privs,内核会把过滤器清掉。这个行为是从安全角度设计的,但它确实会让人困惑——同一个程序直接跑没问题,被 setuid 包装一层就出事了。

CLONE_NEWUSER的坑。很多沙箱方案会先创建一个用户命名空间,然后在里面装 seccomp 过滤器。这么做的问题是,在用户命名空间里加载过滤器需要额外的谨慎处理,而且容器运行时的默认 profile 通常会直接把clone带CLONE_NEWUSER标志的调用挡掉。如果你的方案依赖这个,得提前和运行时的配置对齐。

性能开销别过度担心,但要测。过滤器是在每次系统调用路径上执行的,代价是固定的 BPF 求值。指令数在一千以内的过滤器,实测对吞吐的影响基本淹没在噪声里。真正影响性能的往往不是 seccomp 本身,而是被 ERRNO 拦掉之后业务代码的重试风暴。所以性能验证的重点应该放在"有没有被误拦导致的异常重试",而不是纠结 seccomp 加了几个纳秒。

规则顺序不决定优先级。前面提过一次,这里再强调:libseccomp 里规则是"任一匹配即生效",但如果你有两条动作不同的规则同时匹配同一个调用(比如一条 ALLOW、一条 ERRNO),最终生效的是内核定义的动作优先级,而不是你写的顺序。避免这种歧义最好的办法就是别写冲突规则,白名单里出现过的调用不要再写一条拒绝规则。

5.4 一个真实的排查过程

最后分享一次完整的排查,感觉比任何文档都有用。现象是:一个 Go 写的服务在容器里跑几小时后,会有极低概率出现请求超时,日志里没有任何错误。

第一步,先在容器里看 seccomp 状态,Seccomp: 2,确实开着。第二步,把默认动作临时改成 LOG,导出审计日志。第三步,在日志里 grepSECCOMP,发现有一类futex调用被拦了——但只拦了一部分,因为 Go 运行时绝大多数 futex 调用都是白名单里的类型。第四步,看具体参数,是被拦的那种 futex 带了一个很少见的操作码,只在 GC 的某个特定阶段出现。

结论是:默认 profile 里对futex的参数白名单不完整,覆盖了常见操作码,漏了 GC 场景下的那一个。解决办法是往自定义 profile 里补一条针对该操作码的规则。整个过程最难的不是修,是意识到"偶发超时"和"系统调用被拦"之间有关联——毕竟从现象上看,这两者八竿子打不着。

这件事给我留下的经验是:只要容器里跑的东西对延迟敏感,而现象又是偶发性的,就该去审计日志里翻一眼 seccomp。被 ERRNO 拦掉的调用通常会被上层包装成一个普通的错误,加上重试逻辑之后就变成了"偶发变慢",最容易被忽略。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 6:18:58

手机蓝牙控制Arduino LED亮灭:HC05回显机制解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:18:54

进入Shell的两种方式:交互式Shell与脚本执行全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:18:53

C语言合并两个有序链表:三种实现、内存陷阱与调试方法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:18:43

轮腿穿越组技术全解析:英飞凌TC264、FOC控制与串级PID调参实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:18:09

BigQuant平台实现质量优选低波动多因子策略实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:17:10

Python教学质量评价系统毕业设计:架构拆解与部署避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华