Checkpoint / Restore
Boot a container, checkpoint it, then restore as many fresh sandboxes from that checkpoint as you need — instead of re-running whatever expensive setup got the original into that state.
What this actually captures
A checkpoint is a filesystem capture, not a memory snapshot: restore boots a
container whose filesystem starts exactly where the checkpoint left off, but every
process inside it starts from scratch — nothing about the checkpointed container’s
running processes, open connections, or in-memory state survives. If you have just
written files via exec, run sync in the guest before checkpointing — an unflushed
write is exactly the kind of in-memory state a checkpoint does not capture. State on RAM-backed
mounts is not captured either: the microsandbox guest mounts /tmp as tmpfs, so a
file written there never enters a checkpoint — write anything you need restored to
a rootfs path such as /srv or /var.
That’s enough for the common case: boot a database, run migrations and seed data,
checkpoint it, and every later Container::from_checkpoint(&cp) restores a
fully-migrated-and-seeded database in the time it takes to boot, with no
re-migration and no re-seeding. It is not enough for anything that depends on live
process state (an in-flight transaction, a warmed in-memory cache, an open
connection) — that needs true memory snapshotting, which stays on the
roadmap pending upstream microsandbox support.
Both backends support it — by different mechanisms
| Backend | Mechanism | Effect on the source container |
|---|---|---|
| docker | Image commit (POST /commit) | Undisturbed — the running container keeps running exactly as it was. |
| microsandbox | Disk snapshot: stops the sandbox, snapshots its disk, and boots it back from that snapshot under the same name and ports | The sandbox briefly stops and its workload restarts (the guest reboots); checkpoint() re-runs the container’s own wait strategy before returning, so you never get back a false-ready guard. |
On microsandbox, the guest reboot also drops any emulated network links (see
“Networking is emulated, not native”),
so checkpoint() re-installs them, with the same links this container started
with, before the wait-strategy re-run above.
capabilities().checkpoint is true on both:
use rightsize::backends;
let caps = backends::active().capabilities();
if caps.checkpoint {
// both real backends land here today
}
if caps.checkpoint_restarts_workload {
// microsandbox: the stop/snapshot/start cycle rebooted the guest
} else {
// docker: the image commit left the container running, undisturbed
}
API
use rightsize::Container;
let original = Container::new("postgres:16-alpine")
.with_env("POSTGRES_PASSWORD", "test")
.with_exposed_ports(&[5432])
.start()
.await?;
// ... run migrations, seed data ...
let checkpoint = original.checkpoint().await?;
original.stop().await?;
// Later, in this process or a later one, under the SAME backend:
let restored = Container::from_checkpoint(&checkpoint).start().await?;
checkpoint() requires the guard to be currently running — calling it on a stopped
or never-started guard is a state error, the same shape as exec/logs. A
container started with with_tmpfs_root
refuses outright: its root disk is RAM-backed and gone the moment the guest
stops, so checkpoint()/checkpoint_named() return
RightsizeError::TmpfsRootCheckpoint before touching anything — before the
guest is even stopped, and, for a named checkpoint that would have replaced an
existing one, before that existing checkpoint is touched either. On success it
returns a Checkpoint:
pub struct Checkpoint {
pub checkpoint_ref: String, // backend-native ref, random per checkpoint — see "Ref formats" below
pub backend: String, // which backend created it, e.g. "docker" / "microsandbox"
pub spec: ContainerSpec, // see caveat below
}
spec is the source container’s full spec at checkpoint time only when the
Checkpoint came directly back from checkpoint()/checkpoint_named(). A
Checkpoint rediscovered via Checkpoint::find/Checkpoint::list instead carries a
reconstructed spec: only env, command, exposed ports, and the memory limit are
real (the four fields from_checkpoint actually reads back); every other field is a
placeholder, since the registry never persists the full spec — see “Reusing
checkpoints across runs” below.
Container::from_checkpoint(&checkpoint) builds a normal Container whose image is
checkpoint.checkpoint_ref and whose env, command, exposed ports, and memory limit default
to the source container’s — everything a restored container needs to behave like
the original. Every ordinary builder still works on the result, so a caller can
override anything before .start():
let restored = Container::from_checkpoint(&checkpoint)
.waiting_for(Wait::for_log_message("database system is ready", 1))
.start()
.await?;
Deliberately not carried over from the checkpoint’s spec: mounted files, network membership, and aliases. A checkpoint already has whatever those mounts wrote baked directly into its filesystem, and network topology has no well-defined meaning to replay across a restore.
On microsandbox, the restore also carries the checkpoint’s own root disk as-is —
the snapshot pins it — so with_disk_limit/with_tmpfs_root on the restoring
Container are not valid here; msb rejects a root-disk setting on a
from_checkpoint restore before boot.
Ref formats
A checkpoint’s checkpoint_ref is backend-native, and its shape differs by backend:
- docker: an image tag,
rightsize/checkpoint:<12 hex chars>. - microsandbox: an absolute path to the snapshot artifact,
<cache dir>/checkpoints/rz-ckpt-<12 hex chars>(or<cache dir>/checkpoints/rz-ckpt-<name>for a named checkpoint — see “Reusing checkpoints across runs” below), created via msb’s--dest-dirso every process on the host agrees on where the artifact lives rather than wherever msb’s own default snapshot store happens to be. The ref stays opaque either way — nothing in the public API changes, and a barerz-ckpt-<12hex>ref minted by an earlier release keeps restoring. msb still lists the snapshot in its own global index (msb snapshot list) regardless of the--dest-dirit was created under; removing it viaCheckpoint::removeor the manual CLI cleanup below cleans up both that index entry and the dest-dir artifact.
Both are random per checkpoint (never reused across calls).
Restoring under the wrong backend is a typed error
Checkpoint::backend records which backend created it, and from_checkpoint
refuses to restore under a different active backend — a docker-committed image
has no meaning as a microsandbox snapshot ref, and vice versa:
let restored = Container::from_checkpoint(&checkpoint); // created under docker
// If the active backend is microsandbox:
let err = restored.start().await.unwrap_err();
// RightsizeError::CheckpointBackendMismatch, naming both backends:
// "... the active backend is 'microsandbox', but this checkpoint was created by
// the 'docker' backend — set RIGHTSIZE_BACKEND=docker to use it ..."
This check runs before any backend work, so a mismatch never reaches the CLI/daemon
at all. Checkpoint::export_to/Checkpoint::import_from (see
“Moving checkpoints between machines”
below) apply the exact same gate, before any backend or filesystem work.
Restored containers are ordinary containers
A container started from Container::from_checkpoint(&cp) is indistinguishable from
one started any other way: a fresh name, fresh host ports (chosen by the core
allocator exactly like any other start()), normal registration in the
orphan-reaping ledger, and a normal stop() that tears it down like
any other container. Nothing about it is special once start() returns.
The seeded-fixture pattern
The pattern this feature exists for: boot once per test suite, seed once, then restore per test case instead of re-seeding every time.
use rightsize::{Checkpoint, Container};
// Once, at suite setup:
async fn seed_checkpoint() -> rightsize::Result<Checkpoint> {
let seed = Container::new("postgres:16-alpine")
.with_env("POSTGRES_PASSWORD", "test")
.with_exposed_ports(&[5432])
.start()
.await?;
// run migrations, insert fixture rows, whatever the suite needs baked in ...
let cp = seed.checkpoint().await?;
seed.stop().await?;
Ok(cp)
}
// Per test case:
async fn fresh_seeded_db(cp: &Checkpoint) -> rightsize::Result<rightsize::ContainerGuard> {
Container::from_checkpoint(cp).start().await
}
Every test case gets an independent, already-migrated-and-seeded database, at the cost of one checkpoint up front instead of N re-runs of migrate-and-seed.
Reusing checkpoints across runs
The seeded-fixture pattern above still needs to run seed_checkpoint() at least
once per PROCESS — the Checkpoint it returns only ever lives in memory. A NAMED
checkpoint fixes that: checkpoint_named(name) persists a small registry entry
alongside the backend artifact, so a LATER process — a later test run, a later CI
job, a different binary entirely — can rediscover and restore it without
re-seeding, as long as both processes agree on the rightsize cache directory (see
configuration) and run under the same backend.
use rightsize::{Checkpoint, Container};
let original = Container::new("postgres:16-alpine")
.with_env("POSTGRES_PASSWORD", "test")
.with_exposed_ports(&[5432])
.start()
.await?;
// ... run migrations, seed data ...
let checkpoint = original.checkpoint_named("seeded-db").await?;
original.stop().await?;
name must match ^[a-z0-9][a-z0-9-]{0,40}$ — anything else fails with a typed
RightsizeError::InvalidCheckpointName before any backend call. The unnamed
checkpoint() shown earlier in this page is unaffected: it keeps its exact
existing behavior (a random ref, no registry entry, ephemeral) — only a NAMED
checkpoint persists.
The idiomatic first-run/later-run pattern is find(...) ?: seed() — try to
rediscover the checkpoint first, and only pay the seeding cost if nothing was
found:
async fn seeded_db() -> rightsize::Result<Checkpoint> {
if let Some(cp) = Checkpoint::find("seeded-db").await? {
return Ok(cp);
}
let seed = Container::new("postgres:16-alpine")
.with_env("POSTGRES_PASSWORD", "test")
.with_exposed_ports(&[5432])
.start()
.await?;
// ... run migrations, insert fixture rows ...
let cp = seed.checkpoint_named("seeded-db").await?;
seed.stop().await?;
Ok(cp)
}
let restored = Container::from_checkpoint(&seeded_db().await?).start().await?;
The first process to call this pays the seed cost once; every later process
(concurrent CI shards, a developer’s next local run) rediscovers the same
checkpoint via Checkpoint::find and skips straight to restoring it.
Checkpoint also exposes list() and remove(name):
// Every named checkpoint currently registered — registry contents only, no
// artifact probing.
let all = Checkpoint::list()?;
// Tear one down explicitly: best-effort removes the backend artifact, then the
// registry entry. Returns whether anything existed — idempotent, "not found" is
// success.
let removed = Checkpoint::remove("seeded-db").await?;
Replace semantics: re-checkpointing an existing name REPLACES it —
checkpoint_named best-effort removes the previous ref before taking the new
one (only when that previous ref belongs to the currently active backend; see
below), then rewrites the registry entry. Latest wins; there is no versioning.
The registry: one JSON file per name, <cacheDir>/checkpoints/<name>.json
(the same rightsize cache directory every backend and the
reaping ledger share, and — for microsandbox — the same
checkpoints/ directory its own snapshot artifacts now live under, see “Ref
formats” above), written atomically only after the backend
checkpoint has already succeeded. find(name) probes a same-backend entry’s
artifact before returning it — a stale entry (the artifact deleted out from under
the registry) resolves to absent and is cleaned up automatically; a
different-backend entry is returned unprobed, since restoring under the wrong
backend is already a typed
error at start() time. list() never probes at all. Removing (or replacing) a
checkpoint under a different active backend than its creator drops the registry
record but leaves the artifact behind, and once the record is gone a later remove
finds nothing to act on — remove a checkpoint under its creating backend in the first
place, or
use the manual CLI cleanup below.
Moving checkpoints between machines
Everything above rediscovers a checkpoint on the SAME machine, via the shared
rightsize cache directory’s registry. export_to/import_from move one to a
DIFFERENT machine entirely — a portable archive file, so a checkpoint seeded once
in CI can be cached as a build artifact and restored on a later runner instead of
re-seeding there too:
use rightsize::{Checkpoint, Container};
// On the seeding machine (or CI job):
let checkpoint = original.checkpoint_named("seeded-db").await?;
checkpoint.export_to("/tmp/seeded-db.archive").await?;
// On a later machine, running the SAME backend:
let checkpoint = Checkpoint::import_from("/tmp/seeded-db.archive").await?;
let restored = Container::from_checkpoint(&checkpoint).start().await?;
The archive is a plain file: checkpoint.export_to(path) writes it,
Checkpoint::import_from(path) reads it back and returns a restorable
Checkpoint — no network transport of its own, so shipping it anywhere (a CI
cache, an artifact store, a shared volume) is the caller’s own choice.
The image is not bundled. An archive carries the checkpoint’s filesystem
state, not the base image it was built from — the destination machine pulls the
image fresh on the restored container’s first boot, exactly as it would for any
other start(). This is a deliberate limitation of msb 0.6.6’s export format
(bundling the image fails an integrity check on import), not a rightsize choice;
see the roadmap for the self-contained-archive idea this leaves
open.
Same backend required, same as everywhere else. export_to/import_from
apply the same backend-match gate as from_checkpoint itself:
let err = Checkpoint::import_from("/tmp/seeded-db.archive").await.unwrap_err();
// RightsizeError::CheckpointBackendMismatch if the archive was exported under a
// different backend than the one active on this machine.
A malformed or foreign archive (not a tar, missing its metadata, an unsupported format version, an invalid name) fails the same way — a typed error before any backend work, never a partial import.
Named archives replace, same semantics as checkpoint_named. Importing an
archive exported from a NAMED checkpoint re-registers that name on the
destination machine, replacing any existing entry for it — the same
replace semantics as taking a fresh named
checkpoint locally. An archive from an unnamed checkpoint() imports fine too; it
just writes no registry entry, and import_from returns an ephemeral
Checkpoint.
msb’s imported ref is a digest-derived directory name, not the original name.
microsandbox’s snapshot load is content-addressed: the ref an imported
checkpoint restores under is the digest-dir name it unpacked into (e.g.
sha256-b9c0448ee9d54e33, visible as such in Checkpoint::find/Checkpoint::list
results afterward), never the rz-ckpt-<12hex> name the archive was exported
under, and never the full sha256:<64hex> digest either — msb doesn’t resolve
that as a snapshot ref. This is harmless — refs are opaque everywhere in this
library, and Container::from_checkpoint restores from it exactly like any
other — but don’t be surprised to see a digest-shaped ref where a rz-ckpt- one
might be expected. Docker’s imported ref is the original tag, unchanged.
Archive size follows each backend’s own artifact: microsandbox’s is the
zstd-compressed disk snapshot, typically small even for a seeded database
(sparse, compressed); docker’s is a full docker save of the committed image
layer, closer to the image’s own size. Neither archive applies its own
compression on top — msb’s payload is already compressed, and docker’s compresses
poorly enough not to matter.
Reuse is not a supported combination
.reuse(true) combined with Container::from_checkpoint(...) fails fast with a
typed RightsizeError::ReuseCheckpointConflict, before any backend work — reuse’s
identity hash has no concept of a checkpoint ref, and checkpoint_ref deliberately
never enters it.
Cleanup
Checkpoint artifacts are never auto-reaped by orphan reaping, the
cleanup thread, or any other own-run cleanup path — the same
explicit decision as reuse. Every checkpoint you take stays on disk
until you remove it. For a NAMED checkpoint, Checkpoint::remove(name) is the
affordance built for this — it tears down both the backend artifact and the
registry entry in one call, and is idempotent. The manual, by-hand CLI cleanup
below is still valid (an unnamed checkpoint has no registry entry for
Checkpoint::remove to look up in the first place, so it’s the only option
there), just no longer the only way to do it:
# docker
docker rmi rightsize/checkpoint:<12hex>
# microsandbox
msb snapshot rm rz-ckpt-<12hex>