4、容器运行时安全:只读根文件系统、禁止特权模式、限制系统调用(seccomp)、Capabilities最小化

好,咱们接着聊容器运行时安全。说实话,很多团队在构建镜像时花了大功夫,结果一跑起来就全忘了——特权模式一开,什么防护都白搭。我见过太多这样的案例了。

运行时安全,说白了就是管住容器在跑起来之后能干什么、不能干什么。咱们今天重点聊四个维度:只读根文件系统、禁止特权模式、限制系统调用、Capabilities最小化。这四个点,每一个我都踩过坑。

4.1 只读根文件系统:让容器“只读不写”

你想想看,一个正常的应用进程,真的需要往根目录写文件吗?大部分时候不需要。但攻击者一旦拿到shell,第一件事就是往/tmp、/var下写脚本。

我个人习惯是:只要应用不需要写文件系统,就把根文件系统设为只读。这样攻击者就算进来了,想写个木马都找不到地方。

核心配置:

docker run --read-only --tmpfs /tmp --tmpfs /var/run myapp:latest

注意:加了--read-only后,容器根文件系统不可写。但有些应用需要写/tmp或/var/run,所以用--tmpfs挂载成内存临时目录。

我在项目中遇到过一个问题:一个Java应用启动时往/tmp写日志,结果容器一跑就报错。嗯,后来加了--tmpfs /tmp就解决了。说白了,只读是原则,临时目录是例外

避坑指南:

我曾经遇到一个坑——应用启动时检查/var/log目录是否存在,不存在就创建。结果只读模式下直接崩溃。解决方案:要么在Dockerfile里提前创建好,要么用--tmpfs挂载。

4.2 禁止特权模式:别给容器“超级权限”

特权模式,说白了就是让容器拥有宿主机的root权限。你想想看,这等于把家门钥匙直接给了陌生人。我见过有人为了方便调试,直接--privileged=true跑生产容器——这简直是灾难。

为什么不能开特权模式?因为一旦容器被攻破,攻击者可以直接操作宿主机内核、挂载设备、加载内核模块。说白了,容器隔离瞬间失效

模式 权限范围 风险等级
普通容器 受限的用户空间
特权容器 宿主机root权限 极高
非root用户运行 容器内普通用户 极低

警告:永远不要在生产环境使用--privileged=true。如果确实需要某些特殊权限,请使用--cap-add单独添加,而不是直接给全部权限。

我记得有一次,一个同事为了调试网络问题,直接开了特权模式。结果容器里的进程意外删除了宿主机上的iptables规则——整个服务中断了20分钟。从那以后,我们团队定了个规矩:谁开特权模式,谁写事故报告

4.3 限制系统调用(seccomp):给内核调用上“安检”

系统调用,就是用户程序请求内核帮忙干活。一个正常的Web应用,可能只需要几十个系统调用。但Linux内核有300多个系统调用——攻击者可以利用那些不常用的调用来提权。

Seccomp(安全计算模式)就是干这个的:它像一个安检员,只放行你允许的系统调用。我个人习惯是:先用默认的seccomp策略,再根据应用报错逐步放宽。

使用默认seccomp策略:

docker run --security-opt seccomp=default.json myapp:latest

Docker默认的seccomp策略已经禁用了大约44个危险系统调用,比如mount、reboot、kexec_load等。

你可能会问:那我自己写seccomp策略难吗?其实不难。我一般先用strace抓应用的系统调用,然后生成白名单。举个例子:

# 先用strace抓取系统调用
strace -c -f -S calls myapp 2>&1 | tail -n +3 | head -n -2 | awk '{print $NF}' | sort -u

# 然后生成seccomp JSON策略
{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": ["SCMP_ARCH_X86_64"],
  "syscalls": [
    {"names": ["read", "write", "open", "close", "stat", "fstat", "mmap", "munmap", "brk", "exit_group"], "action": "SCMP_ACT_ALLOW"}
  ]
}

经验之谈:我曾经在压测环境下发现,应用突然调用了io_submit系统调用(异步IO),结果被seccomp拦截了。所以建议在测试环境多跑几种场景,确保白名单覆盖全面。

4.4 Capabilities最小化:给容器“够用”的权限

Linux Capabilities,说白了就是把root权限拆成小块。比如CAP_NET_BIND_SERVICE允许绑定低端口,CAP_SYS_TIME允许修改系统时间。你不需要给容器所有root权限,只需要给那几小块。

我见过最离谱的配置:一个只跑Nginx的容器,居然给了CAP_SYS_ADMIN——这个权限几乎等于半个root。你想想看,Nginx只需要绑定80端口和读写日志文件,给那么多权限干嘛?

常见应用 需要的Capabilities 多余权限示例
Nginx/Web服务 CAP_NET_BIND_SERVICE CAP_SYS_ADMIN, CAP_NET_ADMIN
数据库(MySQL/PostgreSQL) CAP_NET_BIND_SERVICE, CAP_SYS_NICE CAP_SYS_PTRACE, CAP_KILL
消息队列(RabbitMQ) CAP_NET_BIND_SERVICE, CAP_IPC_LOCK CAP_SYS_MODULE, CAP_NET_RAW

最佳实践:

# 只给Nginx绑定低端口的权限
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx:latest

# 或者用docker-compose
services:
  web:
    image: nginx:latest
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE

我个人习惯是:先--cap-drop=ALL丢掉所有权限,再--cap-add逐个添加需要的。这样最安全,也最清晰。你想想看,如果攻击者连CAP_KILL都没有,就算进了容器也杀不了其他进程。

注意:不要使用--privileged=true然后靠cap_drop来补救。因为特权模式还会关闭其他安全机制,比如Seccomp和AppArmor。这两个是独立的防护层,不能混为一谈。

4.5 四个维度的联动配置

好了,咱们把四个点串起来。一个安全的容器运行时配置,应该是这样的:

docker run \
  --read-only \
  --tmpfs /tmp \
  --tmpfs /var/run \
  --security-opt seccomp=custom.json \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  --cap-add=NET_RAW \
  --user 1000:1000 \
  myapp:latest

你看,这一套组合拳下来:文件系统只读、系统调用受限、权限最小化、非root用户运行。攻击者就算突破了应用层,想横向移动或者提权,难度大了好几个数量级。

我记得有一次安全审计,对方问我们:你们容器被攻破后,攻击者能做什么?我回答:他只能读环境变量和网络通信,写不了文件、改不了系统时间、加载不了内核模块、也杀不了其他进程。审计人员听完点了点头——这就是运行时安全的价值。

最后一个小建议:把这些配置写进CI/CD流水线,做成默认模板。别让开发人员每次手动加参数——人总会忘的。自动化才是安全的保障。