Files
Knowledge/1 使用/docker/Docker 容器到底隔离了什么?.md

426 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
很多人对 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 <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:
```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 内核的安全公告,及时打补丁,这是最基本的运维素养。