mirror of
https://github.com/kevinveenbirkenbach/docker-volume-backup.git
synced 2026-08-01 12:34:50 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec3d1a5046 | |||
| 37b735cf7b |
30
CHANGELOG.md
30
CHANGELOG.md
@@ -1,5 +1,35 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [3.1.4] - 2026-07-31
|
||||||
|
|
||||||
|
- Backup: the post-stop volume pass now compares by content (*--checksum*), so
|
||||||
|
it can no longer skip a file the hot pass had copied from a live source. Each
|
||||||
|
volume is rsynced twice into the same destination — once with the container
|
||||||
|
running, once after it is stopped — and the second pass used rsync's quick
|
||||||
|
check (size plus whole-second mtime). A pre-allocated 16 MiB WAL segment never
|
||||||
|
changes size, so when its last pre-stop write and postgres' shutdown
|
||||||
|
checkpoint fell in the same whole second, the cold pass skipped it while still
|
||||||
|
replacing *global/pg_control*, whose previous write was seconds earlier. The
|
||||||
|
generation then paired a post-shutdown *pg_control* with a WAL segment still
|
||||||
|
zeroed at the recorded checkpoint LSN, and restoring it crash-looped postgres
|
||||||
|
with "invalid record length … expected at least 24, got 0" followed by "PANIC:
|
||||||
|
could not locate a valid checkpoint record". *backup_volume* takes
|
||||||
|
*authoritative* as a required keyword rather than an optional flag, so each of
|
||||||
|
the four call sites states which pass it is; the hot passes keep the quick
|
||||||
|
check. *--ignore-times* was rejected because it destroys the *--link-dest*
|
||||||
|
hardlink dedup (measured 20/20 to 0/20, generation size doubled), and
|
||||||
|
*--modify-window=-1* because it makes correctness depend on the destination
|
||||||
|
filesystem preserving sub-second mtimes, which degrades silently on NFS or
|
||||||
|
ext3.
|
||||||
|
- Cost: the quick check scales with file count, *--checksum* with bytes read on
|
||||||
|
both sides, and it runs while the container is stopped. The extra stop time
|
||||||
|
stays under a minute up to roughly 4 GB on spinning disk, 15 GB on a SATA SSD
|
||||||
|
and 60 GB on NVMe; at 1 TB it is 17 minutes on NVMe and over three hours on
|
||||||
|
spinning disk. No size threshold is built in, since a guessed one would drop
|
||||||
|
the guarantee exactly where an unrestorable backup costs most — volumes at
|
||||||
|
that scale want filesystem snapshots or *pg_basebackup* rather than two rsync
|
||||||
|
passes over a live tree.
|
||||||
|
|
||||||
## [3.1.3] - 2026-07-20
|
## [3.1.3] - 2026-07-20
|
||||||
|
|
||||||
- Restore: the postgres dump replay now runs under *--single-transaction*,
|
- Restore: the postgres dump replay now runs under *--single-transaction*,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "backup-docker-to-local"
|
name = "backup-docker-to-local"
|
||||||
version = "3.1.3"
|
version = "3.1.4"
|
||||||
description = "Backup Docker volumes to local with rsync and optional DB dumps."
|
description = "Backup Docker volumes to local with rsync and optional DB dumps."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
@@ -226,19 +226,19 @@ def main() -> int:
|
|||||||
if args.everything:
|
if args.everything:
|
||||||
# "everything": always do pre-rsync, then stop + rsync again
|
# "everything": always do pre-rsync, then stop + rsync again
|
||||||
stoppable = filter_stoppable(containers)
|
stoppable = filter_stoppable(containers)
|
||||||
backup_volume(versions_dir, volume_name, vol_dir)
|
backup_volume(versions_dir, volume_name, vol_dir, authoritative=False)
|
||||||
change_containers_status(stoppable, "stop")
|
change_containers_status(stoppable, "stop")
|
||||||
backup_volume(versions_dir, volume_name, vol_dir)
|
backup_volume(versions_dir, volume_name, vol_dir, authoritative=True)
|
||||||
if not args.shutdown:
|
if not args.shutdown:
|
||||||
change_containers_status(stoppable, "start")
|
change_containers_status(stoppable, "start")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# default: rsync, and if needed stop + rsync
|
# default: rsync, and if needed stop + rsync
|
||||||
backup_volume(versions_dir, volume_name, vol_dir)
|
backup_volume(versions_dir, volume_name, vol_dir, authoritative=False)
|
||||||
if requires_stop(containers, args.images_no_stop_required):
|
if requires_stop(containers, args.images_no_stop_required):
|
||||||
stoppable = filter_stoppable(containers)
|
stoppable = filter_stoppable(containers)
|
||||||
change_containers_status(stoppable, "stop")
|
change_containers_status(stoppable, "stop")
|
||||||
backup_volume(versions_dir, volume_name, vol_dir)
|
backup_volume(versions_dir, volume_name, vol_dir, authoritative=True)
|
||||||
if not args.shutdown:
|
if not args.shutdown:
|
||||||
change_containers_status(stoppable, "start")
|
change_containers_status(stoppable, "start")
|
||||||
|
|
||||||
|
|||||||
@@ -24,16 +24,26 @@ def get_last_backup_dir(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def backup_volume(versions_dir: str, volume_name: str, volume_dir: str) -> None:
|
def backup_volume(
|
||||||
"""Perform incremental file backup of a Docker volume."""
|
versions_dir: str, volume_name: str, volume_dir: str, *, authoritative: bool
|
||||||
|
) -> None:
|
||||||
|
"""Perform incremental file backup of a Docker volume.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
authoritative: compare source and destination by content instead of by
|
||||||
|
size and whole-second mtime. Required on a pass whose destination was
|
||||||
|
already written from a live source, where a file can differ while
|
||||||
|
both attributes still agree.
|
||||||
|
"""
|
||||||
dest = os.path.join(volume_dir, "files") + "/"
|
dest = os.path.join(volume_dir, "files") + "/"
|
||||||
pathlib.Path(dest).mkdir(parents=True, exist_ok=True)
|
pathlib.Path(dest).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
last = get_last_backup_dir(versions_dir, volume_name, dest)
|
last = get_last_backup_dir(versions_dir, volume_name, dest)
|
||||||
link_dest = f"--link-dest='{last}'" if last else ""
|
link_dest = f"--link-dest='{last}'" if last else ""
|
||||||
source = get_storage_path(volume_name)
|
source = get_storage_path(volume_name)
|
||||||
|
verify = "--checksum " if authoritative else ""
|
||||||
|
|
||||||
cmd = f"rsync -abP --delete --delete-excluded {link_dest} {source} {dest}"
|
cmd = f"rsync -abP --delete --delete-excluded {verify}{link_dest} {source} {dest}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
execute_shell_command(cmd)
|
execute_shell_command(cmd)
|
||||||
|
|||||||
Reference in New Issue
Block a user