diff --git a/cloud-hypervisor/tests/integration.rs b/cloud-hypervisor/tests/integration.rs index 5e285ead5..2e1ff0054 100644 --- a/cloud-hypervisor/tests/integration.rs +++ b/cloud-hypervisor/tests/integration.rs @@ -6871,17 +6871,40 @@ mod common_parallel { dest_api_socket: &str, dest_event_path: &str, connections: NonZeroU32, + ) -> bool { + start_live_migration_tcp_with_flags( + src_api_socket, + dest_api_socket, + dest_event_path, + connections, + false, + ) + } + + #[cfg(not(feature = "mshv"))] + fn start_live_migration_tcp_with_flags( + src_api_socket: &str, + dest_api_socket: &str, + dest_event_path: &str, + connections: NonZeroU32, + postcopy: bool, ) -> bool { // Get an available TCP port let migration_port = get_available_port(); let host_ip = "127.0.0.1"; + let receive_arg = if postcopy { + format!("receiver_url=tcp:0.0.0.0:{migration_port},memory_mode=postcopy") + } else { + format!("receiver_url=tcp:0.0.0.0:{migration_port}") + }; + // Start the 'receive-migration' command on the destination let mut receive_migration = Command::new(clh_command("ch-remote")) .args([ &format!("--api-socket={dest_api_socket}"), "receive-migration", - &format!("receiver_url=tcp:0.0.0.0:{migration_port}"), + &receive_arg, ]) .stdin(Stdio::null()) .stderr(Stdio::piped()) @@ -6901,12 +6924,17 @@ mod common_parallel { // Start the 'send-migration' command on the source let connections = connections.get(); + let extra = if postcopy { + ",memory_mode=postcopy" + } else { + "" + }; let mut send_migration = Command::new(clh_command("ch-remote")) .args([ &format!("--api-socket={src_api_socket}"), "send-migration", &format!( - "destination_url=tcp:{host_ip}:{migration_port},connections={connections}" + "destination_url=tcp:{host_ip}:{migration_port},connections={connections}{extra}" ), ]) .stdin(Stdio::null()) @@ -7158,6 +7186,102 @@ mod common_parallel { } } + // Postcopy live migration. Verifies the destination boots a guest + // that touches all of its memory, which forces every page to be + // demand-faulted across the network. + #[cfg(not(feature = "mshv"))] + fn _test_live_migration_tcp_postcopy() { + let disk_config = UbuntuDiskConfig::new(JAMMY_IMAGE_NAME.to_string()); + let guest = Guest::new(Box::new(disk_config)); + let kernel_path = direct_kernel_boot_path(); + let console_text = String::from("On a branch floating down river a cricket, singing."); + let net_id = "netpc1"; + let net_params = format!( + "id={},tap=,mac={},ip={},mask=255.255.255.128", + net_id, guest.network.guest_mac0, guest.network.host_ip0 + ); + let memory_param: &[&str] = &["--memory", "size=512M"]; + let boot_vcpus = 2; + + let src_vm_path = clh_command("cloud-hypervisor"); + let src_api_socket = temp_api_path(&guest.tmp_dir); + let mut src_child = GuestCommand::new_with_binary_path(&guest, &src_vm_path) + .args(["--cpus", format!("boot={boot_vcpus}").as_str()]) + .args(memory_param) + .args(["--kernel", kernel_path.to_str().unwrap()]) + .args(["--cmdline", DIRECT_KERNEL_BOOT_CMDLINE]) + .default_disks() + .args(["--net", net_params.as_str()]) + .args(["--api-socket", &src_api_socket]) + .capture_output() + .spawn() + .unwrap(); + + let dest_event_path = temp_event_monitor_path(&guest.tmp_dir); + let mut dest_api_socket = temp_api_path(&guest.tmp_dir); + dest_api_socket.push_str(".dest"); + let mut dest_child = GuestCommand::new(&guest) + .args(["--api-socket", &dest_api_socket]) + .args([ + "--event-monitor", + format!("path={dest_event_path}").as_str(), + ]) + .capture_output() + .spawn() + .unwrap(); + + let r = std::panic::catch_unwind(|| { + guest.wait_vm_boot().unwrap(); + assert_eq!(guest.get_cpu_count().unwrap_or_default(), boot_vcpus); + assert!(guest.get_total_memory().unwrap_or_default() > 400_000); + guest.check_devices_common(None, Some(&console_text), None); + + assert!( + start_live_migration_tcp_with_flags( + &src_api_socket, + &dest_api_socket, + &dest_event_path, + NonZeroU32::new(1).unwrap(), + /* postcopy */ true, + ), + "Postcopy live migration command failed." + ); + }); + if r.is_err() { + print_and_panic( + src_child, + dest_child, + None, + "Error occurred during postcopy live-migration", + ); + } + + let src_exited_ok = wait_until(Duration::from_secs(60), || { + matches!(src_child.try_wait(), Ok(Some(_))) + }) && src_child.try_wait().unwrap().is_some_and(|s| s.success()); + if !src_exited_ok { + print_and_panic( + src_child, + dest_child, + None, + "Source VM (postcopy) was not terminated successfully.", + ); + } + + let r = std::panic::catch_unwind(|| { + // Probing the destination forces page faults across most of + // guest memory. If the source serve loop drops bytes, these + // checks fail. + assert_eq!(guest.get_cpu_count().unwrap_or_default(), boot_vcpus); + assert!(guest.get_total_memory().unwrap_or_default() > 400_000); + guest.check_devices_common(None, Some(&console_text), None); + }); + + let _ = dest_child.kill(); + let dest_output = dest_child.wait_with_output().unwrap(); + handle_child_output(r, &dest_output); + } + #[cfg(not(feature = "mshv"))] fn _test_live_migration_tcp_timeout(timeout_strategy: TimeoutStrategy) { let disk_config = UbuntuDiskConfig::new(JAMMY_IMAGE_NAME.to_string()); @@ -7383,6 +7507,12 @@ mod common_parallel { _test_live_migration_tcp(NonZeroU32::new(8).unwrap()); } + #[test] + #[cfg(not(feature = "mshv"))] + fn test_live_migration_tcp_postcopy() { + _test_live_migration_tcp_postcopy(); + } + #[test] #[cfg(not(feature = "mshv"))] fn test_live_migration_tcp_timeout_cancel() { diff --git a/docs/live_migration.md b/docs/live_migration.md index daa15f43b..211c31590 100644 --- a/docs/live_migration.md +++ b/docs/live_migration.md @@ -364,6 +364,9 @@ migration process. Via the API or `ch-remote`, you may specify: The number of parallel TCP connections to use for migration. Must be between `1` and `128`. Defaults to `1`. Multiple connections are not supported with local UNIX-socket migration. +- `memory_mode `: \ + Memory transfer mode. `postcopy` resumes the destination first and faults + guest pages in on demand over a dedicated connection. Defaults to `precopy`. ## Version Compatibility diff --git a/vmm/src/lib.rs b/vmm/src/lib.rs index 1e15d9970..b227949ae 100644 --- a/vmm/src/lib.rs +++ b/vmm/src/lib.rs @@ -37,8 +37,8 @@ use serde::{Deserialize, Serialize}; use signal_hook::iterator::{Handle, Signals}; use thiserror::Error; use tracer::trace_scoped; -use vm_memory::GuestMemoryAtomic; use vm_memory::bitmap::AtomicBitmap; +use vm_memory::{Bytes, GuestAddress, GuestAddressSpace, GuestMemoryAtomic}; use vm_migration::protocol::*; use vm_migration::{ MemoryMigrationContext, Migratable, MigratableError, OngoingMigrationContext, Pausable, @@ -1613,7 +1613,9 @@ impl Vmm { // Let every Migratable object know about the migration being started. vm.start_migration()?; - if send_data_migration.local { + if send_data_migration.local + || matches!(send_data_migration.memory_mode, MigrationMode::Postcopy) + { // Now pause VM (skip if already paused, e.g. migrating a paused VM) let downtime_begin = Instant::now(); if vm.get_state() != VmState::Paused { @@ -1656,12 +1658,34 @@ impl Vmm { vm.release_disk_locks() .map_err(|e| MigratableError::UnlockError(anyhow!("{e}")))?; + // For postcopy, serve faults before sending State so the destination + // can fault pages in during restore. + let postcopy_handle = if matches!(send_data_migration.memory_mode, MigrationMode::Postcopy) + { + let fault_stream = transport::open_fault_connection( + &send_data_migration.destination_url, + send_data_migration.tls_dir.as_deref(), + )?; + let guest_memory = vm.guest_memory(); + let handle = thread::Builder::new() + .name("migrate-send-postcopy".to_owned()) + .spawn(move || Self::serve_postcopy(fault_stream, guest_memory)) + .map_err(|e| { + MigratableError::MigrateSend(anyhow!("spawning postcopy serve thread: {e}")) + })?; + Some(handle) + } else { + None + }; + let (vm_snapshot, snapshot_duration) = measure_ok(|| { // Capture snapshot. This may have side effects, e.g. vhost-user backend inflight drain let snapshot = vm.snapshot()?; // One final memory iteration to handle side effects from snapshot. - if !send_data_migration.local { + if !send_data_migration.local + && !matches!(send_data_migration.memory_mode, MigrationMode::Postcopy) + { let memory_ranges = vm.dirty_log()?; transport::send_memory_ranges(&vm.guest_memory(), &memory_ranges, &mut socket)?; } @@ -1701,14 +1725,92 @@ impl Vmm { debug!("Downtime breakdown: {}", ctx.downtime_ctx); // Stop logging dirty pages - if !send_data_migration.local { + if !send_data_migration.local + && !matches!(send_data_migration.memory_mode, MigrationMode::Postcopy) + { vm.stop_dirty_log()?; } + // Wait for the serve thread to drain every page + if let Some(handle) = postcopy_handle { + handle.join().map_err(|e| { + MigratableError::MigrateSend(anyhow!("postcopy serve thread panicked: {e:?}")) + })??; + // Signal that postcopy has drained every page to the destination. + event!("vm", "postcopy-migration-completed"); + } + // Let every Migratable object know about the migration being complete vm.complete_migration() } + /// Serve `Command::PageFault` requests from local guest memory on the fault + /// connection until the destination closes it. Runs on its own thread. + #[expect( + clippy::needless_pass_by_value, + reason = "runs on a dedicated thread and must own its arguments" + )] + fn serve_postcopy( + mut socket: SocketStream, + guest_memory: GuestMemoryAtomic, + ) -> result::Result<(), MigratableError> { + let mut buf: Vec = Vec::new(); + info!("Postcopy: source entering PageFault serve loop"); + + loop { + let req = match Request::read_from(&mut socket) { + Ok(r) => r, + Err(MigratableError::MigrateSocket(e)) + if matches!( + e.kind(), + io::ErrorKind::UnexpectedEof | io::ErrorKind::BrokenPipe + ) => + { + info!("Postcopy: destination closed the fault connection — drain complete"); + return Ok(()); + } + Err(e) => return Err(e), + }; + + match req.command() { + Command::PageFault => { + let range = MemoryRange::read_from(&mut socket)?; + let len = range.length as usize; + const MAX_PAGE: usize = 1 << 30; // 1 GiB + if len == 0 || len > MAX_PAGE { + return Err(MigratableError::MigrateSend(anyhow!( + "Postcopy: invalid page length {len}" + ))); + } + buf.resize(len, 0); + let mem = guest_memory.memory(); + mem.read_slice(&mut buf[..len], GuestAddress(range.gpa)) + .map_err(|e| { + MigratableError::MigrateSend(anyhow!( + "Postcopy: reading guest memory gpa={:#x} len={}: {e}", + range.gpa, + range.length + )) + })?; + Response::new(Status::Ok, range.length).write_to(&mut socket)?; + socket + .write_all(&buf[..len]) + .map_err(MigratableError::MigrateSocket)?; + } + Command::Abandon => { + Response::ok().write_to(&mut socket)?; + info!("Postcopy: received Abandon, exiting serve loop"); + return Ok(()); + } + c => { + return Err(MigratableError::MigrateSend(anyhow!( + "Postcopy: unexpected command in serve loop: {c:?}", + ))); + } + } + } + } + #[cfg(all(feature = "kvm", target_arch = "x86_64"))] fn vm_check_cpuid_compatibility( &self, diff --git a/vmm/src/migration/transport.rs b/vmm/src/migration/transport.rs index da3249934..a93f6a813 100644 --- a/vmm/src/migration/transport.rs +++ b/vmm/src/migration/transport.rs @@ -958,6 +958,22 @@ pub(crate) fn send_migration_socket( } } +/// Open a dedicated fault connection with the destination VM and announce +/// the [`ConnectionRole::Fault`] role. Used by the source to serve `PageFault` +/// requests asynchronously. +pub(crate) fn open_fault_connection( + destination_url: &str, + tls_dir: Option<&Path>, +) -> Result { + let mut socket = send_migration_socket(destination_url, tls_dir)?; + ConnectionRole::Fault.write_to(&mut socket)?; + // Enable fault request/response round-trips. + socket + .set_nodelay(true) + .map_err(MigratableError::MigrateSocket)?; + Ok(socket) +} + /// Bind a migration listener for the receiver side. pub(crate) fn receive_migration_listener( receiver_url: &str,