虚拟机容器 Kata 架构

容器(Container)是一种轻量级虚拟化技术,“轻量”主要是因为容器与传统虚拟机比较,是内核共享的,所以启动快、资源占用小。随着虚拟化技术发展,Docker 与 Kubernets 逐步成为应用打包与部署的标准,以及公有云租赁模式推广,出现了“虚拟机容器”这种形态。

虚拟机容器首先是一台虚拟机,由于 Docker 镜像打包与分发方面的优势(分层构建、Build Once Run Anywhere),虚拟机容器也兼容 Docker 镜像;并提供了 OCI 规范的 runtime ,且适配了 Kubernetes 调度管理 API,能够被 Kubernetes 调度,因此被称为虚拟机容器。

虚拟机容器主要使用场景是在公有云模式下,云服务厂商提供无服务器计算服务(函数服务,无服务器容器),这种模式下租户只租一个容器或进程,不同租户的容器 / 进程可能运行到同一台机器上,传统的内核共享模式在这种场景下安全风险太高,虚拟机容器能够做到内核独占,适合这种场景。

Kata 项目介绍

Kata 源自 Intel 的开源项目 Hyper.sh ,当前在 Openstack 软件基金会(OSF)下治理。

Kata 南向虚拟化实现技术可插拔,当前支持 QEMU/NEMU,在最新的 1.5 版本里支持 Firecrack;北向支持与 Docker 的 Containerd 对接,并可以被 Kubernetes 编排,接入 Docker 与 Kubernetes 生态。

Kata 架构概览 (基于 1.5 版本)

Kata container runtime and shimv2

Kata 容器项目主要由容器运行时( kata container runtime )与一个兼容 CRI 接口的 shim 部件组成。

Kata container runtime 符合 OCI 运行时规范 因此能够被 Docker 引擎管理,作为 Docker 引擎的一个 runtime 插件。 Kata container runtime 还基于 Containerd 的 CRI 插件与 CRI-O 实现了 Kubernetes 的 CRI 规范,因此,使用者可以在 Docker 默认的 runtime runc 与 kata container runtime (runv) 之间平滑切换,上层组件不感知差异。

Kata 容器的另外一个组成部分是 containerd-shim-kata-v2 ,简称为 shimv2 , shimv2 提供了了 Containerd Runtime V2 (Shim API) 的 Kata 实现,从而使得 Kubernetes 场景下能够实现每个 Pod 一个 shim 进程 – shimv2 ;而在此之前,一个 Pod 需要一个 2 个 shim( containrd-shim, kata-shim ),如果 Pod Sandbox 没有暴露 VSOCK 则还需要一个 kata-proxy 。

agent 与 kata-proxy

Kata 容器运行在虚拟机沙箱内,每个虚拟机内运行一个 Agent,Agent 负责运行 container。Agent 同时提供 gRPC 接口,通过 QEMU 的 VIRTIO serial 或 VSOCK 接口向 HOST 主机暴露接口,主机上的 kata-runtime 使用 gRPC 协议与 Agent 通信,向虚拟机内的容器发送指令,I/O 流也通过此通道管理。 如果是使用 VIRTIO serial 的方式暴露接口到 Host 主机,那么还需要在主机上部署一个 kata-proxy 负责转发指令到 Agent。

容器进程管理

在 Host 主机上,每个容器进程的清理是由更上层的进程管理器完成的,在 Docker containerd 的实现里,进程管理器是 containerd-shim ;在 CRI-O 的实现里是 common。

在 Kata 容器场景下,容器进程运行在虚拟机内,Host 主机上的进程管理器不能直接管理到容器进程,Kata 容器项目通过 kata-shim 来解决此问题。kata-shim 运行在 Host 主机上,介于容器进程管理器与 kata-proxy 之间,kata-shim 将来自 Host 主机的信号量、stdin 转发到虚拟机内的容器进程上,并将虚拟机容器内的 stdout 与 stderr 转发到 Host 主机上的容器进程管理器。

kata-runtime 为每个容器进程创建一个 kata-shim 守护进程,为每个通过 OCI 命令连接到容器进程内部执行用户命令的操作创建一个 kata-shim 守护进程(如 docker exec)。

在 Kata1.5 版本,shimv2 收编 kata-runtime, kata-shim, kata-proxy 到 shimv2 进程中。

虚拟化

Kata 架构上能够支持多种虚拟化实现,在 Kata1.0 版本,支持 QEMU/KVM 虚拟化。在 Kata1.5 版本,支持 AWS Firecracker 极轻量的虚拟机。

QEMU/KVM

根据 Host 主机架构,Kata 容器支持多种主机类型,比如 x86 上的 pc 与 q35 ,ARM 上的 virt , IBM Power System 上的 pseries 。默认的 Kata 容器主机类型是 pc ,默认主机类型可以通过配置修改。

Kata 容器使用下面的 QEMU 特性来管理资源配额、缩短启动时间、减少内存占用:
•机器加速器
•热插拔设备

机器加速器

机器加速器是与特定服务器架构相关的,机器加速器能够提升性能并开启某些特性。下面这些机器加速器在 Kata 容器中使用。
•NVDIMM: x86 平台的机器加速器,仅支持 pc 与 q35 机器类型。nvdimm 用来以持久化内存(persistent memory)方式提供虚拟机的根文件系统。

虽然 Kata 容器能够支持大多数 QEMU 发行版本,但是考虑到 Kata 容器的启动时间、内存占用、IO 性能因素,Kata 容器使用一个针对这些因素专门优化过的 QEMU 版本 qemu-lite ,并增加了一些自定义的机器加速器,这些自定义加速器在 QEMU Upstream 版本中不可用。
•nofw: x86 平台的机器加速器,仅支持 pc 与 q35 机器类型。 nofw 用来启动 ELF 格式的系统内核,但是可以跳过 BIOS 与固件自检 (BIOS/firmware) ,这个加速器可以显著提升虚拟机启动速度。
•static-prt: x86 平台的机器加速器,仅支持 pc 与 q35 机器类型。 static-prt 用来减少虚拟机 ACPI(Advanced Configuration and Power Management Interface) 的解释负担。

热插拔设备

Kata 容器虚拟机初始以最小的资源启动,为了提升启动速度,在启动过程中,设备可以热插到虚拟机上。如,容器定义了 cpu 资源,可以通过热插的方式加到虚拟机上。Kata 容器虚拟机支持如下热插设备:
•Virtio block
•Virtio SCSI
•VFIO
•CPU

Kernel 与 Image

虚拟机内核

虚拟机内核在虚拟机启动时候被加载,Kata 容器提供的虚拟机内核针对启动时间与内存占用做了优化。

虚拟机镜像

Kata 容器支持 initrd 与 root filesystem 两种虚拟机镜像。

root filesystem

Kata 容器提供的默认打包好的 root filesystem 镜像,这种镜像也被称为 “mini O/S”,是基于 Clear Linux 优化的,提供最小的运行环境与高度优化的启动路径。 镜像中只有 Kata Agent 与 systemd 两个进程,用户的工作负载被打包到 docker 镜像,在虚拟机内通过 libcontainer 库,以 runc 方式运行起来。

举例,当用户执行 docker run -it ubuntu date 命令时,流程如下:
•虚拟化层加载虚拟机内核,虚拟机内核加载虚拟机镜像。
•systemd 启动虚拟机运行环境(mini-OS Context),并启动 kata-agent 进程(在同一个 Context)
•kata-agent 创建一个独立的 context,运行用户指定的命令(例子中是 date )
•kata-agent 准备 ubuntu 的运行环境并运行 date 命令

initrd

待补充。

Agent

kata-agent 是一个运行在虚拟机中的进程管理虚拟机中的容器进程。

kata-agent 的最小运行单元是沙箱,一个 kata-agent 沙箱是一个由一些列 namespace(NS, UTS, IPC, PID) 隔离出来的。 kata-runtime 能够在一个虚拟机内运行多个容器进程以支持 POD 内多个 container 模式。

kata-agent 使用 gRPC 协议与 Kata 其他组件通信,在 gRPC 同一个 URL 上还运行了一个 yamux 服务。

kata-agent 使用 libcontainer 管理容器生命周期,复用了 runc 的大部分代码。

Runtime

kata-runtime 是一个符合 OCI 规范的容器运行时,负责处理 OCI 运行时规范中的所有命令,并启动 kata-shim 进程。

关键的 OCI 命令实现

create

kata-runtime 处理 OCI create 命令步骤:
1. 创建虚拟机与 shim 进程的 network namespace。
2. 执行 pre-start hook ,回调中负责创建 veth 网络设备,用于连接主机网络与新创建的 network namespace。
3. 扫描新创建的 network namespace,在其中的 veth 设备上创建一个 macvtab 设备。
4. 在新的 network namespace 中创建虚拟机,并将 tab 设备传递给虚拟机。
5. 等待虚拟机启动完成。
6. 启动 kata-proxy,kata-proxy 负责代理所有发送给虚拟机的请求,每个虚拟机一个 kata-proxy 进程。
7. 调用 kata-agent 接口配置虚拟机内的沙箱。
8. 调用 kata-agent 接口创建容器,使用 kata-runtime 提供的默认的 OCI 配置文件 config.json 。
9. 启动 kata-shim 进程,kata-shim 连接到 kata-agent 的 gRPC socket 端口。kata-shim 会创建几个 Go routine 阻塞式调用 ReadStdout(), ReadStderr(), WaitProcess()。ReadStdout(), ReadStderr()以死循环方式执行直到虚拟机内的容器进程中止。 WaitProcess() 返回虚拟机内容器进程的 exit code。 kata-shim 运行在虚拟机的 network namespace 中,通过 kata-shim 进程可以找到创建了哪些 namespace。启动 kata-shim 进程还会创建一个新的 PID namespace,对应到同一个 container 的所有 kata-shim 进程都在同一个 PID namespace,这样当容器进程终止时候很容器将所有 kata-shim 进程终止掉。

此时容器进程在虚拟机内部运行起来了,在 Host 主机上对应到 kata-shim 进程。

start

传统容器的 start 会在容器 namespace 中启动容器进程。Kata 容器中, start 会在虚拟机内启动容器的工作负载,步骤如下;
1. 调用 kata-agent 接口在虚拟机内启动容器负载命令。如,容器内负载命令为 top ,kata-shim 进程的 ReadStdout()会读取到 top 的输出, WaitProcess() 会一直等待到 top 命令结束。
2. 执行 post-start 回调,当前 post-start 实现为空。

exec

OCI 的 exec 命令允许在已有的容器中执行命令。在 Kata 容器中, exec 执行步骤如下:
1. 调用 kata-agent 接口在已有容器中执行命令。
2. 一个新的 kata-shim 进程会创建出来,被放置到已有容器对应的 kata-shim 所在的 PID namespace 中。

此时通过 exec 命令启动的新的容器负载已经在虚拟机内运行,共享已有容器的 namespace (uts, pid, mnt, ipc) 。

kill

OCI kill 命令通过发送 UNIX 信号,如 SIGTERM, SIGKILL ,来终止容器进程。在 Kata 容器中, kill 命令会终止虚拟机内的容器进程与虚拟机。
1. 调用 kata-agent 接口请求 kill 容器进程。
2. 等待 kata-shim 进程退出。
3. 调用 kata-agent 接口请求强制 kill 容器进程(发送 KILL 信号量给容器进程),如果 kata-shim 进程在超时时间内未退出。
4. 等待 kata-shim 进程退出,如果等待超时则报错。
5. 调用 kata-agent 接口删除虚拟机内的容器配置。
6. 调用 kata-agent 接口删除虚拟机内的沙箱配置。
7. 停止虚拟机。
8. 删除 network namespace 中的网络配置,删除 network namespace。
9. 执行 post-stop 回调。

delete

delete 指令删除容器所有相关的资源,正在运行中的容器不能不删除,除非通过 –force 指令强制删除。

如果虚拟机内的沙箱未停止,但是沙箱内的容器进程已经推出,kata-runtime 会先执行一次 kill 流程,之后如果沙箱已经停止,kata-runtime 执行如下动作:
1. 删除容器相关资源:目录 /var/{lib,run}/virtcontainers/sandboxes// 下的所有文件。
2. 删除沙箱:目录 /var/{lib,run}/virtcontainers/sandboxes/ 下的所有文件。

此时,所有容器相关内容都已经在 Host 主机上被删除,没有任何相关进程在运行。

state

state 返回容器的运行状态。在 Kata 容器中,state 需要检测容器进程是否在运行,通过检查对应容器进程的 kata-shim 进程的状态。
1. 通过存储在磁盘上的信息获得容器状态(需要澄清)。
2. 检查 kata-shim 进程。
3. 如果 kata-shim 进程不存在,但是磁盘上的容器状态文件还是 ready 或 running,那么意味着在得到容器返回状态之前,容器进程已经被正常停止了。

Proxy

Host 主机与虚拟机通信可以通过 virtio-serial 或 virtio-socket , virtio-socket 需要内核版本 4.8 以上。默认使用 virtio-serial 。

虚拟机内可能运行多个容器进程,在使用 virtio-serial 场景下,需要 Host 主机上运行 multiplexed 与 demultiplexed 进程;使用 virtio-socket 则不需要。

kata-proxy 进程提供代理访问虚拟机内的 kata-agent ,对应到多个 kata-shim 进程与 kata-runtime 客户端。 kata-proxy 主要功能是负责代理 IO 流与信号量到 kata-agent 。 kata-proxy 通过 Unix domain socket 方式连接 kata-agent 。

Shim

容器进程回收器(reaper),如 Docker 的 containerd-shim 或 CRI-O 的 common ,其设计假设是基于能够监控并回收实际的容器进程。在 Kata 容器中,由于容器进程运行在虚拟机中,Host 主机上的容器进程回收器不能直接监控到虚拟机内的容器进程,至多能看到 QEMU 进程,这个是远远不够的。Kata 容器中的 kata-shim 进程是主机上对应到虚拟机内容器进程的映射,因此 kata-shim 进程需要处理容器进程的 I/O 流并负责转发信号到容器进程。
1.kata-shim 通过 Unix domain socket 连接到 kata-proxy,socket URL 是由 kata-runtime 在启动 kata-shim 进程时候传递给 kata-shim 的,一并传递的参数还有 containerID 与 execID,containerID 与 execID 用来标识虚拟机内的容器进程。
2. 转发来自容器进程回收器的标准输入流,通过 kata-proxy 的 gRPC 的 WriteStdin API。
3. 从容器进程读取标准输出与错误输出。
4. 转发来自容器进程回收器的信号,通过 kata-proxy 的 SignalProcessRequest API。
5. 监控终端的变化并转发,通过 kata-proxy 的 TtyWinResize API。

Networking

容器进程一般被放置在独立的 network namespace 中,在容器生命周期的某个点,容器引擎会创建 network namespace 并将容器进程加入到 network namespace 中。容器进程网络与主机 Host 网络隔离。 在实现技术上,通常使用 veth 技术,veth 的两端分别放置到容器的 network namespace 与主机 Host 的 network namespace 中。这种方式是以 namespace 为中心的,而一些虚拟化技场景下不支持 veth,特别是 QEMU,这种情况下使用 TAP 技术替代。

为了消除虚拟化场景下网络隔离与容器场景下网络隔离的不兼容,kata-runtime 使用 veth+tab(MACVLAN) 方式实现网络隔离与互通。
•主机 Host 网络与容器网络通过 network namespace 隔离,使用 veth 互通。
•容器网络 network namespace 内的 veth 网络设备上创建 MACVLAN(实际上为 MACVTAP)设备。
•在创建虚拟机(QEMU)时候,将 TAP 网络设备作为虚拟机网卡。
•虚拟机内的容器进程使用虚拟机网卡作为主网卡与外部通信,虚拟机内部的多个 POD 共享虚拟机网络,不再隔离。

Kata 容器支持 CNM 与 CNI 两种容器网络标准。

CNM

CNM lifecycle
1.RequestPool
2.CreateNetwork
3.RequestAddress
4.CreateEndPoint
5.CreateContainer
6.Create config.json
7.Create PID and network namespace
8.ProcessExternalKey
9.JoinEndPoint
10.LaunchContainer
11.Launch
12.Run container

CNM 网络配置过程
1. 读取 config.json
2. 创建 network namespace: netns
3. 回调 prestart 钩子(在 netns 内)
4. 扫描 netns 命名空间下的,由 prestart 回调创建的网络接口
5. 创建 bridge/tap 并通过 veth 与主机连接

网络热插拔

Kata 容器开发了一套命令与 api 支持添加 / 删除 / 查看 guest 网络。下面流程图展示了 Kata 容器热插拔的流程。

存储

Kata 容器的虚拟机与 Host 通过 9pfs 共享文件,对于虚拟机内的容器,不建议使用主流的 overlay2 存储驱动,overlay2 存储驱动是基于文件系统的。

Kubernetes 集成

Kubernetes 目前是容器编排的事实标准,Kubernetes 为了解耦 Kubelet 与各种容器 runtime,抽象了 CRI(Container Runtime Interface) 接口规范,Kubelet 相当于是一个 CRI 客户端,不同的 CRI 实现提供 gRPC 接口与 Kubelet 对接,接入到 Kubernetes 生态。当前基于 OCI 标准容器提供的 CRI 接口实现有 CRI-O 与 Containerd CRI Plugin。

Kata 容器的 runtime 是 CRI-O 与 Containerd CRI Plugin 的官方 runtime 之一,因此 Kata 容器可以很容易的集成到 Kubernetes 生态中。

但是由于 Kubernetes 最小调度单元是 Pod 而非容器进程,一个 Pod 可以有多个容器进程,而 Kata 容器又是将容器进程跑在虚拟机内的,因此 Kata 容器需要 Kubernetes 在创建容器进程传递更多信息,用来告知 Kata runtime 是要创建一个新的虚拟机,还是在已有虚拟机内启动容器进程。

Containerd CRI Plugin 集成 kata-runtime

在 Kata1.5 版本,对应 containerd1.2.0,通过 shimv2 实现了 Kata runtime 与 Kubernetes 集成,具体指导参考链接。CRI-O 的实现也正在开发中,跟踪此 Issue。

CRI-O 集成 Kata-runtime

OCI annotations

为了让 kata-runtime(或者任何虚拟机容器的 runtime)区分是要创建一个虚拟机还是仅在虚拟机内创建容器进程,CRI-O 在 OCI 配置文件(config.json)中增加了一个 annotation 来告知这个区分给 kata-runtime。

在执行 runtime 之前,CRI-O 会增加一个 io.kubernetes.cri-o.ContainerType 的 annotation,这个注解由 Kubelet 生成,取值范围是 sandbox, container,kata-runtime 将 sandbox 对应到创建虚拟机(新 Pod),container 对应到在已有 Pod 中创建容器进程。

containerType, err := ociSpec.ContainerType()
if err != nil {
return err
}

handleFactory(ctx, runtimeConfig)

disableOutput := noNeedForOutput(detach, ociSpec.Process.Terminal)

var process vc.Process
switch containerType {
case vc.PodSandbox:
process, err = createSandbox(ctx, ociSpec, runtimeConfig, containerID, bundlePath, console, disableOutput, systemdCgroup)
if err != nil {
return err
}
case vc.PodContainer:
process, err = createContainer(ctx, ociSpec, containerID, bundlePath, console, disableOutput)
if err != nil {
return err
}
}

虚拟机容器与 namespace 隔离容器混合管理

一个有趣的演进是在一个 Kubernetes 集群中混合管理虚拟机容器与 namespace 隔离的容器。 现在 Kubernetes 集群运维人员可以对工作负载打 trusted, untrusted 标签,trusted 标签表示工作负载是安全的,untrusted 表示工作负载存在潜在风险,在支持 kata 容器的 Kubernetes 集群中,会自动根据标签,将 trusted 工作负载以 runc 方式运行,untrusted 工作负载以 runv(kata-runtime)方式运行。

CRI-O 默认行为是认为所有的工作负载在都是 trusted,除非设置了注解 io.kubernetes.cri-o.TrustedSandbox=false,CRI-O 默认的 trust 配置在 configuration.toml 中。

综合来看,CRI-O 是选择 runc 还是 runv,由 Pod 的 Privileged 参数,CRI-O trust 配置 trusted/untrusted,io.kubernetes.cri-o.TrustedSandbox 注解三个值确定。 如果 Pod 是 Privileged,那么只能是 runc。如果 Pod 不是 Privileged,那么 runtime 的选择方式如下:

io.kubernetes.cri-o.TrustedSandbox 未设置

io.kubernetes.cri-o.TrustedSandbox=true

io.kubernetes.cri-o.TrustedSandbox=false

默认的 CRI-O turst 设置: trusted runc runc runv(kata-runtime)
默认的 CRI-O turst 设置: untrusted runv(kata-runtime) runv(kata-runtime) runv(kata-runtime)