OCI Runtime Specのバンドル形式に合わせてconfig.jsonからコンテナを起動する

capabilitiesの削減とseccompによるsyscallフィルタの記事まで、zigcon run <rootfs-path>のように、rootfsのパスを直接コマンドライン引数で渡す独自のCLIで動かしてきました。今回はOCI Runtime Specが定める**バンドル(bundle)**という形式に合わせ、config.jsonから実行コマンド・環境変数・作業ディレクトリ・ホスト名・cgroupのリソース上限を読み取れるようにしました。

対応させるバンドルディレクトリの構成です。

mybundle/
├── config.json
└── rootfs/
    └── ...(busyboxのrootfs)

config.jsonのスキーマ

OCI Runtime Specのフィールド名に合わせつつ、今回実装している範囲だけをサポートする最小限のサブセットにしています。

{
  "ociVersion": "1.0.2",
  "hostname": "zig-container",
  "process": {
    "args": ["/bin/sh"],
    "env": ["PATH=/bin"],
    "cwd": "/"
  },
  "root": {
    "path": "rootfs"
  },
  "linux": {
    "resources": {
      "memory": { "limit": 104857600 },
      "cpu": { "quota": 50000, "period": 100000 }
    }
  }
}

これに対応するZig側の構造体です。

pub const Config = struct {
    ociVersion: []const u8 = "1.0.2",
    hostname: []const u8 = "zigcon",
    process: Process,
    root: Root,
    linux: LinuxConfig = .{},
};

pub const Process = struct {
    args: [][]const u8,
    env: [][]const u8 = &.{},
    cwd: []const u8 = "/",
};

pub const Root = struct {
    path: []const u8,
};

pub const LinuxConfig = struct {
    resources: Resources = .{},
};

pub const Resources = struct {
    memory: Memory = .{},
    cpu: Cpu = .{},
};

pub const Memory = struct {
    limit: ?u64 = null,
};

pub const Cpu = struct {
    quota: ?u64 = null,
    period: ?u64 = null,
};

process.argsroot.pathだけはデフォルト値を与えていません。実行するコマンドと展開先のrootfsは省略しようがない必須情報なので、std.jsonのパース時にキーが存在しなければerror.MissingFieldになるようにしています。他のフィールド(envcwdhostnamelinux.resources以下)はすべてOCI Spec上省略可能な情報なので、Zigの構造体のデフォルト値機能でそのまま表現できます。

config.jsonの読み込み

pub fn loadFromBundle(allocator: std.mem.Allocator, bundle_dir: []const u8) !Config {
    const config_path = try std.fmt.allocPrintSentinel(allocator, "{s}/config.json", .{bundle_dir}, 0);

    var buf: [64 * 1024]u8 = undefined;
    const json_text = try sys.readFile(config_path.ptr, &buf);

    return try std.json.parseFromSliceLeaky(Config, allocator, json_text, .{
        .ignore_unknown_fields = true,
        .allocate = .alloc_always,
    });
}

ignore_unknown_fields = trueにしているのは、実際のOCI Runtime Spec準拠のconfig.jsonrunc specが生成するような完全なもの)には、mountslinux.namespaceslinux.capabilitieshooksなど、今回実装していないフィールドが多数含まれるためです。これがないと、未対応フィールドが1つあるだけでパースエラーになってしまいます。

.allocate = .alloc_alwaysは、パースした文字列を必ずallocator側にコピーさせる指定です。std.jsonのデフォルト(.alloc_if_needed)は、エスケープを含まない単純な文字列であれば、コピーせずに読み込み元のバッファをそのまま指すという最適化をします。今回json_textは関数ローカルの固定長バッファ(buf)から得ているため、その最適化のままだと、関数を抜けてbufのスコープが切れた後もパース結果の文字列がそこを指し続けてしまい、安全に参照できなくなります。alloc_alwaysを明示することで、パース結果の文字列がすべて呼び出し元のallocator(今回はプログラム全体で使っているarena)にコピーされ、bufのスコープに依存しなくなります。

main.zigへの組み込み

ChildContextに、config由来の情報(ホスト名・作業ディレクトリ・実行コマンド)を追加しました。

const ChildContext = struct {
    rootfs: [*:0]const u8,
    sync_read_fd: i32,
    peer_veth_name: [*:0]const u8,
    hostname: []const u8,
    cwd: [*:0]const u8,
    program: [*:0]const u8,
    argv: [:null]const ?[*:0]const u8,
    envp: [:null]const ?[*:0]const u8,
};

config.process.args[][]const u8(JSONパース結果の文字列配列)ですが、execveが要求するのはヌル終端の[*:null]const ?[*:0]const u8です。この変換を行うヘルパーを用意しました。

fn buildCStringArray(allocator: std.mem.Allocator, items: [][]const u8) ![:null]?[*:0]const u8 {
    const array = try allocator.allocSentinel(?[*:0]const u8, items.len, null);
    for (items, 0..) |item, i| {
        array[i] = try allocator.dupeZ(u8, item);
    }
    return array;
}

runInNewNamespaceは、これまでrootfsのパスを直接受け取っていましたが、バンドルディレクトリを受け取り、冒頭でconfigを読み込む形に変えました。

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

    const config = try oci_config.loadFromBundle(allocator, bundle_dir);
    if (config.process.args.len == 0) return error.EmptyProcessArgs;

    const rootfs = try oci_config.resolveRootfsPath(allocator, bundle_dir, config);
    const argv = try buildCStringArray(allocator, config.process.args);
    const envp = try buildCStringArray(allocator, config.process.env);
    const cwd = try std.fmt.allocPrintSentinel(allocator, "{s}", .{config.process.cwd}, 0);
    const program = argv[0].?;
    // ...

cgroupのリソース上限も、config側で指定されていればそちらを、無ければこれまで通りの既定値を使うようにしています。

    const memory_limit = config.linux.resources.memory.limit orelse DEFAULT_MEMORY_LIMIT_BYTES;
    const cpu_quota = config.linux.resources.cpu.quota orelse DEFAULT_CPU_QUOTA_US;
    const cpu_period = config.linux.resources.cpu.period orelse DEFAULT_CPU_PERIOD_US;
    try cgroup.setMemoryMax(memory_limit);
    try cgroup.setCpuMax(cpu_quota, cpu_period);

childMain側は、これまでハードコードしていた"zig-container"/bin/shPATH=/binを、すべてctx経由の値に置き換えています。mountProcの後にはconfig.process.cwdへのchdirも追加しました。

動作確認

既存のrootfsをバンドルディレクトリに移し、config.jsonを用意して実行します。

$ mkdir -p mybundle
$ mv rootfs mybundle/rootfs
$ cat mybundle/config.json
{
  "ociVersion": "1.0.2",
  "hostname": "zig-container",
  "process": {
    "args": ["/bin/sh"],
    "env": ["PATH=/bin"],
    "cwd": "/"
  },
  "root": { "path": "rootfs" },
  "linux": {
    "resources": {
      "memory": { "limit": 104857600 },
      "cpu": { "quota": 50000, "period": 100000 }
    }
  }
}
$ sudo ./zig-out/bin/zigcon run ./mybundle
[parent] pid: 41846
[parent] loaded bundle: rootfs=./mybundle/rootfs, process=/bin/sh, hostname=zig-container
[parent] created cgroup /sys/fs/cgroup/zigcon-41846 (memory<=100MiB, cpu<=50%)
...
[child] pivot_root done, root filesystem is now ./mybundle/rootfs
[child] capabilities reduced to Docker-default set
[child] seccomp filter installed (mount blocked)
[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.

/ #

これまでのフェーズで実装したcgroup・Network namespace・capabilities/seccompがすべて、config.json経由の起動でも問題なく機能していることを確認できました。

config.jsonprocess.argsを書き換えるだけで、実行するコマンド自体を変えられることも確認しました。

$ sed -i 's|"args": \["/bin/sh"\]|"args": ["/bin/echo", "hello from config.json"]|' mybundle/config.json
$ sudo ./zig-out/bin/zigcon run ./mybundle
...
[parent] loaded bundle: rootfs=./mybundle/rootfs, process=/bin/echo, hostname=zig-container
...
[child] handing over to /bin/echo
hello from config.json
[parent] child exited with status: 0

コード(Zig側)を一切変更せずに、config.jsonの書き換えだけで起動するコマンドを変えられるようになりました。

なお、今回パースしているのはprocess/root/hostname/linux.resourcesのみで、OCI Runtime Specが定めるmounts(追加マウント一覧)やlinux.namespaces(有効化するnamespaceの選択)、linux.capabilitiesのような他の主要フィールドはignore_unknown_fieldsで無視しています。namespace/capabilities/seccompの具体的な中身は、引き続きZig側にハードコードされたままです。