真正运行容器的工具:深入了解 runc 和 OCI 规范

358次阅读  |  发布于3年以前

我们谈谈位于 Docker、Podman、CRI-O 和 Containerd 核心的工具:runc。

原始容器运行时

如果试图将链从最终用户绘制到实际的容器进程,它可能如下所示: runc 是一个命令行客户端,用于运行根据 Open Container Initiative (OCI) 格式打包的应用程序,并且是 Open Container Initiative 规范的兼容实现。

有一个关于如何运行容器和管理容器映像的开放容器计划(OCI) 和规范。runc 符合此规范,但还有其他符合 OCI 的运行时。甚至可以运行符合 OCI 标准的虚拟机,Kata Containers 与gVisor就是符合符合 OCI 标准的虚拟机。gVisor 为代表的用户态 Kernel 方案是安全容器的未来,只是现在还不够完善。

runc 希望提供一个“ OCI 包”,它只是一个根文件系统和一个config.json 文件。而不是Podman 或 Docker 那样有“镜像”概念,所以不能只执行**runc run nginx:latest这样来启动一个容器。**

Runc 符合 OCI 规范(具体来说,是runtime-spec),这意味着它可以使用 OCI 包并从中运行一个容器。值得重申的是,这些bundle并不是“容器镜像”,它们要简单得多。层、标签、容器注册表和存储库等功能 - 所有这些都不是 OCI 包甚至运行时规范的一部分。有一个单独的 OCI-spec (image-spec )定义镜像。

文件系统包是你下载容器镜像并解压后得到的。所以它是这样的:

OCI Image -> OCI Runtime Bundle -> OCI Runtime

在我们的例子中,这意味着:

Container image -> Root filesystem and config.json -> runc

让我们构建一个应用程序包。我们可以从 config.json 文件开始,因为这部分非常简单:

mkdir my-bundle
cd my-bundle
runc spec

runc spec生成一个虚拟的 config.json。它已经有一个“进程”部分,用于指定在容器内运行哪个进程 - 即使有几个环境变量。

{
        "ociVersion": "1.0.1-dev",
        "process": {
             "terminal": true,
                "user": {
                        "uid": 0,
                        "gid": 0
                },
                "args": [
                        "sh"
                ],
                "env": [
                        "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
                        "TERM=xterm"
                ],

...

它还定义了在哪里查找根文件系统...

...

        "root": {
                "path": "rootfs",
                "readonly": true

        },
...

...以及其他许多内容,包括容器内的默认挂载、功能、主机名等。如果检查此文件,会注意到,许多部分与平台无关,并且特定于具体操作系统的部分嵌套在适当的内部部分。例如,会注意到有一个带有 Linux 特定选项的“linux”部分。

如果我们尝试运行这个包,我们会得到一个错误:

# runc run test
rootfs (/root/my-bundle/rootfs) does not exist

如果我们简单地创建文件夹,我们会得到另一个错误:

# mkdir rootfs
# runc run test
container_linux.go:345: starting container process caused "exec: \"sh\": executable file not found in $PATH"

这完全有道理 - 空文件夹并不是真正有用的根文件系统,我们的容器没有机会做任何有用的事情。我们需要创建一个真正的 Linux 根文件系统。这里可以使用如下命令解压rootfs:

$ docker export $(docker create busybox) | tar -C /mycontainer/rootfs -xvf -

这里我们使用skopeo 和 umoci 获取 OCI 应用程序包。

如何使用 skopeo 和 umoci 获取 OCI 应用程序包

从头开始创建 rootfilesystem 是一种相当麻烦的事情,因此让我们使用现有的最小映像之一 busybox。

要拉取镜像,我们首先需要安装skopeo。我们也可以使用 Buildah,但它的功能太多,无法满足我们的需求。Buildah 专注于构建镜像,甚至具有运行容器的基本功能。由于我们今天尽可能地低级别,我们将使用 skopeo:

yum install skopeo -y

然后复制busybox镜像:

skopeo copy docker://busybox:latest oci:busybox:latest

没有“拉取”——我们需要告诉 skopeo 镜像的来源和目的地。skopeo 支持几乎十几种不同类型的来源和目的地。请注意,此命令将创建一个新busybox文件夹,将在其中找到所有 OCI 镜像文件,具有不同的镜像层、清单等。

不要混淆 Image manifest 和 Application runtime bundle manifest,它们是不一样的。

我们复制的是一个 OCI Image,但是我们已经知道,runc 需要 OCI Runtime Bundle。我们需要一个将镜像转换为解压包的工具。这个工具将是umoci - 一个 openSUSE 实用程序,其唯一目的是操作 OCI 镜像。要安装它,请从 Github Releases获取最新版本的PATH。在撰写本文时,最新版本是0.4.5. umoci unpack获取 OCI 镜像并从中制作一个包:

umoci unpack --image busybox:latest bundle

让我们看看bundle文件夹里面有什么:

# ls bundle
config.json
rootfs
sha256_73c6c5e21d7d3467437633012becf19e632b2589234d7c6d0560083e1c70cd23.mtree
umoci.json

让我们将rootfs目录复制到之前创建的my-bundle目录。如果你好奇,这是rootfs的内容,如下:

bin  dev  etc  home  root  tmp  usr  var

如果它看起来像一个基本的 Linux 根文件系统,那么就是对的。

根据 OCI Runtime 规范,Linux ABI 下的应用程序会期望 Linux 环境提供以下特殊的文件系统:

这几个文件夹的作用这里略去,有兴趣的读者可以自行查阅 man7.org。runc 文档中还额外要求提供:

runc 是 OCI Runtime 规范的参考实现,规范为容器的创建提供了整洁的接口,只需要为 runc 提供一份 config.json [1]。

使用 runc 运行 OCI 应用程序包

我们准备好将我们的应用程序包作为名为 的容器运行test:

runc run test

接下来发生的事情是我们最终进入了一个新创建的容器内的 shell!

# runc run test
/ # ls
bin   dev   etc   home  proc  root  sys   tmp   usr   var

我们以默认foreground模式运行前一个容器。在这种模式下,每个容器进程都成为一个长时间运行的runc进程的子进程:

6801   997  \_ sshd: root [priv]
6805  6801      \_ sshd: root@pts/1
6806  6805          \_ -bash
6825  6806              \_ zsh
7342  6825                  \_ runc run test
7360  7342                  |   \_ runc run test

如果我终止与该服务器的 ssh 会话,runc 进程也会终止,最终杀死容器进程。让我们通过sleep infinite在 config.json 中替换 command并将终端选项设置为“false”来更仔细地检查这个容器。

runc不提供大量的命令行参数。它有类似start,stop和 run的命令来做容器的生命周期管理,但是容器的配置总是来自文件,而不是来自命令行:

{
        "ociVersion": "1.0.1-dev",
        "process": {
                "terminal": false,
                "user": {
                        "uid": 0,
                        "gid": 0
                },
                "args": [
                        "sleep",
                        "infinite"
                ]
...

这次让我们以分离模式运行容器:

runc run test --detach

我们可以看到正在运行的容器runc list:

ID          PID         STATUS      BUNDLE            CREATED                          OWNER
test        4258        running     /root/my-bundle   2020-04-23T20:29:39.371137097Z   root

在 Docker 的情况下,有一个Docker Daemon守护进程知道关于容器的一切。runc 如何找到我们的容器?事实证明,它只是在文件系统上保持状态,默认情况下在里面/run/runc/CONTAINER_NAME/state.json:

# cat /run/runc/test/state.json
{"id":"test","init_process_pid":4258,"init_process_start":9561183,"created":"2020-04-23T20:29:39.371137097Z","config":{"no_pivot_root":false,"parent_death_signal":0,"rootfs":"/root/my-bundle/rootfs","readonlyfs":true,"rootPropagation":0,"mounts"....

当我们在分离模式下运行时,原始runc run命令(不再有这样的进程)和这个容器进程之间没有关系。如果我们查看进程表,我们会看到容器的父进程是PID 1:

# ps axfo pid,ppid,command
4258     1 sleep infinite

Docker、containerd、CRI-O 等使用分离模式。它的目的是简化 runc 和全功能容器管理工具之间的集成。值得一提的是 runc 本身并不是某种类型的库——它是一个 CLI。当其他工具使用 runc 时,它们会调用我们刚刚在操作中看到的相同 runc 命令。

在runc 文档中阅读有关前台模式和分离模式之间差异的更多信息。虽然容器进程的PID是4258,但在容器内部PID显示为1:

# runc exec test ps                     
PID   USER     TIME  COMMAND
    1 root      0:00 sleep infinite
   13 root      0:00 ps

这要归功于Linux 命名空间,它是真正的容器背后的基本技术之一。我们可以通过lsns在主机系统上执行来列出所有当前的命名空间 :

# lsns
NS TYPE   NPROCS   PID USER COMMAND
4026532219 mnt         1  4258 root sleep infinite
4026532220 uts         1  4258 root sleep infinite
4026532221 ipc         1  4258 root sleep infinite
4026532222 pid         1  4258 root sleep infinite
4026532224 net         1  4258 root sleep infinite

runc 负责我们容器进程的进程、网络、挂载和其他命名空间。

容器世界的影子统治者

Podman、Docker 和所有其他工具,包括在那里运行的大多数 Kubernetes 集群,都归结为runc启动容器进程的二进制文件。

在实际工作中,几乎永远不会做我刚刚给你展示的事情 - 除非正在开发或者调试自己的或现有的容器工具。不能从容器映像中组装应用程序包,并且使用 Podman 而不是直接使用 runc 会更好。

runc就是Low-Level实现的实现,我们了解幕后发生的事情以及运行容器真正涉及的内容是非常有帮助的。最终用户和最终容器过程之间仍然有很多层,但是如果了解最后一层,那么容器将不再是神奇的东西,有时也很奇怪。最后你会发现容器它只是 runc 在命名空间中生成一个进程。当然最后一层是Linux内核,相比宇宙中有无数层。

runc 最重要的部分是它跟踪 OCI运行时规范。尽管几乎每一个容器,这些天与runc催生,它不具有与runc催生。可以将其与遵循运行时规范的任何其他容器运行时交换,并且容器引擎(如 CRI-O)应该以相同的方式工作。

High-Level容器运行时可以不依赖于 runc 本身。它们依赖于一些遵循 OCI 规范的容器运行时。这是当今容器世界真正美丽的部分。

reference

[1]https://github.com/opencontainers/runtime-spec/blob/master/config.md

https://mkdev.me/en/posts/the-tool-that-really-runs-your-containers-deep-dive-into-runc-and-oci-specifications

https://github.com/opencontainers/runc/blob/master/docs/terminals.md

https://katacontainers.io/

https://polyverse.com/blog/skopeo-the-best-container-tool-you-need-to-know-about/

https://umo.ci/quick-start/workflow/

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8