mirror of
https://github.com/kevinveenbirkenbach/docker-volume-backup.git
synced 2026-08-20 21:22:54 +00:00
fix(backup): decide snapshot capture per volume
A volume with a backing store of its own is not in a snapshot of the docker data root: it appears there as an existing empty directory, so the copy succeeds, the generation is stamped complete, and the volume is empty in it. The existing check only asked whether the path was inside the snapshot, which that empty directory answers with yes. The driver, its options and the filesystem the mountpoint sits on now decide, per volume. An uncaptured volume is copied live - correct data without the point in time - while every other volume of the same run keeps its snapshot. One NFS volume no longer costs the whole host its consistent backup. A volume resolving outside the subject degrades the same way instead of aborting the run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,8 +22,8 @@ from .layout import (
|
||||
stamp_directory,
|
||||
)
|
||||
from .policy import requires_stop, volume_is_fully_ignored
|
||||
from .snapshot import volume_snapshot
|
||||
from .volume import backup_volume, get_storage_path
|
||||
from .snapshot import snapshot_source, volume_snapshot
|
||||
from .volume import backup_volume, inspect_backing
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -86,7 +86,8 @@ def main() -> int:
|
||||
else:
|
||||
continue
|
||||
|
||||
live_source = get_storage_path(volume_name)
|
||||
backing = inspect_backing(volume_name)
|
||||
live_source = backing.source
|
||||
|
||||
def copy(
|
||||
*,
|
||||
@@ -104,13 +105,15 @@ def main() -> int:
|
||||
)
|
||||
|
||||
if resolve_source is not None:
|
||||
snapshot_source = resolve_source(live_source)
|
||||
if os.path.isdir(snapshot_source):
|
||||
copy(authoritative=True, source=snapshot_source)
|
||||
source, reason = snapshot_source(
|
||||
resolve_source, backing, args.snapshot_subject
|
||||
)
|
||||
if source is not None:
|
||||
copy(authoritative=True, source=source)
|
||||
else:
|
||||
print(
|
||||
f"WARNING: volume '{volume_name}' is not in the snapshot "
|
||||
"(created after it was taken); copying it live instead.",
|
||||
f"({reason}); copying it live instead.",
|
||||
flush=True,
|
||||
)
|
||||
copy(authoritative=False)
|
||||
|
||||
@@ -9,6 +9,13 @@ built for. That also removes the reason to stop containers at all.
|
||||
The snapshot kind is stated by the caller rather than probed, because falling
|
||||
back to a live copy when a probe is inconclusive would hand out backups that
|
||||
look consistent and are not.
|
||||
|
||||
Which volumes a snapshot of the subject contains is a different question, and
|
||||
it is decided per volume: a volume with a backing store of its own appears
|
||||
inside the snapshot as an existing empty directory, so copying from there
|
||||
succeeds and stores nothing. Such a volume is copied live instead - correct
|
||||
data without the point in time - while every other volume of the same run
|
||||
keeps its snapshot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,6 +25,7 @@ from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from .shell import BackupException, execute_shell_command
|
||||
from .volume import Backing
|
||||
|
||||
KINDS = ("btrfs", "zfs")
|
||||
|
||||
@@ -62,6 +70,60 @@ def _zfs(subject: str, name: str, run: Callable[[str], list[str]]) -> tuple[str,
|
||||
_CREATE = {"btrfs": _btrfs, "zfs": _zfs}
|
||||
|
||||
|
||||
def unsnapshotted(backing: Backing, subject: str) -> str | None:
|
||||
"""Return why a snapshot of ``subject`` does not hold this volume's data.
|
||||
|
||||
Docker mounts a volume's own backing store lazily and unmounts it when the
|
||||
last consumer stops, so the declaration is what gets checked: it is true at
|
||||
every moment, where the mount table is only true while a container happens
|
||||
to hold the volume.
|
||||
|
||||
Args:
|
||||
backing: the volume as the daemon describes it.
|
||||
subject: the snapshot subject, e.g. ``/var/lib/docker``.
|
||||
|
||||
Returns:
|
||||
The reason, or None when the snapshot holds the volume.
|
||||
"""
|
||||
if backing.driver != "local":
|
||||
return f"it uses the {backing.driver} driver"
|
||||
if backing.options:
|
||||
return f"it declares its own backing store {backing.options}"
|
||||
if not backing.mountpoint:
|
||||
return "it reports no mountpoint"
|
||||
real = os.path.realpath(backing.mountpoint)
|
||||
if os.path.ismount(real):
|
||||
return f"its mountpoint {backing.mountpoint} sits on its own mount"
|
||||
try:
|
||||
crosses = os.stat(real).st_dev != os.stat(os.path.realpath(subject)).st_dev
|
||||
except OSError as error:
|
||||
return f"its mountpoint {backing.mountpoint} could not be read: {error}"
|
||||
if crosses:
|
||||
return f"its mountpoint {backing.mountpoint} crosses a filesystem boundary"
|
||||
return None
|
||||
|
||||
|
||||
def snapshot_source(
|
||||
resolve: Callable[[str], str], backing: Backing, subject: str
|
||||
) -> tuple[str | None, str]:
|
||||
"""Resolve where to read a volume from, and why if not from the snapshot.
|
||||
|
||||
Returns:
|
||||
``(path, "")`` to copy from the snapshot, or ``(None, reason)`` to copy
|
||||
it live.
|
||||
"""
|
||||
reason = unsnapshotted(backing, subject)
|
||||
if reason:
|
||||
return None, reason
|
||||
try:
|
||||
source = resolve(backing.source)
|
||||
except SnapshotError as error:
|
||||
return None, str(error)
|
||||
if not os.path.isdir(source):
|
||||
return None, "it was created after the snapshot was taken"
|
||||
return source, ""
|
||||
|
||||
|
||||
@contextmanager
|
||||
def volume_snapshot(
|
||||
kind: str,
|
||||
|
||||
@@ -1,16 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .shell import BackupException, execute_shell_command
|
||||
|
||||
|
||||
def get_storage_path(volume_name: str) -> str:
|
||||
path = execute_shell_command(
|
||||
f"docker volume inspect --format '{{{{ .Mountpoint }}}}' {volume_name}"
|
||||
@dataclass(frozen=True)
|
||||
class Backing:
|
||||
"""Where a docker volume actually keeps its data.
|
||||
|
||||
Args:
|
||||
mountpoint: the path the daemon reports.
|
||||
driver: the volume driver, ``local`` for the built-in one.
|
||||
options: the driver options; a non-empty map means the mountpoint is a
|
||||
mount target rather than the storage itself.
|
||||
"""
|
||||
|
||||
mountpoint: str
|
||||
driver: str = "local"
|
||||
options: dict = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def source(self) -> str:
|
||||
return f"{self.mountpoint}/"
|
||||
|
||||
|
||||
def inspect_backing(volume_name: str) -> Backing:
|
||||
reported = execute_shell_command(
|
||||
f"docker volume inspect --format '{{{{json .}}}}' {volume_name}"
|
||||
)[0]
|
||||
return f"{path}/"
|
||||
data = json.loads(reported)
|
||||
return Backing(
|
||||
data.get("Mountpoint") or "",
|
||||
data.get("Driver") or "",
|
||||
data.get("Options") or {},
|
||||
)
|
||||
|
||||
|
||||
def get_last_backup_dir(
|
||||
|
||||
Reference in New Issue
Block a user