本文章为对 Litchi Pi
的《Writing a container in Rust》的翻译转载,不享受任何著作权利,不用于任何商业目的,不以任何许可证进行授权,不对任何转载行为尤其是商业转载行为负责。一切权利均由原作者 Litchi Pi 保有。个人翻译能力有限,如有疑问可查看原文。
子进程的诞生
在这篇文章中,我们将把我们容器的父进程克隆成一个子进程。
在这之前,我们将通过准备一些进程间通信IPC(inter-process communication)通道来做好基础准备,这样就可以与后续创建的子进程进行交互了。
通过sockets进行进程间通信(IPC)
IPC 介绍
Unix domain sockets 或者叫同主机socket通信是进程间通信(简称IPC)的解决方案,它们与用于与远程主机进行网络操作的”网络socket通信”类型的套接字有所不同。
关于套接字和IPC的的内容你可以在这篇文章中查阅。在实际操作中,它就是一个文件(Unix哲学:一切皆文件)。我们将在这个文件中进行读取或写入,以便将信息从一个进程传输到另一个进程。
对于我们的工具而言。我们并不需要任何复杂的IPC,只希望它能够简单的将布尔值从我们的子进程传入传出即可。
创建socketpair
创建一对sockets,一端交给子进程而另一端留给父进程。

这样我们就能够将原始二进制数据从一个进程传输到另一个进程,就像我们将二进制数据写入存储在文件系统中的文件一样。
让我们创建一个包含了所有进程间通信相关内容的新文件src/cli.rs
:
1 | use crate::errors::Errcode; |
我们创建了一个generate_socketpair
函数,在这个函数中我们调用了socketpair
函数,这是在Unix中创建socket pair的标准方式,只不过我们是通过Rust进行调用的。
AddressFamily::Unix
:指定我们使用的是协议族类型为Unix domain socket(详见all AddressFamily variants for details)SockType::SeqPacket
:socket将使用具有包和固定长度数据报文的语义通信(Semantic Communication)。(详见all SockType variants for details)None
:socket将使用与socket类型相关的默认协议。SockFlag::SOCK_CLOEXEC
:在执行任何exec
家族的系统调用后,socket将自动关闭。(详见Linux manual forexec
syscalls)
我们使用了新的错误Errcode::SocketError
,让我们把它加到src/errors.rs
中:
1 | pub enum Errcode{ |
将sockets添加到容器配置中
在创建配置时,让我们生成socket pair,将其添加到ContainerOpts
数据中,让子进程可以轻松地访问它。修改src/config.rs
文件:
1 | use crate::ipc::generate_socketpair; |
同时我们还要修改ContainerOpts::new
函数,让它返回构造的ContainerOpts
结构体以及sockets,因为父容器需要访问它。
1 | impl ContainerOpts{ |
添加到容器实现中,添加清理设置
在我们的容器实现中,在Container
结构体中添加一个字段来更容易的访问sockets。修改src/container.rs
文件:
1 | use nix::unistd::close; |
sockets 在进程退出前需要进行清理处理,我们在clean_exit
函数中进行关闭sockets相关处理
1 | pub fn clean_exit(&mut self) -> Result<(), Errcode>{ |
创建IPC封装
为了简化sockets的使用,我们先创建两个封装。由于我们只传输布尔值,所以只需要在src/ipc.rs
中创建一个send_boolean
和recv_boolean
函数:
1 | pub fn send_boolean(fd: RawFd, boolean: bool) -> Result<(), Errcode> { |
这里只是与nix
库的send
和recv
函数进行一些交互,处理数据类型转换等…没有太多可说的,但是我们从Rust调用具有低级C后端的函数仍然很有趣。
我们现在暂时不会使用这些封装,但在后面这些它们会很方便。
Patch for this step
这一步的代码可以在github litchipi/crabcan branch “step7”中找到.
前一步到这一步的原始补丁可以在此处找到
克隆进程
为了将所有与克隆和管理子进程相关的内容放在一起,让我们在src/child.rs
文件中创建一个新的child
模块。首先,在src/main.rs
中定义模块:
1 | ... |
我们需要创建新的错误类型,用于处理在生成子进程或在容器内部准备过程中出现的问题,接下来将它们添加到src/errors.rs
中:
1 | pub enum Errcode { |
创建子进程
现在,我们创建一个虚拟的child函数,简单地打印传入的参数。在src/child.rs
中创建这个函数:
1 | fn child(config: ContainerOpts) -> isize { |
这个子进程只会简单打印一些内容到标准输出中,并返回0表示一切正常。我们将希望子进程处理的配置信息全都传入
接下来我们在src/child.rs
中创建一个函数,用于克隆父进程并调用子进程:
1 | use crate::errors::Errcode; |
为了更便于理解, 我们拆分一下这段代码
我们首先分配一个原始数组(缓冲区),大小为我们定义的STACK_SIZE,也就是1KiB。这个缓冲区用于保存子进程的栈(stack),请注意,这和C语言中的
clone
函数不同(细节详见nix::sched::clone文档)其次,我们将设置需要激活的flags掩码,完整的flags列表及其简单描述可以在nix::sched::CloneFlags文档中找到,或者直接在Linux的clone(2)手册中查看。此处我们跳过flags掩码单独定义,因为它们各自都需要一些适当的解释。
接着,我们调用
clone
系统调用,将其重定向到我们的child
函数,同时带上我们的config
结构体,以及进程的临时堆栈和我们设置的flags掩码作为参数。此外,我们还附带了一个指令,要求在子进程退出时向父进程发送SIGCHLD
信号。如果一切顺利,我们将得到一个进程ID,简称
PID
,这是一个独一无二的数字,用于在Linux内核中唯一标识我们的进程。我们将返回这个PID,将它存储在我们的container结构体中。
关于命名空间的说明
如果你不了解什么是linux命名空间,我建议你阅读Wikipedia相关文章来获取快速又全面的介绍。
简单来说,命名空间是Linux内核提供的一种隔离机制,它允许在这个命名空间中的进程拥有与全局不一样的独立系统资源。
- Network namespace: 拥有与整个系统不同的网络配置
- Host namespace:拥有与整个系统不同的主机名
- PID:在命名空间内使用任何PID号,包括
init
(PID = 1) - 其他
你可以查阅linux手册中的namespace部分获取更多信息
目前Linux的命名空间有:
- mount:挂载命名空间,使进程有一个独立的挂载文件系统,始于Linux 2.4.19
- ipc:ipc命名空间,使进程有一个独立的ipc,包括消息队列,共享内存和信号量,始于Linux 2.6.19
- uts:uts命名空间,使进程有一个独立的hostname和domainname,始于Linux 2.6.19
- net:network命令空间,使进程有一个独立的网络栈,始于Linux 2.6.24
- pid:pid命名空间,使进程有一个独立的pid空间,始于Linux 2.6.24
- user:user命名空间,使进程有一个独立的user空间,始于Linux 2.6.23,结束于Linux 3.8
- cgroup:cgroup命名空间,使进程有一个独立的cgroup控制组,始于Linux 4.6
容器中通常通过namespace和cgroup进行资源隔离以及分配,所以在部分情况下通过nsenter
命令(在util-linux包中)来对容器进行调试查看是一个很好的选择
如果想详细关于容器和命名空间相关的内容可以参考这两篇博文
设置flags掩码
让我们回到克隆子进程的准备工作,每个flags掩码将为子进程创建一个新的给定的命名空间。如果没有设置flag,通常子进程将成为父进程所在的命名空间的一部分。
以下是完整代码:
1 | let mut flags = CloneFlags::empty(); |
CLONE_NEWNS
将在Mount
命名空间中启动克隆的子进程,挂载列表从父进程命名空间进行拷贝。
查阅mount命名空间手册以获取更多信息。CLONE_NEWCGROUP
将在新的cgroup
命名空间中启动克隆的子进程。
稍后在教程中我们将解释cgroups,因为我们将使用它们来限制子进程的资源分配。
查阅cgroup命名空间手册以获取更多信息。CLONE_NEWPID
将在新的pid
命名空间中启动克隆的子进程。
这意味着我们的子进程会认为它有一个PID = X,但实际上在Linux内核中它的PID为另一个。
查阅pid命名空间手册以获取更多信息。CLONE_NEWIPC
将在新的ipc
命名空间中启动克隆的子进程。
在此命名空间内的进程可以相互交互,而在命名空间外的进程不能通过正常的IPC
方法交互。
查阅ipc命名空间手册以获取更多信息。CLONE_NEWNET
将在新的nerwork
命名空间中启动克隆的子进程。
它不会共享来自其他命名空间的接口和网络配置。
查阅nerwork命名空间手册以获取更多信息。CLONE_NEWUTS
将在新的uts
命名空间中启动克隆的子进程。
我无法解释为什么叫UTS(UTS代表UNIX时间共享系统),但它将允许被包含的进程在命名空间中设置自己的主机名和NIS域名。
查阅uts命名空间手册以获取更多信息。
所以,在创建我们的子进程时,我们会它的环境与系统环境隔离,允许它修改任何它想要修改的内容(至少对于使用的命名空间来说),而不会对我们的系统造成任何影响。
从容器中生成子进程
现在我们已经有了generate_child_process
函数,我们可以在我们的容器的create
函数中调用它,并将它返回的的pid
存储在结构体字段中。
在src/container.rs
中,添加以下内容:
1 | use crate::child::generate_child_process; |
等待子进程结束
现在我们的容器包含了生成新的干净的子进程所需的一切,接下来我们修改主函数以等待子进程完成。
修改src/container.rs
文件:
1 | pub fn start(args: Args) -> Result<(), Errcode> { |
容器使用我们给予的参数生成子进程,然后hold并等待子进程结束后才退出。
wait_child
函数在src/container.rs
中的定义如下:
1 | pub fn wait_child(pid: Option<Pid>) -> Result<(), Errcode>{ |
这个函数使用了waitpid
这个系统调用,以下是来自手册的解释
waitpid()系统调用会暂停调用进程的执行,直到由pid参数指定的子进程改变状态。默认情况下,waitpid()只等待已终止的子进程,但是可以通过下面描述的options参数修改这种行为。
由于我们正在等待终止,所以我们只会传递None
,并且如果系统调用没有成功完成,我们会返回一个Errcode::ContainerError
错误。
测试
也许从一开始你就在想,为什么我们需要使用sudo
来运行我们的测试。在前七步中,这并不是必要的。但是从现在开始,由于我们为子进程创建了新的命名空间,所以需要CAP_SYS_ADMIN
权限(参见权限手册或者LWN的这篇文章)。
下面是我们在测试这一步时可能得到的输出:
1 | [2021-11-12T08:52:17Z INFO crabcan] Args { debug: true, command: "/bin/bash", uid: 0, mount_dir: "./mountdir/" } |
Patch for this step
这一步的代码可以在github litchipi/crabcan branch “step8”中找到.
前一步到这一步的原始补丁可以在此处找到