资讯动态

从Docker到gVisor:构建不可信代码执行沙箱的防御纵深

发布时间:2026/9/28 6:52:02 来源:尧图企业网站定制
1. 为什么“能跑就行”的容器方案迟早要出事我最早接触容器化执行环境是在做在线代码评测系统的时候。当时团队的想法很简单用户提交一段代码我们把它扔进一个 Docker 容器里跑跑完把结果取出来容器一删干净利落。这套方案上线头三个月确实没出过什么大问题直到有一天运维同事发现某台评测机的 CPU 占用率长时间维持在 100%排查之后发现是一个用户提交的代码里藏了一个 fork 炸弹的变种虽然容器有资源限制但进程数限制没配好容器内部的进程表被瞬间打满连带影响了宿主机上的其他容器。这件事让我意识到一个很现实的问题Docker 提供的隔离和我们通常理解的“安全沙箱”之间存在一条不小的鸿沟。Docker 依赖的是 Linux 内核的 namespace 和 cgroup 机制本质上是一种“软隔离”——它把进程的视野做了裁剪让容器里的进程看不到外面的世界但这些进程共享的是同一个内核。一旦内核层面出现漏洞或者容器配置存在疏漏逃逸就不是什么天方夜谭。这篇文章想聊的就是从一个普通 Docker 沙箱出发一步步把防御纵深做起来最终引入 gVisor 这类用户态内核方案构建一套相对完整的执行沙箱防御架构。适合正在做在线评测、CI/CD 流水线、AI Agent 代码执行、插件系统等需要“跑别人代码”场景的开发者参考。不管你现在的方案是裸 Docker 还是已经上了 Kubernetes这里面的思路和踩坑记录应该都能对上号。2. 执行沙箱的威胁模型先搞清楚敌人在哪2.1 我们到底在防什么在动手改架构之前我习惯先把威胁模型列清楚。对于“执行不可信代码”这个场景攻击面大致可以分成这么几类容器逃逸利用内核漏洞、配置错误比如挂载了 docker.sock、开了 privileged 模式从容器里跑到宿主机上。资源耗尽fork 炸弹、内存炸弹、磁盘写满、文件描述符耗尽把宿主机拖垮。横向移动容器之间网络互通一个容器被攻陷后去探测内网其他服务。数据泄露容器里能读到不该读的文件比如宿主机的环境变量、挂载的密钥文件。持久化攻击者在容器里留下后门下次调度到同一台机器时继续作恶。这五类里资源耗尽和容器逃逸是最常见的也是防御的重中之重。数据泄露和横向移动更多依赖网络策略和挂载策略而持久化则需要在容器生命周期管理上做文章。2.2 Docker 默认配置到底有多“裸”很多人以为docker run出来的容器就是安全的其实默认配置下Docker 给的东西相当宽松。我整理了一张表对比一下默认状态和加固后的状态配置项默认值风险加固建议用户root容器内 root 权限过大指定非 root 用户Capabilities保留默认集合包含 NET_RAW 等危险能力全部 drop 后按需 add进程数限制无fork 炸弹可打满设置 pids-limit内存限制无内存炸弹可拖垮宿主机设置 memory 和 memory-swap网络bridge 模式容器间可互通使用 none 或自定义网络文件系统可写可植入后门只读根文件系统 tmpfsseccomp未启用可调用危险系统调用启用默认或自定义 profileAppArmor/SELinux未启用缺少强制访问控制启用并配置策略这张表里的每一项都是我在实际环境中踩过坑之后加上的。比如 pids-limit一开始觉得没必要直到被 fork 炸弹教育了一次。再比如只读根文件系统有一次发现容器被删了之后宿主机上居然还留着一个定时任务查了半天才发现是容器里写进去的。2.3 从“软隔离”到“硬隔离”的谱系理解了威胁模型之后防御思路就清晰了我们要在“不可信代码”和“宿主机内核”之间尽可能多地插入隔离层。从弱到强大致可以排成这么一条谱系普通 Docker 容器namespace cgroup共享内核。加固后的 Docker 容器加上 seccomp、AppArmor、capabilities 裁剪、只读文件系统。用户态内核方案gVisor在应用和真实内核之间插一层用户态内核系统调用被拦截并模拟。轻量虚拟机方案Kata Containers、Firecracker每个沙箱跑在一个独立的内核里硬件虚拟化隔离。独立物理机/虚拟机最强隔离但成本和调度开销最大。选择哪一层取决于你的场景对隔离强度的要求和能承受的性能开销。在线评测这种场景gVisor 通常是性价比最高的选择如果是多租户的 AI Agent 执行环境可能要考虑 Kata 或 Firecracker。3. Docker 沙箱加固把默认配置的窟窿一个个堵上3.1 最小权限原则的落地用户、Capabilities 与 seccomp先说用户。容器里默认是 root这个 root 虽然和宿主机的 root 不是一回事因为 user namespace 的存在但在很多配置下容器内的 root 和宿主机的 root 共享同一个 UID 映射一旦逃逸就是真 root。所以第一步在 Dockerfile 里创建一个普通用户用USER指令切换过去。FROM python:3.11-slim RUN groupadd -r runner useradd -r -g runner -d /home/runner -s /sbin/nologin runner WORKDIR /home/runner COPY --chownrunner:runner . . USER runner这里有个细节-s /sbin/nologin是为了防止攻击者拿到一个可交互的 shell。虽然容器里通常没有 sshd但万一有别的途径能执行命令一个 nologin 的 shell 能多挡一层。接下来是 Capabilities。Linux 把 root 的权限拆成了几十个 capabilityDocker 默认给容器保留了其中一部分。对于执行不可信代码的场景最稳妥的做法是全部 drop然后按需 add。比如一个纯计算的沙箱可能一个 capability 都不需要。docker run --cap-dropALL --cap-addNET_BIND_SERVICE ...NET_BIND_SERVICE只在需要绑定 1024 以下端口时才加大多数沙箱场景根本用不上。我见过有人为了图省事直接--privileged那基本等于把宿主机拱手让人绝对不要在生产环境这么干。seccomp 是另一道关键防线。它负责过滤系统调用把那些沙箱里根本用不到的危险调用直接拦掉。Docker 自带一个默认的 seccomp profile已经屏蔽了不少危险调用但如果你要跑的是任意代码默认 profile 还是太宽松。我的做法是基于默认 profile 再收紧把ptrace、mount、reboot、kexec_load这类调用全部禁掉。{ defaultAction: SCMP_ACT_ERRNO, architectures: [SCMP_ARCH_X86_64], syscalls: [ { names: [read, write, open, close, stat, fstat, mmap, mprotect, munmap, brk, exit, exit_group, rt_sigreturn, rt_sigaction, rt_sigprocmask, ioctl, access, pipe, select, sched_yield, clone, execve, wait4, kill, uname, fcntl, getdents, getcwd, chdir, rename, mkdir, rmdir, creat, link, unlink, symlink, readlink, chmod, chown, lseek, getpid, getppid, getuid, geteuid, getgid, getegid, gettimeofday, getrlimit, getrusage, sysinfo, times, getgroups, setpgid, getpgid, setsid, getsid, futex, sched_getaffinity, sched_setaffinity, epoll_create, epoll_ctl, epoll_wait, arch_prctl, set_tid_address, set_robust_list, prlimit64, getrandom, statfs, fstatfs, tgkill, openat, newfstatat, readlinkat, faccessat, dup, dup2, dup3, pipe2, epoll_create1, prctl, getdents64, clock_gettime, clock_getres, clock_nanosleep, nanosleep, socket, connect, accept, sendto, recvfrom, sendmsg, recvmsg, shutdown, bind, listen, getsockname, getpeername, socketpair, setsockopt, getsockopt, exit_group], action: SCMP_ACT_ALLOW } ] }这个白名单看起来很长但核心思路是只放行程序正常运行必需的调用其余一律返回错误。注意clone这个调用要特别小心它既能创建线程也能创建进程如果沙箱里不需要多进程可以考虑用clone3配合参数过滤或者干脆限制进程数。3.2 资源限制别让一个 fork 炸弹毁掉整台机器资源限制这块我吃过最大的亏就是 pids-limit。当时觉得容器有内存限制就够了结果 fork 炸弹不占多少内存但能把进程表打满。后来加了--pids-limit64世界就清净了。docker run \ --memory512m \ --memory-swap512m \ --cpus1.0 \ --pids-limit64 \ --ulimit nofile256:256 \ --ulimit nproc64:64 \ ...这里有几个参数值得展开说。--memory-swap设成和--memory一样是为了禁用 swap防止容器用 swap 绕过内存限制。--cpus1.0限制的是 CPU 时间片不是核数实际效果是容器最多用满一个核。--ulimit nofile限制文件描述符数量防止打开大量文件耗尽系统资源。--ulimit nproc是另一道进程数防线和 pids-limit 配合使用。还有一个容易被忽略的点磁盘写入。容器默认的存储驱动是可写的如果代码往磁盘里狂写可能把宿主机的磁盘写满。我的做法是把根文件系统设成只读然后挂一个大小受限的 tmpfs 作为可写目录。docker run \ --read-only \ --tmpfs /tmp:rw,noexec,nosuid,size64m \ --tmpfs /home/runner:rw,noexec,nosuid,size64m \ ...注意 tmpfs 上要加noexec防止攻击者往 /tmp 里写一个可执行文件然后运行。nosuid则是防止 setuid 程序提权。3.3 网络隔离默认 bridge 网络是个隐患Docker 默认的 bridge 网络让所有容器在同一个网段里容器之间可以互相访问。这在微服务场景下是好事但在执行不可信代码的场景下就是灾难——一个容器被攻陷后可以直接扫描并攻击同网段的其他容器。最彻底的做法是--networknone完全断网。但很多场景下沙箱需要联网比如下载依赖、调用外部 API。这时候可以用自定义网络配合 iptables 规则只放行必要的出站流量。docker network create --internal sandbox-net docker run --networksandbox-net ...--internal标志会创建一个没有外部路由的网络容器之间可以通信但无法访问外网。如果确实需要外网访问可以在宿主机上跑一个代理让容器通过代理出去这样所有流量都经过代理审计。提示如果沙箱需要访问特定域名可以在代理层做白名单而不是在容器里配 DNS。容器里的 DNS 配置很容易被绕过。4. gVisor 登场给系统调用加一层“翻译官”4.1 gVisor 到底解决了什么问题Docker 加固做到极致本质上还是在同一个内核上跑代码。只要内核有漏洞逃逸就有可能。gVisor 的思路完全不同它在应用和真实内核之间插了一个用户态的“内核”应用发出的系统调用不会直接到达宿主机内核而是被 gVisor 拦截、解析、模拟。打个比方Docker 像是给房子换了把好锁但房子还是那栋房子墙是共用的。gVisor 则是在房子外面又盖了一层壳所有进出都要经过这层壳的检查。即使应用在内核层面发起攻击打到的是 gVisor 这层壳而不是真正的内核。gVisor 的核心组件有两个Sentry和Gofer。Sentry 是那个用户态内核负责处理系统调用Gofer 负责文件系统访问把文件操作代理到宿主机上。这种架构下应用能看到的“内核”是 Sentry 模拟出来的和真实内核隔离开。4.2 在 Docker 里跑 gVisorrunsc 的安装与配置gVisor 通过一个叫runsc的运行时和 Docker 集成。安装过程不复杂但有几个坑要注意。# 下载 runsc wget https://storage.googleapis.com/gvisor/releases/release/latest/x86_64/runsc wget https://storage.googleapis.com/gvisor/releases/release/latest/x86_64/runsc.sha512 sha512sum -c runsc.sha512 chmod x runsc sudo mv runsc /usr/local/bin/ # 配置 Docker 使用 runsc sudo runsc install sudo systemctl restart dockerrunsc install会往 Docker 的配置文件里写一个 runtime 条目。装完之后用--runtimerunsc就能让容器跑在 gVisor 上。docker run --runtimerunsc --rm hello-world第一次跑可能会遇到权限问题因为 gVisor 需要一些特定的内核特性。如果报错说failed to start because virtualization support not detected那多半是宿主机没开虚拟化或者 runsc 的配置有问题。gVisor 有两种模式ptrace 模式和KVM 模式。ptrace 模式不需要硬件虚拟化但性能差一些KVM 模式需要/dev/kvm性能更好。在云主机上很多实例默认不开嵌套虚拟化这时候只能用 ptrace 模式。# 强制使用 ptrace 模式 sudo runsc --platformptrace install4.3 gVisor 的性能开销与适用边界gVisor 不是没有代价的。系统调用被拦截和模拟意味着每次调用都有额外的开销。根据我的实测在 CPU 密集型的场景下gVisor 的性能大约是原生 Docker 的 70% 到 90%具体取决于系统调用的频率。I/O 密集型的场景开销更大因为文件操作要经过 Gofer 代理。场景原生 DockergVisor (ptrace)gVisor (KVM)纯计算无系统调用100%95%98%频繁文件读写100%60%75%网络请求100%70%85%进程创建100%50%70%这张表是我在自己的测试环境里跑出来的硬件配置不同会有差异但趋势是一致的。所以 gVisor 适合那些系统调用不那么频繁的场景比如代码评测、函数计算。如果是数据库这类 I/O 密集型的负载gVisor 就不太合适了。还有一个坑gVisor 对某些系统调用的支持不完整。比如io_uring、某些ioctl命令、部分网络协议选项gVisor 可能不支持或者行为不一致。我在跑一个需要epoll边缘触发的服务时就遇到过 gVisor 下行为异常的情况。所以上 gVisor 之前一定要把目标负载在 gVisor 下完整跑一遍测试。5. 防御架构的组装从单机到集群5.1 单机架构Docker gVisor 资源限制单机场景下我的典型配置是这样的docker run \ --runtimerunsc \ --rm \ --networknone \ --memory512m \ --memory-swap512m \ --cpus1.0 \ --pids-limit64 \ --read-only \ --tmpfs /tmp:rw,noexec,nosuid,size64m \ --cap-dropALL \ --security-opt seccomp/path/to/seccomp.json \ --security-opt no-new-privileges \ --user 1000:1000 \ sandbox-image:latest这里--security-opt no-new-privileges是个容易被忽略但很重要的选项它防止容器里的进程通过 setuid 程序获得额外权限。--user 1000:1000指定非 root 用户运行和 Dockerfile 里的 USER 指令配合使用。这套配置下即使代码里有逃逸尝试也要先过 seccomp 过滤再过 capabilities 检查然后打到 gVisor 的 Sentry 上最后才可能触及真实内核。攻击面被压缩了很多。5.2 集群架构Kubernetes 上的沙箱调度到了 Kubernetes 环境事情会复杂一些。Kubernetes 本身不直接支持 gVisor需要借助 RuntimeClass 来指定运行时。apiVersion: node.k8s.io/v1 kind: RuntimeClass metadata: name: gvisor handler: runsc然后在 Pod 的 spec 里引用这个 RuntimeClassapiVersion: v1 kind: Pod metadata: name: sandbox-pod spec: runtimeClassName: gvisor containers: - name: runner image: sandbox-image:latest resources: limits: memory: 512Mi cpu: 1 securityContext: runAsNonRoot: true runAsUser: 1000 readOnlyRootFilesystem: true allowPrivilegeEscalation: false capabilities: drop: [ALL]这里allowPrivilegeEscalation: false对应单机场景的no-new-privilegesreadOnlyRootFilesystem: true对应--read-only。Kubernetes 的 securityContext 能覆盖大部分加固需求但 seccomp 的配置要单独通过 annotation 或者 PodSecurityPolicy 来做。集群环境下还有一个额外的问题节点亲和性。不是所有节点都装了 runsc所以要用 nodeSelector 或者 affinity 把沙箱 Pod 调度到支持 gVisor 的节点上。spec: nodeSelector: sandbox: gvisor给装了 runsc 的节点打上sandboxgvisor的标签调度器就会把沙箱 Pod 放到这些节点上。这样普通负载和沙箱负载可以混布在同一个集群里资源利用率更高。5.3 监控与审计沙箱里发生了什么沙箱跑起来之后还得知道里面发生了什么。gVisor 提供了runsc debug命令可以查看 Sentry 的状态、系统调用统计等信息。runsc --root/var/run/docker/runtime-runc/moby debug --strace sandbox-container-id--strace会打印系统调用日志调试的时候很有用但生产环境不要开日志量太大。更实用的做法是收集容器的退出码、资源使用峰值、运行时长这些指标接入 Prometheus 或者类似的监控系统。还有一个审计点代码提交记录。每次执行不可信代码都应该记录是谁提交的、提交了什么、执行了多久、结果如何。这些信息在出问题的时候是排查的关键线索。我一般会在沙箱外面套一层服务由这个服务负责接收代码、调度沙箱、收集结果、记录日志沙箱本身只负责执行。6. 踩坑实录与常见问题排查6.1 gVisor 下程序跑不起来怎么办这是最常见的问题。程序在普通 Docker 里跑得好好的换到 gVisor 就报错。原因通常是 gVisor 不支持某个系统调用或者行为有差异。排查步骤先用runsc --strace看程序卡在哪个系统调用上。查 gVisor 的官方文档看这个调用是否支持。如果是不支持的特性考虑换一种实现方式或者退回普通 Docker 加更严格的 seccomp。我遇到过一个案例程序用了io_uring做异步 I/OgVisor 当时还不支持结果程序直接挂起。后来改成epoll就正常了。所以上 gVisor 之前一定要确认目标负载用到的系统调用在支持列表里。6.2 容器逃逸的常见入口与封堵虽然 gVisor 能挡住大部分逃逸尝试但配置不当还是会留后门。我整理了几个常见的逃逸入口逃逸入口原理封堵方法挂载 docker.sock通过 Docker API 创建特权容器绝不挂载 docker.sockprivileged 模式获得所有 capabilities禁用 privileged内核漏洞利用未修复的内核漏洞及时更新内核用 gVisor 隔离挂载宿主机目录读写宿主机文件只挂载必要的目录只读挂载cgroup 逃逸利用 cgroup release_agent用 cgroup v2限制写入其中挂载 docker.sock 是最危险的因为一旦容器里能访问 Docker API就等于拿到了宿主机的 root。我见过不少 CI 系统为了方便把 docker.sock 挂进构建容器里这等于把整个宿主机暴露给构建脚本。如果确实需要在容器里构建镜像用 Kaniko 或者 Buildah 这类不需要 Docker daemon 的工具。6.3 性能调优让沙箱跑得更快一点gVisor 的性能开销主要来自系统调用的拦截和模拟。有几个调优方向用 KVM 模式如果宿主机支持/dev/kvmKVM 模式比 ptrace 模式快不少。减少系统调用在应用层面优化比如用批量 I/O 代替频繁的小 I/O。调整 Sentry 的配置gVisor 有一些可调参数比如网络缓冲区大小、文件缓存策略可以根据负载特点调整。预热gVisor 的 Sentry 启动有一定开销如果沙箱是短生命周期的可以考虑池化提前启动好一批 Sentry 备用。池化这个思路我在在线评测场景里用过效果不错。维护一个沙箱池每个沙箱预先启动好 gVisor 和运行时环境有任务来了直接分配省去了启动开销。当然池化也带来了新的问题比如沙箱复用时的状态清理这个要小心处理确保上一个任务的数据不会泄露给下一个任务。6.4 常见问题速查表问题现象可能原因解决方法容器启动报 permission deniedseccomp 拦截了必要调用检查 seccomp 白名单补充缺失的调用gVisor 下网络不通gVisor 网络栈配置问题检查 runsc 的网络配置确认支持所需协议容器内时间不对gVisor 时间模拟有偏差挂载宿主机的 /etc/localtime或接受偏差内存限制不生效cgroup 配置问题检查 cgroup v1/v2 配置确认 memory 控制器启用容器退出码异常被 OOM killer 杀掉调大内存限制或优化程序内存使用文件写入失败根文件系统只读把需要写入的目录挂成 tmpfs 或 volume这张表里的问题我都实际遇到过尤其是 seccomp 拦截导致程序启动失败排查起来比较费劲因为错误信息往往不直接指向 seccomp。我的经验是如果程序在普通 Docker 里能跑加了 seccomp 之后跑不起来那大概率就是 seccomp 的问题用strace对比一下系统调用就能定位。7. 一些个人体会这套架构我从最早的裸 Docker 一路演进过来中间踩的坑比这篇文章里写的多得多。最大的体会是安全是一个持续的过程不是一次配置就能搞定的。内核在更新攻击手法在进化今天安全的配置明天可能就有新的绕过方式。所以定期 review 安全配置、关注 gVisor 和内核的更新、保持对新型攻击手法的敏感度这些日常功课不能省。另一个体会是不要追求绝对安全要追求风险可控。gVisor 也不是万能的它自己也有漏洞。但对于大多数场景来说gVisor 加上合理的配置已经把风险降到了可接受的水平。如果非要追求绝对隔离那就得上独立物理机了但成本和调度复杂度会急剧上升。找到适合自己场景的平衡点比盲目堆砌防御手段更重要。最后分享一个小技巧在沙箱里跑代码之前先用一个轻量的静态分析工具扫一遍把明显危险的模式比如fork循环、大量文件写入、网络扫描标记出来。这不能替代沙箱隔离但能提前拦掉一部分低级攻击减轻沙箱的压力。我在评测系统里加了这个环节之后沙箱的异常退出率明显下降了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑