ローファイ日記

出てくるコード片、ぼくが書いたものは断りがない場合 MIT License としています http://udzura.mit-license.org/

Rustでもunshare(というか、Linux Namespaceの分離)したい!

あらかじめ宣言しますが、タイトルでunshare(2)と言いながらclone(2)を使います。

コンテナを自作する のが趣味だったので、Rustでもコンテナの基本的な機能であるLinux Namespace周りのコーディングをしてみた。

必要な材料としては clone(2) のラッパーであるが、 libc::clone() の定義を見ても使い方がよくわからない...

pub unsafe extern "C" fn clone(
    cb: extern "C" fn(_: *mut c_void) -> c_int, 
    child_stack: *mut c_void, 
    flags: c_int, 
    arg: *mut c_void, 
    _: ...
) -> c_int
  • extern "C" fn(_: *mut c_void) -> c_int ってどういう型なんだ?
  • stackが *mut c_void だけどこれってどう作るんだ。 mmap も呼ばないとダメ? 呼びたくありませんが...。
    • Cでclone(2)にstackを渡す際は、スタックは末尾から先頭に伸びるので、末尾のアドレスを渡すためにポインタ演算が必要なんだけど、そういうのもやる必要があるんだよな...

頭が痛くなってきたため、そこで nix::sched::clone() を使う。

docs.rs

コードを見る と、u8のスライスをスタック領域として使えるようにする操作とか、あと実はコールバック関数は void* として第4引数に渡してるんだなるほど... クロージャだもんな... などの様々な気づきがある。

とにかくこれを使ってNamespaceの分離を行う。

実際のコード

最低限だとこうなると思う。 use を大胆に省略するスタイル。

fn main() -> MyResult {
    let cb = Box::new(|| {
        let cmd = CString::new("bash").unwrap();
        let args = vec![
            CString::new("containered bash").unwrap(),
            CString::new("-l").unwrap(),
        ];
        if let Err(e) = execvp(&cmd, &args.as_ref()) {
            eprintln!("execvp failed: {:?}", e);
            return 127;
        }
        127
    });

    let mut child_stack = [0u8; 8192];
    let flags = CloneFlags::CLONE_NEWNS
        | CloneFlags::CLONE_NEWUTS
        | CloneFlags::CLONE_NEWIPC
        | CloneFlags::CLONE_NEWPID;
    let sigchld = 17; // x86/arm. ref man 7 signal
    let _pid = clone(cb, &mut child_stack, flags, Some(sigchld))?;

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

    Ok(())
}

書いた通りなんだけど、 child_stack は普通の u8 の配列でOKな点、サイズは雰囲気で割り当ててる点、sigchldの値は自分で調べてる点*1は留意か。

ただ、これでコンテナ化したbashは立ち上がるんだけど、 /proc を新しくマウントしてるでもなし、chrootしてるでもなしという感じで、ホスト名を弄れるぐらいしか隔離されてる感がない。

最低限必要な前処理を追加

コンテナの雰囲気を出す最低限の前処理を行う。

mount --make-rprivate / 相当を発行する

まず、Mount Namespaceを分離しているにもかかわらず、systemd管理のLinuxディストロではデフォルトでホストの / がsharedになってしまっており、このままでは分離されたNamespaceでの変更がホストに伝播する。この辺りのお話は TenForward さんの解説が詳細なので譲ります...。

ということで mount --make-rprivate / を打つ。コンテナ自作界隈(?)では「あるある」な処理だと思う。今回は nix::mount::mount()システムコールを発行することで実施。

fn mount_make_private() -> Result<(), nix::Error> {
    mount(
        Some("none"),
        "/",
        None::<&str>, // ただのNoneだと型推論してくれないよ...。
        MsFlags::MS_REC | MsFlags::MS_PRIVATE,
        None::<&str>,
    )
}

chroot先のroot filesystemを作る

その上で、コンテナが使うrootをホストのrootをは分けたいので、chroot先のrootをbind mountでさくっと作ってしまう。

fn mount_bind(source: &str, target: &str) -> Result<(), nix::Error> {
    mount(
        Some(source),
        target,
        None::<&str>,
        MsFlags::MS_BIND,
        None::<&str>,
    )
}

// 利用時のイメージ
create_dir(root)?;
mount_bind("/", root)?;

procfsをマウントする

これも同じように mount() に適切なフラグを渡す。

fn mount_proc(source: &str, target: &str) -> Result<(), nix::Error> {
    mount(
        Some(source),
        target,
        Some("proc"),
        MsFlags::empty(),
        None::<&str>,
    )
}

あとは nix::unistd::chroot()std::env::set_current_dir() を適切な順序で呼ぶ。

type MyResult = Result<(), Box<dyn std::error::Error>>;
fn container_prelude(root: &str) -> MyResult {
    mount_make_private()?;
    create_dir(root)?;
    mount_bind("/", root)?;
    chroot(root)?;
    set_current_dir("/")?;
    mount_proc("proc", "/proc")?;
    Ok(())
}

container_prelude() をfork(clone)してからexecする直前までの間で呼ぶことで、コンテナとしてセットアップされた状態で bash が立ち上がることになる。

main の全体

use nix::mount::*;
use nix::sched::*;
use nix::sys::wait::waitpid;
use nix::unistd::{chroot, execvp};
use std::env::{args, set_current_dir};
use std::ffi::CString;
use std::fs::{create_dir, remove_dir};
type MyResult = Result<(), Box<dyn std::error::Error>>;

// 上掲の関数がここに。省略
// ...

fn main() -> MyResult {
    let usage = format!("Usage: {} [newroot]", args().nth(0).unwrap());
    let root = args().nth(1).ok_or(usage)?;

    let cb = Box::new(|| {
        if let Err(e) = container_prelude(&root) {
            eprintln!("prelude failed: {:?}", e);
            return 127;
        }

        let cmd = CString::new("bash").unwrap();
        let args = vec![
            CString::new("containered bash").unwrap(),
            CString::new("-l").unwrap(),
        ];
        if let Err(e) = execvp(&cmd, &args.as_ref()) {
            eprintln!("execvp failed: {:?}", e);
            return 127;
        }
        127
    });

    let mut child_stack = [0u8; 8192];
    let flags = CloneFlags::CLONE_NEWNS
        | CloneFlags::CLONE_NEWUTS
        | CloneFlags::CLONE_NEWIPC
        | CloneFlags::CLONE_NEWPID;
    let sigchld = 17; // x86/arm. ref man 7 signal
    let _pid = clone(cb, &mut child_stack, flags, Some(sigchld))?;

    while let Ok(status) = waitpid(None, None) {
        println!("Exit Status: {:?}", status);
    }
    // 一応後始末する
    remove_dir(&root)?;

    Ok(())
}

動作の様子

$ sudo ../target/debug/minimum-container-example /tmp/foobar-1
root@ubuntu-groovy:/# mount
/dev/sda1 on / type ext4 (rw,relatime)
proc on /proc type proc (rw,relatime)
root@ubuntu-groovy:/# ps auxf
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.1  0.0  10008  5072 ?        S    09:49   0:00 containered bash -l
root          13  0.0  0.0  11476  3560 ?        R+   09:49   0:00 ps auxf
root@ubuntu-groovy:/# exit
logout
Exit Status: Exited(Pid(198843), 0)

今回やっていないこと

  • cgroup とか... ディレクトリ とファイルの操作をするだけなので、今度気が向いたらやる。
  • その他、libcapやlibseccompがやってくれるようなことは一旦やっていない。FFIすればできると思う。あと setsid() するとか細かいとこ...。
  • ネットワーク周り。 setns 相当のnixの関数もあるのでできると思う。netns作るには、たとえば こういうクレート があって、これはpyroute2相当のことが普通にできるみたいなので、netnsやveth作成もできると思う。ちょっと調べきれてないですが...。
  • OCI specに沿った実装に持っていくほどの元気はないです...。

コードはここに置いておいた。

github.com

*1:これもっと適切な出し方がありそう