【译】使用rust来写一个容器(四)

  1. 1. 子进程的诞生
    1. 1.1. 通过sockets进行进程间通信(IPC)
      1. 1.1.1. IPC 介绍
      2. 1.1.2. 创建socketpair
      3. 1.1.3. 将sockets添加到容器配置中
      4. 1.1.4. 添加到容器实现中,添加清理设置
      5. 1.1.5. 创建IPC封装
        1. 1.1.5.1. Patch for this step
    2. 1.2. 克隆进程
      1. 1.2.1. 创建子进程
        1. 1.2.1.1. 关于命名空间的说明
        2. 1.2.1.2. 设置flags掩码
      2. 1.2.2. 从容器中生成子进程
      3. 1.2.3. 等待子进程结束
      4. 1.2.4. 测试
        1. 1.2.4.1. Patch for this step

本文章为对 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use crate::errors::Errcode;

use std::os::unix::io::RawFd;
use nix::sys::socket::{socketpair, AddressFamily, SockType, SockFlag, send, MsgFlags, recv};

pub fn generate_socketpair() -> Result<(RawFd, RawFd), Errcode> {
match socketpair(
AddressFamily::Unix,
SockType::SeqPacket,
None,
SockFlag::SOCK_CLOEXEC)
{
Ok(res) => Ok(res),
Err(_) => Err(Errcode::SocketError(0))
}
}

我们创建了一个generate_socketpair函数,在这个函数中我们调用了socketpair函数,这是在Unix中创建socket pair的标准方式,只不过我们是通过Rust进行调用的。

我们使用了新的错误Errcode::SocketError,让我们把它加到src/errors.rs中:

1
2
3
4
pub enum Errcode{
// ...
SocketError(u8),
}

将sockets添加到容器配置中

在创建配置时,让我们生成socket pair,将其添加到ContainerOpts数据中,让子进程可以轻松地访问它。修改src/config.rs文件:

1
2
3
4
5
6
7
8
9
10
use crate::ipc::generate_socketpair;

// ...
use std::os::unix::io::RawFd;
#[derive(Clone)]
pub struct ContainerOpts{
// ...
pub fd: RawFd,
// ...
}

同时我们还要修改ContainerOpts::new函数,让它返回构造的ContainerOpts结构体以及sockets,因为父容器需要访问它。

1
2
3
4
5
6
7
8
9
10
11
12
13
impl ContainerOpts{
pub fn new(command: String, uid: u32, mount_dir: PathBuf) -> Result<(ContainerOpts, (RawFd, RawFd)), Errcode> {
let sockets = generate_socketpair()?;
// ...
Ok((
ContainerOpts {
// ...
fd: sockets.1.clone(),
},
sockets
))
}
}

添加到容器实现中,添加清理设置

在我们的容器实现中,在Container结构体中添加一个字段来更容易的访问sockets。修改src/container.rs文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use nix::unistd::close;
use std::os::unix::io::RawFd;
// ...

pub struct Container{
sockets: (RawFd, RawFd),
config: ContainerOpts,
}

impl Container {
pub fn new(args: Args) -> Result<Container, Errcode> {
let (config, sockets) = ContainerOpts::new(
// ...
)?;
Ok(Container {
sockets,
config,
})
}
}

sockets 在进程退出前需要进行清理处理,我们在clean_exit函数中进行关闭sockets相关处理

1
2
3
4
5
6
7
8
9
10
11
12
13
pub fn clean_exit(&mut self) -> Result<(), Errcode>{
// ...
if let Err(e) = close(self.sockets.0){
log::error!("Unable to close write socket: {:?}", e);
return Err(Errcode::SocketError(3));
}

if let Err(e) = close(self.sockets.1){
log::error!("Unable to close read socket: {:?}", e);
return Err(Errcode::SocketError(4));
}
// ...
}

创建IPC封装

为了简化sockets的使用,我们先创建两个封装。由于我们只传输布尔值,所以只需要在src/ipc.rs中创建一个send_booleanrecv_boolean函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pub fn send_boolean(fd: RawFd, boolean: bool) -> Result<(), Errcode> {
let data: [u8; 1] = [boolean.into()];
if let Err(e) = send(fd, &data, MsgFlags::empty()) {
log::error!("Cannot send boolean through socket: {:?}", e);
return Err(Errcode::SocketError(1));
};
Ok(())
}

pub fn recv_boolean(fd: RawFd) -> Result<bool, Errcode> {
let mut data: [u8; 1] = [0];
if let Err(e) = recv(fd, &mut data, MsgFlags::empty()) {
log::error!("Cannot receive boolean from socket: {:?}", e);
return Err(Errcode::SocketError(2));
}
Ok(data[0] == 1)
}

这里只是与nix库的sendrecv函数进行一些交互,处理数据类型转换等…没有太多可说的,但是我们从Rust调用具有低级C后端的函数仍然很有趣。

我们现在暂时不会使用这些封装,但在后面这些它们会很方便。

Patch for this step

这一步的代码可以在github litchipi/crabcan branch “step7”中找到.
前一步到这一步的原始补丁可以在此处找到

克隆进程

为了将所有与克隆和管理子进程相关的内容放在一起,让我们在src/child.rs文件中创建一个新的child模块。首先,在src/main.rs中定义模块:

1
2
3
...
mod config;
mod child;

我们需要创建新的错误类型,用于处理在生成子进程或在容器内部准备过程中出现的问题,接下来将它们添加到src/errors.rs中:

1
2
3
4
5
pub enum Errcode {
...
ContainerError(u8),
ChildProcessError(u8),
}

创建子进程

现在,我们创建一个虚拟的child函数,简单地打印传入的参数。在src/child.rs中创建这个函数:

1
2
3
4
fn child(config: ContainerOpts) -> isize {
log::info!("Starting container with command {} and args {:?}", config.path.to_str().unwrap(), config.argv);
0
}

这个子进程只会简单打印一些内容到标准输出中,并返回0表示一切正常。我们将希望子进程处理的配置信息全都传入

接下来我们在src/child.rs中创建一个函数,用于克隆父进程并调用子进程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use crate::errors::Errcode;
use crate::config::ContainerOpts;

use nix::unistd::Pid;
use nix::sched::clone;
use nix::sys::signal::Signal;
use nix::sched::CloneFlags;

const STACK_SIZE: usize = 1024 * 1024;

pub fn generate_child_process(config: ContainerOpts) -> Result<Pid, Errcode> {
let mut tmp_stack: [u8; STACK_SIZE] = [0; STACK_SIZE];
let mut flags = CloneFlags::empty();
// Flags definition
match clone(
Box::new(|| child(config.clone())),
&mut tmp_stack,
flags,
Some(Signal::SIGCHLD as i32)
)
{
Ok(pid) => Ok(pid),
Err(_) => Err(Errcode::ChildProcessError(0))
}
}

为了更便于理解, 我们拆分一下这段代码

  • 我们首先分配一个原始数组(缓冲区),大小为我们定义的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
2
3
4
5
6
7
let mut flags = CloneFlags::empty();
flags.insert(CloneFlags::CLONE_NEWNS);
flags.insert(CloneFlags::CLONE_NEWCGROUP);
flags.insert(CloneFlags::CLONE_NEWPID);
flags.insert(CloneFlags::CLONE_NEWIPC);
flags.insert(CloneFlags::CLONE_NEWNET);
flags.insert(CloneFlags::CLONE_NEWUTS);
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
use crate::child::generate_child_process;
use nix::unistd::Pid;
use nix::sys::wait::waitpid;

pub struct Container {
// ...
child_pid: Option<Pid>,
}

impl Container {
pub fn new(args: Args) -> Result<Container, Errcode> {
// ...
Ok(Container {
sockets,
config,
child_pid: None,
})
}

pub fn create(&mut self) -> Result<(), Errcode> {
let pid = generate_child_process(self.config.clone())?;
self.child_pid = Some(pid);
log::debug!("Creation finished");
Ok(())
}
// ...
}

等待子进程结束

现在我们的容器包含了生成新的干净的子进程所需的一切,接下来我们修改主函数以等待子进程完成。
修改src/container.rs文件:

1
2
3
4
5
6
7
8
pub fn start(args: Args) -> Result<(), Errcode> {
if let Err(e) = container.create(){
// ...
}
log::debug!("Container child PID: {:?}", container.child_pid);
wait_child(container.child_pid)?;
// ...
}

容器使用我们给予的参数生成子进程,然后hold并等待子进程结束后才退出。

wait_child函数在src/container.rs中的定义如下:

1
2
3
4
5
6
7
8
9
10
pub fn wait_child(pid: Option<Pid>) -> Result<(), Errcode>{
if let Some(child_pid) = pid {
log::debug!("Waiting for child (pid {}) to finish", child_pid);
if let Err(e) = waitpid(child_pid, None){
log::error!("Error while waiting for pid to finish: {:?}", e);
return Err(Errcode::ContainerError(1));
}
}
Ok(())
}

这个函数使用了waitpid这个系统调用,以下是来自手册的解释

waitpid()系统调用会暂停调用进程的执行,直到由pid参数指定的子进程改变状态。默认情况下,waitpid()只等待已终止的子进程,但是可以通过下面描述的options参数修改这种行为。

由于我们正在等待终止,所以我们只会传递None,并且如果系统调用没有成功完成,我们会返回一个Errcode::ContainerError错误。

测试

也许从一开始你就在想,为什么我们需要使用sudo来运行我们的测试。在前七步中,这并不是必要的。但是从现在开始,由于我们为子进程创建了新的命名空间,所以需要CAP_SYS_ADMIN权限(参见权限手册或者LWN的这篇文章)。

下面是我们在测试这一步时可能得到的输出:

1
2
3
4
5
6
7
8
9
10
[2021-11-12T08:52:17Z INFO  crabcan] Args { debug: true, command: "/bin/bash", uid: 0, mount_dir: "./mountdir/" }
[2021-11-12T08:52:17Z DEBUG crabcan::container] Linux release: 5.11.0-38-generic
[2021-11-12T08:52:17Z DEBUG crabcan::container] Container sockets: (3, 4)
[2021-11-12T08:52:17Z DEBUG crabcan::container] Creation finished
[2021-11-12T08:52:17Z DEBUG crabcan::container] Container child PID: Some(Pid(134400))
[2021-11-12T08:52:17Z DEBUG crabcan::container] Waiting for child (pid 134400) to finish
[2021-11-12T08:52:17Z INFO crabcan::child] Starting container with command /bin/bash and args ["/bin/bash"]
[2021-11-12T08:52:17Z DEBUG crabcan::container] Finished, cleaning & exit
[2021-11-12T08:52:17Z DEBUG crabcan::container] Cleaning container
[2021-11-12T08:52:17Z DEBUG crabcan::errors] Exit without any error, returning 0

Patch for this step

这一步的代码可以在github litchipi/crabcan branch “step8”中找到.
前一步到这一步的原始补丁可以在此处找到