Skip to content

Commit dd5d58a

Browse files
authored
perf(cli): archive snapshot volumes with multi-threaded zstd in one container (#1061)
`abi stack snapshot create` archived each stateful volume with a separate `alpine tar czf` (single-thread gzip) container, run sequentially. On a real 3.88GB TDB2 (fuseki) volume this took ~146s and produced a 1.06GB archive, with the wall-clock dominated by single-thread gzip on the one large volume. Two changes: - Compress with multi-threaded zstd instead of gzip. A tiny helper image (alpine + zstd, ~6MB) is baked once via `ensure_snapshot_helper_image()` before the stack is stopped, so its one-time build never lands in the downtime window and there is no per-snapshot package-mirror dependency. Compression runs inside the container, so only the compressed bytes cross the docker VM->host boundary. Measured: ~146s -> ~23s on the fuseki volume, and the archive roughly halves (1.06GB -> 501MB). - Archive every volume in a SINGLE helper container (`archive_volumes`) instead of one container per volume, paying container/image startup once. Volumes are mounted read-only at /from/<key> and streamed `tar cf - | zstd -T0 -3` to /to/<key>.tar.zst. `set -eo pipefail` so a failed tar aborts the run rather than being masked by zstd's exit code. Volume archives are now `.tar.zst` (centralised in `volume_archive_name`); `extract_volume` decompresses with `zstd -dc | tar xf -`. No backward-compat shim is needed (no existing snapshots). Cross-volume parallelism was deliberately not added: one 3.88GB volume is ~81% of the bytes, so parallel archiving caps at ~1.2x, and a per-archive `-T0` already saturates all cores. The clean `docker compose stop` is kept (Fuseki/TDB2 needs a clean checkpoint; `docker pause` only yields a crash-consistent, in-flight-lossy image). `storage/` intentionally stays on host-side gzip to preserve the audited `_safe_extract`/`_assert_safe_members` guard and avoid a host-zstd dependency. Tests: 56 pass; batched archive + per-volume extract verified byte-identical end-to-end on real volumes.
1 parent 60a4470 commit dd5d58a

2 files changed

Lines changed: 214 additions & 34 deletions

File tree

libs/naas-abi-cli/naas_abi_cli/cli/snapshot_runtime.py

Lines changed: 98 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
What is captured:
99
* the stateful Docker volumes (``postgres_data``, ``minio_data``,
1010
``fuseki_data``, ``qdrant_storage``, ``redis_data``, ``rabbitmq_data``,
11-
``headscale_data``) -- copied cold as raw tarballs so each engine's on-disk
12-
state is consistent;
11+
``headscale_data``) -- copied cold as zstd-compressed tarballs so each
12+
engine's on-disk state is consistent;
1313
* the host ``storage/`` directory (the durable SQLite event log + local
1414
datastore files).
1515
@@ -54,8 +54,19 @@
5454
MANIFEST_NAME = "manifest.json"
5555
STORAGE_DIRNAME = "storage"
5656
VOLUMES_DIRNAME = "volumes"
57-
# Tiny, ubiquitous image used purely to tar/untar volume contents.
57+
# Tiny, ubiquitous image used purely to reset (empty) volume contents.
5858
HELPER_IMAGE = "alpine:3.20"
59+
# Volume archives are tar + multi-threaded zstd. busybox alpine ships no zstd,
60+
# so we bake a tiny helper image once (alpine + zstd, ~6MB) instead of shelling
61+
# out to a package mirror on every snapshot. Compressing inside the container
62+
# keeps only the compressed bytes crossing the docker VM->host boundary, and
63+
# using all cores (-T0) archives the large TDB2 volume ~6x faster than the
64+
# single-thread gzip busybox tar would use.
65+
SNAPSHOT_HELPER_IMAGE = "abi-snapshot-helper:zstd1"
66+
SNAPSHOT_HELPER_DOCKERFILE = "FROM alpine:3.20\nRUN apk add --no-cache zstd\n"
67+
# zstd's default level; benchmarked as the best time/size point on TDB2 data.
68+
ZSTD_LEVEL = 3
69+
VOLUME_ARCHIVE_SUFFIX = ".tar.zst"
5970
# Files whose content we fingerprint so we can warn about drift on restore.
6071
TRACKED_CONFIG_FILES = ("config.local.yaml", ".env")
6172
MANIFEST_FORMAT_VERSION = 1
@@ -141,6 +152,11 @@ def volume_full_name(project: str, key: str) -> str:
141152
return f"{project}_{key}"
142153

143154

155+
def volume_archive_name(key: str) -> str:
156+
"""Filename for a volume's archive inside a snapshot's ``volumes/`` dir."""
157+
return f"{key}{VOLUME_ARCHIVE_SUFFIX}"
158+
159+
144160
def select_prunable(manifests: list[SnapshotManifest], keep: int) -> list[str]:
145161
"""Return ids of snapshots to delete, keeping the ``keep`` newest."""
146162
if keep < 0:
@@ -295,6 +311,41 @@ def run_docker(
295311
) from error
296312

297313

314+
def ensure_snapshot_helper_image() -> None:
315+
"""Build the zstd-capable helper image if it is not already present.
316+
317+
Idempotent and cheap on the hot path: a present image short-circuits on the
318+
``docker image inspect``. Callers run this *before* stopping the stack so the
319+
one-time build (alpine + zstd) never counts against snapshot downtime.
320+
"""
321+
inspect = subprocess.run(
322+
["docker", "image", "inspect", SNAPSHOT_HELPER_IMAGE],
323+
check=False,
324+
text=True,
325+
capture_output=True,
326+
)
327+
if inspect.returncode == 0:
328+
return
329+
try:
330+
subprocess.run(
331+
["docker", "build", "-t", SNAPSHOT_HELPER_IMAGE, "-"],
332+
input=SNAPSHOT_HELPER_DOCKERFILE,
333+
check=True,
334+
text=True,
335+
capture_output=True,
336+
)
337+
except FileNotFoundError as error:
338+
raise click.ClickException(
339+
"Docker is not installed or not available in PATH."
340+
) from error
341+
except subprocess.CalledProcessError as error:
342+
detail = (error.stderr or "").strip() or f"exit code {error.returncode}"
343+
raise click.ClickException(
344+
f"Could not build the snapshot helper image "
345+
f"'{SNAPSHOT_HELPER_IMAGE}': {detail}."
346+
) from error
347+
348+
298349
def compose_project_name() -> str:
299350
"""Resolve the compose project name (the volume prefix).
300351
@@ -352,29 +403,41 @@ def volume_exists(name: str) -> bool:
352403
)
353404

354405

355-
def archive_volume(volume: str, dest_dir: Path, key: str) -> None:
406+
def archive_volumes(volumes: dict[str, str], dest_dir: Path) -> None:
407+
"""Archive every volume in ``volumes`` in a single helper container.
408+
409+
``volumes`` maps snapshot key -> full docker volume name. Each is mounted
410+
read-only at ``/from/<key>`` and compressed to ``/to/<key>.tar.zst`` with a
411+
multi-threaded zstd. Using one container instead of one-per-volume pays the
412+
image/container startup cost once; the archives still run sequentially
413+
inside it (a single zstd -T0 already saturates the cores, so concurrency
414+
would only oversubscribe them). The stack is stopped, so the cold reads are
415+
consistent. ``set -eo pipefail`` so a failed tar aborts the whole run rather
416+
than being masked by zstd's exit code; create discards the partial snapshot.
417+
Keys come from the hardcoded ``DATA_VOLUMES`` set, so they are safe to
418+
interpolate into the shell script and mount paths. --mount long-form (not
419+
-v) so host paths containing ':' stay unambiguous.
420+
"""
421+
if not volumes:
422+
return
356423
dest_dir.mkdir(parents=True, exist_ok=True)
357-
# --mount long-form (not -v) so host paths containing ':' are unambiguous.
358-
run_docker(
359-
[
360-
"run",
361-
"--rm",
362-
"--mount",
363-
f"type=volume,source={volume},destination=/from,readonly",
424+
args = ["run", "--rm"]
425+
for key, full in volumes.items():
426+
args += [
364427
"--mount",
365-
f"type=bind,source={dest_dir.resolve()},destination=/to",
366-
HELPER_IMAGE,
367-
"tar",
368-
"czf",
369-
f"/to/{key}.tar.gz",
370-
"-C",
371-
"/from",
372-
".",
428+
f"type=volume,source={full},destination=/from/{key},readonly",
373429
]
430+
args += ["--mount", f"type=bind,source={dest_dir.resolve()},destination=/to"]
431+
steps = "; ".join(
432+
f"tar cf - -C /from/{key} . | zstd -q -T0 -{ZSTD_LEVEL} -o /to/{volume_archive_name(key)}"
433+
for key in volumes
374434
)
435+
args += [SNAPSHOT_HELPER_IMAGE, "sh", "-c", f"set -eo pipefail; {steps}"]
436+
run_docker(args)
375437

376438

377439
def extract_volume(volume: str, src_dir: Path, key: str) -> None:
440+
archive = volume_archive_name(key)
378441
run_docker(
379442
[
380443
"run",
@@ -383,10 +446,10 @@ def extract_volume(volume: str, src_dir: Path, key: str) -> None:
383446
f"type=volume,source={volume},destination=/to",
384447
"--mount",
385448
f"type=bind,source={src_dir.resolve()},destination=/from,readonly",
386-
HELPER_IMAGE,
449+
SNAPSHOT_HELPER_IMAGE,
387450
"sh",
388451
"-c",
389-
f"cd /to && tar xzf /from/{key}.tar.gz",
452+
f"set -eo pipefail; zstd -dc /from/{archive} | tar xf - -C /to",
390453
]
391454
)
392455

@@ -491,7 +554,7 @@ def _snapshot_artifacts_ok(snap_dir: Path, manifest: SnapshotManifest) -> list[s
491554
"""Return a list of missing-artifact messages for a snapshot dir (empty=ok)."""
492555
problems: list[str] = []
493556
for key in manifest.volumes:
494-
tarball = snap_dir / VOLUMES_DIRNAME / f"{key}.tar.gz"
557+
tarball = snap_dir / VOLUMES_DIRNAME / volume_archive_name(key)
495558
if not tarball.exists():
496559
problems.append(f"missing volume archive for '{key}' ({tarball.name})")
497560
if manifest.storage_included and not (snap_dir / "storage.tar.gz").exists():
@@ -639,6 +702,10 @@ def create_snapshot(
639702
if missing:
640703
emit(f"Note: skipping volumes not present on this stack: {', '.join(missing)}")
641704

705+
# Build the zstd helper now, while the stack is still up, so its one-time
706+
# build never lands inside the stopped (downtime) window below.
707+
ensure_snapshot_helper_image()
708+
642709
snapshot_id = generate_snapshot_id(now, slug)
643710
snap_dir = snapshots_root(root) / snapshot_id
644711
if snap_dir.exists():
@@ -650,9 +717,11 @@ def create_snapshot(
650717
run_compose(["stop"])
651718
try:
652719
try:
653-
for key in present:
654-
emit(f" - archiving volume {key}")
655-
archive_volume(volume_full_name(project, key), volumes_dir, key)
720+
emit(f" - archiving {len(present)} volume(s): {', '.join(present)}")
721+
archive_volumes(
722+
{key: volume_full_name(project, key) for key in present},
723+
volumes_dir,
724+
)
656725

657726
storage_dir = root / STORAGE_DIRNAME
658727
storage_included = storage_dir.exists()
@@ -736,6 +805,10 @@ def restore_snapshot(
736805
else:
737806
emit("No existing stack data found; skipping safety snapshot.")
738807

808+
# Build the zstd helper (if absent) before tearing the stack down so the
809+
# one-time build stays outside the downtime window.
810+
ensure_snapshot_helper_image()
811+
739812
emit(f"Restoring snapshot {snapshot_id}...")
740813
run_compose(["down"])
741814
try:

0 commit comments

Comments
 (0)