外部記憶装置

外付け記憶装置

Mewz on libkrun - その3 Mewz 追加実装(Linux zeropage, kernel cmd params)

Mewz (WebAssembly x Unikernel) を libkrun で動かしてみた - 外部記憶装置 の詳細を記すシリーズ

目次

ビルドスクリプトの整備

Mewz kernel の libkrunfw 互換な共有ライブラリへの変換

その2 で、libkrun は libkrunfw を利用することを確認した。 libkrun で Mewz を動かすためには、Mewz kernel のビルド後、適切に libkrunfw 互換な共有ライブラリへ変換する必要がある。

公式の手順に従いビルドされた kernel (mewz.qemu.elf) を libkrunfw が提供する bin2cbundle.py で変換する形で対応した。

実装としては、以下のコミットに対応する。

github.com

zig のビルドスクリプト(build.zig) では、zig build libkrunfw と実行すると WebAssembly と kernel のリンク後、libkrunfw 互換の共有ライブラリへ変換するように処理を追加した。 実行すると、libkrunfw-mewz.so という名前で共有ライブラリが生成される。

const rewrite_kernel_cmd = b.addSystemCommand(&[_][]const u8{"./scripts/rewrite-kernel.sh"});
rewrite_kernel_cmd.step.dependOn(b.getInstallStep());
...    
const libkrunfw_cmd = b.addSystemCommand(&[_][]const u8{"./scripts/gen-libkrunfw.sh"});
libkrunfw_cmd.step.dependOn(&rewrite_kernel_cmd.step);
const libkrunfw_step = b.step("libkrunfw", "build libkrunfw compatible shared library");
libkrunfw_step.dependOn(&libkrunfw_cmd.step);

libkrun への対応

libkrun 側では、起動用の kernel の取得に libkrunfw-mewz.so を利用するようにする必要がある。 また、標準では Port IO 経由で利用するシリアルデバイスは接続されないため、Mewz を利用するときにはシリアルデバイスを接続する必要がある。

これらについては、libkrun の feature として mewz を追加し、mewz feature が有効になっているときには libkrunfw-mewz と Port IO 経由のシリアルデバイスを利用するようにした。 また、examples の chroot_vm についても同様に libkrunfw-mewz を利用するようにした。 これらの改変は以下の mewz-on-libkrun ブランチにある。

github.com

Mewz への追加実装

Linux zeropage への対応

zeropage への対応としては、主に利用可能なメモリ領域の取得と cmd params のポインタの取得を行う。 実装としては、0x7000 に存在する zeropage を読み取れば良い。 対応するコミットは以下である。

github.com

メモリの初期化において、zeropage が持つ E820 を順番に読み取り、利用可能な領域(E820 RAM) を multiboot プロトコルで起動したときと同様の手順で登録する。

pub fn initWithZeroPage(info: zeropage.ZeroPageInfo) void {
    const image_end_addr = @intFromPtr(&image_end);
    {
        // disable alignment checks for mmaps
        @setRuntimeSafety(false);
        var entry_idx: u8 = 0;
        while (entry_idx < info.e820_entry_num) {
            entry_idx += 1;
            const entry = info.e820_entries[entry_idx - 1];
            log.info.printf("E820 Entry [{}] addr=0x{x} size=0x{x} type={}\n", .{ entry_idx, entry.addr, entry.size, entry.type_ });
            if (entry.type_ == 1) { // E820 RAM
                // exclude the kernel image from available memory, because it's already used
                const base = @max(image_end_addr, entry.addr);
                const end = entry.addr + entry.size;
                if (end <= base) {
                    continue;
                }
                log.info.printf("available memory: {x} - {x}\n", .{ base, end });
                // align the range to BLOCK_SIZE
                const aligned_base = util.roundUp(usize, base, BLOCK_SIZE);
                const aligned_end = util.roundDown(usize, end, BLOCK_SIZE);
                const length = aligned_end - aligned_base;
                if (length >= MIN_BOOTTIME_ALLOCATOR_SIZE) {
                    // create a new allocator for the boot time
                    const buf = @as([*]u8, @ptrFromInt(aligned_base))[0..length];
                    boottime_fba = FixedBufferAllocator.init(buf);
                    boottime_allocator = boottime_fba.?.allocator();
                } else {
                    // add the range to the free list
                    initRange(aligned_base, length);
                }
            }
        }
    }
}

環境によっては multiboot プロトコルを使う可能性もあるため、Magic value を読み取り共存する形とした。

    if (boot_magic == 0x2badb002) {
        log.info.print("booted with multiboot1\n");
        const bootinfo = @as(*multiboot.BootInfo, @ptrFromInt(boot_params));
        printBootinfo(boot_magic, bootinfo);
        mem.init(bootinfo);
        param.parseFromArgs(util.getString(bootinfo.cmdline));
    } else {
        log.info.print("booted with linux zero page\n");
        const info = zeropage.parseZeroPageInfo(0x7000);
        mem.initWithZeroPage(info);
    }

kernel command-line parameters への対応

Virtio MMIO への対応をするために、cmd params の読み取り部分を拡張した。 cmd params のアドレスや長さは zeropage の setup_header に含まれるため、それを読み取りパースすればよい。 対応するコミットは以下である。

github.com

以下のように、zeropage の setup_header から文字列を切り出し、param.parseFromArgs 関数で処理する。

const cmd_params = @as([*]u8, @ptrFromInt(info.setup_header.cmd_line_ptr))[0..info.setup_header.cmdline_size];
param.parseFromArgs(cmd_params);

param.parseFromArgs 関数内では、virtio_mmio.device へ対応した。

        } else if (std.mem.eql(u8, k, "virtio_mmio.device")) {
            if (params.mmio_device_num >= params.mmio_devices.len) {
                log.warn.printf("too many virtio_mmio devices. '{s}' is ignored\n", .{v});
                continue;
            }
            // TODO: error handle
            // 4K@0xd0004000:9 SIZE@ADDR:IRQ
            var iter = std.mem.splitScalar(u8, v, '@');
            const size_str = iter.next() orelse continue;
            // TODO: support non 4KiB device
            if (!std.mem.eql(u8, size_str, "4K")) {
                log.warn.printf("vrtio_mmio: size {s} is not supported\n", .{size_str});
                continue;
            }
            // addr_with_irq = 0xd0004000:9
            const addr_with_irq = iter.next() orelse continue;
            // addr_str = 0xd0004000
            iter = std.mem.splitScalar(u8, addr_with_irq, ':');
            const addr_str = iter.next() orelse continue;
            const irq_str = iter.next() orelse continue;
            // addr_str contains prefix '0x', so base=0 is specified.
            const addr = std.fmt.parseInt(usize, addr_str, 0) catch |err| {
                log.warn.printf("failed to parse {s}: {}\n", .{ addr_str, err });
                continue;
            };
            const irq_line = std.fmt.parseInt(usize, irq_str, 10) catch |err| {
                log.warn.printf("failed to parse {s}: {}\n", .{ irq_str, err });
                continue;
            };
            params.mmio_devices[params.mmio_device_num] = virtio_mmio.MMIODeviceParam{
                .addr = addr,
                .size = 4096,
                .irq = irq_line,
            };
            params.mmio_device_num += 1;
        }

文字列としては virtio_mmio.device=4K@0xd0004000:9 のようなサイズ@アドレス:IRQ line 番号 という形で受け渡される。 これをパースし、Virtio MMIOバイスとして登録する。 実際のデバイスの認識処理については次回記述する。

その他の対応: PCIバイスの無効化

libkrun では PCIバイスを利用しないため、PCI バスのスキャンを行う必要はない。 また、本来は無効なデバイスについては VendorID を 0xFFFF とすべきところ、libkrun では 0 となっているため大量のデバイスが存在する判定となってしまう。 デバイスの過多による panic が発生するため、ビルド時に PCIバイスのサポートを無効化するオプションを追加した。

github.com

その他の対応: シャットダウン

Mewz のシャットダウンでは、IO Port 0x501 に対して書き込みを行う。 これは QEMU の起動時に isa-debug-exit,iobase=0x501,iosize=2 のオプションを指定することで、該当ポートへの書き込み時にシャットダウンを行う設定となっているためである。 しかし、libkrun では同様の機能は提供されず、代わりに i8042 の IO Port (0x64) への書き込みをもって自身を停止させるようになっている。

libkrun/src/devices/src/legacy/i8042.rs at b67a6a06a2c7b4534e9d67fcd9dba8626e4834d9 · containers/libkrun · GitHub

            OFS_STATUS if data[0] == CMD_RESET_CPU => {
                // The guest wants to assert the CPU reset line. We handle that by triggering
                // our exit event fd. Meaning Firecracker will be exiting as soon as the VMM
                // thread wakes up to handle this event.
                if let Err(e) = self.reset_evt.write(1) {
                    error!("Failed to trigger i8042 reset event: {:?}", e);
                }
            }

IO Port (0x501) への書き込み後終了しなかった場合については、 IO Port (0x64) へ 0xFE を書き込みことで終了させるようにした。

github.com

ここまでの動作確認

一旦ここまでの動作を確認する。

$ git clone https://github.com/containers/libkrunfw
$ git clone -b mewz-on-libkrun https://github.com/naoki9911/libkrun
$ git clone -b mewz-on-libkrun --recursive https://github.com/naoki9911/mewz
$ cd mewz
$ curl -o helloworld.wat https://raw.githubusercontent.com/Mewz-project/Wasker/main/helloworld.wat
$ wasker helloworld.wat
$ zig build -Dapp-obj=wasm.o -Dlog-level=info -Denable-pci=false libkrunfw
$ cd ../libkrun
$ make MEWZ=1
$ mv target/release/libkrun-mewz.so* lib/.
$ cd examples
$ make
$ sudo LD_LIBRARY_PATH=../lib ./chroot_vm dummy dummy
[LOG INFO]: booted with linux zero page
[LOG INFO]: zeropage: e820_entries=2
[LOG INFO]: E820 Entry [1] addr=0x0 size=0x9fc00 type=1
[LOG INFO]: E820 Entry [2] addr=0x100000 size=0x200a0001 type=1
[LOG INFO]: available memory: 144d7000 - 201a0001
[LOG INFO]: virtio_mmio device detected: addr=0xd0000000 size=0x1000 IRQ=5
[LOG INFO]: virtio_mmio device detected: addr=0xd0001000 size=0x1000 IRQ=6
[LOG INFO]: virtio_mmio device detected: addr=0xd0002000 size=0x1000 IRQ=7
[LOG INFO]: virtio_mmio device detected: addr=0xd0003000 size=0x1000 IRQ=8
[LOG INFO]: virtio_mmio device detected: addr=0xd0004000 size=0x1000 IRQ=9

Hello, wasker

このように、helloworld.wat について正しく実行できていることが分かる。

Mewz on libkrun - その2 libkrun の構造

Mewz (WebAssembly x Unikernel) を libkrun で動かしてみた - 外部記憶装置 の詳細を記すシリーズ

目次

対象

この記事は以下のソースコードに対応している

全体の構造

chroot_vm でシェルが起動する場合、各要素間の関係は下図のとおりになっている。

libkrun は共有ライブラリとして提供され、chroot_vm が libkrun の export された関数を呼び出すことで各種設定を行い、VM を起動している。 libkrunfw は Linux kernel の実体を提供する。これについても同様に共有ライブラリとして提供され、libkrun が呼び出す形で利用されている。

各要素の仕組み

libkrunfw

libkrunfw は専用の Linux kernel を共有ライブラリとして提供する。 また、kernel 実体を提供するだけでなく、メモリ上へのロードに必要なロードアドレスやエントリアドレスについても関数経由で設定することができる。

共有ライブラリに対応するコードは make 時に kernel.c として自動で生成される。 中身としては、Linux kernel のバイナリが展開された配列 KERNEL_BUNDLE と、末尾に libkrun に対して kernel を提供する関数 krunfw_get_kernl, krunfw_get_version が定義されている。

#include <stddef.h>
__attribute__ ((aligned (65536))) char KERNEL_BUNDLE[] = 
"\xfc\xf\x1\x15\x60\xe2\x13\x2\xb8\x10\x0\x0\x0\x8e\xd8\x8e"
"\xc0\x8e\xd0\xbf\x20\xd2\x13\x2\x89\xde\x8b\xd\xd0\x35\x1a\x2"
...
char * krunfw_get_kernel(size_t *load_addr, size_t *entry_addr, size_t *size)
{
    *load_addr = 16777216;
    *entry_addr = 16777344;
    *size = sizeof(KERNEL_BUNDLE) - 1;
    return &KERNEL_BUNDLE[0];
}

int krunfw_get_version()
{
    return ABI_VERSION;
}

KERNEL_BUNDLE は巨大な配列であり、122 万行、70MB超のファイルサイズとなっている。

$ wc -l kernel.c 
1220624 kernel.c
$ ls -l kernel.c 
-rw-r--r-- 1 naoki naoki 76255652 Dec 28 17:10 kernel.c

kernel.c 自体はビルド時のログからも分かるように、bin2cbundle.py により生成される。

Generating kernel.c from linux-6.6.63/vmlinux...
python3 bin2cbundle.py -t vmlinux linux-6.6.63/vmlinux kernel.c
cc -fPIC -DABI_VERSION=4 -shared -Wl,-soname,libkrunfw.so.4 -o libkrunfw.so.4.6.0 kernel.c  
strip libkrunfw.so.4.6.0

x86_64 環境においては ELF 形式な vmlinux から生成される。

libkrunfw/bin2cbundle.py at 563389b460691bfe235cd2b4cba56068e97d5546 · containers/libkrunfw · GitHub

def write_elf_cbundle(ifile, ofile) -> int:
    elffile = ELFFile(ifile)
    entry_addr = elffile['e_entry']

    load_segments = [ ]
    for segment in elffile.iter_segments():
        if segment['p_type'] == 'PT_LOAD':
            load_segments.append(segment)
        
    col = 0
    total_size = 0
    prev_paddr = None

    for segment in load_segments:
        if prev_paddr == None:
            load_addr = segment['p_vaddr'] & 0xfffffff
        else:
            padding = segment['p_paddr'] - prev_paddr - prev_filesz
            write_padding(ofile, padding, col)
            total_size = total_size + padding

        assert((segment['p_paddr'] - load_addr) == total_size)
        
        for byte in segment.data():
            ofile.write('\\x{:x}'.format(byte))
                
            if col == 15:
                ofile.write('"\n"')
                col = 0
            else:
                col = col + 1

        prev_paddr = segment['p_paddr']
        prev_filesz = segment['p_filesz']
        total_size = total_size + prev_filesz

    rounded_size = int((total_size + PAGE_SIZE - 1) / PAGE_SIZE) * PAGE_SIZE
    padding = rounded_size - total_size    
    write_padding(ofile, padding, col)

    return load_addr, entry_addr

処理としてはシンプルである。 ELF ヘッダをパースして PT_LOAD セグメントに対応するバイナリを切り出し、一つの配列として扱うために必要に応じてパディングを行っている。 そしてその実体は KERNEL_BUNDLE 配列として出力する。

実体を処理した後、フッターとして krunfw_get_kernel, krunfw_get_version 関数を埋め込む。 このとき、vmlinux から得られたエントリアドレスやロードアドレスをそのまま埋め込む。

それらの処理から生成された kernel.clibkrunfw.so としてビルドすることでビルドの処理が完了する。

libkrunfw/bin2cbundle.py at 563389b460691bfe235cd2b4cba56068e97d5546 · containers/libkrunfw · GitHub

def write_footer_kernel(ofile, load_addr, entry_addr):
    footer = """
char * krunfw_get_kernel(size_t *load_addr, size_t *entry_addr, size_t *size)
{{
    *load_addr = {};
    *entry_addr = {};
    *size = sizeof(KERNEL_BUNDLE) - 1;
    return &KERNEL_BUNDLE[0];
}}

int krunfw_get_version()
{{
    return ABI_VERSION;
}}
"""
    ofile.write('";\n')
    ofile.write(footer.format(load_addr, entry_addr))

libkrun

libkrun 自体は一種の VMM であり、そのすべてを記述することは大変である。 そのため、今回は Mewz を動かすために必要な部分のみをまとめる。

Linux kernel の起動に必要な設定のおおよそは vmm::builder::build_microvm で行われる。 その中で、各種 virtio デバイスの設定や command-line parameters の生成及びマッピングを行う。

VirtIO MMIOバイス

まず、デバイスの管理について追いかける。 x86_64 環境においては必要に応じてシリアルデバイスのみ PortIO で行われ、それ以外は MMIO で扱う。aarch64 においてはすべて MMIO で扱うものと見られる。

libkrun/src/vmm/src/builder.rs at b67a6a06a2c7b4534e9d67fcd9dba8626e4834d9 · containers/libkrun · GitHub

    #[cfg(target_arch = "x86_64")]
    // Safe to unwrap 'serial_device' as it's always 'Some' on x86_64.
    // x86_64 uses the i8042 reset event as the Vmm exit event.
    let mut pio_device_manager = PortIODeviceManager::new(
        serial_device,
        exit_evt
            .try_clone()
            .map_err(Error::EventFd)
            .map_err(StartMicrovmError::Internal)?,
    )
    .map_err(Error::CreateLegacyDevice)
    .map_err(StartMicrovmError::Internal)?;

    // Instantiate the MMIO device manager.
    // 'mmio_base' address has to be an address which is protected by the kernel
    // and is architectural specific.
    #[allow(unused_mut)]
    let mut mmio_device_manager = MMIODeviceManager::new(
        &mut (arch::MMIO_MEM_START.clone()),
        (arch::IRQ_BASE, arch::IRQ_MAX),
    );

virtio のデバイス追加は attach_fs_devicesattach_console_devices といった関数経由で行われる。その中で attach_mmio_device が呼び出され、mmio_device_manager への登録や cmd params へのデバイスに関する情報が登録される。

libkrun/src/vmm/src/builder.rs at b67a6a06a2c7b4534e9d67fcd9dba8626e4834d9 · containers/libkrun · GitHub

/// Attaches an MmioTransport device to the device manager.
fn attach_mmio_device(
    vmm: &mut Vmm,
    id: String,
    device: MmioTransport,
) -> std::result::Result<(), device_manager::mmio::Error> {
    let type_id = device
        .device()
        .lock()
        .expect("Poisoned device lock")
        .device_type();
    let _cmdline = &mut vmm.kernel_cmdline;

    #[cfg(target_os = "linux")]
    let (_mmio_base, _irq) =
        vmm.mmio_device_manager
            .register_mmio_device(vmm.vm.fd(), device, type_id, id)?;
    #[cfg(target_os = "macos")]
    let (_mmio_base, _irq) = vmm
        .mmio_device_manager
        .register_mmio_device(device, type_id, id)?;

    #[cfg(target_arch = "x86_64")]
    vmm.mmio_device_manager
        .add_device_to_cmdline(_cmdline, _mmio_base, _irq)?;

    Ok(())
}

kernel command-line parameters

Virtio MMIOバイスのアドレスや IRQ 番号は固定されているわけではないため、x86_64 では cmd params 経由で受け渡す。

libkrun/src/vmm/src/device_manager/kvm/mmio.rs at b67a6a06a2c7b4534e9d67fcd9dba8626e4834d9 · containers/libkrun · GitHub

    /// Append a registered MMIO device to the kernel cmdline.
    #[cfg(target_arch = "x86_64")]
    pub fn add_device_to_cmdline(
        &mut self,
        cmdline: &mut kernel_cmdline::Cmdline,
        mmio_base: u64,
        irq: u32,
    ) -> Result<()> {
        // as per doc, [virtio_mmio.]device=<size>@<baseaddr>:<irq> needs to be appended
        // to kernel commandline for virtio mmio devices to get recognized
        // the size parameter has to be transformed to KiB, so dividing hexadecimal value in
        // bytes to 1024; further, the '{}' formatting rust construct will automatically
        // transform it to decimal
        cmdline
            .insert(
                "virtio_mmio.device",
                &format!("{}K@0x{:08x}:{}", MMIO_LEN / 1024, mmio_base, irq),
            )
            .map_err(Error::Cmdline)
    }

そのため、VirtIO MMIOバイスを利用するためには cmd params を適切にパースする必要がある。 cmd params 自体はゲストメモリ上の 0x20000 にマップされている。

libkrun/src/vmm/src/builder.rs at b67a6a06a2c7b4534e9d67fcd9dba8626e4834d9 · containers/libkrun · GitHub

#[cfg(all(target_arch = "x86_64", not(feature = "tee")))]
fn load_cmdline(vmm: &Vmm) -> std::result::Result<(), StartMicrovmError> {
    kernel::loader::load_cmdline(
        vmm.guest_memory(),
        GuestAddress(arch::x86_64::layout::CMDLINE_START),
        &vmm.kernel_cmdline
            .as_cstring()
            .map_err(StartMicrovmError::LoadCommandline)?,
    )
    .map_err(StartMicrovmError::LoadCommandline)
}

libkrun/src/arch/src/x86_64/layout.rs at b67a6a06a2c7b4534e9d67fcd9dba8626e4834d9 · containers/libkrun · GitHub

/// Kernel command line start address.
pub const CMDLINE_START: u64 = 0x20000;

Linux zeropage

RAMサイズや cmd params のアドレスおよびそのサイズ等の各種 kernel 初期化に必要なパラメータは "Linux zeropage" 経由で受け渡される。

libkrun/src/arch/src/x86_64/mod.rs at b67a6a06a2c7b4534e9d67fcd9dba8626e4834d9 · containers/libkrun · GitHub

/// Configures the system and should be called once per vm before starting vcpu threads.
///
/// # Arguments
///
/// * `guest_mem` - The memory to be used by the guest.
/// * `cmdline_addr` - Address in `guest_mem` where the kernel command line was loaded.
/// * `cmdline_size` - Size of the kernel command line in bytes including the null terminator.
/// * `initrd` - Information about where the ramdisk image was loaded in the `guest_mem`.
/// * `num_cpus` - Number of virtual CPUs the guest will have.
#[allow(unused_variables)]
pub fn configure_system(
    guest_mem: &GuestMemoryMmap,
    arch_memory_info: &ArchMemoryInfo,
    cmdline_addr: GuestAddress,
    cmdline_size: usize,
    initrd: &Option<InitrdConfig>,
    num_cpus: u8,
) -> super::Result<()> {
    const KERNEL_BOOT_FLAG_MAGIC: u16 = 0xaa55;
    const KERNEL_HDR_MAGIC: u32 = 0x5372_6448;
    const KERNEL_LOADER_OTHER: u8 = 0xff;
    const KERNEL_MIN_ALIGNMENT_BYTES: u32 = 0x0100_0000; // Must be non-zero.
    let first_addr_past_32bits = GuestAddress(FIRST_ADDR_PAST_32BITS);
    let end_32bit_gap_start = GuestAddress(MMIO_MEM_START);

    let himem_start = GuestAddress(layout::HIMEM_START);

    // Note that this puts the mptable at the last 1k of Linux's 640k base RAM
    #[cfg(not(feature = "tee"))]
    mptable::setup_mptable(guest_mem, num_cpus).map_err(Error::MpTableSetup)?;

    let mut params: BootParamsWrapper = BootParamsWrapper(boot_params::default());

    params.0.hdr.type_of_loader = KERNEL_LOADER_OTHER;
    params.0.hdr.boot_flag = KERNEL_BOOT_FLAG_MAGIC;
    params.0.hdr.header = KERNEL_HDR_MAGIC;
    params.0.hdr.cmd_line_ptr = cmdline_addr.raw_value() as u32;
    params.0.hdr.cmdline_size = cmdline_size as u32;

    params.0.hdr.kernel_alignment = KERNEL_MIN_ALIGNMENT_BYTES;
    if let Some(initrd_config) = initrd {
        params.0.hdr.ramdisk_image = initrd_config.address.raw_value() as u32;
        params.0.hdr.ramdisk_size = initrd_config.size as u32;
    }

    #[cfg(feature = "tee")]
    {
        params.0.hdr.syssize = num_cpus as u32;
    }

    add_e820_entry(&mut params.0, 0, EBDA_START, E820_RAM)?;

    let last_addr = GuestAddress(arch_memory_info.ram_last_addr);
    if last_addr < end_32bit_gap_start {
        add_e820_entry(
            &mut params.0,
            himem_start.raw_value(),
            // it's safe to use unchecked_offset_from because
            // mem_end > himem_start
            last_addr.unchecked_offset_from(himem_start) + 1,
            E820_RAM,
        )?;
    } else {
        add_e820_entry(
            &mut params.0,
            himem_start.raw_value(),
            // it's safe to use unchecked_offset_from because
            // end_32bit_gap_start > himem_start
            end_32bit_gap_start.unchecked_offset_from(himem_start),
            E820_RAM,
        )?;

        if last_addr > first_addr_past_32bits {
            add_e820_entry(
                &mut params.0,
                first_addr_past_32bits.raw_value(),
                // it's safe to use unchecked_offset_from because
                // mem_end > first_addr_past_32bits
                last_addr.unchecked_offset_from(first_addr_past_32bits) + 1,
                E820_RAM,
            )?;
        }
    }

    let zero_page_addr = GuestAddress(layout::ZERO_PAGE_START);
    guest_mem
        .write_obj(params, zero_page_addr)
        .map_err(|_| Error::ZeroPageSetup)?;

    Ok(())
}

Linux kernel 公式ドキュメントにまとめられている情報は古いため、 ソースコードを直に参照すると良い。

github.com

実際には、struct boot_params に含まれる struct setup_header にある cmd_line_ptr などを利用する。

linux/arch/x86/include/uapi/asm/bootparam.h at v6.6 · torvalds/linux · GitHub

struct setup_header {
...
    __u32   cmd_line_ptr;
...
    __u32   cmdline_size;
...
} __attribute__((packed));

zeropage はゲストメモリ上の 0x7000 にマップされる。

libkrun/src/arch/src/x86_64/layout.rs at b67a6a06a2c7b4534e9d67fcd9dba8626e4834d9 · containers/libkrun · GitHub

/// The 'zero page', a.k.a linux kernel bootparams.
pub const ZERO_PAGE_START: u64 = 0x7000;

Linux kernel ブートの実際

実際に chroot_vm 経由でブートした際、zeropage (E820 エントリ) から利用可能なメモリ領域を取得し、cmd params で渡された情報に従って VirtIO MMIOバイスの登録が行われていることが分かる。

$ sudo LD_LIBRARY_PATH=../lib ./chroot_vm rootfs_fedora /bin/bash
bash-5.2# dmesg
[    0.000000] Linux version 6.6.63 (root@libkrunfw) (gcc (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0, GNU ld (GNU Binutils for Ubuntu) 2.42) #1 SMP PREEMPT_DYNAMIC Mon Dec  2 11:39:28 CET 2024
[    0.000000] Command line: reboot=k panic=-1 panic_print=0 nomodule console=hvc0 rootfstype=virtiofs rw quiet no-kvmapf init=/init.krun KRUN_INIT=/bin/bash KRUN_WORKDIR=/ KRUN_RLIMITS="6=4096:8192" "TEST=works" virtio_mmio.device=4K@0xd0000000:5 virtio_mmio.device=4K@0xd0001000:6 virtio_mmio.device=4K@0xd0002000:7 virtio_mmio.device=4K@0xd0003000:8 virtio_mmio.device=4K@0xd0004000:9 tsi_hijack  --
[    0.000000] [Firmware Bug]: TSC doesn't count with P0 frequency!
[    0.000000] BIOS-provided physical RAM map:
[    0.000000] BIOS-e820: [mem 0x0000000000000000-0x000000000009fbff] usable
[    0.000000] BIOS-e820: [mem 0x0000000000100000-0x00000000cfffffff] usable
[    0.000000] BIOS-e820: [mem 0x0000000100000000-0x0000000130000000] usable
...
[    0.785511] software IO TLB: mapped [mem 0x00000000cc000000-0x00000000d0000000] (64MB)
[    0.785573] virtio-mmio: Registering device virtio-mmio.0 at 0xd0000000-0xd0000fff, IRQ 5.
[    0.785637] virtio-mmio: Registering device virtio-mmio.1 at 0xd0001000-0xd0001fff, IRQ 6.
[    0.785677] virtio-mmio: Registering device virtio-mmio.2 at 0xd0002000-0xd0002fff, IRQ 7.
[    0.785693] virtio-mmio: Registering device virtio-mmio.3 at 0xd0003000-0xd0003fff, IRQ 8.
[    0.785763] virtio-mmio: Registering device virtio-mmio.4 at 0xd0004000-0xd0004fff, IRQ 9.
...

Mewz の対応方針

これまでの調査から、以下の流れで実装すると動作することが期待される。

  1. libkrunfw を Mewz kernel に差し替え
  2. Linux zeropage への対応 (ここで最小限のブートが可能になる)
  3. kernel command-line params への対応
  4. Virtio MMIO への対応(virtio-net)

ここまで実装すれば、Mewz kernel を起動して通信を行うことが可能になる。 さらに、virtio-vsock と TSI への対応も行う。詳細については実装時にまとめることとする。

Mewz on libkrun - その1 libkrun を試す

Mewz (WebAssembly x Unikernel) を libkrun で動かしてみた - 外部記憶装置 の詳細を記すシリーズ

目次

libkrun とは

libkrunRedHat のエンジニアである Sergio López 氏によって開発されている、コンテナ向けのVMMである。

github.com

VMM としては QEMU が代表例として知られているが、 QEMU はコンテナ向けに利用するにはフットプリントが大きく、コンテナ向けとしては扱いにくい問題がある。 そういった点を踏まえ、libkrun は、コンテナ向けにカスタマイズされた専用の Linux カーネルを動かすために必要最低限な仮想デバイスのみ提供するシンプルなVMMとして設計されている。 Kubernetes 等のコンテナオーケストレーションシステムと合わせて利用されることも想定され、ホストの Network Namespace 経由で通信するための passt との統合や、TSI と呼ばれる virtio-vsock を用いて通信する仕組みを持つ。

libkrun 自体はその名のとおり共有ライブラリとして提供され、CAPI経由で呼び出すことができるため、crun などの既存のコンテナソフトウェアと組み合わせやすいという特徴を持つ。

専用の Linux カーネルについても libkrunfw という共有ライブラリとして提供されている。 実体としては、Linux カーネル全体が配列として定義され、CAPI経由でエントリアドレスやバイナリ自体が提供される仕組みとなっている。

github.com

詳細については以下の記事にて詳しく解説されている。

rheb.hatenablog.com

logmi.jp

libkrun をビルドする

libkrun のビルドには Rust ツールチェインが必要であるため、あらかじめインストールしておく。

$ rustc --version
rustc 1.82.0 (f6e511eec 2024-10-15)

libkrunfw のビルド

まず、libkrunfw をビルド、インストールする。Linux kernel も同時にビルドされるため、必要なパッケージ群をインストールしておく。

$ sudo apt install libncurses-dev gawk flex bison openssl libssl-dev libelf-dev autoconf python3-pyelftools
$ git clone https://github.com/containers/libkrunfw
$ cd libkrunfw
$ mkdir build
$ make -j $(nproc)
$ DESTDIR=./build make install

kernel のビルドが走るため、暫く待つ。ビルドに成功すると、libkrunfw.so.4.6.0 という共有ライブラリが生成される。

$ ls -l build/usr/local/lib64/
total 19204
lrwxrwxrwx 1 naoki naoki       14 Dec 28 17:22 libkrunfw.so -> libkrunfw.so.4
lrwxrwxrwx 1 naoki naoki       18 Dec 28 17:22 libkrunfw.so.4 -> libkrunfw.so.4.6.0
-rwxr-xr-x 1 naoki naoki 19662520 Dec 28 17:22 libkrunfw.so.4.6.0

libkrun のビルド

次に、libkrun をビルドする。patchelf が必要なため、これもインストールしておく。

$ sudo apt install patchelf
$ git clone https://github.com/containers/libkrun
$ cd libkrun

libkrunfw.so を通常とは異なるパスにインストールしたため、rustc の探索パスに上記のパスを追加しておく。

diff --git a/src/libkrun/build.rs b/src/libkrun/build.rs
index a3ccc22..7e78f2d 100644
--- a/src/libkrun/build.rs
+++ b/src/libkrun/build.rs
@@ -1,4 +1,6 @@
 fn main() {
+    println!("cargo:rustc-link-search=../libkrunfw/build/usr/local/lib64");
+
     #[cfg(target_os = "macos")]
     println!("cargo:rustc-link-lib=framework=Hypervisor");
     #[cfg(target_os = "macos")]

今回はネットワーク機能を利用するために NET=1 をオプションに追記してビルドする。 なお、オプションとしてはブロックデバイス(virtio-blk) を有効化するBLK=1 なども存在する。

$ make NET=1
$ ls -l target/release/libkrun.so
-rwxr-xr-x 2 naoki naoki 3712296 Dec 28 17:28 target/release/libkrun.so

ビルドに成功すると、libkrun.so が得られる。 libkrun では、この共有ライブラリを呼び出す形で利用する。 そのため、libkrun を利用するためには自分で呼び出すコードを書く必要があるが、今回はexamplesに含まれるchroot_vmを利用する。

一旦共有ライブラリをまとめ、examples/Makefile に共有ライブラリの探索パスを追加する。

$ cp -r ../libkrunfw/build/usr/local/lib64 lib
$ cp target/release/libkrun.so* lib/.
diff --git a/examples/Makefile b/examples/Makefile
index 8c16305..4059676 100644
--- a/examples/Makefile
+++ b/examples/Makefile
@@ -1,6 +1,6 @@
 ARCH = $(shell uname -m)
 OS = $(shell uname -s)
-LDFLAGS_x86_64_Linux = -lkrun
+LDFLAGS_x86_64_Linux = -lkrun -L../lib -Wl,-rpath-link,../lib
 LDFLAGS_aarch64_Linux = -lkrun
 LDFLAGS_arm64_Darwin = -L/opt/homebrew/lib -lkrun
 LDFLAGS_sev = -lkrun-sev

ビルドし、LD_LIBRARY_PATH で共有ライブラリを配置したディレクトリを指定して実行する。

$ make
$ LD_LIBRARY_PATH=../lib ./chroot_vm
Missing COMMAND argument
Missing NEWROOT argument

Usage: ./chroot_vm [OPTIONS] NEWROOT COMMAND [COMMAND_ARGS...]
OPTIONS:
        -h    --help                Show help
              --net=NET_MODE        Set network mode
              --passt-socket=PATH   Instead of starting passt, connect to passt socket at PATHNET_MODE can be either TSI (default) or PASST

NEWROOT:      the root directory of the vm
COMMAND:      the command you want to execute in the vm
COMMAND_ARGS: arguments of COMMAND

rootfs の作成

最後に、libkrun で利用する rootfs を作成する。podman を利用した rootfs 作成用のコマンドが用意されているため、それを利用する。

$ sudo apt install podman
$ make rootfs
$ ls -l
...
drwxr-xr-x 18 naoki naoki      4096 Dec 28 17:49 rootfs_fedora
...

libkrun を動かす

rootfs として rootfs_fedora が作成されるため、それを利用して chroot_vm を実行する。 なお、/dev/kvm を利用するため、通常は sudo で起動する必要がある。

$ sudo LD_LIBRARY_PATH=../lib ./chroot_vm ./rootfs_fedora /bin/sh
sh-5.2# uname -a
Linux localhost 6.6.63 #1 SMP PREEMPT_DYNAMIC Mon Dec  2 11:39:28 CET 2024 x86_64 GNU/Linux
sh-5.2# echo "nameserver 8.8.8.8" > /etc/resolv.conf
sh-5.2# curl example.com
<!doctype html>
<html>
<head>
    <title>Example Domain</title>

    <meta charset="utf-8" />
    <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <style type="text/css">
...

ネットワーク機能付きでビルドしたため、外部のウェブサイトにもアクセス可能であることがわかる。

まとめ

今回の記事では、 libkrun のビルドから実際に動作させるまでをまとめた。 次の記事では libkrun の内部をまとめる。

newuidmap(1) の動きを調べてみた

大学の計算機で Rootless コンテナを動かそうとした時の調査メモをまとめておく(結局動かなかった)

newuidmap(1) について

newuidmap は対象のプロセスの /proc/[pid]/uid_map を指定されたものに変更するコマンドである。 uid_map は UserNS 内部の UID と外部の UID のマッピングを指定するファイルである。 通常、uid_map の書き換えは root 権限が必要であるが、newuidmap は setuid されたバイナリであるため、一般ユーザーでも EUID=0、つまり root 権限をもつバイナリとして実行できる。 そのため、Rootless コンテナのコンテナ立ち上げ時に利用される。 詳細は以下の utam0k 氏の発表で解説されている。

speakerdeck.com

/etc/subuid との関係

UserNS における UID のマッピングは、ホスト上のユーザー間で重複することがないように /etc/subuid で管理されている。 Rootless コンテナでは、newuidmap の呼び出し前にユーザーが利用可能な SubUID の範囲を調べるために直接読み出してパースしている。

rootlesskit/pkg/parent/idtools/idtools.go at 9c9049ac786483cccc59b25b8454d652669927b1 · rootless-containers/rootlesskit · GitHub

// parseSubidFile will read the appropriate file (/etc/subuid or /etc/subgid)
// and return all found ranges for a specified user. username is optional.
func parseSubidFile(path string, uid int, username string) ([]SubIDRange, error) {
    uidS := strconv.Itoa(uid)
    var rangeList []SubIDRange

    subidFile, err := os.Open(path)
    if err != nil {
        return rangeList, err
    }

読み出した SubUID の範囲から、newuidmapコマンドライン引数を生成し、呼び出している。

rootlesskit/pkg/parent/parent.go at 9c9049ac786483cccc59b25b8454d652669927b1 · rootless-containers/rootlesskit · GitHub

func setupUIDGIDMap(pid int, subidSource SubidSource) error {
    uArgs, gArgs, err := newugidmapArgs(subidSource)
    if err != nil {
        return fmt.Errorf("failed to compute uid/gid map: %w", err)
    }
    pidS := strconv.Itoa(pid)
    cmd := exec.Command("newuidmap", append([]string{pidS}, uArgs...)...)
    out, err := cmd.CombinedOutput()
    if err != nil {
        return fmt.Errorf("newuidmap %s %v failed: %s: %w", pidS, uArgs, string(out), err)
    }

newuidmap 内においても、uid_map 書き込み前に SubUID の範囲をチェックする。

shadow/src/newuidmap.c at 8821d3ff2dcf395455d28bac4f3d75649e30b907 · shadow-maint/shadow · GitHub

 /* Test /etc/subuid */
    if (have_sub_uids(pw->pw_name, range->lower, range->count))
        return true;

have_sub_uids 関数は別のファイルに定義されている。

shadow/lib/subordinateio.c at 8821d3ff2dcf395455d28bac4f3d75649e30b907 · shadow-maint/shadow · GitHub

bool have_sub_uids(const char *owner, uid_t start, unsigned long count)
{
    struct subid_nss_ops *h;
    bool found;
    enum subid_status status;
    h = get_subid_nss_handle();
    if (h) {
        status = h->has_range(owner, start, count, ID_TYPE_UID, &found);
        if (status == SUBID_STATUS_SUCCESS && found)
            return true;
        return false;
    }
    return have_range (&subordinate_uid_db, owner, start, count);
}

get_subuid_nss_handle で nss のハンドルを取得し、そのハンドルが持つ関数has_range を呼び出してチェックしている。

shadow/lib/nss.c at 8821d3ff2dcf395455d28bac4f3d75649e30b907 · shadow-maint/shadow · GitHub

nsswitch.conf の subuid で指定されたデータソースを元に、SubUID の範囲をチェックする仕組みになっている。

 // read nsswitch.conf to check for a line like:
    //   subid:    files
    nssfp = fopen(nsswitch_path, "r");

このとき、指定されたデータソース名に応じて共有ライブラリを開き、必要なハンドラを取得するが、files が指定された場合はハンドラとしては NULL を返すようになっている。

 if (streq(p, "files")) {
        goto null_subid;
    }
    .
        .
        .
    SNPRINTF(libname, "libsubid_%s.so", p);
    h = dlopen(libname, RTLD_LAZY);
    if (!h) {
        fprintf(shadow_logfd, "Error opening %s: %s\n", libname, dlerror());
        fprintf(shadow_logfd, "Using files\n");
        goto null_subid;
    }

そのため、nsswitch.conf で files が指定されていた場合、呼び出し元で have_range を呼び出して /etc/subuid を直接読み確認することになる。

shadow/lib/subordinateio.c at 8821d3ff2dcf395455d28bac4f3d75649e30b907 · shadow-maint/shadow · GitHub

/*
 * have_range: check whether @owner is authorized to use the range
 *             (@start .. @start+@count-1).
 * @db: database to check
 * @owner: owning uid being queried
 * @start: start of range
 * @count: number of uids in range
 *
 * Returns true if @owner is authorized to use the range, false otherwise.
 */
static bool have_range(struct commonio_db *db,
               const char *owner, unsigned long start, unsigned long count)

LDAP 環境

通常のローカルユーザーのみの環境であれば、ユーザー追加時に /etc/subuid に自動的に追加されるため気にする必要はない。 しかし、大学等の組織における計算機環境においては、LDAP などによりユーザーが管理されているため /etc/subuid には SubUID のマッピングが存在しない。

結論としては、SubUID のデータソースとして /etc/subuid ではなく、LDAP を使うようにすれば良い。 正しくは SSSD + LDAP である。

SSSD 2.6.0 Release Notes - sssd.io

Basic support of user’s ‘subuid and subgid ranges’ for IPA provider and corresponding plugin for shadow-utils were introduced.

原理的には nsswitch.confsubuid: sss を指定し、SSSD のバックエンドとなっている LDAP で適切な設定を入れれば良いはずである。 実際には LDAP そのものより、FreeIPA を用いたほうが楽そうではある

freeipa.readthedocs.io

RootlessKit についても v1.1.0 以降、getsubuids を呼び出して SubUID の範囲をチェックする仕組みが入っている。

rootlesskit/docs/subid.md at master · rootless-containers/rootlesskit · GitHub

Auto
The auto source (--subid-source=auto) tries the dynamic source and fall backs to the static source on an error.

Dynamic
The dynamic source (--subid-source=dynamic) executes the /usr/bin/getsubids binary to get the subids.

getsubuids についも、newuidmap と同じ仕組みで SubUID のチェックを行うため、以上の仕組みであればユーザー毎に /etc/subuid へエントリを追加することなく利用することが出来るはずである。

結論

RootlessKit、newuidmap としては SSSD を用いた LDAP 等によるユーザー管理にも対応している。 が、大学の計算機にそのような高尚な仕組みが入る気がしないため、一旦諦めた。

rustls で Certificate Unknown となるエラーへの対処方法

OpenSSL でオレオレ証明書を作って rustls で使おうとしたときにハマったためメモ。

環境

  • Ubuntu 24.04.1
  • OpenSSL 3.0.13
  • rustls 0.23.13

結論

rustls は X.509 証明書で Version 3 しか受け入れない。それ以外は Certificate Unknown が Alert として返される。 そのため、rustls をクライアントとして利用する場合、接続先のサーバーの証明書は Version 3 な証明書である必要がある。 証明書の検証にはサーバー側もクライアント側も同様に Version3 であることを要求するため、rustls をサーバーとして mTLS でクライアント認証を行う場合、クライアントの証明書も Version 3 である必要がある。

github.com

検証

まず簡単にオレオレ証明書を作ってみる。

$ mkdir -p ./certs
$ openssl req -x509 -newkey rsa:4096 -keyout ./certs/ca.key -out ./certs/ca.crt -nodes -days 365 -subj "/CN=MyCA"
$ openssl req -newkey rsa:4096 -keyout ./certs/server.key -out ./certs/server.csr -nodes -subj "/CN=localhost"
$ openssl x509 -req -in ./certs/server.csr -CA ./certs/ca.crt -CAkey ./certs/ca.key -CAcreateserial -out ./certs/server.crt -days 365

すると v1 な証明書ができる。

$ openssl x509 -noout -text -in certs/server.crt
Certificate:
    Data:
        Version: 1 (0x0)
        Serial Number:
            0b:62:fb:8c:8f:31:bc:97:86:2b:ad:01:4c:fb:57:6f:09:81:e7:c2
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: CN = MyCA
        Validity
...

これをサーバー証明書として openssl に噛ませて rustls を以下のようなクライアント(公式の example を流用)を利用して接続させると、エラーとなる。

use std::io::{stdout, Read, Write};
use std::net::TcpStream;
use std::sync::Arc;

use rustls::RootCertStore;

fn main() {
    let cert_file = std::fs::File::open("../certs/ca.crt").unwrap();
    let mut reader = std::io::BufReader::new(cert_file);
    let certs = rustls_pemfile::certs(&mut reader).last().unwrap().unwrap();

    let mut root_store = RootCertStore {
        roots: webpki_roots::TLS_SERVER_ROOTS.into(),
    };
    root_store.add(certs).unwrap();

    let config = rustls::ClientConfig::builder()
        .with_root_certificates(root_store)
        .with_no_client_auth();

    let server_name = "localhost".try_into().unwrap();
    let mut conn = rustls::ClientConnection::new(Arc::new(config), server_name).unwrap();
    let mut sock = TcpStream::connect("localhost:8443").unwrap();
    let mut tls = rustls::Stream::new(&mut conn, &mut sock);
    tls.write_all(
        concat!(
            "GET / HTTP/1.1\r\n",
            "\r\n"
        )
        .as_bytes(),
    )
    .unwrap();
    let ciphersuite = tls
        .conn
        .negotiated_cipher_suite()
        .unwrap();
    writeln!(
        &mut std::io::stderr(),
        "Current ciphersuite: {:?}",
        ciphersuite.suite()
    )
    .unwrap();
    let mut plaintext = Vec::new();
    tls.read_to_end(&mut plaintext).unwrap();
    stdout().write_all(&plaintext).unwrap();
}
$ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
     Running `target/debug/client`
thread 'main' panicked at src/main.rs:42:6:
called `Result::unwrap()` on an `Err` value: Custom { kind: InvalidData, error: InvalidCertificate(Other(OtherError(UnsupportedCertVersion))) }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
$ openssl s_server -accept 8443 -cert ../certs/server.crt -key ../certs/server.key -www
Using default temp DH parameters
ACCEPT
40370E7FF47F0000:error:0A000416:SSL routines:ssl3_read_bytes:sslv3 alert certificate unknown:../ssl/record/rec_layer_s3.c:1593:SSL alert number 46

クライアントの場合は簡単に UnsupportedCertVersion というエラーとなることから原因が証明書のバージョンにあることが分かる。

対応には Version 3 な X.509 証明書を使えば良い。openssl では extension を追加することで Version 3 な証明書を作ることができる。 ここでは簡単に subjectAltName extension を使う。-extfile で extension の内容を含むファイルを指定すれば良い。

$ echo "subjectAltName = DNS:localhost" > certs/v3.txt
$ openssl x509 -req -in ./certs/server.csr -CA ./certs/ca.crt -CAkey ./certs/ca.key -CAcreateserial -out ./certs/server.crt -days 365 -extfile ./certs/v3.txt
$ openssl x509 -noout -in ./certs/server.crt -text
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            35:a3:2c:a1:32:48:91:73:7d:cb:c2:25:08:18:17:db:85:ba:aa:11
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: CN = MyCA
...

このように、Version 3 な証明書が作成された。 あとは同じようにクラアントを実行すると、正常に接続できる。

$ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.24s
     Running `target/debug/client`
Current ciphersuite: TLS13_AES_256_GCM_SHA384
HTTP/1.0 200 ok
Content-type: text/html
...

なお、OpenSSL では extension の有無にかかわらず Version 3 な証明書をデフォルトで発行するパッチが master には入っているようである。Version 1 な証明書を作成する場合、-x509v1 という明示的な指定が必要になるらしい。

github.com

tonic + TLS (rustls) で deocode error が発生する問題への対処方法

tonic (rust 向け gRPC ライブラリ) で TLS(rustls) を有効にした状態で gRPC サーバーを動かしたときに遭遇したエラーへの対処メモ

クライアント側は grpc(v1.46.3) を呼び出す python ライブラリを利用した。

クライアント側で接続先として grpc+tls://127.0.0.1:50051 を指定すると、

E0924 16:20:11.747203306  223980 ssl_transport_security.cc:1501]       Handshake failed with fatal error SSL_ERROR_SSL: error:0A00041A:SSL routines::tlsv1 alert decode error.
Traceback (most recent call last):
  File ...

のようなエラーとなる。

Wireshark でキャプチャをすると、ClientHello の直後にサーバーから Decode error が返ってきていることが分かる。 つまり、ClientHello が何かしらおかしいということである。

ここでぐっと ClientHello を眺めると、Server Name Indication で IP アドレス (127.0.0.1) が指定されていることに気がつく。

Server Name Indication Extension の仕様を定義する RFC 6066 では、

datatracker.ietf.org

   In order to provide any of the server names, clients MAY include an
   extension of type "server_name" in the (extended) client hello.  The
   "extension_data" field of this extension SHALL contain
   "ServerNameList" where:

      struct {
          NameType name_type;
          select (name_type) {
              case host_name: HostName;
          } name;
      } ServerName;

      enum {
          host_name(0), (255)
      } NameType;

      opaque HostName<1..2^16-1>;

      struct {
          ServerName server_name_list<1..2^16-1>
      } ServerNameList;

と規定されている。今回の場合、HostName に IP アドレスが含まれるのだが、

Literal IPv4 and IPv6 addresses are not permitted in "HostName".

とあるように、HostNameIPv4, IPv6 アドレスを含むことは明確に禁止されている。 そのため、rustls が仕様通りに解釈しようとした結果、エラーとなったということである。

解決方法は簡単で、 grpc+tls://localhost:50051 のように指定すれば SNI には localhost が含められるため decode error とはならない。

ちなみに、これは grpc 側の実装のバグであり、2023年11月に修正されている。

github.com

10年以上の歴史を持つライブラリでもこのようなバグが最近見つかっているのは興味深いものがある。 (もしかしたら issue を立てた人も tonic + rustls でハマった可能性が?)

Mewz (WebAssembly x Unikernel) を libkrun で動かしてみた

より詳細な記事を 「Mewz on libkrun」シリーズとして掲載しました。

Mewz とは

Mewz は2023年度未踏IT人材発掘・育成事業において、上田氏、野崎氏によって開発されている WebAssembly (Wasm) 専用の unikernel である。 www.ipa.go.jp github.com

unikernel 実装である Mewz と、WebAssembly バイナリをマシンネイティブなオブジェクトファイルに変換する Wasker の詳細については氏らの成果報告において発表されているため割愛する。

https://demoday.saza.dev/demoday/

libkrun

libkrun は RedHat のエンジニアである Sergio López 氏によって開発されている、コンテナ向けのVMMである。 github.com

VMM としては QEMU が代表例として知られているが、 QEMU はコンテナ向けに利用するにはフットプリントが大きく、コンテナ向けとしては扱いにくい問題がある。 そういった点を踏まえ、libkrun は、コンテナ向けにカスタマイズされた専用の Linux カーネルを動かすために必要最低限な仮想デバイスのみ提供するシンプルなVMMとして設計されている。 また、libkrun 自体はその名のとおり共有ライブラリとして提供され、CAPI経由で呼び出すことができるため、crun などの既存のコンテナソフトウェアと組み合わせやすいという特徴を持つ。 また、TSIと呼ばれる、ホストのTCP/IPスタックを利用する仕組みを持つ。

専用の Linux カーネルについても libkrunfw という共有ライブラリとして提供されている。 実体としては、Linux カーネル全体が配列として定義され、CAPI経由でエントリアドレスやバイナリ自体が提供される仕組みとなっている。 github.com

詳細については以下の記事にて詳しく解説されている。 logmi.jp rheb.hatenablog.com rheb.hatenablog.com

なお、同氏により unikernel の一種である unikraft と Wasm ランタイムである WAMR をその上で動かすことには成功している。

sinrega.org

libkrun で Mewz を動かすために必要な実装

Mewz で libkrun 実装を動かすためには次の実装が必要となる

  • libkrunfw の細工
  • Mewz への追加実装
    • Linux zeropage
    • Linux kernel’s command-line parameters
    • Virtio MMIO
    • virtio-vsock (必要に応じて)

libkrunfw の細工

libkrunfw の Makefile では、専用の Linux カーネルコンパイルしそれをCファイルに変換している。

https://github.com/containers/libkrunfw/blob/8a718429995dce928aa04872a5d8c3a700a39446/Makefile#L76

ここで Mewz を処理対象とすれば、libkrun で利用可能な libkrunfw を生成することができる。

Mewz への追加実装

Linux zeropage

Mewz は multiboot プロトコルに従い BIOS から利用可能なメモリ領域を取得している。 しかし、libkrun はブートプロトコルとして、Linux zeropage を利用している www.kernel.org

multiboot プロトコルの代わりに、Linux zeropage 経由で利用可能なメモリ領域を取得できるようにすればよい。 libkrun では、0x7000 に zeropage がマップされているため、このアドレスから必要な情報を読み取る。

Linux kernel’s command-line parameters

libkrun では、仮想デバイスはすべて MMIO 経由で提供する。それらの情報は以下のデバイスツリーのような形で kernel cmdline に織り込まれた形で提供される。

reboot=k panic=-1 panic_print=0 nomodules console=hvc0 rootfstype=virtiofs rw quiet no-kvmapf init=/init.krun KRUN_INIT=/bin/bash KRUN_WORKDIR=/ KRUN_RLIMITS="6=4096:8192" "TEST=works" virtio_mmio.device=4K@0xd0000000:5 virtio_mmio.device=4K@0xd0001000:6 virtio_mmio.device=4K@0xd0002000:7 virtio_mmio.device=4K@0xd0003000:8 virtio_mmio.device=4K@0xd0004000:9 tsi_hijack  --

virtio デバイスについても、この cmdline を適切にパースし、マップされた先のアドレスと IRQ 番号を取得する必要がある。 cmdline 自体は0x20000 にマップされているため、null 終端な文字列としてパースする。

Virtio MMIO

virtio ではデバイスの制御用チャネルとして PCI を利用する場合と、MMIO を利用する場合がある。 QEMU では通常 PCI を利用し、デバイスのスキャンを行う必要があった。 libkrun ではMMIO 経由でデバイスの制御が可能であり、アドレスも cmdline で指定されているためスキャンを行う必要がなくブートの高速化も行える。

docs.oasis-open.org

ドライバの初期化フローは PCIMMIO で大きく変わらないため、PCI版virtioで行っていた初期化をMMIO版virtioデバイスで行うだけでよい。

virtio-vsock

libkrun では、通常の仮想NICである virtio-net だけでなく、ホストの TCP/IP スタックを利用する TSI が提供されている。 TSI では、virtio-vsock と呼ばれるゲストVM<->ホスト間通信をソケットで行うことができる仕組みを利用する。 そのため、TSI を利用する場合は virtio-vsock のドライバ実装と、TSI のプロトコルに従ったソケットを実装する必要がある。

docs.oasis-open.org

プロトコル等、詳細については後日記す。

動かしてみた

ここまでの実装を全て行い、実際に動かした結果が以下のスクリーンショットである。 ここでは、ホスト側の 1235/tcp ポートをゲスト VM1234/tcp ポートにマップしている。

liblwip をリンクせずとも、ネットワークの機能は TSI 経由で提供されるため正常に WebAssembly 上の Webサーバーと通信できていることが分かる。

これから

現時点では、高負荷時に不具合が生じているため性能測定が実施できていない。 コード自体も大規模かつ破壊的な変更となっているため、現時点でソースコードを公開していない。 不具合の修正と性能測定をしたのち、一連の変更を upstream へ取り込んでもらうよう働きかけたい。