gVisor 的开源为安全容器的实现提供了一种新思路。所谓安全容器需要包含两个要点:安全隔离与 OCI 兼容。如今,共用内核的容器在安全隔离上还是比较弱。虽然内核在不断地增强自身的安全特性,但由于内核自身代码极端复杂,CVE 漏洞层出不穷。2018 年至今,已经公开了 63 个与内核相关的安全漏洞,而 2017 年全年则是 453 个 [1]。
大家已经有了一个共识:要有良好的安全隔离,就要阻断容器内程序对物理机内核的依赖。Hyper,Clear Container 以及二者合二为一的 KATA[2],就遵循了这个思路。KATA 容器本质上是个虚拟机,但兼容了 OCI 标准。每个容器都有一个自己的 Guest Kernel。KATA 通过对 Qemu 与 Guest Kernel 的深度优化,减弱了 VM 在启动速度与运行效率上的劣势。
gVisor 也隔离了容器内的恶意代码对 Host Kernel 的访问,但使用了另一种方法。我们将这种方法称之为进程虚拟化。它在内核之外实现了一个“内核进程”,Sentry,它提供了大部分 Linux Kernel 的系统调用。并且通过巧妙的方式将容器内进程的系统调用转化为对这个“内核进程”的访问。Google 将 gVisor 定位为“Sandbox”,而不是安全容器,一个原因是 Sentry 还不能完全代替 Linux Kernel,部分系统调用还是要转接到 Host Kernel 上。
Google 当前开源的 gVisor 版本更像是一个 Demo 版,在一些典型的应用场景下,性能比不上 KATA。但是我们把它的名字拆解来看:gVisor = Google’s new HyperVisor? 猜测 Google 还会有后招。
本文简单分析了 gVisor 的技术实现。时间仓促,文中叙述难免有不妥甚至错误之处,欢迎大家拍砖。
架构分析整体架构
gVisor 由 3 个组件构成:Runsc,Sentry 与 Gofer。类似 RunC 与 RunV,Runsc 是一种 Runtime 引擎,负责容器的创建与销毁。Sentry 就是上文提到的那个“内核进程”,容器内程序的系统调用都由它进行处理。Gofer 是文件系统的操作代理,IO 请求都会由它转接到 Host 上。这 3 个组件均由 Go 语言实现。这有利有弊:Go 语言开发较简单,并且类型安全,但是却限制了 gVisor 的性能。各组件间的关系如图 1 所示。
图 1: gVisor 的架构
Runsc 的概念较容易理解,我们不再多说。基于 Runsc,可以通过这种方式启动一个 gVisor 容器:
$ docker run --runtime=Runsc hello-gVisor
Sentry
Sentry 是 gVisor 的核心组件,它就像一个简单的操作系统内核,提供了系统调用,进程管理,内存管理等功能。
系统调用
gVisor 设计上的一个巧妙之处是它对系统调用的劫持方法。目前提供了两种劫持模式:KVM 模式,与 ptrace 模式。ptrace 模式的性能不及 KVM 模式。因为应用的每个 SYSCALL 都需要通过 ptrace 访问 Sentry。严格来说,它的安全性也不及 KVM 模式。严重怀疑 ptrace 模式是 Google 最初的 demo,本文不做过多分析。
在 KVM 模式下,gVisor 能够截获应用程序的每个系统调用,并将其转交给 Sentry 进行处理。相比较 VM,我们看不到 Qemu 的身影,也看不到 Guest Kernel,Sentry 包揽了所有必要的操作。这种对虚拟化的实现方法,我们称之为“进程级虚拟化”。
既然基于 KVM,Sentry 就有多种身份。有时它运行在 Guest 态的 Ring0, 此时它就像容器内应用的 Kernel。有时运行在 Host 态的 Ring 3,此时它就像 Host 上的一个普通进程。Sentry 处在什么状态上,完全取决于它当前正在处理的工作。当它在处理容器内的系统调用时,就处于 Guest 态。而当它需要跟 Host Kernel 进行交互时,就会通过 HLT 指令陷回 Host 模式。
Sentry 目前约实现了 200 个左右的系统调用,而 Linux Kernel 则为 X86_64 提供了 318 个系统调用 (4.16 内核)。当应用调用了那些未被实现的系统调用时,Sentry 会直接报错返回。由于系统调用尚未完备,导致部分软件还不能无缝地运行在 gVisor 中。而且 gVisor 已支持的系统调用中,有若干还必须依赖 Host 内核。当处理这些系统调用时,Sentry 会陷回 Host 模式。
Sentry 的进程管理
Sentry 承担了一定的进程管理职责。启动 gVisor 容器后,可以在 Host 上看到两个 Runsc 进程。一个进程负责容器创建与 IO(Gofer),另一个向容器内的应用提供系统调用支持,也就是我们前面提到的“内核进程”。容器内所有的进程,都以 Runsc 进程的线程存在。这有点像 Qemu。Runsc 进程在启动时会创建一定量的 vCPU 线程。
Sentry 复用了 go 语言的 GMP 模型 [3]。每个应用的线程均对应到 go 语言内置的 goroutine(参见 kernle.Task.Start 函数),即 G。go runtime 会根据情况,选择是通过 Host 内核的原生 sys_clone 生成新的 M(工作线程)还是复用之前的。在这里 vCPU 即 P。最后 go runtime 调度器,将 goroutine、vCPU 和工作线程三者结合起来。M、P 线程的调度由 Host 内核来调用。
内存管理
容器内应用的所有代码均由 Runsc 进程进行映射,并代理执行。gVisor 为每个应用进程都维护了一个 Guest 页表, Runsc 进程自身也有一个 Guest 页表。这么做是出于安全性的考虑,隔离了各个进程以及 Runsc 之间的地址空间。避免它们在内存上相互踩踏,也避免了恶意代码对 Runsc 的***。但在这种架构下,应用程序每次触发 syscall,都会伴随着一次 Guest 页表的切换。熟悉虚拟化的同学知道,这是一个非常可观的开销。
可以与之鲜明对比的是 Unikernel[4],在典型的 Unikernel 的架构下,所有的进程 / 内核均运行在同一个地址空间中,系统调用等同于一次函数调用。但是应用程序中的任何一个不小心都可能导致整个系统 core 掉。安全与性能永远是个 Tradeoff。
网络
gVisor 里面看到的网络设备是由 docker 创建容器的时候创建的 veth pair,并且在容器内部将虚拟网络设备改名为 eth0。这与普通的容器并没有太大区别。
gVisor 提供两种网络通信的方式:
通过宿主机 TCP/IP 协议栈
通过 gVisor 实现的用户态 TCP/IP 协议栈 (netstack)
默认配置是使用 netstack,如果需要更高的网络性能可以通过修改配置文件切换到使用宿主机的 TCP/IP 协议栈。
通过 gVisor 的 netstack 网络通信,gVisor 在捕获到应用的程序的系统调用的时候,并不使用宿主机的系统调用接口而是调用 netstack 提供的 socket 接口, 在经过 netstack 协议栈之后,通过宿主机的 raw socket 的方式进行收发包。
通过宿主机 TCP/IP 协议栈进行网络通信,其整个数据流跟原生容器一样,唯一区别在于 gVisor 需要捕获到安全容器内应用程序关于网络的系统调用。例如, listen/accept/sendmsg 等等。之后再将其转换成 host 的系统调用来进行网络通信。
文件系统
gVisor 也跟 linux 一样对文件系统做了一层抽象,提供了 VFS 层,在其之下分别实现具体的文件系统。有 9p,tmpfs,procfs,sysfs 等。
gVisor 兼容 OCI,因此它的 rootfs 的文件来源就来自容器 OCI 镜像各层聚合以后的 rootfs。为了减少 Guest App 直接对 Host 系统调用的依赖,Sentry 使用了 9pfs。应用程序通过 9p 协议与 Runsc 进程通信(内部运行着 Gofer Server 的功能),通过 Runsc 间接地来对 Host 的 rootfs 进行操作。
gVisor 本身并未提供 Library,容器中的应用可以直接链接镜像 rootfs 中的 Library。所有的 binary 无需重新编译链接,确保了 gVisor 对已有程序的兼容性。
除此之外,Sentry 还开发了内部的 tmpfs,这主要是为了保证运行性能。如果应用程序的临时文件也要经过 9pfs,性能上将无法忍受。Sentry 还模拟 Linux,开发了 /proc 与 /sys 文件系统中的部分文件,做到与 Linux 的兼容性。
性能测试数据对比
我们利用 memcached 与 mysql 对比了 gVisor 与普通容器, RunV (类似 KATA),与 AliUK(阿里内部自研的下一代执行单元)。
1. memcached: memcached 的主要开销在网络上。gVisor 在 memcached 的 性能的差距是由于它自身的协议栈未被优化过。
gVisor(ptrace) | gVisor(kvm) | runc | runv | aliuk | |
memcached | 11.8M/s | 13.5M/s | 66.5M/s | 57.8M/s | 82.7M/s |
如上为几种模型对 memcached 的 net_rate 指标,数值越大性能越好,发现 gVisor(ptrace) 确实小于 gVisor(kvm), 并且明显低于 runc/runv/aliuk;aliuk 的性能最佳。
2. mysql:在我们的测试中,mysql 主要开销在 IO 处理。gVisor 的根文件系 . 统通过 9p 协议访问 Host 上的文件。9p 协议性能相比 runc 容器本地 fs 性 能,甚至是 runv 和 aliuk 的 qcow2 的虚拟磁盘性能,都要差很多。
gVisor(kvm) | runc | aliuk | |
mysql | 22773.19ms | 1371.44ms | 1673.86ms |
如上为 gVisor(kvm)、runc、aliuk 使用 sysbench 对 mysql oltp.lua 进行混合测试的平均时延,gVisor 的性能明显也最低。
另外,Sentry 对 Host 内核的依赖,与 Syscall 劫持的低效也是 gVisor 比 AliUK 要差的原因之一。
功能对比
runc容器 | RunV | gVisor | AliUK | 备注 | |
自包含 | NA | 完整 | 较完整 | 完整 | gVisor有部分SYSCALL的实现依赖于Host内核 |
性能 | 高 | 较高 | 低 | 高 | |
资源占用 | 少 | 较少 | 较少 | 较少 | |
兼容性 | NA | 强 | 较强 | 弱 | gVisor某些内核功能支持不完整,同时/proc /sys接口与Linux差别较大 |
安全性 | 弱 | 强 | 较强 | 强 | gVisor某些非外部资源访问的内核功能对Host内核产生依赖,增大了安全容器与Host内核的***面 |
前景展望
gVisor 是一种对安全容器的解决方案。它在 OCI 兼容、二进制兼容、多进程方面实现得很巧妙,随着 SYSCALL 的完善和软件代码的成熟,会有越来越多的容器能够无缝地迁移到 gVisor 上。
但是 gVisor 的当前版本在性能上还不尽如人意。应用程序每次调用 SYSCALL 都伴有页表切换,而且部分功能还依赖于 Host 内核而陷出到 Host。同时 IO 方面采用了性能不佳的 9pfs,加上网络协议栈方面未进行过优化。这些都导致了它的性能相比较普通容器来说,有较大程度地降低。对性能有较高要求的应用无法应用在 gVisor 上面。