feat(firecracker): port freeze/migrate off host Docker (PRD 0069 / #397)
The last host-Docker dependency in the Firecracker launch path. Freeze and resume no longer touch the docker daemon, so the backend needs firecracker + KVM only — completing #348. Freeze: stream the guest rootfs over SSH straight into a persistent committed-rootfs.tar (the resumable/migratable artifact) instead of round-tripping through `docker build` from a scratch image. Written to a .partial sibling and atomically renamed so a failed freeze leaves no truncated artifact. Resume: extract the snapshot tar into a cached base dir and feed it to the existing rootless `mke2fs -d` pipeline, replacing the `docker create` + `docker export | tar` path. Recreate the proc/sys/dev/run mount points the freezer excludes so the guest init can mount them. `util.build_base_rootfs_dir` / `docker_image_id` stay — they still back the opt-in BOT_BOTTLE_INFRA_BUILD=local dev path and off-host publish_infra, which are out of scope. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
"""FirecrackerFreezer — snapshot a running microVM to a Docker image.
|
||||
"""FirecrackerFreezer — snapshot a running microVM to a rootfs tar.
|
||||
|
||||
The VM is live and can't be block-copied safely, so — like the macOS
|
||||
backend — we stream the guest root filesystem out over the control
|
||||
channel (SSH here) and rebuild an image from it. The bottle keeps
|
||||
running after the snapshot.
|
||||
channel (SSH here). Unlike the other backends this needs no Docker: the
|
||||
tar *is* the resumable artifact. `resume` extracts it and rebuilds a
|
||||
fresh per-bottle ext4 with `mke2fs -d` (see `util.build_committed_rootfs_dir`
|
||||
and `launch._build_agent_base`). The bottle keeps running after the
|
||||
snapshot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,9 +14,9 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from ...bottle_state import committed_rootfs_path
|
||||
from ...log import die, info
|
||||
from .. import ActiveAgent
|
||||
from ..freeze import Freezer
|
||||
@@ -30,14 +33,13 @@ class FirecrackerFreezer(Freezer):
|
||||
if not private_key.is_file() or not guest_ip:
|
||||
die(f"cannot freeze {agent.slug}: run dir {run_dir} is missing the "
|
||||
f"SSH key or VM config (is the bottle still running?)")
|
||||
image_tag = f"bot-bottle-committed-{agent.slug}:latest"
|
||||
_commit_via_ssh(private_key, guest_ip, image_tag)
|
||||
info(f"committed {agent.slug} -> {image_tag!r}")
|
||||
return image_tag
|
||||
tar_path = committed_rootfs_path(agent.slug)
|
||||
_commit_rootfs_via_ssh(private_key, guest_ip, tar_path)
|
||||
info(f"committed {agent.slug} -> {tar_path}")
|
||||
return str(tar_path)
|
||||
|
||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
||||
info(f"to export for migration: docker image save {image_ref} "
|
||||
f"-o {slug}.tar")
|
||||
info(f"to export for migration: cp {image_ref} {slug}.tar")
|
||||
|
||||
|
||||
def _guest_ip_from_config(config_path: Path) -> str:
|
||||
@@ -53,24 +55,23 @@ def _guest_ip_from_config(config_path: Path) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _commit_via_ssh(private_key: Path, guest_ip: str, image_tag: str) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="bot-bottle-fc-commit.") as tmp:
|
||||
rootfs_tar = os.path.join(tmp, "rootfs.tar")
|
||||
ssh = util.ssh_base_argv(private_key, guest_ip)
|
||||
with open(rootfs_tar, "wb") as tar_out:
|
||||
result = subprocess.run(
|
||||
[*ssh, "--", "tar", "--create", "--one-file-system",
|
||||
"--exclude=./proc", "--exclude=./sys", "--exclude=./dev",
|
||||
"--exclude=./run", "--file=-", "--directory=/", "."],
|
||||
stdout=tar_out, stderr=subprocess.PIPE, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(f"ssh tar for {guest_ip} failed: "
|
||||
f"{(result.stderr or b'').decode().strip() or '<no stderr>'}")
|
||||
with open(os.path.join(tmp, "Dockerfile"), "w", encoding="utf-8") as f:
|
||||
f.write("FROM scratch\nADD rootfs.tar /\nUSER node\nWORKDIR /home/node\n")
|
||||
build = subprocess.run(
|
||||
["docker", "build", "-t", image_tag, tmp], check=False,
|
||||
def _commit_rootfs_via_ssh(private_key: Path, guest_ip: str, tar_path: Path) -> None:
|
||||
"""Stream the guest rootfs out over SSH into `tar_path`. Excludes the
|
||||
virtual/live mounts (proc/sys/dev/run) — resume recreates those empty
|
||||
mount points. Written to a `.partial` sibling and renamed on success so
|
||||
a failed freeze never leaves a truncated artifact in its place."""
|
||||
tar_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
partial = tar_path.with_name(tar_path.name + ".partial")
|
||||
ssh = util.ssh_base_argv(private_key, guest_ip)
|
||||
with open(partial, "wb") as tar_out:
|
||||
result = subprocess.run(
|
||||
[*ssh, "--", "tar", "--create", "--one-file-system",
|
||||
"--exclude=./proc", "--exclude=./sys", "--exclude=./dev",
|
||||
"--exclude=./run", "--file=-", "--directory=/", "."],
|
||||
stdout=tar_out, stderr=subprocess.PIPE, check=False,
|
||||
)
|
||||
if build.returncode != 0:
|
||||
die(f"docker build for {image_tag!r} failed")
|
||||
if result.returncode != 0:
|
||||
partial.unlink(missing_ok=True)
|
||||
die(f"ssh tar for {guest_ip} failed: "
|
||||
f"{(result.stderr or b'').decode().strip() or '<no stderr>'}")
|
||||
os.replace(partial, tar_path)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Launch flow for the Firecracker backend (PRD 0070, consolidated).
|
||||
|
||||
Per bottle:
|
||||
1. build the agent image (docker), export it to a cached ext4 rootfs;
|
||||
1. build the agent rootfs in a builder VM (buildah, no host docker), or
|
||||
resume a frozen bottle from its committed rootfs tar; cache the ext4;
|
||||
2. ensure the per-host orchestrator + shared gateway are up;
|
||||
3. claim a free TAP pool slot (rootless flock);
|
||||
4. register the bottle on the orchestrator by the VM's guest IP (the
|
||||
@@ -31,6 +32,7 @@ from typing import Callable, Generator
|
||||
|
||||
from ...agent_provider import runtime_for
|
||||
from ...bottle_state import (
|
||||
committed_rootfs_path,
|
||||
egress_state_dir,
|
||||
git_gate_state_dir,
|
||||
read_committed_image,
|
||||
@@ -45,7 +47,6 @@ from ...git_gate import (
|
||||
)
|
||||
from ...log import info, warn
|
||||
from ...supervise import SUPERVISE_PORT
|
||||
from ..docker import util as docker_mod
|
||||
from ..docker.egress import EGRESS_PORT
|
||||
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
||||
from . import firecracker_vm, image_builder, isolation_probe, netpool, util
|
||||
@@ -210,16 +211,13 @@ def _build_agent_base(
|
||||
) -> tuple[FirecrackerBottlePlan, Path]:
|
||||
"""Produce the agent's base rootfs dir. Primary path: build the Dockerfile
|
||||
inside a Firecracker builder VM (buildah, no host docker), smoke-testing
|
||||
the image before export. A committed snapshot (freeze/migrate) is still
|
||||
exported via the host docker path until that is ported too."""
|
||||
the image before export. A committed snapshot (freeze/migrate) is resumed
|
||||
directly from the rootfs tar the freezer wrote — no host docker either."""
|
||||
committed = read_committed_image(plan.slug)
|
||||
if committed and docker_mod.image_exists(committed):
|
||||
info(f"using committed image {committed!r}")
|
||||
plan = dataclasses.replace(
|
||||
plan,
|
||||
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
|
||||
)
|
||||
return plan, util.build_base_rootfs_dir(committed)
|
||||
committed_tar = committed_rootfs_path(plan.slug)
|
||||
if committed and committed_tar.is_file():
|
||||
info(f"resuming from committed rootfs {committed_tar}")
|
||||
return plan, util.build_committed_rootfs_dir(committed_tar)
|
||||
base = image_builder.build_agent_rootfs_dir(
|
||||
Path(plan.dockerfile_path),
|
||||
image_tag=plan.image,
|
||||
|
||||
@@ -14,6 +14,7 @@ and `./cli.py backend setup --backend=firecracker`.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
@@ -212,6 +213,49 @@ def build_base_rootfs_dir(
|
||||
return base
|
||||
|
||||
|
||||
def build_committed_rootfs_dir(tar_path: Path) -> Path:
|
||||
"""Prepare a base rootfs dir from a frozen-bottle snapshot tar (the
|
||||
freeze/resume path — no Docker). Extracts the snapshot, recreates the
|
||||
virtual mount points the freezer excluded, and injects the guest init +
|
||||
static dropbear, mirroring `build_base_rootfs_dir` but sourced from a tar
|
||||
we control rather than a Docker image.
|
||||
|
||||
Cached under the rootfs cache, keyed by the tar's size+mtime so a
|
||||
re-freeze re-extracts but repeated resumes of the same snapshot don't.
|
||||
Returns the prepared directory (read as the `mke2fs -d` source)."""
|
||||
st = tar_path.stat()
|
||||
fingerprint = hashlib.sha256(
|
||||
f"{tar_path}:{st.st_size}:{st.st_mtime_ns}".encode()
|
||||
).hexdigest()[:16]
|
||||
base = cache_dir() / "rootfs" / f"committed-{fingerprint}"
|
||||
ready = base / ".bb-ready"
|
||||
if ready.is_file():
|
||||
return base
|
||||
|
||||
if base.exists():
|
||||
shutil.rmtree(base, ignore_errors=True)
|
||||
base.mkdir(parents=True)
|
||||
|
||||
info(f"extracting committed rootfs {tar_path} -> {base}")
|
||||
result = subprocess.run(
|
||||
["tar", "-x", "-f", str(tar_path), "-C", str(base)],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(f"extracting committed rootfs {tar_path} failed: "
|
||||
f"{result.stderr.strip() or '<no stderr>'}")
|
||||
|
||||
# The freezer excludes the live/virtual filesystems from the snapshot;
|
||||
# recreate them as empty mount points so the guest init can mount
|
||||
# proc/sys/dev and dropbear has a writable /run.
|
||||
for mount_point in ("proc", "sys", "dev", "run"):
|
||||
(base / mount_point).mkdir(mode=0o755, exist_ok=True)
|
||||
|
||||
inject_guest_boot(base)
|
||||
ready.write_text("ok\n")
|
||||
return base
|
||||
|
||||
|
||||
def inject_guest_boot(rootfs: Path, init_script: str | None = None) -> None:
|
||||
"""Drop the static dropbear and the PID-1 init into the rootfs.
|
||||
`init_script` defaults to the SSH-only agent init; the infra VM
|
||||
|
||||
@@ -44,6 +44,7 @@ from .paths import bot_bottle_root
|
||||
_STATE_SUBDIR = "state"
|
||||
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
||||
_COMMITTED_IMAGE_NAME = "committed-image"
|
||||
_COMMITTED_ROOTFS_NAME = "committed-rootfs.tar"
|
||||
_TRANSCRIPT_SUBDIR = "transcript"
|
||||
# Per-daemon scratch subdirs. PRD 0018 chunk 2: bind-mount sources
|
||||
# live here so chunk 3's `docker compose up` can find them at stable
|
||||
@@ -200,6 +201,15 @@ def committed_image_path(identity: str) -> Path:
|
||||
return bottle_state_dir(identity) / _COMMITTED_IMAGE_NAME
|
||||
|
||||
|
||||
def committed_rootfs_path(identity: str) -> Path:
|
||||
"""Where the Firecracker freezer stores a snapshot of the bottle's
|
||||
guest rootfs (a plain tar). This is the resumable/migratable artifact
|
||||
the Firecracker backend boots from — no Docker image involved. The
|
||||
matching `committed-image` state file records that a snapshot exists
|
||||
(and its path); `resume` boots from this tar when both are present."""
|
||||
return bottle_state_dir(identity) / _COMMITTED_ROOTFS_NAME
|
||||
|
||||
|
||||
def write_committed_image(identity: str, image_tag: str) -> Path:
|
||||
"""Persist the committed image tag for `identity`. The next
|
||||
`cli.py resume <identity>` will boot from this image instead of
|
||||
@@ -354,6 +364,7 @@ __all__ = [
|
||||
"cleanup_state",
|
||||
"clear_preserve_marker",
|
||||
"committed_image_path",
|
||||
"committed_rootfs_path",
|
||||
"egress_state_dir",
|
||||
"git_gate_state_dir",
|
||||
"is_preserved",
|
||||
|
||||
@@ -205,25 +205,28 @@ class TestFirecrackerFreezer(_FakeHomeMixin, unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_snapshots_running_vm_without_stopping(self):
|
||||
"""Commit should tar the running guest rootfs over SSH, not stop it."""
|
||||
"""Commit should tar the running guest rootfs over SSH into the
|
||||
committed-rootfs artifact (no Docker), not stop the VM."""
|
||||
slug = "dev-abc12"
|
||||
self._write_meta(slug)
|
||||
self._stage_run_dir(slug)
|
||||
freezer = FirecrackerFreezer()
|
||||
agent = _make_agent(slug, "firecracker")
|
||||
|
||||
with patch("bot_bottle.backend.firecracker.freezer._commit_via_ssh") as mock_commit, \
|
||||
with patch("bot_bottle.backend.firecracker.freezer._commit_rootfs_via_ssh") as mock_commit, \
|
||||
patch("bot_bottle.backend.freeze.info"), \
|
||||
patch("bot_bottle.backend.firecracker.freezer.info"):
|
||||
freezer.commit(agent)
|
||||
|
||||
image_tag = f"bot-bottle-committed-{slug}:latest"
|
||||
tar_path = bottle_state.committed_rootfs_path(slug)
|
||||
self.assertEqual(1, mock_commit.call_count)
|
||||
# (private_key, guest_ip, image_tag) — guest_ip parsed from config.
|
||||
# (private_key, guest_ip, tar_path) — guest_ip parsed from config.
|
||||
args = mock_commit.call_args.args
|
||||
self.assertEqual("100.64.0.1", args[1])
|
||||
self.assertEqual(image_tag, args[2])
|
||||
self.assertEqual(image_tag, bottle_state.read_committed_image(slug))
|
||||
self.assertEqual(tar_path, args[2])
|
||||
# The committed-image state records the artifact path; resume boots
|
||||
# from the tar rather than a Docker image.
|
||||
self.assertEqual(str(tar_path), bottle_state.read_committed_image(slug))
|
||||
self.assertTrue(bottle_state.is_preserved(slug))
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ branches. Mock subprocess/os so nothing needs KVM or a live VM.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -128,5 +129,93 @@ class TestRequireFirecracker(unittest.TestCase):
|
||||
util.require_firecracker()
|
||||
|
||||
|
||||
class TestBuildCommittedRootfsDir(unittest.TestCase):
|
||||
"""Resume prepares the base rootfs dir from the freezer's snapshot tar
|
||||
with no Docker: extract, recreate the excluded mount points, inject the
|
||||
guest boot bits."""
|
||||
|
||||
def _make_tar(self, tmp: Path) -> Path:
|
||||
import tarfile
|
||||
|
||||
src = tmp / "src"
|
||||
(src / "home" / "node").mkdir(parents=True)
|
||||
(src / "home" / "node" / "hello").write_text("hi")
|
||||
tar_path = tmp / "rootfs.tar"
|
||||
with tarfile.open(tar_path, "w") as tar:
|
||||
tar.add(src, arcname=".")
|
||||
return tar_path
|
||||
|
||||
def test_extracts_recreates_mountpoints_and_injects_boot(self):
|
||||
with tempfile.TemporaryDirectory(prefix="fc-committed.") as d:
|
||||
tmp = Path(d)
|
||||
tar_path = self._make_tar(tmp)
|
||||
# Stand in for the static dropbear that inject_guest_boot copies.
|
||||
dropbear = tmp / "dropbear"
|
||||
dropbear.write_text("#!/bin/true\n")
|
||||
cache = tmp / "cache"
|
||||
cache.mkdir()
|
||||
|
||||
with patch.object(util, "cache_dir", return_value=cache), \
|
||||
patch.object(util, "dropbear_path", return_value=dropbear), \
|
||||
patch.object(util, "info"):
|
||||
base = util.build_committed_rootfs_dir(tar_path)
|
||||
|
||||
self.assertEqual("hi", (base / "home" / "node" / "hello").read_text())
|
||||
for mount_point in ("proc", "sys", "dev", "run"):
|
||||
self.assertTrue((base / mount_point).is_dir(),
|
||||
f"missing recreated mount point /{mount_point}")
|
||||
self.assertTrue((base / "bb-dropbear").is_file())
|
||||
self.assertTrue((base / "bb-init").is_file())
|
||||
self.assertTrue((base / ".bb-ready").is_file())
|
||||
|
||||
def test_caches_on_repeat_and_reextracts_after_refreeze(self):
|
||||
with tempfile.TemporaryDirectory(prefix="fc-committed.") as d:
|
||||
tmp = Path(d)
|
||||
tar_path = self._make_tar(tmp)
|
||||
dropbear = tmp / "dropbear"
|
||||
dropbear.write_text("#!/bin/true\n")
|
||||
cache = tmp / "cache"
|
||||
cache.mkdir()
|
||||
|
||||
ctx = [
|
||||
patch.object(util, "cache_dir", return_value=cache),
|
||||
patch.object(util, "dropbear_path", return_value=dropbear),
|
||||
patch.object(util, "info"),
|
||||
]
|
||||
for c in ctx:
|
||||
c.start()
|
||||
self.addCleanup(lambda: [c.stop() for c in ctx])
|
||||
|
||||
real_run = subprocess.run
|
||||
calls = {"n": 0}
|
||||
|
||||
def counting_run(argv, *a, **k):
|
||||
if argv and argv[0] == "tar":
|
||||
calls["n"] += 1
|
||||
return real_run(argv, *a, **k)
|
||||
|
||||
with patch.object(util.subprocess, "run", side_effect=counting_run):
|
||||
first = util.build_committed_rootfs_dir(tar_path)
|
||||
second = util.build_committed_rootfs_dir(tar_path)
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(1, calls["n"]) # cached — no re-extract
|
||||
|
||||
# A re-freeze rewrites the tar; a new size/mtime -> new cache
|
||||
# key -> re-extract. Force a distinct mtime so the test isn't
|
||||
# at the mercy of filesystem timestamp granularity.
|
||||
import tarfile
|
||||
extra = tmp / "extra"
|
||||
extra.mkdir()
|
||||
(extra / "note").write_text("v2")
|
||||
with tarfile.open(tar_path, "w") as tar:
|
||||
tar.add(extra, arcname=".")
|
||||
st = tar_path.stat()
|
||||
os.utime(tar_path, ns=(st.st_atime_ns, st.st_mtime_ns + 1_000_000_000))
|
||||
|
||||
third = util.build_committed_rootfs_dir(tar_path)
|
||||
self.assertNotEqual(first, third)
|
||||
self.assertEqual(2, calls["n"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user