VMライフサイクルをシグナルとCLIで管理する——ブロック中のKVM_RUNをSIGUSR1で起こす

複数vCPUをスレッドで実行しSMP起動させるまでは、カーネルイメージのパスやvCPU数はすべてソース中の定数で、VMの終了経路もゲストがreboot=k経由で出すリセット要求だけでした。今回はこれらの設定をコマンドライン引数化し、ホスト側からのCtrl+C(SIGINT)やSIGTERMでも、複数vCPUのスレッドをすべて合流させたうえでクリーンに終了できるようにします。

CLI引数化

std::env::args()をそのまま読む、最小限の自前パーサーを実装しました。外部クレートには頼らず、Config構造体とデフォルト値、フラグに応じたフィールドの書き換えだけで完結しています。

pub struct Config {
    pub kernel_path: String,
    pub initramfs_path: String,
    pub disk_path: String,
    pub tap_name: String,
    pub num_vcpus: u64,
    pub memory_mb: usize,
}

pub fn parse_args() -> Config {
    let mut config = Config::default();
    let mut args = std::env::args().skip(1);

    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--kernel" => config.kernel_path = next_value(&mut args, "--kernel"),
            "--vcpus" => {
                config.num_vcpus = next_value(&mut args, "--vcpus")
                    .parse()
                    .expect("--vcpus must be a positive integer");
            }
            // ...
        }
    }
    config
}

これに合わせて、boot::load_kernel_and_initramfsはカーネル/initramfsのパスとメモリサイズを引数で受け取るように変更し、virtio_blk/virtio_netに個別にハードコードされていたディスクイメージのパスとTAPデバイス名の定数は削除しました。

VMライフサイクルの課題

複数vCPUをスレッドで実行しSMP起動させるで実装した状態では、ゲストがreboot=kによるi8042経由のリセット要求を出すとBSP(vcpu 0)がそれを検知してメインスレッドのループを抜けますが、AP(vcpu 1以降)を実行しているスレッドは特に何もせず、プロセス終了時にまとめて破棄されるだけでした。ホスト側からCtrl+Cで中断したい場合も、単にプロセスをkillするだけではvCPUスレッドが何をしている最中か分からず、後片付けの機会がありません。今回はこれを、どちらの停止理由でも同じ経路を通る形に整理します。

ブロック中のKVM_RUNをどう起こすか

複数vCPUをスレッドで実行しSMP起動させるで確認した通り、アイドル状態のvCPU(割り込みが有効な状態でhltを実行したもの)は、次の割り込みが来るまでvcpu.run()の呼び出しの中でブロックされたままになり、ユーザー空間には戻ってきません。つまり「共有のフラグを立てて、各スレッドのループの先頭でチェックする」というだけの実装では、ブロックされたままのスレッドはいつまでもそのフラグに気づけません。

これを解決するには、ブロック中のシステムコール自体をシグナルで中断させる必要があります。KVM_RUNはシグナルによって中断されるとEINTRを返す仕様になっており、これを利用して各vCPUスレッドに個別にシグナルを送ります。

const VCPU_KICK_SIGNAL: libc::c_int = libc::SIGUSR1;

extern "C" fn vcpu_kick_handler(_: libc::c_int) {}

pub fn install_vcpu_kick_handler() {
    unsafe {
        libc::signal(VCPU_KICK_SIGNAL, vcpu_kick_handler as *const () as libc::sighandler_t);
    }
}

SIGUSR1はアプリケーションが自由に用途を割り当ててよいシグナルです。ハンドラを登録しないまま送ると、SIGUSR1の既定動作(プロセス終了)が発動してしまうため、中身が空でもハンドラの登録自体は必須です。

各vCPUスレッドは、開始時に自分自身のpthread_t(POSIXスレッドのID)を共有のLifecycle構造体へ登録します。停止を要求する側は、登録済みの全スレッドへpthread_killSIGUSR1を送ります。

pub struct Lifecycle {
    shutdown: AtomicBool,
    vcpu_threads: Mutex<Vec<libc::pthread_t>>,
}

impl Lifecycle {
    pub fn register_current_thread(&self) {
        let tid = unsafe { libc::pthread_self() };
        self.vcpu_threads.lock().unwrap().push(tid);
    }

    pub fn request_shutdown(&self) {
        self.shutdown.store(true, Ordering::SeqCst);
        let threads = self.vcpu_threads.lock().unwrap();
        for &tid in threads.iter() {
            unsafe {
                libc::pthread_kill(tid, VCPU_KICK_SIGNAL);
            }
        }
    }
}

各vCPUの実行ループでは、ループの先頭でフラグを確認し、KVM_RUNEAGAIN(SIPI待ち)またはEINTR(シグナルによる中断)を返した場合は単にループの先頭へ戻ります。実際の判定はすべてループ先頭のフラグチェックに任せる形です。

loop {
    if lifecycle.is_shutdown_requested() {
        println!("\n[vcpu {vcpu_id}] shutdown requested, stopping this vCPU");
        break;
    }

    let exit = match vcpu.run() {
        Ok(exit) => exit,
        Err(e) if e.errno() == libc::EAGAIN => continue,
        Err(e) if e.errno() == libc::EINTR => continue,
        Err(e) => panic!("[vcpu {vcpu_id}] KVM_RUN failed: {e}"),
    };
    // ...
}

ゲスト側のリセット要求・Hlt・致命的なvcpu exitといった、VMMの停止トリガーとなる箇所はすべてlifecycle.request_shutdown()を呼ぶよう統一しました。これにより、どのvCPUが停止のきっかけを作っても、残りの全vCPUへ同じ経路で通知が届きます。

SIGINT/SIGTERMを同期的に受け取る

Ctrl+C(SIGINT)やSIGTERMは、複数スレッドが存在する状態で素朴にシグナルハンドラを登録すると、どのスレッドがそのシグナルを受け取るかが不定になります。これを避けるため、SIGINT/SIGTERMは全スレッドでブロックしたうえで、sigwait()を呼ぶ専用の監視スレッドだけが同期的に受け取るようにしています。

// 他のスレッドを生成するより前に呼ぶ必要がある。
// スレッドは生成元のシグナルマスクを引き継ぐため。
pub fn block_shutdown_signals() {
    unsafe {
        let mut set: libc::sigset_t = std::mem::zeroed();
        libc::sigemptyset(&mut set);
        libc::sigaddset(&mut set, libc::SIGINT);
        libc::sigaddset(&mut set, libc::SIGTERM);
        libc::pthread_sigmask(libc::SIG_BLOCK, &set, std::ptr::null_mut());
    }
}

pub fn spawn_signal_watcher(lifecycle: Arc<Lifecycle>) {
    std::thread::spawn(move || {
        let mut set: libc::sigset_t = unsafe { std::mem::zeroed() };
        unsafe {
            libc::sigemptyset(&mut set);
            libc::sigaddset(&mut set, libc::SIGINT);
            libc::sigaddset(&mut set, libc::SIGTERM);
        }
        let mut received: libc::c_int = 0;
        unsafe {
            libc::sigwait(&set, &mut received);
        }
        println!("\nreceived signal {received}, shutting down...");
        lifecycle.request_shutdown();
    });
}

block_shutdown_signals()は他のスレッドを1つも作る前、main関数の先頭で呼び出す必要があります。スレッドは生成元のシグナルマスクをそのまま引き継ぐため、ここで全スレッド分をまとめてブロックしていることになります。SIGUSR1はこの対象に含めていないため、各vCPUスレッドを個別に起こす用途では引き続き有効に機能します。

動作確認

ゲスト側からのreboot=kによるリセット要求では、BSPがまず検知し、続けて他の全vCPUが停止を報告して合流します。

[   15.868976] reboot: Restarting system
[   15.872280] reboot: machine restart

[vcpu 0] guest requested reset (i8042), stopping VM

[vcpu 1] shutdown requested, stopping this vCPU
VM stopped.

ブート途中(virtio-net/virtio-blockのテスト直後、アイドル中)でホストからCtrl+Cを送った場合も、4つのvCPUすべて(BSPを含む)が停止を報告してから終了します。

^C
received signal 2, shutting down...

[vcpu 1] shutdown requested, stopping this vCPU

[vcpu 2] shutdown requested, stopping this vCPU

[vcpu 0] shutdown requested, stopping this vCPU

[vcpu 3] shutdown requested, stopping this vCPU
VM stopped.

received signal 22はSIGINTの番号です。アイドル中でブロックされたままだったKVM_RUNが、SIGUSR1によるキックで正しく中断され、shutdownフラグに気づけていることが確認できます。

現時点の実装の制限

Ctrl+Cを連打した場合の強制終了(2回目以降は問答無用でプロセスを終了する、といった仕組み)は実装しておらず、常にvCPUスレッドの合流を待ちます。通常はvCPUの停止処理自体がすぐに完了するため実用上の問題にはなりませんが、ゲスト側が何らかの理由で応答しなくなった場合に備えたタイムアウト処理は今後の課題です。また、CLI引数のバリデーションは最小限(--vcpusが0以下でないことのみ)にとどめており、存在しないファイルパスを指定した場合のエラーメッセージも、ファイルオープンに失敗した際の標準的なもの以上には作り込んでいません。