1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rustで小さなLinuxコンテナを自作する:Dockerなしでnamespaces、cgroups、pivot_root、landlock を理解する

1
Last updated at Posted at 2026-06-21

はじめに

DockerやPodmanを使っていると、コンテナは何か特別な箱のように見えます。

docker run -it ubuntu:24.04 /bin/bash

この一行で、別のLinux環境が立ち上がり、別のファイルシステムが見え、別のプロセス空間が見え、メモリやCPUを制限でき、場合によってはネットワークも分離されます。

でも、コンテナは仮想マシンではありません。

コンテナの正体は、かなり乱暴に言えば、

Linux processに対して、見える世界と使える資源と許される操作を制限したもの

です。

この記事では、Dockerを使わずに、Rustで小さなコンテナランタイムを一から作ります。目的は本番で使える安全なコンテナランタイムを作ることではありません。目的は、コンテナがどのLinuxカーネル機能の組み合わせでできているのかを、自分の手で確認することです。

最終的には、次のようなコマンドで小さなコンテナを起動できるようにします。

sudo ./target/debug/microbox ./rootfs /bin/sh

コンテナの中に入ると、次のようなことを確認できます。

/ $ hostname 
microbox

/ $ echo $$ 
1 

/ $ ps 
PID USER COMMAND 
1 root /bin/sh 
...

この記事で扱うのは、主に次のLinux機能です。

  • PID namespace
  • UTS namespace
  • mount namespace
  • pivot_root
  • procfs
  • cgroup v2
  • capabilities
  • seccomp
  • landlock

これらの機能は、実際のDockerやPodmanなどのコンテナや、Claude Code や Codex のサンドボックス技術でも多用されています。

1. コンテナは「別の見え方をするprocess」

まず、最も重要な発想から始めます。コンテナの中で /bin/sh を起動しても、それはホストから見れば普通のLinux processです。仮想マシンの場合、ゲストOSのカーネルが別に動きます。一方、通常のLinuxコンテナでは、ホストとコンテナは同じLinuxカーネルを共有しますが、processから見える世界が違います。

たとえば、コンテナ内のprocessには、

  • 自分専用のhostnameがあるように見える
  • 自分専用のPID空間があるように見える
  • 自分専用のroot filesystemがあるように見える
  • 自分専用のnetwork interfaceがあるように見える
  • rootに見えるが、実際には権限が削られている

という錯覚を与えます。この「錯覚」を作るのが、Linux namespacesです。

2. namespacesとは何か

namespaceは、processから見えるカーネル内のオブジェクトを分離する仕組みです。たとえば、PID namespaceを使うと、同じprocessでもホスト側とコンテナ側で違うPIDに見えます。

ホスト側ではこう見えるかもしれません。

$ ps aux | grep sh 
root 31245 ... /bin/sh

でも、コンテナ内ではこう見えます。

/ $ echo $$ 
1

同じprocessなのに、見えるPIDが違う。これがnamespaceの感覚です。

Linuxにはいくつかのnamespaceがあります。

namespace 分離するもの
UTS hostname / domainname
PID process ID空間
mount mount table
network network devices / routing / ports
IPC System V IPC / POSIX message queues
Paragraph UID / GID と capabilities
cgroup cgroup階層の見え方
time clock offset

コンテナランタイムは、これらを組み合わせて「別環境っぽさ」を作ります。

3. 今回作るもの

今回は、microbox という小さなコンテナランタイムを作ります。

sudo ./target/debug/microbox ./rootfs /bin/sh

第一引数がroot filesystem、第二引数以降がコンテナ内で実行するコマンドです。

作る機能は次の通りです。

  1. UTS namespaceでhostnameを分離する
  2. PID namespaceでコンテナ内のprocessをPID 1にする
  3. mount namespaceでmount tableを分離する
  4. pivot_rootでroot filesystemを差し替える
  5. /proc をコンテナ用にmountする
  6. cgroup v2でメモリ・CPU・PID数を制限する
  7. capabilitiesとseccompの意味を確認する

4. filesystemの準備

コンテナimageというと難しく聞こえますが、最初はただのディレクトリで十分です。

container imageの本質は、かなり単純化すると、

実行に必要なファイルが入ったfilesystem

今回はBusyBoxで小さなrootfsというfilesystemを作ります。Ubuntu/Debian系なら、次のようにできます。BusyBox は、sh, ls, cp, mount, ps などの基本的な Unix/Linux コマンドを、ほぼ 1 個の実行ファイルにまとめたものです。

sudo apt-get update 
sudo apt-get install -y busybox-static 

mkdir -p rootfs/{bin,proc,dev,etc,tmp} 
cp "$(which busybox)" rootfs/bin/busybox 

sudo chroot rootfs /bin/busybox --install -s /bin

ここで、chrooにより、rootfs/ として扱われ、その中の /bin/busybox が実行されるようになります。

ただし、この時点ではまだコンテナではありません。ただのディレクトリです。次に、このディレクトリをprocessの / に見せる仕組みを作っていきます。

5. Rustプロジェクトを作る

プロジェクトを作ります。

cargo new microbox 
cd microbox

Cargo.toml は次のようにします。

[package]
name = "microbox" 
version = "0.1.0" 
edition = "2021" 

[dependencies] 
anyhow = "1" 
libc = "0.2" 

[dependencies.nix] 
version = "0.29" 
features = ["sched", "mount", "hostname", "fs", "process", "signal", "user"]

この記事では、低レベルなLinux syscallを直接呼びたい場面が多いので、nixlibc を使います。

6. 最小の実行器を作る

まずは、何も分離せずに、指定されたコマンドを execvp するだけのプログラムを書きます。src/main.rs に次を書きます。

use anyhow::{bail, Context, Result};
use nix::unistd::execvp;
use std::env; 
use std::ffi::{CStr, CString};
use std::path::PathBuf;

fn main() -> Result<()> { 
    let mut args = env::args().skip(1).collect::<Vec<_>>(); 
    
    if args.len() < 2 { 
        bail!("usage: microbox <rootfs> <command> [args...]"); 
    } 
    
    let rootfs = PathBuf::from(args.remove(0)); 
    let command = args; 
    
    println!("rootfs = {:?}", rootfs); 
    println!("command = {:?}", command); 
    
    exec_command(&command)?; 
    Ok(()) 
    
} 

fn exec_command(argv: &[String]) -> Result<()> { 
    let cstrings = argv 
        .iter() 
        .map(|s| CString::new(s.as_str())) 
        .collect::<std::result::Result<Vec<_>, _>>()?; 
        
    let cstrs = cstrings 
        .iter() 
        .map(|s| s.as_c_str()) 
        .collect::<Vec<&CStr>>(); 
        
    execvp(cstrs[0], &cstrs).context("execvp failed")?; 
    Ok(()) 
}

実行します。

cargo run -- ./rootfs /bin/echo hello

もちろん、この段階ではrootfsは使っていません。単にホスト側の /bin/echo を実行しようとするだけです。次から、processの見える世界を少しずつ変えていきます。

7. UTS namespace:hostnameを分離する

最初に一番わかりやすいnamespaceであるUTS namespaceを使い、hostnameをprocess treeごとに分離します。main.rsに次を追加します。

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

fn setup_namespaces() -> Result<()> { 
    unshare( 
        CloneFlags::CLONE_NEWUTS 
    ).context("unshare UTS namespace failed")?; 
    
    sethostname("microbox").context("sethostname failed")?; 
    
    Ok(()) 
}

そして mainexec_command の前で呼びます。

setup_namespaces()?;
exec_command(&command)?;

実行してみます。

sudo cargo run -- ./rootfs /bin/hostname

出力がこうなれば成功です。

microbox

ホスト側のhostnameは変わっていません。ここで起きたことは単純です。

  • processを新しいUTS namespaceに入れた
  • そのnamespace内でhostnameを変えた
  • そのprocessから見るhostnameだけが変わった

8. PID namespace:コンテナ内でPID 1になる

次にPID namespaceを使います。PID namespaceを使うと、process IDの見え方を分離できます。unshare(CLONE_NEWPID) を呼んでも、呼び出したprocess自身が新しいPID namespaceのPID 1になるわけではありません。新しいPID namespaceは、その後に作られる子processに適用されます。

そのため、流れはこうなります。

  1. 親processが unshare(CLONE_NEWPID) する
  2. fork する
  3. 子processが新しいPID namespaceのPID 1になる
  4. 親processは子processをwaitする

コードを変更します。

use nix::sys::wait::waitpid;
use nix::unistd::{fork, ForkResult};

fn main() -> Result<()> {
    let mut args = env::args().skip(1).collect::<Vec<_>>();

    if args.len() < 2 {
        bail!("usage: microbox <rootfs> <command> [args...]");
    }

    let rootfs = PathBuf::from(args.remove(0));
    let command = args;

    unshare(
        CloneFlags::CLONE_NEWUTS
            | CloneFlags::CLONE_NEWPID
    ).context("unshare failed")?;

    match unsafe { fork()? } {
        ForkResult::Parent { child } => {
            waitpid(child, None).context("waitpid failed")?;
        }
        ForkResult::Child => {
            sethostname("microbox").context("sethostname failed")?;
            exec_command(&command)?;
        }
    }

    Ok(())
}

実行してみます。

sudo cargo run -- ./rootfs /bin/sh

中で確認してみます。

echo $$

1 と表示されれば、コンテナ内のshellがPID 1になっています。

ただし、この時点では、ps がホスト側のprocessを見せてしまうかもしれません。なぜなら、ps/procを読んでおり、PID namespaceを分離しただけでは、/proc がまだホストのものを指している可能性があります。つまり、PIDの見え方を変えただけでは不十分で、mount namespaceも分離し、コンテナ内の /proc をmountし直す必要があります。

9. mount namespace:mount tableを分離する

次に mount namespace を使い、その process tree 専用の mount table を持ちます。

unshare のflagsに CLONE_NEWNS を追加します。

unshare(
    CloneFlags::CLONE_NEWUTS
        | CloneFlags::CLONE_NEWPID
        | CloneFlags::CLONE_NEWNS
).context("unshare failed")?;

さらに、mount propagationをprivateにします。これは、Linuxではmount eventがほかのnamespaceに伝播する設定になっていることがあり、その場合コンテナ内のmount操作がホスト側に伝わってしまうことを防ぐためです。

use nix::mount::{mount, MsFlags};

fn make_mount_private() -> Result<()> {
    mount(
        Option::<&str>::None,
        "/",
        Option::<&str>::None,
        MsFlags::MS_REC | MsFlags::MS_PRIVATE,
        Option::<&str>::None,
    ).context("make mounts private failed")?;

    Ok(())
}

10. pivot_root

chroot でもファイルシステムを切り替えられますが、コンテナでは pivot_root を使うほうが一般的です。これは、chroot はprocessから見える root directory を変えるだけですが、pivot_root は mount namespace 内の root mount そのものを入れ替えるためです。

ざっくり言うと、

  • chroot: ルートディレクトリの見え方を変える
  • pivot_root: mount namespace 内の / を差し替える

という違いがあります。

use nix::mount::{umount2, MntFlags};
use nix::unistd::{chdir, pivot_root};
use std::fs;
use std::path::Path;

fn pivot_to_rootfs(rootfs: &Path) -> Result<()> {
    let rootfs = rootfs
        .canonicalize()
        .context("canonicalize rootfs failed")?;

    // pivot_rootのnew_rootはmount pointである必要がある。
    // そこでrootfsを自分自身にbind mountする。
    mount(
        Some(rootfs.as_path()),
        rootfs.as_path(),
        Option::<&str>::None,
        MsFlags::MS_BIND | MsFlags::MS_REC,
        Option::<&str>::None,
    ).context("bind mount rootfs failed")?;

    let old_root = rootfs.join(".oldroot");
    fs::create_dir_all(&old_root).context("create .oldroot failed")?;

    pivot_root(&rootfs, &old_root).context("pivot_root failed")?;

    chdir("/").context("chdir / failed")?;

    umount2("/.oldroot", MntFlags::MNT_DETACH).context("umount old root failed")?;
    fs::remove_dir_all("/.oldroot").context("remove old root failed")?;

    Ok(())
}

そして、子process側で exec_command の前に呼びます。

ForkResult::Child => {
    sethostname("microbox").context("sethostname failed")?;
    make_mount_private()?;
    pivot_to_rootfs(&rootfs)?;
    exec_command(&command)?;
}

これで、コンテナ内の /./rootfs になります。

sudo cargo run -- ./rootfs /bin/sh
ls /

11. /proc をmountする

依然として/procはホスト側のものなので、コンテナ内でPID namespaceに対応したprocess一覧を見るために、コンテナ内で新しくprocfsをmountします。

fn mount_proc() -> Result<()> {
    fs::create_dir_all("/proc").context("create /proc failed")?;

    mount(
        Some("proc"),
        "/proc",
        Some("proc"),
        MsFlags::empty(),
        Option::<&str>::None,
    ).context("mount proc failed")?;

    Ok(())
}

子process側で、pivot_to_rootfs の後に呼びます。

ForkResult::Child => {
    sethostname("microbox").context("sethostname failed")?;
    make_mount_private()?;
    pivot_to_rootfs(&rootfs)?;
    mount_proc()?;
    exec_command(&command)?;
}

実行してみます。

sudo cargo run -- ./rootfs /bin/sh

中でpsを実行すると、コンテナ内のprocessだけが見えるはずです。

12. cgroupsを用いた資源の制限

ここまででかなり「コンテナっぽく」なりましたが、まだ資源制限がありません。コンテナ内のprocessが大量にメモリを使えば、ホスト全体に影響を与えるかもしれません。そこで、cgroups を使います。

現代のLinuxでは、cgroup v2を使うのが自然で、/sys/fs/cgroup 以下のファイルを書き換えて制御します。

# 例
sudo mkdir /sys/fs/cgroup/microbox-test
echo $$ | sudo tee /sys/fs/cgroup/microbox-test/cgroup.procs
echo 134217728 | sudo tee /sys/fs/cgroup/microbox-test/memory.max
echo 64 | sudo tee /sys/fs/cgroup/microbox-test/pids.max

Rustでも同じことをします。

use nix::unistd::Pid;

fn setup_cgroup(name: &str, pid: Pid) -> Result<()> {
    let base = PathBuf::from("/sys/fs/cgroup").join(name);

    fs::create_dir_all(&base).context("create cgroup failed")?;

    // 128MB
    fs::write(base.join("memory.max"), "134217728")
        .context("write memory.max failed")?;

    // 最大64 process
    fs::write(base.join("pids.max"), "64")
        .context("write pids.max failed")?;

    // 100ms中50msぶんのCPU時間を使える、という意味。
    // ざっくり50%制限。
    fs::write(base.join("cpu.max"), "50000 100000")
        .context("write cpu.max failed")?;

    fs::write(base.join("cgroup.procs"), pid.as_raw().to_string())
        .context("write cgroup.procs failed")?;

    Ok(())
}

親process側で、forkした子processをcgroupに入れます。

match unsafe { fork()? } {
    ForkResult::Parent { child } => {
        let cgroup_name = format!("microbox-{}", child);
        setup_cgroup(&cgroup_name, child)?;
        waitpid(child, None).context("waitpid failed")?;
    }
    ForkResult::Child => {
        sethostname("microbox").context("sethostname failed")?;
        make_mount_private()?;
        pivot_to_rootfs(&rootfs)?;
        mount_proc()?;
        exec_command(&command)?;
    }
}

これで、コンテナ内processに資源制限がかかります。ただし、この実装にはraceがあり、親が子をcgroupに入れる前に、子がすでにexecしてしまう可能性があります。本物のruntimeでは、pipeやsocketpairを使って親子を同期します。

13. ここまでの全体コード

use anyhow::{bail, Context, Result};
use nix::mount::{mount, umount2, MntFlags, MsFlags};
use nix::sched::{unshare, CloneFlags};
use nix::sys::wait::waitpid;
use nix::unistd::{
    chdir, execvp, fork, pivot_root, sethostname, ForkResult, Pid,
};
use std::env;
use std::ffi::{CStr, CString};
use std::fs;
use std::path::{Path, PathBuf};

fn main() -> Result<()> {
    let mut args = env::args().skip(1).collect::<Vec<_>>();

    if args.len() < 2 {
        bail!("usage: microbox <rootfs> <command> [args...]");
    }

    let rootfs = PathBuf::from(args.remove(0));
    let command = args;

    unshare(
        CloneFlags::CLONE_NEWUTS
            | CloneFlags::CLONE_NEWPID
            | CloneFlags::CLONE_NEWNS
            | CloneFlags::CLONE_NEWIPC,
    )
    .context("unshare failed")?;

    match unsafe { fork()? } {
        ForkResult::Parent { child } => {
            let cgroup_name = format!("microbox-{}", child);
            setup_cgroup(&cgroup_name, child)?;

            waitpid(child, None).context("waitpid failed")?;
        }

        ForkResult::Child => {
            sethostname("microbox").context("sethostname failed")?;

            make_mount_private()?;
            pivot_to_rootfs(&rootfs)?;
            mount_proc()?;

            exec_command(&command)?;
        }
    }

    Ok(())
}

fn make_mount_private() -> Result<()> {
    mount(
        Option::<&str>::None,
        "/",
        Option::<&str>::None,
        MsFlags::MS_REC | MsFlags::MS_PRIVATE,
        Option::<&str>::None,
    )
    .context("make mounts private failed")?;

    Ok(())
}

fn pivot_to_rootfs(rootfs: &Path) -> Result<()> {
    let rootfs = rootfs
        .canonicalize()
        .context("canonicalize rootfs failed")?;

    mount(
        Some(rootfs.as_path()),
        rootfs.as_path(),
        Option::<&str>::None,
        MsFlags::MS_BIND | MsFlags::MS_REC,
        Option::<&str>::None,
    )
    .context("bind mount rootfs failed")?;

    let old_root = rootfs.join(".oldroot");
    fs::create_dir_all(&old_root).context("create .oldroot failed")?;

    pivot_root(&rootfs, &old_root).context("pivot_root failed")?;

    chdir("/").context("chdir / failed")?;

    umount2("/.oldroot", MntFlags::MNT_DETACH).context("umount old root failed")?;
    fs::remove_dir_all("/.oldroot").context("remove old root failed")?;

    Ok(())
}

fn mount_proc() -> Result<()> {
    fs::create_dir_all("/proc").context("create /proc failed")?;

    mount(
        Some("proc"),
        "/proc",
        Some("proc"),
        MsFlags::empty(),
        Option::<&str>::None,
    )
    .context("mount proc failed")?;

    Ok(())
}

fn setup_cgroup(name: &str, pid: Pid) -> Result<()> {
    let base = PathBuf::from("/sys/fs/cgroup").join(name);

    fs::create_dir_all(&base).context("create cgroup failed")?;

    fs::write(base.join("memory.max"), "134217728")
        .context("write memory.max failed")?;

    fs::write(base.join("pids.max"), "64")
        .context("write pids.max failed")?;

    fs::write(base.join("cpu.max"), "50000 100000")
        .context("write cpu.max failed")?;

    fs::write(base.join("cgroup.procs"), pid.as_raw().to_string())
        .context("write cgroup.procs failed")?;

    Ok(())
}

fn exec_command(argv: &[String]) -> Result<()> {
    let cstrings = argv
        .iter()
        .map(|s| CString::new(s.as_str()))
        .collect::<std::result::Result<Vec<_>, _>>()?;

    let cstrs = cstrings
        .iter()
        .map(|s| s.as_c_str())
        .collect::<Vec<&CStr>>();

    execvp(cstrs[0], &cstrs).context("execvp failed")?;

    Ok(())
}

実行してみます。

cargo build
sudo ./target/debug/microbox ./rootfs /bin/sh

中で確認します。

hostname
echo $$
ps
ls /
cat /proc/self/status | grep Cap

ここまでで、

  • hostnameが変わる
  • PID 1になる
  • root filesystemが変わる
  • /proc がコンテナ内PID namespaceに対応する
  • cgroupによる資源制限がかかる

を確認しました。

14. capabilities

コンテナ内で whoami すると、root と出るかもしれません。しかし、「rootと表示される」ことと「何でもできる」ことは同じではありません。Linuxのroot権限は、capabilityという細かい権限に分解されています。

例えば、

capability できること
CAP_SYS_ADMIN mountなど非常に広範な管理操作
CAP_NET_ADMIN network interfaceやroutingの変更
CAP_SYS_PTRACE 他processのtrace
CAP_SYS_TIME system clockの変更
CAP_SYS_CHROOT chrootの実行

コンテナ内でrootに見えても、危険なcapabilityを落とせば、できることを制限できます。現在のcapabilityは /proc/self/status で見られます。

cat /proc/self/status | grep Cap

例えば次のような行が出ます。

CapInh:  0000000000000000
CapPrm:  00000000a80425fb
CapEff:  00000000a80425fb
CapBnd:  00000000a80425fb
CapAmb:  0000000000000000

学習用に、bounding setからいくつか危険なcapabilityを落としてみます。

fn drop_capability_from_bounding_set(cap: libc::c_int) -> Result<()> {
    let ret = unsafe {
        libc::prctl(
            libc::PR_CAPBSET_DROP,
            cap,
            0,
            0,
            0,
        )
    };

    if ret != 0 {
        return Err(std::io::Error::last_os_error()).context("prctl PR_CAPBSET_DROP failed");
    }

    Ok(())
}

たとえば、setupが終わったあとで CAP_SYS_ADMIN を落とします。

fn drop_some_capabilities() -> Result<()> {
    drop_capability_from_bounding_set(libc::CAP_SYS_ADMIN)?;
    drop_capability_from_bounding_set(libc::CAP_NET_ADMIN)?;
    drop_capability_from_bounding_set(libc::CAP_SYS_PTRACE)?;
    drop_capability_from_bounding_set(libc::CAP_SYS_TIME)?;
    Ok(())
}

pivot_rootmount には強い権限が必要なため、capabilityを落とすのは、mountやpivot_rootなどの初期化が終わった後です。

ForkResult::Child => {
    sethostname("microbox").context("sethostname failed")?;

    make_mount_private()?;
    pivot_to_rootfs(&rootfs)?;
    mount_proc()?;

    drop_some_capabilities()?;

    exec_command(&command)?;
}

15. Landlock:filesystem accessをさらに細かく制限する

ここまでで、コンテナらしい分離はかなりできましたが、「コンテナ内からどのファイルにどうアクセスできるか」をどう制限するか、という視点が残っています。

たとえば、pivot_root によってコンテナ内の /rootfs に変わりました。しかし、これはあくまで「どのfilesystem treeを見せるか」を変えているだけです。見えているfilesystemの中で、

  • /bin/sh は実行してよい
  • /etc/passwd は読めても書けないようにしたい
  • /tmp だけは書き込み可能にしたい
  • /proc/dev はできるだけ狭くしたい
  • ホストからbind mountした作業ディレクトリはread-onlyにしたい

といったpolicyを表現したくなります。ここで使えるのがLandlockです。

LandlockはLinux Security Module、つまりLSMの一種です。LSMというのは、Linux kernelの中でアクセス制御を追加するための仕組みで、AppArmorやSELinuxもLSMです。

ただし、Landlockには大きな特徴があります。

Landlockは、非root processでも自分自身とその子processを制限できる。

つまり、process が自分で、

これ以降、自分と自分の子processはこの範囲のファイルにしかアクセスできない

と kernel に宣言するための仕組みです。

今回の microbox では、まず単純なpolicyを入れます。

/     は read + execute
/tmp  は read + write + execute

つまり、コンテナ内のほとんどのfilesystemは読み取り専用にし、/tmp だけ書き込み可能にします。これにより、コンテナ内で次のような挙動を期待できます。

/ $ cat /etc/passwd
...

/ $ echo hello > /tmp/ok
/ $ cat /tmp/ok
hello

/ $ echo hacked > /etc/passwd
sh: can't create /etc/passwd: Permission denied

今回は、landlock crateを追加します。

[dependencies]
anyhow = "1"
libc = "0.2"
landlock = "0.4.5"

[dependencies.nix]
version = "0.29"
features = ["sched", "mount", "hostname", "fs", "process", "signal", "user"]

src/main.rs にLandlock用のimportを追加します。

use landlock::{
    path_beneath_rules,
    ABI,
    Access,
    AccessFs,
    Ruleset,
    RulesetAttr,
    RulesetCreatedAttr,
    RulesetStatus,
};

次に、Landlock policyを適用する関数を書きます。

fn apply_landlock_policy() -> Result<()> {
    // まずは互換性重視で ABI::V1 を使う。
    //
    // 新しいABIでは、truncate, ioctl, network, scopeなど、
    // より細かい制御も扱える。
    // ただし、この記事ではfilesystemの基本に集中する。
    let abi = ABI::V1;

    let status = Ruleset::default()
        // handle_access に含めたaccessは、
        // 明示的に許可しない限りdenyされる。
        .handle_access(AccessFs::from_all(abi))
        .context("landlock: handle_access failed")?
        .create()
        .context("landlock: create ruleset failed")?
        // / 以下は読み取り・実行を許可する。
        // つまり、基本的にはrootfs全体をread-onlyにする。
        .add_rules(path_beneath_rules(["/"], AccessFs::from_read(abi)))
        .context("landlock: add read-only / rule failed")?
        // /tmp 以下だけは書き込みも許可する。
        .add_rules(path_beneath_rules(["/tmp"], AccessFs::from_all(abi)))
        .context("landlock: add read-write /tmp rule failed")?
        .restrict_self()
        .context("landlock: restrict_self failed")?;

    match status.ruleset {
        RulesetStatus::FullyEnforced => {
            eprintln!("Landlock: fully enforced");
        }
        RulesetStatus::PartiallyEnforced => {
            eprintln!("Landlock: partially enforced");
        }
        RulesetStatus::NotEnforced => {
            eprintln!("Landlock: not enforced on this kernel");
        }
    }

    Ok(())
}

ここで一番重要なのは、handle_access の意味です。Landlockでは、rulesetが「どの種類のaccessを管理対象にするか」をまず宣言します。

.handle_access(AccessFs::from_all(abi))

これは、

filesystem accessをLandlockの管理対象にする

という意味です。

そして、その後に add_rules で許可する範囲を足していきます。

.add_rules(path_beneath_rules(["/"], AccessFs::from_read(abi)))
.add_rules(path_beneath_rules(["/tmp"], AccessFs::from_all(abi)))

Landlockを適用する位置はかなり重要です。たとえば、pivot_root/proc のmountより前にLandlockを適用すると、必要なmount操作やfilesystem操作までdenyしてしまうかもしれません。したがって、本記事では、コンテナのfilesystem setupが終わったあと、execの直前 に適用します。

ForkResult::Child => {
    sethostname("microbox").context("sethostname failed")?;

    make_mount_private()?;
    pivot_to_rootfs(&rootfs)?;
    mount_proc()?;

    // ここでfilesystem policyを固定する。
    // これ以降、このprocessと子processはLandlockの制限を受ける。
    apply_landlock_policy()?;

    // さらにroot権限やsyscallを絞る。
    drop_some_capabilities()?;
    // install_seccomp_filter()?;

    exec_command(&command)?;
}

この順番にすると、/bin/sh が起動する前にLandlockが有効になります。そのため、shell自身だけでなく、shellから起動される子processにも同じfilesystem policyが引き継がれます。

16. その他

本格的なコンテナは、上記以外にも多くの仕組みを組み合わせて構築されています。

16.1 seccomp:syscall単位で攻撃面を減らす

Linux processは、最終的にはsystem callを通じてkernelに依頼を出します。例えば、openread`write`などがあげられます。seccompを使うと、processが呼べるsystem callを制限することで、

  • ptrace を禁止する
  • keyctl を禁止する
  • bpf を禁止する
  • 新しいuser namespaceの作成を禁止する
  • mount を禁止する

といったことができます。

なお、コンテナ起動のためには、最初に mount や pivot_root が必要なため、seccomp filterは、初期化が終わった最後に入れる必要があります。

1. namespaceを作る
2. hostnameを設定する
3. mount namespaceをprivateにする
4. pivot_rootする
5. /procをmountする
6. cgroupに入れる
7. capabilityを落とす
8. seccomp filterを入れる
9. execする

16.2. network namespace

network namespaceを入れるだけなら、CloneFlags::CLONE_NEWNETを追加するだけですが、それだけではコンテナ内のnetworkはほぼ使えません。新しいnetwork namespaceには、独立したnetwork stackがあります。したがって、外と通信するには通常、次のような作業が必要になります。

1. host側にbridgeを作る
2. veth pairを作る
3. vethの片方をhost bridgeにつなぐ
4. もう片方をcontainerのnetwork namespaceに移す
5. container内でIP addressを設定する
6. routing tableを設定する
7. 必要ならNATを設定する
8. port mappingを設定する

16.3. user namespaceとrootless container

この記事の実装は、root権限で動かす前提です。

sudo ./target/debug/microbox ./rootfs /bin/sh

しかし、現代のコンテナではrootless化も重要です。そこで使うのがuser namespaceです。user namespaceを使うと、コンテナ内ではUID 0に見えるが、ホスト側では通常ユーザーに対応する、というmappingを作れます。

イメージとしてはこうです。

container内 UID 0  ->  host側 UID 1000
container内 GID 0  ->  host側 GID 1000

これにより、コンテナ内でrootに見えても、ホスト側の本物のrootではありません。

ただし、user namespaceの実装は少し複雑で、一般的には、親processと子processの間で同期が必要になります。

1. 子processをuser namespace付きで作る
2. 子processはすぐには進まず、pipeなどで待つ
3. 親processが /proc/<child_pid>/uid_map を書く
4. 親processが /proc/<child_pid>/setgroups を設定する
5. 親processが /proc/<child_pid>/gid_map を書く
6. 親processが子processに「進んでよい」と通知する
7. 子processがsetupを続ける

17. おわりに

コンテナは、一つの巨大な魔法ではなく、小さなLinux kernel機能の組み合わせです。この記事では、Rustで小さなコンテナruntimeを作りながら、次のことを確認しました。

container
= Linux process
+ namespaces
+ different root filesystem
+ cgroups
+ capabilities
+ seccomp
+ a lot of careful setup

DockerやPodmanを使っていると、これらはすべて隠れています。それは便利ですが、何かが壊れたとき、またはsandboxを設計したいときには、隠れている境界を理解していることが大きな力になります。

18. 参考

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?