インフラ

Rustで簡単なLinuxコンテナを実装する

ryo

こんにちは、リョウです!

今回は簡単なLinuxコンテナを実装してみたので共有させていただきます。

はじめに

runcをはじめとするOCIランタイムの実装に興味があり実装を読んでみたのですが、一連のフローを辛うじて理解できたレベルに終わってしまい挫折してしましました。
まず自分で簡単な実装をしてからの方が理解が進むのでは、ということで今回の記事ではコンテナとして最小限の機能をRustを用いて実装してみます。
runcの一連のフローを読む上で以下の記事に大変お世話になりました。筆者の方々ありがとうございます。

https://kurobato.hateblo.jp/entry/2021/05/02/164218
https://speakerdeck.com/utam0k/xiang-shuo-ocikontenarantaimu-youki-at-di-15hui-kontenaji-shu-falseqing-bao-jiao-huan-hui

Rustもコンテナランタイムも勉強中なので、間違いがありましたら是非指摘いただけるとありがたいです。

前提知識

  • OCIランタイムの概要
  • namespaceやcgroupなどコンテナを構成する主要な機能の概要
    • 素晴らしい記事がたくさんあるのでこの記事での解説は割愛させていただきます

今回実装する機能

コンテナを構成する主な要素であるnamespacecgroup を利用します。
namespaceにはいくつかの種類がありますが、今回は以下のnamespaceを利用して実装を行います。

  • UTS namespace
  • PID namespace
  • IPC namespace
  • mout namespace
  • network namespace
  • cgroup namespace

またcgroupを利用してコンテナプロセスのメモリの制限も行います。
runcが提供する機能と比べるとかなり見劣りしますが、コンテナとして最低限の動作を目指します。

実装環境

今回はUbuntuを利用していますが、他のディストリビューションでも問題無く動くと思います。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 22.10
Release:    22.10
Codename:   kinetic

また今回はcgroup v2のみの対応なので、以下のコマンドでcgroup v2がマウントされていることを確認します。

$ mount | grep cgroup
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot)

Rustのバージョンは以下を使用しています。

$ rustc --version
rustc 1.68.2 (9eb3afe9e 2023-03-27)

RustのCargo.tomlは以下になっています。
nix はlibcのラッパーライブラリで、返り値がResultになっているなどRustからシステムコールが扱いやすくなるため利用します。
https://docs.rs/nix/latest/nix/

[dependencies]
nix = "0.26.2"

実装の方針

コンテナを作成するプロセスとコンテナとなるプロセスの2つに分けて実装を行います。

最初に起動したプロセスでunshare(2)を利用することで、現在のプロセスに名前空間の分離が可能ですが、PID namespaceは名前空間の分離後に作成された最初の子プロセスがプロセスID1となるため、PID namespaceを利用する場合は子プロセスの生成が必要です。

runcではdouble forkを用いてコンテナプロセスの作成をしていますが、今回はUser namespaceの利用はしない方針なので、2プロセスのみで実装を行います。
runcがdouble forkを利用している理由はこちらの記事やruncコードのコメントに詳しく記載されています
https://speakerdeck.com/utam0k/xiang-shuo-ocikontenarantaimu-youki-at-di-15hui-kontenaji-shu-falseqing-bao-jiao-huan-hui
https://github.com/opencontainers/runc/blob/e42c219feaacdeece5f078d80ea8bdbfd619e1ce/libcontainer/nsenter/nsexec.c#L936

runcではコンテナプロセスの作成にclone(2)とexecve(2)を利用しているようなので、同じようにnixからclone(2)とexecve(2)を利用します。
名前空間の分離にはclone(2)の呼び出し時に指定できるflags(CLONE_NEWIPC など)とunshare(2)を利用します。2つ利用している点は後述します。

コード全文

src/main.rs は以下のようになっています。

use nix::mount::{mount, MsFlags};
use nix::sched::{clone, unshare, CloneFlags};
use nix::sys::signal::Signal;
use nix::sys::wait::{waitpid, WaitStatus};
use nix::unistd::{chdir, chroot, execve, getpid, sethostname};
use std::env;
use std::ffi::CString;
use std::fs::{create_dir_all, write};
use std::io;
use std::path::PathBuf;

const ROOT_DIR: &str = "./root";
const CGROUP_DIR: &str = "/sys/fs/cgroup/container";

fn main() {
    let args: Vec<String> = env::args().collect();

    if args.len() < 2 {
        eprintln!("Invalid arguments");
        return;
    }

    // コンテナプロセスを作成する前に、事前にcgroupを作成して適用しておく
    // コンテナプロセスはここで作成したcgroupをrootとして起動させるため
    if let Err(e) = setup_cgroup() {
        eprintln!("cgroup setting error: {:?}", e);
        return;
    }

    let mut stack = vec![0; 1024 * 1024];
    let clone_flags = CloneFlags::CLONE_NEWUTS // UTS namespace
    | CloneFlags::CLONE_NEWPID // PID namespace
    | CloneFlags::CLONE_NEWNS // mount namespace
    | CloneFlags::CLONE_NEWIPC // IPC namespace
    | CloneFlags::CLONE_NEWNET; // network namespace
    match clone(
        Box::new(|| container_process(args.clone())),
        &mut stack,
        clone_flags,
        Some(Signal::SIGCHLD as i32),
    ) {
        Ok(pid) => {
            println!("Created Container with PID: {}", pid);
            match waitpid(pid, None) {
                Ok(status) => match status {
                    // https://github.com/nix-rust/nix/blob/1bfbb034cba446a370ba3c899a235b94fbcc2099/src/sys/wait.rs#L88
                    WaitStatus::Exited(_, status) => {
                        println!("Container process exited: {:?}", status)
                    }
                    WaitStatus::Signaled(_, status, _) => {
                        println!("Container process killed by signal: {:?}", status)
                    }
                    _ => eprintln!("Unexpected WaitStatus"),
                },
                Err(e) => eprintln!("Error waiting for child process: {:?}", e),
            }
        }
        Err(e) => {
            eprintln!("Error creating new process: {:?}", e);
        }
    }
}

fn setup_cgroup() -> Result<(), io::Error> {
    // コンテナのrootfsにcgroupfs用のディレクトリを作成しておく
    create_dir_all(
        PathBuf::from(ROOT_DIR)
            .join("sys")
            .join("fs")
            .join("cgroup"),
    )?;

    // containerという名前で作成する
    let cgroup_path = &PathBuf::from(CGROUP_DIR);
    create_dir_all(cgroup_path)?;

    // メモリのハードリミットを50Mに設定する
    write(cgroup_path.join("memory.max"), "50M")?;
    Ok(())
}

fn container_process(args: Vec<String>) -> isize {
    let command = args[1].clone();
    let args = &args[2..];
    let cstr_command = CString::new(command).unwrap_or_else(|_| CString::new("default").unwrap());
    let cstr_args: Vec<CString> = args
        .iter()
        .map(|arg| CString::new(arg.as_str()).unwrap_or_else(|_| CString::new("default").unwrap()))
        .collect();

    if let Err(e) = setup_child_process() {
        return e as isize;
    }
    if let Err(e) = execve::<CString, CString>(&cstr_command, &cstr_args, &[]) {
        return e as isize;
    }
    0
}

fn setup_child_process() -> Result<(), nix::Error> {
    // プロセスIDの書き込み、cgroupを適用する
    let write_res = write(
        PathBuf::from(CGROUP_DIR).join("cgroup.procs"),
        getpid().as_raw().to_string(),
    );
    write_res.map_err(|e| {
        eprintln!("write error: {:?}", e);
        match e.raw_os_error() {
            Some(errno) => nix::errno::from_i32(errno),
            None => nix::errno::Errno::UnknownErrno,
        }
    })?;

    // cgroup namespaceの適用
    unshare(CloneFlags::CLONE_NEWCGROUP)?;

    // UTS namespaceの動作確認のためhostnameを変更する
    sethostname("container")?;

    // マウントプロパゲーションの無効化
    mount::<str, str, str, str>(None, "/", None, MsFlags::MS_REC | MsFlags::MS_PRIVATE, None)?;

    // procfsのマウント。man 8 mountにある通り、sourceは`proc`文字列にする
    mount::<str, PathBuf, str, str>(
        Some("proc"),
        &PathBuf::from(ROOT_DIR).join("proc"),
        Some("proc"),
        MsFlags::MS_NOEXEC | MsFlags::MS_NOSUID | MsFlags::MS_NODEV,
        None,
    )?;

    // sysfsとcgroupfsのマウント
    mount::<str, PathBuf, str, str>(
        Some("sysfs"),
        &PathBuf::from(ROOT_DIR).join("sys"),
        Some("sysfs"),
        MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC | MsFlags::MS_NODEV,
        None,
    )?;
    mount::<str, PathBuf, str, str>(
        Some("cgroup2"),
        &PathBuf::from(ROOT_DIR)
            .join("sys")
            .join("fs")
            .join("cgroup"),
        Some("cgroup2"),
        MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC | MsFlags::MS_NODEV | MsFlags::MS_RELATIME,
        None,
    )?;

    // プロセスのRootディレクトリを変更(簡易化のためpivot_rootは使わない)
    chroot(ROOT_DIR)?;
    // 変更したRootディレクトリに移動
    chdir("/")?;

    Ok(())
}

動作させてみる

(基本的にroot権限でコマンドを実行しています。)

動作させるにはコンテナのルートディレクトリとなるディレクトリが必要です。
ルートディレクトリのパスはコードにハードコードしているため、プロジェクトルートにroot というディレクトリを作成、docker exportを利用してubuntuコンテナで利用しているファイル一式をtar形式で取得し、root 以下に展開を行います。

$ mkdir root
$ docker export $(docker create ubuntu) | tar -C root -xvf -

cargo runで起動すると引数として与えたパスに存在するプログラムが実行されます。
この時に起動されるプログラム(/bin/bash)はホスト側から見ると./root/bin/bash となります。
今回はbashを起動してコンテナ内に入ってみます。
起動したシェルのプロセスIDを見るとちゃんと1になっています。

$ cargo run -- /bin/bash
Created Container with PID: 12003
root@container:/#
root@container:/# echo $
1

無事起動できたので、まずは名前空間の分離ができているかを確認してみます。
/proc/self/ns/ を見ることで、プロセス自体の名前空間を確認可能です。

root@container:/# ls -l /proc/self/ns/
total 0
lrwxrwxrwx 1 root root 0 Apr 12 10:03 cgroup -> 'cgroup:[4026532472]'
lrwxrwxrwx 1 root root 0 Apr 12 10:03 ipc -> 'ipc:[4026532470]'
lrwxrwxrwx 1 root root 0 Apr 12 10:03 mnt -> 'mnt:[4026532468]'
lrwxrwxrwx 1 root root 0 Apr 12 10:03 net -> 'net:[4026532473]'
lrwxrwxrwx 1 root root 0 Apr 12 10:03 pid -> 'pid:[4026532471]'
lrwxrwxrwx 1 root root 0 Apr 12 10:03 pid_for_children -> 'pid:[4026532471]'
lrwxrwxrwx 1 root root 0 Apr 12 10:03 time -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0 Apr 12 10:03 time_for_children -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0 Apr 12 10:03 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 Apr 12 10:03 uts -> 'uts:[4026532469]'

別のターミナルを開きホスト側の名前空間も確認します。

$ ls -l /proc/self/ns/
total 0
lrwxrwxrwx 1 root root 0  4月 12 19:02 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0  4月 12 19:02 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 root root 0  4月 12 19:02 mnt -> 'mnt:[4026531841]'
lrwxrwxrwx 1 root root 0  4月 12 19:02 net -> 'net:[4026531840]'
lrwxrwxrwx 1 root root 0  4月 12 19:02 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0  4月 12 19:02 pid_for_children -> 'pid:[4026531836]'
lrwxrwxrwx 1 root root 0  4月 12 19:02 time -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0  4月 12 19:02 time_for_children -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0  4月 12 19:02 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0  4月 12 19:02 uts -> 'uts:[4026531838]'

今回分離を行ったnamespaceについては数字が異なることを確認出来たので問題無さそうです。

次はcgroupによる制限が出来ているかを確認します。
今回はメモリのハード制限を50MBに指定したので、/sys/fs/cgroup/memory.max ファイルを確認してみます。

root@container:/# cat /sys/fs/cgroup/memory.max
52428800

こちらも問題無さそうです。
また/sys/fs/cgroup 直下のmemory.max に書き込まれていることからcgroup namespaceが機能していることも確認出来ました。

無事コンテナとして最低限の動作は確認出来ました!

コードの解説

ここからは今回実装したコードを関数ごとに解説をしていきます。
まずはmain関数です。

fn main() {
    let args: Vec<String> = env::args().collect();

    if args.len() < 2 {
        eprintln!("Invalid arguments");
        return;
    }

    // コンテナプロセスを作成する前に、事前にcgroupを作成しておく
    // コンテナプロセスはここで作成したcgroupをrootとして起動させるため
    if let Err(e) = setup_cgroup() {
        eprintln!("cgroup setting error: {:?}", e);
        return;
    }

    let mut stack = vec![0; 1024 * 1024];
    let clone_flags = CloneFlags::CLONE_NEWUTS // UTS namespace
    | CloneFlags::CLONE_NEWPID // PID namespace
    | CloneFlags::CLONE_NEWNS // mount namespace
    | CloneFlags::CLONE_NEWIPC // IPC namespace
    | CloneFlags::CLONE_NEWNET; // network namespace
    match clone(
        Box::new(|| container_process(args.clone())),
        &mut stack,
        clone_flags,
        Some(Signal::SIGCHLD as i32),
    ) {
        Ok(pid) => {
            println!("Created Container with PID: {}", pid);
            match waitpid(pid, None) {
                Ok(status) => match status {
                    // https://github.com/nix-rust/nix/blob/1bfbb034cba446a370ba3c899a235b94fbcc2099/src/sys/wait.rs#L88
                    WaitStatus::Exited(_, status) => {
                        println!("Container process exited: {:?}", status)
                    }
                    WaitStatus::Signaled(_, status, _) => {
                        println!("Container process killed by signal: {:?}", status)
                    }
                    _ => eprintln!("Unexpected WaitStatus"),
                },
                Err(e) => eprintln!("Error waiting for child process: {:?}", e),
            }
        }
        Err(e) => {
            eprintln!("Error creating new process: {:?}", e);
        }
    }
}

main関数は主にcgroupのセットアップとclone(2)を利用したコンテナプロセスの生成を行なっています。
clone(2)の実行時に子プロセスが実行する関数、スタックサイズ、分離したい名前空間の指定を行なっています。
ここではcgroup namespace以外の名前空間の分離を行います。
またclone(2)の第二引数であるchild_stackに渡す値はCから呼び出す場合とは異なり、子プロセス用に用意したメモリ空間の1番大きなアドレスを渡す必要はありません。詳細は以下をご確認ください。
https://docs.rs/nix/latest/nix/sched/fn.clone.html
コンテナプロセスの生成後はwait(2)を利用してコンテナプロセスが終了するまで待機を行います。

次はsetup_cgroup関数です。

fn setup_cgroup() -> Result<(), io::Error> {
    // コンテナのrootfsにcgroupfs用のディレクトリを作成しておく
    create_dir_all(
        PathBuf::from(ROOT_DIR)
            .join("sys")
            .join("fs")
            .join("cgroup"),
    )?;

    // containerという名前で作成する
    let cgroup_path = &PathBuf::from(CGROUP_DIR);
    create_dir_all(cgroup_path)?;

    // メモリのハードリミットを50Mに設定する
    write(cgroup_path.join("memory.max"), "50M")?;
    Ok(())
}

この関数ではcgroup v2の設定を行います。
コンテナのルートディレクトリにcgroupfsのマウント用の/sys/fs/cgroup が必要なのであらかじめ作成しておきます。
その後、新しくcontainer という名前で新しくcgroupを作成し、memory.max に50Mと書き込むことで、メモリのハードリミットの制限を行います。

次はcontainer_process関数です。

fn container_process(args: Vec<String>) -> isize {
    let command = args[1].clone();
    let args = &args[2..];
    let cstr_command = CString::new(command).unwrap_or_else(|_| CString::new("default").unwrap());
    let cstr_args: Vec<CString> = args
        .iter()
        .map(|arg| CString::new(arg.as_str()).unwrap_or_else(|_| CString::new("default").unwrap()))
        .collect();

    if let Err(e) = setup_child_process() {
        return e as isize;
    }
    if let Err(e) = execve::<CString, CString>(&cstr_command, &cstr_args, &[]) {
        return e as isize;
    }
    0
}

この関数はclone(2)の呼び出し時に子プロセスが実行する関数として渡されます。
carog run などでプログラムの起動時に渡された引数をexecve関数が要求する形式に変換を行います。
その後はsetup_child_process関数を呼び出しいくつかの設定を行った後、execve(2)を利用してコンテナでPID 1として実行したいプログラムの起動を行います。

最後はsetup_child_process関数です。

fn setup_child_process() -> Result<(), nix::Error> {
    // プロセスIDの書き込み、cgroupを適用する
    let write_res = write(
        PathBuf::from(CGROUP_DIR).join("cgroup.procs"),
        getpid().as_raw().to_string(),
    );
    write_res.map_err(|e| {
        eprintln!("write error: {:?}", e);
        match e.raw_os_error() {
            Some(errno) => nix::errno::from_i32(errno),
            None => nix::errno::Errno::UnknownErrno,
        }
    })?;

    // cgroup namespaceの適用
    unshare(CloneFlags::CLONE_NEWCGROUP)?;

    // UTS namespaceの動作確認のためhostnameを変更する
    sethostname("container")?;

    // マウントプロパゲーションの無効化
    mount::<str, str, str, str>(None, "/", None, MsFlags::MS_REC | MsFlags::MS_PRIVATE, None)?;

    // procfsのマウント。man 8 mountにある通り、sourceは`proc`文字列にする
    mount::<str, PathBuf, str, str>(
        Some("proc"),
        &PathBuf::from(ROOT_DIR).join("proc"),
        Some("proc"),
        MsFlags::MS_NOEXEC | MsFlags::MS_NOSUID | MsFlags::MS_NODEV,
        None,
    )?;

    // sysfsとcgroupfsのマウント
    mount::<str, PathBuf, str, str>(
        Some("sysfs"),
        &PathBuf::from(ROOT_DIR).join("sys"),
        Some("sysfs"),
        MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC | MsFlags::MS_NODEV,
        None,
    )?;
    mount::<str, PathBuf, str, str>(
        Some("cgroup2"),
        &PathBuf::from(ROOT_DIR)
            .join("sys")
            .join("fs")
            .join("cgroup"),
        Some("cgroup2"),
        MsFlags::MS_NOSUID | MsFlags::MS_NOEXEC | MsFlags::MS_NODEV | MsFlags::MS_RELATIME,
        None,
    )?;

    // プロセスのRootディレクトリを変更(簡易化のためpivot_rootは使わない)
    chroot(ROOT_DIR)?;
    // 変更したRootディレクトリに移動
    chdir("/")?;

    Ok(())
}

この関数では主にコンテナに必要な設定を行なっています。
まず事前に親プロセスで作成しておいたcgroupのcgroup.procs に自身のPIDを書き込むことで、コンテナプロセスにcgroupによる制限を適用します。cgroupの適用が終わった後は、cgroup namespaceの分離を行います。この分離を行うことでコンテナプロセスから見るとrootのcgroupに所属しているように見せることが可能です。
別の方法として、親プロセスで自身のPIDを書き込み、clone(2)で子プロセス作成時に自動適用させることも可能です。
ですが、今回は親プロセスが子プロセスのexitまで残るため、親プロセスで自身のPIDを書き込むと親プロセスにもcgroupが適用された状態になってします。

この後、ホスト名の変更を行なっています。これはUTS namespaceの動作確認のためにいれています。

最後のステップはrootfs周りの設定を行います。
runcではリンクの関数で以下の順序でrootfsの設定を行なっています。
https://github.com/opencontainers/runc/blob/11983894a83fe4014250757f1a8859ab8eaa418a/libcontainer/rootfs_linux.go#L53

  1. マウントプロパゲーションをMS_PRIVATE に設定
  2. procfs、sysfs、cgroupfsのマウント
  3. chroot(2) or pivot_root(2)の実行
    • chroot(2)の場合はchdir(2)で/ に移動

今回の実装では出来るだけ同じように実装を行います。
まず最初にマウントプロパゲーションの無効化を行います。initとしてsystemdを利用している場合などはマウントプロパゲーションがshared に設定されているケースがあります。
shared になっていると、mount namespaceの分離を行っていた場合でもマウント操作が他名前空間に伝播してしまうため、/MS_PRIVATE と共にマウントし直すことでprivate に設定する必要があります。

その後、procfs、sysfs、cgroupfsのマウントを行います。
mount(2)呼び出し時のフラグは以下のOCIランタイムの設定を記述するJSONファイルを参照しています。
https://github.com/opencontainers/runtime-spec/blob/main/config.md#configuration-schema-example

最後にchroot(2)でコンテナプロセスのルートディレクトリを./root に設定し、chdir(2)で作業ディレクトリを/ に移動します。
chroot(2)はCAP_SYS_CHROOT ケーパビリティを持っているプロセスが上位ディレクトリに脱獄出来てしまう仕様があるため、pivot_root(2)が広く使われていますが、今回は実装の簡略化のためchroot(2)を利用しています。

まとめ

今回の実装では本当に最小限度の実装に留まっているため、改善点が山のようにあります。
ただ実際に実装を行なってみると、OCIランタイム実装の理解が少し進んだ気がします。
どなたかの参考になれば幸いです。

参考資料

runcの概要

runcの実装について

mount namespaceについて

既存のOCIランタイム

cgroup v2

OCIランタイムがdouble forkしている理由など

AUTHOR
ryo
ryo
記事URLをコピーしました