はじめに
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、第二引数以降がコンテナ内で実行するコマンドです。
作る機能は次の通りです。
- UTS namespaceでhostnameを分離する
- PID namespaceでコンテナ内のprocessをPID 1にする
- mount namespaceでmount tableを分離する
- pivot_rootでroot filesystemを差し替える
- /proc をコンテナ用にmountする
- cgroup v2でメモリ・CPU・PID数を制限する
- 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を直接呼びたい場面が多いので、nix と libc を使います。
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(())
}
そして main の exec_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に適用されます。
そのため、流れはこうなります。
- 親processが unshare(CLONE_NEWPID) する
-
forkする - 子processが新しいPID namespaceのPID 1になる
- 親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_root や mount には強い権限が必要なため、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に依頼を出します。例えば、openやread`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. 参考
- Julia Evans, How Containers Work, https://jvns.ca/blog/2020/04/27/new-zine-how-containers-work/
- Lizzie Dixon, Linux containers in 500 lines of code, https://blog.lizzie.io/linux-containers-in-500-loc.html