gVisor 沙箱加固实战:容器运行时隔离配置

gVisor 沙箱加固的核心思路,是给容器再套一层「用户态内核」。传统容器依赖 namespace 与 cgroup 共享宿主内核,一旦内核出现提权漏洞,容器逃逸就成立了;而 gVisor 通过 runsc 运行时拦截容器的全部系统调用,在用户态用 Go 重新实现 Linux 接口,容器拿到的其实是一个模拟内核。本文给出从安装、注册运行时、平台选型、网络隔离到兼容性排查的完整配置流程,适合需要运行第三方或用户提交代码的场景。

适用场景

  • 多租户平台需要隔离不同客户的容器,避免互相读取数据;
  • CI Runner、在线代码评测、函数计算等需要执行不可信代码的环境;
  • 需要运行外部供应商提供的第三方镜像,但无法审计其内容;
  • 希望降低内核 CVE 对业务影响的纵深防御型架构。

前置条件

  • Linux 内核 4.14.77 及以上,主流发行版当前内核均满足;
  • Docker 17.09 以上(建议 20.10 及以上版本),或使用 containerd 作为运行时;
  • amd64 或 arm64 架构,root 或 sudo 权限;
  • 已确认待运行业务存在可用的替代方案:部分依赖特殊系统调用的程序在沙箱内无法启动。

原理说明

为什么需要第二层隔离

标准容器只做命名空间隔离与资源限制,进程仍然是宿主内核中的普通进程。历史数据表明,容器逃逸大多来自内核漏洞或配置错误,而这类问题不可能靠及时打补丁完全消除。gVisor 的做法是把内核攻击面从容器可触达的范围内整体切除:容器发出的系统调用不会直接进入宿主内核,而是先由 Sentry 进程解析、鉴权,再由它代为执行一次范围极窄的宿主机调用。

Sentry 与 Gofer 的分工

gVisor 运行时由两个进程组成:Sentry 承载用户态内核,负责执行用户代码并处理系统调用;Gofer 是文件系统代理,超出沙箱范围的文件操作会通过 9P 协议转发给它。这种拆分带来的结果是,容器即使攻破了 Sentry,也仍需跨越 Gofer 这一层才能触及宿主文件系统。

平台差异决定了性能与兼容性

runsc 需要一套平台机制来完成上下文切换与内存映射。常见的两种是 ptrace(默认,兼容性最好但系统调用开销最大)与 KVM(依赖 /dev/kvm,性能更好,适合宿主已开启虚拟化的场景);较新版本还提供 systrap 平台。平台选错会直接影响业务性能表现,具体可用取值以本机 runsc --help 输出为准。

操作步骤

第一步:安装 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 > /dev/null

sudo apt-get update && sudo apt-get install -y runsc
runsc --version          # 确认安装成功并记录版本号
which runsc              # 记录二进制路径,注册运行时时必须一致

第二步:把 runsc 注册为 Docker 运行时

官方提供了快捷命令,会自动向 /etc/docker/daemon.json 写入运行时条目:

sudo runsc install
sudo systemctl restart docker

# 核实写入结果
cat /etc/docker/daemon.json

若环境中已有 daemon.json 且不能覆盖,可手工合并以下内容,注意 path 必须与上一步 which runsc 的结果一致,否则容器会以 runc 启动而毫无察觉:

{
  "runtimes": {
    "runsc": {
      "path": "/usr/bin/runsc",
      "runtimeArgs": ["--platform=ptrace", "--network=sandbox"]
    }
  }
}

修改后用 docker info | grep -A3 Runtimes 确认列表中已出现 runsc。

第三步:运行容器并验证内核隔离

# 快速验证
sudo docker run --rm --runtime=runsc hello-world

# 查看沙箱启动信息
sudo docker run --rm --runtime=runsc -it alpine dmesg | head -3
# 预期首行形如:[ 0.000000] Starting gVisor...

# 对比内核版本:runsc 容器内上报的是 gVisor 的模拟内核标识
sudo docker run --rm --runtime=runsc alpine uname -a      # 通常显示 4.4.0
uname -a                                                  # 宿主机真实内核版本

# 确认运行时没有被静默回退
sudo docker inspect -f '{{.HostConfig.Runtime}}' $(sudo docker ps -q | head -1)

需要提醒的是,容器内的 dmesg 输出本身是可以被伪造的,不要把它当作安全敏感场景下的验证依据;生产环境应通过宿主机侧的容器运行时信息与日志来确认。

第四步:按业务选择平台

系统调用密集型的应用在 ptrace 平台下会出现明显开销,若宿主已启用虚拟化,可切换到 KVM 平台:

# 确认宿主支持 KVM
ls -l /dev/kvm

# 创建使用 KVM 平台的运行时条目
sudo runsc install --runtime runsc-kvm -- --platform=kvm

sudo systemctl restart docker
sudo docker run --rm --runtime=runsc-kvm alpine uname -r

选型建议:以兼容性优先选 ptrace,以性能优先且宿主开启虚拟化时选 KVM。切换后务必用业务自身做压测对比,而不是只看通用基准。

第五步:网络隔离与出网收敛

沙箱网络栈与宿主网络栈是两种完全不同的模式。默认使用沙箱内网络栈时,容器看不到宿主的物理网卡;一旦显式使用宿主网络,隔离性会明显下降。

# 沙箱网络栈(推荐,默认值)
sudo docker run --rm --runtime=runsc alpine sh -c 'cat /proc/net/dev'

# 与宿主网络对比:若容器内网卡与宿主机完全一致,说明走的是宿主网络
ip -br addr

更重要的控制手段在出口方向:给执行不可信代码的容器配置独立的 bridge 网络,配合 nftables 仅放通必需的域名与端口,从网络层阻断数据外传与反向连接。仅靠运行时隔离无法阻止沙箱内程序把数据发往外网。

第六步:与 seccomp、capabilities 叠加使用

gVisor 是纵深防御中的一层,不应替代其他加固项。推荐组合:只读根文件系统、丢弃全部 Linux capabilities、禁止特权模式、限制内存与 CPU。

sudo docker run --rm --runtime=runsc \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --cap-drop=ALL \
  --security-opt=no-new-privileges \
  --memory=512m --cpus=1 \
  --network=untrusted-net \
  alpine sh -c 'id && touch /test 2>&1 || echo "rootfs is read-only"'

预期输出中 touch /test 失败并打印 rootfs is read-only,说明只读根文件系统已生效。

第七步:开启调试日志用于兼容性排查

遇到程序启动失败时,用独立运行时条目打开 strace,避免影响生产入口:

sudo runsc install --runtime runsc-debug -- \
  --debug --debug-log=/tmp/runsc-debug.log --strace --log-packets

sudo systemctl restart docker
sudo docker run --rm --runtime=runsc-debug alpine sh -c 'ls /proc/kcore'
sudo grep -E 'ENOSYS|ENOENT|ENOTTY' /tmp/runsc-debug.log | head -20

若日志中出现 ENOSYS,说明该程序依赖 gVisor 尚未实现的系统调用,只能改用 runc 运行该容器,或替换掉触发该调用的功能模块。

配置验证

# 1 版本与路径
runsc --version && which runsc

# 2 Docker 已登记 runsc
docker info | grep -A3 Runtimes

# 3 运行时未被静默回退
docker run -d --name gv-check --runtime=runsc alpine sleep 300
docker inspect -f '{{.HostConfig.Runtime}}' gv-check      # 预期输出 runsc
docker rm -f gv-check

# 4 沙箱内核已接管
docker run --rm --runtime=runsc alpine uname -a            # 预期 4.4.0

# 5 只读与降权生效
docker run --rm --runtime=runsc --read-only --cap-drop=ALL alpine \
  sh -c 'touch /x || echo read-only-ok; capsh --print 2>/dev/null | head -2'

五项全部通过,才算真正把业务容器切换到沙箱运行时;仅完成前三项而不做第 4、5 项验证,是最常见的「以为开了其实没生效」的情况。

常见问题

某些程序在 gVisor 里启动即报错,怎么定位?

先按第七步打开 strace,在日志里搜索 ENOSYS 与 ENOENT,确认是「系统调用未实现」还是「路径/设备节点缺失」。前者通常需要把该容器回退到 runc,或替换依赖(例如涉及 FUSE、io_uring、eBPF 的组件);后者多半是只读根文件系统或缺失 /dev 节点导致,可通过挂载 tmpfs 或调整挂载参数解决。

性能下降多少算正常?

取决于系统调用密度。纯计算型任务几乎无损;网络代理、数据库等频繁陷入内核的负载,开销可达两成以上。建议先在非核心业务上线观察两周,用真实流量对比 P99 延迟与 CPU 使用率,再决定是否推广;切换 KVM 平台通常能显著缩小差距。

gVisor 和 Kata Containers 应该怎么选?

两者思路不同:gVisor 用用户态内核实现隔离,启动快、资源开销小,代价是系统调用兼容性与性能;Kata 使用轻量虚拟机,兼容性接近原生、性能损耗更小,但需要宿主具备虚拟化能力且单实例内存开销更高。已有虚拟化基础设施、追求兼容性可选 Kata;以轻量与快速启动优先、能接受部分系统调用不可用,则选 gVisor。

总结

gVisor 沙箱加固的落地要点是四句话:装得上、注册对、验证准、出口控。安装后必须确认 docker inspect 显示 runsc,并用 uname 与 dmesg 双重验证内核已被接管;再按业务在 ptrace 与 KVM 之间选型;最后别忘了网络出口收敛与 seccomp、capabilities 的叠加使用。对于运行第三方代码的场景,这套组合能把容器逃逸的风险压到内核漏洞之外,是目前性价比很高的纵深防御方案。