Prepare an EC2 bare metal instance to run dense Firecracker microVM workloads, then verify it is ready.
| Script | Purpose | Root | Idempotent |
|---|---|---|---|
provision-host.sh |
Install Firecracker and jailer, apply host tuning and security settings | yes | yes |
build-guest-kernel.sh |
Build an uncompressed vmlinux guest kernel |
yes | yes |
build-rootfs.sh |
Build a minimal ext4 rootfs with the guest agent as /init | yes | yes |
launch-lab-host.sh |
Launch a metal instance for a sweep, stock or from a baked AMI | no (needs AWS creds) | n/a |
bake-lab-ami.sh |
Capture a provisioned host as a reusable AMI | no (needs AWS creds) | n/a |
fetch-swe-agent-traj.sh |
Fetch the pinned SWE-agent trajectory the episode campaign replayed | no | yes |
toggle-smt.sh |
Toggle SMT on a running host and verify it took effect | yes (to change) | status needs none |
validate-host.sh |
Verify the host is ready. Read-only | no | n/a |
lib/common.sh |
Shared helpers and pinned versions. Sourced, not executed | n/a | n/a |
sudo ./provision-host.sh # install and tune
sudo ./build-guest-kernel.sh # build /opt/firecracker/vmlinux
sudo reboot # apply kernel args and module parameters
./validate-host.sh # confirm readinessPreview any change without applying it:
./provision-host.sh --dry-run # no root requiredThe sequence above is ~40 minutes of work on a host that bills the same whether it is compiling or measuring, and every session before 2026-08-07 paid for it again. Capture it instead:
./bake-lab-ami.sh --profile <p> --instance-id i-abc123 --dry-run # check what would be captured
./bake-lab-ami.sh --profile <p> --instance-id i-abc123Then subsequent sessions launch a host that is ready on arrival, with no provisioning and no reboot:
./launch-lab-host.sh --profile <p> --instance-type m8a.metal-48xl --golden--golden resolves the newest available AMI tagged LabAmi=true whose BakedFrom tag
matches the requested instance type, so a saved command line keeps working after a rebake.
--ami <id> pins a specific one.
Two things to know:
- One image per SKU. The filter matches on
BakedFromrather than just the lab tag, because m8a and m8i differ in vendor, SMT capability, and NUMA shape. An m8i image booted on m8a would come up looking healthy and measure the wrong machine. - Rebake when a pinned version moves.
FIRECRACKER_VERSIONandGUEST_KERNEL_VERSIONare recorded as tags on the image. A Firecracker change invalidates snapshots fleet-wide, so the tag is what makes a stale image identifiable without booting it.
bake-lab-ami.sh runs validate-host.sh over SSM first and refuses to capture a host that
fails, because an AMI baked from a half-provisioned host is indistinguishable from a good one
until a sweep produces wrong numbers. Warnings are allowed through with a notice — they will
appear on every instance launched from the image.
The image carries the guest artifacts too (vmlinux, rootfs.ext4, and any converted task
rootfs images), which is what removes the repeated Docker conversions. Firecracker snapshots
are deliberately not part of this: they only resume on identical hardware and Firecracker
version, so see the snapshot portability section below.
The task files and the recorded trajectory are upstream's artifacts and are deliberately not committed here — the Terminal-Bench tasks carry the benchmark's canary string, which exists so benchmark data can be detected in training corpora. They are fetched at a pinned revision instead, which keeps a conversion reproducible without putting that data in one more place.
| Input | Fetched by | Pin |
|---|---|---|
| Terminal-Bench task dirs | fetch-tbench-task.sh |
harbor-framework/terminal-bench-1 @ d28711d0 |
| SWE-agent trajectory | fetch-swe-agent-traj.sh |
SWE-agent/SWE-agent @ 3ea751c0 |
| SWE-bench eval image | docker-to-rootfs.sh --image |
digest in analysis/TASK-THROUGHPUT-RESULTS.md |
Each pin exists because upstream moved under this project at least once:
- Terminal-Bench split into two repos.
laude-institute/terminal-benchredirects toharbor-framework/terminal-bench-1, which holdsoriginal-tasks/. The similarly namedharbor-framework/terminal-benchis the successor benchmark and has nooriginal-tasks/— aiming at it returns 404 for every task. It also removedhello-worldon 2026-07-21, so the measured task now exists only at the pinned revision. - Eight trajectories share one instance ID. Upstream ships one demonstration per agent
configuration for
marshmallow-code__marshmallow-1867, and they are not interchangeable: four use aneditsyntaxtraj-to-episode.pydoes not read, three convert to 11 turns, and one converts to the 14 the campaign measured.fetch-swe-agent-traj.shre-checks that turn count after fetching and fails if it moved, because a silently-revised demonstration would otherwise surface as a wrong number rather than an error. :lateston the SWE-bench image is mutable and has already moved once, so the digest is recorded alongside the tag.
The split is not arbitrary. Anything that requires a reboot goes into the image, so that a newly launched instance is immediately usable rather than needing a boot-configure-reboot cycle.
Baked in (needs a reboot):
- Host kernel command line:
quiet loglevel=1. Console logging measurably slows operations; upstream measured snapshot restore going from 3ms to 8.5ms with console logging enabled. - KVM module parameters:
nx_huge_pages=neverwhere safe, andmin_timer_period_usto boundkvm-pitoverhead. - Swap disabled, KSM disabled.
- File descriptor and PID limits raised for 150 or more VMM processes.
Left to the host agent at runtime (cannot be baked):
- NUMA topology discovery. Actual NPS mode on AMD or SNC configuration on Intel is not knowable until a process is running on the target hardware.
- cpuset allocation and partition setup. Depends on discovered topology. Note that
cpuset.cpus.partitionis owned by the parent cgroup and is not delegatable, so the agent must write it from the parent. The jailer's--cgroupflag cannot set it. - SMT configuration. Runtime-togglable through
/sys/devices/system/cpu/smt/control, so it is a policy decision rather than an image property. kvm-pitthread placement. The kernel creates these threads after guest start, by which point Firecracker has dropped privileges, so an external agent must move them into each microVM's cgroup.- Warm pool prefill. Depends on core count and configured pool size.
Set in lib/common.sh and overridable by environment variable:
| Variable | Default | Notes |
|---|---|---|
FIRECRACKER_VERSION |
v1.16.1 |
Changing this invalidates every existing snapshot. Snapshots only resume on the Firecracker version that produced them |
GUEST_KERNEL_VERSION |
6.18 |
Supported from Firecracker v1.16.1 through 2028-06-01. Guest v6.1 support ends 2026-09-02 |
INSTALL_DIR |
/usr/local/bin |
Where firecracker and jailer are installed |
ARTIFACT_DIR |
/opt/firecracker |
Guest kernel and related artifacts |
JAILER_CHROOT_BASE |
/srv/jailer |
Must not be writable by unprivileged users |
The Firecracker download is checksum-verified against the published
.sha256.txt before it is unpacked.
Snapshots are less portable than is commonly assumed. Firecracker requires that a
snapshot resume on a hardware and software configuration identical to the one that
produced it, and this is stricter than "same CPU vendor": a snapshot taken on
m6i.metal cannot restore on m5n.metal, because different microarchitectures expose
different CPUID features.
Practical consequences:
- Build snapshots per instance type.
m8iandm8aneed separate sets. - The Firecracker version must also match, so upgrading Firecracker means rebuilding snapshots fleet-wide rather than performing a rolling binary swap.
- This is why
bake-lab-ami.shcaptures guest artifacts but not snapshots. A rootfs and avmlinuxare portable across SKUs; a snapshot is not, so baking one into a shared image would produce a restore failure at density rather than at launch.
EC2 CpuOptions do not work on bare metal. RunInstances returns
UnsupportedOperation: The m8i.metal-48xl instance type does not support specifying CpuOptions,
and describe-instance-types shows ValidThreadsPerCore as null for metal SKUs against [1,2]
for the virtualized m8i.48xlarge. CpuOptions is a Nitro hypervisor feature, and metal has no
hypervisor to enforce a thread count.
On metal, the kernel is the only mechanism, and it works on a running host:
./launch-lab-host.sh --profile <p> # one host, as launched: 192 vCPUs
labsweep -run-label smt-on -requested-threads-per-core 2 ...
sudo ./toggle-smt.sh off # 96 online CPUs, smt/active=0
labsweep -run-label smt-off -requested-threads-per-core 1 ...One host measuring both states is better than two hosts, because an SMT difference cannot be
host variance. toggle-smt.sh reads the state back after writing and fails if the kernel refused
the transition, since a sweep labelled smt-off that silently ran with SMT on would be worse than
no data. It also handles the two states that are not toggles: notsupported on AMD metal, where
there is nothing to disable, and forceoff from a nosmt boot argument, which cannot be undone
without a reboot.
Caveat worth stating in any write-up: offlining siblings at runtime is not identical to never having booted with them. The kernel rebuilds scheduler domains, but memory was allocated and interrupts routed while all CPUs were present.
Pass -run-label and -requested-threads-per-core to labsweep either way. That makes each
dataset self-describing and lets the sweep report a mismatch between the requested and observed
thread count as a leading caveat.
| Instance | vCPUs | Physical cores | Threads per core | SMT tuning |
|---|---|---|---|---|
m8i.metal-48xl |
192 | 96 | 2 | Applies |
m8a.metal-48xl |
192 | 192 | 1 | Not applicable |
On m8a metal, validate-host.sh reports SMT as notsupported. That is expected, not
a failure: every vCPU is a full physical core, so there is no SMT to configure and no
hyperthread-sibling side channel to mitigate.
validate-host.sh distinguishes between conditions that block work and conditions
worth noting, so it can be used in automation:
| Code | Meaning |
|---|---|
| 0 | All checks passed |
| 1 | One or more required checks failed. Host is not usable |
| 2 | Passed with warnings. Host is usable, but review before recording measurements |
Verified end to end on an m8i.metal-48xl running Amazon Linux 2023 (kernel
6.1.176-223.369.amzn2023) on 2026-07-30: provision, guest kernel build, reboot, and
validate-host.sh returning 0 with no warnings.
Verified on both m8a.metal-48xl and m8i.metal-48xl on 2026-08-07, each provisioned by
setup-lab-host.sh from stock Amazon Linux 2023: 21 provisioning checks, reboot, and
validate-host.sh returning 0 with no warnings on each. Both were then captured by
bake-lab-ami.sh, so that path is now proven against hardware too.
Not yet verified on a Debian or Ubuntu host.
Measured on that host, for reference:
| Step | Result |
|---|---|
| Guest kernel v6.18 build | 42 seconds wall clock, 22m38s CPU across 192 jobs, 27 MB vmlinux |
| NUMA topology | 3 nodes on a single socket (32 physical cores each). Whether this reflects compute tiles or Sub-NUMA Clustering is not yet established |
itlb_multihit |
Not affected, so nx_huge_pages=never is applied |
Behaviour worth knowing about on Amazon Linux 2023, each of which caused a real failure during validation:
curlmust not be installed by name. AL2023 shipscurl-minimal, which already provides/usr/bin/curl. Requesting thecurlpackage triggers an unresolvable conflict and aborts the whole transaction.nftablesis not preinstalled. Without it the IMDS drop rule cannot be applied, soprovision-host.shinstalls it and treats a missingnftas fatal rather than producing an AMI whose guests can reach instance metadata.- KSM is not compiled in (
CONFIG_KSMunset), so/sys/kernel/mm/ksmis absent. This is the desired state, not a problem: KSM cannot be enabled. limits.ddoes not apply to systemd services. AL2023 setsDefaultLimitNOFILE=65535, which would cap the host agent regardless of/etc/security/limits.d. Systemd drop-ins are written for this reason.
provision-host.shdoes not configure network or block device rate limiters. Those are per-microVM settings applied by the host agent at VM creation.- Hugepage reservation is not configured, and the sweep does not record the host's page-size
state. Guests are 4 KiB-backed as long as transparent hugepages are not
always, which holds on the Amazon Linux 2023 default (madvise). Check/sys/kernel/mm/transparent_hugepage/enabledbefore comparing results from another host image. - Not tested on a Debian or Ubuntu host.
setup-lab-host.shinstalls dependencies withdnf, so it needs an apt path before it would run on either.