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

  1. 1. 定义容器环境
    1. 1.1. 设置容器的主机名称
      1. 1.1.1. 生成主机名
      2. 1.1.2. 向容器的配置中添加信息
      3. 1.1.3. 将配置应用到子进程
      4. 1.1.4. 测试
        1. 1.1.4.1. Patch for this step
    2. 1.2. 修改容器挂载点
      1. 1.2.1. 准备实现
      2. 1.2.2. 重新以private形式挂载根目录/
      3. 1.2.3. 挂载新的根目录
      4. 1.2.4. Pivot the root
      5. 1.2.5. 卸载旧根目录
        1. 1.2.5.1. 空的清理函数
      6. 1.2.6. 测试
        1. 1.2.6.1. Patch for this step

本文章为对 Litchi Pi《Writing a container in Rust》的翻译转载,不享受任何著作权利,不用于任何商业目的,不以任何许可证进行授权,不对任何转载行为尤其是商业转载行为负责。一切权利均由原作者 Litchi Pi 保有。个人翻译能力有限,如有疑问可查看原文。

定义容器环境

设置容器的主机名称

主机名是我们的机器在同一网络中与其他机器相区别的标识。
许多不同的网络软件都会使用主机名,例如avahi这个软件就会在本地网络中广播我们的主机名,使得命令ssh crab@192.168.0.42可以变为ssh crab@crabcan.local,网站http://localhost:80可以变为http://crabcan.local等等…

如果想了解更多信息可以访问avahi官方网站

为了区分我们的容器软件执行的操作和宿主机系统执行的操作,我们接下来要修改容器的主机名。

生成主机名

首先,我们创建一个新文件src/hostname.rs,所有与主机名相关的代码都会在这完成。
在文件内部,设置两个预定义的名字和形容词数组,我们将联合使用这两个数组来生成一个滑稽的随机主机名。

1
2
3
4
5
6
7
8
9
const HOSTNAME_NAMES: [&'static str; 8] = [
"cat", "world", "coffee", "girl",
"man", "book", "pinguin", "moon"];

const HOSTNAME_ADJ: [&'static str; 16] = [
"blue", "red", "green", "yellow",
"big", "small", "tall", "thin",
"round", "square", "triangular", "weird",
"noisy", "silent", "soft", "irregular"];

接下来我们随机生成一些字符串

1
2
3
4
5
6
7
8
9
10
use rand::Rng;
use rand::seq::SliceRandom;

pub fn generate_hostname() -> Result<String, Errcode> {
let mut rng = rand::thread_rng();
let num = rng.gen::<u8>();
let name = HOSTNAME_NAMES.choose(&mut rng).ok_or(Errcode::RngError)?;
let adj = HOSTNAME_ADJ.choose(&mut rng).ok_or(Errcode::RngError)?;
Ok(format!("{}-{}-{}", adj, name, num))
}

我们会得到形如 square-moon-64、big-pinguin-2的主机名。

由于我们使用了新的Errcode::RngError来处理与随机函数相关的错误,我们需要在src/errors.rs中的Errcode枚举中添加这个变体,以及另一个稍后会用到的变体 Errcode::HostnameError(u8)

1
2
3
4
5
pub enum Errcode {
// ...
HostnameError(u8),
RngError
}

此外,我们使用rand包来随机生成主机名,所以我们需要将它添加到Cargo.toml的依赖中:

1
2
3
[dependencies]
# ...
rand = "0.8.4"
译者注

关于Docker容器的名称,实际上名称的生成方式和这个rust容器差不多,由随机形容词+随机科学家/黑客的名字组成,不过这个名称也存在一个小彩蛋,在容器名称中不存在
boring_wozniak这个名字,因为Steve Wozniak is not boring:

1
2
3
4
5
name := left[rand.Intn(len(left))] + "_" + right[rand.Intn(len(right))] //nolint:gosec // G404: Use of weak random number generator (math/rand instead of crypto/rand)
if name == "boring_wozniak" /* Steve Wozniak is not boring */ {
goto begin
}

具体参见names-generator.go代码

向容器的配置中添加信息

现在,我们已经有了一种生成包含我们”随机”主机名的String的方法,我们可以用它来在src/config.rs中设置我们的容器配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use crate::hostname::generate_hostname;

pub struct ContainerOpts{
// ...
pub hostname: String,
}

// ...

impl ContainerOpts{
pub fn new(...) -> ... {
// ...

Ok((
ContainerOpts {
// ...
hostname: generate_hostname()?,
},
// ...
))
}
}

最后,我们在src/hostname.rs中创建一个函数,该函数将使用sethostname系统调用,用新的主机名修改我们host namespace的实际主机名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use crate::errors::Errcode;

use nix::unistd::sethostname;

pub fn set_container_hostname(hostname: &String) -> Result<(), Errcode> {
match sethostname(hostname){
Ok(_) => {
log::debug!("Container hostname is now {}", hostname);
Ok(())
},
Err(_) => {
log::error!("Cannot set hostname {} for container", hostname);
Err(Errcode::HostnameError(0))
}
}
}

你可以查阅linux手册来获取更多有关sethostname系统调用的信息

将配置应用到子进程

对于将应用于子进程的配置,我们封装一个函数来进行所有内容的设置,并在其中添加set_container_hostname函数的调用,让我们先来修改src/child.rs

1
2
3
4
5
6
use crate::hostname::set_container_hostname;

fn setup_container_configurations(config: &ContainerOpts) -> Result<(), Errcode> {
set_container_hostname(&config.hostname)?;
Ok(())
}

然后我们只需在子进程开始时调用这个配置函数:

1
2
3
4
5
6
7
8
9
10
fn child(config: ContainerOpts) -> isize {
match setup_container_configurations(&config) {
Ok(_) => log::info!("Container set up successfully"),
Err(e) => {
log::error!("Error while configuring container: {:?}", e);
return -1;
}
}
// ...
}

请注意,我们无法从“恢复”在子进程中发生的错误,如果出现问题,我们只以retcode = -1退出,并附带一个友好的错误消息。
这里要做的最后一件事是在src/main.rs中添加我们刚刚创建的hostname模块:

1
2
// ...
mod hostname;

测试

在测试时,我们能看见我们生成的容器名称出现在日志中:

1
2
3
4
5
6
7
8
9
10
11
12
[2021-11-15T09:07:38Z INFO  crabcan] Args { debug: true, command: "/bin/bash", uid: 0, mount_dir: "./mountdir/" }
[2021-11-15T09:07:38Z DEBUG crabcan::container] Linux release: 5.13.0-21-generic
[2021-11-15T09:07:38Z DEBUG crabcan::container] Container sockets: (3, 4)
[2021-11-15T09:07:38Z DEBUG crabcan::container] Creation finished
[2021-11-15T09:07:38Z DEBUG crabcan::container] Container child PID: Some(Pid(26003))
[2021-11-15T09:07:38Z DEBUG crabcan::container] Waiting for child (pid 26003) to finish
[2021-11-15T09:07:38Z DEBUG crabcan::hostname] Container hostname is now weird-moon-191
[2021-11-15T09:07:38Z INFO crabcan::child] Container set up successfully
[2021-11-15T09:07:38Z INFO crabcan::child] Starting container with command /bin/bash and args ["/bin/bash"]
[2021-11-15T09:07:38Z DEBUG crabcan::container] Finished, cleaning & exit
[2021-11-15T09:07:38Z DEBUG crabcan::container] Cleaning container
[2021-11-15T09:07:38Z DEBUG crabcan::errors] Exit without any error, returning 0

我们多运行几次这个程序,它会输出不同的有趣名字:D

1
2
3
4
5
[2021-11-15T09:08:33Z DEBUG crabcan::hostname] Container hostname is now round-cat-221

[2021-11-15T09:08:48Z DEBUG crabcan::hostname] Container hostname is now silent-man-45

[2021-11-15T09:09:01Z DEBUG crabcan::hostname] Container hostname is now soft-cat-149

Patch for this step

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

修改容器挂载点

挂载点是我们将作为容器根目录(即容器的/)挂载的某个容器目录,用户可以将一个目录作为参数传递,该目录将被用作容器的根目录。

该过程分为以下几步:

  • 在容器内部挂载系统根目录 /
  • 创建一个新的临时目录 /tmp/crabcan.<random_string>
  • 将用户给定的目录挂载到临时目录
  • 在两个已挂载的目录之间执行root pivot
  • 卸载并删除不必要的目录

请记住,我们在容器内部挂载/卸载的所有内容都通过mountnamespace从系统的其余部分隔离开来。
在实践中,这种隔离保持了 /proc/<pid>/mountinfo/proc/<pid>/mountstats /proc/<pid>/mounts/ 的独立,它们描述了什么在哪里挂载,如何挂载等信息。

你可以查阅proc(5) linux manual或者mount_namespace linux manual来获取更多精确信息

译者注

pivot_root是由 Linux 提供的一种系统调用,它能够将一个mount namespace中的所有进程的根目录和当前工作目录切换到一个新的目录。
chroot不同的是,chroot只会改变当前进程的根目录。

准备实现

接下来我们将创建一个包含setmountpoint函数的src/mounts.rs文件,让我们先完成所有的准备工作,以便稍后专注于完成我们的目录部分。

让我们将setmountpoint函数作为容器配置过程的一部分添加到src/child.rs中:

1
2
3
4
5
6
use crate::mounts::setmountpoint;
fn setup_container_configurations(config: &ContainerOpts) -> Result<(), Errcode> {
// ...
setmountpoint(&config.mount_dir)?;
Ok(())
}

然后,在src/container.rs中的clean_exit函数中添加一个新的部分:

1
2
3
4
5
6
7
8
9
use crate::mounts::clean_mounts;

impl Container {
// ...
pub fn clean_exit(&mut self) -> Result<(), Errcode>{
// ...
clean_mounts(&self.config.mount_dir)?;
}
}

接着,我们在src/errors.rs中的Errcode枚举中添加一个新的错误类型:

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

最后,在src/main.rs中引用src/mounts.rs模块

1
2
// ...
mod mounts;

重新以private形式挂载根目录/

现在真正的重头戏来了!让我们创建src/mounts.rs文件,并添加以下内容:

1
2
3
4
5
6
7
8
9
10
11
use crate::errors::Errcode;
use std::path::PathBuf;

pub fn setmountpoint(mount_dir: &PathBuf) -> Result<(), Errcode> {
log::debug!("Setting mount points ...");
Ok(())
}

pub fn clean_mounts(_rootpath: &PathBuf) -> Result<(), Errcode>{
Ok(())
}

我们使用MS_PRIVATE这个标志来重新挂载我们文件系统的根目录/,这将阻止任何挂载操作的传播。

请参阅这篇LWN 文章,以获取更多关于MS_PRIVATE标志的解释,在另一篇LWN 文章中可以获得示例。

为了实现这个目标,我们将创建一个名为mount_directory的函数,它本质上是对nix库提供的mount系统调用的封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use nix::mount::{mount, MsFlags};

pub fn mount_directory(path: Option<&PathBuf>, mount_point: &PathBuf, flags: Vec<MsFlags>) -> Result<(), Errcode>{
// Setting up the mount flags
let mut ms_flags = MsFlags::empty();
for f in flags.iter(){
ms_flags.insert(*f);
}
// Calling the syscall, handling errors
match mount::<PathBuf, PathBuf, PathBuf, PathBuf>(path, mount_point, None, ms_flags, None) {
Ok(_) => Ok(()),
Err(e) => {
if let Some(p) = path{
log::error!("Cannot mount {} to {}: {}",
p.to_str().unwrap(), mount_point.to_str().unwrap(), e);
}else{
log::error!("Cannot remount {}: {}",
mount_point.to_str().unwrap(), e);
}
Err(Errcode::MountsError(3))
}
}
}

我们需要在setmountpoint函数中调用它:

1
2
3
4
5
pub fn setmountpoint(mount_dir: &PathBuf) -> Result<(), Errcode> {
// ...
mount_directory(None, &PathBuf::from("/"), vec![MsFlags::MS_REC, MsFlags::MS_PRIVATE])?;
// ...
}

挂载新的根目录

现在,让我们挂载用户提供的目录,以便稍后进行pivot root操作。我不会对每一行代码做详细解释,因为这只是简单地调用库函数。

首先,我们创建一个random_string函数,它返回的,嗯,就是一个随机字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Taken from https://rust-lang-nursery.github.io/rust-cookbook/algorithms/randomness.html
use rand::Rng;

pub fn random_string(n: usize) -> String {
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\
abcdefghijklmnopqrstuvwxyz\
0123456789";
let mut rng = rand::thread_rng();

let name: String = (0..n)
.map(|_| {
let idx = rng.gen_range(0..CHARSET.len());
CHARSET[idx] as char
})
.collect();

name
}

我们可以通过这个函数简单的得到一个随机目录名称,在得到这个名称后我们可以创建这个目录:

1
2
3
4
5
6
7
8
9
10
11
use std::fs::create_dir_all;

pub fn create_directory(path: &PathBuf) -> Result<(), Errcode>{
match create_dir_all(path) {
Err(e) => {
log::error!("Cannot create directory {}: {}", path.to_str().unwrap(), e);
Err(Errcode::MountsError(2))
},
Ok(_) => Ok(())
}
}

最后,在`setmountpoint·函数中将所有内容整合起来:

1
2
3
4
5
6
7
8
pub fn setmountpoint(mount_dir: &PathBuf) -> Result<(), Errcode> {
// ...
let new_root = PathBuf::from(format!("/tmp/crabcan.{}", random_string(12)));
log::debug!("Mounting temp directory {}", new_root.as_path().to_str().unwrap());
create_directory(&new_root)?;
mount_directory(Some(&mount_dir), &new_root, vec![MsFlags::MS_BIND, MsFlags::MS_PRIVATE])?;
// ...
}

我们将用户提供的mount_dir挂载到了挂载点/tmp/crabcan.<random_letters>,这将使我们能够在后面进行pivot root操作,并像使用系统真实的根目录一样使用mount_dir

Pivot the root

现在我们可以见识一下真正的魔术了!我们将/tmp/crabcan.<random_letters>设置为我们的新的/根文件系统,并将旧的/移动到新的目录 /tmp/crabcan.<random_letters>/oldroot.<random_letters>
例:

1
2
3
容器外                                          容器内
~/container_dir == mount ==> /tmp/crabcan.12345 == pivot ==> /
/ == pivot ==> /oldroot.54321

请参阅Linux 手册,以获取此过程的详细解释。
以下是代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
use nix::unistd::pivot_root;

pub fn setmountpoint(mount_dir: &PathBuf) -> Result<(), Errcode> {
// ...
log::debug!("Pivoting root");
let old_root_tail = format!("oldroot.{}", random_string(6));
let put_old = new_root.join(PathBuf::from(old_root_tail.clone()));
create_directory(&put_old)?;
if let Err(_) = pivot_root(&new_root, &put_old) {
return Err(Errcode::MountsError(4));
}
// ...
}

卸载旧根目录

我们希望容器与主机系统实现隔离,所以必须卸载”旧的根目录”,这样被容器化的应用程序就无法访问整个文件系统。
为了实现这一点,我们创建了unmount_pathdelete_dir函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use nix::mount::{umount2, MntFlags};

pub fn unmount_path(path: &PathBuf) -> Result<(), Errcode>{
match umount2(path, MntFlags::MNT_DETACH){
Ok(_) => Ok(()),
Err(e) => {
log::error!("Unable to umount {}: {}", path.to_str().unwrap(), e);
Err(Errcode::MountsError(0))
}
}
}

use std::fs::remove_dir;

pub fn delete_dir(path: &PathBuf) -> Result<(), Errcode>{
match remove_dir(path.as_path()){
Ok(_) => Ok(()),
Err(e) => {
log::error!("Unable to delete directory {}: {}", path.to_str().unwrap(), e);
Err(Errcode::MountsError(1))
}
}
}

接下来简单的在setmountpoint函数的末尾调用它们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use nix::unistd::chdir;

pub fn setmountpoint(mount_dir: &PathBuf) -> Result<(), Errcode> {
// ...
log::debug!("Unmounting old root");
let old_root = PathBuf::from(format!("/{}", old_root_tail));

// Ensure we are not inside the directory we want to umount
if let Err(_) = chdir(&PathBuf::from("/")) {
return Err(Errcode::MountsError(5));
}
unmount_path(&old_root)?;
delete_dir(&old_root)?;
// ...
}

注意:我们卸载并删除了/oldroot.<random_letters>目录,因为它位于/tmp/crabcan.<random_letters>目录内,而后者已经成为了我们的新根目录。

空的清理函数

你可能已经注意到,现在的clean_mounts函数完全没有用。主要的问题在于,父容器完全不知道用户提供的目录被挂载在哪里(因为它是一个随机生成的文件名)。
它现在造成的唯一真正问题是,所有创建的/tmp/crabcan.<random_letters>目录在执行后仍然存在,即使在被容器化的进程退出后它们是空的并已经被卸载。
为了简单期间(或者说懒),我简单的保留了这些目录,但保留了一个清理函数的占位符,以防万一。

测试

在测试时,我们可以看到一个位于/tmp/crabcan.<random_letters>的新的root目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[2022-01-04T06:50:25Z INFO  crabcan] Args { debug: true, command: "/bin/bash", uid: 0, mount_dir: "./mountdir/" }
[2022-01-04T06:50:25Z DEBUG crabcan::container] Linux release: 5.13.0-22-generic
[2022-01-04T06:50:25Z DEBUG crabcan::container] Container sockets: (3, 4)
[2022-01-04T06:50:25Z DEBUG crabcan::container] Creation finished
[2022-01-04T06:50:25Z DEBUG crabcan::container] Container child PID: Some(Pid(324564))
[2022-01-04T06:50:25Z DEBUG crabcan::container] Waiting for child (pid 324564) to finish
[2022-01-04T06:50:25Z DEBUG crabcan::hostname] Container hostname is now blue-man-109
[2022-01-04T06:50:25Z DEBUG crabcan::mounts] Setting mount points ...
[2022-01-04T06:50:25Z DEBUG crabcan::mounts] Mounting temp directory /tmp/crabcan.wYGDJtGIKxZ4
[2022-01-04T06:50:25Z DEBUG crabcan::mounts] Pivoting root
[2022-01-04T06:50:25Z DEBUG crabcan::mounts] Unmounting old root
[2022-01-04T06:50:25Z INFO crabcan::child] Container set up successfully
[2022-01-04T06:50:25Z INFO crabcan::child] Starting container with command /bin/bash and args ["/bin/bash"]
[2022-01-04T06:50:25Z DEBUG crabcan::container] Finished, cleaning & exit
[2022-01-04T06:50:25Z DEBUG crabcan::container] Cleaning container
[2022-01-04T06:50:25Z DEBUG crabcan::errors] Exit without any error, returning 0

Patch for this step

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