1 深度剖析-CRI篇

目前我司现网的K8s集群的运行时已经完成从docker到Containerd的切换,有小伙伴对K8s与Containerd调用链涉及的组件不了解,其中Containerd和RunC是什么关系,docker和containerd又有什么区别,以及K8s调用Containerd创建容器又是怎样的流程,最终RunC又是如何创建容器的,诸如此类的疑问。本文就针对K8s使用Containerd作为运行时的整个调用链进行介绍和源码级别的分析。 其中关于kubelet与运行时的分层架构图可以参看下图 🐧[Containerd] 深度剖析-CRI篇 - 图1 那么关于各类运行时的介绍可以参看Containerd深度剖析-runtime篇 ## 1.1 “运行时简介” 容器运行时意思就是能够管理容器运行的整个生命周期,具体一点就是如何制作容器的镜像、容器镜像格式是什么样子的、管理容器的镜像、容器镜像的分发、如何运行一个容器以及管理创建的容器实例等等。 容器运行时有一个行业标准叫做OCI规范,这个规范分成两部分: a. 容器运行时规范:描述了如何通过一个bundle运行容器,bundle就是一个目录,里面包括一个容器的规格文件,文件叫 config.json 和一个rootfsrootfs中包含了一个容器运行时所需操作系统的文件。

b. 容器镜像规范:定义了容器的镜像如何打包如何将镜像转换成一个bundle。

目前流行将运行时分成low-level运行时和high-level运行时,low-level运行时专注于如何创建一个容器例如runc和katahigh-level包含了更多上层功能,比如镜像管理,以dockercontainerd为代表 K8s的kubelet是调用容器运行时创建容器的,但是容器运行时这么多不可能逐个兼容,K8s在对接容器运行时定义了CRI接口,容器运行时只需实现该接口就能被使用。下图分别是k8s使用docker和containerd的调用链,使用containerd时CRI接口是在containerd代码中实现的;使用docker时的CRI接口是在k8s的代码中实现的,叫做docker-shim(kubernetes/pkg/kubelet/dockershim/docker_service.go),这部分代码在k8s代码中是历史原因,当时docker是容器方面行业事实上的标准,但随着越来越多运行时实现了CRI支持,docker-shim的维护日益变成社区负担,在最新的K8s版本中,该部分代码目前已经移出,暂时由mirantis进行维护,下图是插件的发展历程。

🐧[Containerd] 深度剖析-CRI篇 - 图2

1.2 “Containerd CRI简介”

Containerd是一个行业标准的容器运行时,它是一个daemon进程,可以管理主机上容器的全部生命周期和它的文件系统,包括:镜像的分发和存储、容器的运行和监控,底层的存储和网络。

🐧[Containerd] 深度剖析-CRI篇 - 图3

Containerd有多种客户端,比如K8s、docker等,为了不同客户端的容器或者镜像能隔离开,Containerd中有namespace概念,默认情况下docker的namespace是moby,K8s的是k8s.io。 container在Containerd中代表的是一个容器的元数据,containerd中的Task用于获取容器对象并将它转换成在操作系统中可运行的进程,它代表的就是容器中可运行的对象。 Containerd内部的cri模块实现K8s的CRI接口,所以K8s的kubelet可以直接使用containerd。CRI的接口包括:RuntimeServiceImageService go // Runtime service defines the public APIs for remote container runtimes service RuntimeService { // Version returns the runtime name, runtime version, and runtime API version. rpc Version(VersionRequest) returns (VersionResponse) {} // RunPodSandbox creates and starts a pod-level sandbox. Runtimes must ensure // the sandbox is in the ready state on success. rpc RunPodSandbox(RunPodSandboxRequest) returns (RunPodSandboxResponse) {} // StopPodSandbox stops any running process that is part of the sandbox and // reclaims network resources (e.g., IP addresses) allocated to the sandbox. // If there are any running containers in the sandbox, they must be forcibly // terminated. // This call is idempotent, and must not return an error if all relevant // resources have already been reclaimed. kubelet will call StopPodSandbox // at least once before calling RemovePodSandbox. It will also attempt to // reclaim resources eagerly, as soon as a sandbox is not needed. Hence, // multiple StopPodSandbox calls are expected. rpc StopPodSandbox(StopPodSandboxRequest) returns (StopPodSandboxResponse) {} // RemovePodSandbox removes the sandbox. If there are any running containers // in the sandbox, they must be forcibly terminated and removed. // This call is idempotent, and must not return an error if the sandbox has // already been removed. rpc RemovePodSandbox(RemovePodSandboxRequest) returns (RemovePodSandboxResponse) {} // PodSandboxStatus returns the status of the PodSandbox. If the PodSandbox is not // present, returns an error. rpc PodSandboxStatus(PodSandboxStatusRequest) returns (PodSandboxStatusResponse) {} // ListPodSandbox returns a list of PodSandboxes. rpc ListPodSandbox(ListPodSandboxRequest) returns (ListPodSandboxResponse) {} // CreateContainer creates a new container in specified PodSandbox rpc CreateContainer(CreateContainerRequest) returns (CreateContainerResponse) {} // StartContainer starts the container. rpc StartContainer(StartContainerRequest) returns (StartContainerResponse) {} // StopContainer stops a running container with a grace period (i.e., timeout). // This call is idempotent, and must not return an error if the container has // already been stopped. // The runtime must forcibly kill the container after the grace period is // reached. rpc StopContainer(StopContainerRequest) returns (StopContainerResponse) {} // RemoveContainer removes the container. If the container is running, the // container must be forcibly removed. // This call is idempotent, and must not return an error if the container has // already been removed. rpc RemoveContainer(RemoveContainerRequest) returns (RemoveContainerResponse) {} // ListContainers lists all containers by filters. rpc ListContainers(ListContainersRequest) returns (ListContainersResponse) {} // ContainerStatus returns status of the container. If the container is not // present, returns an error. rpc ContainerStatus(ContainerStatusRequest) returns (ContainerStatusResponse) {} // UpdateContainerResources updates ContainerConfig of the container synchronously. // If runtime fails to transactionally update the requested resources, an error is returned. rpc UpdateContainerResources(UpdateContainerResourcesRequest) returns (UpdateContainerResourcesResponse) {} // ReopenContainerLog asks runtime to reopen the stdout/stderr log file // for the container. This is often called after the log file has been // rotated. If the container is not running, container runtime can choose // to either create a new log file and return nil, or return an error. // Once it returns error, new container log file MUST NOT be created. rpc ReopenContainerLog(ReopenContainerLogRequest) returns (ReopenContainerLogResponse) {} // ExecSync runs a command in a container synchronously. rpc ExecSync(ExecSyncRequest) returns (ExecSyncResponse) {} // Exec prepares a streaming endpoint to execute a command in the container. rpc Exec(ExecRequest) returns (ExecResponse) {} // Attach prepares a streaming endpoint to attach to a running container. rpc Attach(AttachRequest) returns (AttachResponse) {} // PortForward prepares a streaming endpoint to forward ports from a PodSandbox. rpc PortForward(PortForwardRequest) returns (PortForwardResponse) {} // ContainerStats returns stats of the container. If the container does not // exist, the call returns an error. rpc ContainerStats(ContainerStatsRequest) returns (ContainerStatsResponse) {} // ListContainerStats returns stats of all running containers. rpc ListContainerStats(ListContainerStatsRequest) returns (ListContainerStatsResponse) {} // PodSandboxStats returns stats of the pod sandbox. If the pod sandbox does not // exist, the call returns an error. rpc PodSandboxStats(PodSandboxStatsRequest) returns (PodSandboxStatsResponse) {} // ListPodSandboxStats returns stats of the pod sandboxes matching a filter. rpc ListPodSandboxStats(ListPodSandboxStatsRequest) returns (ListPodSandboxStatsResponse) {} // UpdateRuntimeConfig updates the runtime configuration based on the given request. rpc UpdateRuntimeConfig(UpdateRuntimeConfigRequest) returns (UpdateRuntimeConfigResponse) {} // Status returns the status of the runtime. rpc Status(StatusRequest) returns (StatusResponse) {} } // ImageService defines the public APIs for managing images. service ImageService { // ListImages lists existing images. rpc ListImages(ListImagesRequest) returns (ListImagesResponse) {} // ImageStatus returns the status of the image. If the image is not // present, returns a response with ImageStatusResponse.Image set to // nil. rpc ImageStatus(ImageStatusRequest) returns (ImageStatusResponse) {} // PullImage pulls an image with authentication config. rpc PullImage(PullImageRequest) returns (PullImageResponse) {} // RemoveImage removes the image. // This call is idempotent, and must not return an error if the image has // already been removed. rpc RemoveImage(RemoveImageRequest) returns (RemoveImageResponse) {} // ImageFSInfo returns information of the filesystem that is used to store images. rpc ImageFsInfo(ImageFsInfoRequest) returns (ImageFsInfoResponse) {} } kubelet调用CRI接口创建一个包含A和B两个业务container的Pod流程如下所示: ① 为Pod创建sandbox ② 创建container A ③ 启动container A ④ 创建container B ⑤ 启动container B

🐧[Containerd] 深度剖析-CRI篇 - 图4

1.3 “Containerd CRI实现”

RunPodSandbox

RunPodSandbox的流程如下: ① 拉取sandbox的镜像,在containerd中配置 ② 获取创建pod要使用的runtime,可以在创建pod的yaml中指定,如果没指定使用containerd中默认的(runtime在containerd中配置) ③ 如果pod不是hostNetwork那么添加创建新net namespace,并使用cni插件设置网络(criService在初始化时会加载containerd中cri指定的插件信息) ④ 调用containerd客户端创建一个container ⑤ 在rootDir/io.containerd.grpc.v1.cri/sandboxes下为当前pod以pod Id为名创建一个目录(pkg/cri/cri.go) ⑥ 根据选择的runtime为sandbox容器创建task ⑦ 启动sandbox容器的task,将sandbox添加到数据库中 代码在containerd/pkg/cri/server/sanbox_run.go 中
  1. // RunPodSandbox creates and starts a pod-level sandbox. Runtimes should ensure
  2. // the sandbox is in ready state.
  3. func (c *criService) RunPodSandbox(ctx context.Context, r *runtime.RunPodSandboxRequest) (_ *runtime.RunPodSandboxResponse, retErr error) {
  4. config := r.GetConfig()
  5. log.G(ctx).Debugf("Sandbox config %+v", config)
  6. // Generate unique id and name for the sandbox and reserve the name.
  7. id := util.GenerateID()
  8. metadata := config.GetMetadata()
  9. if metadata == nil {
  10. return nil, errors.New("sandbox config must include metadata")
  11. }
  12. name := makeSandboxName(metadata)
  13. log.G(ctx).WithField("podsandboxid", id).Debugf("generated id for sandbox name %q", name)
  14. // cleanupErr records the last error returned by the critical cleanup operations in deferred functions,
  15. // like CNI teardown and stopping the running sandbox task.
  16. // If cleanup is not completed for some reason, the CRI-plugin will leave the sandbox
  17. // in a not-ready state, which can later be cleaned up by the next execution of the kubelet's syncPod workflow.
  18. var cleanupErr error
  19. // Reserve the sandbox name to avoid concurrent `RunPodSandbox` request starting the
  20. // same sandbox.
  21. if err := c.sandboxNameIndex.Reserve(name, id); err != nil {
  22. return nil, fmt.Errorf("failed to reserve sandbox name %q: %w", name, err)
  23. }
  24. defer func() {
  25. // Release the name if the function returns with an error.
  26. // When cleanupErr != nil, the name will be cleaned in sandbox_remove.
  27. if retErr != nil && cleanupErr == nil {
  28. c.sandboxNameIndex.ReleaseByName(name)
  29. }
  30. }()
  31. var (
  32. err error
  33. sandboxInfo = sb.Sandbox{ID: id}
  34. )
  35. ociRuntime, err := c.getSandboxRuntime(config, r.GetRuntimeHandler())
  36. if err != nil {
  37. return nil, fmt.Errorf("unable to get OCI runtime for sandbox %q: %w", id, err)
  38. }
  39. sandboxInfo.Runtime.Name = ociRuntime.Type
  40. // Retrieve runtime options
  41. runtimeOpts, err := generateRuntimeOptions(ociRuntime, c.config)
  42. if err != nil {
  43. return nil, fmt.Errorf("failed to generate sandbox runtime options: %w", err)
  44. }
  45. ...
  46. // Create initial internal sandbox object.
  47. sandbox := sandboxstore.NewSandbox(
  48. ...
  49. )
  50. if _, err := c.client.SandboxStore().Create(ctx, sandboxInfo); err != nil {
  51. return nil, fmt.Errorf("failed to save sandbox metadata: %w", err)
  52. }
  53. ...
  54. // Setup the network namespace if host networking wasn't requested.
  55. if !hostNetwork(config) {
  56. netStart := time.Now()
  57. // If it is not in host network namespace then create a namespace and set the sandbox
  58. // handle. NetNSPath in sandbox metadata and NetNS is non empty only for non host network
  59. // namespaces. If the pod is in host network namespace then both are empty and should not
  60. // be used.
  61. var netnsMountDir = "/var/run/netns"
  62. if c.config.NetNSMountsUnderStateDir {
  63. netnsMountDir = filepath.Join(c.config.StateDir, "netns")
  64. }
  65. sandbox.NetNS, err = netns.NewNetNS(netnsMountDir)
  66. if err != nil {
  67. return nil, fmt.Errorf("failed to create network namespace for sandbox %q: %w", id, err)
  68. }
  69. // Update network namespace in the store, which is used to generate the container's spec
  70. sandbox.NetNSPath = sandbox.NetNS.GetPath()
  71. defer func() {
  72. // Remove the network namespace only if all the resource cleanup is done
  73. if retErr != nil && cleanupErr == nil {
  74. if cleanupErr = sandbox.NetNS.Remove(); cleanupErr != nil {
  75. log.G(ctx).WithError(cleanupErr).Errorf("Failed to remove network namespace %s for sandbox %q", sandbox.NetNSPath, id)
  76. return
  77. }
  78. sandbox.NetNSPath = ""
  79. }
  80. }()
  81. if err := sandboxInfo.AddExtension(podsandbox.MetadataKey, &sandbox.Metadata); err != nil {
  82. return nil, fmt.Errorf("unable to save sandbox %q to store: %w", id, err)
  83. }
  84. // Save sandbox metadata to store
  85. if sandboxInfo, err = c.client.SandboxStore().Update(ctx, sandboxInfo, "extensions"); err != nil {
  86. return nil, fmt.Errorf("unable to update extensions for sandbox %q: %w", id, err)
  87. }
  88. // Define this defer to teardownPodNetwork prior to the setupPodNetwork function call.
  89. // This is because in setupPodNetwork the resource is allocated even if it returns error, unlike other resource
  90. // creation functions.
  91. defer func() {
  92. // Remove the network namespace only if all the resource cleanup is done.
  93. if retErr != nil && cleanupErr == nil {
  94. deferCtx, deferCancel := ctrdutil.DeferContext()
  95. defer deferCancel()
  96. // Teardown network if an error is returned.
  97. if cleanupErr = c.teardownPodNetwork(deferCtx, sandbox); cleanupErr != nil {
  98. log.G(ctx).WithError(cleanupErr).Errorf("Failed to destroy network for sandbox %q", id)
  99. }
  100. }
  101. }()
  102. // Setup network for sandbox.
  103. // Certain VM based solutions like clear containers (Issue containerd/cri-containerd#524)
  104. // rely on the assumption that CRI shim will not be querying the network namespace to check the
  105. // network states such as IP.
  106. // In future runtime implementation should avoid relying on CRI shim implementation details.
  107. // In this case however caching the IP will add a subtle performance enhancement by avoiding
  108. // calls to network namespace of the pod to query the IP of the veth interface on every
  109. // SandboxStatus request.
  110. if err := c.setupPodNetwork(ctx, &sandbox); err != nil {
  111. return nil, fmt.Errorf("failed to setup network for sandbox %q: %w", id, err)
  112. }
  113. sandboxCreateNetworkTimer.UpdateSince(netStart)
  114. }
  115. if err := sandboxInfo.AddExtension(podsandbox.MetadataKey, &sandbox.Metadata); err != nil {
  116. return nil, fmt.Errorf("unable to save sandbox %q to store: %w", id, err)
  117. }
  118. controller, err := c.getSandboxController(config, r.GetRuntimeHandler())
  119. if err != nil {
  120. return nil, fmt.Errorf("failed to get sandbox controller: %w", err)
  121. }
  122. // Save sandbox metadata to store
  123. if sandboxInfo, err = c.client.SandboxStore().Update(ctx, sandboxInfo, "extensions"); err != nil {
  124. return nil, fmt.Errorf("unable to update extensions for sandbox %q: %w", id, err)
  125. }
  126. runtimeStart := time.Now()
  127. if err := controller.Create(ctx, id); err != nil {
  128. return nil, fmt.Errorf("failed to create sandbox %q: %w", id, err)
  129. }
  130. resp, err := controller.Start(ctx, id)
  131. if err != nil {
  132. sandbox.Container, _ = c.client.LoadContainer(ctx, id)
  133. if resp != nil && resp.SandboxID == "" && resp.Pid == 0 && resp.CreatedAt == nil && len(resp.Labels) == 0 {
  134. // if resp is a non-nil zero-value, an error occurred during cleanup
  135. cleanupErr = fmt.Errorf("failed to cleanup sandbox")
  136. }
  137. return nil, fmt.Errorf("failed to start sandbox %q: %w", id, err)
  138. }
  139. // TODO: get rid of this. sandbox object should no longer have Container field.
  140. if ociRuntime.SandboxMode == string(criconfig.ModePodSandbox) {
  141. container, err := c.client.LoadContainer(ctx, id)
  142. if err != nil {
  143. return nil, fmt.Errorf("failed to load container %q for sandbox: %w", id, err)
  144. }
  145. sandbox.Container = container
  146. }
  147. labels := resp.GetLabels()
  148. if labels == nil {
  149. labels = map[string]string{}
  150. }
  151. sandbox.ProcessLabel = labels["selinux_label"]
  152. if err := sandbox.Status.Update(func(status sandboxstore.Status) (sandboxstore.Status, error) {
  153. // Set the pod sandbox as ready after successfully start sandbox container.
  154. status.Pid = resp.Pid
  155. status.State = sandboxstore.StateReady
  156. status.CreatedAt = protobuf.FromTimestamp(resp.CreatedAt)
  157. return status, nil
  158. }); err != nil {
  159. return nil, fmt.Errorf("failed to update sandbox status: %w", err)
  160. }
  161. // Add sandbox into sandbox store in INIT state.
  162. if err := c.sandboxStore.Add(sandbox); err != nil {
  163. return nil, fmt.Errorf("failed to add sandbox %+v into store: %w", sandbox, err)
  164. }
  165. // Send CONTAINER_CREATED event with both ContainerId and SandboxId equal to SandboxId.
  166. // Note that this has to be done after sandboxStore.Add() because we need to get
  167. // SandboxStatus from the store and include it in the event.
  168. c.generateAndSendContainerEvent(ctx, id, id, runtime.ContainerEventType_CONTAINER_CREATED_EVENT)
  169. // start the monitor after adding sandbox into the store, this ensures
  170. // that sandbox is in the store, when event monitor receives the TaskExit event.
  171. //
  172. // TaskOOM from containerd may come before sandbox is added to store,
  173. // but we don't care about sandbox TaskOOM right now, so it is fine.
  174. go func() {
  175. resp, err := controller.Wait(ctrdutil.NamespacedContext(), id)
  176. if err != nil {
  177. log.G(ctx).WithError(err).Error("failed to wait for sandbox controller, skipping exit event")
  178. return
  179. }
  180. e := &eventtypes.TaskExit{
  181. ContainerID: id,
  182. ID: id,
  183. // Pid is not used
  184. Pid: 0,
  185. ExitStatus: resp.ExitStatus,
  186. ExitedAt: resp.ExitedAt,
  187. }
  188. c.eventMonitor.backOff.enBackOff(id, e)
  189. }()
  190. // Send CONTAINER_STARTED event with ContainerId equal to SandboxId.
  191. c.generateAndSendContainerEvent(ctx, id, id, runtime.ContainerEventType_CONTAINER_STARTED_EVENT)
  192. sandboxRuntimeCreateTimer.WithValues(labels["oci_runtime_type"]).UpdateSince(runtimeStart)
  193. return &runtime.RunPodSandboxResponse{PodSandboxId: id}, nil
  194. }

CreateContainer

CreateContainer在指定的PodSandbox中创建一个新的container元数据,流程如下: ① 获取容器的sandbox信息 ② 为容器要用的镜像初始化镜像handler ③ 为容器在rootDir/io.containerd.grpc.v1.cri目录下以容器Id命名的目录 ④ 从sandbox中获取所使用的runtime ⑤ 为容器创建containerSpec ⑥ 使用containerd客户端创建container ⑦ 保存container的信息 代码见 containerd/pkg/cri/server/container_create.go 下面是省略过的代码。
  1. func (c *criService) CreateContainer(ctx context.Context, r
  2. *runtime.CreateContainerRequest) (_ *runtime.CreateContainerResponse, retErr error) {
  3. sandbox, err := c.sandboxStore.Get(r.GetPodSandboxId())
  4. s, err := sandbox.Container.Task(ctx, nil)
  5. sandboxPid := s.Pid()
  6. image, err := c.localResolve(config.GetImage().GetImage())
  7. if err != nil {
  8. return nil, errors.Wrapf(err, "failed to resolve image %q", config.GetImage().GetImage())
  9. }
  10. containerdImage, err := c.toContainerdImage(ctx, image)
  11. // Run container using the same runtime with sandbox.
  12. sandboxInfo, err := sandbox.Container.Info(ctx)
  13. if err != nil {
  14. return nil, errors.Wrapf(err, "failed to get sandbox %q info", sandboxID)
  15. }
  16. // Create container root directory. containerRootDir := c.getContainerRootDir(id)
  17. if err = c.os.MkdirAll(containerRootDir, 0755 err != nil {
  18. return nil, errors.Wrapf(err, "failed to create container root directory %q", containerRootDir)
  19. }
  20. ociRuntime, err := c.getSandboxRuntime(sandboxConfig, sandbox.Metadata.RuntimeHandler)
  21. if err != nil {
  22. return nil, errors.Wrap(err, "failed to get sandbox runtime")
  23. }
  24. spec, err := c.containerSpec(id, sandboxID, sandboxPid, sandbox.NetNSPath, containerName, containerdImage.Name(), config, sandboxConfig,
  25. &image.ImageSpec.Config, append(mounts, volumeMounts...), ociRuntime)
  26. if err != nil {
  27. return nil, errors.Wrapf(err, "failed to generate container %q spec", id)
  28. }
  29. opts = append(opts, containerd.WithSpec(spec, specOpts...),
  30. containerd.WithRuntime(sandboxInfo.Runtime.Name, runtimeOptions), containerd.WithContainerLabels(containerLabels), containerd.WithContainerExtension(containerMetadataExtension, &meta))
  31. var cntr containerd.Container
  32. if cntr, err = c.client.NewContainer(ctx, id, opts...); err != nil {
  33. return nil, errors.Wrap(err, "failed to create containerd container")
  34. }
  35. // Add container into container store.
  36. if err := c.containerStore.Add(container err != nil {
  37. return nil, errors.Wrapf(err, "failed to add container %q into store", id)
  38. }
  39. }

StartContainer

StartContainer用于启动一个容器,流程如下: ① 读取保存的container元数据 ② 读取关联的sandbox信息 ③ 为容器创建task ④ 启动task 代码见 containerd/pkg/cri/server/container_start.go,下面是该部分省略过后的代码:
  1. func (c *criService) StartContainer(ctx context.Context, r *runtime.StartContainerRequest) (retRes *runtime.StartContainerResponse, retErr error) {
  2. cntr, err := c.containerStore.Get(r.GetContainerId())
  3. // Get sandbox config from sandbox store. sandbox, err := c.sandboxStore.Get(meta.SandboxID) ctrInfo, err := container.Info(ctx)
  4. if err != nil {
  5. return nil, errors.Wrap(err, "failed to get container info")
  6. }
  7. taskOpts := c.taskOpts(ctrInfo.Runtime.Name)
  8. task, err := container.NewTask(ctx, ioCreation, taskOpts...)
  9. if err != nil {
  10. return nil, errors.Wrap(err, "failed to create containerd task")
  11. }
  12. // wait is a long running background request, no timeout needed. exitCh, err := task.Wait(ctrdutil.NamespacedContext())
  13. // Start containerd task.
  14. if err := task.Start(ctx err != nil {
  15. return nil, errors.Wrapf(err, "failed to start containerd task %q", id)
  16. }
  17. }
创建task的代码如下,调用了containerd的客户端的TasksClient,向服务器端发送创建task的请求
  1. func (c *container) NewTask(ctx context.Context, ioCreate cio.Creator, opts
  2. ...NewTaskOpts) (_ Task, err error) {
  3. ......
  4. request := &tasks.CreateTaskRequest{
  5. ContainerID: c.id,
  6. Terminal: cfg.Terminal, Stdin: cfg.Stdin,
  7. Stdout: cfg.Stdout,
  8. Stderr: cfg.Stderr,
  9. }
  10. ......
  11. response, err := c.client.TaskService().Create(ctx, request)
  12. ......
task启动的代码如下,调用了containerd的客户端的TasksClient,向服务器端发送启动task的请求。
  1. func (t *task) Start(ctx context.Context) error {
  2. r, err := t.client.TaskService().Start(ctx, &tasks.StartRequest{
  3. ContainerID: t.id,
  4. })
  5. if err != nil {
  6. if t.io != nil {
  7. t.io.Cancel()
  8. t.io.Close()
  9. }
  10. return errdefs.FromGRPC(err)
  11. }
  12. t.pid = r.Pid
  13. return nil
  14. }

1.4 “Task Service”

Task Service创建task流程

下面是tasks-service处理创建task请求的代码,根据容器运行时创建容器。
  1. func (l *local) Create(ctx context.Context, r *api.CreateTaskRequest, _
  2. ...grpc.CallOption) (*api.CreateTaskResponse, error) { container, err := l.getContainer(ctx, r.ContainerID)
  3. ......
  4. rtime, err := l.getRuntime(container.Runtime.Name)
  5. if err != nil {
  6. return nil, err
  7. }
  8. _, err = rtime.Get(ctx, r.ContainerID)
  9. if err != nil && err != runtime.ErrTaskNotExists {
  10. return nil, errdefs.ToGRPC(err)
  11. }
  12. if err == nil {
  13. return nil, errdefs.ToGRPC(fmt.Errorf("task %s already exists", r.ContainerID))
  14. }
  15. c, err := rtime.Create(ctx, r.ContainerID, opts)
  16. ......
  17. return &api.CreateTaskResponse{
  18. ContainerID: r.ContainerID, Pid: c.PID(),
  19. }, nil
runtime创建容器代码如下,启动了shim并向shim发送创建请求。
  1. func (m *TaskManager) Create(ctx context.Context, id string, opts runtime.CreateOpts) (_ runtime.Task, retErr error) {
  2. ......
  3. shim, err := m.startShim(ctx, bundle, id, opts)
  4. t, err := shim.Create(ctx, opts)
  5. .....
  6. }
startShim调用shim可执行文件启动了一个service,代码如下:
  1. func (m *TaskManager) startShim(ctx context.Context, bundle *Bundle, id string, opts runtime.CreateOpts) (*shim, error) {
  2. ......
  3. b := shimBinary(ctx, bundle, opts.Runtime, m.containerdAddress, m.containerdTTRPCAddress, m.events, m.tasks)
  4. shim, err := b.Start(ctx, topts, func() {
  5. log.G(ctx).WithField("id", id).Info("shim disconnected")
  6. cleanupAfterDeadShim(context.Background(), id, ns, m.tasks, m.events, b) m.tasks.Delete(ctx, id)
  7. })
  8. ......
执行shim命令所使用的可执行文件是containerd-shim-- ,比如我们平时使用的运行时类型是io.containerd.runc.v2 ,那么所用的可执行文件就是containerd-shim-runc-v2 ,完整的命令格式是
  1. containerd-shim-runc-v2 -namespace xxxx -address xxxx -publish-binary xxxx -id xxxx start
  1. func (b *binary) Start(ctx context.Context, opts *types.Any, onClose func()) (_ *shim, err error) {
  2. args := []string{"­id", b.bundle.ID}
  3. args = append(args, "start")
  4. cmd, err :=
  5. client.Command(ctx,
  6. b.runtime,
  7. b.containerdAddress,
  8. b.containerdTTRPCAddress,
  9. b.bundle.Path,
  10. opts,
  11. args...,
  12. )
  13. ......
  14. out, err := cmd.CombinedOutput()
  15. if err != nil {
  16. return nil, errors.Wrapf(err, "%s", out)
  17. }
  18. address := strings.TrimSpace(string(out))
  19. conn, err := client.Connect(address, client.AnonDialer)
  20. if err != nil {
  21. return nil, err
  22. }
  23. onCloseWithShimLog := func() {
  24. onClose()
  25. cancelShimLog()
  26. f.Close()
  27. }
  28. client := ttrpc.NewClient(conn, ttrpc.WithOnClose(onCloseWithShimLog))
  29. return &shim{
  30. bundle: b.bundle,
  31. client: client,
  32. }, nil

Task Service启动task流程

下面是tasks-service启动一个task的流程:
  1. func (l *local) Start(ctx context.Context, r *api.StartRequest, _ ...grpc.CallOption) (*api.StartResponse, error) {
  2. t, err := l.getTask(ctx, r.ContainerID)
  3. if err != nil {
  4. return nil, err
  5. }
  6. p := runtime.Process(t)
  7. if r.ExecID != "" {
  8. if p, err = t.Process(ctx, r.ExecID); err != nil {
  9. return nil, errdefs.ToGRPC(err)
  10. }
  11. }
  12. if err := p.Start(ctx); err != nil {
  13. return nil, errdefs.ToGRPC(err)
  14. }
  15. state, err := p.State(ctx)
  16. if err != nil {
  17. return nil, errdefs.ToGRPC(err)
  18. }
  19. return &api.StartResponse{
  20. Pid: state.Pid,
  21. }, nil
  22. }
启动容器的进程通过向shim的server端发送请求完成。
  1. func (s *shim) Start(ctx context.Context) error {
  2. response, err := s.task.Start(ctx,
  3. &task.StartRequest{
  4. ID: s.ID(),
  5. })
  6. if err != nil {
  7. return errdefs.FromGRPC(err)
  8. }
  9. s.taskPid = int(response.Pid)
  10. return nil

1.5 “Containerd-shim启动流程”

containerd/runtime/v2/shim/shim.go 中
  1. RunManager(ctx context.Context, manager Manager, opts ...BinaryOpts)
containerd-shim-runc-v2 start 的代码入口:
  1. case "start":
  2. opts := StartOpts{
  3. Address: addressFlag,
  4. TTRPCAddress: ttrpcAddress,
  5. Debug: debugFlag,
  6. }
  7. address, err := manager.Start(ctx, id, opts)
  8. if err != nil {
  9. return err
  10. }
  11. if _, err := os.Stdout.WriteString(address); err != nil {
  12. return err
  13. }
  14. return nil
  15. }
containerd-shim-runc-v2 start进程会再次创建一个containerd-shim-runc-v2 -namespace xxxx -id xxxx - address xxxx 的进程用于启动shim server。
  1. func (manager) Start(ctx context.Context, id string, opts shim.StartOpts) (_ string, retErr error) {
  2. cmd, err := newCommand(ctx, id, opts.Address, opts.TTRPCAddress, opts.Debug)
  3. ...
  4. // make sure that reexec shim-v2 binary use the value if need
  5. if err := shim.WriteAddress("address", address); err != nil {
  6. return "", err
  7. }
  8. ...
  9. if err := cmd.Start(); err != nil {
  10. f.Close()
  11. return "", err
  12. }
  13. ...
  14. // make sure to wait after start
  15. go cmd.Wait()
  16. ...
  17. server, err := newServer(ttrpc.WithUnaryServerInterceptor(unaryInterceptor))
  18. if err != nil {
  19. return fmt.Errorf("failed creating server: %w", err)
  20. }
  21. for _, srv := range ttrpcServices {
  22. if err := srv.RegisterTTRPC(server); err != nil {
  23. return fmt.Errorf("failed to register service: %w", err)
  24. }
  25. }
  26. if err := serve(ctx, server, signals, sd.Shutdown); err != nil {
  27. if err != shutdown.ErrShutdown {
  28. return err
  29. }
  30. }
  31. }
shim server是个ttrpc服务,提供如下接口:
  1. type TaskService interface {
  2. State(context.Context, *StateRequest) (*StateResponse, error)
  3. Create(context.Context, *CreateTaskRequest) (*CreateTaskResponse, error)
  4. Start(context.Context, *StartRequest) (*StartResponse, error)
  5. Delete(context.Context, *DeleteRequest) (*DeleteResponse, error)
  6. Pids(context.Context, *PidsRequest) (*PidsResponse, error)
  7. Pause(context.Context, *PauseRequest) (*emptypb.Empty, error)
  8. Resume(context.Context, *ResumeRequest) (*emptypb.Empty, error)
  9. Checkpoint(context.Context, *CheckpointTaskRequest) (*emptypb.Empty, error)
  10. Kill(context.Context, *KillRequest) (*emptypb.Empty, error)
  11. Exec(context.Context, *ExecProcessRequest) (*emptypb.Empty, error)
  12. ResizePty(context.Context, *ResizePtyRequest) (*emptypb.Empty, error)
  13. CloseIO(context.Context, *CloseIORequest) (*emptypb.Empty, error)
  14. Update(context.Context, *UpdateTaskRequest) (*emptypb.Empty, error)
  15. Wait(context.Context, *WaitRequest) (*WaitResponse, error)
  16. Stats(context.Context, *StatsRequest) (*StatsResponse, error)
  17. Connect(context.Context, *ConnectRequest) (*ConnectResponse, error)
  18. Shutdown(context.Context, *ShutdownRequest) (*emptypb.Empty, error)
  19. }
创建task是执行了runc create —bundle xxxx xxxx 命令,参考代码:
  1. func (r *Runc) Create(context context.Context, id, bundle string, opts *CreateOpts) error
  2. {
  3. args := []string{"create", "­­bundle", bundle}
  4. ......
  5. cmd := r.command(context, append(args, id)...)
  6. ......
  7. ec, err := Monitor.Start(cmd)
  8. ......
  9. }
启动task是执行了runc start xxxx 命令,参考代码:
  1. func (r *Runc) Start(context context.Context, id string) error {
  2. return r.runOrError(r.command(context, "start", id))
  3. }
小结 kubelet创建sandbox流程总结如下: ① containerd的cri模块创建sandbox元数据并保存 ② containerd的cri模块创建sandbox容器并保存 ③ containerd的cri模块通过grpc调用tasks-service创建task ④ tasks-service模块创建containerd-shim-xxxx-xxxx start 进程 ⑤ containerd-shim-xxxx-xxxx start 进程创建containerd-shim- xxxx-xxxx 进程并退出 ⑥ containerd-shim-xxxx-xxxx 进程启动shim server,提供ttrpc服务 ⑦ tasks-service模块调用shim server的Create接口,创建task,shim server 执行runc create 命令 ⑧ containerd的cri模块通过grpc调用tasks-service启动task tasks-service模块调用shimserverStart接口,启动taskshim server 执行runc start命令

🐧[Containerd] 深度剖析-CRI篇 - 图5

2 Containerd深入浅出-安全容器篇

Containerd 是一个高度模块化的高级运行时,所有模块均可插拔,模块均以 RPC service 形式注册并调用(gRPC 或者 TTRPC)。不同插件通过声明互相依赖,由 Containerd 核心实现统一加载,使用方可以自行实现插件以实现定制化的功能。当然这种设计虽然使得 Containerd 拥有强大的跨平台、可插拔的能力,同时也带来一些缺点,模块之间功能互调必须通过 RPC 调用。

2.1 技术简介

2.1.1 K0s

K0s可以认为是一个 Kubernetes 发行版,是一个简易、稳定且经过认证的 Kubernetes 发行版,其由云计算服务供应商Mirantis推出,该版本强调简易醒与强健性,k0s针对各种工作负载的需求,均能够满足,无论是本地端部署,还是是大规模集群部署等。

2.1.2 Containerd

Containerd 是一个高度模块化的高级运行时,所有模块均可插拔,模块均以 RPC service 形式注册并调用(gRPC 或者 TTRPC)。不同插件通过声明互相依赖,由 Containerd 核心实现统一加载,使用方可以自行实现插件以实现定制化的功能。当然这种设计虽然使得 Containerd 拥有强大的跨平台、可插拔的能力,同时也带来一些缺点,模块之间功能互调必须通过 RPC 调用。

注:TTRPC 是一种基于 gRPC 的裁剪版通信协议。

从官方文档中可以看出,Containerd 被定义为 “它是为其宿主机系统提供完整容器生命周期管理的守护进程,从镜像传输与存储到容器执行和监控以及低级存储等”。 正如下图所示,Containerd 逐渐成为容器管理内核。

🐧[Containerd] 深度剖析-CRI篇 - 图6

2.1.3 Containerd-shim

Runtime v2 为运行时实现者引入了一套shim API,以便与 Containerd 集成。shim API 只针对容器的执行生命周期管理。其是用于剥离 Containerd 守护进程与容器进程。目前已有 shim v1 和 shim v2 两个版本;它是Containerd 中的一个插件,其通过 shim 调用低级运行时命令来启动容器。

注:简单来说引入shim是允许低级运行时(如runc、youki等)在创建和运行容器之后退出, 并将shim作为容器的父进程, 而不是containerd作为父进程,是否还记得Containerd 抽象uds漏洞?

每一个 Containerd 容器都有一个相应的shim守护进程,这个守护进程会提供一个 API,Containerd 使用该 API 来管理容器基本的生命周期(启/停等),在容器中执行新的进程、调整 TTY 的大小以及与特定平台相关的其他操作。shim 还有一个作用是向 Containerd 报告容器的退出状态,在容器退出状态被 Containerd 收集之前,shim 会一直存在。这一点和僵尸进程很像,僵尸进程在被父进程回收之前会一直存在,只不过僵尸进程不会占用资源,而 shim 会占用资源。

2.2 创建集群

Mirantis引入了k0sctl配套的二进制文件,它只需要对一些Linux服务器进行ssh访问,以便在这些服务器上自动安装集群。K0s是作为单个二进制文件进行分发的,除了内核之外,它不依赖于主机操作系统,不需要特定的主机操作系统发行版,也不需要额外安装软件包。
  1. $ k0sctl init > k0sctl.yaml
该配置文件包含以下内容:
  1. apiVersion: k0sctl.k0sproject.io/v1beta1
  2. kind: Cluster
  3. metadata:
  4. name: k0s-cluster
  5. spec:
  6. hosts:
  7. - ssh:
  8. address: 10.0.0.1
  9. user: root
  10. port: 22
  11. keyPath: /Users/luc/.ssh/id_rsa
  12. role: server
  13. - ssh:
  14. address: 10.0.0.2
  15. user: root
  16. port: 22
  17. keyPath: /Users/luc/.ssh/id_rsa
  18. role: worker
  19. k0s:
  20. version: 0.10.0
可以修改上述文件,为K8s服务组件(kube-apiserver、kube-controller、kube-proxy)、网络插件(默认为Calico)和其他组件添加额外的配置选项。在目前的例子中,只是保留了默认的配置,但改变了IP地址以匹配预先提供的主机。
  • master node: 163.172.190.5
  • worker node: 163.172.190.5
用简单的命令启动集群创建(k0sctl.yaml是默认使用的配置文件)。
  1. $ k0sctl apply

🐧[Containerd] 深度剖析-CRI篇 - 图7

生成的kubeconfig文件并配置本地kubectl。
  1. $ k0sctl kubeconfig -c k0sctl.yaml > kubeconfig
  2. $ export KUBECONFIG=$PWD/kubeconfig
检查集群的节点,可以发现只列出了一个节点。master节点是专门用来运行管理控制平面的,因此不允许调度Pod。
  1. $ kubectl get no
  2. NAME STATUS ROLES AGE VERSION
  3. worker Ready <none> 5m v1.20.2-k0s1

注意:创建k0s的主要原因之一是控制平面隔离。

kubelet是运行在集群每个节点上的agent,其需要与容器运行时进行通信,以便管理容器。为此,K0s默认提供containerd运行时,当然可以配置其他的运行时,如:
  • Docker
  • CRI-O
注意:由于Docker与CRI不兼容,所以在kubelet内部实现了dockershim服务,以便它能与Docker通信。dockershim在Kubernetes 1.20中声明废弃,并已经在Kubernetes 1.24版本着手删除工作,下图为满足CRI的运行时。

🐧[Containerd] 深度剖析-CRI篇 - 图8

Containerd 认为是一个高级的容器运行时,它可以与下面列出的低级运行时进行交互。

🐧[Containerd] 深度剖析-CRI篇 - 图9

runc是Open Container Initiative(OCI)指定的容器运行时的实现参照。其是诸多Kubernetes发行版默认安装和使用的容器运行时,当然,安装和使用其他低级容器运行时也非常方便。为了提高工作负载的安全性,可能需要其他的安全容器。

注意:下图实际docker(即moby)默认使用containerd容器运行时。

🐧[Containerd] 深度剖析-CRI篇 - 图10

2.3 容器运行时

2.3.1 默认运行时

标准化的Kubernetes集群上运行一个简单的pod时,低级运行时容器默认是runc。创建的pod配置如下。
  1. $ cat <<EOF | kubectl apply -f -
  2. apiVersion: v1
  3. kind: Pod
  4. metadata:
  5. name: www-runc
  6. spec:
  7. containers:
  8. - image: nginx:1.18
  9. name: www
  10. ports:
  11. - containerPort: 80
  12. EOF
通过查看工作节点上运行的进程,可以发现有多个containerd-shim-runc-v2的进程:一个用于新创建的pod,一个用于为启动的每个pod。目前有7个pod在运行。
  1. $ kubectl get po -A | awk '{print $1" "$2}'
  2. default www-runc
  3. kube-system calico-kube-controllers-5f6546844f-tw8t6
  4. kube-system calico-node-4p98g
  5. kube-system coredns-5c98d7d4d8-9z425
  6. kube-system konnectivity-agent-m4gst
  7. kube-system kube-proxy-jq6qd
  8. kube-system metrics-server-6fbcd86f7b-m8cnd
逻辑关系可以简单表述如下。 kubelet → containerd → containerd-shim-runc-v2 → runc

🐧[Containerd] 深度剖析-CRI篇 - 图11

2.3.2 GVisor

Google gVisor 是 Google 计算平台 (GPC) App Engine、Cloud Functions 和 CloudML 的sandbox基础。谷歌意识到在公共云基础设施中运行不受信任的应用程序的风险以及使用虚拟机沙箱应用程序的效率低下,由此开发了一个用户空间内核用来处理不受信任的应用程序。gVisor通过拦截应用程序发起的针对主机内核的系统调用,并在用户空间中通过Sentry处理这些系统调用。即使容器的恶意代码对内核进行破坏也是容器的内核,而非宿主机的内核。 简而言之,gVisor内核用Go语言编写,在用户空间运行,这个内核实现了Linux系统调用接口的很大一部分,并拦截应用程序的系统调用,从而提供了相应的保护,避免了主机内核的漏洞。gVisor组件包括一个runsc的Open Container Initiative(OCI)运行时。

🐧[Containerd] 深度剖析-CRI篇 - 图12

在下文中,我们将配置containerd来通过runsc运行容器。 首先,使用下面的命令(详见k0s官方文档)来安装所有gVisor软件包。
  1. set -e
  2. URL=https://storage.googleapis.com/gvisor/releases/release/latest
  3. wget ${URL}/runsc ${URL}/runsc.sha512 \
  4. ${URL}/gvisor-containerd-shim ${URL}/gvisor-containerd-shim.sha512 \
  5. ${URL}/containerd-shim-runsc-v1 ${URL}/containerd-shim-runsc-v1.sha512
  6. sha512sum -c runsc.sha512 \
  7. -c gvisor-containerd-shim.sha512 \
  8. -c containerd-shim-runsc-v1.sha512
  9. rm -f *.sha512
  10. chmod a+rx runsc gvisor-containerd-shim containerd-shim-runsc-v1
  11. sudo mv runsc gvisor-containerd-shim containerd-shim-runsc-v1 /usr/local/bin
然后,在containerd配置文件中添加一些配置,以便它可以使用gVisor作为其他的运行时。
  1. $ cat<<EOF | sudo tee /etc/k0s/containerd.toml
  2. disabled_plugins = ["restart"]
  3. [plugins.linux]
  4. shim_debug = true
  5. [plugins.cri.containerd.runtimes.runsc]
  6. runtime_type = "io.containerd.runsc.v1"
  7. EOF
接下来,重新加载其配置。
  1. $ kill -s SIGHUP CONTAINER_PID
注意:如果是大规模集群,建议使用批量工具,如ansible,或者使用高级节点管理工具等 紧接着,定义一个与gVisor的runsc运行时关联的RuntimeClass。
  1. $ cat<<EOF | kubectl apply -f -
  2. apiVersion: node.k8s.io/v1beta1
  3. kind: RuntimeClass
  4. metadata:
  5. name: gvisor
  6. handler: runsc
  7. EOF
然后使用这个新的RuntimeClass运行Pod。
  1. $ cat<<EOF | kubectl apply -f -
  2. apiVersion: v1
  3. kind: Pod
  4. metadata:
  5. labels:
  6. app: untrusted
  7. name: www-gvisor
  8. spec:
  9. runtimeClassName: gvisor
  10. containers:
  11. - image: nginx:1.18
  12. name: www
  13. ports:
  14. - containerPort: 80
  15. EOF
列出带有app=untrusted标签的Pod,可以看到pod www-gvisor运行正常。
  1. $ kubectl get po -l app=untrusted
  2. NAME READY STATUS RESTARTS AGE
  3. www-gvisor 1/1 Running 0 9s
它使用gVisor/runsc,增加了一个额外的保护,因为系统调用首先由用户区内核处理。进程的调用链如下。 kubelet → containerd → containerd-shim-runsc-v1 → runsc

2.3.3 Kata Containers

kata container是为了解决容器安全的问题而诞生的,传统的容器是基于namespace和cgroup进行隔离,在带来轻量简洁的同时,也带来了一些安全的隐患。容器虽然提供一个与系统中的其它进程资源相隔离的执行环境,但是与宿主机系统是共享内核的,一旦容器里的应用逃逸到内核,后果不堪设想,尤其是在多租户的场景下。Kata就是在这样的背景下应运而生,kata很好的权衡了传统虚拟机的隔离性、安全性与容器的简洁、轻量。这一点和firecracker类似,都是轻量的虚拟机。但是他们的本质的区别在于:kata虽然是基于虚机,但是其表现的却跟容器是一样的,可以像使用容器一样使用kata;而firecracker虽然具备容器的轻量、极简性,但是其依然是虚机,一种比QEMU更轻量的VMM。

🐧[Containerd] 深度剖析-CRI篇 - 图13

之前的版本使用了几个shim进程(containerd-shim、kata-shim、kata-runtime、kata-proxy),而目前的版本它只使用(container-shim-kata-v2)。

🐧[Containerd] 深度剖析-CRI篇 - 图14

首先需要确保启用了嵌套虚拟化,因为工作节点上将为每个容器创建一个新的虚拟机,通过以下命令确认。
  1. $ cat /sys/module/kvm_amd/parameters/nested
  2. 1
安装kata容器包。
  1. $ bash -c "$(curl -fsSL https://raw.githubusercontent.com/kata-containers/tests/master/cmd/kata-manager/kata-manager.sh) install-packages"
接下来,修改containerd配置文件,使其使用kata作为另外的运行时(在前面步骤中添加的runc和gVisor/runsc之上)。
  1. $ cat<<EOF | sudo tee -a /etc/k0s/containerd.toml
  2. [plugins.cri.containerd.runtimes.kata]
  3. runtime_type = "io.containerd.kata.v2"
  4. EOF
接下来,重新加载其配置。
  1. $ kill -s SIGHUP CONTAINER_PID

注意:如果是大规模集群,建议使用批量工具,如ansible,或者使用高级节点管理工具等

接下来,创建 一个”kata “运行时的RuntimeClass。
  1. $ cat<<EOF | kubectl apply -f -
  2. kind: RuntimeClass
  3. apiVersion: node.k8s.io/v1beta1
  4. metadata:
  5. name: kata
  6. handler: kata
  7. EOF
然后使用这个新的RuntimeClass运行Pod。
  1. $ cat<<EOF | kubectl apply -f -
  2. apiVersion: v1
  3. kind: Pod
  4. metadata:
  5. labels:
  6. app: untrusted
  7. name: www-kata
  8. spec:
  9. runtimeClassName: kata
  10. containers:
  11. - image: nginx:1.18
  12. name: www
  13. ports:
  14. - containerPort: 80
  15. EOF
列出带有<font style="color:rgb(51, 51, 51);">app=untrusted</font>标签的Pod,可以看到pod www-kata运行正常。
  1. $ kubectl get po -l app=untrusted
  2. NAME READY STATUS RESTARTS AGE
  3. www-gvisor 1/1 Running 0 8m1s
  4. www-kata 1/1 Running 0 3h25m
它运行在一个专用的虚拟机(通常定义为microVM)中,该虚拟机只为该容器创建,还可以看到一个qemu进程正在运行。
  1. root@worker:~# ps -ef | grep qemu | awk '{print $11}'
  2. /usr/bin/qemu-vanilla-system-x86_64
以下为相关调用链。 kubelet → containerd → containerd-shim-kata-v2 → kata
参考文献

https://betterprogramming.pub/kata-container-and-gvisor-with-k0s-82efbbcc240b

3 Containerd深度剖析-runtime篇

虽然容器领域的创业随着CoreOS、Docker的卖身,而逐渐归于平寂,但随着Rust语言的兴起,Firecracker、youki项目在容器领域泛起涟漪,对于云原生从业者来说,面试等场景中或多或少都会谈论到容器一些的历史与技术背景。

3.1 需求简介

注: Container runtime统称为容器运行时

Docker时代,关于容器运行时术语的定义是非常明确的,其为运行和管理容器的软件。但随着Docker涵盖的内容日益增多,以及多种容器编排工具的引入,该定义变得日益模糊了。 当你运行一个Docker容器时,一般的步骤是:
  • 下载镜像
  • 将镜像解压成一个bundle,即将各层文件平铺到一个单一的文件系统中。
  • 运行容器
最初的规范规定,只有运行容器的部分定义为容器运行时,但一般用户,将上述三个步骤都默认为容器运行时所必须的能力,从而让容器运行时的定义成为一个令人困惑的话题。 当人们想到容器运行时,可能会想到一连串的相关概念;runc、runv、lxc、lmctfy、Docker(containerd)、rkt、cri-o。每一个都是基于不同的场景而实现的,均实现了不同的功能。如containerd和cri-o,实际均可使用runc来运行容器,但其实现了如镜像管理、容器API等功能,可以将这些看作是比runc具备的更高级的功能。 可以发现,容器运行时是相当复杂的。每个运行时都涵盖了从低级到高级的不同部分,如下图所示。

🐧[Containerd] 深度剖析-CRI篇 - 图15

根据功能范围划分,将其分为低级容器运行时 (Low level Container Runtime)和高级容器运行时 (High level Container Runtime),其中只关注容器的本身运行通常称为低级容器运行时(Low level Container Runtime)。支持更多高级功能的运行时,如镜像管理及一些gRPC/Web APIs,通常被称为 高级容器运行时 (High level Container Runtime)。需要注意的是,低级运行时和高级运行时有本质区别,各自解决的问题也不同。

3.2 低级容器运行时

低级运行时的功能有限,通常执行运行容器的低级任务。大多数开发者日常工作中不会使用到。其一般指按照 OCI 规范、能够接收可运行roofs文件系统和配置文件并运行隔离进程的实现。这种运行时只负责将进程运行在相对隔离的资源空间里,不提供存储实现和网络实现。但是其他实现可以在系统中预设好相关资源,低级容器运行时可通过 config.json 声明加载对应资源。低级运行时的特点是底层、轻量,限制也很一目了然:
  • 只认识 rootfs 和 config.json,没有其他镜像能力
  • 不提供网络实现
  • 不提供持久实现
  • 无法跨平台等

低级运行时demo

通过以root方式使用Linux cgcreate、cgset、cgexec、chroot和unshare命令来实现简单容器。 首先,以busybox容器镜像作为基础,设置一个根文件系统。然后,创建一个临时目录,并将busybox解压到该目录中。
  1. $ CID=$(docker create busybox)
  2. $ ROOTFS=$(mktemp -d)
  3. $ docker export $CID | tar -xf - -C $ROOTFS
紧接着创建uuid,并对内存和CPU设置限制。内存限制是以字节为单位设置的。在这里,将内存限制设置为100MB。
  1. $ UUID=$(uuidgen)
  2. $ cgcreate -g cpu,memory:$UUID
  3. $ cgset -r memory.limit_in_bytes=100000000 $UUID
  4. $ cgset -r cpu.shares=512 $UUID
例如,如果我们想把我们的容器限制在两个cpu core上,可以设定一秒钟的周期和两秒钟的配额(1s=1,000,000us),这将允许进程在一秒钟的时间内使用两个cpu core。
  1. $ cgset -r cpu.cfs_period_us=1000000 $UUID
  2. $ cgset -r cpu.cfs_quota_us=2000000 $UUID
接下来在容器中执行命令。
  1. $ cgexec -g cpu,memory:$UUID \
  2. > unshare -uinpUrf --mount-proc \
  3. > sh -c "/bin/hostname $UUID && chroot $ROOTFS /bin/sh"
  4. / # echo "Hello from in a container"
  5. Hello from in a container
  6. / # exit
最后,删除前面创建的cgroup和临时目录。
  1. $ cgdelete -r -g cpu,memory:$UUID
  2. $ rm -r $ROOTFS

低级运行时demo

为了更好地理解低级容器运行时,以下列举了几个低级运行时代表,各自实现了不同的功能。

runC

runC是目前使用最广泛的容器运行时。它最初是集成在Docker的内部,后来作为一个单独的工具,并以公共库的方式提取出来。 在2015 年,在 Linux 基金会的支持下有了 Open Container Initiative (OCI)(就是负责制定容器标准的组织),Docker 将自己容器格式和运行时 runC 捐给了 OCI。OCI 在此基础上制定了 2 个标准:运行时标准 Runtime Specification (runtime-spec) 和 镜像标准 Image Specification (image-spec) ,下面通过示例,简要介绍一下 runC。 首先创建根文件系统。这里我们将再次使用busybox。
  1. $ mkdir rootfs
  2. $ docker export $(docker create busybox) | tar -xf - -C rootfs
接下来创建一个config.json文件
  1. $ runc spec
这个命令为容器创建一个模板config.json。
  1. $ cat config.json
  2. {
  3. "ociVersion": "1.0.2",
  4. "process": {
  5. "terminal": true,
  6. "user": {
  7. "uid": 0,
  8. "gid": 0
  9. },
  10. "args": [
  11. "sh"
  12. ],
  13. "env": [
  14. "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
  15. "TERM=xterm"
  16. ],
  17. "cwd": "/",
  18. "capabilities": {
  19. ...
默认情况下,它在根文件系统位于./rootfs的目录下运行命令。
  1. $ sudo runc run mycontainerid
  2. / # echo "Hello from in a container"
  3. Hello from in a container

rkt(已废弃)

rkt是一个同时具有低级和高级功能的运行时。例如,很像Docker,rkt允许你构建容器镜像,获取和管理本地存储库中的容器镜像,并通过一个命令运行它们。

runV

runv 是 OCF 基于管理程序的(Hypervisor-based )运行时 Runtime.runV 兼容 OCF。作为虚拟容器运行时引擎的runV已被淘汰。runV团队与英特尔一起在OpenInfra Foundation中创建了Kata Containers项目

youki

Rust是时下最流行的编程语言,而容器开发也是一个时兴的应用领域。将两者结合使用Rust来做容器开发是一个值得尝鲜的体验。youki是使用Rust的实现OCI运行时规范,类似于runc。

3.3 高级容器运行时

高级运行时负责容器镜像的传输和管理,解压镜像,并传递给低级运行时来运行容器。通常情况下,高级运行时提供一个守护程序和一个API,远程应用程序可以使用它来运行容器并监控它们,它们位于低层运行时或其他高级运行时之上。 高层运行时也会提供一些看似很低级的功能。例如,管理网络命名空间,并允许容器加入另一个容器的网络命名空间。 这里有一个类似逻辑分层图,可以帮助理解这些组件是如何结合在一起工作的。

🐧[Containerd] 深度剖析-CRI篇 - 图16

3.3.1 高级运行时代表

Docker

Docker是最早的开源容器运行时之一。它是由平台即服务的公司dotCloud开发的,用于在容器中运行用户的应用。 Docker是一个容器运行时,包含了构建、打包、共享和运行容器。Docker基于C/S架构实现,最初是由一个守护程序dockerd和docker客户端应用程序组成。守护程序提供了构建容器、管理镜像和运行容器的大部分逻辑,以及一些API。命令行客户端可以用来发送命令和从守护进程中获取信息。 它是第一个流行开来的运行时间,毫不过分的说,Docker对容器的推广做出了巨大的贡献。 Docker最初实现了高级和低级的运行时功能,但这些功能后来被分解成单独的项目,如runc和containerd,以前Docker的架构如下图所示,现有架构中,docker-containerd变成了containerd,docker-runc变成了runc。

🐧[Containerd] 深度剖析-CRI篇 - 图17

dockerd提供了诸如构建镜像的功能,而dockerd使用containerd来提供诸如镜像管理和运行容器的功能。例如,Docker的构建步骤实际上只是一些逻辑,它解释Docker文件,使用containerd在容器中运行必要的命令,并将产生的容器文件系统保存为一个镜像。

Containerd

containerd是从Docker中分离出来的高级运行时。containerd实现了下载镜像、管理镜像和运行镜像中的容器。当需要运行一个容器时,它会将镜像解压到一个OCI运行时bundle中,并向runc发送init以运行它。 Containerd还提供了API,可以用来与它交互。containerd的命令行客户端是ctr和nerdctl。 可以通过ctr拉取一个容器镜像。
  1. $ sudo ctr images pull docker.io/library/redis:latest
列出所有的镜像:
  1. $ sudo ctr images list
运行容器:
  1. $ sudo ctr container create docker.io/library/redis:latest redis
列出运行容器:
  1. $ sudo ctr container list
停止容器:
  1. $ sudo ctr container delete redis
这些命令类似于用户与Docker的互动方式。

rkt(已废弃)

rkt是一个同时具有低级和高级功能的运行时。例如,很像Docker,rkt允许你构建容器镜像,获取和管理本地存储库中的容器镜像,并通过一个命令运行它们。

3.3.2 Kubernetes CRI

CRI在Kubernetes 1.5中引入,作为kubelet和容器运行时之间的桥梁。社区希望Kubernetes集成的高级容器运行时实现CRI。该运行时处理镜像的管理,支持Kubernetes pods,并管理容器,因此根据高级运行时的定义,支持CRI的运行时必须是一个高级运行时。低级别的运行时并不具备上述功能。

为了进一步了解CRI,可以看看整个Kubernetes架构。kubelet代表工作节点,位于Kubernetes集群的每个节点上,kubelet负责管理其节点的工作负载。当需要运行工作负载时,kubelet通过CRI与运行时进行通信。由此可以看出,CRI只是一个抽象层,允许切换不同的容器运行时。

🐧[Containerd] 深度剖析-CRI篇 - 图18

3.3.3 CRI规范

CRI定义了gRPC API,该规范定义在Kubernetes仓库中cri-api目录中。CRI定义了几个远程程序调用(RPC)和消息类型。这些RPC用于管理工作负载等内容,如 “拉取镜像”(ImageService.PullImage)、”创建pod”(RuntimeService.RunPodSandbox)、”创建容器”(RuntimeService.CreateContainer)、”启动容器”(RuntimeService.StartContainer)、”停止容器”(RuntimeService.StopContainer)等操作。 例如,通过CRI启动一个新的Pod(篇幅有限,进行了一些简化工作)。RunPodSandbox和CreateContainer RPCs在其响应中返回ID,在后续请求中使用。
  1. ImageService.PullImage({image: "image1"})
  2. ImageService.PullImage({image: "image2"})
  3. podID = RuntimeService.RunPodSandbox({name: "mypod"})
  4. id1 = RuntimeService.CreateContainer({
  5. pod: podID,
  6. name: "container1",
  7. image: "image1",
  8. })
  9. id2 = RuntimeService.CreateContainer({
  10. pod: podID,
  11. name: "container2",
  12. image: "image2",
  13. })
  14. RuntimeService.StartContainer({id: id1})
  15. RuntimeService.StartContainer({id: id2})
可以直接使用crictl工具与CRI运行时交互,可以用它来调试和测试CRI的相关实现。
  1. cat <<EOF | sudo tee /etc/crictl.yaml
  2. runtime-endpoint: unix:///run/containerd/containerd.sock
  3. EOF
或者通过命令行指定:
  1. crictl --runtime-endpoint unix:///run/containerd/containerd.sock
关于crictl的使用参见官网。

3.3.4 支持CRI的运行时

Containerd

containerd应该是目前最流行的CRI运行时。它以插件的方式实现CRI,默认是启用的。它默认在unix套接字上监听消息。 从1.2版本开始,它通过 runtime handler来支持多种低级运行时。运行时处理程序是通过CRI中的字段传递,根据该运行时处理程序,containerd运行shim的应用程序来启动容器。这可以用来运行 runc及其他的低级运行时的容器,如 gVisor、Kata Containers等。在Kubernetes API中通过RuntimeClass进行运行时配置。 下图是Containerd的发展史。

🐧[Containerd] 深度剖析-CRI篇 - 图19

🐧[Containerd] 深度剖析-CRI篇 - 图20


Docker

docker-shim是K8s社区第一个被开发的,作为kubelet和Docker之间的shim。随着Docker将其许多功能分解到containerd中,现在通过containerd支持CRI。当现代版本的Docker被安装时,containerd也一起被安装,CRI直接与containerd对话,随着docker-shim正式废弃,是时候考虑相关迁移的工作了,K8s在这方面做了大量的工作,具体可参看官方文档。

CRI-O

cri-o是一个轻量级的CRI运行时,它支持OCI,并提供镜像的管理、容器进程管理、监控日志及资源隔离等工作。 cri-o的通信地址默认是在<font style="color:rgb(51, 51, 51);">/var/run/crio/crio.sock</font>

🐧[Containerd] 深度剖析-CRI篇 - 图21

🐧[Containerd] 深度剖析-CRI篇 - 图22

下图为CRI插件的演变史。

🐧[Containerd] 深度剖析-CRI篇 - 图23

参考文献

  1. https://blog.mobyproject.org/where-are-containerds-graph-drivers-145fc9b7255
  2. https://insujang.github.io/2019-10-31/container-runtime/
  3. https://github.com/cri-o/cri-o