Rust 中的容器运行时(第一部分)

语言: CN / TW / HK

C T O

   

   

 

Go Rust Python Istio containerd CoreDNS Envoy etcd Fluentd Harbor Helm Jaeger Kubernetes Open Policy Agent Prometheus Rook TiKV TUF Vitess Argo Buildpacks CloudEvents CNI Contour Cortex CRI-O Falco Flux gRPC KubeEdge Linkerd NATS Notary OpenTracing Operator Framework SPIFFE SPIRE     Thanos

Rust 中的容器运行时——第一部分

                         囚禁容器进程

在本系列的第 0 部分中,我们已经看到了进程如何获得它们所看到资源的受限视图。这部分将解释容器运行时如何为容器进程准备和创建隔离环境。

这部分的先决条件是了解 Linux 文件系统的工作原理,什么是 inode 符号链接 挂载点 。可以在此处找到这篇文章的完整源代码。

https://github.com/penumbra23/pura

首先,让我们从 OCI 规范开始。

操作

在撰写本文时, OCI 规范至少定义了五种标准操作:创建、启动、状态、删除和终止。记住这一点,使用 clap库 我们可以很快地生成一个不错的 CLI 界面。它应该是这样的:

clap库 : https://crates.io/crates/clap
let matches = App::new("Container Runtime")
    .subcommand(
        SubCommand::with_name("create")
            .arg(Arg::with_name("bundle").required(true))
            .arg(Arg::with_name("id").required(true)),
    )
    .subcommand(SubCommand::with_name("start").arg(Arg::with_name("id").required(true)))
    .subcommand(
        SubCommand::with_name("kill")
            .arg(Arg::with_name("id").required(true))
            .arg(Arg::with_name("signal")),
    )
    .subcommand(SubCommand::with_name("delete").arg(Arg::with_name("id").required(true)))
    .subcommand(SubCommand::with_name("state").arg(Arg::with_name("id").required(true)))
    .get_matches();

我们将主要关注 create start 命令,因为这是运行 docker run 命令时最重要的两个命令。

bundle 目录包含配置。 Json文件 包含创建容器的所有元数据:

  • ociVersion - OCI 规范的版本

  • process - 容器执行的用户定义进程(shell、数据库、Web 应用程序、gRPC 服务等),带有必要的参数和环境变量

  • root - 容器根目录的子目录路径

  • 容器的主机名

  • mounts - 容器内的挂载点列表

此外, OCI 规范包含一个特定于平台的部分,支持基于运行容器的平台的自定义设置。因为我们只研究 Linux 容器,所以 Linux 部分将对我们有用。

create 命令与容器 ID 和包路径一起提供。它的目的是初始化容器进程,挂载所有必要的子目录,将容器“监禁”在根目录中。 path 文件夹,更新容器内的所有系统变量( env、主机名、用户、组 ),执行几个钩子(稍后我们将对此进行研究),为容器本身分配惟一 ID ,并等待直到启动 start 命令。在 create 命令完成后,容器处于已创建状态,用户进程必须等待 start 命令来启动实际的容器进程。

关于实现,一切似乎都很简单,但“监禁”的部分可能会有点令人困惑。这是怎么做到的?

Chroot

Chroot 是一个系统调用,它更改调用进程的根目录。它将新的根路径作为参数,它可以是绝对路径或相对路径。来自终端的 chroot 命令做同样的事情,除了它需要一个额外的参数,即将在更改的根中执行的进程。

在我们看一个示例之前,首先我们需要准备新的 rootfs 。不幸的是,在 jail 中使用的二进制文件必须驻留在 chroot-ed 目录中(显然),因此我们需要一个预先生成的 rootfs 。幸运的是,我们可以使用我们的主机操作系统二进制文件和挂载绑定已经存在的文件,并以这样的结构结束:

Chroot: https://man7.org/linux/man-pages/man2/chroot.2.html

容器文件夹的文件和目录结构

如果您的列表不同,请不要担心,只需确保 bin 目录中有 bash ls 。我们来看看 chroot 命令(使用 sudo 运行):

正如我们所看到的,列出根目录之外的目录( ls ..)列出了被监禁的根目录,似乎我们看不到外面的任何东西。此外,列出 bin lib 目录的结果与上述示例相同。

可以说“这就是容器被监禁的方式”,然后直接从头开始构建容器。但是,事情并没有那么容易…… Chroot 不会更改文件系统,也不会更改进程看到的挂载点。它只是改变了进程根的视图,但一切都保持不变。而且,打破这个 jail 是相当容易的描述在这里。

https://deepsec.net/docs/Slides/2015/Chw00t_How_To_Break%20Out_from_Various_Chroot_Solutions_-_Bucsay_Balazs.pdf
pivot_root :https://man7.org/linux/man-pages/man2/pivot_root.2.html

另一方面, Pivot_root 做的正是我们需要的。给定当前根的新根和子目录,它将当前根移动到子目录,并将新根作为根挂载点挂载。通过这种方式,它更改了根目录的物理挂载文件夹。稍后,我们可以卸载“old”根,只留下新创建的根挂载点。我们来看一个例子。

**注: pivot_root 更改了根挂载点,可能会导致文件系统混乱,所以请务必遵循以下步骤。

首先,我们需要一个真正的 rootfs 文件系统。我们不能使用上面的例子,因为我们挂载了主机二进制文件。我们需要一个独立的目录,它可以独立存在。为此,我们将使用 Docker Alpine 容器导出一个新鲜的 rootfs 。然后我们将使用 unshare (还记得第0部分中的朋友)来创建一个新的挂载名称空间。然后我们要在容器内以根为中心。它应该是这样的:

将进程监禁在基于 apline rootfs

Docker 导出只是简单地将容器中的文件复制到主机系统的 tar 归档文件中。从 Alpine 镜像导出 rootfs 后,我们绑定挂载目录到它自己,为什么?因为根据 pivot_root 系统调用的说明, new_root 必须是与" / "不同的挂载点的路径。

在准备容器根目录之后,我们需要创建一个新的挂载命名空间,使其与我们的主机环境不同,这样 pivot_root 就不会改变主机挂载命名空间上的任何东西。我们创建一个临时文件夹来保存旧根目录,对根目录进行枢轴操作,卸载旧根目录(或使用 umount -l 解除链接),并删除旧根目录来完成交换。瞧!现在我们有一个 bash 进程在监禁的容器文件夹内运行。

Rust 代码中,用 nix crate 安装 rootfs 文件夹看起来像这样:

nix crate: https://docs.rs/nix/0.22.1/nix/
pub fn mount_rootfs(rootfs: &Path) -> Result<(), Box<dyn Error>> {
    mount(
        None::<&str>,
        "/",
        None::<&str>,
        MsFlags::MS_PRIVATE | MsFlags::MS_REC,
        None::<&str>,
    )?;

    mount::<Path, Path, str, str>(
        Some(&rootfs),
        &rootfs,
        None::<&str>,
        MsFlags::MS_BIND | MsFlags::MS_REC,
        None::<&str>,
    )?;

    Ok(())
}
      在 Rust Mount rootfs

第一个挂载将根挂载点的挂载传播更改为私有(由于明显的原因, pivot_root 不允许共享挂载)。整个过程的代码应该是这样的:

pub fn pivot_rootfs(rootfs: &Path) -> Result<(), Box<dyn Error>> {
    chdir(rootfs)?;

    std::fs::create_dir_all(rootfs.join("oldroot"))?;

    pivot_root(rootfs.as_os_str(), rootfs.join("oldroot").as_os_str())?;

    umount2("./oldroot", MntFlags::MNT_DETACH)?;

    std::fs::remove_dir_all("./oldroot")?;

    chdir("/")?;
    Ok(())
}

注意, mount_rootfs pivot_rootfs 都是在新创建的挂载命名空间中调用的。

特殊链接和安装

OCI 运行时规范定义了一组特殊的符号链接。这些符号链接用于将容器引擎( Docker, containerd )的 stdin、stdout stderr 流传递给运行时,反之亦然。它只是将容器的标准流绑定到容器进程的外部文件描述符。容器运行时需要在 pivot_root 之前建立这些符号链接。

OCI 运行时规范定义了一组需要挂载到容器中的文件系统。同时提取一些配置。来自 apline、Ubuntu、Debian /dev/pts /dev/shm 等的 json 文件都出现在运行时配置规范的挂载部分。

需要更多注意的两个重要文件系统是 proc sysfs

proc 文件系统挂载到 /proc 目录,并充当内核内部结构的接口。对于每个进程,它都有一个 /proc/[PID] 子目录,用于保存文件描述符、 cpu 和内存使用情况、挂载信息、页表和许多其他信息。例如,在没有创建 fs 之前,我们不能(使用 mount 命令)检查当前的挂载点。挂载 proc fs 的确切命令是:

mount -t proc proc /proc

所述的 sysfs 文件系统是一个伪 FS PROC 提供到内部内核对象的接口。与 proc 文件系统相反,它保存系统范围的信息,如块和字符设备的元数据、总线信息、驱动程序、控制组、内核信息和其他全局变量。挂载 sysfs proc 相同:

mount -t sysfs sysfs /sys

PROC sysfs 中需要被安装 pivot_root 之后,当新的根挂载创建点。

设备

Linux 中,一切都被视为一个文件。硬盘驱动器、外围设备甚至进程都可以通过文件描述符进行完整描述。设备也不例外。软盘、 CDROM 、串行端口以及您连接的任何设备都应出现在根目录下的 /dev 子目录中。设备有类型,大多数设备是块(存储某种类型的数据)或字符(流或传输数据到/从)设备。终端、伪随机数生成器甚至 /dev/null 文件也被视为设备。

OCI 规范定义了每个容器所需的设备, config.json linux 部分下包含一个设备列表。容器运行时负责在容器根目录中创建这些设备。创建设备的系统调用是 mknod 。此系统调用(也是终端内的命令)接受 4 个必需参数:

  • 路径名 -文件位置的完整路径

  • type - 块、字符或其他设备类型

  • 主要和次要- 设备的唯一标识符

例如,主要次要编号为 1、8 的字符设备是代表伪随机数生成器的随机设备。每当您的应用请求一个随机数时,此设备都会收到一个请求。

我们可以使用 nix mknod 函数轻松生成特殊设备,或者在绑定到主机设备( OCI 规范涵盖)的情况下使用 mount bind 选项。

结论

我们已经看到 chroot 如何更改当前进程的根目录视图,以及 pivot_root 如何交换根挂载点,从而创建文件系统的逻辑隔离。我们还了解了如何创建标准的容器设备,以及不同的容器可以在配置的 mount 部分中请求特殊的设备。 json 文件。

了解 unshare pivot_root 是如何工作的,可以让我们在终端中手动创建 Linux 容器。在接下来的部分中,我们将更深入地讨论实现。特别是关于克隆子进程和启动容器命令的准备。

5.3 参考资料

参考地址 [1]

参考资料

[1]

参考地址: https://penumbra23.medium.com/container-runtime-in-rust-part-i-7bd9a434c50a