很多人对 Docker 有个误解,觉得容器就是轻量级虚拟机。这个想法很危险。虚拟机有自己独立的内核,而容器是共享宿主机内核的,区别大了去了。 Docker 的隔离主要靠两样东西:**[Namespace](https://zhida.zhihu.com/search?content_id=784190073&content_type=Answer&match_order=1&q=Namespace&zhida_source=entity)** 和 **[Cgroup](https://zhida.zhihu.com/search?content_id=784190073&content_type=Answer&match_order=1&q=Cgroup&zhida_source=entity)**。 Namespace 做的是资源视角的隔离,让容器里的进程觉得自己是系统唯一的主人。Linux 目前提供了 6 个主要的 Namespace: - **PID Namespace**:容器里的进程看不到宿主机和其他容器的进程 - **Mount Namespace**:容器有自己独立的文件系统挂载点 - **Network Namespace**:容器有独立的网络栈、IP地址、端口 - **IPC Namespace**:进程间通信隔离 - **UTS Namespace**:主机名隔离 - **[User Namespace](https://zhida.zhihu.com/search?content_id=784190073&content_type=Answer&match_order=1&q=User+Namespace&zhida_source=entity)**:用户和用户组隔离 Cgroup 做的是资源使用的限制,防止一个容器把宿主机的 CPU、内存、磁盘 IO 全吃光。 听着挺完善的对吧?但问题在于,这些隔离都是"软隔离",不是"硬隔离"。共享内核意味着,一旦内核有漏洞,或者你的配置有疏漏,容器里的恶意代码就有可能突破这些限制,逃到宿主机上。 我之前看过一个案例,某在线编程平台因为配置不当,用户通过 Python 的 `os.system()` 直接在宿主机上执行了命令,把整台服务器搞崩了。所以,做代码沙箱,安全这根弦必须绷紧。 ## **第一道锁:资源限制,别让恶意代码把机器吃干抹净** 做代码沙箱,最基本的就是限制资源。你想想,要是用户提交一个死循环,或者疯狂 fork 进程的 fork 炸弹,不限制的话宿主机直接就废了。 我一般会在 `docker run` 的时候加上这些参数: ```bash 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 也得限制: ```bash # 创建限速的 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: ```bash 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 都去掉,只加回必须的: ```bash 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](https://zhida.zhihu.com/search?content_id=784190073&content_type=Answer&match_order=1&q=Seccomp&zhida_source=entity) 系统调用过滤,把攻击面缩到最小** Seccomp 是我最喜欢的一个安全机制。它可以在内核层面限制容器进程能调用哪些系统调用,相当于给容器加了一层"系统调用防火墙"。 Docker 默认就启用了 Seccomp,使用一个白名单配置文件,默认会屏蔽大约 44 个危险或不常用的系统调用。你可以通过 `docker info` 确认: ```text Security Options: seccomp Profile: default ``` 但是,默认配置对于代码沙箱来说还是太宽松了。我一般会写一个自定义的 Seccomp Profile,只允许代码执行真正需要的系统调用。 举个实际的例子,如果沙箱只用来运行 Python 代码,我可以通过 `strace` 先抓取 Python 运行时需要哪些系统调用: ```bash strace -c -f python3 /tmp/test.py 2>&1 | head -50 ``` 然后根据抓取结果编写 Seccomp Profile。一个精简版的 Profile 大概长这样: ```json { "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 启动容器: ```bash 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](https://zhida.zhihu.com/search?content_id=784190073&content_type=Answer&match_order=1&q=AppArmor&zhida_source=entity) / SELinux,强制访问控制再加一道门** Seccomp 管的是系统调用,AppArmor 管的是资源访问。它可以在文件读写、网络访问、Capability 等层面做更细粒度的限制。 Ubuntu 默认使用 AppArmor,CentOS/RHEL 默认使用 SELinux。Docker 在 Ubuntu 上默认会给容器加载一个 AppArmor Profile,叫做 `docker-default`。 对于代码沙箱,我一般会写一个更严格的自定义 AppArmor Profile: ```text #include ​ profile sandbox-profile flags=(attach_disconnected,mediate_deleted) { #include ​ # 只允许读 /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: ```bash # 编译并加载 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 的方式: ```bash # 先在 /etc/docker/daemon.json 中配置 { "userns-remap": "default" } ​ # 重启 Docker systemctl restart docker ``` Docker 会自动创建一个叫 `dockremap` 的用户,并配置 subuid 和 subgid 映射。你可以验证一下: ```bash cat /etc/subuid # dockremap:100000:65536 ​ cat /etc/subgid # dockremap:100000:65536 ``` 这意味着容器内的 UID 0-65535 会映射到宿主机的 UID 100000-165535。 启用 User Namespace 之后有个副作用:一些需要特权的操作(比如挂载 NFS、使用 `--net=host`)就不能用了。但对于代码沙箱来说,这些功能本来就不需要,所以影响不大。 ## **第六道锁:只读文件系统 + 临时存储,不给恶意代码留后门** 代码沙箱最怕的就是恶意代码往文件系统里写东西,比如写个后门脚本、改个配置文件什么的。我的做法是把容器的根文件系统设成只读: ```bash 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`,也可以加上: ```bash --tmpfs /dev/shm:size=64m,mode=1777 ``` 这样恶意代码就算想写东西,也只能写到 `/tmp` 里,而且容器一销毁就没了,啥也留不下。 还有一点,别把宿主机的目录挂载到容器里,尤其是敏感目录。我见过有人把 `/var/run/docker.sock` 挂进容器的,这等于把宿主机的 Docker 控制权拱手让人,攻击者可以直接创建一个特权容器然后逃逸。 ## **第七道锁:网络隔离,让恶意代码连不上外网** 代码沙箱里的程序一般不需要网络访问。就算需要,也应该严格限制。 最简单粗暴的方式就是直接禁用网络: ```bash docker run \ --network=none \ sandbox-runner ``` 这样容器里连 `ping` 都用不了,恶意代码想外传数据?没门。 如果你的沙箱确实需要网络(比如代码里要请求某个 API),那可以用自定义网络 + iptables 规则来做白名单: ```bash # 创建自定义网络 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,其他一概不通。 ## **第八道锁:超时机制,别让代码跑到天荒地老** 恶意代码不一定要搞破坏,它也可以就是跑个死循环,占着你的计算资源不放。所以超时机制必须有。 我一般这样处理: ```bash timeout 10 docker run \ --rm \ --memory="512m" \ --cpus="1" \ sandbox-runner ``` `timeout 10` 表示 10 秒后强制终止。`--rm` 表示容器结束后自动删除,不留痕迹。 但在生产环境中,我更倾向于在代码层面控制超时,因为 `timeout` 命令发的是 SIGTERM,有些进程可能捕获信号后不退出。更可靠的做法是在容器内部用进程管理器来控制: ```python 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](https://zhida.zhihu.com/search?content_id=784190073&content_type=Answer&match_order=1&q=Kata+Containers&zhida_source=entity),真正的沙箱容器** 前面说的那些加固措施,都是基于传统 Docker 容器的,本质上还是共享内核。如果你的安全要求特别高(比如多租户环境、运行完全不可信的代码),那可以考虑使用沙箱容器运行时。 **gVisor** 是 Google 开源的一个项目,它实现了一个用户态内核叫 Sentry,拦截容器进程的所有系统调用,在用户态处理后再转发给宿主机内核。这样容器进程和宿主机内核之间就多了一层防护,攻击面大幅缩小。 gVisor 和宿主机内核的通信只用了不到 20 个系统调用,而传统容器可以用 300 多个。这差距,安全程度可想而知。 安装和使用 gVisor 也比较简单: ```bash # 安装 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 可能更合适。 ## **我的最终方案:层层叠加,纵深防御** 说了这么多,最后晒一下我在生产环境中实际使用的完整配置。我把它封装成了一个脚本: ```bash #!/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 内核的安全公告,及时打补丁,这是最基本的运维素养。