Skip to content

Commit 0e4cf4a

Browse files
committed
vmm: add diff snapshots
Every snapshot currently dumps the entire guest RAM, so the pause a snapshot imposes grows with the memory size regardless of how little the guest has changed. Iterative checkpointing and warm pre-copy (take a snapshot, let the guest run, take another) repeat that full-memory cost every round. Add a diff snapshot: `vm.snapshot` accepts a `snapshot_type` of `full` (default) or `diff`, exposed as `ch-remote snapshot --diff`. The first diff of a series writes a full baseline and enables dirty-page tracking; each subsequent diff dumps only the pages dirtied since the previous one, so its pause is proportional to the dirtied memory rather than to the guest RAM size. Dirty pages are harvested after the device snapshot, so pages touched by snapshot side effects are included. They are written as a sparse `memory-ranges.diff` whose extents sit at the same offsets as in the baseline `memory-ranges`, so a delta applies onto the baseline without translation. A full snapshot, a memory layout change, a migration, restore, or deleting the VM ends the series; the next diff starts a new one. Restore takes the chain directly: `vm.restore` accepts `memory_chain`, the ancestor snapshot URLs oldest first, with `source_url` pointing at the newest delta. Memory fills from the baseline, then every delta's dirty extents replay in order through the same SEEK_DATA walk the eager restore already uses; device state and config come from the newest delta as before. A delta whose length does not match the restored layout is rejected before any page is touched, a filesystem that cannot report extents fails the restore rather than zeroing undirtied pages, and `memory_chain` is incompatible with `memory_restore_mode=ondemand`. Dirty tracking is enabled lazily on the first diff rather than at boot, so a VM that never takes a diff snapshot pays no runtime cost. Validated: an integration test writes tmpfs markers before and after the baseline and reads both back through a chain restore; unit tests cover extent replay over holes and the layout-length check. On a three-phase workload that dirtied one phase between snapshots, the diff pause measured ~17x shorter than the full one. Signed-off-by: CMGS <ilskdw@gmail.com>
1 parent f5e1351 commit 0e4cf4a

10 files changed

Lines changed: 669 additions & 118 deletions

File tree

cloud-hypervisor/src/bin/ch-remote.rs

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -487,12 +487,10 @@ fn rest_api_do_command(matches: &ArgMatches, socket: &mut UnixStream) -> ApiResu
487487
.map_err(Error::HttpApiClient)
488488
}
489489
Some("snapshot") => {
490+
let sub = matches.subcommand_matches("snapshot").unwrap();
490491
let snapshot_config = snapshot_config(
491-
matches
492-
.subcommand_matches("snapshot")
493-
.unwrap()
494-
.get_one::<String>("snapshot_config")
495-
.unwrap(),
492+
sub.get_one::<String>("snapshot_config").unwrap(),
493+
sub.get_flag("diff"),
496494
);
497495
simple_api_command(socket, "PUT", "snapshot", Some(&snapshot_config))
498496
.map_err(Error::HttpApiClient)
@@ -711,12 +709,10 @@ fn dbus_api_do_command(matches: &ArgMatches, proxy: &DBusApi1ProxyBlocking<'_>)
711709
proxy.api_vm_add_vsock(&vsock_config)
712710
}
713711
Some("snapshot") => {
712+
let sub = matches.subcommand_matches("snapshot").unwrap();
714713
let snapshot_config = snapshot_config(
715-
matches
716-
.subcommand_matches("snapshot")
717-
.unwrap()
718-
.get_one::<String>("snapshot_config")
719-
.unwrap(),
714+
sub.get_one::<String>("snapshot_config").unwrap(),
715+
sub.get_flag("diff"),
720716
);
721717
proxy.api_vm_snapshot(&snapshot_config)
722718
}
@@ -923,9 +919,14 @@ fn add_vsock_config(config: &str) -> Result<String, Error> {
923919
Ok(vsock_config)
924920
}
925921

926-
fn snapshot_config(url: &str) -> String {
922+
fn snapshot_config(url: &str, diff: bool) -> String {
927923
let snapshot_config = api::VmSnapshotConfig {
928924
destination_url: String::from(url),
925+
snapshot_type: if diff {
926+
api::VmSnapshotType::Diff
927+
} else {
928+
api::VmSnapshotType::Full
929+
},
929930
};
930931

931932
serde_json::to_string(&snapshot_config).unwrap()
@@ -1165,6 +1166,15 @@ fn get_cli_commands_sorted() -> Box<[Command]> {
11651166
Command::new("shutdown-vmm").about("Shutdown the VMM"),
11661167
Command::new("snapshot")
11671168
.about("Create a snapshot from VM")
1169+
.arg(
1170+
Arg::new("diff")
1171+
.long("diff")
1172+
.action(clap::ArgAction::SetTrue)
1173+
.help(
1174+
"Write only pages dirtied since the previous snapshot; \
1175+
the first --diff takes a full baseline and starts dirty tracking",
1176+
),
1177+
)
11681178
.arg(
11691179
Arg::new("snapshot_config")
11701180
.index(1)

cloud-hypervisor/tests/integration.rs

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8289,6 +8289,12 @@ mod ivshmem {
82898289
);
82908290
}
82918291

8292+
#[test]
8293+
#[cfg(not(feature = "mshv"))]
8294+
fn test_snapshot_restore_diff_chain() {
8295+
snapshot_restore_common::_test_snapshot_restore_diff_chain();
8296+
}
8297+
82928298
#[test]
82938299
#[cfg(not(feature = "mshv"))]
82948300
fn test_snapshot_restore_with_resume() {
@@ -8428,7 +8434,7 @@ mod ivshmem {
84288434

84298435
#[cfg(not(feature = "mshv"))]
84308436
mod snapshot_restore_common {
8431-
use std::fs::{read_to_string, remove_dir_all};
8437+
use std::fs::{create_dir, read_to_string, remove_dir_all};
84328438
use std::process::Command;
84338439

84348440
use crate::*;
@@ -8488,6 +8494,141 @@ mod snapshot_restore_common {
84888494
));
84898495
}
84908496

8497+
fn snapshot_diff(api_socket: &str, url: &str) -> bool {
8498+
let output = Command::new(clh_command("ch-remote"))
8499+
.args([
8500+
&format!("--api-socket={api_socket}"),
8501+
"snapshot",
8502+
"--diff",
8503+
url,
8504+
])
8505+
.output()
8506+
.unwrap();
8507+
if !output.status.success() {
8508+
eprintln!(
8509+
"ch-remote snapshot --diff failed: {}",
8510+
String::from_utf8_lossy(&output.stderr)
8511+
);
8512+
}
8513+
output.status.success()
8514+
}
8515+
8516+
// A diff series baseline plus one delta, restored through memory_chain.
8517+
// tmpfs markers written before and after the baseline prove the delta
8518+
// rebases onto the baseline: marker A lives in the baseline pages, marker
8519+
// B only in the delta's dirty extents.
8520+
pub(crate) fn _test_snapshot_restore_diff_chain() {
8521+
let disk_config = UbuntuDiskConfig::new(JAMMY_IMAGE_NAME.to_string());
8522+
let guest = Guest::new(Box::new(disk_config));
8523+
let kernel_path = direct_kernel_boot_path();
8524+
8525+
let api_socket_source = format!("{}.1", temp_api_path(&guest.tmp_dir));
8526+
let net_params = format!(
8527+
"id=net123,tap=,mac={},ip={},mask=255.255.255.128",
8528+
guest.network.guest_mac0, guest.network.host_ip0
8529+
);
8530+
8531+
let mut child = GuestCommand::new(&guest)
8532+
.args(["--api-socket", &api_socket_source])
8533+
.args(["--cpus", "boot=1"])
8534+
.args(["--memory", "size=1G"])
8535+
.args(["--kernel", kernel_path.to_str().unwrap()])
8536+
.args([
8537+
"--disk",
8538+
format!(
8539+
"path={}",
8540+
guest.disk_config.disk(DiskType::OperatingSystem).unwrap()
8541+
)
8542+
.as_str(),
8543+
format!(
8544+
"path={}",
8545+
guest.disk_config.disk(DiskType::CloudInit).unwrap()
8546+
)
8547+
.as_str(),
8548+
])
8549+
.args(["--net", net_params.as_str()])
8550+
.args(["--cmdline", DIRECT_KERNEL_BOOT_CMDLINE])
8551+
.capture_output()
8552+
.spawn()
8553+
.unwrap();
8554+
8555+
let snapshot_dir = temp_snapshot_dir_path(&guest.tmp_dir);
8556+
let base_dir = format!("{snapshot_dir}/base");
8557+
let delta_dir = format!("{snapshot_dir}/delta");
8558+
create_dir(&base_dir).unwrap();
8559+
create_dir(&delta_dir).unwrap();
8560+
8561+
let r = panic::catch_unwind(|| {
8562+
guest.wait_vm_boot().unwrap();
8563+
8564+
guest
8565+
.ssh_command("echo diffmarkerA > /dev/shm/marker_a")
8566+
.unwrap();
8567+
8568+
// First diff of a series writes the full baseline.
8569+
assert!(remote_command(&api_socket_source, "pause", None));
8570+
assert!(snapshot_diff(
8571+
&api_socket_source,
8572+
format!("file://{base_dir}").as_str(),
8573+
));
8574+
assert!(remote_command(&api_socket_source, "resume", None));
8575+
8576+
guest
8577+
.ssh_command("echo diffmarkerB > /dev/shm/marker_b")
8578+
.unwrap();
8579+
8580+
// Second diff carries only the pages dirtied since the baseline.
8581+
assert!(remote_command(&api_socket_source, "pause", None));
8582+
assert!(snapshot_diff(
8583+
&api_socket_source,
8584+
format!("file://{delta_dir}").as_str(),
8585+
));
8586+
});
8587+
8588+
kill_child(&mut child);
8589+
let output = child.wait_with_output().unwrap();
8590+
handle_child_output(r, &output);
8591+
8592+
// Restore from the delta, chaining it onto the baseline.
8593+
let api_socket_restored = format!("{}.2", temp_api_path(&guest.tmp_dir));
8594+
let mut child = GuestCommand::new(&guest)
8595+
.args(["--api-socket", &api_socket_restored])
8596+
.args([
8597+
"--restore",
8598+
format!(
8599+
"source_url=file://{delta_dir},memory_chain=[file://{base_dir}],resume=true"
8600+
)
8601+
.as_str(),
8602+
])
8603+
.capture_output()
8604+
.spawn()
8605+
.unwrap();
8606+
8607+
let r = panic::catch_unwind(|| {
8608+
assert!(wait_until(Duration::from_secs(30), || remote_command(
8609+
&api_socket_restored,
8610+
"info",
8611+
None
8612+
)));
8613+
8614+
// Both markers must survive: A from the baseline pages the delta
8615+
// never dirtied, B from the delta's extents.
8616+
assert_eq!(
8617+
guest.ssh_command("cat /dev/shm/marker_a").unwrap().trim(),
8618+
"diffmarkerA"
8619+
);
8620+
assert_eq!(
8621+
guest.ssh_command("cat /dev/shm/marker_b").unwrap().trim(),
8622+
"diffmarkerB"
8623+
);
8624+
});
8625+
8626+
let _ = remove_dir_all(snapshot_dir.as_str());
8627+
kill_child(&mut child);
8628+
let output = child.wait_with_output().unwrap();
8629+
handle_child_output(r, &output);
8630+
}
8631+
84918632
/// Easy disambiguation between snapshot/restore variants.
84928633
#[derive(Clone, Copy, Default)]
84938634
pub(crate) struct SnapshotRestoreTest {

docs/snapshot_restore.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,32 @@ be needed.
6060
`state.json` contains the virtual machine state. It is used to restore each
6161
component in the state it was left before the snapshot occurred.
6262

63+
## Diff snapshots
64+
65+
Passing `snapshot_type=diff` (`ch-remote snapshot --diff <url>`) writes only
66+
the pages dirtied since the previous snapshot of the series. The first `diff`
67+
request takes a full baseline and enables dirty-page tracking; each subsequent
68+
one dumps the delta, so the pause cost is proportional to the amount of dirtied
69+
memory rather than to the guest RAM size. A `full` request (or any snapshot
70+
failure, a memory layout change, a migration, or deleting the VM) ends the
71+
series; the next `diff` starts a new one with a fresh baseline.
72+
73+
A delta directory contains `config.json`, `state.json` and
74+
`memory-ranges.diff`, a sparse file whose extents sit at the same offsets as
75+
in the baseline `memory-ranges`. Restore it by pointing `source_url` at the
76+
newest delta and listing its ancestors, oldest first, in `memory_chain`:
77+
78+
```bash
79+
./ch-remote --api-socket=/tmp/cloud-hypervisor.sock restore \
80+
source_url=file:///foo/diff2,memory_chain=[file:///foo/base,file:///foo/diff1]
81+
```
82+
83+
Memory is filled from the baseline, then each delta's dirty extents are
84+
replayed in order; device state and config come from the newest delta. The
85+
files must all come from one uninterrupted series and sit on a filesystem
86+
with `SEEK_DATA`/`SEEK_HOLE` support, and `memory_chain` cannot be combined
87+
with `memory_restore_mode=ondemand`.
88+
6389
## Restore a Cloud Hypervisor VM
6490

6591
Given that one has access to an existing snapshot in `/home/foo/snapshot`,

fuzz/fuzz_targets/http_api.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use vm_migration::MigratableError;
1515
use vmm::api::http::*;
1616
use vmm::api::{
1717
ApiRequest, RequestHandler, VmInfoResponse, VmReceiveMigrationData, VmSendMigrationData,
18-
VmmPingResponse,
18+
VmSnapshotConfig, VmmPingResponse,
1919
};
2020
use vmm::config::RestoreConfig;
2121
use vmm::vm::{Error as VmError, VmState};
@@ -100,7 +100,7 @@ impl RequestHandler for StubApiRequestHandler {
100100
Ok(())
101101
}
102102

103-
fn vm_snapshot(&mut self, _: &str) -> Result<(), VmError> {
103+
fn vm_snapshot(&mut self, _: &VmSnapshotConfig) -> Result<(), VmError> {
104104
Ok(())
105105
}
106106

vmm/src/api/mod.rs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,10 +262,25 @@ pub struct VmRemoveDeviceData {
262262
pub id: String,
263263
}
264264

265+
/// Type of a VM snapshot: full memory dump or dirty-pages-only delta.
266+
#[derive(Copy, Clone, Default, Deserialize, Serialize, Debug, PartialEq, Eq)]
267+
#[serde(rename_all = "lowercase")]
268+
pub enum VmSnapshotType {
269+
/// Complete guest memory dump.
270+
#[default]
271+
Full,
272+
/// Only pages dirtied since the previous snapshot of the series. The
273+
/// first diff request takes a full baseline and starts dirty tracking.
274+
Diff,
275+
}
276+
265277
#[derive(Clone, Deserialize, Serialize, Default, Debug)]
266278
pub struct VmSnapshotConfig {
267279
/// The snapshot destination URL
268280
pub destination_url: String,
281+
/// Full dump or dirty-pages delta.
282+
#[serde(default)]
283+
pub snapshot_type: VmSnapshotType,
269284
}
270285

271286
#[derive(Clone, Deserialize, Serialize, Default, Debug)]
@@ -839,7 +854,7 @@ pub trait RequestHandler {
839854

840855
fn vm_resume(&mut self) -> Result<(), VmError>;
841856

842-
fn vm_snapshot(&mut self, destination_url: &str) -> Result<(), VmError>;
857+
fn vm_snapshot(&mut self, config: &VmSnapshotConfig) -> Result<(), VmError>;
843858

844859
fn vm_restore(&mut self, restore_cfg: RestoreConfig) -> Result<(), VmError>;
845860

@@ -1960,7 +1975,7 @@ impl ApiAction for VmSnapshot {
19601975
info!("API request event: VmSnapshot {config:?}");
19611976

19621977
let response = vmm
1963-
.vm_snapshot(&config.destination_url)
1978+
.vm_snapshot(&config)
19641979
.map_err(ApiError::VmSnapshot)
19651980
.map(|_| ApiResponsePayload::Empty);
19661981

@@ -2454,4 +2469,21 @@ mod unit_tests {
24542469
)
24552470
.unwrap_err();
24562471
}
2472+
2473+
#[test]
2474+
fn test_vm_snapshot_config_snapshot_type() {
2475+
let config: VmSnapshotConfig =
2476+
serde_json::from_str(r#"{"destination_url": "file:///foo"}"#).unwrap();
2477+
assert_eq!(config.snapshot_type, VmSnapshotType::Full);
2478+
2479+
let config: VmSnapshotConfig =
2480+
serde_json::from_str(r#"{"destination_url": "file:///foo", "snapshot_type": "diff"}"#)
2481+
.unwrap();
2482+
assert_eq!(config.snapshot_type, VmSnapshotType::Diff);
2483+
2484+
serde_json::from_str::<VmSnapshotConfig>(
2485+
r#"{"destination_url": "file:///foo", "snapshot_type": "bogus"}"#,
2486+
)
2487+
.unwrap_err();
2488+
}
24572489
}

vmm/src/api/openapi/cloud-hypervisor.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1475,6 +1475,14 @@ components:
14751475
properties:
14761476
destination_url:
14771477
type: string
1478+
snapshot_type:
1479+
type: string
1480+
enum: [full, diff]
1481+
default: full
1482+
description:
1483+
With "diff", only pages dirtied since the previous snapshot of the
1484+
series are written; the first "diff" takes a full baseline and
1485+
starts dirty tracking.
14781486

14791487
VmCoredumpData:
14801488
type: object
@@ -1498,6 +1506,13 @@ components:
14981506
type: boolean
14991507
memory_restore_mode:
15001508
$ref: "#/components/schemas/MemoryRestoreMode"
1509+
memory_chain:
1510+
type: array
1511+
items:
1512+
type: string
1513+
description:
1514+
Ancestor snapshot URLs of a diff-snapshot series, oldest (the
1515+
full baseline) first; source_url is the newest delta.
15011516
resume:
15021517
type: boolean
15031518
zone_updates:

0 commit comments

Comments
 (0)