http://liubin.org/kata-dev-book/src/runtime-arch.html

由于本人也是刚入门,有什么错误的地方还请各位大佬及时指正,感谢感谢!!

docker和Kata Containers的区别

Docker容器共享宿主机内核

Docker容器是基于Linux内核的命名空间(Namespaces)和控制组(cgroups)技术实现的。简单来说,Docker为容器提供了看起来像是独立的文件系统、网络、进程空间、用户ID空间等,但实际上,所有容器以及宿主机上的Docker守护进程都共用同一个Linux内核。这就意味着,虽然容器在逻辑上隔离,但在物理层面,它们共享宿主机的CPU、内存和操作系统内核。如果内核存在安全漏洞或容器内程序试图访问未授权的系统资源,理论上存在安全隐患。

每个容器实例运行在轻量级虚拟机(MicroVM)

Kata Containers等技术采用了一种不同的方法,它为每个容器实例创建了一个非常轻量级的虚拟机(MicroVM)。在这个模型中,每个容器不再直接共享宿主机的操作系统内核,而是拥有自己的微型虚拟化环境,包括独立的内核。这通常通过QEMU-KVM这样的技术实现,为每个容器提供一个隔离的硬件抽象层,包括CPU、内存、I/O设备等,以及一个独立的内核。

这意味着,即使容器内的程序试图突破其安全边界,它也无法直接影响宿主机或其它容器,因为每个容器都在自己的微型虚拟机内运行,有着更严格的硬件层面的隔离。这种设计显著提高了安全性,尤其是在运行不可信代码或需要强隔离的场景下,但代价是可能稍微增加了资源消耗和启动时间,因为每个容器都需要启动自己的内核和虚拟化环境。

总结

  • Docker容器通过命名空间和控制组提供逻辑隔离,共享宿主机的内核,资源利用高效,但隔离度相对较低。
  • Kata Containers等使用MicroVM技术,为每个容器提供独立的内核和硬件抽象层,隔离度高,安全性增强,但可能牺牲一点启动速度和资源效率。

通俗例子Kata Containers架构

在这里插入图片描述

这张图展示的是Kubernetes(kubelet)如何通过CRI接口与Containerd以及Kata Containers进行交互的过程。

  • Kubernetes(kubelet) 是我们的聪明管家。
  • Containerd 是物业经理。
  • Kata Containers 则是提供了一种更安全的方式运行容器,就像在每个房间外面加了一个小房子(轻量级虚拟机),使得不同房间之间的隔离性更好。

现在,假设房东(用户)希望在一个更加安全的环境中运行他的软件。于是,他告诉管家(Kubernetes):“我需要一个特别的房间,里面要有一个独立的小房子,确保我的软件和其他东西完全隔离开。”

  1. 管家(Kubernetes)接到命令:管家知道房东的需求后,决定使用Kata Containers提供的服务。他通过CRI接口告诉Containerd,这次需要一个特殊的房间,即一个带有轻量级虚拟机的容器。

  2. Containerd调用Kata Containers:为了满足这个需求,Containerd调用了名为"containerd-shim-kata-v2"的东西,这是一个专门用于处理Kata Containers请求的代理程序。这个代理程序进一步与hypervisor(虚拟机管理器)通信,告诉它需要创建一个轻量级虚拟机来容纳新的容器。

  3. hypervisor创建轻量级虚拟机:hypervisor收到请求后,在主系统上创建了一个轻量级虚拟机,这个虚拟机有自己的客体操作系统(guest kernel)。在这个虚拟机内部,hypervisor还为新容器创建了一个单独的空间。

  4. 容器在轻量级虚拟机中运行:最后,hypervisor在轻量级虚拟机内的空间里启动了新容器,这个容器与其他容器以及其他进程都得到了更好的隔离保护。

整个过程完成后,管家(Kubernetes)就可以告诉房东(用户)他的软件已经在一个非常安全的环境中运行了。同时,由于使用了Kata Containers,即使其他房间出现故障,也不会影响到这个特别的房间。

实际Kata Containers架构

在这里插入图片描述

图解容器技术栈

  1. 最上层:应用管理平台(如Kubernetes)

    • 这一层是用户或管理员与系统交互的地方,用于部署、管理和编排容器化的应用。它会发送命令给下一层,比如创建、停止或重启容器。
  2. 容器运行时管理器(Containerd)

    • Containerd是操作系统级别的软件,负责容器的生命周期管理。它接收来自上层的命令(如Kubernetes的CRI接口传来的命令),然后执行创建、启动、停止容器等操作。
  3. Shim 层(containerd-shim-kata-v2)

    • Shim层是一个轻量级的进程,作为Containerd和更底层容器运行技术之间的桥梁。containerd-shim-kata-v2特别针对Kata Containers设计,它的作用是将Containerd的标准化API转换为Kata Containers能够理解的格式,并管理Kata Containers实例的生命周期。Shim层确保了Containerd不需要直接了解Kata Containers的具体实现细节。
  4. Kata Containers

    • Kata Containers是一个容器运行时,它通过轻量级虚拟化技术为每个容器提供更强的隔离性。与传统的容器相比,Kata使用了一个微型的虚拟机(VM)来运行每个容器,从而实现了接近于虚拟机级别的安全隔离,同时尽量保持容器的启动速度和资源效率。
  5. Hypervisor

    • 在Kata Containers架构中,Hypervisor是核心组件之一。它是一个运行在物理主机上的软件,能够创建和运行虚拟机。Hypervisor负责分配物理资源(CPU、内存、I/O设备)给每个微型虚拟机,确保它们之间的隔离。对于Kata Containers而言,QEMU是一个常用的Hypervisor,它创建并管理那些运行容器的微型VM。

总结

这张图可能详细展示了从应用管理层到基础虚拟化层的整个流程,强调了不同层次之间的信息流动和职责划分。Containerd作为容器运行时的核心,通过shim层间接控制Kata Containers及其依赖的Hypervisor,共同为容器提供了一个高度安全、隔离的运行环境。

agent和shim

想象一下,你是一位忙碌的家长(shim进程),需要照顾住在不同房间的孩子们(容器),而每个房间里都有一位贴心的保姆(agent)。你希望确保孩子们的需求得到满足,同时也需要监督他们的活动,但又不能直接进入每个房间打扰他们。

家长(shim进程)的角色

作为家长,你负责协调家里的一切,包括孩子们提出的要求和日常安排。但是,直接跟每个孩子交流会很耗时,也不够高效。于是,你决定通过房间里的对讲机系统(agent协议)与每位保姆沟通。

保姆(agent)的职责

保姆住在孩子的房间里,对孩子们的需求了如指掌。他们通过房间内的对讲机监听家长的指示,比如“给小明准备晚餐”或“让小红休息一下”。保姆接收到信息后,会立即按照要求照顾孩子,同时也会通过对讲机向家长报告孩子们的状态,比如“小明已经吃完饭,在看书了”。

“agent协议”的作用

这里的“agent协议”就像是那个对讲机系统,它定义了家长和保姆之间沟通的语言和规则。比如,规定了如何表达“吃饭时间”、“休息”等指令,以及如何报告“已完成”、“需要帮助”等反馈。这样,家长就能在不直接进入房间的情况下,高效地管理整个家庭的日常,同时保证每个孩子(容器)的需求得到个性化的关注和满足。

实际技术映射

回到Kata Containers的场景,shim进程相当于那位细心的家长,它通过“agent协议”与在虚拟机(guest)内部运行的agent进行沟通。这个协议确保了宿主机上的管理进程能够安全地发送控制命令、获取状态信息,而无需直接干预虚拟机内部的运作,从而维护了容器的隔离性和安全性。通过这种方式,Kata Containers能在保证高性能的同时,提供类似虚拟机级别的安全隔离能力。

runtime部分

在这里插入图片描述
想象一下你在经营一家非常特别的酒店,这家酒店叫做“容器旅馆”。这家旅馆有一些特别的规定和高科技设备来确保每位客人的住宿既舒适又安全。

  1. 前台(containerd):containerd就像是酒店的前台,它负责接待客人(即处理创建、启动、停止容器的请求)。当有新客人入住或者离开时,前台会处理所有的入住手续和退房事宜。

  2. 特别安保房间(Kata Containers):不同于普通酒店,这里的每个房间都是高度安全的小型公寓,称为Kata Containers。每个房间都配备了一套完整的安全系统和独立的管家服务,确保住客之间完全隔离,不会互相干扰或窥探隐私。这就像是每个客人都有自己的小房子,而不仅仅是一个房间。

  3. 保安兼翻译(containerd-shim-kata-v2):由于这些安保房间比较特殊,前台和房间之间需要一个既懂前台规则又能与房间直接沟通的人,这就是containerd-shim-kata-v2。它帮助前台的指令准确无误地传达给安保房间,并把房间的反馈传回前台。

  4. 房间内的智能助手(Agent):每个安保房间内部还有一个智能助手,它负责日常维护和汇报房间状态给安保监控中心。这就像房间里的一个小机器人,随时检查房间是否一切正常,并与外界保持通讯。

  5. 安保监控中心(Kata Monitor):这是一个高级监控系统,持续关注所有安保房间的状态,确保一切运作正常,如果有任何异常,它会立即通知前台采取行动。

  6. 隔离技术(Hypervisor & Guest Kernel):为了让每个房间彻底独立,酒店使用了一种高科技手段,为每个房间创造了一个虚拟的世界,包括一套迷你操作系统(Guest Kernel)和虚拟环境管理器(Hypervisor)。这样,即使一个房间出现问题,也不会影响到其他房间。

containerd-shim-v2

type service struct {
    // hypervisor pid, Since this shimv2 cannot get the container processes pid from VM,
    // thus for the returned values needed pid, just return the hypervisor's
    // pid directly.
    hpid uint32

    // shim's pid
    pid uint32

    sandbox    vc.VCSandbox
    containers map[string]*container
    config     *oci.RuntimeConfig
    events     chan interface{}
    monitor    chan error

    id string
}

让我们用一个比喻来理解这段描述中的service struct及其关键属性,将其放在一个虚拟的科技园区场景中:

想象你是一位科技园区的管理员,负责管理整个园区的运营和安全。这个园区就像是一个sandbox(沙盒环境),而园区中的各个创业公司就是containers(容器)。你手头有一份园区布局图(service struct),上面详细记录了园区的管理策略和关键信息。

  1. hypervisor pid (hpid): 这相当于园区内的一座中央控制塔的编号,它监管着整个园区的电力供应。由于某些特殊原因,当你需要报告园区内某个区域的活跃状态时,如果无法直接获取具体公司(容器)的信息,你会直接提供这座控制塔(hypervisor)的编号作为参考。

  2. shim’s pid (pid): 这是你的个人工号,标识了你是这个园区唯一的管理员,负责园区的日常运营和紧急响应。

  3. sandbox (vc.VCSandbox): 园区本身作为一个整体,它遵循一套标准的管理规范(vc.VCSandbox接口),这套规范定义了如何维护园区基础设施、如何接纳新的创业公司入驻、如何确保园区的生态环境健康等。

  4. containers (map[string]*container): 这是一个详细的园区公司名录,键是公司的名称,值是每个公司的详细信息。你通过这份名录来管理各个公司的进出、资源分配、活动协调等。许多具体的管理工作,比如帮助新公司入驻、调整已有公司的办公空间,都依赖于这个名录。

  5. events (chan interface{}): 这是一个即时通讯频道,园区内发生的任何事件(如新公司开业、活动举办、安全警报等)都会通过这个频道即时通知你,让你能够迅速响应和处理。

  6. monitor (chan error): 监控通道,专门用来报告园区核心设施(特别是那个中央控制塔)的运行状况。如果控制塔出现故障或者园区面临重大问题,这个通道会发送错误信息给你,确保你能立即介入解决问题,保障园区的正常运转。

type Shim interface {
    shimapi.TaskService
    Cleanup(ctx context.Context) (*shimapi.DeleteResponse, error)
    StartShim(ctx context.Context, id, containerdBinary, containerdAddress string) (string, error)
}

type TaskService interface {
    State(ctx context.Context, req *StateRequest) (*StateResponse, error)
    Create(ctx context.Context, req *CreateTaskRequest) (*CreateTaskResponse, error)
    Start(ctx context.Context, req *StartRequest) (*StartResponse, error)
    Delete(ctx context.Context, req *DeleteRequest) (*DeleteResponse, error)
    Pids(ctx context.Context, req *PidsRequest) (*PidsResponse, error)
    Pause(ctx context.Context, req *PauseRequest) (*google_protobuf1.Empty, error)
    Resume(ctx context.Context, req *ResumeRequest) (*google_protobuf1.Empty, error)
    Checkpoint(ctx context.Context, req *CheckpointTaskRequest) (*google_protobuf1.Empty, error)
    Kill(ctx context.Context, req *KillRequest) (*google_protobuf1.Empty, error)
    Exec(ctx context.Context, req *ExecProcessRequest) (*google_protobuf1.Empty, error)
    ResizePty(ctx context.Context, req *ResizePtyRequest) (*google_protobuf1.Empty, error)
    CloseIO(ctx context.Context, req *CloseIORequest) (*google_protobuf1.Empty, error)
    Update(ctx context.Context, req *UpdateTaskRequest) (*google_protobuf1.Empty, error)
    Wait(ctx context.Context, req *WaitRequest) (*WaitResponse, error)
    Stats(ctx context.Context, req *StatsRequest) (*StatsResponse, error)
    Connect(ctx context.Context, req *ConnectRequest) (*ConnectResponse, error)
    Shutdown(ctx context.Context, req *ShutdownRequest) (*google_protobuf1.Empty, error)
}

想象一下,你在经营一个虚拟的游戏世界,这个世界由无数个小游戏(任务)组成,每个游戏都有它的规则和生命周期。Shimv2就像是这个虚拟世界的管理员,负责创建、管理以及结束这些小游戏(任务)。

Shim Interface

  • shimapi.TaskService:这部分接口就像是一本详细的“游戏指南”,包含了如何开始一个新的游戏(Create)、如何让游戏开始运行(Start)、如何暂停或继续游戏(PauseResume)、甚至如何在游戏中途调整规则(Update)等等。每个游戏(任务)有自己的状态(State),管理员可以查询它是否还在运行,是否已经结束。

  • Cleanup:当游戏(任务)结束后,需要有人收拾场地,准备迎接新的游戏。Cleanup就是这个清理员,它确保即使游戏管理员(shimv2进程)因故提前离场,也能通过其他方式清理遗留的资源,保证游戏世界的干净整洁。

  • StartShim:想象你新开了一个游戏区域,需要安排一位新的管理员来专门负责。StartShim就是这个过程,它负责启动一个新的管理员(shimv2进程),并告知他游戏世界的入口(containerdBinary和containerdAddress)、他所负责区域的编号(id),让他能够顺利开始工作。

容器与任务

在游戏世界里,一个“容器”可以比作是一个游戏套装,它包含了所有游戏开始前需要的道具和规则说明,但直到你真正开始玩这个游戏(运行容器),它才变成一个活生生的“任务”——一个正在运行中的游戏实例,有它自己的生命历程和玩家互动。

实际例子

假设你决定在你的虚拟世界中增加一个“寻宝任务”(容器)。首先,你需要准备任务包(创建容器配置),包含地图、宝藏位置等信息(静态资源)。然后,通过StartShim命令,你指派了一位管理员(启动shimv2进程),告诉他任务的规则和如何与其他系统(containerd)协调。

一旦任务(寻宝游戏)正式开始(任务运行),它就变成了一个活跃的进程(Task),管理员通过TaskService接口来管理这个任务的方方面面,比如调整难度(资源限制调整)、查看玩家进度(状态查询)、结束任务(删除任务)等。

如果某天,这个管理员突然消失了(shimv2进程意外退出),你还可以通过Cleanup来清理遗留问题,确保游戏世界继续平稳运行,为下一个任务做好准备。

启动过程

在这里插入图片描述

想象一下你在家里安装了一个智能家居系统,这个系统就像containerd,帮助你管理家里的各种智能设备(相当于容器)。而shimv2呢,你可以把它想象成是你家里的一个智能中控台。

第一个启动的shimv2相当于中控台做了开机自检,确保一切准备就绪,然后它开启了一个特别的对讲机频道(ttrpc服务),这个频道能让它和其他智能设备高效沟通。并且将该频道的地址返回给containerd

第二个启动的shimv2则是那个一直开着的智能中控台本身。它不关机,始终在那里等着接收你的命令或者自动执行预设的任务。比如,你可以通过手机APP(相当于containerd发来的指令)告诉中控台:“把客厅的灯打开”,或者“调节卧室空调温度到25度”。这个中控台(第二个shimv2)就是通过之前设置的那个对讲机频道(ttrpc server)来接收和执行这些命令的。

这个智能中控台会一直开机在线,通过一种特殊的沟通方式(ttrpc),等着接收并执行你(通过containerd)的各种家居控制命令,确保你的智能家居生活顺畅无阻。

CreateContainer

在这里插入图片描述

想象下总导演要举办一场比赛

  1. 总导演(containerd)向技术团队(shimv2)发出命令,要求创建一个新的比赛区域(PodSandbox)。这个命令从containerdservice.go文件中的Create()方法开始。

  2. 技术团队接到命令后,开始按照预定的步骤搭建比赛区域。这个过程涉及到了多个文件和函数调用,如create.go中的create()方法、pkg/katautils/create.go中的CreateSandbox()方法以及virtcontainers/api.go中的CreateSandbox()方法等。

  3. 在搭建过程中,技术团队需要完成以下工作:

    • 规划布局newSandbox()):确定比赛区域的基本结构和配置,例如网络设置、存储空间等。
    • 创建资源管理器createCgroupManager()):设置资源限制和监控机制,确保比赛区域内的资源分配合理。
    • 初始化沙箱的 CgroupssetupSandboxCgroup()):确保比赛区域内的资源分配合理,例如CPU时间片、内存限制等。
    • 启动虚拟机startVMM()):为比赛区域提供硬件模拟支持,例如处理器数量、内存容量等。
    • 创建并启动容器createContainers()):根据比赛需求,在比赛区域内创建并启动各个比赛项目对应的容器实例。
  4. 当所有准备工作完成后,技术团队会向总导演报告比赛区域已准备好,并等待进一步的指示。

  5. 总导演收到报告后,可以开始安排具体的比赛项目(PodContainer),由技术团队在比赛区域内创建相应的容器实例。

  6. 比赛开始后,总导演可以通过控制台(containerd的API)实时查看比赛状态,调整资源分配,甚至在必要时终止某些比赛项目。

  7. 比赛结束后,总导演通知技术团队清理比赛区域,释放资源。技术团队会按照既定的流程逐步关闭容器、停止虚拟机并删除比赛区域。

通过这样的方式,containerd利用shimv2实现了对容器生命周期的有效管理和调度。。

PodSandbox和PodContainer

想象一下,你正计划在自家后院举办一场周末烧烤聚会,这个场景可以帮助我们形象地理解PodSandboxPodContainer的区别。

PodSandbox:创建沙盒容器

在准备聚会时,首先你需要清理并布置一块区域作为烧烤活动的专用场地,这块区域就相当于PodSandbox。你铺设防滑垫、搭建遮阳棚、准备垃圾桶等基础设施,确保场地既安全又能满足烧烤的基本需求。这个场地并不直接烤肉或进行食物准备,但它为所有烧烤活动提供了基础环境。在Kubernetes中,PodSandbox就是这样一个基础环境,它包括网络命名空间、存储卷挂载、安全上下文等,为真正的应用程序容器提供运行的基础。

PodContainer:创建普通业务容器

当场地准备就绪,你开始放置烤架、准备食材和工具。第一个烤架用来烤肉,第二个用来烤蔬菜,这些烤架和它们各自的任务就相当于PodContainer。烤肉的烤架(容器)专注于制作美味的牛排,而蔬菜烤架(另一个容器)则专注于各种蔬菜的烤制。每个烤架(容器)都有其特定的功能,共同服务于聚会(Pod)的整体目标。在Kubernetes里,PodContainer就是执行具体应用逻辑的容器,比如一个容器运行数据库,另一个容器运行前端服务,它们共同协作来提供完整的应用服务。

总结

因此,PodSandbox就像是搭建了一个具有基础服务和环境的舞台,而PodContainer则是在这个舞台上扮演不同角色的演员,各自承担一部分演出任务,共同完成一场精彩的表演(提供服务)。在Kubernetes中,通过这样的设计,使得应用的部署、管理更加灵活和高效。

agent

  • ttrpc服务:作为 agent 服务,通过 ttrpc 响应来自 shim 进程的请求
  • 启动容器:启动容器时,agent 会通过运行 agent 二进制在新的进程中启动容器

想象你经营着一家快递公司,这个公司就好比是我们的容器环境。在这家公司里,有一个核心部门叫做Agent服务,它扮演着调度中心的角色。

ttrpc服务 - 快递调度中心的接线员

Agent服务中有一个重要的职责是通过ttrpc(想象成一种高级的对讲机系统)来接收和响应来自各个配送站点(这里比喻为shim进程)的请求。每个配送站点代表一个具体的任务区域,它们会根据客户(外部系统)的需求,通过ttrpc对讲机向调度中心(Agent服务)发送请求,比如:“客户A需要紧急发送一个包裹,请安排取件”。

调度中心的接线员(ttrpc服务部分)接到这些请求后,会迅速记录下来,然后分配给合适的快递员(相当于Agent内部的处理逻辑),确保每一个请求都能被及时、准确地处理和回应。这样,无论是收件、派件还是查询包裹状态,都能高效进行,保证了整个快递网络的顺畅运作。

启动容器 - 新的快递配送小分队

另外,当有新业务拓展或特殊需求时,比如开辟一条全新的快递线路(启动一个新的容器服务),Agent服务不仅仅是个调度中心,它还能充当“人力资源部”。这时,它会启动一个全新的快递小分队(通过运行Agent二进制在新的进程中),这个小分队专门负责这条新线路的全部快递事务,确保新开业务能够独立、高效地运行起来,而不会干扰到现有业务。

启动 ttrpc 服务

fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
    let args: Vec<String> = env::args().collect();

    // 如果是 kata-agent init,则准备进入创建容器的步骤
    if args.len() == 2 && args[1] == "init" {
        rustjail::container::init_child();
        exit(0);
    }

    let rt = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()?;

    // 在 real_main 里启动 ttrpc 服务。
    rt.block_on(real_main())
}

启动 agent 服务代码在 real_main 函数中。

启动容器前

之前的图中shim v2后面创建容器(为后面实际容器提供基础环境)会通过agent来创建,但是通过shim的定义agent.go文件完成
在这里插入图片描述

而当前这部分创建并启动容器是通过rust

create_container/do_create_container

创建容器前的准备工作

  1. rescan_pci_bus: 想象你在整理家里的储藏室,发现了一些新设备(比如新的USB设备),这个步骤就是在虚拟化环境中重新扫描硬件,确保系统能够识别并准备好这些新设备供容器使用。

  2. add_devicesadd_storages: 就像为新房添置家具,你需要根据装修蓝图(OCI Spec)选购和布置家具(设备配置)。这两个步骤确保容器内能够访问到所需的物理或虚拟设备,以及挂载必要的存储卷,以满足应用程序的运行需求。

  3. update_container_namespacesupdate_device_cgroup: 容器就像一个独立的公寓,有自己专属的地址(namespace)和物业规定(cgroup)。这两个操作是调整容器的隔离环境,确保其拥有独立的网络、文件系统视图和资源限制。

  4. append_guest_hooks: 类似于在搬家前设置好智能家庭系统的自动化脚本,这个步骤是在容器启动前后添加一些自定义的脚本或钩子,用于执行特定的初始化或清理任务。

  5. setup_bundle: 创建容器的“生活空间”(bundle目录),包括配置文件(config.json),就像为新房准备好入住指南一样,里面包含了容器启动所需的所有配置信息。

实际“启动”容器


let mut ctr: LinuxContainer =
    LinuxContainer::new(cid.as_str(), CONTAINER_BASE, opts, &sl!())?;

let p = if oci.process.is_some() {
    Process::new(
        &sl!(),
        &oci.process.as_ref().unwrap(),
        cid.as_str(),
        true,
        pipe_size,
    )?
} else {
    return Err(anyhow!(nix::Error::from_errno(nix::errno::Errno::EINVAL)));
};

ctr.start(p).await?;
  • 创建 LinuxContainer 对象: ctr就像是新公寓的物业管理办公室,它管理着这个容器的一切。通过LinuxContainer::new()初始化,设定容器ID、基础路径、启动选项等基本信息。

  • 创建 Process 对象: p代表容器内将要运行的“住户”(进程),通过Process::new()创建,它包含了进程的入口点、环境变量、工作目录等细节。

  • ctr.start(p): 虽然名字叫“启动”,但这个阶段更多是做最后的准备工作和初始化。

ctr.start(p)

let exec_path = std::env::current_exe()?;
let mut child = std::process::Command::new(exec_path);
let mut child = child
    .arg("init")
    .stdin(child_stdin)
    .stdout(child_stdout)
    .stderr(child_stderr)
    .env(INIT, format!("{}", p.init))
    .env(NO_PIVOT, format!("{}", self.config.no_pivot_root))
    .env(CRFD_FD, format!("{}", crfd))
    .env(CWFD_FD, format!("{}", cwfd))
    .env(CLOG_FD, format!("{}", cfd_log));

if p.init {
    child = child.env(FIFO_FD, format!("{}", fifofd));
}

child.spawn()?;

初始化阶段

  1. Kata Agent的自启动

    当Kata Containers准备启动一个新的容器时,首先做的事情是让Kata Agent启动一个新的进程,以init模式运行。

    • std::env::current_exe()?获取当前正在执行的Kata Agent的路径,类似于找到自己的“身份证明”。
    • std::process::Command::new(exec_path)创建一个命令对象,准备启动一个与当前进程相同的副本。
    • 通过.arg("init")指明启动模式,这告诉新的Agent进程它需要初始化容器环境,而非处理普通的RPC请求。
    • 设置环境变量和标准I/O重定向,确保新进程有正确的环境配置和与外界的沟通渠道。

进程间通信准备


let pid_buf = read_async(&mut pipe_r).await?;
let pid_str = std::str::from_utf8(&pid_buf).context("get pid string")?;
let pid = match pid_str.parse::<i32>() {
    Ok(i) => i,
    Err(e) => {
    }
};


let (prfd, cwfd) = unistd::pipe().context("failed to create pipe")?;
let (crfd, pwfd) = unistd::pipe().context("failed to create pipe")?;

let mut pipe_r = PipeStream::from_fd(prfd);
let mut pipe_w = PipeStream::from_fd(pwfd);
  1. 管道的建立

    • 使用unistd::pipe()创建两个匿名管道,这相当于搭建了两座桥,一座用于父进程(当前Kata Agent)向子进程发送信息(crfdpwfd),另一座用于子进程向父进程回传消息(prfdcwfd)。
    • PipeStream::from_fd将原始的文件描述符转换为更方便操作的流对象,便于读写操作。

启动容器进程

  1. 启动Kata Agent子进程并传递PID

    • child.spawn()?启动新Agent进程,这时,新进程会执行do_init_child函数,开始容器的初始化工作。
    • 一旦容器的初始进程(PID为1的进程)在子Agent中成功启动(要执行do_init_child),它会通过之前建立的管道之一(cwfd)发送其PID给父进程。
    • 父进程通过read_async等待并接收这个PID,这样就得知了容器进程的确切标识。

命名空间与进程管理

  1. 加入命名空间

    • join_namespaces是Kata Agent内部的一个关键步骤,它负责在新创建的容器进程上设置命名空间,包括PID命名空间、网络命名空间等,确保容器进程在一个完全隔离的环境中运行,就像是在一个独立的房间内开展活动,不受外界干扰。(ctr.start() 最后执行的一个比较重要的函数就是 join_namespaces)
    • 这个过程可能涉及clone系统调用,它允许新进程继承父进程的某些属性,同时在某些方面(如命名空间)与父进程分离,创建一个全新的执行环境。

容器进程的执行

  1. 在新Agent进程中执行do_init_child

    • 到达这一步,子Agent(作为容器的守护进程)开始执行do_init_child函数,完成剩余的初始化操作,如挂载文件系统、配置网络、执行用户指定的应用程序等。
    • 通过之前传递的环境变量和文件描述符,比如FIFO_FD指向的exec.fifo文件,可以控制容器进程的启动时机和方式,确保一切按计划进行。

do_init_child

// kata-containers/src/agent/rustjail/src/container.rs

fn do_init_child(cwfd: RawFd) -> Result<()> {
    match fork() {
        Ok(ForkResult::Parent { child, .. }) => {
            let _ = write_sync(cwfd, SYNC_DATA, format!("{}", pid_t::from(child)).as_str());
            // parent return
            return Ok(());
        }
        Ok(ForkResult::Child) => (),
        Err(e) => {
        }
    }

}

do_init_child函数的作用

  • Fork与角色分工do_init_child函数在Kata Agent被init参数启动后的子进程中执行,首先执行fork()系统调用,产生两个进程。父进程(这里的do_init_child所在的进程)主要负责将新创建的子进程(即真正的容器进程)的PID写入到cwfd,然后退出。子进程则继续执行,成为容器内部的第一个进程。

  • PID同步:父进程通过write_sync(cwfd, SYNC_DATA, format!("{}", pid_t::from(child)).as_str()),将刚创建的子进程PID写入到管道,这样父进程(实际上是主Kata Agent进程)就能通过读取管道另一端(crfd)获知容器进程的真实PID。

容器进程初始化


if init {
    if init {
        fifofd = std::env::var(FIFO_FD)?.parse::<i32>().unwrap();
    }

    ... ...

    let fd = fcntl::open(
        format!("/proc/self/fd/{}", fifofd).as_str(),
        OFlag::O_RDONLY | OFlag::O_CLOEXEC,
        Mode::from_bits_truncate(0),
    )?;
    unistd::close(fifofd)?;
    let mut buf: &mut [u8] = &mut [0];
    unistd::read(fd, &mut buf)?;
}

do_exec(&args);
  • 读取exec.fifo:当启动参数中带有init时,表示需要初始化容器的主进程。do_init_child函数会根据环境变量FIFO_FD找到exec.fifo文件的描述符,然后通过一系列文件操作(fcntl::openunistd::closeunistd::read)读取exec.fifo文件。这个过程实际上是在等待一个信号,因为exec.fifo是用于控制容器进程何时启动的同步机制。

  • do_exec与进程替换:在读取完exec.fifo后,调用do_exec(&args)。这个函数内部使用execvp系统调用来替换当前进程映像,加载容器应用程序(如args指定的命令)的可执行文件,至此,容器进程才真正开始执行用户指定的程序,之前的所有步骤都是为这一刻做准备。

容器启动与RPC请求的同步

  • 等待start_container RPC:尽管do_exec被调用,但由于之前的read操作在等待exec.fifo的信号,因此容器进程实际上被阻塞在这里,直到外部(如通过shim发送的)一个start_container的RPC请求到达。这个请求通常会触发写入exec.fifo文件,解除阻塞,使得do_exec得以继续,容器进程开始执行。

start_container/do_start_container(启动容器)

exec函数

这个函数的主要任务是通过向一个名为exec.fifo的FIFO(命名管道)文件写入数据来触发容器内部进程的启动。具体步骤如下:

  1. 构建FIFO文件路径:首先,它通过格式化字符串构造出FIFO文件的完整路径。这个FIFO文件通常在容器的根目录下,并且具有固定的名称(如EXEC_FIFO_FILENAME定义的)。

  2. 打开FIFO文件:使用fcntl::open函数以写入模式(O_WRONLY)打开上述路径的FIFO文件。这里也设置了模式为0,意味着不改变已有权限。

  3. 写入数据并通知:向刚打开的FIFO文件写入一个字节的数据(在这个例子中是[0])。这一步实际上是一个信号机制,用于通知另一个等待在这个FIFO另一端(读取端)的进程可以继续执行。这是因为在容器初始化时,通常会有一个进程阻塞在这里等待这个信号,以便知道何时开始执行容器内的实际应用程序。

  4. 记录启动时间和状态变更:记录容器进程启动的时间,并将容器状态更新为RUNNING

  5. 关闭文件描述符:最后,关闭之前打开的FIFO文件的文件描述符。

start_container 最终会执行到这个函数:

// kata-containers/src/agent/rustjail/src/container.rs
fn exec(&mut self) -> Result<()> {
    let fifo = format!("{}/{}", &self.root, EXEC_FIFO_FILENAME);
    let fd = fcntl::open(fifo.as_str(), OFlag::O_WRONLY, Mode::from_bits_truncate(0))?;
    let data: &[u8] = &[0];
    unistd::write(fd, &data)?;
    info!(self.logger, "container started");
    self.init_process_start_time = SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .unwrap()
        .as_secs();

    self.status.transition(ContainerState::RUNNING);
    unistd::close(fd)?;

    Ok(())
}

核心逻辑是往 exec.fifo 文件里写了点数据而已。这时,我们在上面看到的 do_init_child 函数中 block 在读取 exec.fifo 的地方就会继续执行,然后执行后面的 do_exec 方法

fn do_exec(args: &[String]) -> ! {
    let path = &args[0];
    let p = CString::new(path.to_string()).unwrap();
    let sa: Vec<CString> = args
        .iter()
        .map(|s| CString::new(s.to_string()).unwrap_or_default())
        .collect();
    let a: Vec<&CStr> = sa.iter().map(|s| s.as_c_str()).collect();

    let _ = unistd::execvp(p.as_c_str(), a.as_slice()).map_err(|e| match e {
        nix::Error::Sys(errno) => {
            std::process::exit(errno as i32);
        }
        _ => std::process::exit(-2),
    });

    unreachable!()
}

最后通过execvp 系统调用启动了容器中指定的进程。

do_exec函数

这个函数负责实际执行容器内的命令或程序。其工作流程如下:

  1. 参数准备:从传入的参数切片中取出第一个元素作为程序路径,并将其转换为CString类型,因为execvp系统调用需要C字符串格式的参数。同时,将所有参数转换为CString数组,准备传递给execvp

  2. 执行execvp调用

    • 使用execvp尝试替换当前进程映像为指定程序。它接收两个参数:一个是程序的路径(C字符串格式),另一个是参数列表(C字符串的指针数组)。
    • 如果execvp成功,当前函数将不会返回,因为进程已经被替换成新的程序。
    • 如果失败,根据错误类型决定退出码。如果是系统错误,则直接将错误号作为退出码;对于其他类型的错误,默认使用-2作为退出码。
  3. unreachable!():此语句表明如果执行流到达此处,理论上是不可能的,因为execvp成功后不会返回,失败则会直接退出进程。这是一个逻辑断言,帮助静态分析工具理解代码的预期执行流程。

agent启动容器部分

在这里插入图片描述

想象一下,你是一位导演,正在筹备一部电影的拍摄。你可以把这个过程类比于create_containerstart_container的过程。

create_container 阶段

  1. 导演(kata-agent service)规划场景:你作为导演(相当于kata-agent service),决定要开拍一部新电影(创建一个容器)。为了开始工作,你需要先安排一个助理(kata-agent init)来准备拍摄现场。

  2. 助理准备拍摄团队:助理启动了一个摄制团队(容器临时进程),这个团队包括了导演助理、摄影师、灯光师等(各种初始化工作)。助理(父进程)安排好一切后,会把实际负责拍摄工作的导演助理(子进程)的联系方式(pid)留给导演(kata-agent service),然后自己就可以离开了。

  3. 导演助理等待指令:导演助理(子进程)现在在拍摄现场,一切就绪,但还在等待导演的具体拍摄指令,所以他坐在导演椅上,盯着一个特定的信号灯(阻塞在读取exec.fifo)。

start_container 阶段

  1. 导演发出开拍信号:导演(kata-agent service)觉得一切准备就绪,于是按下按钮(向exec.fifo写入数据),点亮了那个信号灯。

  2. 正式拍摄开始:导演助理(子进程)一看到信号灯亮起,立即起身开始执行拍摄计划(通过execvp启动容器的真实进程,比如开始录制第一幕)。这标志着电影正式开拍,也就是容器内的应用开始运行。

  3. 幕后沟通:在整个拍摄过程中,导演(父进程)和导演助理(子进程)之间还需要不断沟通(通过管道文件句柄同步数据),比如导演需要告诉助理调整镜头角度或演员走位等,保证拍摄顺利进行。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐