自前netlinkクライアントでNetwork namespaceとvethペアを構築する——RTM_SETLINKは通るのに/sys/class/netには見えない現象

cgroup v2でCPU・メモリ使用量を制限する記事まででCPU・メモリのリソース制限を実装しました。今回はNetwork namespaceとvethペアを実装し、ホストとコンテナの間でpingが通る状態を作ります。Network namespace自体はclone(2)CLONE_NEWNETを足すだけですが、vethペアの作成・netns間の移動・IPアドレス設定は、Linuxではrtnetlink(netlinkプロトコルの一種)経由が標準的な手段で、ioctlでは代替できません。

なぜ自前でnetlinkクライアントを書くか

実際のコンテナランタイム(runc/youki)でも、vethのようなリンク操作はipコマンドを呼ぶのではなく、Go/Rustそれぞれのnetlinkクライアントライブラリ(vishvananda/netlinkrtnetlinkクレート)を使うのが一般的です。Zigにはこの位置づけのライブラリがほぼ無いため、今回は生のnetlinkメッセージを手で組み立てる実装にしました。std.os.linuxにはnlmsghdrifinfomsgIFLAIFAといったプロトコルのデータ構造自体はすでに定義されていて、足りないのは「それらを使ってメッセージを組み立て、送受信するクライアントの部分」です。

設計: TLV形式のメッセージをバイト列として組み立てる

netlinkのメッセージは、ヘッダ(nlmsghdr)+ペイロード(ifinfomsg等)+属性(rtattr、TLV形式)という構造で、属性は入れ子にできます。例えばip link add veth0 type veth peer name veth1相当のメッセージは、IFLA_LINKINFOの中にIFLA_INFO_KIND("veth"という文字列)とIFLA_INFO_DATA、さらにその中にVETH_INFO_PEER(peer側のifinfomsgと名前)という3段のネストになります。

これを固定バッファ上に組み立てるBuilderという内部型を用意しました。

const Builder = struct {
    buf: [512]u8 = undefined,
    len: usize = 0,

    fn appendRaw(self: *Builder, bytes: []const u8) void {
        @memcpy(self.buf[self.len..][0..bytes.len], bytes);
        self.len += bytes.len;
    }

    fn pad(self: *Builder) void {
        while (self.len % 4 != 0) : (self.len += 1) self.buf[self.len] = 0;
    }

    fn appendStruct(self: *Builder, value: anytype) void {
        self.appendRaw(std.mem.asBytes(&value));
        self.pad();
    }

    // rtattrヘッダ(len:u16, type:u16)を書き込み、後で長さを埋め戻すためのオフセットを返す。
    fn beginAttr(self: *Builder, attr_type: u16) usize {
        const start = self.len;
        self.appendRaw(&[_]u8{ 0, 0, 0, 0 });
        std.mem.writeInt(u16, self.buf[start + 2 ..][0..2], attr_type, .little);
        return start;
    }

    fn endAttr(self: *Builder, start: usize) void {
        const attr_len: u16 = @intCast(self.len - start);
        std.mem.writeInt(u16, self.buf[start..][0..2], attr_len, .little);
        self.pad();
    }

    fn appendAttr(self: *Builder, attr_type: u16, data: []const u8) void {
        const start = self.beginAttr(attr_type);
        self.appendRaw(data);
        self.endAttr(start);
    }

    fn appendAttrZ(self: *Builder, attr_type: u16, str: []const u8) void {
        const start = self.beginAttr(attr_type);
        self.appendRaw(str);
        self.buf[self.len] = 0;
        self.len += 1;
        self.endAttr(start);
    }

    fn beginMessage(self: *Builder, msg_type: linux.NetlinkMessageType, flags: u16, seq: u32) void {
        self.appendStruct(linux.nlmsghdr{ .len = 0, .type = msg_type, .flags = flags, .seq = seq, .pid = 0 });
    }

    fn finish(self: *Builder) []const u8 {
        std.mem.writeInt(u32, self.buf[0..4], @intCast(self.len), .little);
        return self.buf[0..self.len];
    }
};

netlinkの属性は4バイト境界にパディングされている必要があるため、appendStructendAttrの両方でpad()を呼び、書き込むたびにアラインメントを維持しています。beginAttr/endAttrは、属性のデータ長が書き込み終わるまで分からない(特にネストした属性)ため、先に長さ0のヘッダを書いておいて、後から実際の長さを埋め戻す形にしています。

メッセージの送受信とACK確認はNetlinkSocket.requestにまとめました。

fn request(self: *NetlinkSocket, msg: []const u8) !void {
    try sys.sendNetlink(self.fd, msg);

    var buf: [256]u8 = undefined;
    const n = try sys.recvNetlink(self.fd, &buf);
    if (n < @sizeOf(linux.nlmsghdr) + 4) return error.ShortNetlinkReply;

    const hdr = std.mem.bytesToValue(linux.nlmsghdr, buf[0..@sizeOf(linux.nlmsghdr)]);
    if (hdr.type != .ERROR) return error.UnexpectedNetlinkReply;

    const err_code = std.mem.bytesToValue(i32, buf[@sizeOf(linux.nlmsghdr)..][0..4]);
    if (err_code != 0) {
        std.log.err("netlink request failed: errno={d}", .{-err_code});
        return error.NetlinkRequestFailed;
    }
}

NLM_F_ACKを立てたリクエストに対して、カーネルはNLMSG_ERROR型のメッセージを返します。ペイロードの先頭4バイトがエラーコード(0なら成功)で、その後ろに元のリクエストのnlmsghdrがそのままエコーされる、という形式です。

このBuilderrequestを土台に、createVethPair(vethペア作成)・moveToNetns(netns間の移動)・setLinkUp(リンクのup)・addAddress(IPアドレス割り当て)を実装しています。

Network namespaceの作成と、親子間の同期

clone(2)のフラグにCLONE_NEWNETを追加するだけでNetwork namespace自体は作成できますが、子プロセスはclone直後からpivot_rootexecveに向かって走り出してしまいます。一方、vethペアの作成・子のnetnsへの移動は親プロセス側の仕事なので、「親のネットワーク設定が終わるまで子を待たせる」同期が必要です。

今回はパイプを使い、子はsync_read_fdから1バイト読むまでブロックする形にしました。

var sync_byte: [1]u8 = undefined;
_ = sys.readFd(ctx.sync_read_fd, &sync_byte) catch |err| {
    std.debug.print("[child] waiting for network setup failed: {s}\n", .{@errorName(err)});
    return 1;
};

親側は、vethのセットアップに失敗しても必ずパイプへ書き込むようにしています。

setupHostNetwork(host_veth_name, peer_veth_name, child_pid) catch |err| {
    std.debug.print("[parent] network setup failed: {s}\n", .{@errorName(err)});
};
// ...
_ = try sys.writeFd(sync_pipe[1], "x");

ここをtryにしてセットアップ失敗時に即座にreturnしてしまうと、子プロセスがパイプの読み込みでブロックしたまま残ってしまいます。過去にCLONE_NEWNS周りでホストの`/`を壊した事故以来、失敗時にプロセスが不定な状態で残らないようにすることを意識しています。

遭遇した現象: RTM_SETLINKは成功するのに、sysfs経由でifindexが見えない

RTM_NEWADDR(IPアドレス割り当て)は、インターフェースを名前ではなく数値のifindexでしか指定できません。当初はこれを/sys/class/net/<name>/ifindexを読んで解決していました。

fn ifindexOf(link_name: []const u8) !i32 {
    var path_buf: [64]u8 = undefined;
    const path = try std.fmt.bufPrintZ(&path_buf, "/sys/class/net/{s}/ifindex", .{link_name});

    const fd = try sys.openFile(path.ptr, .{ .ACCMODE = .RDONLY });
    defer sys.closeFd(fd);

    var buf: [16]u8 = undefined;
    const n = try sys.readFd(fd, &buf);
    const text = std.mem.trimEnd(u8, buf[0..n], "\n");
    return std.fmt.parseInt(i32, text, 10) catch return error.UnexpectedIfindexFormat;
}

ところが実際に動かすと、コンテナ側でsetLinkUp(peer_name)RTM_SETLINKをインターフェース名で送る処理)は成功するのに、直後のifindexOfNotFoundENOENT)で失敗するという現象が起きました。同じプロセス・同じNetwork namespace・同じインターフェース名に対する操作で結果が食い違っており、原因を完全には特定できていません。

対処として、ifindexの解決をsysfs読み取りではなく、RTM_GETLINKというnetlinkのリクエストに変更しました。

fn getLinkIndex(self: *NetlinkSocket, link_name: []const u8) !i32 {
    var b: Builder = .{};
    b.beginMessage(.RTM_GETLINK, linux.NLM_F_REQUEST, self.nextSeq());
    b.appendStruct(emptyIfinfomsg());
    b.appendAttrZ(ifla(.IFNAME), link_name);

    try sys.sendNetlink(self.fd, b.finish());

    var buf: [512]u8 = undefined;
    const n = try sys.recvNetlink(self.fd, &buf);
    if (n < @sizeOf(linux.nlmsghdr) + @sizeOf(linux.ifinfomsg)) return error.ShortNetlinkReply;

    const hdr = std.mem.bytesToValue(linux.nlmsghdr, buf[0..@sizeOf(linux.nlmsghdr)]);
    if (hdr.type == .ERROR) {
        const err_code = std.mem.bytesToValue(i32, buf[@sizeOf(linux.nlmsghdr)..][0..4]);
        std.log.err("RTM_GETLINK for {s} failed: errno={d}", .{ link_name, -err_code });
        return error.NetlinkRequestFailed;
    }
    if (hdr.type != .RTM_NEWLINK) return error.UnexpectedNetlinkReply;

    const info_start = @sizeOf(linux.nlmsghdr);
    const info = std.mem.bytesToValue(linux.ifinfomsg, buf[info_start..][0..@sizeOf(linux.ifinfomsg)]);
    return info.index;
}

RTM_GETLINKは他のリクエストと違い、ACK(NLMSG_ERROR)ではなくRTM_NEWLINKメッセージそのものが返ってきます。ifinfomsgはそのメッセージのペイロード先頭にそのまま乗っているため、nlmsghdrの直後をifinfomsgとして読めば.indexが取れます。setLinkUpと同じnetlink経由の名前解決に統一したことで、この問題は解消しました。sysfsとnetlinkで名前解決の結果が食い違う具体的なメカニズムまでは追い切れていませんが、少なくとも今回のように「一方の手段では見えるが、もう一方では見えない」というケースがあり得る、という点は実装上の注意点として残しておきます。

動作確認

$ sudo ./zig-out/bin/zigcon run ./rootfs
[parent] pid: 22542
[parent] created cgroup /sys/fs/cgroup/zigcon-22542 (memory<=100MiB, cpu<=50%)
[parent] shmget returned shmid 2 (visible host-wide)
[parent] child pid (as seen from parent's namespace): 22543
[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)
[parent] host veth veth-h22542 = 10.200.0.1/24, peer veth-c22542 moved into child's netns
[parent] moved child pid 22543 into cgroup /sys/fs/cgroup/zigcon-22542
[child] veth-c22542 = 10.200.0.2/24, lo up
[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.

/ #

ホスト側から、コンテナ側のvethに割り当てたアドレスへping:

$ ping -c 3 10.200.0.2
PING 10.200.0.2 (10.200.0.2) 56(84) bytes of data.
64 bytes from 10.200.0.2: icmp_seq=1 ttl=64 time=0.118 ms
64 bytes from 10.200.0.2: icmp_seq=2 ttl=64 time=0.057 ms
64 bytes from 10.200.0.2: icmp_seq=3 ttl=64 time=0.054 ms

--- 10.200.0.2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2032ms

コンテナ内のシェルから、逆方向にホスト側のvethへping:

/ # ping -c 3 10.200.0.1
PING 10.200.0.1 (10.200.0.1): 56 data bytes
64 bytes from 10.200.0.1: seq=0 ttl=64 time=0.126 ms
64 bytes from 10.200.0.1: seq=1 ttl=64 time=0.097 ms
64 bytes from 10.200.0.1: seq=2 ttl=64 time=0.105 ms

--- 10.200.0.1 ping statistics ---
3 packets transmitted, 3 packets received, 0% packet loss

双方向とも0%パケットロスで疎通が確認できました。ip addr showでも、コンテナ側のveth-c2254210.200.0.2/24loにも127.0.0.1/8がそれぞれ設定されていることが確認できています。今回はホストとコンテナの直接通信の確立までがスコープで、NAT/MASQUERADEによるインターネットへの到達性は対象外にしています。