执行沙箱隔离实战:Namespace、Cgroup与Seccomp三层防护

发布时间:2026/10/14 11:02:37
执行沙箱隔离实战:Namespace、Cgroup与Seccomp三层防护
1. 执行沙箱隔离到底在解决什么问题第一次接触“执行沙箱隔离”这个概念是在做一个代码在线评测功能的时候。用户提交一段代码系统需要在服务端跑起来拿到输出结果再跟标准答案比对。听起来很简单对吧但真正动手做的时候问题一个接一个冒出来用户提交的代码里写了while(true)怎么办代码里试图读取系统文件怎么办代码里 fork 了一堆子进程把 CPU 占满怎么办更极端一点代码里直接执行rm -rf /呢这些问题的本质是同一个你让一段不受信任的代码在你的机器上运行了。执行沙箱隔离要做的就是给这段代码画一个圈让它只能在圈里折腾碰不到圈外的任何东西。这个主题适合谁看如果你正在做在线评测系统、函数计算平台、插件运行环境、自动化脚本执行引擎或者任何需要“让别人的代码在我的机器上跑”的场景那执行沙箱隔离就是你绕不过去的核心问题。哪怕你只是对系统安全感兴趣理解沙箱的运作机制也会让你对操作系统的隔离能力有全新的认识。我下面会从设计思路开始讲然后拆解核心技术点再给出一套可落地的实操方案最后把我踩过的坑和排查经验整理出来。整套内容基于 Linux 环境因为这是服务端最主流的部署平台但思路在其他平台上同样适用。2. 整体设计思路与方案选型2.1 沙箱隔离的四个核心维度做执行沙箱隔离归根结底要解决四个维度的隔离问题。我把它们称为沙箱的“四面墙”第一面墙是文件系统隔离。被执行的代码只能看到有限的目录不能读取宿主机的敏感文件不能写入系统目录不能修改其他用户的数据。这面墙没做好的话一段open(/etc/passwd)就能把你的系统信息摸个底朝天。第二面墙是进程隔离。代码不能看到宿主机上的其他进程不能向其他进程发信号不能 fork 出大量子进程耗尽系统资源。没有这面墙一个 fork 炸弹就能让整台机器瘫痪。第三面墙是网络隔离。代码不能随意发起网络连接不能访问内网服务不能往外发送数据。很多安全事件就是因为沙箱里的代码能自由联网导致数据被外传或者被当作跳板。第四面墙是资源隔离。CPU 时间、内存大小、磁盘空间、文件描述符数量、进程数量这些都需要有明确的限额。没有资源限制的沙箱等于没有沙箱一段死循环代码就能把你的服务拖垮。这四个维度不是孤立的它们之间有交叉。比如进程隔离和资源隔离都涉及 cgroup 的使用文件系统隔离和网络隔离在 namespace 层面有相似的机制。理解这四个维度之后你就能根据自己的场景决定每一面墙要砌多厚。2.2 为什么选择 Linux Namespace Cgroup Seccomp 组合实现沙箱隔离的方案有很多种我大致列一下常见的选项和它们的适用场景方案隔离级别性能开销适用场景主要局限虚拟机硬件级高强隔离需求启动慢、资源占用大容器NamespaceCgroup操作系统级低通用沙箱共享内核需额外加固Seccomp系统调用级极低限制系统调用只管控 syscall语言级沙箱应用级最低同语言代码隔离能力有限chroot文件系统级低简单文件隔离容易被逃逸我最终选择的是Namespace Cgroup Seccomp 三层组合原因很直接Namespace 负责“看不见”——让沙箱内的进程看不到外面的进程、网络、文件系统挂载点。这是最基础的隔离层Linux 内核原生支持不需要额外安装什么重量级组件。Cgroup 负责“用不了”——限制沙箱内进程能使用的 CPU、内存、IO 等资源。Namespace 解决的是可见性问题但一个可见的进程仍然可以疯狂消耗 CPU所以需要 Cgroup 来兜底。Seccomp 负责“调不动”——过滤掉危险的系统调用。即使前两层都做好了某些系统调用仍然可能被滥用比如ptrace可以用来调试其他进程mount可以改变文件系统结构。Seccomp 在内核层面拦截这些调用直接返回错误。这三层的关系可以这样理解Namespace 是围墙Cgroup 是粮草配额Seccomp 是门禁系统。围墙防止你跑出去粮草配额防止你吃太多门禁系统防止你拿到不该拿的工具。注意这三层不是简单的叠加关系配置的时候需要通盘考虑。比如你用了 PID namespace沙箱内的进程号从 1 开始但 Cgroup 的进程数限制仍然作用于宿主机层面的真实进程。如果两者配置不匹配可能出现沙箱内看起来进程不多但宿主机上已经创建了大量进程的情况。2.3 方案的整体架构整个执行沙箱的工作流程分为五个阶段接收请求拿到待执行的代码和输入数据创建沙箱分配独立的 Namespace、Cgroup、Seccomp 规则执行代码在沙箱内启动进程运行代码收集输出回收资源执行结束后销毁沙箱释放所有资源返回结果把执行结果标准输出、标准错误、退出码、耗时、内存峰值返回给调用方这五个阶段看起来简单但每个阶段都有很多细节需要处理。比如创建沙箱时Namespace 的创建顺序会影响隔离效果执行代码时超时控制需要精确到毫秒级回收资源时要确保没有僵尸进程残留。我选择用 Go 语言来实现这套方案原因是 Go 的os/exec包对进程管理支持得很好syscall包可以直接调用 Linux 的系统调用而且 Go 编译出来的二进制文件没有动态链接依赖部署起来很方便。当然你用 Python 的subprocess或者 Rust 的nix库也能做核心思路是一样的。3. 核心技术点深度拆解3.1 Namespace 隔离让沙箱内的进程“看不见”外面Linux Namespace 是内核提供的一种资源隔离机制它让一组进程拥有独立的视图。目前 Linux 支持八种 Namespace做执行沙箱主要用到其中五种PID Namespace让沙箱内的进程拥有独立的进程号空间。沙箱内第一个进程的 PID 是 1它看不到宿主机上的其他进程也没法向它们发信号。这个隔离非常关键因为很多攻击手法都依赖于枚举系统进程或者向特定进程发送信号。Mount Namespace让沙箱内的进程拥有独立的文件系统挂载点视图。配合pivot_root或者chroot可以把沙箱的根目录切换到一个受限的目录树中。沙箱内的进程看不到宿主机的真实文件系统只能看到我们允许它看到的那些文件。Network Namespace让沙箱内的进程拥有独立的网络栈包括网卡、路由表、iptables 规则等。默认情况下新建的 Network Namespace 里只有一个 loopback 接口没有任何外部网络连接能力。如果沙箱需要联网可以创建一对 veth 设备把沙箱连接到宿主机的网桥上再通过 iptables 规则控制流量。UTS Namespace让沙箱内的进程拥有独立的主机名和域名。这个隔离看起来不起眼但有些程序会根据主机名来做判断隔离之后可以避免信息泄露。IPC Namespace让沙箱内的进程拥有独立的 System V IPC 和 POSIX 消息队列。没有这个隔离沙箱内的进程可能通过共享内存和宿主机上的其他进程通信。创建 Namespace 的方式有两种一种是用clone()系统调用在创建进程时指定 Namespace 标志另一种是用unshare()系统调用让当前进程脱离某个 Namespace。在 Go 里面最方便的方式是通过exec.Cmd的SysProcAttr字段来指定cmd : exec.Command(/path/to/sandbox_init) cmd.SysProcAttr syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET | syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC, }这段代码创建了一个新的进程它拥有独立的 PID、Mount、Network、UTS 和 IPC Namespace。注意CLONE_NEWPID的效果是新进程的子进程会成为新 PID Namespace 里的 PID 1而新进程本身仍然在宿主机的 PID Namespace 里。这个细节很容易搞混我第一次用的时候就被绕进去了。实操心得创建 Mount Namespace 之后默认情况下挂载点的变化会传播到宿主机。你需要把 Mount Namespace 的传播类型设置为MS_PRIVATE或MS_SLAVE防止沙箱内的挂载操作影响到宿主机。这个操作要在进入沙箱之前完成。3.2 Cgroup 资源限制给沙箱划定“粮草配额”CgroupControl Group是 Linux 内核提供的资源限制机制它可以限制一组进程能使用的 CPU、内存、磁盘 IO、网络带宽等资源。目前主流的是 Cgroup v2相比 v1 它把多个控制器统一到了一个层级结构中配置起来更清晰。做执行沙箱我通常会设置以下几类限制CPU 限制用cpu.max来控制。格式是$QUOTA $PERIOD比如50000 100000表示在每个 100ms 的周期内这组进程最多使用 50ms 的 CPU 时间也就是限制在 0.5 个核心。对于代码评测场景我一般给 1 到 2 个核心的配额具体看评测任务的类型。内存限制用memory.max来控制单位是字节。超过这个限制会触发 OOM Killer把沙箱内的进程杀掉。这里有个坑memory.max限制的是物理内存不包括 swap。如果你希望连 swap 一起限制还需要设置memory.swap.max。我一般会把 swap 设置为 0强制沙箱只能用物理内存。进程数限制用pids.max来控制。这个限制非常关键没有它的话一个 fork 炸弹就能在几秒钟内创建出成千上万个进程把系统资源耗尽。我一般设置为 64 到 256 之间根据实际需要调整。IO 限制用io.max来控制可以限制读写带宽和 IOPS。对于代码评测场景IO 限制不是必须的但如果你的沙箱会执行大量文件操作建议加上。配置 Cgroup 的方式有两种一种是通过文件系统直接写入另一种是通过 systemd 的 D-Bus 接口。在容器化环境中直接操作 cgroupfs 更常见# 创建 cgroup mkdir /sys/fs/cgroup/sandbox_001 # 设置 CPU 限制为 1 个核心 echo 100000 100000 /sys/fs/cgroup/sandbox_001/cpu.max # 设置内存限制为 256MB echo 268435456 /sys/fs/cgroup/sandbox_001/memory.max # 设置 swap 限制为 0 echo 0 /sys/fs/cgroup/sandbox_001/memory.swap.max # 设置最大进程数为 64 echo 64 /sys/fs/cgroup/sandbox_001/pids.max # 把沙箱进程加入这个 cgroup echo $SANDBOX_PID /sys/fs/cgroup/sandbox_001/cgroup.procs注意Cgroup v2 要求所有控制器在同一个层级中启用不能像 v1 那样单独挂载某个控制器。如果你在较老的系统上用的是 Cgroup v1配置方式会有所不同需要分别操作cpu、memory、pids等子系统。3.3 Seccomp 系统调用过滤把危险操作挡在门外SeccompSecure Computing Mode是 Linux 内核提供的系统调用过滤机制。它允许你定义一个 BPF 程序对每个系统调用进行检查决定是允许、拒绝还是杀掉进程。Seccomp 有两种模式strict 模式和 filter 模式。strict 模式只允许read、write、exit、sigreturn四个系统调用其他一律杀掉。这个模式太严格了基本上只能运行最简单的程序。filter 模式允许你自定义过滤规则灵活得多也是实际使用中采用的模式。定义一个 Seccomp 过滤规则需要写一个 BPF 程序。手写 BPF 字节码很痛苦通常用库来生成。在 Go 里面seccomp/libseccomp-golang这个库提供了比较友好的接口filter, _ : seccomp.NewFilter(seccomp.ActAllow) // 禁止 ptrace 系统调用 filter.AddRule(syscall.SYS_PTRACE, seccomp.ActErrno) // 禁止 mount 系统调用 filter.AddRule(syscall.SYS_MOUNT, seccomp.ActErrno) // 禁止 reboot 系统调用 filter.AddRule(syscall.SYS_REBOOT, seccomp.ActErrno) // 禁止 kexec_load 系统调用 filter.AddRule(syscall.SYS_KEXEC_LOAD, seccomp.ActErrno) // 加载过滤器 filter.Load()这段代码创建了一个默认允许所有系统调用的过滤器然后逐个禁止危险调用。ActErrno表示返回错误码ActKill表示直接杀掉进程。对于大多数场景返回错误码就够了因为正常的程序不会调用这些系统调用只有恶意代码才会。哪些系统调用需要禁止我整理了一份常用清单系统调用风险建议动作ptrace调试其他进程读写内存禁止mount/umount改变文件系统结构禁止reboot重启系统禁止kexec_load加载新内核禁止init_module加载内核模块禁止delete_module卸载内核模块禁止swapon/swapoff管理交换分区禁止acct启用进程记账禁止settimeofday修改系统时间禁止clock_settime修改时钟禁止vhangup挂断终端禁止pivot_root改变根文件系统禁止chroot改变根目录视情况实操心得Seccomp 过滤器的加载是不可逆的一旦加载就不能移除。所以一定要在沙箱进程启动之后、执行用户代码之前加载。另外加载过滤器需要CAP_SYS_ADMIN权限或者设置no_new_privs标志。我一般会在沙箱初始化脚本里设置prctl(PR_SET_NO_NEW_PRIVS, 1)这样就不需要额外的权限了。3.4 文件系统隔离构建最小化的根目录文件系统隔离的目标是让沙箱内的进程只能看到一个受限的目录树。实现方式通常是准备一个最小化的根文件系统然后通过pivot_root把沙箱的根目录切换过去。最小化根文件系统里需要包含什么这取决于你要执行什么类型的代码。如果是编译型语言C、C、Go、Rust你需要包含编译器、标准库、链接器。如果是解释型语言Python、JavaScript、Ruby你需要包含解释器和运行时库。此外还需要一些基础的系统文件比如/dev/null、/dev/zero、/proc、/tmp等。我一般用 BusyBox 来构建最小化根文件系统因为它体积小、功能全一个二进制文件就提供了大部分常用命令。构建过程大致如下# 创建根目录结构 mkdir -p rootfs/{bin,dev,proc,tmp,usr,lib,lib64} # 复制 BusyBox cp /bin/busybox rootfs/bin/ # 创建常用命令的符号链接 for cmd in sh ls cat echo mkdir rm cp mv; do ln -s /bin/busybox rootfs/bin/$cmd done # 复制必要的动态链接库 cp /lib/x86_64-linux-gnu/libc.so.6 rootfs/lib/x86_64-linux-gnu/ cp /lib64/ld-linux-x86-64.so.2 rootfs/lib64/ # 创建必要的设备文件 mknod rootfs/dev/null c 1 3 mknod rootfs/dev/zero c 1 5 mknod rootfs/dev/random c 1 8 mknod rootfs/dev/urandom c 1 9 # 挂载 proc 文件系统 mount -t proc proc rootfs/proc构建好根文件系统之后在沙箱初始化脚本里执行pivot_root// 把新根目录挂载为 private防止传播 mount(, /, , MS_PRIVATE | MS_REC, NULL); // 绑定挂载新根目录 mount(new_root, new_root, , MS_BIND | MS_REC, NULL); // 切换根目录 chdir(new_root); pivot_root(., .oldroot); // 卸载旧根目录 umount2(.oldroot, MNT_DETACH);pivot_root比chroot更安全因为chroot可以被有CAP_SYS_CHROOT权限的进程逃逸而pivot_root把旧根目录整个换掉了逃逸难度大得多。注意pivot_root要求新根目录必须是一个挂载点而且不能和当前根目录在同一个挂载树上。所以需要先做一个绑定挂载把新根目录变成独立的挂载点。这个步骤很容易出错我第一次做的时候卡了很久后来发现是绑定挂载的时候忘了加MS_REC标志导致子目录没有一起挂载过去。4. 完整实操流程与核心环节实现4.1 环境准备与依赖检查在开始搭建沙箱之前需要确认系统环境满足以下条件Linux 内核版本 4.15 或以上Cgroup v2 和 Seccomp 的完整支持已启用 Cgroup v2检查/sys/fs/cgroup/cgroup.controllers是否存在已启用 Seccomp检查/proc/sys/kernel/seccomp/actions_avail是否存在拥有 root 权限或等效的 capabilities检查命令如下# 检查内核版本 uname -r # 检查 Cgroup v2 cat /sys/fs/cgroup/cgroup.controllers # 检查 Seccomp cat /proc/sys/kernel/seccomp/actions_avail # 检查当前用户的 capabilities capsh --print如果 Cgroup v2 没有启用需要在启动参数里加上systemd.unified_cgroup_hierarchy1。如果 Seccomp 没有启用需要检查内核编译选项里是否有CONFIG_SECCOMP和CONFIG_SECCOMP_FILTER。4.2 沙箱初始化程序的编写沙箱初始化程序是整个方案的核心它负责在创建 Namespace 之后、执行用户代码之前完成所有的隔离配置。我把它写成一个独立的二进制文件用 C 或者 Go 都可以。下面是一个用 Go 写的简化版本package main import ( os syscall ) func main() { // 设置 no_new_privs允许非 root 用户加载 Seccomp 过滤器 if err : syscall.Prctl(syscall.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err ! nil { panic(err) } // 设置 Mount Namespace 为 private if err : syscall.Mount(, /, , syscall.MS_PRIVATE|syscall.MS_REC, ); err ! nil { panic(err) } // 切换根目录 newRoot : /path/to/rootfs if err : syscall.Chdir(newRoot); err ! nil { panic(err) } if err : syscall.PivotRoot(., .oldroot); err ! nil { panic(err) } if err : syscall.Unmount(.oldroot, syscall.MNT_DETACH); err ! nil { panic(err) } // 挂载 /proc if err : syscall.Mount(proc, /proc, proc, 0, ); err ! nil { panic(err) } // 加载 Seccomp 过滤器 loadSeccompFilter() // 执行用户代码 // ... }这个程序需要在创建了 Namespace 的进程中运行。在 Go 里面可以通过exec.Cmd的SysProcAttr来指定 Namespace 标志然后让这个程序作为沙箱内的第一个进程运行。4.3 资源限制的配置与验证Cgroup 的配置需要在沙箱进程启动之前完成。我一般会写一个辅助函数来创建和配置 Cgroupfunc setupCgroup(sandboxID string, limits ResourceLimits) error { cgroupPath : filepath.Join(/sys/fs/cgroup, sandboxID) // 创建 cgroup 目录 if err : os.MkdirAll(cgroupPath, 0755); err ! nil { return err } // 设置 CPU 限制 cpuMax : fmt.Sprintf(%d %d, limits.CPUQuota, limits.CPUPeriod) if err : os.WriteFile(filepath.Join(cgroupPath, cpu.max), []byte(cpuMax), 0644); err ! nil { return err } // 设置内存限制 memMax : strconv.FormatInt(limits.MemoryBytes, 10) if err : os.WriteFile(filepath.Join(cgroupPath, memory.max), []byte(memMax), 0644); err ! nil { return err } // 设置 swap 限制为 0 if err : os.WriteFile(filepath.Join(cgroupPath, memory.swap.max), []byte(0), 0644); err ! nil { return err } // 设置进程数限制 pidsMax : strconv.Itoa(limits.MaxPids) if err : os.WriteFile(filepath.Join(cgroupPath, pids.max), []byte(pidsMax), 0644); err ! nil { return err } return nil }配置完成之后需要验证限制是否生效。我通常用一段测试代码来验证# 测试内存限制分配超过限制的内存 python3 -c a x * (300 * 1024 * 1024) # 测试进程数限制创建大量子进程 python3 -c import os for i in range(1000): try: os.fork() except OSError: print(fork limit reached) break # 测试 CPU 限制跑一个死循环观察 CPU 使用率 python3 -c while True: pass4.4 执行超时与输出收集执行超时控制是沙箱的必备功能。没有超时控制的话一段while(true)代码就能让沙箱永远运行下去。实现超时控制有几种方式最简单的方式是用context.WithTimeout配合exec.CommandContextctx, cancel : context.WithTimeout(context.Background(), 5*time.Second) defer cancel() cmd : exec.CommandContext(ctx, /path/to/sandbox_init) var stdout, stderr bytes.Buffer cmd.Stdout stdout cmd.Stderr stderr err : cmd.Run() if ctx.Err() context.DeadlineExceeded { // 超时处理 }这种方式的问题是CommandContext在超时后发送的是SIGKILL信号但沙箱内的进程可能已经 fork 出了子进程这些子进程不会被自动杀掉。所以还需要配合 Cgroup 的cgroup.kill功能一次性杀掉整个 Cgroup 里的所有进程// 超时后杀掉整个 cgroup os.WriteFile(filepath.Join(cgroupPath, cgroup.kill), []byte(1), 0644)输出收集需要注意两个问题一是输出量可能非常大需要限制最大输出大小二是输出可能包含二进制数据需要正确处理编码。我一般会设置一个 1MB 的输出上限超过部分直接截断type limitedBuffer struct { buf bytes.Buffer maxSize int truncated bool } func (b *limitedBuffer) Write(p []byte) (int, error) { if b.buf.Len()len(p) b.maxSize { remaining : b.maxSize - b.buf.Len() if remaining 0 { b.buf.Write(p[:remaining]) } b.truncated true return len(p), nil } return b.buf.Write(p) }4.5 沙箱的销毁与资源回收沙箱执行结束后需要彻底销毁所有资源包括杀掉 Cgroup 内所有进程删除 Cgroup 目录清理临时文件释放 Namespace 资源清理顺序很重要。如果先删除了 Cgroup 目录里面的进程会变成孤儿进程被挂到根 Cgroup 上资源限制就失效了。正确的顺序是先杀进程再删目录func cleanupSandbox(sandboxID string) error { cgroupPath : filepath.Join(/sys/fs/cgroup, sandboxID) // 先杀掉所有进程 if err : os.WriteFile(filepath.Join(cgroupPath, cgroup.kill), []byte(1), 0644); err ! nil { // 如果 cgroup.kill 不存在回退到遍历 pids 文件 killAllProcesses(cgroupPath) } // 等待进程完全退出 time.Sleep(100 * time.Millisecond) // 删除 cgroup 目录 if err : os.Remove(cgroupPath); err ! nil { return err } // 清理临时文件 os.RemoveAll(filepath.Join(/tmp/sandbox, sandboxID)) return nil }实操心得清理操作一定要放在defer里确保即使执行过程中出现异常也能正确回收资源。我见过因为清理逻辑没做好导致 Cgroup 目录越积越多最后把/sys/fs/cgroup塞满的情况。另外删除 Cgroup 目录之前要确认里面没有进程了否则会返回EBUSY错误。5. 常见问题与排查技巧实录5.1 沙箱逃逸的常见路径与防范沙箱逃逸是执行沙箱最严重的问题。我整理了几种常见的逃逸路径和对应的防范措施通过文件描述符逃逸。如果沙箱进程继承了宿主机上的文件描述符可能通过读写这些描述符来影响宿主机。防范措施是在进入沙箱之前关闭所有不需要的文件描述符只保留 stdin、stdout、stderr。通过 /proc 逃逸。如果 /proc 挂载不当沙箱内的进程可能通过 /proc 读取宿主机的信息甚至写入 /proc/sys 来修改内核参数。防范措施是只挂载必要的 /proc 子目录或者用hidepid选项隐藏其他进程的信息。通过 Unix Socket 逃逸。如果沙箱内存在 Unix Socket 文件可能通过它和宿主机上的服务通信。防范措施是清理根文件系统中的所有 Socket 文件禁止创建新的 Socket。通过设备文件逃逸。如果沙箱内存在块设备或字符设备文件可能直接读写磁盘。防范措施是只创建必要的设备文件null、zero、random、urandom其他一律不创建。通过 ptrace 逃逸。如果 Seccomp 没有禁止 ptrace沙箱内的进程可能通过 ptrace 调试宿主机上的其他进程。防范措施是在 Seccomp 过滤器中禁止 ptrace 系统调用。5.2 性能优化如何降低沙箱启动开销沙箱的启动开销主要来自三个方面Namespace 创建、Cgroup 配置、根文件系统准备。在高并发场景下这些开销会累积成可观的延迟。Namespace 创建的开销主要来自内核的数据结构分配通常在毫秒级别。优化空间不大但可以通过复用 Namespace 来减少创建次数。比如对于同一用户的连续请求可以复用同一个 Network Namespace。Cgroup 配置的开销主要来自文件系统写入。每次创建 Cgroup 都需要创建目录、写入多个配置文件。优化方式是把 Cgroup 的创建和配置合并成一次操作减少系统调用次数。另外可以预先创建一批 Cgroup需要时直接分配用完回收。根文件系统准备的开销是最大的。如果每次执行都重新构建根文件系统可能需要几百毫秒。优化方式是预先构建好一个模板根文件系统然后用 OverlayFS 或者 bind mount 来创建轻量级的副本# 用 OverlayFS 创建根文件系统的副本 mount -t overlay overlay \ -o lowerdir/path/to/template,upperdir/path/to/upper,workdir/path/to/work \ /path/to/mergedOverlayFS 的 lowerdir 是只读的模板upperdir 是可写的差异层。这样每个沙箱只需要创建一个空的 upperdir不需要复制整个根文件系统。5.3 常见错误速查表错误现象可能原因排查方法解决方案pivot_root 返回 EINVAL新根目录不是挂载点检查是否做了 bind mount先执行 bind mountSeccomp 加载失败没有 CAP_SYS_ADMIN检查 capabilities设置 no_new_privsCgroup 写入失败Cgroup v1/v2 混用检查 cgroup.controllers统一使用 v2沙箱内进程看不到 /proc没有挂载 proc检查挂载点在沙箱内挂载 proc超时后进程仍然存在只杀了主进程检查子进程用 cgroup.kill内存限制不生效swap 未限制检查 memory.swap.max设置为 0输出被截断缓冲区太小检查输出大小增大缓冲区或分片读取5.4 几个容易忽略的细节信号处理。沙箱内的进程可能收到各种信号需要确保信号不会导致沙箱逃逸。比如SIGCHLD信号可能被用来触发某些操作需要在 Seccomp 中限制rt_sigreturn等系统调用。时间隔离。沙箱内的进程可以读取系统时间这可能泄露宿主机的信息。如果需要可以用 Time NamespaceLinux 5.6来隔离时间视图。主机名泄露。如果没有设置 UTS Namespace沙箱内的进程可以通过gethostname()获取宿主机的主机名。这个信息可能被用来做指纹识别。环境变量泄露。沙箱进程会继承宿主机的环境变量其中可能包含敏感信息。需要在启动沙箱之前清理环境变量只保留必要的几个。文件权限。沙箱内的文件权限需要仔细设置确保沙箱进程只能读写允许的目录。我一般会把根文件系统设为只读只把 /tmp 和 /home 设为可写。实操心得做沙箱隔离最怕的就是“以为做好了”。我建议每次修改配置之后都用一段测试代码来验证隔离效果。测试代码应该覆盖文件读写、网络访问、进程创建、系统调用等各个方面。只有实测通过的隔离才是真隔离。6. 沙箱隔离的扩展与进阶方向6.1 多语言运行时的支持前面的方案主要针对编译型语言如果要支持 Python、JavaScript 等解释型语言需要在根文件系统中加入对应的运行时。这会显著增加根文件系统的大小但通过 OverlayFS 的共享机制多个沙箱可以共用同一个只读的运行时层只有用户代码和临时文件需要单独存储。对于 Python我通常会把 Python 解释器和标准库打包到一个独立的层中然后在沙箱启动时把这个层挂载到根文件系统的/usr/lib/python3目录下。用户代码放在/tmp目录中执行时用python3 /tmp/code.py来运行。6.2 分布式沙箱调度当单机沙箱的容量不够时需要考虑分布式调度。核心思路是把沙箱的创建、执行、销毁抽象成任务由调度器分配到不同的工作节点上执行。调度器需要考虑节点的负载、沙箱的亲和性、任务的优先级等因素。在分布式场景下沙箱的状态管理变得更重要。每个沙箱需要有一个全局唯一的 ID所有的操作都要带上这个 ID方便追踪和排查问题。我一般会用 UUID 作为沙箱 ID然后在日志中记录完整的生命周期事件。6.3 安全审计与监控沙箱运行过程中产生的日志是安全审计的重要依据。需要记录的事件包括沙箱创建、代码开始执行、代码执行结束、资源使用峰值、异常事件如 Seccomp 拦截、OOM 杀进程、沙箱销毁。监控方面需要关注几个关键指标沙箱创建成功率、平均执行时间、资源使用分布、异常事件频率。这些指标可以帮助你及时发现沙箱配置的问题比如某个限制设置得太紧导致正常代码被误杀或者某个限制设置得太松导致资源被滥用。我个人在实际操作中的体会是沙箱隔离这件事没有“一劳永逸”的方案。内核在更新攻击手法在进化你的沙箱配置也需要持续调整。定期做一次逃逸测试定期 review 一遍 Seccomp 规则定期检查一遍 Cgroup 配置这些习惯能帮你避免大部分问题。另外不要试图自己从头实现所有隔离机制Linux 内核已经提供了足够强大的原语把它们组合好、配置对比重新造轮子靠谱得多。