由蚂蚁集团研发的 Kata Containers 是一个开源的容器运行时,致力于使用轻量级虚拟机(VM )构建安全的容器运行时。相对于传统的 docker/runC 容器,Kata Containers 提供了更强的安全隔离以及性能隔离性。蚂蚁集团的基础设施大量采用 Kata 容器为上层业务提供更加安全的运行环境,同时也避免了大量混部离线业务对在线业务的干扰,极大提高了蚂蚁集团的单机利用率。
本文整理自蚂蚁集团高级技术专家李福攀在DIVE全球基础软件创新大会2022(容器运行时与安全专场)的演讲分享,主题为“Kata容器及其在蚂蚁集团的落地实践”。
分享主要分为三个部分,开篇是 Kata 容器的介绍,第二部分介绍 Kata 容器的架构演进与规划,最后分享 Kata 容器在蚂蚁集团内部的应用。
以下是分享内容。
大家好,很高兴来到 Dive 大会,和大家分享一些我在安全容器方面的知识积累和实践情况。首先自我介绍:我是李福攀,来自于蚂蚁金服。我之前在 2018 年加入了 Kata 容器的创始团队 Haper 团队,2019 年跟随 Haper 团队一起来到了蚂蚁。从 2018 年到现在,我一直从事 Kata 容器方面的开发工作,在 Kata 容器的实践方面有一定积累。
Kata 容器介绍
2015 年左右容器概念开始出现,并伴随着 Docker 概念火了一把。Docker 容器带来了两个方面的革新:首先 Docker Image 的出现加快了业务部署速度,并给业务提供了部署的统一环境;另一大革新就是容器运行时,也就是我们常说的 runC 容器。
runC 容器是利用了 Linux 内核的一些相关特性对容器做封装。因为 runC 容器都是运行在一个共享的 Host Kernel 上,如果有一个容器进程被恶意程序攻击,就有可能造成容器逃逸,轻则会对当前的容器造成破坏,重则会对 Linux 内核造成崩溃,导致整个机器宕机,这在安全生产环境中往往是不能接受的。于是我们有了开发一种新的安全容器的想法,也就是现在的 Kata 容器。
从传统的虚拟机发展到现在经历了很长的过程,而且虚拟机有着业界公认的隔离速度以及很好的隔离环境。所以我们就设想能否把现有的 runC 容器放在一个虚拟机里,利用虚拟机的安全隔离性、快速部署能力,将两个安全特性合在一起形成一个新的架构理念。于是 Kata 容器就这样诞生了。
Kata 容器拥有虚拟机级别的安全隔离性。一个容器即使被其他业务代码或者恶意程序所攻击,也顶多会对当前的容器产生破坏,但由于虚拟机的边界无法突破,它就不会对这个 Kata 容器所在的整体系统造成损害,更无法对系统上的其他容器发起攻击了,这也是 Kata 容器带来的最大好处。
因为整个容器生态圈是以 Docker 架构为主的,Kata 容器如果想要兼容整个容器生态圈,就需要和 Docker 容器兼容。而 Docker 每起一个容器都需要从 containerd 这边创建一个 containerd-shim 进程,主要用来连接 containerd 和容器进程之间做交流用途。它最主要的一个任务就是用来控制容器的生命状态,以及监控它的退出。但因为 Kata 容器的容器进程是运行在虚拟机里,而这个虚拟机和 Host 之间又隔离,所以 containerd-shim 很难再监控到 Kata 容器进程的生命状态。对 Kata 容器来说,我们要想融入这个生态就需要在 containerd 和 Kata 容器的进程之间加一层 kata-shim 进程来做中转。这样一个 Docker、一个 Kata 容器就会对应一个 containerd-shim 进程,同时还需要有一个 kata-shim 进程对应。
另外 Kata 容器的 Pod 上还需要一个 Proxy 进程。因为在 Kata 1.0 架构中,Kata 1.0 在 Kata Agent 和 Kata Runtime 之间做通讯用的是一个串口设备,这个串口设备是不支持分时复用的。因为一个 Pod 里可能需要启动多个容器,多个容器的 IO 流都需要转接到 Host 上,这时一个串口设备是无法满足多个容器的 IO 流的共享的。我们必须要引入 Proxy 层做分时复用,这样就可以把一个 Pod 里面多个容器的 IO 流通过 Proxy 转接到 Host 上来。
所以 Kaka 容器为了融入 Docker 的容器生态圈,需要引入 kata-shim 进程,还需要引入 Proxy,另外还有一个 HyperVisor 进程。相对于传统的容器来说,Kata 容器的进程比较多,这也就是 Kata 的兼容性开销。
另外 Kata 容器外面有一层虚拟机封装,而且它需要引入一个 Guest 系统,还有一个 Kata Agent,这些都会带来一定开销,我们也叫做胖容器。为了让 Kata 向下一代演进,我们要减少 Kata 容器的这些自身开销,以及为了兼容性所带来的额外资源消耗。于是 Kata 社区向 Container 社区提出了 Shimv2 的概念。
Kata 的架构演进与规划
所谓 Shimv2 就是在 containerd 和 shim 之间引入一个新的接口,从原来的 Shimv1 扩展到了 Shimv2。而 Shimv2 带来的一个好处就是不需要每个容器都需要起一个 shim 进程了,我们只需要让一个 Pod 对应一个进程,而且让 Shimv2 形成一个标准,以后无论是 runC、Kata 还是其他运行时都只要兼容 Shimv2 标准就可以让一个 containerd 对接很多其他运行时。
自从有了 Shimv2 之后,Kata 安全容器成为了云原生世界的一等公民。从此 Kata 容器不再只是模仿 runC,而是和 runC 平起平坐。这时 Kata 进入到了 Kata 2.0 架构,从原来的多进程模型进入到现在的只有两个进程的模型,这样就会给运维带来很大便利。此外 Kata 的程序代码也得到了大幅精简,因为我们不需要再有各个 Shim 模块、Proxy 模块之间的通讯、生命状态的保护等开销,整个 Kata Shim 显得更精简和清爽。
后来蚂蚁内部想大批量在一个 Node 上部署 Kata 容器,但 Kata 本身的开销还是比较大的。因为 Kata 有个虚拟机,这个虚拟机需要一个 Guest 内核,还需要一个 guest image。为了节约这部分开销,我们利用了 nvdimm+dax 技术在 Pod 之间共享 guest image。这样一个 Node 上无论部署多少个 Kata 容器,这些 Kata 的虚拟机的内核和 guest image 都是共享内存的,可以极大压缩内存开销。
之后我们发现 Kata 的开销是在 Kata Agent 上面。因为 Kata Agent 之前是用 Golang 写的程序,而且在 Host 和 Guest 之间是用 gRPC 通讯。Golang 是有一个运行时的,而且这个运行时的开销非常大,可 Kata Agent 本身要完成的任务是非常简单的。于是我们调研了一下能不能用一个新的语言,这个语言不需要运行时的开销就可以完成这个简单的工作。
一开始的时候我们是想用 runC 语言来写,但 C 语言本身虽然没有运行时,它的开发效率却是比较低的。于是我们最后选定了 Rust 这门语言对 Kata Agent 进行了改造。改造完后发现 Kata Agent 带来的开销从原来的 11M 降低到后来的 1.1M 左右。
这时候我们发现 Kata Agent 和 Kata Runtime 之间做通讯交流用了 gRPC,但 Rust 语言没有一个非常标准的支持 gRPC 的一套协议。于是我们用 Rust 重写了一套 ttRPC,一套比较精简的协议,现在已经捐赠给了社区,作为社区的一个子项目在维护。用 ttRPC 替代 gRPC 之后,我们发现 Kata Agent 的开销从原来的 11M 降到了现在的只有 300K,极大提高了 Kata 容器的单机部署密度。
一个容器的运行状态和生命周期管理是非常重要的,而且运行时的 Message 数据也是非常重要的。在 Kata 社区,我们给 Kata 引入了 Metrics 特性。Metrics 可以把 Kata 容器的内存、CPU 和一些安全指标存储到一个通用的 Metrics 数据库当中,我们可以根据这些数据指标再形成一个监控系统,在生产当中可以实时根据这些监控指标来察看 Kata 运行时的状态、报警情况。
另一大改进就是用 virtio-fs 取代了 9P。因为 9P 协议的兼容性做得不是太完善,正好我们发现 Redhat 正在开发一种新的协议叫 virtio-fs。这个 virtio-fs 协议就是将原有的 fuse 协议和 virtio 协议糅合在一起形成的。virtio-fs 协议在 Guest 里的内核上还是 fuse 的,所以在 Guest 里访问文件的时候走的是 fuse 协议,而当这个 FUSE 协议的请求通过 virtio,从 Guest 发送到 Host 这边的后端时,这时后端再真正享用请求,把请求再返回给 Guest。另外为了降低 virtio-fs 带来的内存开销,我们还引入 dax 特性,就是在 Guest 和 Host 之间共享 virtio-fs 打开的文件的功能,这样就可以避免一个文件打开时需要在 Guest、Host 两方面同时需要两份内存开销的缺点。
另外 virtio-fs 协议是对内核不敏感的,兼容性很好,对内核没有特殊的要求。
下面来看 Kata 的整体架构。Kata 的整体架构可以分为两大部分,第一部分是 Runtime 这边,第二部分就是 Guest 这边。Runtime 这边对外暴露的是一个 shimv2 接口,这个接口主要是响应 shimv2 containerd 调用。到 Runtime 这边之后有一个 Sandbox,负责掌控整个 Pod 里面所有容器以及资源的情况。
第二个是容器接口,这个容器主要对应每一个容器的资源和生命状态的管理需求。每个容器发回来请求后,它会通过一个 Agent Client 组件和 Guest 的 Kata Agent 交流。Kata Agent 和 Agent Client 之间现在使用 vsock 协议代替了传统的串口协议,所以也不需要 Proxy 的能力了。
下面是 Dev_Manager 模块。一个 Pod 容器可能需要一些存储卷,可能会把 Host 的一些设备文件放到 Guest 中,我们就需要 Dev_Manager 这个模块来负责把 Host 上的一些设备直通到 Guest 中。
下一个是网络模块。Kata 对应的是 K8s 的一整套生态,后者的网络主要是 CI 插件。但 CI 插件设计时主要用于 runC 容器,没有考虑到安全容器的需求,所以我们就需要 Network 这个组件来对接原有的 CI 模块。CI 模块对每一个 Pod 容器会生成一个 Net Namespace,会把一个容器的一个网络接口从 Host 上挪到 Namespace 里,对于 runC 来说这已经足够了。但对于 Kata 容器来说,因为容器进程是运行在 Guest 里的,所以需要把 Guest 里面的网络和 Host 上面的 Namespace 之间做一个桥梁,这个 Network 模块就是来负责这一工作。我们在 Guest 虚拟机上创建一个设备,把这个设备的流量包和 Host 的 Net Namespace 中的网络接口之间做流量转发。
最后一个重要组件是 Hypervisor。因为 Kata 支持的 Hypervisor 特别多,所以抽象出来一个 Hypervisor 组件,对上可以提供一个统一接口,对下可以支持其他的各种 Hypervisor。
红色的部分叫做 Persistence 模块。因为 Kata 容器现在是双进程架构,一个是 Hypervisor 进程,一个是 Runtime 进程。但如果 Runtime 进程由于各种原因崩溃,或者被人恶意 Kill,这时整个 Runtime 进程就会丢失。Runtime 进程丢失之后的它 qemu 进程还在,但 containerd 又感知不到 qemu 进程的存在,所以我们需要把整个 Pod 或者 Sandbox 的状态落盘。落盘之后,如果 qemu 进程被 Kill 掉,containerd 会拉起一个新的 shim 进程,而新的 shim 进程会从落盘的数据中恢复出一个完整的 Kata Runtime,这个 Kata Runtime 就可以和原有的 qemu 进程对接,对接上之后回收一些资源。这样即使一个 Runtime 进程被 Kill 掉,也不会泄露其他的资源。
左下角就是刚才提到的 Metrics 模块。它主要将 Kata 容器所有的健康管理指标通过 Prometheus 协议上传到数据库。
看完架构之后我们来看 Kata 容器的启动流程。刚才提到 Kata 与 containerd 使用 shimv2 协议对接,每一个容器启动时,containerd 这边会发起一个 shimv2 的 Start 这个命令接口。containerd 这边会根据每一个 Runtime Class 变量来组装成一个新的 Runtime Class 的 binary,根据这个 binary 的 pass 来找到 shimv2 命令。找到之后会启动一个进程,进程启动之后首先加载 Kata 的配置文件,之后拉起一个 Hypervisor 进程,这个 Hypervisor 进程由 Sandbox 来管理。
Runtime 这边会等待虚拟机启动完毕,之后它会在里面启动一个 Kata Agent 进程。然后 Sandbox 这边的 Runtime 会发起一个请求给 Kata Agent,等请求连通之后就说明整个 Sandbox 启动完成。接下来 Agent 会向 Kata Agent 这边发起一个请求,初始化一些 Sandbox 所需要的资源,之后 Sandbox 启动流程结束。containerd 这边会发起一个新的 create container 请求,请求到了 Runtime 这边后首先解析 container,如果它需要一些热插设备,又会通过设备管理模块把这些设备插入到 Guest 里,在 Guest 里再做 mount。最后 Agent Kata 这边再往 Kata Agent 这边发起一个 create container 请求,这时 Guest 里的整个容器就拉起完毕。之后我们需要把 Kata 容器 IO 流从 Guest 这边中转到 Host 这边,因为这个 IO 流主要是 containerd 这边负责回收的。这样整个 Kata 容器的启动流程就算结束。
以上就是 Kata 1.0 到 Kata 2.0 的架构介绍。今年我们要发布 Kata 3.0。刚才提到 Kata Agent 已经用 Runtime 重写,而在 Kata 3.0 中我们想把 Golang 语言也替换成 Rust,用 Rust 语言重写 Kata Runtime。因为现在的 Kata 不仅支持 qemu 这种 Hypervisor,还支持 RustVM 的两种 Hypervisor。如果 Kata Runtime 也能用 Rust 语言重写,我们就会用一种语言来统一整个 Rust 社区的开发工作,所有的组件都是用 Rust 语言来开发。
另外,既然 Runtime 组件也是用 Rust 语言写,而 Hypervisor 也是 Rust 语言,如果它们组合成一个进程,会不会带来新的化学反应?这样 Kata 的运行时环境就从原来的两个进程变成了一个 Kata Rust Runtime,这样会极大精简 Kata 的整个架构,从原来的多进程模型进化到单进程模型,运维和内存开销都会大幅下降。
在 Kata 3.0 中我们还想引入的一个新特性是镜像加速功能。我们知道容器圈的 Image 都叫 OCR Image。它有两大缺点,一个是它的存储都是分层存储,既使我们在某一层把一个文件删除了,它也只是在上一层上做了一部分的掩盖,实际上文件的内容是没有被删除的,所以这样就带来一些文件冗余。另一个缺点是,容器启动之前首先要把整个容器的 Image 的所有内容都下载下来,组装成一个容器的 Rootfs,才能把容器启动起来。但有时启动一个容器时所需要的文件内容很少,而整个 Image 可能很大,所以最好能在启动的过程中只把所需要的那些文件内容下载下来。
于是蚂蚁集团和阿里云共同开发了一个新的镜像加速系统,名叫 Nydus。Nydus 在容器加载时,一开始只是把容器镜像的源数据下载下来,并告诉容器运行时说,你所需要的内容我都已经准备好了。可其实它只是欺骗了 Runtime 这边,这样运行时就能以非常快的速度把自己拉起来。拉起来之后,它首先要去访问一些镜像里的文件,这时候 Nydus 进程才会按需从 Server 上拉取所需文件,这样就会显著加速容器的启动过程。
另外 Nydus 的好处是它不仅可以加速容器的启动过程,还会对安全审计带来一定的益处。原来的 OCR Image 是分层加载的,而 Nydus 是按照文件的粒度加载的,而且文件的粒度还是划分到文件的 block 块。于是 Nydus 的 Server 可以感知到容器在运行过程当中都获取了哪些文件内容,可以实时跟踪访问文件的粒度。如果一个容器运行时被恶意程序攻击或者破坏,文件访问模式就有可能改变,这时我们可以根据原有统计数据及时发现这一情况。大家可以访问 Nydus 官网来了解更多内容。
Kata 3.0 引入的另一个特性是机密计算。安全容器的一个假定是说,提供容器的服务商不信任容器运行服务里边的程序,要形成一层隔离,让容器运行程序无法访问容器提供商的系统,这就是安全隔离。但一些银行不信任容器提供商,他们希望他们的服务或数据不被容器运行商窥探,于是要求容器的镜像首先不能在 Host 上加载,其次希望他们的容器内存放的内容不能被 Host 的其他容器或者容器服务提供商窥看。
针对这两个需求,Kata 容器给机密计算提供了一个新的特性,就是在 Guest 里加载容器镜像。这时用户的镜像加载是在 Guest 里完成,所走的网络也是客户自己的私有网络,数据在 Host 上是访问不到的。另外容器运行时利用了英特尔的机密计算技术对内存加密,客户的容器内存在 Host 上是无法访问的,无法被云服务提供商所窥探。
Kata 容器在蚂蚁集团的落地应用
下面我来分享 Kata 容器在蚂蚁集团的落地应用情况。
首先,安全是 Kata 容器的主要特性,但在蚂蚁的应用落地过程中,我们发现 Kata 容器不仅能提供安全隔离,还能提供性能隔离。蚂蚁的计算环境中有成千上万机器,它们的资源利用是不均衡的,会有很多闲置时间。我们会在闲置时间部署其他业务,从而充分利用硬件。但这会带来一个坏处,就是虽然这些离线业务的优先级较低,但它和 Guest 与其他业务之间还是共享内核。所以有可能一个低优先级程序拿到了内核的一些锁,或者其他的一些关键资源,导致了一些正向业务的响应时间无法得到满足,或出现抖动,这是客户无法接受的。所以我们想能否把低优先级任务放到 Kata 容器中,因为 Kata 容器有自己的一套系统,资源隔离能力要比传统的容器好,最后我们就形成了性能隔离的图谱。
低优先级任务用 Kata 容器封装,资源使用和高优先级的容器之间区分开,这样低优先级的任务就不可能影响到高优先级的任务。为了兼容我们的生态环境,蚂蚁内部形成了一个多运行时多解决方案的应用架构。
为了满足我们的不同需求,我们设计了一套存储架构。如果业务对 IO 不太敏感,就用原来的 virtio-fs 来在 Guest 和 Host 之间进行存储共享;如果业务对 IO 有一定要求,但要求量又不是太高,这时我们就会用一些裸文件块插入到 Guest 里作为一个读写层,这样既可以满足它对 IO 的一定需求,又可以在 Host 上做 IO 的隔离。如果业务对 IO 的要求非常高,这时我们可以把高速 SSD 设备直通到 Pod 里,这样它的整个资源就给这个 Pod 独占。
最后是网络应用。前面提到,Kata 容器为了兼容整个容器生态圈,在 Guest 的网络和 Host 网络之间做了一层转发。如果业务对网络的性能要求不是太高,就可以用传统的网络模式。
如果业务对网络要求性能较高,就可以利用直通模式把网卡直通到容器里,形成网络隔离环境。
以上就是 Kata 容器在蚂蚁落地之后的一些实践经验。我的分享到此结束,谢谢大家。
相关阅读:
如何在 Anolis OS 上轻松使用 Kata 安全容器?
评论