Containerd在某些场景下已经替代docker成为了kubernetes的首选容器运行时。比如边缘端场景k8s的衍生项目k3s则使用containerd代替docker作为默认运行时;比如安全容器场景kata、gVisor项目都直接使用containerd来与k8s对接(并非通过docker)。k8s将对容器的操作抽象成了CRI接口来支持多种容器运行时,针对containerd,k8s社区为其开发了cri-plugin内置在containerd中来支持k8s。本文将针对CRI接口通过containerd创建k8s Pod的详细过程进行介绍。
    概述
    2.1相关概念

    2.1.1 POD
    Pod是kubernetes中运行、调度容器化应用的最小逻辑单元,通常一个Pod由多个容器组成,这些容器被追加在Pod内,共享Pod内的资源,例如:网络、环境变量、volume等等。Pod中会默认运行一个pause容器来“hold”住网络命名空间及一些资源。
    2.1.2 Containerd
    Containerd是docker贡献给社区的行业标准级的容器运行时项目,containerd可以管理容器的整个生命周期:镜像拉取和存储、容器执行和管理、低级别存储和网络的附加。拆分后的docker,对容器的操作便都是通过containerd来完成的。
    2.1.3 CRI
    Kubernetes为了支持多种容器运行时,将关于容器的操作进行了抽象,定义了CRI接口,来供容器运行时接入。目前kubernetes支持的容器运行时有:docker、containerd、cri-o。
    CRI的接口主要分为两类:

    • 镜像相关的操作,包括:镜像拉取,删除,列表等
    • 容器相关的操作:包括:Pod沙盒的创建、停止,Pod内容器的创建、启动、停止、删除等

    深入介绍CRI通过Containerd创建Pod的过程 - 图1
    CRI接口是基于gRPC协议设计的,我们可以将其分为server和client端,server端主要是实现CRI定义的接口,client就负责调用;client端通过gRPC协议向server端发送gRPC请求,server端收到请求后便会做相应处理。这就是CRI接口的工作方式。

    CRI gRPC client端主要就是:

    • kubelet:kubelet在是k8s在各个节点上创建容器的组件,kubelet中会调用CRI接口
    • crictl:crictl是用于调试CRI接口的命令行工具

    CRI gRPC server端主要就是:

    • cri-containerd、cri-o等kubernetes的容器运行时的实现者。

    2.2 CRI创建Pod过程概述

    下图描绘了cri client调用CRI接口containerd创建出容器的一个大致过程。
    深入介绍CRI通过Containerd创建Pod的过程 - 图2
    kubelet调用CRI接口创建Pod的过程主要分为3步:

    1. 创建PodSandbox:对应的CRI接口是RunPodSandbox。PodSandbox就是k8s Pod这个沙盒环境本身,Pod中会默认运行一个的pause容器来“hold”住网络命名空间以便对Pod网络进行设置。使用不同的容器运行时,Pod沙盒的实现方式也不一样,比如使用kata作为runtime,Pod沙盒被实现为一个虚拟机;而使用runc作为runtime,Pod沙盒就是一个独立的namespace和cgroups而已。
    2. 创建PodContainer:对应的CRI接口是CreatePodContainer。PodContainer就是用户所要运行的容器,比如nginx容器。创建好的PodContainer会被加入到PodSanbox中,共享网络命名空间。
    3. 启动PodContainer:对应的CRI接口是StartPodContainer。启动上一步中创建的PodContainer。

    调用过程详细介绍
    下面我们用crictl来模拟kubelet创建Pod的过程。
    3.1 使用命令创建Pod沙盒

    首先我们创建一个Pod沙盒,我们使用crictl runp命令来完成,此命令仅会调用RunPodSandbox接口。
    准备pod的配置文件,配置文件中的metadata主要配置pod的元数据信息,包括:pod的name、命名空间,唯一id、启动的尝试次数,logDirectory配置的是pod产生的日志存放目录












      1. [root@oe workspace]# cat > pod-config.json <<EOF> {> "metadata": {> "name": "nginx-sandbox",> "namespace": "default",> "attempt": 1,> "uid": "hdishd83djaidwnduwk28bcsb"> },> "logDirectory": "/tmp"> }> EOF

    创建Pod,这里使用runc作为runtime



      1. [root@oe workspace]# crictl runp --runtime=runc ./pod-config.jsond8255707d90f3f6edba7fc9f5c0b1c7710193a35345f61e037608cb3f6794f9a

    查看创建出的Pod




      1. [root@oe workspace]# crictl podsPOD ID CREATED STATE NAME NAMESPACE ATTEMPTef66378856a96 12 seconds ago Ready pod-sandbox default 1

    用ctr(类似docker,是containerd的命令行工具)查看创建完Pod后当前跑了什么容器。




      1. [root@oe workspace]# ctr --namespace k8s.io container lsCONTAINER IMAGE RUNTIME26a80618390a152fad85ea16497e758937e69b19aac970e9dac0b2f8728a3583 registry.cn-hangzhou.aliyuncs.com/google_containers/pause@sha256:4a1c4b21597c1b4415bdbecb28a3296c6b5e23ca4f9feeb599860a1dac6a0108 io.containerd.runc.v2

    可以看到目前Pod已经创建出来后,内部仅跑了一个pause容器。

    上述操作中具体执行的过程是怎样的呢?

    首先crictl工具会通过unix:///run/containerd.sock这个unix套接字来与containerd进程建立连接,发送gRPC请求RunPodSandboxRequest;位于containerd进程中的cri plugin作为cri的gRPC server端,收到请求后调用RunPodSandbox接口的实现函数处理。下面我们来开始逐步分析。
    3.2 RunPodSandbox在
    containerd中的处理过程解析

    3.2.1 containerd架构概述
    在开始解析CRI处理流程前,我们先简单了解一下containerd的架构。Containerd 是一个工业级标准的容器运行时,它强调简单性、健壮性和可移植性。采用松耦合、模块化的架构。
    深入介绍CRI通过Containerd创建Pod的过程 - 图3
    其将内部的各个功能抽象成了服务,以模块化服务的方式对外提供接口。因此其内部的功能模块都是一个个“微服务”。

    • containers service主要负责管理容器元数据,对外提供:创建、删除、列表查询等功能
    • images service主要对外提供:镜像的查询、创建、拉取、删除等功能
    • tasks service主要对外提供:任务的创建、启动等功能

    3.2.2 调用流程时序图
    RunPodSandbox整个执行过程可用如下时序图描述:
    深入介绍CRI通过Containerd创建Pod的过程 - 图4
    3.2.3 RunPodSanbox处理过程解析
    在RunPodSanbox中,cri-containerd plugin的主要处理逻辑如下:
    1.创建sandbox对象:创建的是元数据对象,仅是一条记录而已,不会创建任何资源。元数据对象是用来将一些资源关联在一起的。
    2.确保镜像存在:确保pause容器镜像存在,不在则拉取镜像。
    3.获取oci runtime:根据containerd的配置文件中关于runtime的配置,来决定容器使用哪个runtime启动
    4.创建并配置网络命名空间:创建Pod的网络命名空间,调用CNI插件设置Pod网络。
    5.创建容器对象:创建的是元数据对象,仅是一条记录而已。不会创建任何资源。元数据对象是用来将一些资源关联在一起的。
    6.创建任务: 这里会向containerd中的task service发送创建任务请求,task service中会启动containerd-shim(v2)进程调用runc来创建容器。这一步的执行效果就是pause容器被创建出来
    7.启动任务:这里会向containerd中的task service发送启动任务请求,task service中会将启动任务请求转发给containerd-shim(v2)进程,containerd-shim(v2)进程又会调用runc来讲上面创建的容器启动起来。这一步的执行最终效果就是pause容器被运行起来。

    下面是仅保留了主要过程并去掉了函数的参数的伪代码:



























      1. // RunPodSandbox creates and starts a pod-level sandbox. Runtimes should ensure the sandbox is in ready state.func (c *criService) RunPodSandbox(ctx context.Context, r *runtime.RunPodSandboxRequest) (_ *runtime.RunPodSandboxResponse, retErr error) { // 实例化一个sanbox对象 sandbox := sandboxstore.NewSandbox() // 确保pause镜像存在,不存在则会拉取 image, err := c.ensureImageExists() // 获取sandbox的oci runtime ociRuntime, err := c.getSandboxRuntime() // 创建Pod沙盒的网络命名空间 sandbox.NetNS, err = netns.NewNetNS() // 这里会调用CNI插件(例如:/opt/cni/bin/bridge)来给Pod沙盒的网络命名空间配置网络 c.setupPodNetwork(); // 构造runtime spec spec, err := c.sandboxContainerSpec() // 使用上面构造的spec,创建容器对象 container, err := c.client.NewContainer() // 创建sandbox的root工作目录,将配置文件保存到sandbox根目录下,主要是hostname resolv.conf hosts文件 sandboxRootDir := c.getSandboxRootDir() c.os.MkdirAll(sandboxRootDir, 0755); c.setupSandboxFiles(); // 创建任务,这里会向containerd发送CreateTaskRequest来创建任务 task, err := container.NewTask() // 这里会向containerd发送StartTaskRequest来启动任务,效果就是运行pause容器 task.Start(); ...}

    创建任务container.NewTask()、启动任务task.Start() 才是真正创建和启动容器的。其背后做了许多事情,下面我们来继续分析。
    3.2.4 创建任务过程解析
    container.NewTask()这一步才会真正创建出容器。就如上面说,此函数会向containerd中的task service发送创建任务请求,task service中会启动containerd-shim(v2)进程调用runc来创建容器。具体过程是怎样的呢?

    NewTask函数中主要处理就是给task service发送CreateTaskRequest请求。伪代码参考如下:





























      1. func (c *container) NewTask(ctx context.Context, ioCreate cio.Creator, opts ...NewTaskOpts) (_ Task, err error) {// 实例化tasks.CreateTaskRequestrequest := &tasks.CreateTaskRequest{ ContainerID: c.id, Terminal: cfg.Terminal, Stdin: cfg.Stdin, Stdout: cfg.Stdout, Stderr: cfg.Stderr, }// 获取容器的挂载点信息,填充到request中s, err := c.client.getSnapshotter(ctx, r.Snapshotter)mounts, err := s.Mounts(ctx, r.SnapshotKey)for _, m := range mounts { request.Rootfs = append(request.Rootfs, &types.Mount{ Type: m.Type, Source: m.Source, Options: m.Options, }) }for _, m := range info.RootFS { request.Rootfs = append(request.Rootfs, &types.Mount{ Type: m.Type, Source: m.Source, Options: m.Options, }) }// 发送tasks.CreateTaskRequest请求c.client.TaskService().Create(ctx, request)

    在task service收到CreateTaskRequest请求后,主要干了两件事:
    1.启动container shim进程。task service中会执行命令containerd-shim-runc-v2 -namespace k8s.io -id ${id} -bundle ${bundle} -address ${address} start来启动一个containerd shim (v2)进程,shim进程也是以服务化的方式,给containerd调用的。
    2.将CreateTaskRequest请求转给shim进程处理。shim内部的处理这里不再展开,最终shim进程会调用 runc —root ${root} —bundle ${bundle} —log ${log} —pid-file ${pidfile} create ,来创建一个runc 容器
    处理函数的伪代码如下:












      1. // Create a new taskfunc (m *TaskManager) Create(ctx context.Context, id string, opts runtime.CreateOpts) (_ runtime.Task, err error) { ns, err := namespaces.NamespaceRequired(ctx) bundle, err := NewBundle(ctx, m.root, m.state, id, opts.Spec.Value) // 启动containerd-shim进程,由于我们使用的是container-shim-runc-v2,所以这里会调用container-shim-runc-v2命令启动shim b := shimBinary(ctx, bundle, opts.Runtime, m.containerdAddress, m.containerdTTRPCAddress, m.events, m.tasks) shim, err := b.Start() // shim进程起来之后,再将CreateTaskRequest发给shim去处理。shim会调用runc create来创建一个runc容器t, err := shim.Create(ctx, opts)

    containerd shim(v2) 进程
    containerd shim v2是containerd shim的v2版本。shim进程是用来“垫”在containerd和runc启动的容器之间的,其主要作用是:
    1.调用runc命令创建、启动、停止、删除容器等
    2.作为容器的父进程,当容器中的第一个实例进程被杀死后,负责给其子进程收尸,避免出现僵尸进程
    3.监控容器中运行的进程状态,当容器执行完成后,通过exit fifo文件来返回容器进程结束状态
    3.2.5 启动任务过程解析
    task.Start()就如同上面所说,会向containerd中的task service发送启动任务请求,task service中会将启动任务请求转发给containerd-shim(v2)进程,containerd-shim(v2)进程又会调用runc来将上面创建的容器启动起来。具体过程是怎样的呢?

    Start函数的实现伪代码如下:








      1. func (t *task) Start(ctx context.Context) error { //向containerd内部的task service发送StartTaskRequest请求 t.client.TaskService().Start(ctx, &tasks.StartRequest{ ContainerID: t.id, }) ...}


      其主要逻辑就是向task service发送一个StartTaskRequest。task service收到StartTaskRequest请求后的处理函数伪代码如下:



















      1. func (l *local) Start(ctx context.Context, r *api.StartRequest, _ ...grpc.CallOption) (*api.StartResponse, error) { // 获取到先前创建的task t, err := l.getTask(ctx, r.ContainerID) p := runtime.Process(t) p, err = t.Process(ctx, r.ExecID); // 这里调用shim接口向shim进程发送时start请求 err := p.Start(ctx); ...} // p.Start()func (s *shim) Start(ctx context.Context) error { //向shim发送请求 response, err := s.task.Start(ctx, &task.StartRequest{ ID: s.ID(), }) ...}

    上面这个函数主要就是做了一件事情:将CreateTaskRequest请求又转给containerd-shim(v2)进程处理。containerd-shim(v2)进程又会调用 runc —root ${root} —bundle ${bundle} —log ${log} —pid-file ${pidfile} start ${id}将先前创建的容器启动起来。
    到此RunPodSandbox处理的解析结束。
    4、总结
    本文深入且详细地介绍了CRI接口RunPodSanbox在containerd中的处理过程以及一些背景概念。深入了解CRI调用containerd创建Pod的过程,我们可以将一些常见容器领域概念CRI、containerd、CNI、pod、shim进程,runc等诸多概念串联起来,帮助我们深入的理解k8s创建容器背后的过程及原理。