Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

BackendMechanismEffect on the source container
dockerImage commit (POST /commit)Undisturbed — the running container keeps running exactly as it was.
microsandboxDisk snapshot: stops the sandbox, snapshots its disk, and boots it back from that snapshot under the same name and portsThe 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-dir so 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 bare rz-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-dir it was created under; removing it via Checkpoint::remove or 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>