Introduction
rightsize-rust runs your integration-test containers as hardware-isolated microsandbox microVMs instead of Docker containers. It gives you a Testcontainers-shaped workflow — boot a real database/broker/service image, get back a handle with a mapped port, run your test, tear it down — but the isolation boundary underneath is a microVM, not a shared-kernel container, and there’s no daemon to install first.
What it is
- A Tokio-async-native, RAII-guard Rust API:
Container::new("image").start().awaitreturns a guard; the guard’sDrop(or an explicitstop().await) tears the container down. - A self-provisioning runtime: the pinned
msbbinary downloads itself (SHA-256-verified) into~/.cache/rightsize/on first use. One Cargo dependency, zero install steps, no root. - A hand-rolled Docker fallback for platforms microVMs can’t reach — Intel Macs,
Windows, Linux without
/dev/kvm— talking to the daemon over a plaintokio::net::UnixStream, neverbollard/hyper. - Eighteen preconfigured containers (
rightsize-modules) for common test dependencies — see the Modules index for the full list.
use rightsize_modules::RedisContainer;
#[tokio::test]
async fn orders_flow_end_to_end() -> Result<(), Box<dyn std::error::Error>> {
let redis = RedisContainer::new().start().await?; // boots a real microVM
let client = redis::Client::open(redis.uri())?; // 127.0.0.1:<mapped port>
// ... your test ...
redis.stop().await?; // explicit, ordered teardown
Ok(())
}
Drop the guard instead of calling stop() and it still tears down — a dedicated
cleanup thread reclaims it even if the test panics. See
Containers & Guards for the full story.
Why a microVM instead of a container
| Docker + Testcontainers | rightsize-rust | |
|---|---|---|
| Isolation | shared kernel (containers) | hardware-level (microVM per container) |
| Runtime install | Docker Desktop / daemon required | none — self-provisions on first use |
| Licensing | Docker Desktop licensing in orgs | Apache-2.0 all the way down |
| Async model | blocking client calls | async fn throughout — no thread-per-container blocking |
| Docker client (fallback path) | bollard/shiplift over hyper | hand-rolled, unix-socket-only — can’t be misrouted onto TCP |
Testcontainers-style libraries run each dependency as a Docker container: same
kernel, same namespace machinery, one daemon coordinating everything. That’s
fast and well-understood, but
it means every test process trusts the Docker daemon’s isolation guarantees and needs
that daemon (or a compatible one) reachable at all. rightsize-rust’s default backend
instead boots each container as its own microVM — a real, separate kernel per
container, supervised as an attached child process, with no persistent daemon at all.
The trade is a small amount of boot latency and a narrower feature surface (see
Backend differences) in exchange for isolation
that doesn’t depend on kernel namespace correctness and a runtime that provisions
itself with no sudo.
The honest platform matrix
rightsize-rust picks a backend automatically; override with
RIGHTSIZE_BACKEND=microsandbox|docker.
| Platform | Backend used |
|---|---|
| macOS (Apple Silicon) | microsandbox (microVMs) |
Linux x86_64 / arm64 with /dev/kvm | microsandbox (microVMs) |
| Intel Mac | Docker (auto-fallback) |
| Windows | Docker (auto-fallback) |
| Linux without KVM | Docker (auto-fallback) |
Both backends satisfy one behavioral contract (SandboxBackend), verified by a
shared contract test suite — tests you write run unchanged on either. A handful of
edges are backend-specific rather than behavioral divergences; see
Backend differences before you hit one in the
wild. That same contract is verified identically in the Kotlin and TypeScript
ports of this library — see Cross-Language Parity.
Honest limits, up front
- Not yet published to crates.io. Depend on it via a git reference until a tagged release exists — see Getting Started.
- Eighteen modules, not an exhaustive catalog. Anything else is the plain
ContainerAPI — a thin wrapper is a small, welcome contribution; see the Modules index for the full list. - No JUnit-style
@Containertest extension. The RAII guard is the whole API surface; a shared-container test fixture is a documented recipe usingtokio::sync::OnceCell, not a proc-macro annotation. - Network-alias emulation on microsandbox is deliberately narrow — one connection at a time per tunnel, client-speaks-first only. See Networking.
Want to run something first?
crates/rightsize-modules/examples/
in the repo has three runnable examples (RAII guard + RESP, RAII guard + a real
client round-trip, multi-container networking) — see the project README’s
Examples section for the
exact cargo run commands.
This book expands on the README — if you’ve read the README already, skip ahead to whichever chapter covers what you need next; nothing here duplicates it verbatim.
Getting Started
Install
# Cargo.toml
[dev-dependencies]
rightsize = "0.3.0"
rightsize-modules = "0.3.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
rightsize-modules pulls in both backend crates by default (backend-msb and
backend-docker Cargo features, both on) so you don’t have to wire anything up by
hand for the common case.
Your first test
use rightsize_modules::RedisContainer;
#[tokio::test]
async fn cache_roundtrip() -> rightsize::Result<()> {
let redis = RedisContainer::new().start().await?;
let client = redis::Client::open(redis.uri()).unwrap();
let mut con = client.get_connection().unwrap();
redis::cmd("SET").arg("k").arg("v").execute(&mut con);
let v: String = redis::cmd("GET").arg("k").query(&mut con).unwrap();
assert_eq!(v, "v");
redis.stop().await?;
Ok(())
}
Run it with plain cargo test — no separate setup step, no daemon to start by hand.
What happens on first run
The first time any process calls Container::start() (directly or via a module like
RedisContainer) with the microsandbox backend active, rightsize-rust provisions its
own runtime before doing anything else:
- Platform check. It detects your OS/arch (macOS aarch64, or Linux x86_64/arm64
with a readable
/dev/kvm). No match — nomsbbuild for this platform — and provisioning fails with an actionable error namingRIGHTSIZE_BACKEND=dockerorMSB_PATHas the way out. - Download. The pinned
msbrelease (currently0.6.6) is fetched from GitHub releases: themsbbinary itself, the matchinglibkrunfwasset, and achecksums.sha256manifest — three separate downloads, over a small blockingureqclient (this bootstrap runs before any async work, so a blocking HTTP client is the honest tool for the job). - Verify. Every downloaded asset is SHA-256-checked against the manifest before anything is installed.
- Atomic install. Both files land in a temp location first; the
krunasset is moved intolib/before themsbbinary is moved intobin/. Themsbbinary’s presence is therefore the commit marker for a complete install — if the process is killed mid-install, the next run detects the half-finished state (missingbin/msb) and repairs it, rather than trusting a partial install. - Cache. Everything lands under
~/.cache/rightsize/msb/0.6.6/(orRIGHTSIZE_CACHE_DIRif you’ve set it). Every later test run in every project on this machine reuses it — no re-download. - Cross-process lock. If two
cargo testprocesses race to provision the same cache dir at once, the second one blocks on a lock file and then finds the first one already did the work.
None of this needs sudo, a running daemon, or a pre-installed anything. If you’re
on a platform microsandbox doesn’t support, rightsize-rust falls back to the Docker
backend automatically (see Backends) — the only prerequisite there
is a reachable Docker-compatible daemon.
Useful env vars while you’re getting set up (full reference in Backends):
| Env var | Effect |
|---|---|
RIGHTSIZE_BACKEND | Force microsandbox or docker, skipping auto-detection. |
MSB_PATH | Point at an msb binary you already have; skips the download entirely. |
RIGHTSIZE_CACHE_DIR | Relocate the provisioner’s cache root. |
RIGHTSIZE_MSB_SKIP_DOWNLOAD | true turns a cache miss into a hard error instead of a network fetch — for air-gapped CI that pre-seeds the cache. |
Backend wiring — automatic with modules, explicit without
Consumers of rightsize-modules write no backend wiring at all: the
feature-enabled backends (backend-msb, backend-docker — both default
features) register themselves the first time any module starts.
Backend resolution happens at the process’s first Container::start() and
is cached for the life of the process. Two cases still call for one explicit
line:
-
Your first container is a plain
Container, not a module. Call the same entry point every module start uses, before that first start:rightsize_modules::register_default_backends(); -
You depend on
rightsizeand the backend crates directly, withoutrightsize-modules. Rust has noServiceLoader-style automatic plugin discovery, so register once per process before the first start:rightsize::backends::register_provider(Box::new(rightsize_msb::MsbBackendProvider)); rightsize::backends::register_provider(Box::new(rightsize_docker::DockerBackendProvider));Registration is idempotent by provider name, so a shared test-fixture module calling this from several places is fine.
rightsize::backends::resolvepicks among whatever’s registered, honoringRIGHTSIZE_BACKENDwhen it’s set.
The shared-container recipe
There’s no JUnit-style @Container static-scope extension in rightsize-rust — the RAII
guard is the API (see Binding decisions). For
a container shared across many tests in one binary, boot it once behind a
tokio::sync::OnceCell and never call stop() on it; process exit (or the Drop
fallback) reclaims it:
use std::sync::Arc;
use tokio::sync::OnceCell;
use rightsize_modules::{RedisContainer, RedisGuard};
static REDIS: OnceCell<Arc<RedisGuard>> = OnceCell::const_new();
async fn shared_redis() -> Arc<RedisGuard> {
REDIS
.get_or_init(|| async { Arc::new(RedisContainer::new().start().await.unwrap()) })
.await
.clone()
}
#[tokio::test]
async fn first_test_using_redis() {
let redis = shared_redis().await;
// ... use redis.uri() ...
}
#[tokio::test]
async fn second_test_using_redis() {
let redis = shared_redis().await; // same instance, booted once
// ...
}
More on why this shape, and the full RAII lifecycle it relies on, in Containers & Guards.
Core Concepts
The rightsize crate is the backend-agnostic core: the Container builder and its
RAII ContainerGuard, Network for alias-based connectivity, the Wait readiness
strategies, and MountableFile for getting host data into a guest. Everything in
this section applies identically regardless of which backend (rightsize-msb or
rightsize-docker) ends up running the container — that’s the whole point of the
SandboxBackend trait boundary (see How It Works).
- Containers & Guards — the builder, the RAII lifecycle, and the two-tier cleanup story.
- Wait Strategies — the four built-in readiness checks and when to reach for a custom one.
- Networking — cross-container alias resolution and its limits on the microVM backend.
- Files & Resources — mounting host files into a container, and memory limits.
Containers & Guards
The builder
Container::new(image) starts a builder with no exposed ports, no env, the default
Wait::for_listening_port readiness check, and every other
option at its default. Every with_* method takes self by value and returns Self,
so calls chain:
use rightsize::{Container, Wait};
let arango = Container::new("arangodb:3.11")
.with_env("ARANGO_NO_AUTH", "1")
.with_exposed_ports(&[8529])
.waiting_for(Wait::for_http("/_api/version").for_port(8529))
.start()
.await?;
let port = arango.get_mapped_port(8529)?; // published on 127.0.0.1
// ... your test ...
arango.stop().await?;
The builder surface, as it exists today:
| Method | Effect |
|---|---|
Container::new(image) | Starts building from an image reference. |
.with_env(k, v) | Sets an env var. Calling it again with the same key overwrites the value but keeps the key’s first position — last-value-wins, first-position-wins, exactly LinkedHashMap semantics. |
.remove_env(key) | Removes every entry for key set so far. No-op if never set. Needed when a later env var must retract an earlier one’s effect on the entrypoint, not just be shadowed by value — see ArangoContainer::with_root_password for the worked example. |
.with_exposed_ports(&[..]) | Declares guest ports to publish; each gets a host port assigned before boot. |
.with_command(&[..]) | Overrides the image’s default entrypoint/command. |
.with_network(&net) | Joins an Arc<Network> — see Networking. |
.with_network_aliases(&[..]) | Names this container is reachable as on its network. |
.with_copy_file_to_container(file, guest_path) | Mounts a MountableFile read-only into the guest — see Files & Resources. |
.waiting_for(strategy) | Overrides the readiness check; defaults to Wait::for_listening_port(). |
.with_memory_limit(megabytes) | Caps guest memory. Unset means “each backend’s own default” — see Files & Resources. |
.start() | Consumes the builder, returns Result<ContainerGuard>. |
Container::start() consumes the builder — there’s no mutate-in-place start()
followed by later getters on the same object, unlike Testcontainers-for-Java. The
guard returned is the thing you hold for the rest of the test.
The RAII lifecycle
start() does the following, in order, and on any failure partway through, tears
everything back down before the error ever reaches your code:
- If the container joins a
Network, ensure the network exists on the backend. - Allocate host ports for every exposed guest port (see the port-bind-conflict retry note below).
- Create the container on the active backend, then start it.
- If networked: install network links to every already-running sibling, then register this container as a network member.
- Run the wait strategy until ready (or time out).
- If the module set a post-start hook (e.g. MongoDB’s replica-set initiation — see MongoDbContainer), run it.
If step 4, 5, or 6 fails, start() awaits this container’s own stop() to completion
— stop, remove, release ports — before returning the error. A container that
fails partway through boot never leaks a running process or a held port; you don’t
need a try/cleanup dance around a failed start() call.
Port-bind-conflict retries. Allocating a host port and actually binding it are two
separate steps with an unavoidable race (something else grabs the port between
allocation and bind). start() retries the whole create+start step up to 5 times with
freshly allocated ports whenever the failure is classified as a port-bind conflict —
recognized either as the typed RightsizeError::PortBindConflict (checked first, by
walking the error’s source chain) or by matching known message phrasings
("address already in use", "already allocated") as a fallback for backends that
don’t throw the typed variant. Any other failure is not retried — it fails fast.
stop().await vs Drop — the cleanup story
Two-tier cleanup, because Rust has no async Drop:
Happy path: guard.stop().await is an explicit, awaited, ordered teardown —
backend stop, then remove, then release the guard’s mapped host ports back to the
process-wide allocator. It’s idempotent: calling the internal teardown twice (which
can only happen via Drop running after an explicit stop(), since stop() consumes
the guard by value) never double-calls the backend or double-releases a port.
Fallback: if a guard is simply dropped — a test panics mid-body, a scope exits
early without calling stop() — Drop still tears the container down. Drop cannot
be async, and it must work correctly even with no Tokio runtime anywhere in the
process (a plain Drop can run during runtime shutdown, or on a thread that never
had a runtime at all). So the fallback does the least synchronous work that’s still
correct:
- Release the guard’s mapped host ports synchronously —
FreePortsis a plainstd::sync::Mutex, no runtime needed — so a dropped-not-stopped guard never leaks its ports even if the next step is slow, or never runs at all. - Hand a small
{ backend, container_id }teardown descriptor to a dedicated, process-global cleanup OS thread (started once viastd::thread, not Tokio) and return immediately.
That cleanup thread drains a channel of these descriptors and tears each one down
using blocking std I/O only — std::process::Command for msb stop/rm, a
blocking std::os::unix::net::UnixStream for docker’s DELETE calls. Never Tokio,
never block_on, because this thread has no async runtime and must not assume one
exists anywhere else in the process either. A panic tearing down one job is caught
(std::panic::catch_unwind) so it can’t take the whole cleanup thread down for the
rest of the process.
This is the direct analogue of a JVM shutdown hook, made crash-tolerant. But Drop
itself never runs on a bare SIGKILL — nothing running in userland survives that —
which is why both backends also run a run-id-scoped orphan reaper at startup:
it lists sandboxes/containers, filters out anything belonging to this run, and
force-removes the rest — leftovers from a prior process that died hard enough to skip
Drop entirely. The graceful path (Drop → cleanup thread) is the backstop for a
panicking test; the reaper is the backstop for the backstop.
stop().await -> ordered async teardown (backend stop, remove, ports) [happy path]
drop(guard) -> sync port release + cleanup-thread teardown [fallback]
process SIGKILL -> Drop never runs; next process's reaper cleans it up [backstop]
is_running(), get_mapped_port(), exec(), logs(), follow_output()
Once you have a ContainerGuard:
guard.host()— always"127.0.0.1"; published ports are loopback-only.guard.get_mapped_port(guest_port)— the host port a declared guest port is published on. Distinguishes two failure causes in its error message: the guard isn’t running at all (never started, or already stopped), versus the guard is running but that guest port was never declared viawith_exposed_ports.guard.is_running()—truefrom a successfulstart()untilstop().guard.exec(&["cmd", "arg"]).await— runs a command inside the running container, returnsExecResult { exit_code, stdout, stderr }.guard.logs().await— the full captured logs so far.guard.follow_output(|line| ...).await— streams log lines to a closure as they’re produced; dropping (or explicitly closing) the returnedFollowHandlehalts delivery — no further lines reach the closure even if the container keeps running. See Backend differences for a microsandbox-specific timing nuance in how the last line is delivered.guard.copy_file_to_container(...)/copy_content_to_container(...)/copy_file_from_container(...)— runtime file copy against an already-running container, on either backend. See Copying Files.guard.checkpoint().await— captures the running container’s filesystem state forContainer::from_checkpoint(...)to restore later;guard.checkpoint_named(name).awaitdoes the same but persists a registry entry a later process can rediscover. See Checkpoint / Restore.
The OnceCell shared-container recipe
Covered in full in Getting Started
— the short version: build the container once behind a
tokio::sync::OnceCell<Arc<SomeGuard>>, clone the Arc into every test that needs it,
and never call stop() on the shared instance. This is the RAII-guard analogue of a
JUnit @Container static-scope field — a documented pattern, not extra API surface.
Wait Strategies
A WaitStrategy is a pluggable readiness check run after a container starts, before
Container::start() hands you the guard. Every built-in strategy shares one polling
loop (poll_until_ready): it probes readiness first, then checks the deadline — so
even a startup timeout shorter than one poll interval still gets at least one real
readiness check, rather than failing having never actually looked.
On timeout, every built-in strategy’s error message includes the container’s
describe() and its last 50 log lines, so a failure is diagnosable from the test
output alone without re-running anything.
The four built-in strategies
Wait::for_listening_port()
The default when .waiting_for() is never called. Ready when every exposed guest
port accepts a TCP connection and passes a read-probe (see below). Vacuously ready
immediately if the container exposes no ports.
Wait::for_http(path)
Wait::for_http("/_api/version")
.for_port(8529) // defaults to the first exposed port if omitted
.for_status_code(200) // defaults to 200
Polls a GET to path until the response status matches. Built on a tiny hand-rolled
HTTP/1.1 client (core takes no HTTP-client dependency) — connect + read timeouts of
1s each, just enough to read a status line.
Wait::for_log_message(regex, times)
Wait::for_log_message(".*database system is ready to accept connections.*", 2)
Ready once a log line matching regex has appeared at least times times.
times = 0 means “ready immediately, no line ever required” — used as a bare
boot-wait when there’s genuinely nothing else to check (see the msb network-link
integration tests, which use Wait::for_log_message(".*", 0) for a container whose
whole job is to sit idle as a network peer).
regex is matched with regex-lite — the same syntax
family as the regex crate, minus unicode tables, chosen because log-line matching
needs none of that. The match is a substring search anywhere in the line, not an
implicit whole-line anchor, so wrap a pattern in .* on either side unless you
deliberately want to anchor part of it — see
MariaDbContainer for a worked example with both an anchor
and a literal escaped dot.
poll_until_ready — the shared polling primitive
rightsize::wait::poll_until_ready is pub, not crate-private, precisely so a custom
WaitStrategy doesn’t have to hand-roll its own deadline/poll-interval loop. Pass it
a WaitTarget, a timeout, a short description, and an async closure returning bool;
it returns Ok(()) once the closure reports ready, or a timeout error with the same
log-tail-plus-describe() shape every built-in strategy produces.
The read-probe story
The single highest-leverage correctness detail in this crate’s wait strategies: a bare TCP connect is not proof of readiness, on either backend.
Docker’s userland proxy (and microsandbox’s loopback forwarder) exist to forward
traffic — they accept a connection before the workload inside the container is
necessarily listening yet, because accepting a socket doesn’t require anyone to be
home on the other end. A wait strategy that only checks “did connect() succeed”
can hand you a guard whose first real client request hits a socket that gets closed
out from under it.
Wait::for_listening_port (and internally, for_http) accounts for this with a
short read-probe immediately after connecting: it sets a 200ms read timeout and
attempts a zero-byte-or-more read.
- EOF (the read returns 0 bytes), or a reset — the peer closed the stream right after accepting. The hallmark of a proxy with nothing listening behind it yet. Not ready — keep polling.
- Data arrives, or the read simply times out with the connection still open — either a real server that speaks first, or one that’s waiting for the client to speak first. Both are ready. A closed connection is the only failure signal; silence with the connection still open is not.
This is why a service that speaks first on connect (Redis) is fine with the plain listening-port wait, while one that says nothing until spoken to and has a genuinely protocol-level readiness question (Memcached) ships its own probe instead — see the next section.
Custom wait strategies via poll_until_ready
Implement WaitStrategy directly when the readiness signal genuinely needs to be
protocol-level — the workload accepts a connection immediately but isn’t ready to
serve a real request. The shipped MemcachedContainer is
the worked example: Memcached logs nothing useful on startup and the port-forwarding
layer on either backend can bind the host port before the server inside is actually
accepting, so a bare TCP-connect wait can pass while the first real client connection
still gets a dead stream. Its custom strategy sends version\r\n and requires a
reply starting with VERSION:
use rightsize::wait::{WaitStrategy, WaitTarget};
use std::time::Duration;
struct MemcachedResponds;
#[async_trait::async_trait]
impl WaitStrategy for MemcachedResponds {
async fn wait_until_ready(&self, target: &dyn WaitTarget) -> rightsize::Result<()> {
let port = target.mapped_port(11211);
let host = target.host().to_string();
rightsize::wait::poll_until_ready(
target,
Duration::from_secs(60),
"a VERSION reply",
|| {
let host = host.clone();
async move { probe_once(&host, port).await }
},
)
.await
}
fn with_startup_timeout(self: Box<Self>, _timeout: Duration) -> Box<dyn WaitStrategy> {
self // fixed poll budget; no override on this strategy
}
}
WaitTarget is the minimal backend-agnostic view a strategy needs — host(),
mapped_port(guest_port), exposed_guest_ports(), current_logs(), and
describe() — so a custom strategy never needs to know which backend booted the
container it’s probing.
Readiness caveats, summarized
- The read-probe caveat applies on both backends, not just microsandbox — it’s a property of port-forwarding, not of microVMs specifically.
Wait::for_listening_port’s read-probe is a good default but is not a substitute for a protocol-level check when the workload’s own readiness is genuinely behind an extra step (auth handshake, schema load, replica election). Preferfor_http/for_log_message, or a custom strategy, in that case.for_log_message’stimesparameter exists because some entrypoints print their “ready” line more than once during a normal boot (seePostgresContainer, which restarts once internally and prints the same line both times) — waiting for the first occurrence can race a restart that hasn’t finished yet.
Networking
Network gives containers alias-based connectivity on both backends — the same API
whether the containers underneath are Docker containers on a bridge network or
fully-isolated microVMs with no shared network at all.
use rightsize::{Container, Network, Wait};
use std::sync::Arc;
let net = Arc::new(Network::new_network());
let config = Container::new("hyness/spring-cloud-config-server:latest")
.with_network(&net)
.with_network_aliases(&["configuration-stub"])
.with_exposed_ports(&[8888])
.start()
.await?;
let app = Container::new("my-service:latest")
.with_network(&net)
.with_env("CONFIG_URI", &format!("http://{}", net.resolve("configuration-stub", 8888)?))
.start()
.await?;
Network::resolve(alias, guest_port) returns alias:guest_port — identical string
shape on both backends — and errors, naming the alias, if no registered member
carries it. A container only contributes links to later joiners once it’s
registered, which happens after its own network-link installation step, so a
container can never end up linked to itself.
What each backend actually does under the hood
On Docker: this is a native Docker network alias — the daemon’s own bridge
networking and embedded DNS resolve alias:port for you. No emulation, no tunnel,
full native container-to-container connectivity.
On microsandbox: microVMs are fully isolated from each other — there’s no shared bridge network to attach to. rightsize-rust transparently installs:
- An
/etc/hostsentry inside the consuming container’s guest, mapping the alias to127.0.0.1. - A TCP relay tunneled over the sandbox’s
exec --streamchannel — the only guest data path available on this msb build (no sandbox→host TCP under any net-rule tried; SSH forwarding was found broken too). The tunnel pumps raw bytes, unbuffered, flush-per-read, in both directions.
This is real emulation, not a shortcut, and it has real limits — see below.
Limits on the microVM backend
- Start dependencies before their consumers. Network links are computed for a new member from whichever siblings are already running at the moment it joins — see Containers & Guards, step 4. A container started before its dependency is up won’t retroactively gain a link to it.
- One connection at a time per tunnel. The in-guest
nc -llistener backing a tunnel serves one connection, then gets respawned for the next. Fine for config-fetch-style traffic; not fine for a long-lived cross-container consumer (e.g. a Kafka consumer reading continuously from a broker on a sibling microVM). - Client speaks first. The tunnel protocol assumes the connecting side sends the first bytes — matches HTTP requests and most RPC-style protocols; a server that waits silently for the client to speak needs the client end to actually be the one initiating data, which HTTP/REST calls naturally are.
- The consumer image needs
nc/busybox. The tunnel is implemented as a shelled-outnclistener inside the guest. An image without it (a scratch-based image, or one that stripped busybox) failsstart()fast, with an error naming the missing binary and suggestingRIGHTSIZE_BACKEND=dockeras the workaround — verified by this crate’s own integration suite usingmongo:8.0as the no-nccounter-example. - A target that never propagates TCP close can’t be detected by naive EOF. The msb port-publish proxy doesn’t propagate the target socket’s close to the tunnel, so end-of-exchange is inferred from an idle window after the first byte arrives — not from the whole connection, which would wrongly truncate a slow-to-respond target. This is an internal detail (see How It Works), but it explains why a connection that never sends any bytes back can hang until the idle timeout rather than closing immediately.
Every one of these is a real capability gap versus Docker’s native bridge networking
on this backend, not a timing quirk that will resolve itself with retries — pick
RIGHTSIZE_BACKEND=docker for a network topology this doesn’t fit, or restructure the
test to fit inside these bounds (they cover this project’s own contract suite, which
is mostly one-shot config-fetch-shaped traffic).
Alias resolution is registration-order-independent for readers
Network::resolve just checks “is any registered member carrying this alias” — it
doesn’t care what order resolve calls happen in relative to start() calls, only
that the target container has already finished starting (and thus registering)
by the time you call resolve on its alias. Calling resolve for an alias that
hasn’t started yet returns an error naming the alias, not a hang.
Files & Resources
This page covers mounting files in at start time. To copy files into (or out of) an already-running container, see Copying Files instead.
Mounting host files into a container
MountableFile resolves a host path (or a bundled test resource) before you mount it
in with Container::with_copy_file_to_container:
use rightsize::{Container, MountableFile};
let fixture = MountableFile::for_classpath_resource("tests/fixtures/init.sql")?;
let db = Container::new("postgres:18-alpine")
.with_copy_file_to_container(fixture, "/docker-entrypoint-initdb.d/init.sql")
.start()
.await?;
Two constructors:
MountableFile::for_host_path(path)— a file already on the host filesystem. A relative path is absolutized against the current working directory; an absolute path is used as-is.MountableFile::for_classpath_resource(resource)— Rust has no classpath, so this is the documented convention that plays the same role:resourceresolves relative toCARGO_MANIFEST_DIR, matching how a crate actually bundles test/data files (e.g. undertests/fixtures/). Errors, naming the resource and the manifest directory searched, if the path doesn’t exist — a missing fixture fails loud and specific, not with a generic “file not found” from deep inside the backend.
Mounts default to read-only (FileMount::new sets read_only: true); call
.read_write() on the FileMount if the guest genuinely needs to write to it. See
Backend differences for a real gap here: on
microsandbox 0.6.2, read_only is currently advisory only — the guest gets a
writable mount regardless of the flag. Don’t rely on guest-side write protection
under RIGHTSIZE_BACKEND=microsandbox; Docker enforces it genuinely.
Memory limits — when and why
Container::with_memory_limit(megabytes) caps the container’s guest memory. Leaving
it unset lets each backend apply its own default — and on microsandbox, that default
is small: ~450 MB per microVM. That’s plenty for a lean single-process workload
(Redis, Memcached, a KRaft Kafka broker with a trimmed heap) but not for a JVM image
whose fixed memory regions are computed above that.
Two shipped modules hit this in practice, with measured evidence rather than a guessed round number:
The Paketo story (SpringCloudConfigContainer)
Paketo’s memory calculator sizes a Spring Boot JVM image’s fixed regions (metaspace,
thread stacks, direct memory) at roughly 688 MB before the heap itself — comfortably
above microsandbox’s ~450 MB default. SpringCloudConfigContainer therefore ships
with .with_memory_limit(1024) baked into its constructor; you don’t need to set this
yourself when using the module, only when hand-rolling the equivalent via the plain
Container API against a similarly memory-hungry JVM image.
The Pinot story (PinotContainer)
Apache Pinot’s QuickStart -type EMPTY boots four JVMs (controller, broker, server,
minion) plus an embedded ZooKeeper in one container, and the image itself bakes in
JAVA_OPTS=-Xms4G -Xmx4G — the QuickStart driver JVM alone requests a 4 GiB heap
before any of the four sub-JVMs it spawns takes anything. The module originally
scaled the SpringCloudConfig number up naively to 2048 MB (“four JVMs instead of
one”) — that undershot badly. Measured directly against a real
apachepinot/pinot:1.5.1 QuickStart -type EMPTY boot:
--memory=2048m -> OOMKilled=true (timed out at 180s waiting for /health, reaped by the kernel)
--memory=2560m -> OOMKilled=true
--memory=3072m -> OOMKilled=false; /health 200 within ~15s; BUT settles at ~99% of the 3 GiB
limit, and under that pressure the controller's Helix-backed schema/table
RPCs intermittently time out (a schema POST returns
{"code":500,"error":"java.util.concurrent.TimeoutException"}) even though
/health reports 200. Reproduced repeatedly at 3072m.
--memory=4096m -> stable: settles at ~73-75% of the limit, comfortable headroom; schema POST
succeeded on every attempt across a 60s repeated-POST probe.
PinotContainer ships with .with_memory_limit(4096) — the lowest round number with
real headroom above the image’s own 4 GiB heap request, not merely enough to dodge the
OOM killer outright. Verified stable on both Docker and microsandbox.
The takeaway for your own images
If you’re wrapping a JVM (or otherwise memory-hungry) image in the plain Container
API and it hangs or gets silently killed partway through boot on the microsandbox
backend specifically, suspect the ~450 MB default before anything else — check what
the image’s own baked JAVA_OPTS/heap flags request, and set
.with_memory_limit(...) above that with real headroom, the same way these two
modules did. See Troubleshooting for the matching
symptom→fix entry.
Backends
rightsize-rust picks a backend automatically; override with
RIGHTSIZE_BACKEND=microsandbox|docker. Both satisfy the same SandboxBackend
contract (verified by a shared contract test suite in
crates/rightsize-modules/tests/contract.rs) — code you write against Container
runs unchanged on either. That same contract is also what the Kotlin and
TypeScript ports are held to — see Cross-Language Parity.
Selection
| Platform | Backend used |
|---|---|
| macOS (Apple Silicon) | microsandbox (microVMs) |
Linux x86_64 / arm64 with /dev/kvm | microsandbox (microVMs) |
| Windows x86_64 / arm64 with WHP enabled | microsandbox (microVMs)¹ |
| Intel Mac | Docker (auto-fallback) |
| Windows without WHP | Docker (auto-fallback) |
| Linux without KVM | Docker (auto-fallback) |
¹ Windows msb runs Linux guests on the Windows Hypervisor Platform (WHP);
upstream still labels this beta. Platform::current() reports a Windows build
exists; virtualization_available() on Windows is attempt-and-report (no cheap,
reliable, no-elevation WHP-state probe exists in portable std) — an unusable
WHP surfaces at msb’s own first-boot failure rather than at resolution time.
CI-verified on windows-2022/windows-2025 hosted runners: WHP was already
Enabled with RestartNeeded: False, so no enablement step or reboot was needed.
If RIGHTSIZE_BACKEND=microsandbox is forced on a Windows host without usable
WHP, resolution errors naming the precondition (msb doctor --fix in an
elevated terminal, which may require a reboot) rather than silently falling
back — the same convention as any other unsupported forced backend.
Resolution logic (rightsize::backends::resolve), precisely:
- If
RIGHTSIZE_BACKENDnames a provider (case-insensitive), that provider is used even if a higher-priority one is also registered — and it’s an error if the named provider isn’t supported on this host (the error names the specific reason). - Otherwise, the highest-priority supported provider wins.
- An empty provider list, or a list where nothing is supported, is an error listing
every provider’s
unsupported_reason. - An unknown
RIGHTSIZE_BACKENDname is an error listing every known provider name.
Resolution happens once per process and is cached — the env var is read exactly once,
at the first Container::start()/module start() call.
Configuration
| Env var | Effect |
|---|---|
RIGHTSIZE_BACKEND | Force microsandbox or docker. |
MSB_PATH | Use a pre-installed msb binary; skip downloads. |
RIGHTSIZE_CACHE_DIR | Relocate the runtime cache (default ~/.cache/rightsize, %LOCALAPPDATA%\rightsize on Windows). |
RIGHTSIZE_MSB_SKIP_DOWNLOAD | true = fail instead of downloading (air-gapped CI). |
RIGHTSIZE_REAPER | on (default) / sweep / off — see Orphan Reaping. |
RIGHTSIZE_REUSE | true or 1 (exact string) enables the reuse half of .reuse(true)’s double opt-in — see Container Reuse. |
microsandbox deep-dive
Provisioning
Covered step-by-step in Getting Started:
download, SHA-256-verify, atomically install (krun before msb, so msb’s presence
is the completion marker), cache under ~/.cache/rightsize/msb/<version>/, guarded by
a cross-process file lock. MSB_PATH bypasses the whole pin — bring your own binary,
any version, at your own risk of behavioral drift from what this crate was tested
against.
Attached-mode supervision
Each container is a held child process supervising its own microVM — the image’s
ENTRYPOINT runs exactly as it would under Docker. This matters because detached
msb run -d never actually starts the image’s ENTRYPOINT on this msb build —
attached mode is the only mode that does. Readiness of the sandbox itself (not the
workload inside it) is “name shows Running in msb ls --format json”; workload logs
come from msb logs, which has its own quirk (see
Backend differences below).
msb exec blocks until stdin reaches EOF, so every child process rightsize-rust spawns
under the hood gets a closed/null stdin — an exec call that’s fed an open, never-EOF’d
stdin (piping in a live process’s output, say) hangs forever. This is the single most
common way to accidentally wedge an exec() call — see
Troubleshooting.
Cache
Both the provisioned toolchain (~/.cache/rightsize/msb/<version>/) and pulled
container images live under the msb cache root. Seed an image once if you expect
rate-limiting on first pull (see Troubleshooting for the
Redpanda case specifically):
docker save redpanda/redpanda:<tag> | msb load -t redpanda/redpanda:<tag>
Networking is emulated, not native
Covered in full in Networking:
/etc/hosts + an exec-tunneled TCP relay, because microVMs share no bridge network
with each other on this msb build.
Docker deep-dive
Unix-socket-only, and why
rightsize-docker is a from-scratch client over tokio::net::UnixStream — no
bollard, no hyper. This is a binding decision, not an oversight: a shared HTTP
stack that some other dependency in a consumer’s tree can independently upgrade is a
real, previously-observed failure mode (a httpclient5 bump misrouting a JVM Docker
client onto plaintext TCP port 2375 in the wild). Hand-rolling the client means this
crate’s dependency tree structurally cannot be the reason a Docker client gets
misrouted onto TCP by an unrelated bump elsewhere in your tree — the whole surface is
exactly the daemon endpoints this backend uses, talking to the daemon over a plain
unix-socket HTTP connection, always.
On Windows this means the Docker fallback needs a daemon reachable over a real unix
socket — Docker Desktop’s WSL2-backed daemon exposes one — not Windows’ native named
pipe (\\.\pipe\docker_engine), which this client does not speak.
Chunked transfer decoding and the daemon’s log-stream multiplexing frame format
([stream_type: u8, 0, 0, 0, len: u32_be, payload]) are still hand-parsed — that’s
wire transport, the same binding decision as the socket choice above. The JSON layer
itself is ordinary serde/serde_json: derived structs for the request bodies this
backend sends and the response fields it reads (Id, ExitCode, network id,
container-list ids).
Cleanup by label
Containers are labeled dev.rightsize.run_id=<RunId> (or, for a keep_alive/reuse
sandbox, dev.rightsize.reuse=<hash> instead — never both); close() lists and
force-removes this run’s own labeled leftovers. Orphan reaping itself — the sweep that
cleans up a crashed prior run’s leftovers, and the per-run watchdog — is not
docker-specific: it’s the same ledger-based mechanism described in
Orphan Reaping, and it covers docker uniformly with msb (this is the
first release where it does; docker previously had no such coverage at all). Networks
are created and removed explicitly. host.docker.internal:host-gateway is added as an
extra host entry on every container, so containers can reach services on the host
uniformly across platforms.
Port-bind-conflict detection
The Docker daemon has no distinct exception type for “this port is already bound” —
it returns a plain 500 with a message containing "address already in use" or "port is already allocated". This backend classifies by message content and re-throws the
typed RightsizeError::PortBindConflict, which is what lets
Container::start()’s retry loop (see
Containers & Guards)
recognize it without string-matching at the core layer.
Backend differences
The two backends are contract-equivalent on everything the shared suite exercises, but a few edges are real, not just timing quirks:
- Read-only file mounts aren’t enforced in-guest on microsandbox 0.6.2.
FileMount::read_onlyis honored by Docker (the bind mount is genuinely read-only inside the container); on microsandbox the guest currently gets a writable mount regardless of the flag. Don’t rely on guest-side write protection underRIGHTSIZE_BACKEND=microsandbox. follow_output’s tail-flush on microsandbox is a watchdog, not a stream close.msb logs -fdoesn’t exit when its sandbox stops (a documented gap in msb 0.6.2), so this backend polls in the background and replays only the not-yet-delivered tail once the sandbox is confirmed stopped. Consumers see the same ordered, no-duplicate output either backend produces — this is an implementation detail, not an observable behavior difference — but it means afollow_outputsubscriber on microsandbox can see its last line arrive slightly after the sandbox itself reports stopped, rather than exactly at stream EOF.- Network-alias tunnels on microsandbox serve one connection at a time. See Networking — a real capability gap versus Docker’s native bridge networking, not a timing quirk.
- Checkpointing restarts the workload on microsandbox, not on Docker. Both
backends support
checkpoint()/checkpoint_named()(capabilities().checkpointistrueon both), but by different mechanisms: Docker commits the running container to an image, undisturbed; microsandbox stops the sandbox, snapshots its disk, and boots it back up, rebooting the guest —capabilities().checkpoint_restarts_workloadtells you which you’re on. See Checkpoint / Restore. - Readiness-probe caveats apply to both backends, not just microsandbox — see Wait Strategies. A userland proxy/forwarder on either backend can accept a connection before the guest process is actually listening.
- Isolation strength differs by design, not by gap. microsandbox gives each
sandbox its own kernel; Docker’s containers share the host’s. See
Isolation Requirement for the guarantees table and
.require_isolation(true), the API for a test that needs to depend on this rather than just note it.
Wiring a backend
Automatic for rightsize-modules consumers: the feature-enabled backends
register themselves the first time any module starts. Covered in
Getting Started —
including the two cases that still take one explicit line
(rightsize_modules::register_default_backends() before a plain-Container
first start, or rightsize::backends::register_provider when depending on the
backend crates directly).
Cross-Language Parity
The claim
The same container spec produces the same observable behavior whether it’s built
in Kotlin, Rust, or TypeScript, on either backend. A Container built from a given
image, env, command, ports, mounts, and options starts the same way, exposes the
same mapped ports, streams logs the same way, reaps the same way after a crash, and
computes the same reuse identity — regardless of which language wrote the spec or
which of the two backends is running it. This isn’t an aspiration: every row in the
table below is verified by each port’s own shared contract suite, run against both
backends, on every change to a SandboxBackend implementation.
Verified behavior areas
| Area | What’s verified |
|---|---|
| Lifecycle | Start, stop, and idempotence — a second stop() on an already-stopped guard is a no-op, not an error. |
| Host port mapping | A container’s exposed port is published to 127.0.0.1 on both backends, readable back via the guard. |
| Env / command propagation | Environment variables and a custom command are visible inside the running workload. |
| File copy-in | A bundled resource and a host path both round-trip into the guest at the requested path at start time (with_copy_file_to_container); the default mount is read-only. |
| Runtime file copy | copyFileToContainer / copyContentToContainer / copyFileFromContainer round-trip files, in-memory content, and directories against a RUNNING container on both backends; destination parents are created automatically; both operations require a running container and fail with a typed error otherwise. |
| Exec | Real exit codes and stderr come back from an in-guest command. |
| Logs + follow | Captured stdout, for_log_message waiting on it, and follow_output’s ordered no-duplicate streaming — including each backend’s own follow-channel quirk (msb’s watchdog-driven tail replay vs. docker’s natural stream close). |
| Wait strategies and budgets | Port, HTTP, and log-message waits all honor a configured startup timeout. |
| Networks and aliases | Alias-based resolution reaches a peer container by name on both backends, including the microVM backend’s exec-tunneled emulation. |
| Boot-failure retries | State-db migration races and image-cache corruption self-heal with an automatic single retry rather than surfacing to the caller. |
| Reaping ledger + sweep | A run’s ledger files are written before create and removed after a clean stop; a fabricated dead run’s sandbox is provably reaped, at the backend level, by a fresh process’s init-time sweep. |
| Reuse gating + identity hash | The double opt-in (.reuse(true) + RIGHTSIZE_REUSE), adoption of an already-running spec-identical sandbox, and the identity hash’s pinned cross-language test vector — see below. |
| Capabilities | Each backend reports its pinned hardwareIsolated/checkpoint values — microsandbox: isolated (its own kernel per sandbox) AND checkpoint-capable via disk snapshot; docker: not isolated (shared host kernel) but checkpoint-capable via image commit. |
requireIsolation gating | A container requiring hardware isolation refuses to start on a non-isolated backend and starts normally on a hardware-isolated one. |
| Diagnostics report format | The exact rendered shape of a live-container diagnostics report — see below. |
| Checkpoint gating | checkpoint() succeeds on both real backends and throws a typed, backend-naming error on a backend without the capability, before any backend call; restoring a checkpoint under a different active backend than the one that created it fails with a typed mismatch error before any backend work. |
| Named checkpoints | A checkpoint created with a name persists a registry entry (one JSON file per name under the rightsize cache directory, pinned field names) and is rediscoverable in any later process via find/list/remove; re-checkpointing a name replaces its artifact and entry; a stale entry whose artifact is gone resolves to absent and is cleaned up. |
The pinned identity-hash vector
Container reuse identifies a sandbox by sha256 over a canonical JSON
serialization of its reuse-relevant spec. That hash has to come out identical no
matter which language computed it, so one exact input is pinned as a
cross-language test vector and checked against a fixed output in every port:
{"image":"redis:7-alpine","env":{"A":"1","B":"2"},"command":[],"exposedPorts":[6379],"memoryLimitMb":null,"copies":[]}
sha256 -> 799aad5a3338ce3d36999c7ff2733d4673c0592d417563f334544693ec1907a5
The resulting sandbox name — rz-reuse- plus the first 12 hex characters of that
hash — is rz-reuse-799aad5a3338, and this crate’s contract suite starts a real
container from that exact spec and asserts the backend received exactly that name.
See Container Reuse for the full
canonical-JSON shape.
Sibling implementations
- Kotlin: rightsize-kotlin — docs at ngriaznov.github.io/rightsize-kotlin
- Rust (this repo): rightsize-rust — docs at ngriaznov.github.io/rightsize-rust
- TypeScript: rightsize-node — docs at ngriaznov.github.io/rightsize-node
How the contract is enforced
Every behavior in the table above is a real test against a real backend, not a
document anyone has to keep in sync by hand:
crates/rightsize-modules/tests/contract.rs
is a single suite of #[tokio::test] functions, parameterized at runtime by
RIGHTSIZE_BACKEND rather than compiled twice, so the same test bodies run
unchanged against microsandbox and docker:
RIGHTSIZE_BACKEND=docker cargo test -p rightsize-modules --features sandbox-it --test contract
RIGHTSIZE_BACKEND=microsandbox cargo test -p rightsize-modules --features sandbox-it --test contract
The Kotlin and Node ports carry the equivalent suite in their own repos
(BackendContractTest and test/it/contract.test.ts respectively) — same
behaviors, same assertions, adapted to each language’s test framework and async
model. Any change to a SandboxBackend implementation, in any of the three repos,
is expected to pass its contract suite against both backends before merging; that
discipline is what keeps this page true rather than aspirational.
Orphan Reaping
While your test process is alive, normal lifecycle handles cleanup: stop(self) on
the happy path, Drop’s synchronous fallback (see
Containers & Guards) if a guard is simply
dropped. The only event that can leave a sandbox running forever is the process
dying without either of those getting a chance to run — a bare SIGKILL, an OOM-kill,
a crashed CI step. Reaping exists for exactly that case.
Two layers
Run records + init-time sweep (always on)
Under the rightsize cache dir (~/.cache/rightsize on
macOS/Linux, %LOCALAPPDATA%\rightsize on Windows — the same directory the msb
provisioner uses, and the same one a Kotlin/Node/Rust process on this host all agree
on):
runs/<run-id>.json— written atomically, before this process’s first sandbox is created: the process id, the ISO-8601 instant the process started (not the record — this defeats pid reuse), the active backend’s name, and the provisionedmsbbinary path when that’s the backend.runs/<run-id>.sandboxes/runs/<run-id>.networks— one name/id per line, appended before the backend actually creates the resource and removed after a successful stop/remove. The file is always a superset of what’s actually live.
At first backend use, right after resolution succeeds (once per process — no extra
processes, no extra thread), every other run’s record on disk is checked:
unparseable-and-fresh is left alone (it might be another process mid-write); dead
(no process with the recorded pid, or a pid whose actual start time doesn’t match
the recorded one within 2 seconds) means every name in that run’s .sandboxes is
removed via this process’s own backend, then every id in .networks too (in use
errors ignored — a network the crashed run’s own containers are still attached to
until they’re removed is expected, not a failure), not found errors included
either way — the sweep is idempotent and may race a sibling process’s own sweep of
the same leftover. A process only reaps a dead run whose recorded backend matches
its own — a docker process cannot remove msb sandboxes, so an msb run’s leftovers
wait for the next process that resolves to msb (and vice versa).
On clean shutdown — the last sandbox stopping with no networks left either — the three files for this run are deleted. A crash leaves them in place for the next sweep to find.
Watchdog (default on, per run, not a daemon)
A small detached script (POSIX sh on macOS/Linux, PowerShell on Windows) is
written to <cache dir>/reaper/ and spawned once per process, right before the
first sandbox is created. It blocks reading its stdin, held open by a pipe whose
write end only this process holds (never inherited by any other child this process
spawns — the one detail that makes the whole mechanism work: a leaked write end means
the watchdog’s stdin never reaches EOF). On EOF — this process exited, cleanly or via
SIGKILL — it removes this run’s sandboxes and networks via backend-CLI commands
(docker rm -f, or msb’s stop+rm with one retry on msb’s own state-database
error) and deletes the run’s record files, then exits. An empty/never-populated
.sandboxes (the clean-shutdown case) just means it deletes the record files and
exits — no clean/crash signaling needed, since this is idempotent either way.
This is the fast path: seconds after a crash, instead of waiting for the next process’s init-time sweep.
RIGHTSIZE_REAPER
| Value | Effect |
|---|---|
on (default) | Run records + init-time sweep + watchdog. |
sweep | Run records + init-time sweep only — no watchdog process. |
off | Neither. No run record is written at all — nothing would ever read it. |
Unknown values are treated as on.
What clean shutdown does
ContainerGuard::stop(self) removes the sandbox’s line from .sandboxes right after
the backend confirms it’s gone. Once both .sandboxes and .networks are empty, the
three ledger files for this run are deleted — the next container this process starts
(if any) recreates them from scratch.
The crash story
A Drop-only teardown (no explicit stop(), process still alive) is already covered
by the cleanup thread described in
Containers & Guards — reaping is the
backstop for the case that thread itself never runs: the process is gone before it
gets the chance. SIGKILL’d mid-test, Drop never fires at all (nothing survives
that signal) — the watchdog is what notices, within seconds. If the watchdog itself
somehow didn’t fire (its script failed to write, the process was frozen rather than
killed and the OS never actually closed the pipe), the next process on this host that
resolves the same backend reaps it at startup instead — bounded by however long until
that next process runs, not indefinitely.
Reuse immunity
A keep_alive sandbox — the flag Container Reuse sets — is never
listed in .sandboxes in the first place, so it is structurally immune to both the
sweep and the watchdog: there is no line naming it for either to act on. The msb
backend also excludes it from its own shutdown bookkeeping, and the docker backend
labels it dev.rightsize.reuse=<hash> instead of
dev.rightsize.run_id=<run-id>, so close()’s run-id-scoped listing structurally
cannot match it either.
The docker-remote caveat
A local watchdog process cannot outlive the machine it runs on — if a CI VM is torn down mid-test, no local process survives to reap anything, watchdog included. This is only a real gap for a remote docker daemon: containers created against a daemon running on a different host (or one that outlives the VM issuing commands, e.g. a shared CI docker-in-docker setup) can outlive the caller entirely. The next process that runs a test against that same daemon still reaps them at its own init-time sweep — the sweep only needs the ledger’s cache dir to be reachable by a later process, not the watchdog’s own process to have survived. A fully isolated, one-VM-per-run docker daemon (the common case) has no such gap: the daemon and every container on it die with the VM regardless of reaping.
Troubleshooting
See Troubleshooting for what to check if a sandbox survives anyway.
Container Reuse
A container marked for reuse survives process exit: the next equivalent start() —
in this process or a later one — ADOPTS the same sandbox instead of booting a fresh
one. stop() on a reuse-active guard leaves the sandbox running; that’s the feature.
Double opt-in
Reuse only activates when BOTH are true:
- the container is marked via
.reuse(true); RIGHTSIZE_REUSEis set to exactly"true"or"1"in the process environment.
use rightsize::Container;
let guard = Container::new("redis:7-alpine")
.with_exposed_ports(&[6379])
.reuse(true)
.start()
.await?;
guard.stop().await?;
.reuse(true) with RIGHTSIZE_REUSE unset (or anything other than "true"/"1")
behaves exactly like an ordinary ephemeral container — no adoption, no registry file,
stop() tears it down normally — with a single stderr note that reuse was requested
but not enabled. Neither knob alone does anything; both are required, on purpose: an
env var that’s easy to forget to set locally, and impossible to set by accident on a
CI runner that never exports it.
Identity: what busts the hash
A reuse container’s identity is sha256 over a canonical JSON serialization of:
{image, env (sorted by key), command, exposedPorts (sorted), memoryLimitMb,
copies: [{guestPath, sha256(content)}] sorted by guestPath}
Two Containers hash identically — and therefore adopt the same sandbox — only if
their image, environment (regardless of the order .with_env was called in), command,
exposed ports, memory limit, and every copied-in file’s content all match exactly.
Change any one of those and the next start() computes a different hash, a different
rz-reuse-<12hex> name, and boots a brand-new sandbox alongside the old one (which
still needs manual cleanup — see below).
Notably NOT part of the identity: network membership (see
Unsupported: custom networks below), wait
strategy, aliases, and the mount’s read_only flag.
The identity hash and its canonical JSON shape are a cross-language contract: the same logical spec hashes identically whether it was built in this crate, the Kotlin port, or the Node port, pinned by a fixed test vector in each implementation.
Sandbox naming
rz-reuse-<first 12 hex chars of the identity hash> — deterministic, not
process-unique like an ordinary container’s rz-<run-id>-<seq>. This is what makes
adoption possible: two processes building an identical spec compute the identical
name and can find each other’s sandbox by it.
The registry
<cacheDir>/reuse/<full-hash>.json (same cache dir
the reaping ledger uses), written atomically after the reused container first starts
successfully and passes its wait strategy:
{
"name": "rz-reuse-<12hex>",
"image": "redis:7-alpine",
"ports": { "6379": 32768 },
"createdIso": "2025-01-01T00:00:00Z",
"backend": "microsandbox"
}
Start flow
- Registry file exists — verify the sandbox is actually running (a real backend
query, not just “the file says so”), then re-run the container’s own wait strategy
against the registry’s recorded host ports. Success: ADOPT — mapped ports come
from the registry, no
create()call on the backend,is_running()istrueimmediately. Failure (not running, the wait strategy fails, or the file doesn’t parse) — best-effort remove whatever’s actually there and the registry file, then fall through to step 2. - No registry file (or the fallback above) — allocate host ports normally,
create under the
rz-reuse-<12hex>name, run the wait strategy, and on success write the registry file. - Name collision on create — another process won the race to create the same identity’s sandbox first. Re-enter the adopt path (step 1) once, reading whatever the winner has by then written; if that still doesn’t pan out, the original collision error surfaces.
Crash-mid-boot orphan recovery
Step 2’s registry write happens only after the fresh sandbox’s own wait strategy
has succeeded — so a process that crashes, or fails its own wait, after create()
but before that write leaves a RUNNING sandbox under the identity’s fixed name with
no registry entry pointing at it at all. Because a reuse sandbox is keep_alive
(see Interaction with reaping), no watchdog or sweep
ever touches it — invisible to reaping is keep_alive’s whole point, and that holds
here too, for better or worse. Left alone, the next fresh-create of the same identity
would collide with it: docker at least rejects the create with a 409 on the name, but
microsandbox has no such guard at all and happily starts a second workload under
the same sandbox name — the two then fight over in-guest ports, and every future
start of that identity times out until someone removes it by hand.
Step 2 closes that gap itself: once the adopt path above has concluded there is no
usable registry entry (missing, corrupt, or failed verification), start() asks the
backend directly — the same find_running query the adopt path itself uses — whether
a sandbox under the identity’s fixed name is already running, and if so, best-effort
removes it before attempting the fresh create. This is deliberately narrower than
step 3’s name-collision retry: there, a create() conflict means another live
process just won the race and is (or is about to be) the one writing the registry
entry, so that sandbox is adopted, never removed — removing it out from under a live
concurrent creator would defeat the entire point of reuse.
Stop semantics
stop() on a reuse-active guard clears only in-process bookkeeping (the handle, the
mapped-port cache) — no backend.stop/backend.remove call, and the sandbox is
never listed in the reaping ledger at any point, so no sweep or
watchdog ever touches it either. The container keeps running, holding its host ports,
until something removes it explicitly.
There is no remove()/full-teardown API in this release — clean it up with the
backend’s own tooling:
# docker
docker rm -f <name>
# microsandbox
msb stop <name> && msb rm <name>
or delete <cacheDir>/reuse/<hash>.json and let the next start() for that identity
create a fresh sandbox (the stale one becomes an ordinary orphan at that point — worth
removing by hand rather than leaving it running indefinitely).
Unsupported: custom networks together
.reuse(true) combined with .with_network(...) fails fast at start() with a typed
error (RightsizeError::ReuseNetworkConflict in this crate) rather than silently
ignoring one or the other: the identity hash has no concept of network topology, so
“which network is this adopted sandbox on” has no well-defined answer. Drop either
.reuse(true) or .with_network(...).
Interaction with runtime copy
copy_file_to_container/copy_content_to_container/copy_file_from_container
(see Copying Files) work against a reuse sandbox exactly like any
other runtime operation — but they mutate SHARED reused state, and they are not
part of the reuse identity hash. Two Containers with an identical spec still
adopt the same sandbox even if one process has copied files into it that another
adopting process never touched; if a workload’s correctness depends on a copied-in
file’s presence or content, tracking that dependency is on the caller, not the
identity hash.
Interaction with reaping
A reuse sandbox is keep_alive under the hood — see
Orphan Reaping’s reuse immunity for the full mechanism.
In short: it’s excluded from every own-run cleanup path (the reaping ledger, the
Drop-path cleanup thread, each backend’s own run-scoped bookkeeping) from the moment
it’s created, by construction, not by a special case bolted on afterward.
CI guidance
Do not enable RIGHTSIZE_REUSE on ephemeral CI. A fresh CI runner gets nothing
back from reuse (there’s no warm sandbox from a “previous run” to adopt — every run
starts a clean machine) and pays the cost of a sandbox that outlives the test process,
which then needs its own cleanup step or it accumulates across runs on a
long-lived/self-hosted runner. Reuse earns its keep in a local dev loop, where the
same machine runs the same test suite repeatedly and skipping the boot is the entire
point.
Troubleshooting
See Troubleshooting if a reuse sandbox needs to be found and removed by hand.
Copying Files
Two different ways to get files into a container, for two different moments in a container’s life.
Runtime copy vs. start-time mount — when to use which
Container::with_copy_file_to_container (covered in
Files & Resources) configures a mount
before boot — it’s part of the spec the container starts from, and it never
changes again for that container’s lifetime.
The operations on this page run after start(), against an already-running
container — useful when the file to copy doesn’t exist yet at boot time (a
generated fixture, a config rendered from a template, output the guest itself
produced) or when you only decide what to copy in partway through a test.
use rightsize::Container;
let guard = Container::new("alpine:3.19")
.with_command(&["sleep", "120"])
.start()
.await?;
// Any time after start() — not just once, not just before boot:
guard.copy_file_to_container("/host/path/config.json", "/etc/app/config.json").await?;
The three operations
// Host file or directory -> guest.
guard.copy_file_to_container(host_path, "/guest/path").await?;
// In-memory bytes or a string -> guest (writes a private temp file under the hood,
// delegates to copy_file_to_container, cleans the temp file up afterward).
guard.copy_content_to_container(b"hello\n", "/guest/path/hello.txt").await?;
guard.copy_content_to_container("hello\n", "/guest/path/hello.txt").await?;
// Guest file or directory -> host.
guard.copy_file_from_container("/guest/path", host_dest_path).await?;
There is no separate “directory” method on either side — each one accepts a file
OR a directory source, exactly like cp -r/docker cp/msb copy themselves do.
Directory semantics
Copying a directory to an absent destination produces the destination as a copy of the source — contents land directly under the destination, not nested one level deeper inside it:
// /host/fixtures/{a.txt, sub/b.txt} exists; /guest/dst does not.
guard.copy_file_to_container("/host/fixtures", "/guest/dst").await?;
// -> /guest/dst/a.txt, /guest/dst/sub/b.txt
The same shape applies copying a directory out. This matches how cp -r/
docker cp/msb copy already behave against a nonexistent destination — nothing
rightsize-rust invents on top of the underlying tools.
The parent-creation guarantee
You never have to pre-create a destination’s parent directory, on either side:
- Copying in: the guest’s destination parent is created first
(
exec: mkdir -p <parent>), before the transfer. - Copying out: the host destination’s parent is created via the standard
library (
std::fs::create_dir_all), before the transfer.
Requirements and failure modes
- The container must be running. A never-started or stopped guard fails with
the same “not running” error shape
exec/logsuse, before any backend call. - The container path must be absolute. Both
msb copyanddocker cprequire aNAME:/abs/pathshape; a relative path fails fast with an actionable message, before any backend call. - A failed copy surfaces the tool’s stderr. A missing source in the guest, a
permission error, and so on raise the backend-error shape carrying the
underlying
msb/dockerstderr — never a silent success.
Reuse caveat
Runtime copies work on a reuse container exactly like any other runtime operation — but they mutate shared reused state, and they are not part of the reuse identity hash. Two reuse containers with an identical spec still adopt the same sandbox even if one process has copied files into it that the other never touched; if your workload’s correctness depends on a copied-in file’s presence or content, that dependency is on you to manage, not on the identity hash.
Backend implementation notes
- microsandbox:
msb copy -q <src> <name>:<dst>(and the mirrored form for copy-out) — the same invocation plumbing this backend uses for every othermsbsubcommand. - docker: shells out to the
dockerCLI (docker cp) rather than the daemon’s own tar-archive-in/tar-archive-out endpoints — hand-rolling tar encode/decode, or adding a dependency for it, would be worse than reusing a CLI this backend already requires (the orphan-reaping watchdog’s own kill command is a plaindocker rm -f).
Failure Diagnostics
When a test fails because a container never became ready, the useful debugging information — what’s actually running, on what port, what did it log — normally has to be reconstructed by hand after the fact. Failure diagnostics surfaces it automatically instead.
The report
rightsize::diagnostics() returns a human-readable snapshot of every container this
process currently has running, built from a process-local registry updated on every
successful start() and stop():
use rightsize::diagnostics;
let report = diagnostics().await;
eprintln!("{report}");
Sample output, two containers:
== rightsize diagnostics: 2 running container(s) ==
-- rz-ab12cd34-redis (redis:7-alpine) --
state: running host: 127.0.0.1 ports: 6379->49213
last 50 log lines:
1:M 01 Jan 2026 00:00:00.000 * Ready to accept connections tcp
-- rz-ab12cd34-postgres (postgres:16-alpine) --
state: running host: 127.0.0.1 ports: 5432->49214
last 50 log lines:
database system is ready to accept connections
Nothing running:
== rightsize diagnostics: no running containers ==
Each container’s log tail is its own logs call’s last 50 lines, indented two
spaces. A logs call that fails degrades that one container’s section to a single
line instead of failing the whole report:
-- rz-ab12cd34-redis (redis:7-alpine) --
state: running host: 127.0.0.1 ports: 6379->49213
logs: unavailable (msb exec timed out after 5s)
This exact format — the header line, the count, the per-container blocks — is a cross-language contract: the same report renders identically whether it’s produced by this crate, the Kotlin port, or the Node port, so a screenshot or a pasted report from any of them reads the same way.
The registry
A container is registered the moment start() produces a ContainerGuard — even if
a readiness wait strategy fails right afterward, since “the container booted but
never became ready” is exactly the failure this feature exists to explain — and
deregistered on stop() or Drop. It’s entirely in-process: no file, no
cross-process visibility, no interaction with the reaping ledger.
Its only job is answering “what is this process’s own run doing right now.”
Automatic hook: DiagnosticsGuard
Wiring diagnostics() into every test by hand is exactly the kind of thing worth
automating. DiagnosticsGuard does it: construct one at the top of a test, and its
Drop prints the report to stderr — but only if the thread is unwinding from a
panic. A passing test prints nothing.
use rightsize::{Container, DiagnosticsGuard};
#[tokio::test]
async fn redis_round_trips_a_value() {
let _diagnostics = DiagnosticsGuard::new();
let guard = Container::new("redis:7-alpine")
.with_exposed_ports(&[6379])
.start()
.await
.unwrap();
// ... assertions that might panic ...
guard.stop().await.unwrap();
}
If an assertion after start() panics, the report — including this container’s
last 50 log lines — lands on stderr right next to the panic message, without an
extra round trip to reproduce the failure locally.
DiagnosticsGuard’s Drop cannot itself be async, and may run from inside a
#[tokio::test]’s own runtime while that runtime is mid-unwind — nesting another
block_on there would panic. It sidesteps that by fetching the report on a fresh,
throwaway OS thread (the same trick crate::cleanup’s own background thread uses)
and joining it synchronously, so it’s safe to use from any test regardless of the
Tokio flavor it runs under.
Isolation Requirement
The two backends give meaningfully different isolation guarantees. Most tests never
need to care — but a test that runs untrusted code (a plugin, a user-supplied
script, an artifact pulled from somewhere you don’t fully control) does, and
.require_isolation(true) lets it demand the stronger one instead of silently
running with whatever backend happened to resolve.
Guarantees table
| Backend | hardware_isolated | What that means |
|---|---|---|
| microsandbox (msb) | true | Each sandbox is its own microVM with its own kernel — a workload cannot see or affect the host kernel, other sandboxes, or the host process table. |
| docker | false | Containers are namespaces/cgroups on one shared host kernel. Strong process/filesystem isolation, but a kernel-level exploit inside the container can reach the host. |
Exposed on the backend SPI as capabilities().hardware_isolated:
use rightsize::backends;
let caps = backends::active().capabilities();
if caps.hardware_isolated {
// running on msb: real microVM isolation
}
When to require it
Reach for .require_isolation(true) when the container will run code you don’t
trust — not merely code you didn’t write, but code whose behavior you can’t bound in
advance:
- executing a user-supplied plugin, script, or build step;
- running an artifact pulled from an untrusted or unverified source;
- any workload where “it could try to escape the container” is a real threat model, not a hypothetical.
Ordinary test fixtures — a database, a message broker, a well-known third-party
image you already trust — don’t need it; the docker fallback’s isolation is enough,
and requiring hardware isolation there would just mean the test refuses to run
without RIGHTSIZE_BACKEND=microsandbox.
API
use rightsize::Container;
let guard = Container::new("untrusted/plugin-runner:latest")
.require_isolation(true)
.start()
.await?;
Checked in start(), before any create or network work: if the active backend’s
capabilities().hardware_isolated is false, start() returns
RightsizeError::IsolationRequired and no sandbox is created. The error names the
active backend and the fix:
Hardware isolation was required (.require_isolation(true)) but the active 'docker'
backend does not provide it — set RIGHTSIZE_BACKEND=microsandbox to run on a hardware-isolated
backend, or drop .require_isolation(true) if this workload does not need it
Untrusted-code guidance
Hardware isolation raises the ceiling on what a workload could do if compromised — it doesn’t replace ordinary caution about what you hand it in the first place:
- Cap memory. Untrusted code with an unbounded memory limit can still degrade the
host by exhausting it. Use
.with_memory_limit(megabytes). - No secrets in env. Environment variables are visible to
exec/process inspection tools and often end up in logs. Don’t pass API keys, credentials, or tokens the workload doesn’t strictly need to an untrusted container — hardware isolation protects the host kernel, not a secret you handed the workload directly. - Prefer
require_isolation(true)over hoping the active backend happens to be msb. Without it, a host or CI runner where msb isn’t available (no KVM, no supported OS) silently falls back to docker, and the untrusted workload runs with weaker guarantees than intended, with nothing failing to say so. - Read-only by default. File mounts are already read-only unless you explicitly
call
.read_write()on them (see Files & Resources) — leave untrusted-code mounts read-only unless the workload genuinely needs to write back.
Interaction with other features
capabilities() is independent of reuse and reaping —
a reused or reaped sandbox is still subject to the same .require_isolation(true)
check on the start() call that adopts or creates it, since the check runs before
either of those code paths does any backend work.
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. 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.
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: a disk snapshot name,
rz-ckpt-<12 hex chars>.
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:
// "... was started under the 'microsandbox' backend, but this checkpoint was
// created by the 'docker' backend — set RIGHTSIZE_BACKEND=docker to restore it ..."
This check runs before any backend work, so a mismatch never reaches the CLI/daemon at all.
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), 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.
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>
Modules
rightsize-modules ships eighteen preconfigured containers on top of the
rightsize core: Redis, Memcached, ArangoDB, MongoDB, Redpanda, Kafka,
SpringCloudConfig, PostgreSQL, MySQL, Apache Pinot, RabbitMQ, MariaDB, WireMock,
ClickHouse, Keycloak, Neo4j, Floci, and Apache Flink. Each module is a thin newtype
wrapping Container — no subclassing, just the spec-customizer and post-start hooks
the core exposes (see
Containers & Guards) — with connection
helpers on its guard.
| Module | Helper(s) |
|---|---|
RedisContainer | uri() |
MemcachedContainer | address() (protocol-level VERSION probe, not a bare port wait) |
MongoDbContainer | connection_string() (single-node replica set, auto-initiated) |
ArangoContainer | endpoint(), with_root_password(…) |
PostgresContainer | connection_string(), username(), password(), database_name(), with_username/with_password/with_database(…) |
MySqlContainer | connection_string(), username(), password(), database_name(), with_username/with_password/with_database(…) |
MariaDbContainer | connection_string(), username(), password(), database_name(), with_username/with_password/with_database(…) |
RedpandaContainer | bootstrap_servers(), schema_registry_url() |
KafkaContainer | bootstrap_servers() (KRaft single node) |
RabbitMqContainer | amqp_url(), management_url(), username(), password(), with_username/with_password(…) |
ClickHouseContainer | http_url(), username(), password(), database_name(), with_username/with_password/with_database(…) |
SpringCloudConfigContainer | uri() (defaults with_memory_limit(1024)) |
PinotContainer | controller_url(), broker_url() (defaults with_memory_limit(4096)) |
WireMockContainer | base_url(), admin_url() |
KeycloakContainer | auth_server_url(), management_url(), admin_username(), admin_password(), with_admin_username/with_admin_password(…) (defaults with_memory_limit(1024)) |
Neo4jContainer | http_url(), bolt_url(), username(), password(), with_password(…) (defaults with_memory_limit(1024)) |
FlociContainer | FlociContainer::aws()/azure()/gcp(), endpoint_url() |
FlinkContainer | rest_url(), with_task_manager() (docker only; defaults with_memory_limit(1024)) |
Backend wiring is a Cargo feature choice, not a runtime one: backend-msb and
backend-docker (both on by default) pull in rightsize-msb and rightsize-docker
respectively, so you can trim the one you don’t need.
Anything not on this list
Eighteen modules is not an exhaustive catalog — it’s the current coverage,
growing as needs arise. Anything else is the plain Container API (see
Containers & Guards); a thin
wrapper following the existing modules’ shape (a newtype over Container, a
*Guard newtype over ContainerGuard with connection helpers, a with_image/new
pair) is a small, welcome contribution — see the repository’s CONTRIBUTING.md.
RedisContainer
A single-node Redis container, ready-checked with a plain TCP read-probe
(Wait::for_listening_port) — Redis speaks
first on connect, so the bare listening-port wait is sufficient here (contrast
MemcachedContainer, which needs a protocol-level probe).
Default image: redis:8.6-alpine
Guest port: 6379
| Method | On | Effect |
|---|---|---|
RedisContainer::new() | builder | Pinned default image. |
RedisContainer::with_image(image) | builder | Caller-chosen image. |
.start() | builder → Result<RedisGuard> | Boots the container. |
.uri() | guard | redis://host:port connection URI. |
.stop() | guard | Stops and removes the container, releases its port. |
RedisGuard derefs to ContainerGuard, so exec(), logs(), get_mapped_port(),
etc. are all available directly on it too.
Complete example
use rightsize_modules::RedisContainer;
#[tokio::test]
async fn cache_roundtrip() -> rightsize::Result<()> {
let redis = RedisContainer::new().start().await?;
let client = redis::Client::open(redis.uri()).unwrap();
let mut con = client.get_connection().unwrap();
redis::cmd("SET").arg("k").arg("v").execute(&mut con);
let v: String = redis::cmd("GET").arg("k").query(&mut con).unwrap();
assert_eq!(v, "v");
redis.stop().await?;
Ok(())
}
(This crate’s own integration suite proves the same connectivity with a raw TCP
PING/PONG round-trip rather than pulling in the redis crate as a dev-dependency
— see crates/rightsize-modules/tests/datastore_modules_it.rs.)
Backend notes
No memory-limit override and no known quirks on either backend — Redis’s default footprint is well under microsandbox’s ~450 MB default microVM RAM. Nothing else to flag here; see Backends for the general backend-difference list if you hit something unexpected.
MemcachedContainer
A single-node Memcached container, ready-checked with a protocol-level version
probe instead of the bare listening-port wait.
Default image: memcached:1.6-alpine
Guest port: 11211
| Method | On | Effect |
|---|---|---|
MemcachedContainer::new() | builder | Pinned default image. |
MemcachedContainer::with_image(image) | builder | Caller-chosen image. |
.start() | builder → Result<MemcachedGuard> | Boots the container. |
.address() | guard | host:port address of the running container. |
.stop() | guard | Stops and removes the container, releases its port. |
Why not the default wait strategy
Memcached logs nothing useful on startup, and the port-forwarding layer on either
backend can bind the host port before the server inside is actually accepting — a
bare TCP-connect wait (even with the standard read-probe) can pass while the first
real client connection still gets a dead stream. This module ships a custom
WaitStrategy that sends version\r\n over the wire and requires a reply starting
with VERSION before considering the container ready — a genuine protocol
handshake, not just “something answered.”
This is the worked example referenced throughout the rest of this book whenever a readiness signal needs to be protocol-level rather than a bare port check — see Wait Strategies.
Complete example
use rightsize_modules::MemcachedContainer;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;
#[tokio::test]
async fn memcached_speaks_the_protocol() -> rightsize::Result<()> {
let guard = MemcachedContainer::new().start().await?;
let mut stream = TcpStream::connect(guard.address()).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
stream.write_all(b"version\r\n").unwrap();
let mut buf = [0u8; 64];
let n = stream.read(&mut buf).unwrap();
assert!(String::from_utf8_lossy(&buf[..n]).starts_with("VERSION"));
guard.stop().await?;
Ok(())
}
By the time start() returns Ok, the module’s own wait strategy has already proven
this exact protocol exchange succeeds — a test using a real memcached client crate is
proving the client library, not the container’s readiness.
Backend notes
No memory-limit override and no known quirks beyond the readiness story above, which applies identically on both backends.
MongoDbContainer
A single-node MongoDB container started as a one-member replica set (named
docker-rs) — required for transactions and change streams, which is why this
module doesn’t just boot a bare standalone mongod.
Default image: mongo:8.0
Guest port: 27017
| Method | On | Effect |
|---|---|---|
MongoDbContainer::new() | builder | Pinned default image. |
MongoDbContainer::with_image(image) | builder | Caller-chosen image. |
.start() | builder → Result<MongoDbGuard> | Boots the container; does not return until the replica set has an elected primary. |
.connection_string() | guard | mongodb://host:port/test?directConnection=true. |
.replica_set_url() | guard | Alias for connection_string(). |
.stop() | guard | Stops and removes the container, releases its port. |
The post-start hook
start() runs a Container::with_post_start hook (see
Containers & Guards)
that, once the plain listening-port wait passes:
- Runs
rs.initiate()viamongosh— checkingrs.status()first, so a retry after a partial initiate doesn’t re-initiate a set that’s already forming. - Polls
db.hello().isWritablePrimaryuntil it reportstrue.
Both steps retry through the proxy-accepts-before-mongod-listens race (the same
read-probe-relevant timing gap covered in
Wait Strategies): the
listening-port wait can return before mongod is far enough along to accept a real
client command, so the first few mongosh invocations failing is expected and
retried, not a fatal error. The practical upshot: connection_string() is always
immediately usable once start() returns Ok — no separate “wait for the replica set”
step needed in your own test.
Complete example
use rightsize_modules::MongoDbContainer;
#[tokio::test]
async fn mongo_accepts_a_write() -> rightsize::Result<()> {
let guard = MongoDbContainer::new().start().await?;
// start() already awaited a writable primary — connection_string() is usable now.
let insert = guard
.exec(&["mongosh", "--quiet", "--eval", "db.smoke.insertOne({ok: 1})"])
.await?;
assert_eq!(insert.exit_code, 0);
let count = guard
.exec(&["mongosh", "--quiet", "--eval", "db.smoke.countDocuments({ok: 1})"])
.await?;
assert!(count.stdout.trim().ends_with('1'));
guard.stop().await?;
Ok(())
}
Swap the exec-based mongosh calls above for the mongodb driver crate of your
choice against guard.connection_string() in your own tests — this crate’s own
integration suite uses exec specifically to avoid taking on a MongoDB
client-library dev-dependency just for a smoke test.
Backend notes
No memory-limit override. The replica-set initiation retries absorb the same proxy-timing gap that affects wait strategies generally on both backends — nothing MongoDB-specific beyond that.
ArangoContainer
A single-node ArangoDB container. Auth is disabled by default
(ARANGO_NO_AUTH=1); call .with_root_password(...) to enable it instead.
Default image: arangodb:3.11
Guest port: 8529
| Method | On | Effect |
|---|---|---|
ArangoContainer::new() | builder | Pinned default image, auth disabled. |
ArangoContainer::with_image(image) | builder | Caller-chosen image, auth disabled. |
.with_root_password(password) | builder | Enables auth with the given root password instead of no-auth. |
.start() | builder → Result<ArangoGuard> | Boots the container. |
.endpoint() | guard | HTTP API base URI, e.g. http://127.0.0.1:<port>. |
.stop() | guard | Stops and removes the container, releases its port. |
with_root_password — why it removes ARANGO_NO_AUTH instead of just setting a password
This is worth understanding before you reach for it: the official ArangoDB
entrypoint checks ARANGO_NO_AUTH for mere presence, unconditionally, near the
very end of the script (if [ ! -z "$ARANGO_NO_AUTH" ]; then AUTHENTICATION="false"; fi) — right before it execs arangod --server.authentication=$AUTHENTICATION. This
check does not care whether ARANGO_ROOT_PASSWORD is also set. Verified directly
against the real entrypoint (docker run --entrypoint /bin/cat arangodb:3.11 /entrypoint.sh): with both variables set, the root password does get initialized
(the init block only cares that ARANGO_ROOT_PASSWORD is set), but AUTHENTICATION
still ends up "false" because ARANGO_NO_AUTH is still present — auth stays off
regardless of the password.
So with_root_password calls remove_env("ARANGO_NO_AUTH") before setting
ARANGO_ROOT_PASSWORD — a core-level primitive
(Container::remove_env) that exists
specifically for cases like this, where a later env var must retract an earlier
one’s effect on the entrypoint, not merely be shadowed by last-wins value
resolution.
Complete example
use rightsize_modules::ArangoContainer;
#[tokio::test]
async fn arango_serves_version() -> Result<(), Box<dyn std::error::Error>> {
let guard = ArangoContainer::new().start().await?;
let body = ureq::get(format!("{}/_api/version", guard.endpoint()))
.call()?
.body_mut()
.read_to_string()?;
assert!(body.contains("version"));
guard.stop().await?;
Ok(())
}
Backend notes
No memory-limit override and no known quirks beyond the auth-flag behavior above, which is purely an ArangoDB entrypoint detail — identical on both backends.
PostgresContainer
A single-node PostgreSQL container. Defaults to a test/test/test
user/password/database trio so connection_string() is usable with zero
configuration.
Default image: postgres:18-alpine
Guest port: 5432
| Method | On | Effect |
|---|---|---|
PostgresContainer::new() | builder | Pinned default image, test/test/test. |
PostgresContainer::with_image(image) | builder | Caller-chosen image. |
.with_username(u) / .with_password(p) / .with_database(d) | builder | Override any of the trio before start(). |
.start() | builder → Result<PostgresGuard> | Boots the container. |
.username() / .password() / .database_name() | guard | The configured trio. |
.connection_string() | guard | postgres://user:pass@host:port/db. |
.stop() | guard | Stops and removes the container, releases its port. |
Readiness: why times = 2
The postgres entrypoint starts the server twice: once to run initdb scripts
against it, then shuts it down and starts it again for real — printing “database
system is ready to accept connections” both times. Waiting for the first
occurrence races the restart: a client can connect to the init-time server just
before it’s torn down. This module’s wait strategy is
Wait::for_log_message(".*database system is ready to accept connections.*", 2) —
it waits for the second occurrence, the durable listen. This is the canonical
showcase for Wait::for_log_message’s times parameter — see
Wait Strategies.
The control-character env fix (DOCKER_PG_LLVM_DEPS)
The official postgres:*-alpine image bakes DOCKER_PG_LLVM_DEPS into its manifest
with a literal tab character in the value (a package list built with \t\t
continuation). msb 0.6.2’s krun VMM builder panics with InvalidAscii on that boot-env
value before the guest ever starts — reproduced with zero rightsize-set env vars, so
this is the image’s own baked value, not anything this module or your test adds.
Docker is unaffected. The module works around it by overriding the variable to an
empty string (.with_env("DOCKER_PG_LLVM_DEPS", "")) — a no-op for the build the
image already baked, and harmless on Docker.
If you hit InvalidAscii on a different image under RIGHTSIZE_BACKEND=microsandbox,
suspect a baked env var with a control character the same way — see
Troubleshooting.
Complete example
use rightsize_modules::PostgresContainer;
#[tokio::test]
async fn postgres_round_trips_a_row() -> Result<(), Box<dyn std::error::Error>> {
let guard = PostgresContainer::new().start().await?;
let conn_str = guard.connection_string().replacen("postgres://", "postgresql://", 1);
let (client, connection) = tokio_postgres::connect(&conn_str, tokio_postgres::NoTls).await?;
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("postgres connection task ended: {e}");
}
});
client
.execute("CREATE TABLE smoke (id INT PRIMARY KEY, note TEXT)", &[])
.await?;
client
.execute("INSERT INTO smoke (id, note) VALUES ($1, $2)", &[&1i32, &"hello-rightsize"])
.await?;
let rows = client.query("SELECT note FROM smoke WHERE id = $1", &[&1i32]).await?;
let note: &str = rows[0].get(0);
assert_eq!(note, "hello-rightsize");
guard.stop().await?;
Ok(())
}
Note the postgres:// → postgresql:// rewrite: tokio-postgres requires the
postgresql:// scheme; connection_string() returns postgres:// to match the
conventional URI form used elsewhere in this crate’s docs and other clients.
Backend notes
No memory-limit override needed — Postgres’s default footprint fits microsandbox’s
~450 MB microVM default. The only backend-relevant quirk is the
DOCKER_PG_LLVM_DEPS fix above, which is microsandbox-only in cause but applied
unconditionally (harmless on Docker) so the module behaves identically on both.
MySqlContainer
A single-node MySQL container. Defaults to a test/test/test
user/password/database trio (plus MYSQL_ROOT_PASSWORD=test) so
connection_string() is usable with zero configuration.
Default image: mysql:8.4
Guest port: 3306
| Method | On | Effect |
|---|---|---|
MySqlContainer::new() | builder | Pinned default image, test/test/test. |
MySqlContainer::with_image(image) | builder | Caller-chosen image. |
.with_username(u) / .with_password(p) / .with_database(d) | builder | Override any of the trio before start(). |
.start() | builder → Result<MySqlGuard> | Boots the container. |
.username() / .password() / .database_name() | guard | The configured trio. |
.connection_string() | guard | mysql://user:pass@host:port/db. |
.stop() | guard | Stops and removes the container, releases its port. |
Readiness — empirically pinned, not guessed
The official entrypoint boots mysqld twice: once as a throwaway “temp server”
to run init scripts, then for real. Both prints, plus the X Plugin’s own “ready for
connections” line, contain the substring ready for connections, and naively
counting occurrences is a trap: the temp server’s X Plugin binds port: 33060 —
whose digits start with 3306, so an unanchored port: 3306 search false-matches
it too.
Captured verbatim from a real docker run mysql:8.4 boot with this module’s env
(MYSQL_USER=test, MYSQL_DATABASE=test, MYSQL_ROOT_PASSWORD=test):
[System] [MY-011323] [Server] X Plugin ready for connections. Socket: /var/run/mysqld/mysqlx.sock
[System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.4.10' socket: '/var/run/mysqld/mysqld.sock' port: 0 MySQL Community Server - GPL.
...(init scripts run, temp server shuts down)...
[System] [MY-011323] [Server] X Plugin ready for connections. Bind-address: '::' port: 33060, socket: /var/run/mysqld/mysqlx.sock
[System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.4.10' socket: '/var/run/mysqld/mysqld.sock' port: 3306 MySQL Community Server - GPL.
Four lines contain ready for connections; only the last is the real server bound
to 3306. The temp server prints port: 0 (no port yet) and the X Plugin lines print
33060, whose 3306 prefix would satisfy an unanchored match. This module ships a
small custom WaitStrategy (predating Wait::for_log_message’s move to a real regex
engine — see Wait Strategies) rather than a
regex: it requires a line to contain mysqld: ready for connections, then checks that
port: 3306 appears later on that same line immediately followed by end-of-line or a
non-digit — which is exactly what rules out 33060. This is equivalent to a regex
anchor, port: 3306($|[^0-9]); the hand-written character check is untouched by the
regex-engine swap, since it was never built on the shared matcher in the first place.
Complete example
use mysql_async::prelude::*;
use rightsize_modules::MySqlContainer;
#[tokio::test]
async fn mysql_round_trips_a_row() -> Result<(), Box<dyn std::error::Error>> {
let guard = MySqlContainer::new().start().await?;
let pool = mysql_async::Pool::new(guard.connection_string().as_str());
let mut conn = pool.get_conn().await?;
conn.query_drop("CREATE TABLE smoke (id INT PRIMARY KEY, note TEXT)").await?;
conn.exec_drop(
"INSERT INTO smoke (id, note) VALUES (?, ?)",
(1i32, "hello-rightsize"),
).await?;
let note: Option<String> = conn
.exec_first("SELECT note FROM smoke WHERE id = ?", (1i32,))
.await?;
assert_eq!(note.as_deref(), Some("hello-rightsize"));
drop(conn);
pool.disconnect().await.ok();
guard.stop().await?;
Ok(())
}
Backend notes
No memory-limit override: MySQL 8.4’s InnoDB default footprint fits msb’s
default ~450 MB microVM RAM, unlike
SpringCloudConfigContainer’s Paketo JVM image — no
module-level memory floor was warranted here after measurement.
RedpandaContainer
A single-node Redpanda broker (Kafka API-compatible) with its schema registry enabled.
Default image: redpandadata/redpanda:v24.2.4
Guest ports: 9092 (external Kafka), 9093 (internal Kafka), 8081 (schema
registry)
| Method | On | Effect |
|---|---|---|
RedpandaContainer::new() | builder | Pinned default image. |
RedpandaContainer::with_image(image) | builder | Caller-chosen image. |
.start() | builder → Result<RedpandaGuard> | Boots the container. |
.bootstrap_servers() | guard | PLAINTEXT://host:port for the EXTERNAL listener. |
.schema_registry_url() | guard | Schema registry base URI. |
.stop() | guard | Stops and removes the container, releases its ports. |
The advertised-listener rewrite
Redpanda needs to advertise the address a client can actually reach — but the
mapped host port for the Kafka listener is only known after ports are allocated,
which happens right before create(). This module uses
Container::with_spec_customizer to
rewrite the boot command the instant before create, once the mapped port is known:
- EXTERNAL listener advertises
127.0.0.1:<mapped host port>— what a test process on the host actually dials. - INTERNAL listener advertises the fixed
redpanda:9093alias/port — what a sibling container on the sameNetworkresolves to reach this broker without going through the host port mapping at all.
This is the same trick KafkaContainer uses for its single advertised
listener; Redpanda’s version is the fuller example because it has two listeners to
rewrite (EXTERNAL and INTERNAL) rather than one.
Complete example
use rightsize_modules::RedpandaContainer;
#[tokio::test]
async fn redpanda_boots_and_advertises_a_reachable_port() -> rightsize::Result<()> {
let guard = RedpandaContainer::new().start().await?;
// guard.bootstrap_servers() is a PLAINTEXT://127.0.0.1:<port> address usable by
// any Kafka-protocol client crate (rdkafka, kafka-protocol, etc.) from the host.
println!("bootstrap: {}", guard.bootstrap_servers());
println!("schema registry: {}", guard.schema_registry_url());
guard.stop().await?;
Ok(())
}
This crate does not take a Kafka client-library dev-dependency for its own
integration suite (crates/rightsize-modules/tests/broker_modules_it.rs checks the
schema registry over plain HTTP instead) — bring whichever Kafka-protocol client
your project already uses.
Backend notes
No memory-limit override. INTERNAL_ALIAS = "redpanda" is the alias siblings resolve
through on a shared Network — this port has no sibling Kafka-consumer module of its
own yet, so cross-container consumption over the microsandbox
network-link emulation
is best-effort, not covered by this crate’s own contract suite.
KafkaContainer
A single-node Kafka broker running in KRaft mode — no ZooKeeper.
Default image: apache/kafka:4.0.0
Guest port: 9092
| Method | On | Effect |
|---|---|---|
KafkaContainer::new() | builder | Pinned default image. |
KafkaContainer::with_image(image) | builder | Caller-chosen image. |
.start() | builder → Result<KafkaGuard> | Boots the container. |
.bootstrap_servers() | guard | PLAINTEXT://host:port bootstrap address. |
.stop() | guard | Stops and removes the container, releases its port. |
Defaults baked in
This module sets a full KRaft single-node env block so the broker is usable with
zero configuration: KAFKA_NODE_ID=1, KAFKA_PROCESS_ROLES=broker,controller, a
self-quorum KAFKA_CONTROLLER_QUORUM_VOTERS, and KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1
(a single-node broker can’t satisfy a replication factor greater than 1).
The heap fix: the apache/kafka image defaults KAFKA_HEAP_OPTS to -Xmx1G,
which exceeds microsandbox’s ~450 MB default microVM RAM and aborts the JVM with an
“insufficient memory” error. This module overrides it to -Xmx256M -Xms256M — a
single-node KRaft dev broker runs comfortably in a 256 MB heap, and the override is
harmless on the Docker backend, which isn’t memory-constrained here. If you’re
wrapping a different Kafka-family image yourself and it aborts on boot under
RIGHTSIZE_BACKEND=microsandbox, check its default heap flags before reaching for
with_memory_limit — see Files & Resources.
The advertised-listener rewrite: same trick as
RedpandaContainer, simpler (one listener instead of two) — a
with_spec_customizer hook rewrites KAFKA_ADVERTISED_LISTENERS to
PLAINTEXT://127.0.0.1:<mapped host port> right before create(), once the mapped
port is actually known.
Complete example
use rightsize_modules::KafkaContainer;
#[tokio::test]
async fn kafka_boots_and_advertises_a_reachable_port() -> rightsize::Result<()> {
let guard = KafkaContainer::new().start().await?;
// guard.bootstrap_servers() is a PLAINTEXT://127.0.0.1:<port> address usable by
// any Kafka-protocol client crate.
println!("bootstrap: {}", guard.bootstrap_servers());
guard.stop().await?;
Ok(())
}
Backend notes
The KAFKA_HEAP_OPTS override above is required for this module to boot under
microsandbox’s default RAM — without it, boot fails outright. No
with_memory_limit call is needed on top of the heap override; the reduced heap
already fits comfortably.
SpringCloudConfigContainer
A Spring Cloud Config Server container, ready-checked via its actuator health endpoint.
Default image: hyness/spring-cloud-config-server:latest
Guest port: 8888
| Method | On | Effect |
|---|---|---|
SpringCloudConfigContainer::new() | builder | Pinned default image. |
SpringCloudConfigContainer::with_image(image) | builder | Caller-chosen image. |
.with_env(key, value) | builder | Thin passthrough to Container::with_env — see below. |
.start() | builder → Result<SpringCloudConfigGuard> | Boots the container. |
.uri() | guard | Config server base URI, e.g. http://127.0.0.1:<port>. |
.stop() | guard | Stops and removes the container, releases its port. |
The Paketo memory story
Paketo’s memory calculator sizes this JVM image’s fixed regions (metaspace, thread
stacks, direct memory) at roughly 688 MB — above microsandbox’s ~450 MB default
microVM RAM. This module ships with .with_memory_limit(1024) baked into its
constructor for exactly this reason; you don’t set this yourself when using the
module. See Files & Resources
for the full account, and PinotContainer for the module that hit an
even bigger version of the same problem.
The native-profile decision, and why it’s the caller’s call
The default, git-backed environment repository needs a configured git URI to boot at all — booting this module with zero configuration will not serve config from anywhere useful. Callers wanting the classpath/filesystem-backed environment repository instead should chain:
use rightsize_modules::SpringCloudConfigContainer;
let config = SpringCloudConfigContainer::new()
.with_env("SPRING_PROFILES_ACTIVE", "native")
.start()
.await?;
This module deliberately does not set this profile itself — that decision
belongs to the caller/test. If you’re networking this container to a consumer service via
Network (the pattern this module’s rustdoc and
this crate’s own contract tests both use it for), you’ll almost always want the
native profile plus a mounted or baked-in config repository.
Complete example (networked)
SpringCloudConfigContainer has no with_network/with_network_aliases
passthrough — unlike with_env, networking was never wired through this
newtype. Networking this config server to a consumer means building it with
the plain Container API directly (the same image the module wraps), not the
module type:
use rightsize::{Container, Network, Wait};
use std::sync::Arc;
#[tokio::test]
async fn app_fetches_config_from_a_sibling() -> rightsize::Result<()> {
let net = Arc::new(Network::new_network());
let config = Container::new("hyness/spring-cloud-config-server:latest")
.with_env("SPRING_PROFILES_ACTIVE", "native")
.with_exposed_ports(&[8888])
.waiting_for(Wait::for_http("/actuator/health").for_port(8888))
.with_memory_limit(1024) // Paketo's JVM regions need more than msb's ~450 MB default
.with_network(&net)
.with_network_aliases(&["configuration-stub"])
.start()
.await?;
let app = Container::new("my-service:latest")
.with_network(&net)
.with_env(
"SPRING_CLOUD_CONFIG_URI",
&format!("http://{}", net.resolve("configuration-stub", 8888)?),
)
.start()
.await?;
// ... exercise `app`, which fetches its config from the sibling above ...
app.stop().await?;
config.stop().await?;
Ok(())
}
This mirrors the cross-container pattern from Networking
exactly — SpringCloudConfigContainer is the right type for the direct-use case
(no sibling to reach it from), but reaching it from another container over a
Network currently means dropping to the plain Container API and reproducing
the module’s own wait strategy and memory limit by hand, as above.
Backend notes
.with_memory_limit(1024) is set unconditionally by the module — harmless on
Docker, required on microsandbox. No other backend-specific quirks known.
PinotContainer
A single-container Apache Pinot “QuickStart” cluster: controller, broker, server,
minion, and an embedded ZooKeeper, all as one process tree inside one image,
started with QuickStart -type EMPTY — a clean cluster with no demo tables. This is
a real-cluster smoke fixture, not a data-loading harness.
Default image: apachepinot/pinot:1.5.1
Guest ports: 9000 (controller REST API), 8000 (broker query API)
| Method | On | Effect |
|---|---|---|
PinotContainer::new() | builder | Pinned default image. |
PinotContainer::with_image(image) | builder | Caller-chosen image. |
.start() | builder → Result<PinotGuard> | Boots the container (up to 180s timeout). |
.controller_url() | guard | Controller REST base URI (schema/table/segment admin). |
.broker_url() | guard | Broker query base URI. |
.stop() | guard | Stops and removes the container, releases its ports. |
Ports — empirically verified, not the QuickStart docs’ assumption
The controller REST API is on 9000 as documented. The broker’s query port is 8000, not 8099 as some QuickStart documentation implies — confirmed from a real boot log:
StartControllerCommand ... -controllerPort 9000 ...
INFO: Started listener bound to [0.0.0.0:9000]
StartBrokerCommand ... -brokerPort 8000 -brokerGrpcPort 8010 ...
INFO: Started listener bound to [0.0.0.0:8000]
curl http://<host>:8000/health returns 503 while the broker is still registering
with the cluster and 200 once it’s live; 8099 is not opened by QuickStart at all.
This module exposes 8000 and names the helper broker_url accordingly — if you’ve
seen 8099 referenced elsewhere for Pinot, that’s not this deployment mode.
Memory — measured, not the originally-planned 2048 MB
See Files & Resources
for the full measured table (2048/2560/3072/4096 MB attempts). Short version: the
image bakes JAVA_OPTS=-Xms4G -Xmx4G, and this module ships with
.with_memory_limit(4096) — the lowest round number with real headroom, verified
stable (schema POSTs succeeding under repeated load) on both backends. Anything at or
below 3072 MB either gets OOM-killed outright or runs Helix RPCs into intermittent
500s under memory pressure even though /health reports 200 — a passing readiness
check that doesn’t mean the cluster is actually usable is exactly the trap this
measurement avoids.
Boot time
A four-JVM cluster booting cold on a laptop is legitimately slow (observed 60-120s).
This module’s wait strategy uses a 180-second startup timeout on
Wait::for_http("/health").for_port(9000) for exactly this reason — the
controller’s REST listener comes up well before the whole cluster has stabilized,
but it’s the only readiness signal that’s both meaningful and doesn’t require
polling the broker separately before every test can proceed.
Complete example
use rightsize_modules::PinotContainer;
#[tokio::test]
async fn pinot_controller_and_broker_are_reachable() -> Result<(), Box<dyn std::error::Error>> {
let guard = PinotContainer::new().start().await?;
let body = ureq::get(format!("{}/health", guard.controller_url()))
.call()?
.body_mut()
.read_to_string()?;
println!("controller health: {body}");
println!("broker url: {}", guard.broker_url());
guard.stop().await?;
Ok(())
}
Backend notes
.with_memory_limit(4096) is set unconditionally by the module. This is the largest
memory floor of any shipped module — if you’re timing out at the default 180s
startup wait, check available host RAM before assuming it’s a readiness-wait bug;
four JVMs at a 4 GiB ceiling is a genuinely heavy boot.
RabbitMqContainer
A single-node RabbitMQ container with the management plugin enabled. Defaults to a
guest/guest credential pair (the image’s own default) so amqp_url() is usable
with zero configuration.
Default image: rabbitmq:4-management-alpine
Guest ports: 5672 (AMQP), 15672 (management)
| Method | On | Effect |
|---|---|---|
RabbitMqContainer::new() | builder | Pinned default image, guest/guest. |
RabbitMqContainer::with_image(image) | builder | Caller-chosen image. |
.with_username(u) / .with_password(p) | builder | Override either credential before start(). |
.start() | builder → Result<RabbitMqGuard> | Boots the container. |
.username() / .password() | guard | The configured credential pair. |
.amqp_url() | guard | amqp://user:pass@host:port — the AMQP listener. |
.management_url() | guard | The management UI/API base URI. |
.stop() | guard | Stops and removes the container, releases its ports. |
Readiness — verified against a real 4.x boot
rabbitmq:4-management-alpine still prints the same "Server startup complete" line
the 3.x series used (captured verbatim from a real boot with this module’s env):
2026-07-04 08:47:17.936423+00:00 [info] <0.1036.0> started TCP listener on [::]:5672
completed with 4 plugins.
2026-07-04 08:47:18.001311+00:00 [info] <0.900.0> Server startup complete; 4 plugins started.
2026-07-04 08:47:18.001311+00:00 [info] <0.900.0> * rabbitmq_prometheus
2026-07-04 08:47:18.001311+00:00 [info] <0.900.0> * rabbitmq_management
2026-07-04 08:47:18.001311+00:00 [info] <0.900.0> * rabbitmq_management_agent
2026-07-04 08:47:18.001311+00:00 [info] <0.900.0> * rabbitmq_web_dispatch
The line appears exactly once, so for_log_message at times=1 is unambiguous —
unlike Postgres/MySQL/MariaDB, there is no double-boot restart to race here. The
management API’s own /api/health/checks/... endpoints require authenticated
requests, so the log line is the simpler and equally reliable readiness signal.
No with_memory_limit override: booted clean on msb’s default ~450M microVM RAM
(observed ~5.5s IT round-trip on both backends — an Erlang VM, not a JVM, so no
Paketo/QuickStart-style heap demand).
A 4.x behavior change worth knowing
RabbitMQ 4.x deprecates transient_nonexcl_queues and, per the broker’s own startup
warning, “this feature can still be used for now” — but a client that declares a
non-durable, non-exclusive queue (durable=false, exclusive=false) may be
rejected with reply-code=541 INTERNAL_ERROR depending on the deployed policy,
reproduced directly against this module’s pinned image. Declare durable,
non-exclusive queues (or exclusive transient ones) from client code exercising this
container; this module itself declares no queues.
Complete example
This module’s own integration test round-trips over the management HTTP API rather
than an AMQP client library — lapin (the natural AMQP client choice) pulls in a
time-based transitive tree above this workspace’s MSRV. The management API’s
/api/queues/.../publish and /api/queues/.../get endpoints exercise the same
“does the broker actually work” claim at zero extra dependency cost:
use rightsize_modules::RabbitMqContainer;
#[tokio::test]
async fn declare_publish_and_get_round_trips() -> Result<(), Box<dyn std::error::Error>> {
let guard = RabbitMqContainer::new().start().await?;
let agent = ureq::Agent::new_with_defaults();
let admin = guard.management_url();
let auth = format!("Basic {}", "guest:guest"); // base64 in the real test
// Durable, non-exclusive — RabbitMQ 4.x's transient_nonexcl_queues deprecation
// (see above) can reject the opposite combination.
agent
.put(format!("{admin}/api/queues/%2f/smoke"))
.header("Content-Type", "application/json")
.header("Authorization", &auth)
.send(r#"{"durable":true,"auto_delete":false}"#)?;
agent
.post(format!("{admin}/api/exchanges/%2f/amq.default/publish"))
.header("Content-Type", "application/json")
.header("Authorization", &auth)
.send(
r#"{"properties":{},"routing_key":"smoke","payload":"hello-rightsize",
"payload_encoding":"string"}"#,
)?;
let mut get = agent
.post(format!("{admin}/api/queues/%2f/smoke/get"))
.header("Content-Type", "application/json")
.header("Authorization", &auth)
// The management API's field is `ackmode`, not `ack_mode` — verified
// directly against a real 4.x boot.
.send(r#"{"count":1,"ackmode":"ack_requeue_false","encoding":"auto"}"#)?;
let body = get.body_mut().read_to_string()?;
assert!(body.contains("hello-rightsize"));
guard.stop().await?;
Ok(())
}
Backend notes
No memory-limit override is needed on either backend — see Readiness above.
MariaDbContainer
A single-node MariaDB container. Defaults to a test/test/test
user/password/database trio (plus MARIADB_ROOT_PASSWORD=test) so
connection_string() is usable with zero configuration.
Default image: mariadb:11.4
Guest port: 3306
| Method | On | Effect |
|---|---|---|
MariaDbContainer::new() | builder | Pinned default image, test/test/test. |
MariaDbContainer::with_image(image) | builder | Caller-chosen image. |
.with_username(u) / .with_password(p) / .with_database(d) | builder | Override any of the trio before start(). |
.start() | builder → Result<MariaDbGuard> | Boots the container. |
.username() / .password() / .database_name() | guard | The configured trio. |
.connection_string() | guard | mysql://user:pass@host:port/db — MariaDB speaks the MySQL wire protocol, so this crate’s house mysql:// scheme applies here too. |
.stop() | guard | Stops and removes the container, releases its port. |
Readiness — empirically pinned, following MySqlContainer’s precedent exactly
The official mariadb entrypoint double-boots exactly like MySQL’s: once as a
throwaway “temp server” to run init scripts (which prints ready for connections
with port: 0, i.e. no port bound yet), then for real on port 3306. Captured
verbatim from a real docker run mariadb:11.4 boot with this module’s env
(MARIADB_USER=test, MARIADB_DATABASE=test, MARIADB_ROOT_PASSWORD=test):
2026-07-04 8:47:29 0 [Note] mariadbd: ready for connections.
Version: '11.4.12-MariaDB-ubu2404' socket: '/run/mysqld/mysqld.sock' port: 0 mariadb.org binary distribution
2026-07-04 8:47:30 0 [Note] Server socket created on IP: '0.0.0.0', port: '3306'.
2026-07-04 8:47:30 0 [Note] Server socket created on IP: '::', port: '3306'.
2026-07-04 8:47:30 0 [Note] mariadbd: ready for connections.
Version: '11.4.12-MariaDB-ubu2404' socket: '/run/mysqld/mysqld.sock' port: 3306 mariadb.org binary distribution
Unlike MySQL 8.4, MariaDB has no X Plugin adding a third ready for connections line
with a decoy 3306-prefixed port (33060), so there’s no false-match trap to anchor
against — but the temp server’s port: 0 line still means an unanchored times=2
count would be correct only by coincidence (it happens to work here because there are
exactly two ready for connections lines total). This module follows the MySQL house
precedent anyway and anchors on the literal port: 3306 immediately followed, later
on the same line, by mariadb.org binary distribution — so the wait is robust to the
temp-server line’s exact wording even if a future MariaDB point release changes it.
Ships as a plain Wait::for_log_message(r".*port: 3306.*mariadb\.org binary distribution.*", 1).
No with_memory_limit override needed — same InnoDB-footprint precedent as MySQL
8.4: boots clean on msb’s default ~450M microVM RAM (observed ~14.8s IT round-trip
on msb).
Complete example
use mysql_async::prelude::*;
use rightsize_modules::MariaDbContainer;
#[tokio::test]
async fn mariadb_round_trips_a_row() -> Result<(), Box<dyn std::error::Error>> {
let guard = MariaDbContainer::new().start().await?;
let pool = mysql_async::Pool::new(guard.connection_string().as_str());
let mut conn = pool.get_conn().await?;
conn.query_drop("CREATE TABLE smoke (id INT PRIMARY KEY, note TEXT)").await?;
conn.exec_drop(
"INSERT INTO smoke (id, note) VALUES (?, ?)",
(1i32, "hello-rightsize"),
).await?;
let note: Option<String> = conn
.exec_first("SELECT note FROM smoke WHERE id = ?", (1i32,))
.await?;
assert_eq!(note.as_deref(), Some("hello-rightsize"));
drop(conn);
pool.disconnect().await.ok();
guard.stop().await?;
Ok(())
}
Backend notes
No memory-limit override is needed on either backend — see Readiness above.
WireMockContainer
A WireMock server container for stubbing HTTP dependencies in integration tests. There is no first-class in-process WireMock story for Rust integration tests, so this module fills a real gap.
Default image: wiremock/wiremock:3.13.2
Guest port: 8080
| Method | On | Effect |
|---|---|---|
WireMockContainer::new() | builder | Pinned default image. |
WireMockContainer::with_image(image) | builder | Caller-chosen image. |
.start() | builder → Result<WireMockGuard> | Boots the container. |
.base_url() | guard | The stub server’s base URI — mount stubbed paths under this. |
.admin_url() | guard | The /__admin management API’s base URI (stub CRUD, request journal, health). |
.stop() | guard | Stops and removes the container, releases its port. |
Readiness — verified against a real 3.13.2 boot
WireMock 3.x ships a dedicated /__admin/health endpoint (unlike some older 2.x
builds, where /__admin/mappings was the only reliable 200). Verified directly
against a real container:
$ curl http://127.0.0.1:<port>/__admin/health
{"status":"healthy","message":"Wiremock is ok","version":"3.13.2","uptimeInSeconds":9,...}
so this module waits on that endpoint rather than falling back to
/__admin/mappings.
No with_memory_limit override was needed — the JVM boots comfortably on msb’s
default ~450M microVM RAM (observed ~5.5s IT round-trip on msb; a small
embedded-Jetty app, not a JVM-heavy cluster like Pinot).
Complete example
use rightsize_modules::WireMockContainer;
const STUB: &str = r#"{"request":{"method":"GET","urlPath":"/hello"},
"response":{"status":200,"body":"world"}}"#;
#[tokio::test]
async fn stub_mapping_round_trips() -> Result<(), Box<dyn std::error::Error>> {
let guard = WireMockContainer::new().start().await?;
let agent = ureq::Agent::new_with_defaults();
agent
.post(format!("{}/mappings", guard.admin_url()))
.header("Content-Type", "application/json")
.send(STUB)?;
let mut get = agent.get(format!("{}/hello", guard.base_url())).call()?;
let body = get.body_mut().read_to_string()?;
assert_eq!(body, "world");
guard.stop().await?;
Ok(())
}
Backend notes
No memory-limit override is needed on either backend — see Readiness above.
ClickHouseContainer
A single-node ClickHouse container, queried over its HTTP interface (port 8123).
The native protocol port (9000) is exposed too, but this module’s helpers are
HTTP-first: the HTTP interface needs no client dependency (ureq as a dev-dep, no
runtime crate), matching the house convention for HTTP-first modules.
Defaults to a test/test user/password pair and a test database so
http_url() plus basic auth is usable with zero configuration.
Default image: clickhouse/clickhouse-server:25.8
Guest ports: 8123 (HTTP — what the helpers use), 9000 (native protocol, exposed but not wrapped)
| Method | On | Effect |
|---|---|---|
ClickHouseContainer::new() | builder | Pinned default image, test/test/test. |
ClickHouseContainer::with_image(image) | builder | Caller-chosen image. |
.with_username(u) / .with_password(p) / .with_database(d) | builder | Override any of the trio before start(). |
.start() | builder → Result<ClickHouseGuard> | Boots the container. |
.username() / .password() / .database_name() | guard | The configured trio. |
.http_url() | guard | The HTTP interface’s base URI — POST a SQL body, basic-auth’d. |
.stop() | guard | Stops and removes the container, releases its ports. |
Env var names — verified against a real boot
CLICKHOUSE_USER / CLICKHOUSE_PASSWORD / CLICKHOUSE_DB are the image’s
documented names and were confirmed directly: booting with those three set produces
/entrypoint.sh: create new user 'test' instead 'default' and /entrypoint.sh: create database 'test' in the logs, and curl -u test:test against the HTTP
interface authenticates and runs queries successfully.
Readiness — verified against a real 25.8 (LTS) boot
GET /ping on the HTTP port returns the literal body Ok.\n as soon as the HTTP
server is accepting connections (protocol-aware — no restart/double-boot race like
the Postgres/MySQL/MariaDB entrypoints have), so for_http("/ping") at the default
200 status code is the correct and simplest readiness signal.
No with_memory_limit override was needed at default settings (observed ~12s IT
round-trip on msb, no memory-ladder escalation needed) — a single-node ClickHouse
server, unlike Pinot’s QuickStart cluster, is not a JVM process at all.
Complete example
use rightsize_modules::ClickHouseContainer;
#[tokio::test]
async fn create_insert_select_round_trips_over_http() -> Result<(), Box<dyn std::error::Error>> {
let guard = ClickHouseContainer::new().start().await?;
let agent = ureq::Agent::new_with_defaults();
let auth = format!("Basic {}", "dGVzdDp0ZXN0"); // base64("test:test") in the real test
let query = |sql: &'static str| {
agent.post(guard.http_url()).header("Authorization", &auth).send(sql)
};
query("CREATE TABLE t (x Int32) ENGINE=Memory")?;
query("INSERT INTO t VALUES (1)")?;
let mut select = query("SELECT x FROM t")?;
let body = select.body_mut().read_to_string()?;
assert_eq!(body.trim(), "1");
guard.stop().await?;
Ok(())
}
Backend notes
No memory-limit override is needed on either backend — see Readiness above.
KeycloakContainer
A single-node Keycloak container in start-dev mode (in-memory H2, no external
database — fine for tests, never for production).
Default image: quay.io/keycloak/keycloak:26.4
Guest ports: 8080 (HTTP / auth server), 9000 (management — health lives here, see below)
| Method | On | Effect |
|---|---|---|
KeycloakContainer::new() | builder | Pinned default image, admin/admin, with_memory_limit(1024). |
KeycloakContainer::with_image(image) | builder | Caller-chosen image. |
.with_admin_username(u) / .with_admin_password(p) | builder | Override either bootstrap admin credential before start(). |
.start() | builder → Result<KeycloakGuard> | Boots the container. |
.admin_username() / .admin_password() | guard | The configured bootstrap admin credentials. |
.auth_server_url() | guard | The auth server’s base URI (HTTP port) — realm/OIDC endpoints live under this. |
.management_url() | guard | The management interface’s base URI (health/metrics — port 9000, a different port than auth_server_url()). |
.stop() | guard | Stops and removes the container, releases its ports. |
Admin bootstrap env — 26.x renamed these, verified against the pinned tag
Keycloak 26.x replaced the old KEYCLOAK_ADMIN/KEYCLOAK_ADMIN_PASSWORD pair with
KC_BOOTSTRAP_ADMIN_USERNAME/KC_BOOTSTRAP_ADMIN_PASSWORD. Verified directly
against quay.io/keycloak/keycloak:26.4: booting with only the new names produces
KC-SERVICES0077: Created temporary admin user with username admin in the log; the
legacy names are not read by this image at all. This module sets only the new names.
Health lives on the management port (9000), not 8080
Captured verbatim from a real boot:
Listening on: http://0.0.0.0:8080. Management interface listening on http://0.0.0.0:9000.
With KC_HEALTH_ENABLED=true set, GET /health on port 9000 returns 200 OK
(body: literal OK); the same path on 8080 404s, and the commonly assumed
/health/ready sub-path 404s on this tag too — this image’s SmallRye Health root
aggregate is served bare at /health, not /health/ready — pinned to /health on
9000 accordingly.
Memory — Quarkus JVM, needed the ladder
Booted under msb’s default ~450M microVM RAM, the JVM is Killed (OOM) partway
through startup (captured: 'java' ... -XX:MaxRAMPercentage=70 ... Killed) — same
Paketo/Quarkus-on-microVM story as
SpringCloudConfigContainer. Retried with -m 1024M:
boots clean, /health reports 200 well within the startup timeout.
with_memory_limit(1024) is this module’s default.
Complete example
use rightsize_modules::KeycloakContainer;
#[tokio::test]
async fn oidc_discovery_document_asserts_the_issuer() -> Result<(), Box<dyn std::error::Error>> {
let guard = KeycloakContainer::new().start().await?;
let mut resp = ureq::Agent::new_with_defaults()
.get(format!(
"{}/realms/master/.well-known/openid-configuration",
guard.auth_server_url()
))
.call()?;
let body = resp.body_mut().read_to_string()?;
let expected = format!("\"issuer\":\"{}/realms/master\"", guard.auth_server_url());
assert!(body.contains(&expected));
guard.stop().await?;
Ok(())
}
Backend notes
with_memory_limit(1024) is set unconditionally by the module — see Memory above.
Neo4jContainer
A single-node Neo4j Community container, queried over its HTTP Cypher transaction
endpoint (/db/neo4j/tx/commit) — no bolt driver dependency needed, matching the
house convention for HTTP-first modules (ClickHouse,
Pinot). The bolt port (7687) is still exposed and its URI available
via bolt_url() for callers who do want a real driver.
Default image: neo4j:5-community
Guest ports: 7474 (HTTP), 7687 (bolt)
| Method | On | Effect |
|---|---|---|
Neo4jContainer::new() | builder | Pinned default image, neo4j/rightsize-test, with_memory_limit(1024). |
Neo4jContainer::with_image(image) | builder | Caller-chosen image. |
.with_password(p) | builder | Overrides the password half before start() (the image requires ≥8 characters). |
.start() | builder → Result<Neo4jGuard> | Boots the container. |
.username() | guard | The fixed admin username (neo4j — no env var to change it). |
.password() | guard | The configured admin password. |
.http_url() | guard | The HTTP interface’s base URI — Cypher transactions via POST {http_url}/db/neo4j/tx/commit. |
.bolt_url() | guard | The bolt interface’s URI, for callers using a real bolt driver. |
.stop() | guard | Stops and removes the container, releases its ports. |
There’s no with_username — the username is fixed by the image at neo4j.
Readiness — Started. is the exact log line, verified against a real boot
Captured verbatim from neo4j:5-community:
... INFO Bolt enabled on 0.0.0.0:7687.
... INFO HTTP enabled on 0.0.0.0:7474.
... INFO Remote interface available at http://localhost:7474/
... INFO Started.
Started. is logged only after both connectors are already listening, so it’s both
accurate and simpler than a two-port HTTP/bolt race.
Ships as .*Started\..* — a real regex, so the escaped dot means exactly what it
reads: a literal . right after “Started”, not a wildcard.
The image refuses short passwords
neo4j/neo4j is rejected at boot — passwords must be at least 8 characters — so
this module defaults to neo4j/rightsize-test, giving zero-configuration use of
http_url() plus basic auth. with_password still requires an 8+-character value.
Memory — measured, needed the ladder
At msb’s default ~450 MB microVM RAM, the server logs ERROR Invalid memory configuration - exceeds physical memory and shuts itself down cleanly (INFO Stopped.) rather than hanging or getting OOM-killed — Neo4j’s own
memory-recommendation calculator sizes the page cache and heap off total visible RAM
and refuses to start if the sums don’t fit. Retried with -m 1024M: boots clean.
with_memory_limit(1024) is this module’s default, same number as
KeycloakContainer.
Complete example
use rightsize_modules::Neo4jContainer;
#[tokio::test]
async fn create_then_match_round_trips() -> Result<(), Box<dyn std::error::Error>> {
let guard = Neo4jContainer::new().start().await?;
let agent = ureq::Agent::new_with_defaults();
let auth = format!("Basic {}", "bmVvNGo6cmlnaHRzaXplLXRlc3Q"); // base64 in the real test
let commit_url = format!("{}/db/neo4j/tx/commit", guard.http_url());
let commit = |statement: &str| {
let body = format!(r#"{{"statements":[{{"statement":"{statement}"}}]}}"#);
agent
.post(&commit_url)
.header("Content-Type", "application/json")
.header("Authorization", &auth)
.send(body)
};
let mut create = commit("CREATE (n:Test {name: 'hello'}) RETURN n.name AS name")?;
let create_body = create.body_mut().read_to_string()?;
assert!(create_body.contains("\"errors\":[]"));
let mut matched = commit("MATCH (n:Test {name: 'hello'}) RETURN n.name AS name")?;
let match_body = matched.body_mut().read_to_string()?;
assert!(match_body.contains("\"hello\""));
guard.stop().await?;
Ok(())
}
Backend notes
with_memory_limit(1024) is set unconditionally by the module — see Memory above.
FlociContainer
A floci.io cloud emulator — one native Quarkus image per cloud
provider, each speaking that provider’s REST APIs against an in-memory backing
store. One module type covers all three variants; pick one with the
FlociContainer::aws/azure/gcp factory functions rather than a bare
constructor — each factory pins the provider’s own image and guest port.
Default images: floci/floci:1.5.30 (AWS), floci/floci-az:0.8.0 (Azure), floci/floci-gcp:0.4.0 (GCP)
Guest ports: 4566 (AWS), 4577 (Azure), 4588 (GCP)
| Method | On | Effect |
|---|---|---|
FlociContainer::aws() / aws_with_image(image) | builder | The AWS emulator — S3, DynamoDB, SQS, etc. |
FlociContainer::azure() / azure_with_image(image) | builder | The Azure emulator. |
FlociContainer::gcp() / gcp_with_image(image) | builder | The GCP emulator. |
.start() | builder → Result<FlociGuard> | Boots the container. |
.endpoint_url() | guard | This variant’s REST endpoint — the base URI for every emulated API call. |
.stop() | guard | Stops and removes the container, releases its port. |
There’s no plain constructor to call directly — always go through one of the three factory functions, which pin the right image and guest port for that provider.
Readiness — /health works uniformly, unlike the AWS-flavored /_localstack/health
The AWS variant ships a LocalStack-compatible /_localstack/health endpoint, but
the Azure and GCP variants do not carry that path (Azure: 501; GCP: 404). All
three, however, answer a plain GET /health with 200 and a small JSON status body
the moment the embedded Quarkus HTTP listener is up — verified directly against real
boots of all three images. /health is pinned as the one wait path that works
across all three variants.
No signing needed — verified against the AWS variant’s S3 surface
The AWS variant’s S3-shaped REST endpoints accept unsigned requests with no
Authorization header at all: PUT /<bucket>, PUT /<bucket>/<key>, and GET /<bucket>/<key> all round-trip successfully with a bare HTTP client call — no
SigV4, no AWS SDK dependency required.
No with_memory_limit override is needed for any variant — all three images are
native (GraalVM) Quarkus binaries that settle at roughly 11-27 MiB RSS (docker stats, real boot).
Complete example
use rightsize_modules::FlociContainer;
#[tokio::test]
async fn aws_variant_s3_round_trips_with_no_signing() -> Result<(), Box<dyn std::error::Error>> {
let guard = FlociContainer::aws().start().await?;
let agent = ureq::Agent::new_with_defaults();
let endpoint = guard.endpoint_url();
agent.put(format!("{endpoint}/rightsize-test-bucket")).send_empty()?;
agent
.put(format!("{endpoint}/rightsize-test-bucket/hello.txt"))
.send("hello world")?;
let mut get_obj = agent
.get(format!("{endpoint}/rightsize-test-bucket/hello.txt"))
.call()?;
let body = get_obj.body_mut().read_to_string()?;
assert_eq!(body, "hello world");
guard.stop().await?;
Ok(())
}
Backend notes
No memory-limit override is needed on either backend for any variant — see Readiness above.
FlinkContainer
An Apache Flink JobManager, optionally paired with a companion TaskManager
via with_task_manager() for a real session cluster that can actually run jobs (a
bare JobManager has zero task slots and can only accept/reject submissions, never
execute them).
Default image: flink:1.20.5
Guest ports: 8081 (REST), 6123 (RPC — only meaningful once a TaskManager joins)
| Method | On | Effect |
|---|---|---|
FlinkContainer::new() | builder | Pinned default image, with_memory_limit(1024). |
FlinkContainer::with_image(image) | builder | Caller-chosen image. |
.with_task_manager() | builder → Result<Self> | Adds a companion TaskManager on a shared network for a real session cluster with task slots — docker only, see below. |
.start() | builder → Result<FlinkGuard> | Boots the JobManager (and TaskManager, if added). |
.rest_url() | guard | The JobManager REST base URI (/overview, /taskmanagers, job submission, etc). |
.stop() | guard | Stops and removes both containers (if a TaskManager was added), releases ports, closes the internally-created network. |
Topology
A real Flink session cluster is two processes bound by a persistent bidirectional
RPC connection (Pekko/Akka remoting), not a one-shot request/response: the
TaskManager dials the JobManager’s RPC port (6123) at boot and stays connected,
carrying heartbeats and slot offers/task deployments both ways for the cluster’s
whole lifetime. with_task_manager() puts both containers on one
internally-created Network, aliases the JobManager as jobmanager, and sets
FLINK_PROPERTIES=jobmanager.rpc.address: jobmanager on both containers — not
just the TaskManager. Verified directly: setting it on the TaskManager alone leaves
the JobManager’s own Pekko actor system bound under its container hostname rather
than the alias, so every registration attempt from the TaskManager gets silently
dropped as a non-local recipient. The JobManager must be told its own address is
the alias too.
Backend support — full on docker, JobManager-only on msb
with_task_manager() returns Ok(Self) on docker and is verified end-to-end there:
the TaskManager registers with the JobManager (Successful registration at resource manager ... in its own log) and GET /taskmanagers on the JobManager’s REST port
shows one slot-bearing TM within seconds of both containers starting.
On microsandbox, with_task_manager() returns
Err(RightsizeError::UnsupportedByBackend { .. }) before ever booting anything, and
the reason is more basic than a Pekko/tunnel incompatibility: msb’s network-link
emulation requires nc/busybox inside the consumer image to serve the tunnel’s
in-guest listener, and the official flink:1.20.5 image is a bare JRE + Flink
install with neither — the msb backend’s own nc prerequisite probe fails
immediately, before a single byte of Pekko traffic could be exchanged. Whether
Pekko’s persistent-connection RPC registration would work over the tunnel’s
single-connection-at-a-time model was never reached or tested — the missing
nc/busybox prerequisite stops the attempt before that question is even in play.
A bare JobManager works fine on both backends — it needs no network-link emulation
at all, just the ordinary published-port HTTP path against /overview — so this
module supports msb for JobManager-only use; only with_task_manager() is gated to
docker.
Memory — JVM, the ladder applies to both roles
A JobManager settles around ~310 MiB RSS and a TaskManager around ~375 MiB RSS at
rest on docker with no cap (docker stats, real boot) — both comfortably over
msb’s ~450 MB default individually, and this module runs the JobManager on msb
too (see above), so with_memory_limit(1024) is this module’s default for both
roles, matching the family’s established single-JVM floor
(KeycloakContainer, Neo4jContainer).
Complete example
A bare JobManager, which works on both backends:
use rightsize_modules::FlinkContainer;
#[tokio::test]
async fn bare_jobmanager_answers_rest_overview() -> Result<(), Box<dyn std::error::Error>> {
let guard = FlinkContainer::new().start().await?;
let mut resp = ureq::Agent::new_with_defaults()
.get(format!("{}/overview", guard.rest_url()))
.call()?;
let body = resp.body_mut().read_to_string()?;
assert!(body.contains("\"taskmanagers\""));
guard.stop().await?;
Ok(())
}
A full session cluster (docker only):
use std::time::Duration;
use rightsize_modules::FlinkContainer;
#[tokio::test]
async fn with_task_manager_registers_a_slot_bearing_taskmanager() -> Result<(), Box<dyn std::error::Error>> {
let guard = FlinkContainer::new().with_task_manager()?.start().await?;
let agent = ureq::Agent::new_with_defaults();
let taskmanagers_url = format!("{}/taskmanagers", guard.rest_url());
let deadline = std::time::Instant::now() + Duration::from_secs(60);
let mut body = String::new();
while std::time::Instant::now() < deadline {
if let Ok(mut resp) = agent.get(&taskmanagers_url).call() {
if resp.status().is_success() {
body = resp.body_mut().read_to_string()?;
if body.contains("\"id\"") {
break;
}
}
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
assert!(body.contains("\"id\""));
guard.stop().await?;
Ok(())
}
On microsandbox, with_task_manager() fails fast instead:
use rightsize::RightsizeError;
use rightsize_modules::FlinkContainer;
fn with_task_manager_is_rejected_on_microsandbox() {
let err = match FlinkContainer::new().with_task_manager() {
Ok(_) => panic!("with_task_manager() must be rejected on microsandbox"),
Err(e) => e,
};
assert!(matches!(err, RightsizeError::UnsupportedByBackend { .. }));
}
Backend notes
with_memory_limit(1024) is set unconditionally by the module for both the
JobManager and any TaskManager — see Memory above. with_task_manager() is
docker-only; run RIGHTSIZE_BACKEND=docker for session-cluster tests.
Troubleshooting
Every entry here traces back to an empirically observed failure mode — not a guess. Where relevant, the underlying fact is cited so you can find the exact mechanism in How It Works or the source.
“My exec() call hangs forever”
Cause: msb exec blocks until its stdin reaches EOF. Every child process
rightsize-rust spawns under the hood gets a closed/null stdin for exactly this reason
— but if you’re driving exec() with something that pipes in a live, never-closing
stream of your own, the underlying msb exec --stream call has no reason to ever
see EOF and simply never returns.
Fix: make sure whatever’s feeding stdin into an exec call closes cleanly.
This only bites you if you’re doing something unusual with a command’s stdin
directly — guard.exec(&["cmd", "arg"]) as shown throughout this book already
gives every spawned command a closed stdin and is not affected.
“The first connection through a network alias fails, but retries work”
Cause: on the microsandbox backend, a network-alias tunnel’s in-guest nc -l
listener serves exactly one connection, then gets respawned for the next. A
connection attempt that lands in the gap between “previous connection closed” and
“listener respawned” sees a connection refused/reset — a real, if narrow, timing
window, not a bug in your test.
Fix: for a client that only makes one request against an alias (config fetch,
health check), a short retry-on-connect-failure loop around the first call is the
practical fix. For a workload that needs a persistent, always-available connection
to a sibling container, this backend’s network emulation isn’t the right shape —
switch to RIGHTSIZE_BACKEND=docker, whose native bridge networking has no such
gap. See Networking.
“Container won’t boot on microsandbox but works fine on Docker”
Three distinct, independently observed causes share this symptom. Check them in this order:
1. Memory ceiling
Cause: microsandbox’s default microVM RAM is ~450 MB. A JVM (or otherwise memory-hungry) image whose fixed memory regions are computed above that either hangs indefinitely or gets silently OOM-killed by the kernel — the symptom looks like a readiness-wait timeout, not an out-of-memory error, because the process dies before it ever gets a chance to log anything useful.
Fix: check the image’s own baked heap/memory flags (JAVA_OPTS, -Xmx, etc.)
and set Container::with_memory_limit(megabytes) above that with real headroom —
not just enough to dodge the OOM killer, but enough that the workload isn’t running
at 99% of its ceiling under load (a passing readiness check that starts failing
requests under memory pressure is a real, previously-measured trap; see
Files & Resources for the
exact measured numbers from two shipped modules that hit this).
2. A baked env var with a control character
Cause: msb 0.6.2’s krun VMM builder panics with InvalidAscii if any boot-env
value contains a control character — and at least one official image
(postgres:*-alpine) bakes one into its own manifest (DOCKER_PG_LLVM_DEPS with a
literal tab). This happens before the guest ever boots, with zero rightsize-set env
vars involved — it’s the image, not your test.
Fix: override the offending variable to an empty string
(.with_env("VAR_NAME", "")) — a no-op for whatever the image’s build already
baked, and harmless on Docker. PostgresContainer ships this fix already; if a
different image hits InvalidAscii, inspect its baked env (docker inspect <image> and look at Config.Env) for anything with an embedded tab/control
character and apply the same override.
3. Read-only mounts you’re relying on
Cause: FileMount::read_only is advisory-only on microsandbox 0.6.2 — the
guest gets a writable mount regardless of the flag. If your test asserts the guest
cannot write to a mounted path, it’ll pass on Docker (which enforces it) and fail
on microsandbox (which doesn’t) — or vice versa if your test logic assumes
writability is blocked.
Fix: don’t write a test that depends on guest-side write protection while
targeting RIGHTSIZE_BACKEND=microsandbox. See
Backends.
“A no-nc image fails to join a network, citing Docker”
Cause: microsandbox’s network-alias emulation is implemented as a shelled-out
nc listener inside the consuming container’s guest. An image without nc/busybox
(a scratch-based image, or one that stripped it) can’t host that listener at all.
Fix: this is a fail-fast, not a flaky timeout — the error message already names
RIGHTSIZE_BACKEND=docker as the workaround. Either switch backends for this test,
or use an image that ships nc/busybox as the network-joining container.
“msb ls output looks fine but my code can’t parse it”
Cause: msb ls --format json returns a flat array of
{created_at,image,name,status} with status capitalized ("Running", not
"running") — and there is no plain --json flag, only --format json. If you’re
scripting around msb directly (rather than going through rightsize-rust), a
case-sensitive or key-order-dependent parser will misfire on real output.
Fix: this is only relevant if you’re shelling out to msb yourself outside of
rightsize-rust — the crate’s own parser (serde_json, deserializing into a struct with
no key-order assumption, tolerant of extra/missing fields) doesn’t have this problem.
Don’t hand-roll your own key-order- or case-sensitive parser against msb ls’s output
if you’re writing tooling of your own against msb.
“Registry pulls are rate-limited / a broker image won’t pull”
Cause: registries rate-limit
anonymous pulls, and msb’s image pulls are single-arch — a cold cache plus a
rate-limited registry can make the first RedpandaContainer boot in CI fail well
before any rightsize-rust-specific logic runs.
Fix: seed the image into the msb cache once, ahead of time:
docker save redpanda/redpanda:<tag> | msb load -t redpanda/redpanda:<tag>
“Sandboxes left behind after a crashed test run”
Cause: normal lifecycle (stop(), Drop’s cleanup thread) only runs while the
test process is alive. A SIGKILL, an OOM-kill, or a crashed CI step that tears
down the process itself skips all of that — see Orphan Reaping for
the full design. Two layers cover it: a per-run watchdog reaps within seconds of the
crash (default on), and an init-time sweep reaps a dead run’s leftovers the next time
any process resolves the same backend, even if the watchdog itself never fired.
Fix: if a sandbox is still visible right after a crash, give the watchdog a few
seconds, or just start a new process against the same RIGHTSIZE_CACHE_DIR and
backend — its own init-time sweep reaps the leftover on its way up. If sandboxes
accumulate persistently:
- Check
RIGHTSIZE_REAPERisn’t set tooff(or, if you only need the sweep and not a spawned watchdog process,sweepis the lighter middle ground). - A remote docker daemon (not the common one-VM-per-run case) has a real gap: a local watchdog cannot outlive the machine it runs on, so containers on a daemon that outlives the caller wait for the next process to reap them at its own init-time sweep — see the docker-remote caveat.
- A crashed run’s leftovers only get reaped by a process running the SAME backend that created them (a docker process cannot remove msb sandboxes, and vice versa) — if your workflow alternates backends across runs, the leftover waits for a process on its own backend.
“My wait strategy times out even though the service looks fine in the logs”
Cause: a bare TCP-connect readiness check (or even the built-in read-probe) is not the same as “the workload is ready to serve a real request.” A userland proxy/forwarder on either backend can accept a connection before the guest process is genuinely listening, and some workloads (Memcached, MongoDB before its replica set has a primary) have a real protocol-level gap between “accepting connections” and “actually ready.”
Fix: see Wait Strategies for the full
read-probe mechanism and when to reach past it — Wait::for_http,
Wait::for_log_message, or (following the
MemcachedContainer example) a custom WaitStrategy that
speaks the actual protocol.
“A log-line wait fires on the wrong line” (e.g. MySQL-style multi-boot images)
Cause: some entrypoints print their “ready” line more than once during a
completely normal boot (a temp-server pass, a sub-component’s own “ready” line that
happens to share a substring with the real one). Wait::for_log_message’s built-in
matcher is a plain substring/wildcard searcher with no anchors — a naive pattern can
match a decoy line before the real one appears.
Fix: see MySqlContainer’s worked example: it ships a
small custom WaitStrategy that anchors on the end of the port number
(port: 3306 immediately followed by end-of-line or a non-digit) rather than
stretching the shared substring matcher to do anchored matching it isn’t built for.
Capture a real boot log for your own image before writing the pattern — don’t guess
at what the “real” ready line looks like.
How It Works
Architecture, one paragraph: a Cargo workspace with four member crates.
rightsize (core) owns the public API — Container builder + RAII ContainerGuard,
Network, Wait strategies, FreePorts, RunId, the SandboxBackend trait +
BackendProvider registry, and the error enum — and depends on no backend.
rightsize-msb drives the pinned msb CLI as attached child processes, provisions
the toolchain from GitHub releases, and emulates networking with TCP-over-exec --stream tunnels. rightsize-docker is a from-scratch Docker HTTP client over
tokio::net::UnixStream and the correctness oracle the microVM backend is checked
against. rightsize-modules ships the preconfigured containers covered in
Modules. Host ports are pre-allocated core-side — backends
bind, never allocate — because msb only supports static host:guest maps; this
one invariant is what lets advertised-listener modules like
RedpandaContainer work in a single boot attempt instead of
a restart dance.
One trait, two runtimes
SandboxBackend is a small async_trait — create, start, stop, remove,
exec, logs, follow_logs, ensure_network, remove_network,
install_network_links, plus a synchronous cleanup_sync for the Drop-path
cleanup thread (see Containers & Guards).
Every method takes a &dyn SandboxHandle — an opaque per-container handle a backend
issues from create() and looks up its own mutable state by, rather than the core
ever needing to know what shape that state takes.
#[async_trait::async_trait]
pub trait SandboxBackend: Send + Sync {
fn name(&self) -> &str;
fn supports_native_networks(&self) -> bool;
async fn create(&self, spec: ContainerSpec) -> Result<Box<dyn SandboxHandle>>;
async fn start(&self, handle: &dyn SandboxHandle) -> Result<()>;
async fn stop(&self, handle: &dyn SandboxHandle) -> Result<()>;
async fn remove(&self, handle: &dyn SandboxHandle) -> Result<()>;
async fn exec(&self, handle: &dyn SandboxHandle, cmd: &[String]) -> Result<ExecResult>;
async fn logs(&self, handle: &dyn SandboxHandle) -> Result<String>;
async fn follow_logs(&self, handle: &dyn SandboxHandle, consumer: Box<dyn Fn(String) + Send + Sync>) -> Result<FollowHandle>;
async fn ensure_network(&self, network_id: &str) -> Result<()>;
async fn remove_network(&self, network_id: &str) -> Result<()>;
async fn install_network_links(&self, handle: &dyn SandboxHandle, links: &[NetworkLink]) -> Result<()>;
fn cleanup_sync(&self, container_id: &str);
}
A shared contract test suite (crates/rightsize-modules/tests/contract.rs) exercises
this trait’s observable behavior against both real backends — the Docker backend
serves as the correctness oracle the microVM backend is checked against, since Docker’s
behavior here is the well-understood baseline. Every test you write against
Container runs against whichever backend resolves at runtime, unchanged — that’s
the entire point of the trait boundary.
Ports before boot
ContainerSpec.ports arrives at a backend with host ports already chosen — see
ContainerSpec. This ordering is load
-bearing, not incidental: msb only supports a fixed host:guest port map declared
at launch, so a backend that tried to allocate its own port and report it back
after boot couldn’t support it at all. Docker could allocate its own ports (the
daemon supports it), but doing so consistently across both backends would mean two
different behaviors for the same builder call. Instead, Container::start()
allocates from a process-wide FreePorts pool before create() is ever called, and
every backend just binds what it’s given.
This is also what makes advertised-listener rewrites — the
RedpandaContainer/KafkaContainer
with_spec_customizer trick — a single-boot operation rather than a “boot, discover
the port, restart advertising the right thing” dance: the mapped host port is known
before create() runs, so the customizer can bake it into the boot command on the
first and only attempt.
The allocate-then-bind gap is a real, if narrow, race (something else grabs the port
between allocation and the backend’s own bind call) — Container::start() retries up
to 5 times with fresh ports specifically to absorb it; see
Containers & Guards.
Exec-tunnels: the microVM networking workaround
Showcased in full in Networking, the mechanism in
one paragraph: microsandbox microVMs share no bridge network with each other, and
exec --stream is the only guest data path available on this msb build — no
sandbox→host TCP under any net-rule tried, SSH forwarding found broken too. So a
network link between two containers is emulated as an /etc/hosts entry (the alias
resolves to 127.0.0.1 inside the consuming guest) plus a raw, unbuffered,
flush-per-read byte pump tunneled over exec --stream, backed by a respawned
nc -l listener in the target’s guest. The pump can’t rely on the proxied socket’s
TCP close propagating (msb’s port-publish proxy doesn’t forward it), so
end-of-exchange is inferred from an idle window that only starts counting after
the first byte arrives — scoping it to the whole connection instead would wrongly
truncate any target slower than the idle window to respond its first byte.
consumer guest --exec --stream--> msb host process --raw TCP--> target's published port
^ |
| nc -l (respawned per connection) |
+----------------------------------------------------------------------+
This is genuinely narrower than Docker’s native bridge networking — one connection
at a time, client-speaks-first only, nc/busybox required in the consumer image —
not a temporary limitation waiting to be lifted, but a real trade against “no
persistent daemon, hardware-isolated microVMs.” See
Backend differences for the complete list.
Binding decisions
- Tokio async-native, no async
Drop. The public surface isasync fnthroughout; cleanup is two-tier — explicitstop(), or a synchronousDropfallback via a dedicated cleanup thread — because Rust has no asyncDrop. Full story in Containers & Guards. - RAII guards only.
container.start().awaitreturns a guard; there is no test-framework extension layered on top. The shared-container pattern (JUnit@Containerstatic-scope equivalent) is a documented recipe usingtokio::sync::OnceCell, not API surface — see Getting Started. - Idiomatic Rust naming, not a mechanical port of a Testcontainers-style API —
snake_case, builder-stylewith_*methods,Container::new("image"). - Hand-rolled Docker client over
tokio::net::UnixStream— nobollard, nohyper— so this crate’s dependency tree can’t be the reason a Docker client gets misrouted onto TCP by an unrelated dependency bump elsewhere in a consumer’s tree. See Backends.
Where to look in the source
If you want to read the implementation directly rather than take this book’s word for it, start with these files:
| Concern | File |
|---|---|
Container/ContainerGuard, two-tier cleanup, port retry | crates/rightsize/src/container.rs |
| Wait strategies, the read-probe | crates/rightsize/src/wait.rs |
Network, alias resolution | crates/rightsize/src/network.rs |
| Backend resolution | crates/rightsize/src/backends.rs |
The Drop-path cleanup thread | crates/rightsize/src/cleanup.rs |
| msb provisioning | crates/rightsize-msb/src/provisioner.rs |
| Exec-tunnel networking | crates/rightsize-msb/src/exec_tunnel.rs |
| Docker unix-socket client | crates/rightsize-docker/src/client.rs |
| Shared contract suite (both backends) | crates/rightsize-modules/tests/contract.rs |
Roadmap
Ideas under consideration for future releases, roughly ordered by expected impact. Items graduate off this page when they ship; the CHANGELOG records what landed.
Native microVM memory snapshots
Filesystem-level checkpoint/restore ships on both backends now (see Checkpoint / Restore): docker via image commit, microsandbox via disk snapshots (stops the sandbox, snapshots its disk, and boots it back from that snapshot under the same name and ports) — boot once, seed once, restore per test instead of re-seeding. What’s left: true MEMORY snapshots, which would resume a sandbox mid-execution — a near-instant restore where a live process’s in-memory state (not just its filesystem) survives too. That still needs upstream microsandbox support.
Portable checkpoint archives
Named checkpoints make a
checkpoint rediscoverable across processes on the SAME machine, via the shared
rightsize cache directory’s registry. What’s missing is moving one to a different
machine or CI cache entirely: export/import via msb snapshot export/docker save (and their import/load counterparts), so a checkpoint seeded once in CI
can be shipped as a build artifact and restored on a fresh runner instead of
re-seeding there too.
Module breadth
The gaps Testcontainers users will hit first: LocalStack, Elasticsearch / OpenSearch, Vault, MinIO, NATS, Cassandra, MSSQL, Oracle Free, and Ollama (LLM-in-a-box testing, which also fits the isolation story).
Framework integrations
One-annotation setup in the frameworks people actually use: Spring Boot
@ServiceConnection-style wiring, Quarkus Dev Services, a pytest-style
fixture story, Vitest/Jest global-setup helpers, Axum/sqlx examples.
Building images from code
Define an ad-hoc image inline in the test (Dockerfile-from-code) instead of publishing one — for testing your own service, not just its dependencies.
Host-directory mounts
Start-time host-directory binds (with_copy_file_to_container currently mounts
individual files) alongside the existing copy-in. Runtime copy-out already shipped
— see Copying Files.
Declarative multi-service groups
A rightsize-native way to declare “these five services, this network, this startup order” as one artifact, serving the docker-compose need without the compose file format.
Warm pools
A background pool of pre-booted sandboxes so start() is near-instant —
paired with reuse, this attacks time-to-first-test directly.
Fault injection
The backend controls the virtual NIC: latency, packet loss, partitions between sandboxes, kill-and-revive — first-class API instead of a separate Toxiproxy container.
Time control
A VM owns its clock: advance time inside the guest to test TTLs, certificate expiry, and cron logic faithfully — awkward to impossible on a shared kernel.
Private registry authentication
Pulling from authenticated registries, documented and tested — table stakes for enterprise evaluation.