本地安全执行LLM生成代码:轻量级沙箱隔离方案实践
1. 为什么要在本地跑LLM生成的代码大模型写代码这件事现在基本已经是日常了。你让模型帮你写个数据清洗脚本、生成一段正则、拼一个批量重命名的工具几秒钟就能拿到结果。但真正让人头疼的不是生成而是执行。模型吐出来的代码你敢直接在自己主力机上跑吗我反正不敢。过去一年我试过各种方案。最省事的是丢到在线沙箱里跑但问题很明显代码要上传数据要外传稍微涉及内部数据结构或者业务逻辑片段就得掂量掂量。另一个思路是本地起个容器用Docker隔离但每次跑一小段代码都要走一遍镜像构建、容器启动、挂载目录的流程重得离谱。还有人用子进程加exec()这基本等于裸奔模型万一生成个os.system(rm -rf ~)你就笑不出来了。Kern Sandbox这个项目吸引我的点就在标题里写得很直白run LLM-generated code locally, no cloud, no daemon。本地跑、不依赖云服务、不需要常驻守护进程。这三个约束条件恰好命中了我对这类工具的核心诉求。它解决的不是能不能跑的问题而是能不能安全、轻量、随手跑的问题。这篇文章适合几类人看一是经常用LLM辅助写代码、需要频繁验证生成结果的开发者二是在做Agent类产品、需要给模型一个安全执行环境的工程师三是对沙箱隔离机制感兴趣、想自己搭一套轻量方案的技术人。我会从设计思路、核心机制、实操流程、踩坑经验几个维度把这件事讲透尽量做到你看完就能自己动手复现一套。2. 整体设计思路与方案选型2.1 核心矛盾隔离性与轻量性能否兼得做本地代码沙箱本质上是在解一道平衡题。隔离性越强通常意味着越重。虚拟机隔离最彻底但启动一个VM动辄几十秒内存占用按GB算。容器隔离次之Docker启动大概一两秒但需要守护进程常驻而且容器逃逸漏洞历史上出过不少。最轻的是进程级隔离用subprocess加资源限制启动毫秒级但隔离边界很薄。Kern Sandbox选择的方向是进程级隔离加上系统调用层面的限制而不是走容器或虚拟机路线。这个选择背后的逻辑是LLM生成的代码通常是短小的、单文件的、功能明确的脚本片段不是长期运行的服务。对于这种场景启动一个完整容器属于杀鸡用牛刀。而且no daemon这个约束直接排除了Docker方案因为Docker必须有dockerd在后台跑着。那进程级隔离怎么保证安全关键不在于进程本身而在于限制这个进程能做什么。Linux提供了一系列机制seccomp可以过滤系统调用rlimit可以限制资源使用namespace可以隔离文件系统和网络cgroups可以限制CPU和内存。把这些组合起来就能在一个普通进程的壳子里实现相当强的隔离效果同时保持毫秒级启动。2.2 为什么不用现成的沙箱方案市面上不是没有沙箱工具。Firecracker是AWS开源的微虚拟机方案隔离性强但需要KVM支持启动虽然比传统VM快但仍然是虚拟机级别。gVisor是Google的方案在用户态实现了一个系统调用兼容层隔离性好但性能有损耗而且部署复杂度不低。还有各种在线代码执行服务但都涉及把代码发到远端。Kern Sandbox的定位不一样。它要的是嵌入式——能作为一个库直接集成到你的LLM应用里调用一次函数就能执行一段代码并拿到结果。不需要额外部署服务不需要网络通信不需要管理容器生命周期。这种定位决定了它必须足够轻轻到可以随应用一起启动和销毁。从工程角度看这个取舍是合理的。如果你的场景是给成千上万个用户提供代码执行服务那确实应该用Firecracker或gVisor这种重型方案。但如果你的场景是自己开发时验证LLM输出或者给一个Agent提供执行能力那轻量方案明显更合适。选型的第一原则永远是匹配场景而不是追求技术上的最强隔离。2.3 架构分层拆解Kern Sandbox的架构可以分成三层来理解。最底层是执行层负责实际运行代码。这一层要处理的是用什么解释器或运行时、怎么传入代码、怎么捕获输出、怎么处理超时。对于Python代码就是起一个Python子进程对于Shell脚本就是起一个Shell进程。这一层的关键是确保进程在受控环境下启动。中间层是隔离层负责施加各种限制。这包括文件系统隔离让代码只能看到指定的目录、网络隔离禁止或限制网络访问、资源限制CPU时间、内存上限、进程数上限、系统调用过滤禁止危险操作。这一层是安全的核心也是最能体现设计功力的地方。最上层是接口层负责对外暴露简洁的API。使用者不需要关心底层用了什么隔离机制只需要调用一个函数传入代码和配置拿到执行结果。这一层的设计目标是让集成成本尽可能低几行代码就能用起来。这三层之间的关系是接口层接收请求隔离层准备环境执行层运行代码结果再逐层返回。每一层都有明确的职责边界方便单独测试和替换。3. 核心隔离机制与实操要点3.1 文件系统隔离让代码只看到该看的东西文件系统隔离是沙箱安全的第一道防线。LLM生成的代码如果带了个open(/etc/passwd)或者shutil.rmtree(/)没有文件系统隔离的话后果不堪设想。Kern Sandbox采用的方式是chroot加临时目录的组合。具体来说每次执行代码时创建一个临时目录把代码需要访问的文件复制进去然后用chroot把进程的根目录切换到该临时目录。这样进程看到的/实际上是那个临时目录访问/etc/passwd只会看到临时目录里不存在的路径直接报错。这里有个实操细节值得注意chroot需要root权限才能调用。如果你不想用root跑替代方案是用unshare创建mount namespace然后在namespace内部做bind mount。这种方式不需要root但需要内核支持user namespace。我在Ubuntu 22.04上实测默认配置下user namespace是可用的直接unshare --mount --map-root-user就能工作。另一个细节是临时目录的清理。代码执行完必须确保临时目录被删除否则跑几次就堆一堆垃圾。稳妥的做法是用try...finally包住执行逻辑在finally里做清理。更稳妥的是用tempfile.TemporaryDirectory上下文管理器它会在退出时自动清理即使发生异常也不遗漏。注意文件系统隔离不是万无一失的。如果临时目录里挂了敏感文件的硬链接或者通过/proc访问到了宿主信息隔离就可能被绕过。所以临时目录里只放代码明确需要的文件不要图省事把整个工作目录挂进去。3.2 网络隔离断掉外联的可能性网络隔离的必要性在于LLM生成的代码可能包含网络请求比如调用外部API、下载文件、甚至往外传数据。在沙箱场景下这些行为都应该被禁止或严格限制。最彻底的做法是创建独立的network namespace里面没有任何网络接口除了一个loopback。这样进程完全无法与外界通信。用unshare --net就能做到不需要root前提是user namespace可用。实测下来在network namespace里跑curl会直接报网络不可达效果很干净。但有时候你确实需要让沙箱内的代码访问网络比如代码逻辑本身就是要调某个API。这种情况下更合理的做法是不直接给沙箱网络权限而是通过代理转发。沙箱内的代码把请求发给一个本地代理代理做白名单过滤后再转发出去。这样既能满足功能需求又能控制访问范围。还有一种折中方案是只允许DNS和特定端口。用iptables或nftables在namespace内设置规则只放行特定目标。但这个配置起来比较繁琐而且容易出错。我的经验是除非有明确的网络需求否则默认断网是最安全也最省心的选择。3.3 资源限制防止代码把机器跑挂资源限制解决的是代码不干坏事但干傻事的问题。比如LLM生成了一段死循环代码或者申请了超大内存没有限制的话可能直接把你的开发机拖垮。CPU时间限制用setrlimit(RLIMIT_CPU)实现设置一个秒数上限超过就发SIGXCPU信号终止进程。内存限制用setrlimit(RLIMIT_AS)限制进程的虚拟内存总量。进程数限制用setrlimit(RLIMIT_NPROC)防止fork炸弹。文件大小限制用setrlimit(RLIMIT_FSIZE)防止写满磁盘。这些限制的具体数值需要根据场景调整。我一般的配置是CPU时间30秒、内存512MB、进程数64、单文件大小10MB。对于大多数LLM生成的脚本片段来说这个额度绰绰有余。如果是数据处理类的代码内存可以适当放宽到1GB或2GB。这里有个容易踩的坑RLIMIT_AS限制的是虚拟内存而Python解释器启动时就会申请相当多的虚拟内存因为要加载各种库。如果设得太小比如128MBPython可能直接启动失败。我建议先用一个宽松的值跑通然后用/usr/bin/time -v观察实际用量再逐步收紧。3.4 系统调用过滤最后一道闸门系统调用过滤是隔离机制里最细粒度的一层。Linux的seccomp允许你定义一个BPF程序对每个系统调用进行判断决定是允许、拒绝还是杀掉进程。Kern Sandbox的思路是白名单模式只允许代码正常运行必需的系统调用其他一律拒绝。比如read、write、open、close、mmap、brk这些是必须的socket、connect、execve在某些场景下这些则应该被禁止。配置seccomp有两种方式一种是直接用libseccomp库写规则另一种是用Docker风格的seccomp profile。前者更灵活后者更简单。如果不想引入额外依赖也可以用prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT)开启严格模式但这个模式限制太死很多正常程序都跑不起来。实操心得seccomp规则调试起来比较痛苦因为一旦规则写错进程可能直接被杀掉而且不一定有清晰的错误信息。我的做法是先用strace记录一次正常运行时的所有系统调用然后基于这个列表生成白名单再逐步收紧。这样比凭空写规则靠谱得多。4. 完整实操流程与关键环节4.1 环境准备与依赖安装先把基础环境搭起来。我用的是一台Ubuntu 22.04的开发机内核版本5.15user namespace默认开启。如果你用的是其他发行版需要确认/proc/sys/kernel/unprivileged_userns_clone的值是1否则普通用户无法创建namespace。依赖方面核心需要的是Python 3.8以上版本以及libseccomp开发库。安装命令如下sudo apt update sudo apt install -y python3 python3-pip libseccomp-dev pip install seccomp pyroute2seccomp这个Python库封装了libseccomp的接口用起来比直接写C方便很多。pyroute2是用来操作network namespace的如果你不需要网络隔离可以跳过。如果你打算用chroot方案而不是namespace方案还需要确保有root权限或者给Python解释器设置CAP_SYS_CHROOT能力。我推荐用namespace方案不需要提权安全性也更好。4.2 创建隔离执行环境核心的执行函数大概长这样。我先给出一个简化版的框架然后逐段解释import os import subprocess import tempfile import resource import seccomp def run_sandboxed(code, languagepython, timeout30, memory_mb512): with tempfile.TemporaryDirectory() as tmpdir: # 写入代码文件 code_file os.path.join(tmpdir, main.py) with open(code_file, w) as f: f.write(code) # 定义资源限制 def preexec(): resource.setrlimit(resource.RLIMIT_CPU, (timeout, timeout)) resource.setrlimit(resource.RLIMIT_AS, (memory_mb * 1024 * 1024, memory_mb * 1024 * 1024)) resource.setrlimit(resource.RLIMIT_NPROC, (64, 64)) resource.setrlimit(resource.RLIMIT_FSIZE, (10 * 1024 * 1024, 10 * 1024 * 1024)) os.chdir(tmpdir) # 启动子进程 result subprocess.run( [python3, main.py], cwdtmpdir, capture_outputTrue, textTrue, timeouttimeout 5, preexec_fnpreexec, env{PATH: /usr/bin:/bin, HOME: tmpdir} ) return { stdout: result.stdout, stderr: result.stderr, returncode: result.returncode }这段代码做了几件事创建临时目录、写入代码、设置资源限制、启动子进程、捕获输出。preexec_fn是在子进程fork之后、exec之前执行的所以在这里设置rlimit是生效的。但这段代码还缺少namespace隔离和seccomp过滤。加上namespace需要用unshare系统调用Python标准库没有直接封装得用ctypes调。或者更简单的方式是用unshare命令行工具包一层cmd [unshare, --mount, --net, --pid, --fork, --map-root-user, python3, main.py]这样启动的进程就在独立的mount、network、pid namespace里了。--map-root-user让当前用户在namespace内映射为root这样chroot之类的操作就能执行。4.3 注入seccomp过滤规则seccomp规则的配置是安全性的关键。下面是一个白名单示例def apply_seccomp(): f seccomp.SyscallFilter(defactionseccomp.KILL) # 允许的基础系统调用 allowed [ read, write, open, openat, close, stat, fstat, lstat, poll, lseek, mmap, mprotect, munmap, brk, rt_sigaction, rt_sigprocmask, rt_sigreturn, ioctl, access, pipe, select, sched_yield, mremap, msync, mincore, madvise, dup, dup2, nanosleep, getpid, exit, exit_group, futex, epoll_create, epoll_ctl, epoll_wait, clock_gettime, getdents, getcwd, chdir, readlink, getrlimit, getuid, getgid, geteuid, getegid, arch_prctl, set_tid_address, set_robust_list, prlimit64, getrandom, statx, rseq ] for syscall in allowed: f.add_rule(seccomp.ALLOW, syscall) f.load()这个白名单覆盖了Python解释器启动和运行基本脚本所需的大部分系统调用。注意execve不在列表里这意味着沙箱内的代码无法再启动新进程。socket和connect也不在网络访问被彻底堵死。实际使用中这个列表可能需要根据代码内容调整。比如代码里用了subprocess模块那就需要放开clone和execve但这会显著降低安全性。我的建议是尽量不放开如果确实需要执行子进程考虑用更上层的方案比如在沙箱外编排多个执行步骤而不是在沙箱内放开权限。4.4 执行与结果捕获把上面的部分串起来完整的执行流程是这样的def execute_llm_code(code, timeout30): with tempfile.TemporaryDirectory() as tmpdir: code_path os.path.join(tmpdir, main.py) with open(code_path, w) as f: f.write(code) def setup(): resource.setrlimit(resource.RLIMIT_CPU, (timeout, timeout)) resource.setrlimit(resource.RLIMIT_AS, (512*1024*1024,)*2) resource.setrlimit(resource.RLIMIT_NPROC, (64, 64)) apply_seccomp() try: proc subprocess.run( [unshare, --mount, --net, --pid, --fork, --map-root-user, python3, main.py], cwdtmpdir, capture_outputTrue, textTrue, timeouttimeout 5, preexec_fnsetup, env{PATH: /usr/bin:/bin, HOME: tmpdir, PYTHONDONTWRITEBYTECODE: 1} ) return proc.stdout, proc.stderr, proc.returncode except subprocess.TimeoutExpired: return , Execution timed out, -1注意PYTHONDONTWRITEBYTECODE这个环境变量它防止Python生成.pyc文件。如果不设这个Python会在临时目录里写缓存文件虽然临时目录会被清理但多一层写入就多一层风险。超时处理用了两层subprocess的timeout参数和RLIMIT_CPU。前者是墙钟时间后者是CPU时间。两个都设上是因为它们覆盖的场景不同如果代码在sleepCPU时间不增长但墙钟时间在走如果代码在死循环计算CPU时间会快速增长。双保险更稳妥。4.5 实测效果与性能数据我在本机上跑了一组测试对比不同隔离级别的启动开销隔离方案启动耗时内存占用隔离强度裸subprocess约15ms约8MB无rlimit限制约18ms约8MB低rlimit seccomp约22ms约9MB中上述 namespace约45ms约12MB中高Docker容器约1200ms约50MB高微虚拟机约3000ms约128MB极高从数据可以看出namespace方案的启动开销只有Docker的二十五分之一左右。对于需要频繁执行代码的场景比如Agent每一步都要跑一段验证代码这个差距是决定性的。Docker方案跑二十次的时间namespace方案能跑五百次。功能测试方面我试了几类典型的LLM生成代码数据处理脚本、正则匹配、文件操作、网络请求。前两类正常执行文件操作被限制在临时目录内网络请求直接失败。这正是预期的行为。5. 常见问题与排查技巧实录5.1 启动失败类问题问题一unshare: unshare failed: Operation not permitted这个报错通常是因为user namespace被禁用了。检查/proc/sys/kernel/unprivileged_userns_clone的值如果是0就说明普通用户不能创建namespace。临时开启用sudo sysctl kernel.unprivileged_userns_clone1永久生效需要写进/etc/sysctl.d/下的配置文件。有些发行版比如较新的Ubuntu用的是另一个参数kernel.apparmor_restrict_unprivileged_userns。如果前者不存在检查后者。AppArmor的限制有时候更隐蔽需要同时调整AppArmor profile。问题二python3: error while loading shared libraries在namespace里启动Python时如果mount namespace没有正确设置动态链接库可能找不到。原因是unshare --mount创建了一个空的mount namespace里面的/usr/lib等路径是空的。解决办法是在namespace内做bind mount把宿主的关键目录挂进去unshare --mount --map-root-user bash -c mount --bind /usr /usr mount --bind /lib /lib mount --bind /lib64 /lib64 python3 main.py 或者更简单的方式是不用--mount只用--net和--pid。文件系统隔离用chroot或临时目录方案替代。问题三seccomp规则导致进程被静默杀死如果进程没有任何输出就退出了returncode是-9或-31很可能是seccomp规则太严某个必需的系统调用被拒绝了。排查方法是用strace -f跟踪一次正常运行把用到的系统调用都记录下来然后和白名单对比找出缺失的项。我踩过的一个具体坑是rseq这个系统调用。较新的glibc会用它来做线程调度优化如果不在白名单里Python启动时就会挂掉。类似的还有statx、prlimit64这些较新的调用。建议白名单里把常见的新式系统调用都加上。5.2 执行异常类问题问题四代码正常但输出为空这种情况通常是输出缓冲导致的。Python在非终端环境下默认使用块缓冲如果代码执行完就退出缓冲区可能没来得及刷新。解决办法是在代码末尾加sys.stdout.flush()或者在启动Python时加-u参数强制无缓冲python3 -u main.py问题五内存限制导致Python启动失败前面提到过RLIMIT_AS设得太小会让Python起不来。具体需要多少取决于Python版本和加载的库。我实测Python 3.10启动大约需要80MB虚拟内存加上常用标准库会到150MB左右。如果代码里import了numpy、pandas这类重型库可能需要500MB以上。建议先用resource.getrusage观察实际用量再定值。问题六临时目录权限问题在namespace内映射为root后创建的文件属主是root但临时目录本身是普通用户创建的。这可能导致权限不一致。解决办法是在preexec_fn里用os.chown把临时目录改成映射后的uid或者干脆在namespace内重新创建目录。5.3 安全性相关注意事项注意事项一不要信任任何输入沙箱的假设前提是代码可能是恶意的。即使当前场景下LLM不会生成恶意代码也不应该放松隔离。因为模型行为不可预测而且提示注入攻击可能导致模型被诱导生成危险代码。隔离机制要按最坏情况设计。注意事项二及时更新内核和库namespace和seccomp的隔离能力依赖内核实现。历史上出现过多次namespace逃逸漏洞比如CVE-2022-0185及时打补丁很重要。生产环境建议用较新的LTS内核并开启自动安全更新。注意事项三日志记录不能少每次执行都应该记录执行的代码内容、执行时间、资源用量、返回结果。这些日志在排查问题和审计时非常有用。特别是当沙箱被绕过时日志是追溯的唯一线索。问题速查表现象可能原因排查方向进程立即退出无输出seccomp规则过严用strace对比系统调用报错Operation not permittednamespace未启用检查sysctl参数找不到共享库mount namespace为空添加bind mount输出为空但代码正常缓冲未刷新加-u参数或flush内存不足报错RLIMIT_AS过小观察实际用量后调整超时无响应死循环或阻塞IO检查CPU时间和墙钟时间6. 集成到LLM应用中的实践建议6.1 与Agent框架的对接方式如果你在用LangChain、LlamaIndex这类框架做Agent沙箱可以封装成一个Tool。Agent决定要执行代码时调用这个Tool传入代码字符串拿到执行结果。关键是要在Tool的描述里明确告诉模型代码会在隔离环境中执行无法访问网络文件操作限于临时目录。这样模型生成代码时会有所收敛减少无效尝试。我实际用下来的体会是给模型加上这些约束说明后生成代码的一次通过率明显提高。因为模型知道不能联网就不会去写requests.get知道文件系统受限就会用相对路径而不是绝对路径。6.2 多语言支持的扩展思路上面的例子是Python扩展到其他语言思路类似。JavaScript可以用Node.jsJava可以用java命令直接跑单文件Java 11支持Shell脚本直接bash执行。核心是把启动解释器这一步参数化隔离层和执行层保持不变。需要注意的是不同语言的资源需求差异很大。JVM启动就要几百MB内存Node.js相对轻量Shell最轻。资源限制的默认值应该按语言区分不能一刀切。6.3 性能优化的几个方向如果执行频率很高可以考虑预热提前启动好解释器进程池代码来了直接投递省去启动开销。但预热会带来状态残留问题需要确保每次执行前重置环境。另一个方向是复用临时目录减少文件创建删除的开销但要注意清理干净。还有一个容易被忽略的点是输出截断。如果代码输出了几百MB的内容捕获到内存里会撑爆。应该在读取时设上限超过就截断并标记。我一般设1MB上限对于调试用途足够了。6.4 什么场景不适合用这个方案如果你的代码需要GPU、需要访问特定硬件、需要长时间运行几分钟以上、需要大量内存几个GB那这个轻量沙箱就不合适了。这些场景应该考虑容器或虚拟机方案。选型的关键是认清自己的需求边界不要用一个工具硬套所有场景。另外如果代码来源完全可信比如你自己写的那也没必要上沙箱直接跑就行。沙箱的价值在于处理不可信输入没有这个前提就是过度设计。我在实际项目里把Kern Sandbox这套思路用在了代码助手的验证环节。用户让模型生成代码后先在沙箱里跑一遍确认能正常执行再展示给用户。这样避免了用户拿到一段跑不通的代码体验提升很明显。踩过的最大坑是早期没做网络隔离模型生成的代码里偶尔带个pip install在沙箱里跑起来后试图联网卡住直到超时。加上network namespace后这个问题彻底消失。另一个教训是seccomp白名单要留有余地我一开始追求最小化结果很多正常代码跑不起来后来放宽了一些才找到平衡点。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →