
前阵子有个做在线编程平台的朋友找到我,说他们平台要支持用户在线提交代码并执行,问我用 Docker 跑用户代码安不安全。我当时就笑了——这事儿我太熟了,之前在做一个类似 LeetCode 的 OJ 系统的时候,就踩过不少坑。
说白了,Docker 容器不是什么铜墙铁壁,它本质上就是跑在宿主机内核上的进程,只不过套了几层"隔离马甲"。你要是觉得 docker run 一下就万事大吉了,那迟早要出事。今天我就把自己在生产环境中是怎么一步步加固 Docker 代码沙箱的经验,原原本本讲出来。
很多人对 Docker 有个误解,觉得容器就是轻量级虚拟机。这个想法很危险。虚拟机有自己独立的内核,而容器是共享宿主机内核的,区别大了去了。
Docker 的隔离主要靠两样东西:Namespace 和 Cgroup。
Namespace 做的是资源视角的隔离,让容器里的进程觉得自己是系统唯一的主人。Linux 目前提供了 6 个主要的 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 限制在实际操作中比较麻烦,我更倾向于直接把容器的文件系统设成只读,后面会讲。
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 是我最喜欢的一个安全机制。它可以在内核层面限制容器进程能调用哪些系统调用,相当于给容器加了一层"系统调用防火墙"。
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。
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-runnerAppArmor 还有个好处是它的日志很好用。当容器进程尝试访问被禁止的资源时,/var/log/syslog 或 /var/log/audit/audit.log 里会有详细记录,方便你排查问题。
如果你的系统是 CentOS,那就用 SELinux,思路是一样的,只是配置方式不同。SELinux 用标签和策略来管理访问控制,比 AppArmor 更严格但也更复杂。不过对于代码沙箱场景,AppArmor 够用了。
这是个很容易被忽略但非常重要的安全机制。默认情况下,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 dockerDocker 会自动创建一个叫 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-runnertimeout 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前面说的那些加固措施,都是基于传统 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-runnergVisor 的缺点也很明显:系统调用开销导致性能有损耗,不适合系统调用密集型的应用;不支持 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这里有几个值得注意的点:
--runtime=runsc:使用 gVisor 运行时,从内核层面加强隔离--network=none:完全禁用网络--read-only:只读文件系统--cap-drop=ALL:去掉所有 Linux Capabilities--security-opt seccomp=...:自定义 Seccomp Profile--security-opt apparmor=...:自定义 AppArmor Profile--user=1000:1000:以非 root 用户运行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 内核的安全公告,及时打补丁,这是最基本的运维素养。