mirror of
https://github.com/cloud-hypervisor/cloud-hypervisor.git
synced 2026-08-05 02:19:16 +00:00
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:
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
110
vmm/src/lib.rs
110
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<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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user