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 的叠加使用。对于运行第三方代码的场景,这套组合能把容器逃逸的风险压到内核漏洞之外,是目前性价比很高的纵深防御方案。