Ephemeral Firecracker microVM runners for GitHub Actions. Every job runs in a fresh, single-use microVM that registers a just-in-time (JIT) ephemeral runner, executes exactly one job, and then self- destructs.
Built for maximum control and a minimal dependency surface: firerunner talks
to the Firecracker REST API directly over its unix socket using only the Go
standard library — no containerd, no CNI, no LVM, no VM-management daemon. Its
only external dependency is GitHub's official runner scale-set client
(github.com/actions/scaleset), which drives the long-poll control plane.
Status: early, but validated end-to-end on real hardware. The full path — GitHub scale-set long-poll, per-VM networking, the nftables egress allowlist, off-VM log shipping, golden-image build, and microVM boot → MMDS-JIT → self-destruct — has been exercised on a KVM host (Firecracker v1.10.1). Running a live production fleet additionally needs a golden image and a GitHub App (see below).
Self-hosted runners execute untrusted third-party code (dependencies, actions, fork PRs). Containers share the host kernel, so a container escape reaches the host — unacceptable when the host holds credentials. A Firecracker microVM has its own kernel and hardware-enforced (KVM) isolation, at ~1–2% overhead versus bare metal. See GitHub's guidance on self-hosted runner security.
┌──────────────────── firerunner (single Go binary) ─────────────────┐
GitHub ◀──────▶ │ listener long-poll GitHub → desired runner count │
(scaleset API) │ scheduler reconcile running microVMs to desired, ≤ maxRunners│
│ provisioner per job: reflink golden.ext4 → tap+nft → MMDS(JIT) │
│ → firecracker InstanceStart → wait exit → reap │
└────────────────────────────────────────────────────────────────────┘
│ one microVM per job
▼
┌──────── Firecracker microVM (ephemeral) ────────┐
│ actions/runner --jitconfig → run ONE job │
│ then `reboot -f` → VMM exits → host reaps it │
└─────────────────────────────────────────────────┘
The guest self-destructs with reboot -f (not poweroff): with reboot=k on
the kernel command line this triggers an i8042 reset that makes the Firecracker
VMM process exit, which the host detects to reap the job.
Each microVM gets its own tap device on a dedicated /30 subnet
(172.16.<slot>.0/30; host gateway .1, guest .2), so many VMs run in
parallel without collisions. The guest address, gateway and netmask are handed
to the kernel via the ip= boot argument (no DHCP needed). Slot allocation is
bounded by --max-runners.
Egress is controlled by an nftables allowlist (in a dedicated firerunner
table). Instead of a blanket masquerade, the host enables IPv4 forwarding and
only forwards guest traffic destined for GitHub's own IP ranges — fetched from
api.github.com/meta
and refreshed periodically (--meta-refresh, default 24h) — plus optional DNS
and NTP. Everything else from 172.16.0.0/16 is dropped, then the allowed
traffic is masqueraded out the external interface (--ext-iface). Configure the
allowlist with --egress (default api,actions,git,dns,packages,ntp); the
GitHub categories are api, actions, git, packages, and dns/ntp are
pseudo-categories. Pass --egress open to disable the allowlist and fall back to
blanket NAT.
The same table also protects the host itself: an input chain drops
guest→host traffic (so a guest cannot reach host-local services), allowing only
the optional cache-server port on its gateway; interface-keyed anti-spoof
drops any guest packet whose source is outside the VM range; and a companion
ip6 table black-holes all guest-originated IPv6 (guests are IPv4-only). All of
these are scoped to firerunner's own tap/veth interfaces, so the host's other
traffic is untouched. This protection applies even in --egress open mode.
Because the microVM is destroyed after its one job, its serial console (which
carries the runner/job output) is streamed to a per-runner file under
--log-dir on the host — off-VM log forwarding, as GitHub recommends for
ephemeral runners.
- Ephemeral / JIT runners — one job per runner, auto-deregistered (GitHub's recommended model for autoscaling).
- Official runner agent — uses
actions/runnerinside the golden image. - Official scaling API — built on
github.com/actions/scaleset(reliable long-poll; GitHub warns webhook-based scaling is less reliable). - Least-privilege auth — GitHub App preferred over PAT.
- Clean environment per job — reflink-cloned rootfs, destroyed after use.
- Per-VM network isolation + egress allowlist — dedicated tap/subnet per microVM; guests may reach only GitHub's published IP ranges (plus DNS/NTP).
- External log forwarding — serial console shipped off-VM to
--log-dir. - Golden-image rebuild pipeline — images rebuilt on a schedule so the
bundled
actions/runnerstays within GitHub's ≤30-day support window (seeimages/).
- Linux bare-metal host with KVM (
/dev/kvm). firecrackerbinary, a guest kernel (vmlinux), and a golden rootfs image withactions/runner+ a JIT-reading boot service pre-installed (seeimages/for the build tooling and rebuild policy).- A reflink-capable filesystem (btrfs or XFS) for the work directory.
iproute2(ip) andnftables(nft) on the host;CAP_NET_ADMINand the ability to setnet.ipv4.ip_forward.
make build # build the binary
make test # unit tests with -race
make cover-check # unit coverage with a minimum threshold (COVER_MIN)
make e2e # end-to-end tests (requires a real KVM host; -tags e2e)Tests follow a functional-core / imperative-shell split: pure logic (the
Firecracker API sequence, the scale-up plan, config parsing) is unit-tested to
near-100%, host I/O is exercised through injected seams (a commandRunner fake
and an httptest server over a unix socket), and real microVM boots live in
test/e2e behind the e2e build tag. CI (.github/workflows/ci.yml) runs
build, vet and the coverage gate on every push and PR.
firerunner \
--url https://github.com/ORG/REPO \
--name firerunner \
--max-runners 4 --vcpu 4 --mem-mib 8192 \
--kernel /var/lib/firerunner/vmlinux \
--golden /var/lib/firerunner/golden.ext4 \
--ext-iface enp2s0 --log-dir /var/log/firerunner \
--egress api,actions,git,dns,packages,ntp --meta-refresh 24h \
--app-client-id ... --app-installation-id ... --app-private-key /path/key.pemAll flags can also be set via FR_* environment variables (see
config.example.env).
Subcommands: firerunner status / doctor inspect a deployment (see below),
firerunner cache-server runs the self-hosted dependency
cache, and firerunner version / help do the obvious.
firerunner registers runners through the Actions runner scale-set API. A GitHub App is the preferred credential (short-lived installation tokens, no personal account tied to the runners). Set one up once per org (or repo):
-
Create the App — Settings → Developer settings → GitHub Apps → New GitHub App. A homepage URL and a name are all that's required; you can disable the webhook. Grant the least-privilege permission for the scope you register at:
FR_URLscopeRequired permission https://github.com/ORG(org-wide)Organization → Self-hosted runners: Read & write https://github.com/ORG/REPO(single repo)Repository → Administration: Read & write -
Generate a private key — on the App page, Generate a private key. This downloads a
.pem. Install it where only thefirerunneruser can read it —installsets the mode directly instead of inheriting your umask like a plaincpwould:sudo install -d -m0750 -o root -g firerunner /etc/firerunner sudo install -m0600 -o firerunner -g firerunner \ ~/Downloads/your-app.*.private-key.pem /etc/firerunner/app-key.pem
-
Install the App — Install App → choose the org/account and grant it access to the repositories that will use the runners (or All repositories for org-wide use).
-
Collect the three identifiers and map them to config:
Where to find it Flag Env App page → Client ID --app-client-idFR_APP_CLIENT_IDInstalled App URL .../installations/<id>(or Install App → gear)--app-installation-idFR_APP_INSTALLATION_IDThe downloaded .pempath--app-private-keyFR_APP_PRIVATE_KEY -
Create a runner group (optional but recommended) under the org's Actions → Runner groups, restrict it to the intended repositories, and pass its name via
--runner-group/FR_RUNNER_GROUP(defaults todefault).
With the App configured, point FR_URL at the org or repo and start firerunner
(see Running as a service). A PAT
(FR_TOKEN=ghp_...) works too but is discouraged for production.
A single firerunner process can serve several tiers — each its own GitHub scale set with its own microVM shape and golden image — so developers pick the runner they need straight from the workflow:
jobs:
build: { runs-on: firerunner } # default tier
heavy: { runs-on: firerunner-8c16g } # 8 vCPU / 16 GiB
web: { runs-on: firerunner-node } # Node-baked golden image
compat: { runs-on: firerunner-ubuntu } # faithful ubuntu-latest full imagePoint --tiers (or FR_TIERS) at a JSON catalog:
[
{ "name": "firerunner", "vcpu": 2, "mem_mib": 4096, "golden": "/var/lib/firerunner/golden.ext4", "min": 1, "max": 8 },
{ "name": "firerunner-8c16g", "vcpu": 8, "mem_mib": 16384, "golden": "/var/lib/firerunner/golden.ext4", "min": 0, "max": 2 },
{ "name": "firerunner-node", "vcpu": 2, "mem_mib": 4096, "golden": "/var/lib/firerunner/golden-node.ext4", "min": 0, "max": 4 },
{ "name": "firerunner-ubuntu","vcpu": 4, "mem_mib": 8192, "golden": "/var/lib/firerunner/ubuntu-rootfs-full.ext4","min": 0, "max": 2 },
{ "name": "firerunner-ubuntu-min","vcpu": 4, "mem_mib": 8192, "golden": "/var/lib/firerunner/ubuntu-rootfs-minimal.ext4","toolcache": "/var/lib/firerunner/toolcache.ext4","min": 0, "max": 2 }
]See examples/tiers.json. All tiers share the host
kernel, network and — importantly — the --max-runners slot budget:
vcpu/mem_mib/golden vary per tier, but the total number of
concurrent microVMs across every tier is capped at --max-runners (the tiers'
warm pools, min, must fit within it, and no single tier's max may exceed it).
When --tiers is unset, firerunner
runs the single tier derived from --name/--vcpu/--mem-mib/--golden, so
existing deployments are unchanged.
The golden image is operator-scoped by design: a repo picks a tier from the trusted catalog you publish, but never injects its own rootfs. Define new tiers by building an image (see
images/) and adding an entry.
Each tier is reachable with runs-on: <name>. An optional "labels" array
adds extra runs-on aliases for a tier (the tier name is always advertised
too), e.g. "labels": ["node"] lets a job target the tier with either
runs-on: firerunner-node or runs-on: node.
The firerunner-ubuntu tier above points at the faithful ubuntu-latest
full image — an Ubuntu 24.04 (glibc 2.39) rootfs with the hosted tool cache
and the runner-images installer subset baked in (built by
images/build-ubuntu-rootfs.sh --toolset full).
Use it for jobs that need the closest parity with GitHub-hosted ubuntu-latest;
the lean Debian firerunner tier stays the default for everything else.
For Ubuntu glibc parity without the full image's bulk, the
firerunner-ubuntu-min tier above points at a
--toolset minimal golden: a thin Ubuntu 24.04
base (runner + git + build-essential + system python3 + Node.js runtime, no
Docker or baked language toolchains, ~1.6 GiB on disk) meant to be paired with
a --toolcache drive so setup-*
actions resolve languages on demand. Give it a drive with the tier's own
"toolcache" field (as above) — a per-tier binding that overrides the
process-wide --toolcache/FR_TOOLCACHE default, so a lean tier can attach a
drive while a full tier keeps its baked-in cache. It is ideal for the common
build/test/lint/SAST jobs that never touch Docker; Docker jobs stay on
firerunner-8c16g-docker or the full firerunner-ubuntu tier. Measured on a
real CodeQL run it matches the full image's speed at a fraction of the size
(CodeQL bundle served from the drive, no per-run download).
Two read-only subcommands help operators inspect and diagnose a runner host.
Both read the same flags / FR_* environment as the daemon, so run them with
the same environment as the service (e.g. via the systemd EnvironmentFile):
firerunner status # config, kernel/golden images, network wiring, live microVMs
firerunner doctor # preflight health checks; exits non-zero if any FAILPass --json to either subcommand for machine-readable output (dashboards,
monitoring, or scripting a deploy gate):
firerunner status --json | jq .microvms.active
firerunner doctor --json | jq -r '.checks[] | select(.level=="FAIL") | .name'status prints the resolved scale-set config, the golden/kernel/tool-cache
images (path, size, mtime), the network wiring, and the microVMs currently on
the host (cross-checked against firecracker processes and tap devices).
doctor runs a checklist — /dev/kvm, the firecracker/jailer binaries, the
kernel and golden images, the egress interface, ip_forward, nftables, a
writable and reflink-capable work dir, GitHub auth (presence plus a live
read-only credential check), and API reachability (against the derived API base,
so GitHub Enterprise Server works too) — and prints one PASS/WARN/FAIL
line each. It exits non-zero when any check fails, so it can gate a deploy.
Every job runs in a fresh microVM, so by default a runner is cold-booted only once a job is queued. The microVM itself boots in well under a second, but the GitHub runner agent still has to start and open its session to GitHub before it can accept work — so the first job after an idle period waits for that one-time connect.
Set --min-runners N (env FR_MIN_RUNNERS) to keep N microVMs pre-booted and
already registered (Listening for Jobs). A queued job is then handed to a warm
runner immediately, and firerunner launches a replacement to refill the pool.
This trades idle capacity for lower pickup latency:
--min-runners |
Pickup | Idle cost |
|---|---|---|
0 (default) |
cold boot + runner connect per job | none |
1+ |
job handed to a waiting runner | N × (--vcpu / --mem-mib) held idle |
Warm runners are still single-use and ephemeral: a pre-booted VM that has not
run a job is discarded like any other once it does. Size the pool to your peak
concurrency — --max-runners still caps the total.
On GitHub-hosted ubuntu-latest, setup-go / setup-node / setup-python
etc. are fast because the image ships a hosted tool cache
(/opt/hostedtoolcache, RUNNER_TOOL_CACHE) pre-populated with common language
versions: the action just finds the version and adds it to PATH instead of
downloading it. A stock golden has no such cache, so every setup-* pays the
download tax (a Go toolchain is ~5–9 s).
--toolcache <image> (env FR_TOOLCACHE) closes that gap without changing any
workflow YAML. Point it at a read-only ext4 image that mirrors the hosted
tool-cache layout and is labelled hostedtoolcache:
/ # ext4 labelled "hostedtoolcache"
└── go/
└── 1.26.7/
├── x64/ # the extracted toolchain
└── x64.complete
firerunner attaches it to every microVM as a shared read-only drive (one
host image backs all VMs at once), and the golden mounts it by label at
/opt/hostedtoolcache as an overlay lower layer under a per-VM tmpfs upper,
exporting RUNNER_TOOL_CACHE. setup-* reads pre-seeded versions from the
drive (cache hit); when it needs a version the drive lacks, it downloads and
installs into the writable upper instead of failing on the read-only drive
(cache miss). The upper is tmpfs, so it is discarded with the VM:
firerunner ... --toolcache /var/lib/firerunner/toolcache-go.ext4--toolcache/FR_TOOLCACHE is the process-wide default, applied to every
tier. A tier can override it with its own "toolcache" field in the
tier catalog — so a lean tier
attaches a drive while a full tier keeps its baked-in cache (empty falls back to
the global default, or no cache when neither is set).
Design properties:
- Zero workflow change. Only
runs-on:differs fromubuntu-latest; thesetup-go@… with: go-version-file: go.modstep is untouched. - Pure accelerator, always safe. When the image is absent, or a requested
toolchain/version is not in it,
setup-*falls back to downloading — jobs still pass, just slower. A cache miss copy-up lands on the per-VM rootfs (disk-backed and discarded with the VM), not in guest RAM. The same golden works with or without the drive. - Opt-in, no bloat. Include only the toolchains a project actually uses
(derive them from its
go.mod/setup-*steps); no kitchen-sink image. - Measured on the node tier:
setup-goforgo 1.26dropped from ~6 s (download) to ~0 s (cache hit) with the drive attached.
Under --jailer the image is staged read-only into each jail (reflink keeps
this near-free on a reflink-capable filesystem); otherwise the host path is
opened directly.
Building the drive. images/build-toolcache.sh
cuts a hostedtoolcache-labelled ext4 with exactly the tools and versions you
name — no OS rebuild, decoupled from the golden, so you can re-cut the cache
(new versions, more tools) and share one drive across every tier:
# Node + Go need only curl/tar/mkfs.ext4 (no Docker); many versions per tool:
sudo images/build-toolcache.sh --out /var/lib/firerunner/toolcache.ext4 \
--node 20.18.0,22.22.2 --go 1.27.0
# Python is fetched from actions/python-versions and relocated in an
# ubuntu:24.04 container (so its layout matches the guest) -> needs Docker:
sudo images/build-toolcache.sh --out toolcache.ext4 --python 3.12
# CodeQL bundle (accelerates github/codeql-action / SAST); `latest` tracks the
# version the newest codeql-action pins, or pass a CLI version to pin it:
sudo images/build-toolcache.sh --out toolcache.ext4 --codeql latestTailor it to a team: scan the repos you serve for their setup-* steps
(go-version-file: go.mod, .nvmrc, .python-version) and bake only those
versions.
actions/cache (and everything built on it — setup-node's cache: pnpm,
setup-go's build cache, actions/setup-python's pip cache, etc.) normally
uploads and downloads archives from GitHub's hosted cache. On a self-hosted
runner every restore still crosses the internet to GitHub, and each repo is
capped at a 10 GB quota. firerunner can instead keep those archives on the
host's local disk, so dependency restore runs at LAN speed with no quota.
How GitHub's cache works (v2). The @actions/cache toolkit talks a small
Twirp/JSON RPC to the service at ACTIONS_RESULTS_URL
(CreateCacheEntry → FinalizeCacheEntryUpload → GetCacheEntryDownloadURL),
then streams the tar+zstd archive to/from a signed blob URL the RPC returns
(never through the RPC itself). Entries are keyed by key+version, namespaced
per repository, immutable, and restored by exact key first then restore-keys
as prefixes.
Our server. firerunner cache-server is a small, dependency-free
implementation of exactly that protocol, backed by a directory on disk:
firerunner cache-server --dir /var/lib/firerunner/cacheIt implements the three Twirp methods plus the Azure block-blob upload
(single-shot and staged comp=block/comp=blocklist) and ranged downloads,
tags each blob URL with a per-entry token, and persists an index so caches
survive restarts. It performs no authentication and cannot cryptographically
isolate repositories — run one per repository and pin it with --repository;
see the security model below before deploying it.
It binds 127.0.0.1:8099 by default. Because each microVM slot reaches the host
on its own per-slot gateway IP (172.16.<slot>.1), serving guests means binding
a broader address — in practice --addr 0.0.0.0:8099, which the host firewall
must then keep off any LAN/WAN interface (the guest→host input chain already
restricts guests to the cache port, but the host's own external NIC is your
responsibility). Never leave 0.0.0.0:8099 reachable from an untrusted network.
By default the store is capped at 50 GB; once a newly finalized entry pushes the
total over the cap, the least-recently-used entries are evicted until it fits
(mirroring GitHub's per-repo LRU). Tune it with --max-size (e.g. --max-size 100GB, or --max-size 0 for unlimited):
firerunner cache-server --dir /var/lib/firerunner/cache --max-size 50GBThe server also exposes GET /stats (JSON: entry count, bytes, hits, misses,
saves, evictions) and GET /metrics (Prometheus text, no client library
needed) for scraping. firerunner status reads /stats and shows the live
entry count, size vs. cap, and hit rate under the dep cache row.
Pointing microVMs at it. Two pieces are needed because the real GitHub
runner overwrites ACTIONS_RESULTS_URL with the value from its per-job message:
- Config — run firerunner with
--cache-port 8099(envFR_CACHE_PORT). firerunner publishes the port to each microVM via MMDS; the guest boot script buildsACTIONS_RESULTS_URLfrom its own default gateway (the host's per-slot tap IP) and this port, so the URL always points back at the host. For a cache-server that is not on the host gateway, use--cache-url http://host:8099(envFR_CACHE_URL) instead, and add its host to the egress allowlist. - A cache-redirect golden — build the rootfs with
build-ubuntu-rootfs.sh --cache-redirect. This renames theACTIONS_RESULTS_URLstring inside the runner agent so GitHub's URL lands in a dead variable whileactions/cachestill reads the real one firerunner exported. Without this patch the runner overrides our URL and caching stays on GitHub.
Both pieces are required together. A cache-redirect golden run without
--cache-port/--cache-url disables caching entirely: the runner no longer
sets ACTIONS_RESULTS_URL (it writes the dead name) and firerunner exports
nothing, so actions/cache has no endpoint. Jobs still run, just with no cache.
# 1. build a cache-redirect golden (pair it with a --toolcache drive as usual):
sudo images/build-ubuntu-rootfs.sh --toolset minimal --cache-redirect \
--out /var/lib/firerunner/ubuntu-rootfs-minimal.ext4
# 2. run the cache-server bound to the guest-facing address, then point
# firerunner at it (0.0.0.0 serves every per-slot gateway; keep it firewalled
# off any LAN/WAN interface):
firerunner cache-server --addr 0.0.0.0:8099 --repository owner/name \
--dir /var/lib/firerunner/cache &
firerunner ... --golden /var/lib/firerunner/ubuntu-rootfs-minimal.ext4 --cache-port 8099status shows the resolved cache configuration and doctor probes the
cache-server for reachability; both note that the cache is off by default and
that jobs fall back to GitHub's hosted cache when it is not configured.
Security model. This server performs no authentication and cannot
cryptographically isolate repositories: the ACTIONS_RUNTIME_TOKEN a runner
presents is signed by an internal GitHub key with no public JWKS, so a
self-hosted server can neither verify it nor trust the repository_id a guest
supplies (the @actions/cache toolkit does not even send it). Without
--repository, all entries share one global namespace: any caller can read
another entry with a blank restore key (restore-keys: [''] prefix-matches
everything), and the per-entry blob token is the same for download and
upload, so anyone who can read an entry can also overwrite it.
Because per-repository isolation cannot be authenticated, enforce it structurally by running one cache-server per repository and pinning it:
- Pass
--repository owner/name. The server then ignores the unauthenticated clientrepository_idand forces every entry into that single tenant, so a blank or forgedrepository_idcannot create or match another repo's entries. This is the enforceable form of "one server per repo"; without it the server logs a warning at startup. - Keep the listen address reachable only from its own microVMs. The default
--addr 127.0.0.1:8099is loopback-only; to serve guests bind the host's guest-facing gateway IP (or--addr 0.0.0.0:8099) and firewall it so no LAN/WAN client can reach it. - Assume any job that can reach it can read and overwrite every entry within its tenant (a fork-PR job can poison a cache a later trusted job restores).
Uploads are bounded per-entry (--max-entry-size, default 10GB) and in total
(--max-size), and finalized entries are immutable.
The cache is a pure accelerator — every job still passes with caching disabled, so when in doubt, leave it off.
deploy/firerunner.service is a hardened unit template (dedicated firerunner
user, least-privilege capabilities). To install:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin firerunner
sudo usermod -aG kvm firerunner
sudo install -m0755 firerunner /usr/local/bin/firerunner
# /etc/firerunner holds secrets (the env file and the App private key). Keep the
# directory traversable by the firerunner user only, never world-readable.
sudo install -d -m0750 -o root -g firerunner /etc/firerunner
# The env file carries FR_TOKEN / App credentials, so install it 0640 (systemd
# reads it as root; the firerunner group may read it) instead of letting cp
# inherit your umask and leave it world-readable.
sudo install -m0640 -o root -g firerunner config.example.env /etc/firerunner/firerunner.env # then edit
sudo install -m0644 deploy/firerunner.service /etc/systemd/system/
sudo systemctl enable --now firerunnerStopping the service sends SIGTERM; firerunner stops taking new work and
drains in-flight microVMs before exiting (TimeoutStopSec bounds the wait).
Restarts are safe: registration is idempotent and a session left behind by an
unclean exit is retried until GitHub expires it.
firerunner already runs the VMM non-root (a dedicated firerunner user with
only cap_net_admin) and Firecracker installs seccomp filters by default, so
the two biggest sandboxing wins are on out of the box. The
jailer
adds the rest — it chroots each microVM into <chroot-base>/firecracker/<id>/root,
gives it a private PID namespace, creates jail-local /dev/kvm and /dev/net/tun
nodes, and drops the VMM to an unprivileged uid/gid.
It is off by default because it inverts the privilege model: the launcher
must run as root (to chroot, mknod and drop privileges), whereas the
default deployment keeps firerunner unprivileged. Enable it for multi-tenant,
untrusted-code or shared-host deployments:
firerunner ... \
--jailer --jailer-bin /usr/local/bin/jailer \
--chroot-base /srv/jailer \
--jail-uid "$(id -u firerunner)" --jail-gid "$(id -g firerunner)"Notes:
-
The
jailerbinary must be the same version asfirecracker(it ships in the same release tarball) and Firecracker must be the static musl build. -
By default no network namespace is used (
--netnsis not passed), so the per-slot host-namespace tap devices and the egress allowlist work unchanged. -
Per-VM network namespace (opt-in, jailer only):
--netnsputs each microVM's tap inside its own network namespace, giving the strongest network isolation — a guest cannot see the host's other taps, interfaces or routes, only a point-to-point veth uplink:firerunner ... --jailer ... --netns
The jailer joins the namespace natively via
--netns /var/run/netns/<ns>(creating/entering a namespace needsCAP_SYS_ADMIN, which only the root jailer has — hence the jailer requirement). Each slot gets a private namespace holding the guest tap (owned by the jail uid/gid so the dropped-privilege VMM can still attach it) plus a veth pair on a transit/30to the host; the host routes the guest/32back through the veth. Guest egress keeps its source IP to the host (single NAT), so the masquerade and the guest-to-guest drop rules apply unchanged. Namespaces are torn down on teardown and swept on startup. -
Per-VM cgroup limits (opt-in, jailer only):
--jailer-cgrouptakes a semicolon-separated list of cgroup v2<file>=<value>settings the jailer applies to each microVM's own cgroup, bounding one VM's blast radius on the shared host (a noisy-neighbour guard for multi-tenant use):firerunner ... --jailer ... \ --jailer-cgroup "memory.max=2147483648;cpu.max=200000;pids.max=512"Semicolons (not commas) separate entries because values such as
cpuset.cpus=0-3,5legitimately contain commas. The jailer places the VMM in/sys/fs/cgroup/firecracker/<id>and enables the needed controllers; the (empty) cgroup dir is reclaimed on teardown and on startup. Setmemory.maxabove the guest RAM (--mem-mib) plus VMM overhead — a cap below the guest's memory will let the kernel OOM-kill the VMM under load. -
Per-launch overhead is ~single-digit milliseconds (chroot + staging the VMM), negligible against the microVM boot.
-
The systemd unit must run as
rootwhen the jailer is enabled: usedeploy/firerunner-jailer@.service(a root, jailer-forced unit with a trimmed capability set and cgroup-writable sandbox). The defaultdeploy/firerunner.serviceruns as the unprivilegedfirerunneruser for the non-jailer mode.