Files
docker-backup/docker_backup.py
2026-04-10 15:36:35 -07:00

464 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Docker Backup/Restore Tool
Backup and restore containers (config + volumes) across multiple Docker hosts.
"""
import argparse
import json
import os
import sys
import tarfile
import tempfile
import time
from datetime import datetime
from io import BytesIO
from pathlib import Path
import docker
from docker.errors import DockerException, NotFound, APIError
from rich.console import Console
from rich.table import Table
from rich import print as rprint
console = Console()
# ── Host connection helpers ──────────────────────────────────────────────────
def connect(host: str) -> docker.DockerClient:
"""Connect to a Docker host. 'local' uses the default socket."""
try:
if host in ("local", "unix:///var/run/docker.sock", ""):
client = docker.from_env()
elif host.startswith("ssh://"):
client = docker.DockerClient(base_url=host, use_ssh_client=True)
else:
# tcp:// or host:port shorthand
if not host.startswith("tcp://") and "://" not in host:
host = f"tcp://{host}"
client = docker.DockerClient(base_url=host)
client.ping()
return client
except DockerException as e:
console.print(f"[red]Cannot connect to {host}: {e}[/red]")
sys.exit(1)
# ── Backup ───────────────────────────────────────────────────────────────────
def backup_container(client: docker.DockerClient, container_name: str,
output_dir: Path, save_image: bool = False):
"""
Backup a single container to output_dir/<container_name>_<timestamp>.tar
Contents:
config.json — full docker inspect output
volumes/ — one .tar per named/anonymous volume
image.tar — saved image (optional, can be large)
"""
try:
container = client.containers.get(container_name)
except NotFound:
console.print(f"[red]Container not found: {container_name}[/red]")
sys.exit(1)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_name = container.name.lstrip("/").replace("/", "_")
out_file = output_dir / f"{safe_name}_{ts}.tar"
console.print(f"Backing up [cyan]{container.name}[/cyan] → [green]{out_file}[/green]")
inspect = client.api.inspect_container(container.id)
with tarfile.open(out_file, "w") as tar:
# 1. config.json
config_bytes = json.dumps(inspect, indent=2).encode()
info = tarfile.TarInfo(name="config.json")
info.size = len(config_bytes)
info.mtime = int(time.time())
tar.addfile(info, BytesIO(config_bytes))
console.print(" [dim]✓ config.json[/dim]")
# 2. Volumes
mounts = inspect.get("Mounts", [])
for mount in mounts:
mtype = mount.get("Type", "")
if mtype not in ("volume", "bind"):
continue
if mtype == "volume":
vol_name = mount.get("Name", "")
dest = mount.get("Destination", "")
label = vol_name or dest.replace("/", "_")
else:
src = mount.get("Source", "")
label = src.replace("/", "_").lstrip("_")
dest = src
archive_name = f"volumes/{label}.tar"
console.print(f" [dim]Archiving volume: {label} ({dest})[/dim]")
try:
# Use docker cp stream to get volume data
stream, _ = client.api.get_archive(container.id, dest)
vol_data = b"".join(stream)
vol_info = tarfile.TarInfo(name=archive_name)
vol_info.size = len(vol_data)
vol_info.mtime = int(time.time())
tar.addfile(vol_info, BytesIO(vol_data))
console.print(f" [dim]✓ {archive_name} ({len(vol_data)//1024} KB)[/dim]")
except (APIError, Exception) as e:
console.print(f" [yellow]⚠ Could not archive {label}: {e}[/yellow]")
# 3. Image (optional)
if save_image:
image_tag = inspect["Config"].get("Image", "")
console.print(f" [dim]Saving image: {image_tag} (may take a while)…[/dim]")
try:
image = client.images.get(image_tag)
img_data = b"".join(image.save())
img_info = tarfile.TarInfo(name="image.tar")
img_info.size = len(img_data)
img_info.mtime = int(time.time())
tar.addfile(img_info, BytesIO(img_data))
console.print(f" [dim]✓ image.tar ({len(img_data)//1024//1024} MB)[/dim]")
except Exception as e:
console.print(f" [yellow]⚠ Could not save image: {e}[/yellow]")
console.print(f"[green]✓ Backup complete:[/green] {out_file}")
return out_file
# ── Restore ──────────────────────────────────────────────────────────────────
def restore_container(client: docker.DockerClient, backup_file: Path,
new_name: str | None = None, start: bool = False,
load_image: bool = False):
"""
Restore a container from a backup .tar produced by backup_container().
"""
console.print(f"Restoring from [cyan]{backup_file}[/cyan]")
with tarfile.open(backup_file, "r") as tar:
# 1. Read config
config_member = tar.getmember("config.json")
config = json.loads(tar.extractfile(config_member).read())
orig_name = config["Name"].lstrip("/")
container_name = new_name or orig_name
image_name = config["Config"]["Image"]
console.print(f" Original name : [cyan]{orig_name}[/cyan]")
console.print(f" Restore as : [cyan]{container_name}[/cyan]")
console.print(f" Image : [cyan]{image_name}[/cyan]")
# 2. Optionally load image from backup
if load_image:
with tarfile.open(backup_file, "r") as tar:
try:
img_member = tar.getmember("image.tar")
console.print(" [dim]Loading image from backup…[/dim]")
img_data = tar.extractfile(img_member).read()
client.images.load(img_data)
console.print(" [dim]✓ Image loaded[/dim]")
except KeyError:
console.print(" [yellow]⚠ No image.tar in backup, skipping load[/yellow]")
# 3. Pull image if not present
try:
client.images.get(image_name)
console.print(f" [dim]✓ Image {image_name} already present[/dim]")
except NotFound:
console.print(f" [dim]Pulling {image_name}…[/dim]")
try:
client.images.pull(image_name)
except Exception as e:
console.print(f" [red]Cannot pull image: {e}[/red]")
sys.exit(1)
# 4. Check for name collision
try:
existing = client.containers.get(container_name)
console.print(f" [yellow]Container {container_name} already exists (id={existing.short_id})[/yellow]")
answer = input(" Remove existing container? [y/N] ").strip().lower()
if answer == "y":
existing.remove(force=True)
else:
console.print("[red]Restore aborted.[/red]")
sys.exit(1)
except NotFound:
pass
# 5. Recreate volumes and restore data
with tarfile.open(backup_file, "r") as tar:
volume_members = [m for m in tar.getmembers()
if m.name.startswith("volumes/") and m.name.endswith(".tar")]
mounts = config.get("Mounts", [])
vol_map: dict[str, str] = {} # label -> actual volume name or bind path
for mount in mounts:
mtype = mount.get("Type", "")
dest = mount.get("Destination", "")
if mtype == "volume":
vol_name = mount.get("Name", "")
label = vol_name or dest.replace("/", "_")
else:
src = mount.get("Source", "")
label = src.replace("/", "_").lstrip("_")
vol_map[label] = src
continue # bind mounts: path must exist on target host
# Create volume if missing
try:
client.volumes.get(vol_name)
console.print(f" [dim]Volume {vol_name} already exists[/dim]")
except NotFound:
client.volumes.create(name=vol_name)
console.print(f" [dim]✓ Created volume {vol_name}[/dim]")
vol_map[label] = vol_name
# Restore volume data via a temporary Alpine container
with tarfile.open(backup_file, "r") as tar:
for vmember in volume_members:
label = vmember.name[len("volumes/"):-len(".tar")]
if label not in vol_map:
console.print(f" [yellow]⚠ No matching mount for volume backup: {label}[/yellow]")
continue
vol_name = vol_map[label]
dest_path = next(
(m["Destination"] for m in mounts
if (m.get("Name") == vol_name or
m.get("Source", "").replace("/", "_").lstrip("_") == label)),
None
)
if not dest_path:
continue
console.print(f" [dim]Restoring volume data: {vol_name}{dest_path}[/dim]")
vol_data = tar.extractfile(vmember).read()
# Spin up temp container with the volume mounted, restore via tar stream
if vol_map[label] == vol_name:
volumes_arg = {vol_name: {"bind": "/restore_target", "mode": "rw"}}
else:
volumes_arg = {vol_name: {"bind": "/restore_target", "mode": "rw"}}
try:
restore_ctr = client.containers.run(
"alpine",
command="sh -c 'cd /restore_target && tar xf /tmp/vol.tar --strip-components=1'",
volumes=volumes_arg,
detach=True,
remove=False,
)
# Upload the tar to the temp container
client.api.put_archive(restore_ctr.id, "/tmp", vol_data)
restore_ctr.wait()
restore_ctr.remove()
console.print(f" [dim]✓ Volume data restored: {vol_name}[/dim]")
except Exception as e:
console.print(f" [yellow]⚠ Could not restore volume data for {vol_name}: {e}[/yellow]")
# 6. Rebuild container create kwargs from inspect
cfg = config["Config"]
host_cfg = config["HostConfig"]
create_kwargs: dict = {
"image": image_name,
"name": container_name,
"command": cfg.get("Cmd"),
"entrypoint": cfg.get("Entrypoint"),
"environment": cfg.get("Env") or [],
"working_dir": cfg.get("WorkingDir") or None,
"user": cfg.get("User") or None,
"hostname": cfg.get("Hostname") or None,
"domainname": cfg.get("Domainname") or None,
"labels": cfg.get("Labels") or {},
"tty": cfg.get("Tty", False),
"stdin_open": cfg.get("OpenStdin", False),
}
# Ports
exposed = cfg.get("ExposedPorts") or {}
port_bindings = host_cfg.get("PortBindings") or {}
if exposed or port_bindings:
ports = {}
for port_proto in exposed:
bindings = port_bindings.get(port_proto)
if bindings:
host_ports = [b.get("HostPort") for b in bindings if b.get("HostPort")]
ports[port_proto] = host_ports or None
else:
ports[port_proto] = None
create_kwargs["ports"] = ports
# Restart policy
restart = host_cfg.get("RestartPolicy", {})
if restart.get("Name"):
create_kwargs["restart_policy"] = {
"Name": restart["Name"],
"MaximumRetryCount": restart.get("MaximumRetryCount", 0),
}
# Network mode
net_mode = host_cfg.get("NetworkMode", "default")
if net_mode not in ("default", "bridge"):
create_kwargs["network_mode"] = net_mode
# Volumes mounts
volume_binds = []
for mount in mounts:
mtype = mount.get("Type", "")
dest = mount.get("Destination", "")
mode = "rw" if not mount.get("RW") is False else "ro"
if mtype == "volume":
volume_binds.append(f"{mount['Name']}:{dest}:{mode}")
elif mtype == "bind":
volume_binds.append(f"{mount['Source']}:{dest}:{mode}")
if volume_binds:
create_kwargs["volumes"] = volume_binds
# Extra host config fields
if host_cfg.get("Privileged"):
create_kwargs["privileged"] = True
if host_cfg.get("NetworkMode") == "host":
create_kwargs["network_mode"] = "host"
cap_add = host_cfg.get("CapAdd") or []
if cap_add:
create_kwargs["cap_add"] = cap_add
devices = host_cfg.get("Devices") or []
if devices:
create_kwargs["devices"] = [
f"{d['PathOnHost']}:{d['PathInContainer']}:{d['CgroupPermissions']}"
for d in devices
]
# 7. Create container
try:
ctr = client.containers.create(**{k: v for k, v in create_kwargs.items() if v is not None})
console.print(f"[green]✓ Container created:[/green] {ctr.name} ({ctr.short_id})")
except Exception as e:
console.print(f"[red]Failed to create container: {e}[/red]")
sys.exit(1)
if start:
ctr.start()
console.print(f"[green]✓ Container started[/green]")
return ctr
# ── List ─────────────────────────────────────────────────────────────────────
def list_containers(hosts: list[str], all_containers: bool = False):
table = Table(title="Docker Containers")
table.add_column("Host", style="cyan")
table.add_column("Name", style="white")
table.add_column("Image", style="dim")
table.add_column("Status", style="green")
table.add_column("Ports", style="dim")
for host in hosts:
client = connect(host)
label = host
try:
containers = client.containers.list(all=all_containers)
for c in containers:
ports = ", ".join(
f"{v[0]['HostPort']}{k}" if v else k
for k, v in (c.ports or {}).items()
) or "-"
status = c.status
color = "green" if status == "running" else "yellow"
table.add_row(label, c.name, c.image.tags[0] if c.image.tags else c.image.short_id,
f"[{color}]{status}[/{color}]", ports)
label = "" # only show host label on first row
except Exception as e:
table.add_row(host, f"[red]ERROR: {e}[/red]", "", "", "")
console.print(table)
# ── CLI ──────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Backup and restore Docker containers across multiple hosts.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# List containers on multiple hosts
docker_backup.py list --host local --host tcp://192.168.1.10:2375
# Backup a container on a remote host
docker_backup.py backup myapp --host tcp://192.168.1.10:2375 --output /backups
# Backup with image saved
docker_backup.py backup myapp --host local --save-image
# Restore to a different host (pull image from registry)
docker_backup.py restore /backups/myapp_20240101_120000.tar --host tcp://192.168.1.20:2375
# Restore with a new name and auto-start
docker_backup.py restore /backups/myapp_20240101_120000.tar --name myapp2 --start
# Restore using image from backup file (no registry needed)
docker_backup.py restore /backups/myapp_20240101_120000.tar --load-image --start
"""
)
sub = parser.add_subparsers(dest="command", required=True)
# list
p_list = sub.add_parser("list", help="List containers across hosts")
p_list.add_argument("--host", action="append", default=[], dest="hosts",
metavar="HOST", help="Docker host (repeatable). Use 'local' for local socket.")
p_list.add_argument("-a", "--all", action="store_true", help="Show stopped containers too")
# backup
p_back = sub.add_parser("backup", help="Backup a container")
p_back.add_argument("container", help="Container name or ID")
p_back.add_argument("--host", default="local", metavar="HOST",
help="Docker host (default: local)")
p_back.add_argument("--output", "-o", default=".", metavar="DIR",
help="Output directory (default: current dir)")
p_back.add_argument("--save-image", action="store_true",
help="Also save the container image into the backup (large!)")
# restore
p_rest = sub.add_parser("restore", help="Restore a container from backup")
p_rest.add_argument("backup", help="Path to the backup .tar file")
p_rest.add_argument("--host", default="local", metavar="HOST",
help="Docker host to restore to (default: local)")
p_rest.add_argument("--name", metavar="NAME",
help="Override container name (default: original name)")
p_rest.add_argument("--start", action="store_true",
help="Start the container after restoring")
p_rest.add_argument("--load-image", action="store_true",
help="Load image from backup instead of pulling from registry")
args = parser.parse_args()
if args.command == "list":
hosts = args.hosts or ["local"]
list_containers(hosts, all_containers=args.all)
elif args.command == "backup":
client = connect(args.host)
output_dir = Path(args.output)
output_dir.mkdir(parents=True, exist_ok=True)
backup_container(client, args.container, output_dir, save_image=args.save_image)
elif args.command == "restore":
client = connect(args.host)
restore_container(client, Path(args.backup),
new_name=args.name,
start=args.start,
load_image=args.load_image)
if __name__ == "__main__":
main()