clone(2)にnamespaceフラグをまとめて渡し、PID/UTS/IPCを分離する——同じプロセスなのに親と子で見える世界が違う理由

コンテナランタイムの中核となるnamespace分離のうち、PID・UTS・IPCの3つを実装しました。この3つはどれも「clone(2)に対応するフラグを渡すだけでカーネル資源のID空間が分離される」という共通の仕組みを持っているため、1つのフェーズにまとめて扱います。同じLinuxカーネル上で動いていても、namespaceが異なれば見える値がまったく変わる、という挙動を実際に確認します。

namespaceの基本: 1回のclone呼び出しで複数のnamespaceを作る

fork(2)は現在のnamespaceの中で子プロセスを複製するだけで、新しいnamespaceを作成する機能はありません。namespaceを新規作成しながらプロセスを起動するには、clone(2)にnamespace用のフラグを渡す必要があります。

CLONE_NEWPID  ... プロセスIDの空間を分離
CLONE_NEWUTS  ... ホスト名/ドメイン名を分離
CLONE_NEWIPC  ... System V IPC/POSIXメッセージキューのID空間を分離

これらのフラグはビットORで組み合わせられるので、CLONE_NEWPID | CLONE_NEWUTS | CLONE_NEWIPCとすれば1回のclone呼び出しで3つのnamespaceを同時に作成できます。実際のコンテナランタイムでも、必要なnamespaceフラグを1回のclone呼び出しにまとめて渡すのが一般的です。

std.os.linuxclone()ラッパーの扱い方や、CLONE_NEW*系フラグを自前で定義した経緯といった実装の詳細は、std.os.linuxの薄いラッパーを自作する——sethostname(2)に高レベル関数が存在しない問題に追記してあります。ここではsys.cloneInNamespace/sys.waitForChildをブラックボックスとして使い、namespaceそのものの挙動を確認します。

PID namespace: 子から見ると自分がPID 1

PID namespaceは、プロセスIDの割り当て空間をグループごとに分離する仕組みです。新しいPID namespace内で最初に生成されたプロセスは、そのnamespace内ではPID 1として扱われます(従来のinitプロセスに相当する特別な立場になります)。一方、そのプロセスをホスト側(親のnamespace)から見ると、システム全体でユニークな通常のPIDとして見えます。コンテナ内でpsを打つとPID 1からプロセスが並んでいるように見えるのは、この仕組みによるものです。

UTS namespace: ホスト名を変えても親には影響しない

UTS namespaceは、ホスト名とNISドメイン名を分離します。CLONE_NEWUTSを指定した子プロセス内でsethostname(2)を呼んでも、ホスト(親のnamespace)側のホスト名には影響しません。setHostname/getUnameはPhase 1で実装済みなので、今回はこれをUTS namespace内で呼び出すだけです。

IPC namespace: 同じキーでも新しいnamespaceからは見えない

IPC namespaceは、System V IPC(共有メモリ・メッセージキュー・セマフォ)のID空間を分離します。System V IPCはキー(key_t)を指定してオブジェクトを共有する仕組みなので、キーさえ知っていればどのプロセスからでも同じオブジェクトにたどり着けます。裏を返せば、namespaceで分離しないとコンテナ間でキーが衝突・干渉しうる、ということです。

分離を確認する方法として、最初は「親子それぞれで同じキーを指定してshmgetし、返ってきたshmidを見比べる」という方法を試しました。しかしこれは誤りで、親子とも同じshmidが返ってくることがあります。shmidはグローバルに一意な値ではなく、そのnamespace内の管理テーブルの何番目のスロットが割り当てられたかで決まる値だからです。プログラム終了時に毎回セグメントを削除していると、親のnamespaceも子の(新規に作られた空の)namespaceも「そのnamespaceで最初に確保されたセグメント」という状態が揃ってしまい、たまたま同じshmidになります。

そこで、子プロセス側ではIPC_CREATを付けずに(=新規作成せず、存在確認のみで)同じキーを検索する方式にしました。namespaceが分離されていなければ親が作ったセグメントがそのまま見つかりますが、分離されていれば「そのキーのセグメントは存在しない」(ENOENT)で失敗します。新規作成の可否ではなく検索の成否を見ることで、分離の有無をはっきり判別できます。

実装

const SHM_KEY: i32 = 0x1234;

var child_stack: [1024 * 1024]u8 align(16) = undefined;

fn childMain(parent_shmid: usize) callconv(.c) u8 {
    std.debug.print("[child] pid inside new PID namespace: {d}\n", .{sys.getPid()});

    sys.setHostname("zig-container") catch |err| {
        std.debug.print("[child] setHostname failed: {s}\n", .{@errorName(err)});
        return 1;
    };

    const uts = sys.getUname() catch |err| {
        std.debug.print("[child] getUname failed: {s}\n", .{@errorName(err)});
        return 1;
    };
    std.debug.print(
        "[child] hostname inside new UTS namespace: {s}\n",
        .{std.mem.sliceTo(&uts.nodename, 0)},
    );

    if (sys.shmGet(SHM_KEY, 4096, 0)) |found_shmid| {
        std.debug.print(
            "[child] unexpectedly found parent's shmid {d} (namespace isolation failed?)\n",
            .{found_shmid},
        );
    } else |err| {
        std.debug.print(
            "[child] shmget lookup for the same key failed as expected: {s} (parent's shmid was {d}, but this IPC namespace has no such segment)\n",
            .{ @errorName(err), parent_shmid },
        );
    }

    return 0;
}

fn runInNewNamespace() !void {
    std.debug.print("[parent] pid: {d}\n", .{sys.getPid()});

    const parent_shmid = try sys.shmGet(SHM_KEY, 4096, sys.IPC_CREAT | 0o600);
    std.debug.print(
        "[parent] shmget returned shmid {d} (visible host-wide)\n",
        .{parent_shmid},
    );

    const child_pid = sys.cloneInNamespace(
        sys.CLONE_NEWPID | sys.CLONE_NEWUTS | sys.CLONE_NEWIPC,
        &child_stack,
        childMain,
        @intCast(parent_shmid),
    ) catch |err| {
        std.debug.print("clone failed (try running with sudo): {s}\n", .{@errorName(err)});
        return;
    };
    std.debug.print(
        "[parent] child pid (as seen from parent's namespace): {d}\n",
        .{child_pid},
    );

    const exit_status = try sys.waitForChild(child_pid);
    std.debug.print("[parent] child exited with status: {d}\n", .{exit_status});

    const uts = try sys.getUname();
    std.debug.print(
        "[parent] hostname on host: {s}\n",
        .{std.mem.sliceTo(&uts.nodename, 0)},
    );

    try sys.shmRemove(parent_shmid);
    std.debug.print("[parent] removed shmid {d}\n", .{parent_shmid});
}

親のshmidを子へ渡すのに、cloneInNamespacearg引数(子の関数にそのまま渡されるusize値)を使っています。子プロセス側で「親のshmidと比較して、それが見えないことを示す」というログを出すためだけの用途で、分離の判定ロジック自体には使っていません。

動作確認

CLONE_NEWPID/CLONE_NEWUTS/CLONE_NEWIPCはいずれもCAP_SYS_ADMIN権限を要求するため、sudoで実行しています。

$ sudo ./zig-out/bin/zigcon run
[parent] pid: 21198
[parent] shmget returned shmid 1 (visible host-wide)
[parent] child pid (as seen from parent's namespace): 21199
[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 (parent's shmid was 1, but this IPC namespace has no such segment)
[parent] child exited with status: 0
[parent] hostname on host: server
[parent] removed shmid 1

3つのnamespaceすべてで、親と子で見える結果が異なることが確認できました。

  • PID: 親から見た子のPIDは21199(ホスト全体でユニークな値)ですが、子自身がgetpid()で取得したPIDは1です。
  • UTS: 子は自分のホスト名をzig-containerに変更できていますが、子プロセス終了後に親が読んだホスト名はserverのままで、ホスト全体には影響していません。
  • IPC: 親が作成した共有メモリ(shmid 1)を、子が同じキーで検索してもNotFoundになり、見つかりません。

なお、現時点ではこれらのnamespaceフラグ単独では特権が必要なためsudo実行が前提になっています。CLONE_NEWUSERと組み合わせたuser namespaceによるrootless対応は、別フェーズで扱います(現時点では未対応です)。