Rustでコンテナを実装してみる(超シンプル編)

2023.06.08

Introduction

DockerFinchなど、
いまや当たり前となったコンテナ技術ですが、
これは実行されるソフトウェアとそのOSの間で仮想化する、分離された実行環境です。

OS上のコンテナエンジンは個別に隔離&構成されたコンテナを作成し、
そのコンテナ内でソフトウェアを実行します。
もしそのコンテナがクラッシュしたりリソースを使い切ったとしても、
システム全体とその他のコンテナで実行されているサービスには影響はありません。

本稿ではコンテナを作成するための基礎と、
Rustで実際にコンテナを作成するためのCLIアプリを実装します。

Environment

今回試した環境は以下のとおりです。

  • MacBook Pro (13-inch, M1, 2020)
  • OS : MacOS 12.4
  • UTM : 4.1.5
  • Rust : 1.67.0

※UTMからubuntu 23.04を起動して動作確認

About Container

コンテナとは、サーバー内でアプリを実行して管理を効率よく行うための仮想化技術です。
そのため、コンテナではアプリとその依存関係を1つにまとめて管理します。

コンテナはそれ以前の仮想化(VirtualBOXとかのホストOS型)と違い、
共有のOSを使うので従来の仮想化よりもリソース使用量と
オーバーヘッドが少なくてすみます。
これにより、コンテナを使ったアプリ開発&管理が一般的になりました。

How to implement a Container?

Dockerは使用したことがあるのですが、
「どうやって隔離された実行環境やネットワークを構築したり
リソース制限をしているのか」
ということはよく知りませんでした。

まずはコンテナがどういう仕組で動作しているのかを確認してみます。
*主にLinuxでのコンテナ型仮想化

コンテナを簡単にいうと、
「独立した名前空間を持っている、他のプロセスと実行環境がわかれているプロセス」
です。 コンテナは同じホストOSの中で動くプロセスとして存在します。
ですが、通常のプロセスは各種リソース(ファイルやCPUなど)を他のプロセスと共有していて、
互いに強く依存しています。

その状態で各プロセスを独立して動かすため、
Linuxのnamespace機能を使います。
この独立したプロセスがコンテナです。

namaspaceを使ってみる

Linuxのnamaspace(名前空間)は、 共有リソースを隔離して、
個々のプロセスが独立した専用環境を持っているかのようにみせる仕組みです。

この機能を使えば、各プロセスは自分のnamespace内においてrootとして振る舞うことができ、
そのnamespace内で独自のUserやGroupを構成することができます。

参考 : User namespaces and Linux Capabilties

namespaceを作成する方法はいくつかありますが、
ここではunshare(1)コマンドを使います。
unshareコマンドは、プロセスの名前空間を隔離するめたのコマンドです。

試しにつかってみましょう。
Linux(Ubuntu23)で動作確認してみます。

% ls -l /proc/$$/ns/pid
lrwxrwxrwx 1 myuser myuser 0 Feb 28 11:46 /proc/6208/ns/pid -> 'pid:[4026531836]'

新規PID namespaceを作成して、bashを実行します。

% sudo unshare --fork --pid --mount-proc bash

forkとpidオプションを指定して、新しい名前空間でプロセスをフォークし、
PID名前空間を作成します。
これで新しい名前空間内のプロセスは元の名前空間とは違うPIDを持ちます。
また、mount-procオプションを指定して
新しい名前空間で/procをマウンするように指定しています。
これしないとpsコマンドが動きません。

最後にbashを指定し、新しい名前空間作成後に
新しいプロセスがフォークされて、その名前空間でbashシェルが実行されます。
新しい名前空間内では、PIDやマウントポイントなどが
元のシステムと隔離されています。

#現在のpid表示
root@mytest:/home/myuser# echo $$
1

作成したnamespaceではpidが1になっています。
また、作成したnamespaceのプロセスはbashとpsだけです。

root@mytest:/home/myuser# ps -ax
    PID TTY      STAT   TIME COMMAND
      1 pts/0    S      0:00 bash
      9 pts/0    R+     0:00 ps -ax

この状態で、新しくコンソールを起動してプロセスツリーとpidをみてみます。
ほかのプロセスと隔離されているのがわかります。

% pstree -p | grep unshare
           |-sshd(841)-+-sshd(1272)---sshd(1355)---bash(1356)---sudo(6111)---unshare(6112)---bash(6113)

% sudo ls -l /proc/6113/ns/pid
lrwxrwxrwx 1 root root 0 Feb 28 11:46 /proc/6113/ns/pid -> 'pid:[4026532336]'

こんな感じで名前空間を分離する処理をRustでやってみます。

SetUp Rust Project

まずはCargoを使ってサンプルプログラムのセットアップをします。

% cargo new mini-container && cd mini-container

ちなみに、このままだとcargo build時に
seccompがないとかなんとかでエラーがでるかもしれません。 
その場合には下記コマンドでlibseccompパッケージをインストールしましょう。

#amazon linuxの場合
% sudo yum install libseccomp-devel

#ubuntuの場合
% sudo apt-get install -y libseccomp-dev

依存ライブラリに↓のものを追加します。

[dependencies]
nix = "0.26.1"
thiserror = "1.0.37"
log = "0.4.17"
simplelog = "0.12.0"
anyhow = "1.0.66"
libc = "0.2.139"
tokio = { version = "1.28.0", features = ["full"] }

ビルドしてエラーがでなければOKです。

% cargo build
・・・

Implementing Rust Code

nixを使って実装していく

Rustでunshareを実行するには、nixクレートを使います。
nixはUnixでシステムプログラミングをするときによく使うcrateで、
いろいろなシステムコールを呼び出すラッパーを提供しています。

実際の呼び出しは↓のようにします。
簡単です。

use nix::sched::{unshare, CloneFlags};

・・・

unshare(CloneFlags::CLONE_NEWNS).expect("Failed to create a new namespace");

本稿ではunshareとcloneを組み合わせて名前空間の分離を行い、
コンテナ(っぽい)動作をさせてみます。

unshareとclone、どちらもnamespaceを制御するシステムコールであり、
できることも多少かぶっているのですが、 unshareはnamespaceの制御、
cloneはプロセス制御にそれぞれ特化しています。

また、unshareはさきほどのようにコマンドとシステムコールが
それぞれ提供されてますが、
cloneはシステムコールのみです。

% whatis unshare
unshare (1)          - run program in new namespaces
unshare (2)          - disassociate parts of the process execution context

% whatis clone
clone (2)            - create a child process

main.rsにコードを記述します。
いろいろ雑だけど気にしない。
※88行目で指定しているディレクトリは、適宜書き換えて作成しておいてください

use nix::mount::*;
use nix::sys::wait::waitpid;
use nix::sys::signal::{Signal, SIGCHLD};
use nix::unistd::{chroot, execvp};
use nix::sched::{clone,unshare, CloneFlags};
use std::env::{args, set_current_dir};
use std::ffi::CString;
use std::process::Command;
use anyhow::{self};
use log::{info, debug, error};
use simplelog::*;

//ログの初期化
fn init_log() {
    CombinedLogger::init(
        vec![
            TermLogger::new(LevelFilter::Debug, Config::default(), TerminalMode::Mixed, ColorChoice::Auto),
        ]
    ).unwrap();
}

//make-privateみたいなことをする
fn mount_private() -> anyhow::Result<(), nix::Error> {
    let result = mount(
        Some("none"),
        "/",
        None::<&str>,
        //MS_REC:指定したマウントポイント以下すべてにマウント操作が再帰的に適用
        //MS_PRIVATE:プライベートなマウント名前空間を作成
        MsFlags::MS_REC | MsFlags::MS_PRIVATE,
        None::<&str>,
    );
    info!("mount_private:result:{:?}",result);

    result
}

//mount --bind相当の処理
fn mount_bind(source: &str, target: &str) -> anyhow::Result<(), nix::Error> {
    let result = mount(
        Some(source),
        target,
        None::<&str>,
        //指定したファイルまたはディレクトリを別の場所にbind mount
        MsFlags::MS_BIND,
        None::<&str>,
    );

    info!("mount_bind:result:{:?}",result);

    result
}

//procをマウント
fn mount_proc(source: &str, target: &str) -> anyhow::Result<(), nix::Error> {
    let result = mount(
        Some(source),
        target,
        Some("proc"),
        MsFlags::empty(),
        None::<&str>,
    );
    info!("mount_proc:result:{:?}",result);

    result
}

//初期化処理
fn container_init(container_rootdir: &str) -> anyhow::Result<(), Box<dyn std::error::Error>> {
    mount_private()?;
    //chrootのroot filesystem作成
    mount_bind("/", container_rootdir)?;
    //指定したディレクトリを新しいrootディレクトリとして設定
    chroot(container_rootdir)?;
    //プロセスのカレントディレクトリを指定したディレクトリに変更
    set_current_dir("/")?;
       //procをマウント
    mount_proc("proc", "/proc")?;
    Ok(())
}

fn main() -> anyhow::Result<(), Box<dyn std::error::Error>> {

    init_log();

    debug!("mini container start.");

    let root = "/path/your/new_root_tmp_dir";

    unshare(CloneFlags::CLONE_NEWNS | CloneFlags::CLONE_NEWUTS).expect("Failed to create a new namespace");

    let closure = || {

        if let Err(e) = container_init(&root) {
            eprintln!("init container failed: {:?}", e);
            return 256;
        }

        let cmd = CString::new("bash").unwrap();
        let args = vec![
            CString::new("containered bash").unwrap(),
        ];
        if let Err(e) = execvp(&cmd, &args.as_ref()) {
            error!("Error -> {}", e);
            return 256;
        }
        256
    };

    let cb = Box::new(closure);
    let mut child_stack = [0u8; 8192];
    let flags = CloneFlags::CLONE_NEWIPC | CloneFlags::CLONE_NEWPID;

    let sigchld: libc::c_int = SIGCHLD as libc::c_int;
    let _pid = clone(cb, &mut child_stack, flags, Some(sigchld))?;

    info!("PID : {:?}", _pid);

    while let Ok(status) = waitpid(None, None) {
        info!("Exit Status: {:?}", status);
    }

    Ok(())
}

ビルドして実行すると↓のような感じでシェルが起動します。
ここではプロセスIDが86519で起動したことがわかります。

% cargo build
% sudo target/debug/mini-container
08:45:58 [DEBUG] (1) mini: mini container start.
08:45:58 [INFO] PID : Pid(86519)
08:45:58 [INFO] mount_private:result:Ok(())
08:45:58 [INFO] mount_bind:result:Ok(())
08:45:58 [INFO] mount_bind:result:Ok(())
08:45:58 [INFO] mount_proc:result:Ok(())
root@myuser:/#

psでみると2つしかプロセスがありません。
また、別コンソールを起動してpsでみると、86519のプロセスが見えます。

root@myuser:/# ps -ax
    PID TTY      STAT   TIME COMMAND
      1 ?        S      0:00 containered bash
      7 ?        R+     0:00 ps -ax

namespaceが分離されてるか確認します。
unshare時にCLONE_NEWUTSを指定したので、hostnameも分離されているはずです。
ためしに、新しいnamespaceでhostnameを変更してみます。

root@myuser:/# hostname hoge
root@myuser:/# hostname
hoge

別コンソールでhostnameを確認しても、変わっていません。

$ hostname
myhost

さらに、mountの状態も分離されており、元システムに影響はありません。
今回はやってませんが、ファイルシステムやネットワークも分離することができます。

Summary

今回はnixクレートを使ってnamespaceの動作を試してみました。
ここではシンプルに実装しましたが、
これをベースにリソース制限や仮想ネットワーク設定も可能です。

References