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