Jason Pan

揭秘容器(二):容器运行时

潘忠显 / 2023-11-02


本文翻译并补充了 CNCF 上的博客,系列文章共4篇,旨在从容器发展的历史角度,带领读者认识容器。文章会通过简单的示例,结合历史背景,引导你从最小 Linux 环境开始认识内核层次的一些技术,一直到构建安全容器,逐层地去认识现代云架构

最终目的,是更深入地理解 Linux 内核、容器工具、运行时、软件定义网络和编排软件(如 Kubernetes)的设计理念、底层工作原理,完美地适应当今和未来的容器编排世界


在本系列的《揭秘容器(一):内核空间 chroot & namespace》一文中 ,我们讨论了 Linux 内核命名空间,并介绍了容器相关的基本隔离技术,那是容器的基础。

本篇为系列文章的第二篇,主要介绍容器运行时(Container Runtimes)。跟第一篇类似,仍然会从容器运行时的历史起源出发,深入地研究两个容器运行时的项目—— runc 和 CRI-O:首先,将从较低层次运行时 runc 开始,为容器运行时如何在底层工作奠定良好的基础;然后,利用更高层次的 CRI-O,在不使用 Kubernetes 的情况下,运行 Kubernetes 原生负载。

现在,我们想更深入地讨论:“如何实际运行容器”。我们将会刻意避开 Kubernetes 的功能细节和安全相关主题,以免要讨论的主题受到干扰。

1. 什么是容器运行时?

容器运行时(Container Runtime)是指管理容器的一类软件组件。它提供了一种隔离和管理应用程序的环境,使得应用程序可以在独立的环境中运行,而不会相互干扰。除资源个隔离外,容器运行时还会负责处理容器的创建、启动、停止和销毁等生命周期管理任务,以及与宿主操作系统和底层硬件的交互。

what-is-container-runtime

在 UNIX 世界中,根据由 Ken Thompson 提出的 Unix哲学,应用程序应采用极简主义模块化的软件开发方式,各组件在完整的系统中协调工作。有很多遵循 Unix 哲学观点的优秀例子,比如:UNIX管道vim 文本编辑器,这些工具尽其所能地解决一项专门的任务,并在各自方面取得了巨大的成功。而像 systemd 或 CMake 这样的项目并未遵循这样的哲学,它们不断地演化,实现了丰富而复杂的功能。

当我们讨论**“初始化系统应该是什么样的?” 或者 “构建系统应该做什么?” **时,也有同样的分歧,会有多种观点和看法。

许多应用程序都可以运行容器,而每个应用程序对于容器运行时应该做什么和支持什么都有截然不同的看法。systemd 能够通过 systemd-nspawn 运行容器, NixOS 也集成了容器管理。更别说还有现成的容器运行时,如 CRI-OKata ContainersFirecrackergVisorcontainerdLXCruncNabla Containers 等等。其中很多现在都是云原生计算基金会(CNCF) 及其 landscape 的一部分。你肯定会问: “为什么存在这么多容器运行时?”

cncf-landscape-container-runtime

现在,欢迎来到容器的世界! 按照我们的系列博客文章的惯例,我们应该从历史的起点开始。

2. 简短的历史介绍

2008 年 cgroup 发明后,一个名为 Linux Containers (LXC) 的项目开始兴起,这彻底改变了容器世界。LXC 结合了 cgroup 和命名空间技术,为运行应用程序提供隔离的环境。

你可能知道,我们有时生活在平行世界:在 LXC 发展的同一时代,Google 在 2007 年启动了自己的容器化项目,名为 Let Me Contain That For You ( LMCTFY ),该项目与 LXC 基本处于同一层次上工作。Google 试图利用 LMCTFY 提供稳定且 API 驱动的配置,而用户无需了解 cgroup 及其内部细节。

我们再回顾 2013 年,名为 Docker 的工具出现了,它构建在当时的 LXC 栈之上。Docker 的一项重要发明是,用户现在能够将容器打包成镜像,以便在机器之间传播。正如他们在“标准容器宣言( Standard Container Manifesto) ”中所说,Docker 是第一批尝试将容器打造成标准软件单元的工具

几年后,他们开始研究 libcontainer,这是一种 生成和管理容器的 Go 原生方式。LMCTFY 也在那段时间被放弃,但 LMCTFY 的核心概念和主要优点被移植到了 libcontainer 和 Docker 中。

来到 2015 年,Kubernetes 发布了 1.0 版本。在这一年,也产生了两个重要组织:

cncf-logo

oci-logo

他们的主要目标是围绕容器格式和运行时,创建开放的行业标准。当时正处于容器与经典虚拟机 (VM) 并驾齐驱的状态。需要对容器如何运行进行规范,OCI 运行时规范 便应运而生。运行时开发人员拥有了定义良好的 API 来开发其容器运行时。在此期间,libcontainer 项目被捐赠给了 OCI,而一个名为 runc 的新工具也作为其中的一部分诞生了。借助 runc,已经可以直接与 libcontainer 交互、解释 OCI 运行时规范、并从中运行容器。

截至目前,runc 是容器生态系统中最受欢迎的项目之一,并被用于许多其他项目,如 containerd(由 Docker 使用)、CRI-O 和 podman。其他项目也采用了 OCI 运行时规范,例如:Kata Containers 使构建和运行安全容器成为可能,其中包括轻量级虚拟机,这些虚拟机的表现和感觉类似于容器,但使用硬件虚拟化技术作为第二层防御来提供更强大的工作负载隔离。

让我们深入研究 OCI 运行时规范(OCI Runtime Specification),以更好地了解容器运行时在幕后如何工作。

3. 运行容器 - runc

runc-logo

OCI 运行时规范提供了有关容器的配置、执行环境和整体生命周期的信息。配置主要是一个 JSON 文件,其中包含在不同目标平台(如 Linux、Windows 或虚拟机 (VM))上创建容器所需的所有信息。

3.1 runc spec 配置

使用 runc 可以轻松生成示例规范:

> runc spec  # 会生成 config.json 文件
> cat config.json | jq 

config.json 文件主要包含 runc 开始运行容器所需的所有信息,比如:

要开始运行容器,还缺少一件事:我们需要一个合适的根文件系统(rootfs)——我们在在之前的博客文章已经介绍过,如何从现有的容器镜像中获取:

> skopeo copy docker://opensuse/tumbleweed:latest oci:tumbleweed:latest
> sudo umoci unpack --image tumbleweed:latest bundle

解压的容器映像已经包含我们运行该包所需的运行时规范:

> sudo chown -R $(id -u) bundle
> cat bundle/config.json

{
  "ociVersion": "1.0.0",
  "process": {
    "terminal": true,
    "user": { "uid": 0, "gid": 0 },
    "args": ["/bin/bash"],
    "env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
      "TERM=xterm",
      "HOME=/root"
    ],
    "cwd": "/",
    "capabilities": { [...] },
    "rlimits": [...]
  },
  "root": { "path": "rootfs" },
  "hostname": "mrsdalloway",
  "mounts": [...],
  "annotations": {
    "org.opencontainers.image.title": "openSUSE Tumbleweed Base Container",
    "org.opencontainers.image.url": "https://www.opensuse.org/",
    "org.opencontainers.image.vendor": "openSUSE Project",
    "org.opencontainers.image.version": "20190517.6.190",
    [...]
  },
  "linux": {
    "resources": { "devices": [ { "allow": false, "access": "rwm" } ] },
    "namespaces": [
      { "type": "pid" },
      { "type": "network" },
      { "type": "ipc" },
      { "type": "uts" },
      { "type": "mount" }
    ]
  }
}

跟前边运行 runc spec 命令产生的 config.json 有所不同的是:从 SUSE 镜像恢复的根目录中的 config.json 多了一个 annotations 字段。通过 annotations 可以向容器添加任意元数据,更高级别的运行时可以利用这些元数据,向规范添加附加信息

3.2 创建

让我们利用 runc 从 bundle 目录中创建一个新容器。在实际调用 runc 之前,我们必须设置一个接收器终端才能与容器交互,可以使用包含在 runc 仓库中的 recvtty工具

> go install github.com/opencontainers/runc/contrib/cmd/recvtty@latest
> recvtty tty.sock

在另一个终端中,我们现在调用 runc create 并指定捆绑包和终端套接字:

> sudo runc create -b bundle --console-socket $(pwd)/tty.sock container
> sudo runc list

没有进一步的输出,那么现在发生了什么?看起来我们已经在 created 状态中创建了一个新容器:

runc-create

容器当前的状态是 created 而不是 running,看上去并没有运行,但是里面运行的是什么?

> runc ps container
UID        PID  PPID  C STIME TTY          TIME CMD
root       811     1  0 11:28 pts/0    00:00:00 runc init

3.3 运行

runc init 命令设置一个包含所有必要命名空间的全新环境,并启动一个新的初始进程。主进程 /bin/bash 尚未在容器内运行,但我们仍然可以在容器内执行进一步的进程:

> sudo runc exec -t container echo "Hello, world!"
> Hello, world!

容器的状态为设置网络提供了良好的环境。要在容器内实际执行某些操作,我们必须将其带入状态running。这可以通过以下方式完成 runc start:

> sudo runc start container

在进程运行的终端中 recvtty ,现在应该弹出一个新的 bash shell 会话:

runc_creat_start_container

很好,容器似乎正在运行。我们现在可以用来runc检查容器的状态:

> sudo runc list
ID          PID         STATUS      BUNDLE      CREATED                          OWNER
container   4985        running     /bundle     2019-05-20T12:14:14.232015447Z   root
> sudo runc ps container
UID        PID  PPID  C STIME TTY          TIME CMD
root      6521  6511  0 14:25 pts/0    00:00:00 /bin/bash

runc-ps-container

runc init 进程已经消失,现在 /bin/bash 容器内仅存在实际的进程。

3.4 暂停、恢复、停止、删除

我们还可以对容器进行一些基本的生命周期管理。使用 pause 可以暂停容器,此时 tty 是没有响应的;再调用 resume 可以恢复容器,之前尝试在 tty 输入的所有内容现在都会在恢复的容器终端中弹出:

> sudo runc pause container   # recvtty 会话会阻塞住
> sudo runc resume container  # recvtty 会话会恢复
runc_pause_resume_container

停止容器,我们只需退出 recvtty 会话即可。处于该状态的容器 stopped 无法再次运行,因此必须从新状态重新创建它们。然后,可以使用 runc delete: 命令移除容器:

runc_stop_delete_container

3.5 容器事件信息

如果我们需要了解容器的更多信息,例如 CPU 和内存使用情况,那么我们可以通过 API 检索它们 runc events

> sudo runc events container

我们可以看到我们能够检索有关容器的详细运行时信息。输出有点难以阅读,所以让我们重新格式化它并删除一些字段:

{
  "type": "stats",
  "id": "container",
  "data": {
    "cpu": {
      "usage": {
        "total": 31442016,
        "percpu": [ 5133429, 5848165, 827530, ... ],
        "kernel": 20000000,
        "user": 0
      },
      "throttling": {}
    },
    "memory": {
      "usage": {
        "limit": 9223372036854771712,
        "usage": 1875968,
        "max": 6500352,
        "failcnt": 0
      },
      "swap": { "limit": 0, "failcnt": 0 },
      "kernel": {
        "limit": 9223372036854771712,
        "usage": 311296,
        "max": 901120,
        "failcnt": 0
      },
      "kernelTCP": { "limit": 9223372036854771712, "failcnt": 0 },
      "raw": {
        "active_anon": 1564672,
        [...]
      }
    },
    "pids": { "current": 1 },
    "blkio": {},
    "hugetlb": { "1GB": { "failcnt": 0 }, "2MB": { "failcnt": 0 } },
    "intel_rdt": {}
  }
}

3.6 修改容器

如前所述,会使用从镜像中提取的 rootfs 以及config.json 文件来设置容器。

[例1] 修改容器的初始运行命令,修改配置文件中的 process 下的 args 数组:

> cd bundle
> jq '.process.args = ["echo", "Hello, world!"]' config.json | sponge config.json
> sudo runc run container
Hello, world!

runc-run-container-change-start-command

[例2] 通过删除 config.json 中的 linux 下 namespaces 字段,来拆除容器和主机之间的 PID 命名空间隔离:

> jq '.process.args = ["ps", "a"] | del(.linux.namespaces[0])' config.json | sponge config.json
> sudo runc run container
runc-run-contianer-rm-network-namespace

3.7 不要直接使用 runc

runc 是一个相当低级别的运行时,如果不正确的配置和使用可能带来严重的安全问题。诚然 runc 对 seccomp安全增强型 Linux (SELinux)AppArmor 等安全增强功能有支持 ,但这些功能应该由更高级别的运行时使用,以确保在生产环境中正确使用。值得一提的是,可以通过 runc 以无根模式运行容器,以进一步强化部署的安全性。

仅使用 runc 运行容器的另一个缺点是,我们必须手动设置主机的网络才能连接到互联网或其他容器。为了做到这一点,我们可以在实际启动容器之前使用*运行时规范 Hook*功能来设置默认桥。但这项工作,是可以让更高级别的运行时来做的。

4. Kubernetes CRI

早在 2016 年,Kubernetes 项目就宣布实施 容器运行时接口 (CRI),该接口为容器运行时与 Kubernetes 配合使用提供了标准 API。该接口使用户能够轻松地在集群中交换运行时。

API 是如何工作的?每个 Kubernetes 集群的每个节点上,都运行一个名为 kubelet 的底层代理,它的主要工作是保持容器工作负载运行和健康。kubelet 在启动时连接到 gRPC 服务器,并需要那里有一个预定义的 API。例如,API 的一些服务定义如下所示:

// Runtime service defines the public APIs for remote container runtimes
service RuntimeService {
    rpc CreateContainer (...) returns (...) {}
    rpc ListContainers  (...) returns (...) {}
    rpc RemoveContainer (...) returns (...) {}
    rpc StartContainer  (...) returns (...) {}
    rpc StopContainer   (...) returns (...) {}

这似乎与我们已经使用 runc 所做的差不多,管理容器生命周期。如果我们进一步查看 API,我们会看到:

		rpc ListPodSandbox  (...) returns (...) {}
    rpc RemovePodSandbox(...) returns (...) {}
    rpc RunPodSandbox   (...) returns (...) {}
    rpc StopPodSandbox  (...) returns (...) {}
}

**“沙盒”是什么意思?**容器应该已经是某种沙箱了,但在 Kubernetes 世界中,Pod 是可以创建和管理的、最小的可部署的计算单元(特定于应用的 “逻辑主机”),是一组(一个或多个) 容器; 这些容器共享存储、网络、以及怎样运行这些容器的声明。

为了实现资源的与外部隔离、与内部共享,每次创建 Kubernetes Pod 都从设置所谓的 PodSandbox开始。Pod 内运行的每个容器都附加到此沙箱,内部容器可以共享公共资源,例如网络接口。

前文提到的 runc 本身并不能提供此类开箱即用的功能,因此我们必须使用更高级别的运行时来实现我们的目标。

4.1 CRI-O

crio-logo

CRI-O 是一个更高级别的容器运行时,专门为与 Kubernetes CRI 一起使用而编写。该名称源自容器运行时接口(OCI)和开放容器计划(OCI)的组合。CRI-O 的旅程始于 2016 年的 Kubernetes 孵化器项目,名称为 开放容器倡议守护进程 (OCID)。1.0.0 版本已于一年后的 2017 年发布,并从那之后遵循 Kubernetes 的发布周期,这意味着 Kubernetes 1.15 可以与 CRI-O 1.15 等安全地一起使用。当在K8s内将运行生产环境就绪的工作负载时,CRI-O 往往可以成为 Docker 或 containerd 的轻量级替代品。

CRI-O 的实现遵循主要的 UNIX 理念,它只有一项主要任务:实现 Kubernetes CRI。为此,它在后端利用 runc 进行基本容器管理,同时服务器向前端提供 gRPC API 服务。介于两者之间的所有事情都由 CRI-O 本身或一些核心库(如containers/storagecontainers/image)。

接下来,让我们尝试一下使用高层次运行时 CRI-O 。

4.2 操作环境说明

原文作者准备了一个 saschagrunert/crio-playground 容器镜像,但是这个镜像有两个问题

所以我这里也准备了一个容器镜像 panzhongxian/crio-playground:

启动运行 crio-playground 的特权容器,只需执行:

> sudo podman run --privileged -h crio-playground -it panzhongxian/crio-playground

4.3 创建沙箱 Pod

从现在开始,我们将使用名为 crictl 的工具CRI-O 及其容器运行时接口实现进行交互。

crictl-warning

crictl 允许我们使用 CRI API 请求将 YAML 形式的数据发送到 CRI-O。例如,我已经在容器中工作目录放置了一个 sandbox.yml 文件,用于创建新的 PodSandbox:

metadata:
  name: sandbox
  namespace: default
dns_config:
  servers:
    - 8.8.8.8

要在运行的 crio-playground 容器中创建沙箱,我们现在执行(这里设置超时时间30秒,避免拉取镜像超时):

root@crio-playground:~# crictl --timeout=30s runp sandbox.yml
1d1a9a66b433399a4cd24a77ae7dc56ba68915baf058df26b40078e56b216eb9

返回的字符串便是沙箱ID标识符,让我们将其存储为 $POD_ID 环境变量以供以后使用:

root@crio-playground:~# export POD_ID=1d1a9a66b433399a4cd24a77ae7dc56ba68915baf058df26b40078e56b216eb9

如果我们现在运行,crictl pods 我们可以看到我们终于有了一个 PodSandbox 并正在运行:

root@crio-playground:~# crictl pods | transpose
         POD ID: 1d1a9a66b4333
        CREATED: 37 seconds ago
          STATE: Ready
           NAME: sandbox
      NAMESPACE: default
        ATTEMPT: 0
        RUNTIME: (default)

现在沙箱里面有什么?我们可以使用 runc 进一步检查沙箱:

root@crio-playground:~# runc list | transpose
             ID: 1d1a9a66b433399a4cd24a77ae7dc56ba68915baf058df26b40078e56b216eb9
            PID: 87
         STATUS: running
         BUNDLE: /run/containers/storage/vfs-containers/1d1a9a66b433399a4cd24a77ae7dc56ba68915baf058df26b40078e56b216eb9/userdata
        CREATED: 2023-11-02T11:57:36.139619785Z
          OWNER: root

沙箱似乎在 /run/containers. 沙箱内只有一个进程在运行,称为 pause。正如 pause的源代码所示,该进程主要任务是保持环境运行并对传入信号做出反应

root@crio-playground:~# runc ps $POD_ID
UID          PID    PPID  C STIME TTY          TIME CMD
root          87      79  0 11:57 ?        00:00:00 /pause

4.4 运行工作容器

在我们实际在沙箱中创建工作负载之前,我们必须预先拉取我们想要运行的映像。一个简单的例子是运行一个 Web 服务器,所以让我们通过调用以下命令来检索 nginx 映像:

root@crio-playground:~# crictl pull nginx:alpine
Image is up to date for docker.io/library/nginx@sha256:7e528502b614e1ed9f88e495f2af843c255905e0e549b935fdedd95336e6de8d

现在让我们在 YAML 中创建一个非常简单的容器定义,就像我们为沙箱所做的那样:

metadata:
  name: container
image:
  image: nginx:alpine

现在,让我们启动容器。为此,我们必须提供沙箱的哈希值以及沙箱和容器的 YAML 定义:

root@crio-playground:~# crictl create $POD_ID container.yml sandbox.yml
a2431ece5c7707f796898a887a636b1e31f69de6d71144ed97c7a97afd05d743

上边返回哈希值说明已经创建容器生效,我们用 $CONTAINER_ID 存储容器标识符,以供以后重用:

root@crio-playground:~# export CONTAINER_ID=a2431ece5c7707f796898a887a636b1e31f69de6d71144ed97c7a97afd05d743

如果我们现在检查两个正在运行的容器的状态,会得到什么结果呢?是的,容器应该处于 created 状态:

root@crio-playground:~# runc list | transpose
         ID: 1d1a9a66b433399a4cd24a77ae7dc56ba68915baf058df26b40078e56b216eb9
        PID: 87
     STATUS: running
     BUNDLE: /run/containers/storage/vfs-containers/1d1a9a66b433399a4cd24a77ae7dc56ba68915baf058df26b40078e56b216eb9/userdata
    CREATED: 2023-11-02T11:57:36.139619785Z
      OWNER: root
-
     ID: a2431ece5c7707f796898a887a636b1e31f69de6d71144ed97c7a97afd05d743
        PID: 198
     STATUS: created
     BUNDLE: /run/containers/storage/vfs-containers/a2431ece5c7707f796898a887a636b1e31f69de6d71144ed97c7a97afd05d743/userdata
    CREATED: 2023-11-02T12:08:34.25203254Z
      OWNER: root

并且,就像我们之前的 runc 示例一样,容器等待 runc init

root@crio-playground:~# runc ps $CONTAINER_ID
UID          PID    PPID  C STIME TTY          TIME CMD
root         198     190  0 12:08 ?        00:00:00 /usr/lib/cri-o-runc/sbin/runc init

crictl 也显示了容器:

root@crio-playground:~# crictl ps -a | transpose
  CONTAINER: a2431ece5c770
      IMAGE: nginx:alpine
    CREATED: 2 minutes ago
      STATE: Created
       NAME: container
    ATTEMPT: 0
     POD ID: 1d1a9a66b4333
        POD: unknown

现在我们必须启动工作负载以使其进入状态 running ,并验证所有进程是否都正常运行:

root@crio-playground:~# crictl start $CONTAINER_ID
a2431ece5c7707f796898a887a636b1e31f69de6d71144ed97c7a97afd05d743
root@crio-playground:~# crictl ps | transpose
  CONTAINER: a2431ece5c770
      IMAGE: nginx:alpine
    CREATED: 2 minutes ago
      STATE: Running
       NAME: container
    ATTEMPT: 0
     POD ID: 1d1a9a66b4333
        POD: unknown

现在容器内应该运行一个 nginx Web 服务器:

root@crio-playground:~# runc ps $CONTAINER_ID
UID          PID    PPID  C STIME TTY          TIME CMD
root         198     190  0 12:08 ?        00:00:00 nginx: master process nginx -g daemon off;
systemd+     273     198  0 12:11 ?        00:00:00 nginx: worker process

但现在如何访问网络服务器呢?我们没有暴露容器的任何端口,也没有进行其他高级配置,因此它应该与主机相当隔离。解决方案在于容器网络:我们在 crio-playground 中使用桥接网络配置,所以我们可以简单地访问容器网络地址。具体地,在 /etc/crio/crio.conf 文件中配置 net.d 目录,在 /etc/cni/net.d/10-crio-bridge.conf 当中指明网络类型是桥接。

crio-playground-network-config

容器具体的网络地址,我们可以通过 exec 进入容器执行指令,列出网络接口:

root@crio-playground:~# crictl exec $CONTAINER_ID ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
3: eth0@if4: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP
    link/ether 02:85:47:19:10:2d brd ff:ff:ff:ff:ff:ff
    inet 172.0.0.2/16 brd 172.0.255.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::85:47ff:fe19:102d/64 scope link
       valid_lft forever preferred_lft forever

现在只需查询 inet 地址 eth0

root@crio-playground:~# curl 172.0.0.2 2>/dev/null |  head -5
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>

**我们在没有运行 Kubernetes 的情况下,成功运行了 Kubernetes 工作负载!**这里通过一张图,简单总结一下我们上述操作前后,宿主机、容器、Pod的一些关系:

demystifying-container

关于Kubernetes 的网络插件或容器网络接口 (CNI) 的相关内容,后续有机会再写一篇博文。本文提供的镜像时,涉及在Ubuntu上安装CRI-O环境,也有一些总结。这里为了不带跑偏题目,就都不做展开。

5. 结论

这是容器揭秘的博客系列的第二篇。我们介绍了容器运行时的简史,并有尝试使用低级别运行时 runc 以及更高级别运行时 CRI-O 来运行容器。非常建议读者仔细查看 OCI 运行时规范,并在 crio-playground 环境中测试不同的配置。后续文章中,当我们谈论安全或网络等容器相关主题时,我们将来会再次看到 CRI-O。

除此之外,我们还尝试了 podmanbuildahskopeo 等不同的工具,它们提供更先进的容器管理解决方案。

真诚地希望您喜欢这篇文章,并继续关注后续文章。

参考资料