std.os.linuxの薄いラッパーを自作する——sethostname(2)に高レベル関数が存在しない問題

Zigでコンテナランタイムを開発するプロジェクトの最初の実装として、Linux syscallを呼び出すための薄いラッパーモジュールsrc/linux.zigを用意しました。namespaces・cgroup・pivot_rootといった今後の実装は、すべてこのモジュールを土台にしていく予定です。

std.os.linuxの現状

Zigのstd.os.linuxが提供する高レベル関数の充実度は、syscallによってばらつきがあります。

たとえばuname(2)にはこのような薄いラッパーが既に用意されています。

pub fn uname(uts: *utsname) usize

一方、同じくらいよく使われるsethostname(2)には、std.os.linuxに対応する関数が存在しません。この場合は、syscall番号が定義されたSYS列挙型と、生のsyscallを発行するsyscall2/syscall3のような関数を使って直接呼び出す必要があります。

const rc = linux.syscall2(.sethostname, @intFromPtr(name.ptr), name.len);

どちらのケースでも、戻り値は「成功なら0、失敗なら-errno」という生のusizeのままで、Zigのエラーユニオン型には変換されません。この差を吸収し、以後の実装で毎回同じerrnoチェックを書かずに済むよう、自前のラッパーモジュールを用意することにしました。

設計方針

方針は次の3点です。

  1. 全syscallの戻り値をstd.os.linux.errno(rc)でチェックする共通関数を1つ用意する
  2. 個々のsyscallごとに、その共通関数を使った薄いラッパー関数を追加していく
  3. 呼び出し側がerrnoの数値を直接見なくて済むよう、名前付きのエラーセットに変換する
const std = @import("std");
const linux = std.os.linux;

pub const SyscallError = error{
    PermissionDenied,
    Unexpected,
};

fn check(comptime name: []const u8, rc: usize) SyscallError!void {
    switch (linux.errno(rc)) {
        .SUCCESS => return,
        .PERM => return error.PermissionDenied,
        else => |err| {
            std.log.err("{s} failed: errno={d} ({s})", .{ name, @intFromEnum(err), @tagName(err) });
            return error.Unexpected;
        },
    }
}

pub fn getUname() SyscallError!linux.utsname {
    var uts: linux.utsname = undefined;
    try check("uname", linux.uname(&uts));
    return uts;
}

pub fn setHostname(name: []const u8) SyscallError!void {
    const rc = linux.syscall2(.sethostname, @intFromPtr(name.ptr), name.len);
    try check("sethostname", rc);
}

SyscallErrorは現時点ではPermissionDeniedUnexpectedの2種類のみですが、namespace・mount・cgroup関連の実装が増えるにつれて、必要なエラーをこのセットに追加していきます。

utsname構造体を値として返す設計にした理由

getUnameutsname構造体をそのまま値として返す設計にしています。これは、関数内のローカル変数を指すスライスやポインタを返り値にすると、関数を抜けた時点でその参照先(スタックフレーム)が無効になるためです。

補足として、最初はnodenameフィールドだけをスライスとして返す実装を試しました。

// この書き方だと、返ってきたスライスの参照先がすでに無効になっている
pub fn getHostname() SyscallError![]const u8 {
    var uts: linux.utsname = undefined;
    try check("uname", linux.uname(&uts));
    return std.mem.sliceTo(&uts.nodename, 0);
}

このコードは問題なくコンパイルが通り、実行時にも高い頻度で「たまたま」正しい値を返しますが、utsgetHostnameのスタックフレーム上にしか存在しないため、関数を抜けた後にそのフレームが別の関数呼び出しで上書きされると、返ってきたスライスの中身は不定になります。今回は直後のstd.debug.print呼び出しが同じスタック領域を使ったため、実際に文字化けした値が表示されました。

Zigにはこの種の参照の生存期間をコンパイル時に検証する仕組みがないため、コンパイルが通ることと、参照先が有効であることは別問題として扱う必要があります。utsnameの各フィールドはポインタではなく[64:0]u8という固定長配列(実データそのもの)なので、構造体ごと値として返せば、呼び出し元に384バイト分のデータが丸ごとコピーされ、参照の生存期間を気にする必要がなくなります。

可変長のデータを扱う場合はこの方法が使えないため、その際は呼び出し元が用意したバッファに書き込む方式か、std.mem.Allocatorでヒープに確保する方式のどちらかを選ぶことになります。

動作確認

$ zig build run -- create
command: create
current hostname: server
setHostname failed as expected (no privilege / no UTS namespace yet): PermissionDenied

setHostnamePermissionDeniedで失敗するのは想定通りの結果です。sethostname(2)はCAP_SYS_ADMIN権限を要求するsyscallで、現時点ではUTS namespaceも作成していないためです。この呼び出しが成功するようになるのは、UTS namespaceを作成する実装が入ってからになります(現時点では未対応です)。

補足として、utsnamenodename(ホスト名)とdomainnameは、UTS namespaceで分離するまでは文字通りシステムに1つしか存在しないグローバルな値です。sethostname(2)CAP_SYS_ADMINという強い権限を要求するのは、成功するとホスト上で動作している全プロセスに影響するためです。一方、同じ構造体に含まれるsysname"Linux")・release(カーネルバージョン)・machine(アーキテクチャ)にはこれを変更する一般的なsyscallが存在しないため、実質的に常に実カーネルの値を反映し続けます。namespace分離で実際に意味を持つのはnodename/domainnameの2フィールドのみです。

clone(2)ラッパーの追加

PID namespaceを分離するにはfork(2)ではなくclone(2)にnamespace用のフラグを渡す必要があります。まずはPID namespace用のCLONE_NEWPIDに対応しました。

std.os.linuxにはclone()自体は用意されていますが、シグネチャがglibcのスレッド生成API互換になっており、fork(2)のような単純な戻り値では扱えません。

pub fn clone(
    func: *const fn (arg: usize) callconv(.c) u8,
    stack: usize,
    flags: u32,
    arg: usize,
    ptid: ?*i32,
    tp: usize,
    ctid: ?*i32,
) usize

子プロセス側で実行する関数ポインタと、子プロセス用のスタック領域を呼び出し側が用意する必要があります。またuname(2)/sethostname(2)のときと同様に、CLONE_NEWPIDをはじめとするCLONE_NEW*系のnamespaceフラグはstd.os.linuxに定数として定義されていないため、値を自前で定義しました。

pub const CLONE_NEWPID: u32 = 0x20000000;
pub const CLONE_NEWUTS: u32 = 0x04000000;
pub const CLONE_NEWIPC: u32 = 0x08000000;

pub fn cloneInNamespace(
    flags: u32,
    stack: []align(16) u8,
    func: *const fn (arg: usize) callconv(.c) u8,
    arg: usize,
) SyscallError!linux.pid_t {
    const stack_top = @intFromPtr(stack.ptr) + stack.len;
    const rc = linux.clone(
        func,
        stack_top,
        flags | @intFromEnum(linux.SIG.CHLD),
        arg,
        null,
        0,
        null,
    );
    try check("clone", rc);
    return @intCast(rc);
}

pub fn waitForChild(pid: linux.pid_t) SyscallError!u8 {
    var status: u32 = undefined;
    const rc = linux.waitpid(pid, &status, 0);
    try check("waitpid", rc);
    return linux.W.EXITSTATUS(status);
}

pub fn getPid() linux.pid_t {
    return linux.getpid();
}

flagsの下位バイトは、子プロセス終了時に親へ送るシグナルとしてclone(2)に解釈されます。ここにSIGCHLDを指定しないとwaitpidで子プロセスを回収できないため、cloneInNamespace内でSIG.CHLDを合成しています。子の終了ステータスの解析(WIFEXITED/WEXITSTATUS相当)も、checkと同じ考え方でwaitForChildの中に隠しており、呼び出し側はstd.os.linux.Wを直接扱う必要がありません。

getPidは失敗しないsyscallなのでエラーユニオンを返しません。すべてのsyscallを同じ形でラップするのではなく、失敗し得るかどうかで戻り値の型を素直に変える方針にしています。

IPC namespace確認用のshmget/shmctlラッパー

IPC namespaceの分離を確認するために、System V共有メモリ用のshmget(2)/shmctl(2)もラップしました。これらもstd.os.linuxに対応する関数がないため、syscall番号を直接指定して呼び出しています。

pub fn shmGet(key: i32, size: usize, flags: u32) SyscallError!i32 {
    const rc = linux.syscall3(.shmget, @intCast(key), size, flags);
    try check("shmget", rc);
    return @intCast(rc);
}

pub fn shmRemove(shmid: i32) SyscallError!void {
    const rc = linux.syscall3(.shmctl, @intCast(shmid), IPC_RMID, 0);
    try check("shmctl", rc);
}

IPC_CREAT/IPC_RMIDCLONE_NEW*と同様にstd.os.linuxに定義がないため自前で用意します。値の出典は/usr/include/linux/ipc.h(Ubuntu環境ではlinux-libc-devパッケージが提供するUAPIヘッダ)です。

pub const IPC_CREAT: u32 = 0o1000;
pub const IPC_RMID: u32 = 0;

補足として、この値を0x1000と16進数で書いてしまい、shmgetENOENTで失敗する問題に遭遇しました。Cヘッダ側の定義は#define IPC_CREAT 00001000で、先頭の0はC言語における8進数リテラルの表記です。8進数の01000は10進数で512ですが、これを16進数の0x1000(10進数で4096)と書き間違えるとIPC_CREATビット(実際の値は0x200)が立たず、カーネルは「IPC_CREATを指定していないのにそのキーのセグメントが存在しない」と判断してENOENTを返します。Cのヘッダをそのまま移植する際は、8進数リテラルを0o接頭辞に変換し忘れないよう注意が必要です。

ENOENTerror.NotFoundとして扱いたいケースが出てきたため、checkにもブランチを追加しました。

pub const SyscallError = error {
    PermissionDenied,
    NotFound,
    Unexpected,
};

fn check(comptime name: []const u8, rc: usize) SyscallError!void {
    switch (linux.errno(rc)) {
        .SUCCESS => return,
        .PERM => return error.PermissionDenied,
        .NOENT => return error.NotFound,
        else => |err| {
            std.log.err("{s} failed: errno={d} ({s})", .{ name, @intFromEnum(err), @tagName(err) });
            return error.Unexpected;
        },
    }
}

Mount namespace/pivot_root関連のラッパー追加

Mount namespaceとpivot_rootを扱うために、CLONE_NEWNS定数と3つの関数(pivotRootInto/mountProc/execInto)を追加しました。用途や動作確認はMount namespaceとpivot_rootでrootfsを差し替える記事にまとめてあり、ここではlinux.zig側の実装のみを扱います。

pub const CLONE_NEWNS: u32 = 0x00020000;

pub fn pivotRootInto(new_root: [*:0]const u8) SyscallError!void {
    try check("mount(private)", linux.mount(null, "/", null, linux.MS.REC | linux.MS.PRIVATE, 0));
    try check("mount(bind)", linux.mount(new_root, new_root, null, linux.MS.BIND | linux.MS.REC, 0));
    try check("chdir(new_root)", linux.chdir(new_root));
    try check("mkdir(put_old)", linux.mkdir("put_old", 0o700));
    try check("pivot_root", linux.pivot_root(".", "put_old"));
    try check("chdir(/)", linux.chdir("/"));
    try check("umount2(put_old)", linux.umount2("/put_old", linux.MNT.DETACH));
    try check("rmdir(put_old)", linux.rmdir("/put_old"));
}

pub fn mountProc() SyscallError!void {
    try check("mount(proc)", linux.mount("proc", "/proc", "proc", 0, 0));
}

pub fn execInto(
    path: [*:0]const u8,
    argv: [*:null]const ?[*:0]const u8,
    envp: [*:null]const ?[*:0]const u8,
) SyscallError!void {
    try check("execve", linux.execve(path, argv, envp));
}

CLONE_NEWNSは他のCLONE_NEW*系と同様、std.os.linuxに定数が存在しないため自前で定義しています(出典は/usr/include/linux/sched.h)。一方、ここで使っているmount(2)/pivot_root(2)/umount2(2)/chdir(2)/mkdir(2)/rmdir(2)はいずれもstd.os.linuxに薄いラッパーが既に用意されており、syscall2/syscall3で自前発行する必要はありませんでした。sethostname(2)shmget(2)のように対応関数が存在しないsyscallと、uname(2)のように既に用意されているsyscallが混在するという最初に触れた現状が、ここでも同じ形で表れています。

pivotRootIntoは他のラッパー関数と違い、単一のsyscallではなく7段階の手順を1つの関数にまとめています。これはpivot_root(2)が単体では安全に機能せず、事前にマウントをprivateにする・対象をbindマウントでマウントポイント化する・事後に古いルートを検知不能にするところまでを1セットで行って初めて「ルートを安全に差し替える」という操作として成立するためです。手順の一部だけを呼び出し側の裁量に委ねる意味がないため、linux.zig側で手順ごと1つの関数に閉じ込めています。