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
|
||||
|
||||
## [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
|
||||
|
||||
- Restore: the postgres dump replay now runs under *--single-transaction*,
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
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."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -226,19 +226,19 @@ def main() -> int:
|
||||
if args.everything:
|
||||
# "everything": always do pre-rsync, then stop + rsync again
|
||||
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")
|
||||
backup_volume(versions_dir, volume_name, vol_dir)
|
||||
backup_volume(versions_dir, volume_name, vol_dir, authoritative=True)
|
||||
if not args.shutdown:
|
||||
change_containers_status(stoppable, "start")
|
||||
continue
|
||||
|
||||
# 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):
|
||||
stoppable = filter_stoppable(containers)
|
||||
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:
|
||||
change_containers_status(stoppable, "start")
|
||||
|
||||
|
||||
@@ -24,16 +24,26 @@ def get_last_backup_dir(
|
||||
return None
|
||||
|
||||
|
||||
def backup_volume(versions_dir: str, volume_name: str, volume_dir: str) -> None:
|
||||
"""Perform incremental file backup of a Docker volume."""
|
||||
def backup_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") + "/"
|
||||
pathlib.Path(dest).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
last = get_last_backup_dir(versions_dir, volume_name, dest)
|
||||
link_dest = f"--link-dest='{last}'" if last else ""
|
||||
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:
|
||||
execute_shell_command(cmd)
|
||||
|
||||
Reference in New Issue
Block a user