- Modified to save all files to a folder tree for backup rather than creating a single monolithic archive. This will allow restic to properly manage snapshots. - Changed exception lookup to case-insensitive
335 lines
9.9 KiB
Bash
335 lines
9.9 KiB
Bash
#!/usr/bin/env bash
|
|
###############################################################################
|
|
### Docker Stack Backup Script
|
|
### Created by Charlie Silverman - ackitsme3@gmail.com
|
|
###############################################################################
|
|
### Version: 2.0.1
|
|
### Updated: 2026-10-18
|
|
### Purpose: Backs up all docker stacks in a given directory to Restic bucket
|
|
###############################################################################
|
|
### Requirements:
|
|
### Restic environment file: /root/.restic-env
|
|
### File format:
|
|
### export AWS_ACCESS_KEY_ID="[S3 Bucket Access Key ID]"
|
|
### export AWS_SECRET_ACCESS_KEY="[S3 Secret Access Key]"
|
|
### export RESTIC_REPOSITORY="Restic repository URL"
|
|
### export RESTIC_PASSWORD_FILE="Filepath to text file with restic password"
|
|
### Exception list: Optionally store a list of exceptions in the file
|
|
### dockerbackup-exception.list in the same directory
|
|
### as this file. File should be one entry per line.
|
|
### Each entry can contain wildcards if neeeded.
|
|
###############################################################################
|
|
# Version History:
|
|
# 1.0.0 - Initial build
|
|
# 1.1.0 - Added exception list via text file
|
|
# 1.1.1 - Added snapshot size to log
|
|
# 1.1.2 - Added aggregate backup size to end of log
|
|
# 1.1.3 - Added GiB to total backup size information
|
|
# 2.0.0 - Modified to save all files to a folder tree for backup rather than
|
|
# creating a single monolithic archive. This will allow restic to
|
|
# properly manage snapshots
|
|
# 2.0.1 - Changed exception lookup to case-insensitive
|
|
|
|
set -Eeuo pipefail
|
|
|
|
###############################################################################
|
|
# Configuration
|
|
###############################################################################
|
|
|
|
COMPOSE_ROOT="/home/ackitsme/docker"
|
|
LOG_DIR="/home/ackitsme/docker/backuplogs"
|
|
LOG_FILE="${LOG_DIR}/docker-backup_$(date +%Y%m%d-%H%M%S).log"
|
|
|
|
# Restic repository configuration
|
|
export RESTIC_REPOSITORY="s3:https://s3.us-west-004.backblazeb2.com/ackitsbucket/docker"
|
|
export RESTIC_ENV_FILE="/root/.restic-env"
|
|
|
|
# Load Restic environment
|
|
source $RESTIC_ENV_FILE
|
|
|
|
HOSTNAME_SHORT="$(hostname -s)"
|
|
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
|
|
|
# Load exceptions, if any
|
|
if [ -f "dockerbackup-exception.list" ]; then
|
|
mapfile -t SKIP_PROJECTS < dockerbackup-exception.list
|
|
fi
|
|
|
|
|
|
###############################################################################
|
|
# Functions
|
|
###############################################################################
|
|
|
|
## Log to screen and log file
|
|
log() {
|
|
local message="[$(date '+%F %T')] $*"
|
|
|
|
echo "$message"
|
|
echo "$message" >> "$LOG_FILE"
|
|
}
|
|
|
|
## Check if specified project is in the exception list
|
|
should_skip() {
|
|
local project="$1"
|
|
|
|
shopt -s nocasematch
|
|
|
|
for pattern in "${SKIP_PROJECTS[@]}"; do
|
|
if [[ "$project" == $pattern ]]; then
|
|
return 0
|
|
fi
|
|
done
|
|
|
|
shopt -u nocasematch
|
|
|
|
return 1
|
|
}
|
|
|
|
## Check if docker container is running
|
|
is_container_running() {
|
|
local container_name="$1"
|
|
|
|
docker inspect -f '{{.State.Running}}' "$container_name" 2>/dev/null | grep -q '^true$'
|
|
}
|
|
|
|
## Restart composer stack
|
|
restart_stack() {
|
|
|
|
local compose_dir="$1"
|
|
|
|
log "Restarting stack: ${compose_dir}"
|
|
|
|
(
|
|
sudo -u ackitsme docker compose up -d
|
|
su - ackitsme -c "cd ${compose_dir};docker compose up -d"
|
|
|
|
) || log "WARNING: Failed to restart stack ${compose_dir}"
|
|
}
|
|
|
|
###############################################################################
|
|
# Verify Requirements
|
|
###############################################################################
|
|
|
|
for cmd in docker restic tar gzip; do
|
|
command -v "$cmd" >/dev/null 2>&1 || {
|
|
echo "Missing required command: $cmd"
|
|
exit 1
|
|
}
|
|
done
|
|
|
|
###############################################################################
|
|
# Locate Compose Files
|
|
###############################################################################
|
|
|
|
mapfile -t COMPOSE_FILES < <(
|
|
find "$COMPOSE_ROOT" \
|
|
\( -name "docker-compose.yml" \
|
|
-o -name "docker-compose.yaml" \
|
|
-o -name "compose.yml" \
|
|
-o -name "compose.yaml" \) \
|
|
| sort
|
|
)
|
|
|
|
if [ "${#COMPOSE_FILES[@]}" -eq 0 ]; then
|
|
log "No compose files found."
|
|
exit 0
|
|
fi
|
|
|
|
###############################################################################
|
|
# Process Each Compose Project
|
|
###############################################################################
|
|
|
|
total_size=0
|
|
|
|
for compose_file in "${COMPOSE_FILES[@]}"; do
|
|
|
|
compose_dir="$(dirname "$compose_file")"
|
|
project_name="$(basename "$compose_dir")"
|
|
|
|
if should_skip "$project_name"; then
|
|
log "Skipping project: ${project_name}"
|
|
continue
|
|
fi
|
|
|
|
log "=================================================="
|
|
log "Processing project: ${project_name}"
|
|
log "Compose file: ${compose_file}"
|
|
log "=================================================="
|
|
|
|
STAGING_DIR="/home/Backups/docker-backup-${project_name}"
|
|
|
|
mkdir -p \
|
|
"${STAGING_DIR}/images" \
|
|
"${STAGING_DIR}/volumes" \
|
|
"${STAGING_DIR}/metadata"
|
|
|
|
###################################################
|
|
# Gather Information Before Shutdown
|
|
###################################################
|
|
|
|
cd "$compose_dir"
|
|
|
|
docker compose config --images \
|
|
| sort -u \
|
|
> "${STAGING_DIR}/metadata/images.list" || true
|
|
|
|
docker compose ps -q \
|
|
> "${STAGING_DIR}/metadata/containers.list" || true
|
|
|
|
###################################################
|
|
# Determine Volumes Used By Containers
|
|
###################################################
|
|
|
|
> "${STAGING_DIR}/metadata/volumes.list"
|
|
|
|
while read -r container_id; do
|
|
|
|
[ -z "$container_id" ] && continue
|
|
|
|
docker inspect "$container_id" \
|
|
--format '{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}}{{println}}{{end}}{{end}}'
|
|
|
|
done < "${STAGING_DIR}/metadata/containers.list" \
|
|
| sort -u \
|
|
> "${STAGING_DIR}/metadata/volumes.list"
|
|
|
|
###################################################
|
|
# Stop Stack
|
|
###################################################
|
|
|
|
log "Stopping stack..."
|
|
|
|
docker compose down --timeout 60
|
|
|
|
###################################################
|
|
# Backup Compose Files
|
|
###################################################
|
|
|
|
log "Backing up compose files..."
|
|
mkdir -p "${STAGING_DIR}/compose"
|
|
|
|
cp -a \
|
|
"${compose_dir}" \
|
|
"${STAGING_DIR}/compose/"
|
|
|
|
###################################################
|
|
# Backup Images
|
|
###################################################
|
|
|
|
log "Backing up images..."
|
|
|
|
while read -r image; do
|
|
|
|
[ -z "$image" ] && continue
|
|
|
|
if docker image inspect "$image" >/dev/null 2>&1; then
|
|
|
|
safe_name=$(echo "$image" | sed 's#[/:]#_#g')
|
|
|
|
log "Saving image: $image"
|
|
|
|
docker save "$image" \
|
|
> "${STAGING_DIR}/images/${safe_name}.tar"
|
|
fi
|
|
|
|
done < "${STAGING_DIR}/metadata/images.list"
|
|
|
|
###################################################
|
|
# Backup Volumes
|
|
###################################################
|
|
|
|
log "Backing up volumes..."
|
|
|
|
while read -r volume; do
|
|
|
|
[ -z "$volume" ] && continue
|
|
|
|
log "Saving volume: $volume"
|
|
|
|
docker run --rm \
|
|
-v "${volume}:/source:ro" \
|
|
-v "${STAGING_DIR}/volumes:/backup" \
|
|
alpine \
|
|
sh -c "cd /source && tar cf - . | tar xf - -C /backup"
|
|
|
|
done < "${STAGING_DIR}/metadata/volumes.list"
|
|
|
|
###################################################
|
|
# Metadata
|
|
###################################################
|
|
|
|
docker image ls \
|
|
> "${STAGING_DIR}/metadata/docker-images.txt"
|
|
|
|
docker volume ls \
|
|
> "${STAGING_DIR}/metadata/docker-volumes.txt"
|
|
|
|
docker network ls \
|
|
> "${STAGING_DIR}/metadata/docker-networks.txt"
|
|
|
|
docker compose config \
|
|
> "${STAGING_DIR}/metadata/rendered-compose.yml" || true
|
|
|
|
###################################################
|
|
# Restart Stack
|
|
###################################################
|
|
|
|
restart_stack "${compose_dir}"
|
|
|
|
###################################################
|
|
# Restic Backup
|
|
###################################################
|
|
|
|
log "Uploading project backup to Restic..."
|
|
|
|
restic backup \
|
|
"${STAGING_DIR}" \
|
|
--tag docker \
|
|
--tag compose \
|
|
--tag "${project_name}" \
|
|
--tag "${HOSTNAME_SHORT}"
|
|
|
|
###################################################
|
|
# Get backup size
|
|
###################################################
|
|
lastsnap=`restic ls latest`
|
|
lastsnapid=$(echo "$lastsnap" | awk '{print $2}')
|
|
snapdata=`restic snapshots $lastsnapid`
|
|
snapnum=$(echo $snapdata | awk '{print $14}')
|
|
snapsu=$(echo $snapdata | awk '{print $15}')
|
|
case "$snapsu" in
|
|
"TiB")
|
|
snapnum=$(echo "scale=2;$snapnum * 1024" | bc)
|
|
;;
|
|
"MiB")
|
|
snapnum=$(echo "scale=2;$snapnum / 1024" | bc)
|
|
;;
|
|
esac
|
|
snapnum=$(printf "%.2f\n" "$snapnum")
|
|
|
|
###################################################
|
|
# Cleanup Local Backup
|
|
###################################################
|
|
|
|
rm -rf "${STAGING_DIR}"
|
|
|
|
log "Project complete: ${project_name} ${snapnum} GiB"
|
|
log ""
|
|
total_size=$(bc <<< "$total_size + $snapnum")
|
|
done
|
|
|
|
###############################################################################
|
|
# Retention Policy
|
|
###############################################################################
|
|
|
|
log "Applying Restic retention policy..."
|
|
|
|
restic forget \
|
|
--keep-daily 7 \
|
|
--keep-weekly 4 \
|
|
--keep-monthly 12 \
|
|
--prune
|
|
|
|
log "All compose projects backed up successfully."
|
|
log "Total Backup Size: $total_size GiB"
|