exec fifoでcreate/startを同期し、コンテナのライフサイクル管理(create/start/kill/state/delete)を実装する

これまでの実装は、`run`コマンド一発でコンテナを起動する形にとどまっていました。今回はOCI Runtime Specに準拠したライフサイクル管理として、create(コンテナの準備のみ行い、実行はしない)・start(準備済みのコンテナを実行する)・killstatedeleteの5つのサブコマンドを実装しました。createstartを分離する都合上、コンテナのinitプロセスを「準備は終わっているが、実行はまだ始めない」状態でブロックさせておく必要があり、名前付きパイプ(exec fifo)を使ってその同期を取っています。

この設計自体はruncのexec.fifoの仕組みを踏襲したものですが、実装・検証の過程で4つの独立したはまりどころに遭遇しました。後半でそれぞれ具体的に書いています。

設計: stateファイルとexec fifoによる二段階起動

コンテナごとの状態は/run/zigcon/<id>/state.jsonに永続化し、createstartkillstatedeleteはすべてこのファイルを介して同じコンテナを参照します。

pub const STATE_ROOT = "/run/zigcon";

pub const Status = enum { created, running, stopped };

pub const State = struct {
    id: []const u8,
    pid: i32,
    bundle: []const u8,
    status: Status,
};

pub fn stateDir(allocator: std.mem.Allocator, id: []const u8) ![]const u8 {
    return std.fmt.allocPrint(allocator, "{s}/{s}", .{ STATE_ROOT, id });
}

pub fn execFifoPath(allocator: std.mem.Allocator, id: []const u8) ![:0]const u8 {
    return std.fmt.allocPrintSentinel(
        allocator,
        "{s}/{s}/exec.fifo",
        .{ STATE_ROOT, id },
        0
    );
}

pub fn save(allocator: std.mem.Allocator, io: std.Io, state: State) !void {
    const dir_path = try stateDir(allocator, state.id);
    const dir = try std.Io.Dir.cwd().createDirPathOpen(io, dir_path, .{});
    defer dir.close(io);

    const file = try dir.createFile(io, "state.json", .{});
    defer file.close(io);

    var buffer: [4096]u8 = undefined;
    var writer = file.writer(io, &buffer);
    try std.json.Stringify.value(state, .{ .whitespace = .indent_2 }, &writer.interface);
    try writer.interface.flush();
}

createが行うのは、namespace作成・rootfs差し替え・cgroup設定・veth構築・capabilities/seccomp適用など、`pivot_root`によるrootfs差し替えの記事から実装してきた準備一式に加えて、mknodで名前付きパイプexec.fifoを作っておくことです。準備が整った子プロセスは、実際にプログラムをexecveする直前でこのexec.fifoを読み込みモードでopenし、書き込み側が現れるまでブロックします。

if (exec_fifo_path_fd) |path_fd| {
    std.debug.print("[child] waiting for start signal on exec fifo\n", .{});

    var proc_fd_path_buf: [32]u8 = undefined;
    const proc_fd_path = std.fmt.bufPrintZ(&proc_fd_path_buf, "/proc/self/fd/{d}", .{path_fd}) catch unreachable;

    const fifo_fd = sys.openForReading(proc_fd_path) catch |err| {
        std.debug.print("[child] opening exec fifo failed: {s}\n", .{@errorName(err)});
        return 1;
    };
    sys.closeFd(path_fd);

    var start_byte: [1]u8 = undefined;
    _ = sys.readFd(fifo_fd, &start_byte) catch |err| {
        std.debug.print("[child] reading exec fifo failed: {s}\n", .{@errorName(err)});
        return 1;
    };
    sys.closeFd(fifo_fd);
}

std.debug.print("[child] handing over to {s}\n", .{std.mem.span(ctx.program)});
sys.execInto(ctx.program, ctx.argv, ctx.envp) catch |err| {
    std.debug.print("[child] execve failed: {s}\n", .{@errorName(err)});
    return 1;
};

start側は、このパイプを書き込みモードでopenして1バイト書き込むだけです。

fn startContainer(allocator: std.mem.Allocator, io: std.Io, id: []const u8) !void {
    var state = try lifecycle.load(allocator, io, id);
    if (state.status != .created) return error.ContainerNotCreated;

    const exec_fifo_path = try lifecycle.execFifoPath(allocator, id);
    const fifo_fd = try sys.openForWriting(exec_fifo_path);
    _ = try sys.writeFd(fifo_fd, "x");
    sys.closeFd(fifo_fd);

    state.status = .running;
    try lifecycle.save(allocator, io, state);
    std.debug.print("[parent] container {s} started\n", .{id});
}

名前付きパイプは、読み込み側・書き込み側のopenが両方揃うまでどちらもブロックするという性質を持っているため、追加のロックや待機処理を書かなくても「startが呼ばれるまでexecveしない」という同期がそのまま実現できます。

exec.fifoを開く前に、sys.openPathO_PATHフラグ付きの参照を先に取得しておき、pivot_root後は/proc/self/fd/<fd>経由で開き直しているのもポイントです。exec.fifoのパスはホスト側(pivot_root前)のパスなので、pivot_root後はパスとしては辿れなくなりますが、O_PATHで取得したファイルディスクリプタ自体はpivot_rootの影響を受けないため、/proc/self/fd経由での再オープンが可能です。

createはここまでの準備が完了したことを、ready_pipeという別のパイプで親プロセスに伝えます。子プロセスはcapabilities/seccompの適用まで終えた時点で1バイト書き込み、createを実行している親プロセス側はそれを受け取ってからstate.jsoncreatedを書き込みます。

遭遇した問題

今回は、動くところまで持っていくのに4つの独立した問題に当たりました。いずれも「エラーが出て終了する」ではなく「原因が分かりにくい形で止まる/誤った状態を表示する」タイプの問題だったため、記録として詳しく残しておきます。

1. pivot_root後の空の/devdetachStdioが失敗し、startが無限にブロックする

createはデーモン化のため、準備完了後に子プロセスの標準入出力を/dev/nullに付け替えます(detachStdio)。

pub fn detachStdio() SyscallError!void {
    const fd_rc = linux.open("/dev/null", .{ .ACCMODE = .RDWR }, 0);
    try check("open", fd_rc);
    const fd: i32 = @intCast(fd_rc);
    defer _ = linux.close(fd);

    try check("dup2", linux.dup2(fd, 0));
    try check("dup2", linux.dup2(fd, 1));
    try check("dup2", linux.dup2(fd, 2));
}

最初の実装ではこの処理が以下のエラーで失敗していました(detachStdioが失敗している時点ではまだ標準エラーの付け替え自体は行われていないため、このエラーメッセージ自体は画面に出力されます)。

[child] detaching stdio failed: NotFound

原因は単純で、pivot_root後のrootfsの/devが空だったことです。OCIイメージのlayerには通常/devの中身(デバイスノード)は含まれておらず、これはランタイム側が用意する決まりになっています(OCI Runtime SpecのDefault Devicesにも、ランタイムが用意すべきデフォルトのデバイス群として/dev/null等が明記されています)。今回の実装ではこの手当てを行っていなかったため、/dev/nullopenENOENTで失敗していました。

厄介なのはこの失敗の伝わり方です。detachStdioのエラーで子プロセスはreturn 1しexec fifoを待つ前に終了しますが、ready_pipeへの書き込み(=準備完了の合図)はこれより前のタイミングで既に完了しています。そのためcreateコマンド自体は正常終了し、state.jsonにはcreatedと記録されてしまいます。この状態でstartを実行すると、書き込みモードでopenしたexec.fifoに対応する読み込み側のプロセスがもう存在しないため、openが永遠にブロックし続けます。「createは成功しているように見えるのに、startがいつまでも返ってこない」という、create単体のログだけでは原因の見えないハングとして観測されました。

対処として、pivot_root直後・capability dropとseccompより前に、tmpfs/devにマウントし、最低限のキャラクタデバイス(null/zero/full/random/urandom/tty)をmknodする処理を追加しました。

pub fn mountTmpfs(target: [*:0]const u8) SyscallError!void {
    try check("mount(tmpfs)", linux.mount("tmpfs", target, "tmpfs", linux.MS.NOSUID, @intFromPtr("mode=755".ptr)));
}

pub fn makeCharDevice(path: [*:0]const u8, mode: u32, major: u32, minor: u32) SyscallError!void {
    const dev: u32 = (major << 8) | minor;
    try check("mknod", linux.mknod(path, linux.S.IFCHR | mode, dev));
}
const DEV_NODES = [_]DevNode {
    .{ .path = "/dev/null", .major = 1, .minor = 3 },
    .{ .path = "/dev/zero", .major = 1, .minor = 5 },
    .{ .path = "/dev/full", .major = 1, .minor = 7 },
    .{ .path = "/dev/random", .major = 1, .minor = 8 },
    .{ .path = "/dev/urandom", .major = 1, .minor = 9 },
    .{ .path = "/dev/tty", .major = 5, .minor = 0 },
};

fn setupMinimalDev() !void {
    try sys.mountTmpfs("/dev");
    for (DEV_NODES) |node| {
        try sys.makeCharDevice(node.path, 0o666, node.major, node.minor);
    }
}

mountにはCAP_SYS_ADMINが必要な上、capabilities削減とseccompフィルタの記事で導入したseccompフィルタがmount自体をブロックするため、この処理は権限降格・seccomp適用より前に置く必要があります。mknodに必要なCAP_MKNODはcapability dropで残す一覧に含めているため、順序さえ守れば以降の権限降格の影響は受けません。

2. clone(2)が複製するファイルディスクリプタテーブルと、初期化失敗の検知漏れ

createは子プロセスの初期化完了を検知するため、ready_pipeという単純なパイプを使っています。子は準備が整った時点でこのパイプに1バイト書き込み、親はreadFdでブロック待ちする、という設計です。

var ready_byte: [1]u8 = undefined;
const ready_len = try sys.readFd(ready_pipe[0], &ready_byte);

clone(2)fork(2)と同様に、呼び出し元プロセスのファイルディスクリプタテーブルを丸ごと複製します。つまりready_pipeの書き込み側ready_pipe[1]は本来子プロセスだけが使うべきものですが、clone直後は親プロセス自身もこの複製を無意識に持ったままになっていました。

この状態で、もし子プロセスが(例えば1で説明した/devセットアップの失敗などで)ready信号を送る前に終了すると、パイプの書き込み側の参照は親自身が持つ複製によって生き続けるため、readFdは書き込み側の全クローズによるEOF(読み取りバイト数0)を検出できず、永久にブロックし続けます。子プロセスの初期化失敗をcreateコマンド側が検知できないまま、コマンドごとハングしてしまうという、1の/dev問題とは独立した別種の問題です。

対処として、clone()直後に親プロセスが使わない側の複製(sync_pipe[0]ready_pipe[1])をすぐ閉じ、readFd0バイトを返した場合(=子が書き込む前に死んでパイプがEOFになった場合)を明示的にエラーとして扱うようにしました。

const child_pid = try sys.cloneInNamespace(
    sys.CLONE_NEWPID | sys.CLONE_NEWUTS | sys.CLONE_NEWIPC | sys.CLONE_NEWNS | sys.CLONE_NEWNET,
    &child_stack,
    childMain,
    @intFromPtr(&child_ctx),
);
std.debug.print("[parent] child pid: {d}\n", .{child_pid});

sys.closeFd(sync_pipe[0]);
sys.closeFd(ready_pipe[1]);

try setupHostNetwork(host_veth_name, peer_veth_name, child_pid);

_ = try sys.writeFd(sync_pipe[1], "x");
sys.closeFd(sync_pipe[1]);

try cgroup.addProcess(child_pid);

var ready_byte: [1]u8 = undefined;
const ready_len = try sys.readFd(ready_pipe[0], &ready_byte);
sys.closeFd(ready_pipe[0]);
if (ready_len == 0) return error.ContainerInitFailed;

pipeを使ったプロセス間同期では、clone/fork直後に「自分が使わない側の端」を即座に閉じるのが定石ですが、今回はこれを怠っていたために、単純な初期化失敗が無限ハングという分かりにくい形で表面化しました。

3. kill(pid, 0)によるプロセス生存確認でEPERMESRCHを区別していなかった

statedeleteでは、コンテナのinitプロセスがまだ生きているかどうかをkill(pid, 0)(シグナル番号0を送るだけで、実際にはシグナルを送らずエラーの有無だけを確認する慣用手段)で判定しています。

pub fn processExists(pid: linux.pid_t) bool {
    const rc = linux.kill(pid, @enumFromInt(@as(u32, 0)));
    return linux.errno(rc) == .SUCCESS;
}

動作確認中、create/startをroot権限で実行したコンテナに対してstateだけ非root権限で実行したところ、実際にはプロセスが生きているにもかかわらずstatus=stoppedと表示されました。kill(pid, 0)は、対象プロセスが存在しない場合はESRCHを返しますが、対象プロセスは存在するものの呼び出し元に権限がない場合はEPERMを返します。上記の実装はSUCCESS以外を一律「存在しない」扱いにしていたため、権限不足を「コンテナは停止している」と誤認していました。

今回の運用ではすべてのサブコマンドをroot権限で揃える前提ですが、コード側で権限不足と未存在を区別しないのは事実として不正確です。ESRCHの場合だけを確実に「存在しない」と判定し、それ以外(EPERMを含む)は安全側に倒して「存在する」とみなすよう修正しました。

pub fn processExists(pid: linux.pid_t) bool {
    const rc = linux.kill(pid, @enumFromInt(@as(u32, 0)));
    return linux.errno(rc) != .SRCH;
}

4. PID namespaceのinitプロセスは、デフォルトではSIGTERMを無視する

kill <id> [signal]はシグナル番号を省略するとSIGTERM(15)を送りますが、動作確認中、killコマンドでSIGTERMを送ってもコンテナ内のsleepプロセスが終了しない、という状況に遭遇しました。

$ sudo ./zig-out/bin/zigcon kill t2
[parent] sent signal 15 to container t2 (pid=49969)
$ sudo ./zig-out/bin/zigcon state t2
id=t2 pid=49969 bundle=pulled/library/alpine status=running

これはバグではなく、Linuxカーネルの仕様です。`pid_namespaces(7)`に明記されている通り、PID namespace内でPID 1(init)として動作するプロセスは、自身でシグナルハンドラを明示的に登録していない限り、デフォルトアクションが「終了」であるシグナル(SIGTERMなど)を無視します。SIGKILLSIGSTOPだけはこの例外で、常に効きます。今回コンテナのプロセスとして使っている/bin/sleepはシグナルハンドラを持たないため、この仕様の影響をそのまま受けていました。DockerやruncがSIGTERMを送ったあと一定時間反応がなければSIGKILLにフォールバックするのも、同じ仕様への対処です。

kill <id> 9でSIGKILLを送ると、確実にコンテナのプロセスを終了できることを確認しています。

$ sudo ./zig-out/bin/zigcon kill t2 9
[parent] sent signal 9 to container t2 (pid=49969)
$ sudo ./zig-out/bin/zigcon state t2
id=t2 pid=49969 bundle=pulled/library/alpine status=stopped

今回の実装ではシグナル番号の指定を必須にしていない(省略時はSIGTERM)ため、シグナルハンドラを持たないプロセスを確実に止めたい場合は、呼び出し側が9を明示する必要があります。

動作確認

createstartstatekilldeleteまで一通り実行し、状態遷移とプロセス・cgroup・stateディレクトリの後始末を確認しました。

$ sudo ./zig-out/bin/zigcon create app pulled/library/alpine
[parent] creating container app: rootfs=pulled/library/alpine/rootfs, process=/bin/sleep
[parent] child pid: 52275
[child] pivot_root done, root filesystem is now pulled/library/alpine/rootfs
[child] minimal /dev populated (null, zero, full, random, urandom, tty)
[child] capabilities reduced to Docker-default set
[child] seccomp filter installed (mount blocked)
[parent] container app created (pid=52275)

$ sudo ./zig-out/bin/zigcon state app
id=app pid=52275 bundle=pulled/library/alpine status=created

$ sudo ./zig-out/bin/zigcon start app
[parent] container app started

$ sudo ./zig-out/bin/zigcon state app
id=app pid=52275 bundle=pulled/library/alpine status=running

$ ps -ef | grep 'sleep 300'
root       52275       1  0 08:04 ?        00:00:00 /bin/sleep 300

$ sudo ./zig-out/bin/zigcon kill app 9
[parent] sent signal 9 to container app (pid=52275)

$ sudo ./zig-out/bin/zigcon state app
id=app pid=52275 bundle=pulled/library/alpine status=stopped

$ sudo ./zig-out/bin/zigcon delete app
[parent] container app deleted

create直後は/bin/sleepプロセスがまだexecve前で待機しており、startを呼んで初めてps/bin/sleep 300が現れること、killでPID 1のプロセスを実際に終了できること、deletestate.json/sys/fs/cgroup配下のcgroupディレクトリが両方消えることを確認しています。

今後の課題

現時点の実装では、createstartが別プロセス(別のCLI実行)にまたがる前提でexec fifoによる同期を行っていますが、複数コンテナの一括管理や、start前にcreateした子プロセスが異常終了した場合のクリーンアップ(cgroup・stateディレクトリの自動掃除)は未対応です。次のフェーズではUser namespaceによるrootless対応(uid_map/gid_mapと権限モデル)を予定しています。