cgroup v2でCPU・メモリ使用量を制限する——syscallではなくファイル操作だけで実現する仕組み

Mount namespaceとpivot_rootでrootfsを差し替える記事まででPID/UTS/IPC/Mount namespaceを実装しました。今回はcgroup v2を使い、コンテナのCPU・メモリ使用量に上限を設けます。cgroup関連の用語は用語集に整理済みなので、ここでは実装に絞って説明します。

namespaceとの違い: syscallではなくファイル操作

これまで実装してきたnamespace(PID/UTS/IPC/Mount)は、いずれもclone(2)にフラグを渡す、あるいはpivot_root(2)のような専用syscallを呼ぶことで操作していました。cgroup v2はアプローチが異なり、専用のsyscallを持ちません。/sys/fs/cgroupにマウントされたcgroupfsに対する、ごく普通のディレクトリ作成・ファイル書き込みだけで完結します。

  • mkdir /sys/fs/cgroup/<name> — 新しいcgroup(サブグループ)の作成
  • <name>/memory.max<name>/cpu.maxへ書き込み — リソース上限の設定
  • <name>/cgroup.procsへPIDを書き込み — 対象プロセスをそのcgroupへ移動
  • rmdir /sys/fs/cgroup/<name> — cgroupの削除(中身が空である必要がある)

cpu.max<quota> <period>(マイクロ秒)という書式で、例えば50000 100000なら「100msの期間ごとに50msまで実行可能」、つまりCPU1個分の50%に相当します。

実装: linux.zigへの追加

syscall自体はopen/write/close/mkdir/rmdirという馴染みのあるものなので、これまでと同じ要領でlinux.zigに薄いラッパーを追加しました。pivotRootIntoのように複数syscallをまとめる形に倣い、writeFileは「開く→書く→閉じる」を1つの関数にしています。

pub fn makeDir(path: [*:0]const u8, mode: u32) SyscallError!void {
    try check("mkdir", linux.mkdir(path, mode));
}

pub fn removeDir(path: [*:0]const u8) SyscallError!void {
    try check("rmdir", linux.rmdir(path));
}

pub fn writeFile(path: [*:0]const u8, data: []const u8) SyscallError!void {
    const fd_rc = linux.open(path, .{ .ACCMODE = .WRONLY }, 0);
    try check("open", fd_rc);
    const fd: i32 = @intCast(fd_rc);
    defer _ = linux.close(fd);
    try check("write", linux.write(fd, data.ptr, data.len));
}

cgroupfsの制御ファイルはカーネル側で1回のwrite(2)が値全体を受け取る前提の実装になっているため、部分書き込みへの対応(ループでの再書き込み)は不要です。

実装: cgroup.zig

cgroup固有のロジック(パスの組み立てや制限値のフォーマット)は新しくcgroup.zigに切り出し、Cgroup構造体としてまとめました。

const std = @import("std");
const sys = @import("linux.zig");

const cgroup_root = "/sys/fs/cgroup";

pub const Cgroup = struct {
    allocator: std.mem.Allocator,
    path: [:0]const u8,

    pub fn create(allocator: std.mem.Allocator, name: []const u8) !Cgroup {
        sys.writeFile(cgroup_root ++ "/cgroup.subtree_control", "+cpu +memory") catch |err| {
            std.log.warn(
                "enabling cpu/memory controllers on root cgroup failed (maybe already enabled): {s}",
                .{@errorName(err)},
            );
        };

        const path = try std.fmt.allocPrintSentinel(allocator, "{s}/{s}", .{ cgroup_root, name }, 0);
        errdefer allocator.free(path);
        try sys.makeDir(path.ptr, 0o755);

        return Cgroup{ .allocator = allocator, .path = path };
    }

    pub fn setMemoryMax(self: Cgroup, bytes: u64) !void {
        try self.writeControlFile("memory.max", "{d}", .{bytes});
    }

    pub fn setCpuMax(self: Cgroup, quota_us: u64, period_us: u64) !void {
        try self.writeControlFile("cpu.max", "{d} {d}", .{ quota_us, period_us });
    }

    pub fn addProcess(self: Cgroup, pid: i32) !void {
        try self.writeControlFile("cgroup.procs", "{d}", .{pid});
    }

    fn writeControlFile(
        self: Cgroup,
        file_name: []const u8,
        comptime fmt: []const u8,
        args: anytype,
    ) !void {
        const file_path = try std.fmt.allocPrintSentinel(
            self.allocator,
            "{s}/{s}",
            .{ self.path, file_name },
            0,
        );
        defer self.allocator.free(file_path);

        const value = try std.fmt.allocPrint(self.allocator, fmt, args);
        defer self.allocator.free(value);

        try sys.writeFile(file_path.ptr, value);
    }

    pub fn destroy(self: Cgroup) void {
        sys.removeDir(self.path.ptr) catch |err| {
            std.log.warn("removing cgroup {s} failed: {s}", .{ self.path, @errorName(err) });
        };
        self.allocator.free(self.path);
    }
};

createの冒頭で、ルートcgroup(/sys/fs/cgroup直下)のcgroup.subtree_control+cpu +memoryを書き込んでいます。子cgroupでmemory.max/cpu.maxを使うには、親側でそのコントローラーが有効になっている必要があるためです。すでに有効な場合(systemd管理下のディストリではデフォルトで有効なことが多い)は書き込みが冪等に成功するので、失敗時はログを出すだけに留めています。

main.zigへの組み込み

runInNewNamespaceはcgroup作成のためallocatorを受け取るように変更し、cloneで子プロセスを生成する前にcgroupを用意・設定します。

const MEMORY_LIMIT_BYTES: u64 = 100 * 1024 * 1024; // 100MiB
const CPU_QUOTA_US: u64 = 50_000; // 100msごとに50ms=CPU1個の50%まで
const CPU_PERIOD_US: u64 = 100_000;

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

    const cgroup_name = try std.fmt.allocPrint(allocator, "zigcon-{d}", .{sys.getPid()});
    const cgroup = try Cgroup.create(allocator, cgroup_name);
    defer cgroup.destroy();

    try cgroup.setMemoryMax(MEMORY_LIMIT_BYTES);
    try cgroup.setCpuMax(CPU_QUOTA_US, CPU_PERIOD_US);
    // ...(shmget〜cloneはこれまでと同じ)

cloneが返した子プロセスのPIDを、そのままcgroup.procsへ書き込みます。cgroupfsはnamespace分離されていないホスト全体で共通のファイルシステムなので、親のnamespace(=ホスト)から見たPIDをそのまま使えます。

    try cgroup.addProcess(child_pid);
    std.debug.print("[parent] moved child pid {d} into cgroup {s}\n", .{ child_pid, cgroup.path });

    const exit_status = try sys.waitForChild(child_pid);

後始末はdefer cgroup.destroy()で行っています。waitForChildwaitpid(2))が子プロセスを回収した後に呼ばれるため、cgroup.procsはすでに空になっており、rmdirが問題なく通ります。

動作確認

$ sudo ./zig-out/bin/zigcon run ./rootfs
[parent] pid: 6303
[parent] created cgroup /sys/fs/cgroup/zigcon-6303 (memory<=100MiB, cpu<=50%)
[parent] shmget returned shmid 0 (visible host-wide)
[parent] child pid (as seen from parent's namespace): 6304
[parent] moved child pid 6304 into cgroup /sys/fs/cgroup/zigcon-6303
[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.

/ # exit
[parent] child exited with status: 0
[parent] hostname on host: server
[parent] removed shmid 0

コンテナ実行中に別ターミナルからcgroupfsを直接読み、設定値が反映されていることを確認しました。

$ cat /sys/fs/cgroup/zigcon-6303/memory.max
104857600
$ cat /sys/fs/cgroup/zigcon-6303/cpu.max
50000 100000
$ cat /sys/fs/cgroup/zigcon-6303/cpu.stat
usage_usec 1024
user_usec 1024
system_usec 0
nr_periods 2
nr_throttled 0
throttled_usec 0

memory.maxは指定した100MiB(104857600バイト)、cpu.maxは指定した50000 100000(50%制限)がそのまま反映されています。cpu.statusage_usec/nr_periodsからは、コンテナ内シェルの実行分がこのcgroupの会計として計上されていることも確認できます。コンテナ終了後は/sys/fs/cgroup/zigcon-6303ディレクトリ自体が消えており、destroyによる後始末も正しく機能しています。リソース上限値(MEMORY_LIMIT_BYTES/CPU_QUOTA_US/CPU_PERIOD_US)は今回は定数で固定しており、CLI引数での変更には対応していません。