运维知识
悠悠
2026年6月2日

Docker 代码沙箱防逃逸实战:从 Namespace 到 gVisor,我是怎么一层层锁死容器的

前阵子有个做在线编程平台的朋友找到我,说他们平台要支持用户在线提交代码并执行,问我用 Docker 跑用户代码安不安全。我当时就笑了——这事儿我太熟了,之前在做一个类似 LeetCode 的 OJ 系统的时候,就踩过不少坑。

说白了,Docker 容器不是什么铜墙铁壁,它本质上就是跑在宿主机内核上的进程,只不过套了几层"隔离马甲"。你要是觉得 docker run 一下就万事大吉了,那迟早要出事。今天我就把自己在生产环境中是怎么一步步加固 Docker 代码沙箱的经验,原原本本讲出来。

先搞清楚一个问题:Docker 容器到底隔离了什么?

很多人对 Docker 有个误解,觉得容器就是轻量级虚拟机。这个想法很危险。虚拟机有自己独立的内核,而容器是共享宿主机内核的,区别大了去了。

Docker 的隔离主要靠两样东西:NamespaceCgroup

Namespace 做的是资源视角的隔离,让容器里的进程觉得自己是系统唯一的主人。Linux 目前提供了 6 个主要的 Namespace:

  • PID Namespace:容器里的进程看不到宿主机和其他容器的进程
  • Mount Namespace:容器有自己独立的文件系统挂载点
  • Network Namespace:容器有独立的网络栈、IP地址、端口
  • IPC Namespace:进程间通信隔离
  • UTS Namespace:主机名隔离
  • User Namespace:用户和用户组隔离

Cgroup 做的是资源使用的限制,防止一个容器把宿主机的 CPU、内存、磁盘 IO 全吃光。

听着挺完善的对吧?但问题在于,这些隔离都是"软隔离",不是"硬隔离"。共享内核意味着,一旦内核有漏洞,或者你的配置有疏漏,容器里的恶意代码就有可能突破这些限制,逃到宿主机上。

我之前看过一个案例,某在线编程平台因为配置不当,用户通过 Python 的 os.system() 直接在宿主机上执行了命令,把整台服务器搞崩了。所以,做代码沙箱,安全这根弦必须绷紧。

第一道锁:资源限制,别让恶意代码把机器吃干抹净

做代码沙箱,最基本的就是限制资源。你想想,要是用户提交一个死循环,或者疯狂 fork 进程的 fork 炸弹,不限制的话宿主机直接就废了。

我一般会在 docker run 的时候加上这些参数:

docker run \
  --memory="512m" \
  --memory-swap="512m" \
  --cpus="1" \
  --pids-limit="50" \
  --cpu-shares=512 \
  sandbox-runner

逐个解释下:

  • --memory="512m":限制容器最多使用 512MB 内存,超过就直接 OOM Kill,不给任何商量余地
  • --memory-swap="512m":这个和 memory 设成一样的值,意思就是禁用 swap。别小看这个,有些恶意代码会利用 swap 来绕过内存限制
  • --cpus="1":最多使用 1 个 CPU 核心的算力
  • --pids-limit="50":限制容器内最多 50 个进程。这个是防 fork 炸弹的关键,:(){ :|:& };: 这种东西直接就废了
  • --cpu-shares=512:CPU 时间的权重分配,多个容器竞争 CPU 时按这个比例来

还有一点容易忽略的,磁盘 IO 也得限制:

# 创建限速的 cgroup
mkdir /sys/fs/cgroup/blkio/sandbox
echo "8:0 1048576" > /sys/fs/cgroup/blkio/sandbox/blkio.throttle.read_bps_device

不过说实话,磁盘 IO 限制在实际操作中比较麻烦,我更倾向于直接把容器的文件系统设成只读,后面会讲。

第二道锁:Capability 裁剪,别给容器不需要的权限

Linux 把 root 的权限拆分成了几十个 Capability,Docker 默认只保留了容器运行所需的一小部分。但即便如此,默认配置下容器还是有一些不必要的权限。

你可以用这个命令看看默认情况下容器有哪些 Capability:

docker run --rm alpine capsh --print

我这边输出大概有这些:CAP_CHOWN, CAP_DAC_OVERRIDE, CAP_FSETID, CAP_FOWNER, CAP_MKNOD, CAP_NET_RAW, CAP_SETGID, CAP_SETUID, CAP_SETFCAP, CAP_SETPCAP, CAP_NET_BIND_SERVICE, CAP_SYS_CHROOT, CAP_KILL, CAP_AUDIT_WRITE

对于代码沙箱来说,这里面很多都是多余的。我的做法是把所有 Capability 都去掉,只加回必须的:

docker run \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  sandbox-runner

--cap-drop=ALL 先把所有权限都干掉,然后 --cap-add=NET_BIND_SERVICE 只加回绑定 1024 以下端口的权限(如果你的沙箱不需要网络,这个也省了)。

千万别用 --privileged 参数!这个参数等于把所有安全机制全关了,容器里的 root 和宿主机的 root 基本没区别。我见过有人在生产环境用 --privileged 跑容器,被领导骂了三天。

第三道锁:Seccomp 系统调用过滤,把攻击面缩到最小

Seccomp 是我最喜欢的一个安全机制。它可以在内核层面限制容器进程能调用哪些系统调用,相当于给容器加了一层"系统调用防火墙"。

Docker 默认就启用了 Seccomp,使用一个白名单配置文件,默认会屏蔽大约 44 个危险或不常用的系统调用。你可以通过 docker info 确认:

Security Options:
 seccomp
  Profile: default

但是,默认配置对于代码沙箱来说还是太宽松了。我一般会写一个自定义的 Seccomp Profile,只允许代码执行真正需要的系统调用。

举个实际的例子,如果沙箱只用来运行 Python 代码,我可以通过 strace 先抓取 Python 运行时需要哪些系统调用:

strace -c -f python3 /tmp/test.py 2>&1 | head -50

然后根据抓取结果编写 Seccomp Profile。一个精简版的 Profile 大概长这样:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": ["SCMP_ARCH_X86_64"],
  "syscalls": [
    {
      "names": [
        "read", "write", "open", "close", "fstat", "lseek",
        "mmap", "mprotect", "munmap", "brk", "rt_sigaction",
        "rt_sigprocmask", "ioctl", "access", "pipe", "select",
        "poll", "mremap", "nanosleep", "clock_gettime",
        "clone", "fork", "vfork", "execve", "exit", "wait4",
        "uname", "fcntl", "flock", "fsync", "dup", "dup2",
        "getpid", "getppid", "getuid", "getgid", "geteuid",
        "getegid", "getrlimit", "gettimeofday", "arch_prctl",
        "set_tid_address", "set_robust_list", "futex",
        "clock_getres", "clock_nanosleep"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

关键点在于 defaultAction 设成了 SCMP_ACT_ERRNO,意思就是默认拒绝所有系统调用,只在白名单里的才放行。这比默认配置安全得多。

使用自定义 Profile 启动容器:

docker run \
  --security-opt seccomp=/path/to/seccomp-profile.json \
  sandbox-runner

有个真实的案例很有说服力:CVE-2017-16995 这个内核漏洞,攻击者可以通过 bpf 系统调用实现提权。但 Docker 默认的 Seccomp 配置屏蔽了 bpf 系统调用,所以这个漏洞在 Docker 容器里根本没法利用。这就是 Seccomp 的价值。

不过写 Seccomp Profile 是个苦力活,不同语言运行时需要的系统调用不一样,你得一个一个测。我建议先用 SCMP_ACT_LOG 模式跑一段时间收集日志,确认没问题再切换到 SCMP_ACT_ERRNO

第四道锁:AppArmor / SELinux,强制访问控制再加一道门

Seccomp 管的是系统调用,AppArmor 管的是资源访问。它可以在文件读写、网络访问、Capability 等层面做更细粒度的限制。

Ubuntu 默认使用 AppArmor,CentOS/RHEL 默认使用 SELinux。Docker 在 Ubuntu 上默认会给容器加载一个 AppArmor Profile,叫做 docker-default

对于代码沙箱,我一般会写一个更严格的自定义 AppArmor Profile:

#include <tunables/global>

profile sandbox-profile flags=(attach_disconnected,mediate_deleted) {
  #include <abstractions/base>

  # 只允许读 /usr 和 /lib
  /usr/** r,
  /lib/** r,
  /lib64/** r,

  # 允许写临时目录
  /tmp/** rw,
  /dev/null rw,
  /dev/urandom r,

  # 禁止网络访问(除了已建立的连接)
  deny network,
  deny network inet,
  deny network inet6,

  # 禁止 ptrace(防止调试其他进程)
  deny ptrace,

  # 禁止 mount
  deny mount,

  # 禁止加载内核模块
  deny /sys/module/** rw,
}

加载并使用这个 Profile:

# 编译并加载 Profile
apparmor_parser -r /etc/apparmor.d/sandbox-profile

# 使用 Profile 启动容器
docker run \
  --security-opt apparmor=sandbox-profile \
  sandbox-runner

AppArmor 还有个好处是它的日志很好用。当容器进程尝试访问被禁止的资源时,/var/log/syslog/var/log/audit/audit.log 里会有详细记录,方便你排查问题。

如果你的系统是 CentOS,那就用 SELinux,思路是一样的,只是配置方式不同。SELinux 用标签和策略来管理访问控制,比 AppArmor 更严格但也更复杂。不过对于代码沙箱场景,AppArmor 够用了。

第五道锁:User Namespace,让容器里的 root 变成宿主机的 nobody

这是个很容易被忽略但非常重要的安全机制。默认情况下,Docker 容器里的 root 用户(UID 0)和宿主机的 root 用户在文件权限上是等价的。也就是说,一旦容器逃逸,攻击者拿到的是宿主机的 root 权限,想想都后怕。

User Namespace 的作用是把容器内的 UID 映射到宿主机的非特权 UID。比如容器内的 root(UID 0)在宿主机上实际是 UID 100000,这样就算逃逸了,攻击者在宿主机上也没有任何特权。

Docker 启用 User Namespace 的方式:

# 先在 /etc/docker/daemon.json 中配置
{
  "userns-remap": "default"
}

# 重启 Docker
systemctl restart docker

Docker 会自动创建一个叫 dockremap 的用户,并配置 subuid 和 subgid 映射。你可以验证一下:

cat /etc/subuid
# dockremap:100000:65536

cat /etc/subgid
# dockremap:100000:65536

这意味着容器内的 UID 0-65535 会映射到宿主机的 UID 100000-165535。

启用 User Namespace 之后有个副作用:一些需要特权的操作(比如挂载 NFS、使用 --net=host)就不能用了。但对于代码沙箱来说,这些功能本来就不需要,所以影响不大。

第六道锁:只读文件系统 + 临时存储,不给恶意代码留后门

代码沙箱最怕的就是恶意代码往文件系统里写东西,比如写个后门脚本、改个配置文件什么的。我的做法是把容器的根文件系统设成只读:

docker run \
  --read-only \
  --tmpfs /tmp:size=100m,mode=1777 \
  --tmpfs /run:size=10m,mode=0755 \
  sandbox-runner

--read-only 把整个根文件系统挂载为只读,--tmpfs 给需要写入的目录单独挂载内存文件系统。/tmp 给 100MB,/run 给 10MB,够用了。

有些运行时还需要 /dev/shm,也可以加上:

--tmpfs /dev/shm:size=64m,mode=1777

这样恶意代码就算想写东西,也只能写到 /tmp 里,而且容器一销毁就没了,啥也留不下。

还有一点,别把宿主机的目录挂载到容器里,尤其是敏感目录。我见过有人把 /var/run/docker.sock 挂进容器的,这等于把宿主机的 Docker 控制权拱手让人,攻击者可以直接创建一个特权容器然后逃逸。

第七道锁:网络隔离,让恶意代码连不上外网

代码沙箱里的程序一般不需要网络访问。就算需要,也应该严格限制。

最简单粗暴的方式就是直接禁用网络:

docker run \
  --network=none \
  sandbox-runner

这样容器里连 ping 都用不了,恶意代码想外传数据?没门。

如果你的沙箱确实需要网络(比如代码里要请求某个 API),那可以用自定义网络 + iptables 规则来做白名单:

# 创建自定义网络
docker network create --driver bridge sandbox-net

# 启动容器
docker run \
  --network=sandbox-net \
  sandbox-runner

# 在宿主机上用 iptables 限制容器只能访问特定 IP
iptables -I FORWARD -s 172.20.0.0/16 -d 允许的IP -j ACCEPT
iptables -I FORWARD -s 172.20.0.0/16 -j DROP

这样容器只能访问你允许的 IP,其他一概不通。

第八道锁:超时机制,别让代码跑到天荒地老

恶意代码不一定要搞破坏,它也可以就是跑个死循环,占着你的计算资源不放。所以超时机制必须有。

我一般这样处理:

timeout 10 docker run \
  --rm \
  --memory="512m" \
  --cpus="1" \
  sandbox-runner

timeout 10 表示 10 秒后强制终止。--rm 表示容器结束后自动删除,不留痕迹。

但在生产环境中,我更倾向于在代码层面控制超时,因为 timeout 命令发的是 SIGTERM,有些进程可能捕获信号后不退出。更可靠的做法是在容器内部用进程管理器来控制:

import subprocess
import signal

try:
    result = subprocess.run(
        ["python3", "/tmp/user_code.py"],
        timeout=10,
        capture_output=True,
        preexec_fn=lambda: signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError()))
    )
except subprocess.TimeoutExpired:
    # 超时处理
    pass

终极大招:gVisor / Kata Containers,真正的沙箱容器

前面说的那些加固措施,都是基于传统 Docker 容器的,本质上还是共享内核。如果你的安全要求特别高(比如多租户环境、运行完全不可信的代码),那可以考虑使用沙箱容器运行时。

gVisor 是 Google 开源的一个项目,它实现了一个用户态内核叫 Sentry,拦截容器进程的所有系统调用,在用户态处理后再转发给宿主机内核。这样容器进程和宿主机内核之间就多了一层防护,攻击面大幅缩小。

gVisor 和宿主机内核的通信只用了不到 20 个系统调用,而传统容器可以用 300 多个。这差距,安全程度可想而知。

安装和使用 gVisor 也比较简单:

# 安装 runsc
curl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases release main" | sudo tee /etc/apt/sources.list.d/gvisor.list
sudo apt-get update && sudo apt-get install runsc

# 配置 Docker 使用 runsc
# 在 /etc/docker/daemon.json 中添加
{
  "runtimes": {
    "runsc": {
      "path": "/usr/bin/runsc"
    }
  }
}

# 重启 Docker
systemctl restart docker

# 使用 gVisor 运行容器
docker run --runtime=runsc sandbox-runner

gVisor 的缺点也很明显:系统调用开销导致性能有损耗,不适合系统调用密集型的应用;不支持 GPU 直通;没有实现全部的 Linux 系统调用,有些应用可能跑不起来。

Kata Containers 是另一种方案,它本质上是在轻量级虚拟机里跑容器。每个容器(或 Pod)都有自己独立的内核,隔离性接近虚拟机,但使用体验和普通容器一样。

Kata 的安全边界比 gVisor 更强,但资源开销也更大,每个容器至少需要几十 MB 的额外内存。对于在线编程平台这种需要同时运行成百上千个沙箱的场景,gVisor 可能更合适。

我的最终方案:层层叠加,纵深防御

说了这么多,最后晒一下我在生产环境中实际使用的完整配置。我把它封装成了一个脚本:

#!/bin/bash
docker run \
  --rm \
  --runtime=runsc \
  --network=none \
  --read-only \
  --memory="512m" \
  --memory-swap="512m" \
  --cpus="1" \
  --pids-limit="50" \
  --cap-drop=ALL \
  --security-opt seccomp=/etc/docker/seccomp/sandbox.json \
  --security-opt apparmor=sandbox-profile \
  --tmpfs /tmp:size=100m,mode=1777 \
  --tmpfs /run:size=10m,mode=0755 \
  --user=1000:1000 \
  sandbox-runner \
  timeout 10 python3 /tmp/user_code.py

这里有几个值得注意的点:

  1. --runtime=runsc:使用 gVisor 运行时,从内核层面加强隔离
  2. --network=none:完全禁用网络
  3. --read-only:只读文件系统
  4. --cap-drop=ALL:去掉所有 Linux Capabilities
  5. --security-opt seccomp=...:自定义 Seccomp Profile
  6. --security-opt apparmor=...:自定义 AppArmor Profile
  7. --user=1000:1000:以非 root 用户运行
  8. timeout 10:10 秒超时

这套配置不是一次性就定下来的,是在实际运行中不断调整的。刚开始的时候,因为 Seccomp 太严格导致很多正常代码跑不了,我就把 SCMP_ACT_ERRNO 改成 SCMP_ACT_LOG 先观察了一周,收集了所有被拒绝的系统调用,确认哪些是必须的后才逐步放开。

还有个经验:每次更新语言运行时版本后,都要重新测试 Seccomp Profile,因为新版本可能会用到之前不需要的系统调用。我就吃过这个亏,升级 Python 版本后用户代码全报错,排查了半天才发现是 Seccomp 屏蔽了一个新加的系统调用。

一些血的教训

最后分享几个我在实战中遇到的真实问题:

1. docker.sock 千万别挂进去

这个前面提过了,但还是要强调。我见过不止一次,有人为了在容器里操作 Docker,把 /var/run/docker.sock 挂进容器。这就等于把宿主机的 root 权限交出去了。

2. 别用 latest 标签跑生产环境

镜像版本要固定,用 digest 更好。恶意镜像替换这种攻击虽然少见,但不是不可能。

3. 定期更新内核

Docker 的安全很大程度上依赖内核。脏牛漏洞(CVE-2016-5195)可以直接从容器里逃逸,就是因为内核的问题。保持内核更新是最基本的安全措施。

4. CVE-2019-5736 的启示

这个漏洞非常经典。攻击者通过覆写宿主机上的 runc 二进制文件,实现了容器逃逸。根本原因是 runc 进入容器命名空间时,/proc/self/exe 指向了宿主机的 runc 程序,而容器内的 root 用户可以写入这个文件。这个漏洞的修复方式是在 runc 进入容器前先克隆一份自身的二进制文件。这个案例告诉我们,不要低估攻击者的创造力,即使看起来很小的疏漏也可能被利用。

5. 监控和日志不能少

再安全的沙箱也需要监控。我会在宿主机上用 auditd 记录所有和容器相关的系统调用,用 Falco 做运行时安全检测。一旦发现异常行为(比如容器进程尝试访问 /proc/sys 下的敏感文件),立即告警。

总结

做 Docker 代码沙箱,没有银弹,只有纵深防御。单靠任何一层安全机制都不够可靠,必须把 Namespace、Cgroup、Capability、Seccomp、AppArmor、User Namespace、只读文件系统、网络隔离这些手段叠加起来,再加上 gVisor 这类沙箱运行时,才能把风险降到可接受的水平。

记住一个原则:最小权限。容器只需要能完成任务的最低权限,多给一点都不行。安全这事儿,宁可过度,不能不足。

还有就是,安全是一个持续的过程,不是一次性的配置。新的漏洞会不断出现,你的防御措施也要跟着更新。保持关注 Docker 和 Linux 内核的安全公告,及时打补丁,这是最基本的运维素养。


如果你觉得这篇文章对你有帮助,欢迎关注公众号【耕云躬行录】,我会持续分享更多运维和云原生安全方面的实战经验。也欢迎转发给你的同事朋友,让更多人少踩坑。

有问题可以在公众号留言,我看到都会回复的。

公众号:耕云躬行录
个人博客:躬行笔记

文章目录

博主介绍

热爱技术的云计算运维工程师,Python全栈工程师,分享开发经验与生活感悟。
欢迎关注我的微信公众号@运维躬行录,领取海量学习资料

微信二维码