vmm: Wire postcopy live migration from source VM

Wire up the source side of postcopy migration over TCP. When
`mode=postcopy` is requested on vm.send-migration, the source skips
the pre-copy dirty-tracking loop and lets the destination resume early,
then serves guest pages on demand over a dedicated connection.

Signed-off-by: Sebastien Boeuf <sboeuf@meta.com>
Assisted-by: Claude:claude-opus-4-7
This commit is contained in:
Sebastien Boeuf
2026-05-29 05:57:12 -07:00
parent 60398f11ff
commit 80958acdab
4 changed files with 257 additions and 6 deletions

View File

@@ -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() {

View File

@@ -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 <precopy|postcopy>`: \
Memory transfer mode. `postcopy` resumes the destination first and faults
guest pages in on demand over a dedicated connection. Defaults to `precopy`.
## Version Compatibility

View File

@@ -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<GuestMemoryMmap>,
) -> result::Result<(), MigratableError> {
let mut buf: Vec<u8> = 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,

View File

@@ -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<SocketStream, MigratableError> {
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,