Mount namespaceとpivot_rootでrootfsを差し替える——busyboxの--installが生成する絶対パスシンボリックリンクが壊れる理由
clone(2)にnamespaceフラグをまとめて渡し、PID/UTS/IPCを分離する記事ではPID/UTS/IPC namespaceを実装しました。今回はMount namespaceとpivot_root(2)を実装し、コンテナ専用のルートファイルシステム(rootfs)に切り替える処理を組み込みます。あわせて、コンテナ内で最初に動かすシェルとしてbusyboxベースの最小rootfsを用意しています。
Mount namespace・pivot_root・mount propagationの用語自体は用語集にまとめてあるので、ここでは実装に絞って説明します。
rootfsの用意
busyboxの静的リンクバイナリ1つから、最小限のrootfsを作ります。
mkdir -p rootfs/bin rootfs/proc
cp /usr/bin/busybox rootfs/bin/busybox
cd rootfs/bin
./busybox --install -s .busybox --install -sは、ls・cat・shなどbusyboxが対応するコマンド名ぶんのシンボリックリンクを一括生成するオプションです。ポイントは--installを実行する際のカレントディレクトリをrootfs/binにしておくことです。busyboxは自分自身の実行パスを/proc/self/exeから絶対パスで取得し、それをシンボリックリンクのリンク先に使います。rootfs/binの外(プロジェクトルートなど)からrootfs/bin/busybox --install -s rootfs/binのように相対パス経由で呼び出すと、リンク先が/home/user/zig-container/rootfs/bin/busyboxのようなホスト側の絶対パスになってしまいます。
これはpivot_root前のホスト環境では問題なく動作しますが、pivot_root後は新しいルートがrootfsそのものに置き換わるため、/home/user/zig-container/rootfs/bin/busyboxというパスはコンテナ内に存在しない扱いになります。結果として/bin/sh起動時のexecve(2)がENOENTで失敗します。rootfs/bin内で--installを実行すれば、リンク先はbusyboxという同一ディレクトリ内の相対パスになり、ホスト側でもpivot_root後でも同じように解決できます。
Mount namespaceとpivot_rootの実装
CLONE_NEWNSをclone(2)に渡すと、子プロセスは親と同じマウント一覧をコピーした、独立したMount namespaceの中で動作します。この状態を前提に、linux.zig側にpivotRootInto/mountProc/execIntoを追加しました。
pivotRootInto:自分のマウント一覧をすべてprivateにした上で、new_rootをbindマウントしてマウントポイント化し、pivot_root(2)でルートを切り替え、古いルートをMNT_DETACHで遅延アンマウントする、という一連の手順をまとめた関数mountProc:ルート切り替え後に/procを再マウントする関数(psなどが/procを読むため必須)execInto:execve(2)の薄いラッパー
これらの実装や、std.os.linuxにどこまで既存のラッパーがあるかといった詳細はstd.os.linuxの薄いラッパーを自作する記事に追記したので、ここではsys.pivotRootInto/sys.mountProc/sys.execIntoをブラックボックスとして使い、childMain側からどう呼ぶかだけを見ます。
try sys.pivotRootInto(rootfs);
try sys.mountProc();
const shell_argv = [_:null]?[*:0]const u8{"/bin/sh"};
const shell_envp = [_:null]?[*:0]const u8{"PATH=/bin"};
try sys.execInto("/bin/sh", &shell_argv, &shell_envp);clone(2)のフラグにはCLONE_NEWNSを追加し、main.zig側で起動時の引数としてrootfsのパスを受け取るようにしています。
const child_pid = sys.cloneInNamespace(
sys.CLONE_NEWPID | sys.CLONE_NEWUTS | sys.CLONE_NEWIPC | sys.CLONE_NEWNS,
&child_stack,
childMain,
@intFromPtr(rootfs.ptr),
) catch |err| {
std.debug.print("clone failed (try running with sudo): {s}\n", .{@errorName(err)});
return;
};動作確認
$ sudo ./zig-out/bin/zigcon run ./rootfs
[parent] pid: 4581
[parent] shmget returned shmid 1 (visible host-wide)
[parent] child pid (as seen from parent's namespace): 4582
[child] pid inside new PID namespace: 1
[child] hostname inside new UTS namespace: zig-container
[child] shmget lookup for the same key failed as expected: NotFound (this IPC namespace has no such segment)
[child] pivot_root done, root filesystem is now ./rootfs
[child] handing over to /bin/sh
BusyBox v1.30.1 (Ubuntu 1:1.30.1-7ubuntu3.1) built-in shell (ash)
Enter 'help' for a list of built-in commands.
/ #/bin/shに入った状態でpsを実行するとPID 1(自分自身)しか見えず、hostnameはzig-containerになっており、ls /ではrootfsに用意したディレクトリ(bin/proc/home)だけが見えます。PID/UTS/IPC/Mount namespaceとpivot_rootによって、ホストとは別の環境として振る舞えていることが確認できました。
備考: CLONE_NEWNSの指定漏れでホストの/を壊した
上記の実装に至る前、clone(2)のフラグにCLONE_NEWNSを含め忘れたままsudoで実行してしまい、ホストマシンの/と/procが実際に差し替わってしまう事故を起こしました。復旧は再起動のみで対応しています。
原因は単純で、CLONE_NEWNSを渡さないと子プロセスは親(=ホスト)と同じMount namespaceのまま動作し続けます。この状態でpivotRootInto内のmount(MS_PRIVATE)・pivot_root・umount2(MNT_DETACH)を実行すると、これらはすべて「現在のMount namespace」に対する操作として扱われるため、ホストのMount namespaceそのものの/が指定したrootfsに置き換わり、元の/はumount2(MNT_DETACH)によって切り離されます。子プロセスに閉じた変更のつもりが、実際にはホスト全体に影響する操作になっていました。
clone(2)はnamespace用のフラグを渡さなければ、その種類のnamespaceを新規作成せず親のnamespaceをそのまま共有するという仕様です。PID/UTS/IPC namespaceであれば、フラグを付け忘れても「分離されていないだけ」で実害はほぼありませんが、Mount namespaceでpivot_rootやumountのような破壊的な操作を伴う場合は、フラグの付け忘れがそのままホストへの実害に直結します。この非対称性は実装前に想定できておらず、今回のように実際に壊してから初めて認識しました。
再現・検証する場合は、可能であれば使い捨てにできるVMやネストしたコンテナ環境で試すことを推奨します。