Updated to version 2.1.4
# 2.1.2 - Added EMAIL_TO and EMAIL_FROM variables for reports. # 2.1.3 - Added DOCKERUSER variable for account to use for launching contaners # 2.1.4 - Restic snapshot details (ID, parent, file/dir counts, data added, # total size, duration) are now written to the logs. # restic backup now runs with --json; snapshot size is read from the # backup summary instead of parsing 'restic snapshots' table output, # so it always refers to this run's snapshot. # Removed dependency on bc (awk used for arithmetic). # Added total new data uploaded to end of log.
This commit is contained in:
1 parent
fc26387aeb
commit
fac0d3202b
1 file changed
+117
-48
+117
-48
@@ -3,8 +3,8 @@
|
|||||||
### Docker Stack Backup Script
|
### Docker Stack Backup Script
|
||||||
### Created by Charlie Silverman - ackitsme3@gmail.com
|
### Created by Charlie Silverman - ackitsme3@gmail.com
|
||||||
###############################################################################
|
###############################################################################
|
||||||
### Version: 2.1.1
|
### Version: 2.1.4
|
||||||
### Updated: 2026-10-18
|
### Updated: 2026-10-26
|
||||||
### Purpose: Backs up all docker stacks in a given directory to Restic bucket
|
### Purpose: Backs up all docker stacks in a given directory to Restic bucket
|
||||||
###############################################################################
|
###############################################################################
|
||||||
### Requirements:
|
### Requirements:
|
||||||
@@ -38,6 +38,15 @@
|
|||||||
# Added automatic RUN_LOG cleanup during failure and interruption.
|
# Added automatic RUN_LOG cleanup during failure and interruption.
|
||||||
# Improved housekeeping to prevent orphaned staging data after
|
# Improved housekeeping to prevent orphaned staging data after
|
||||||
# unexpected termination.
|
# unexpected termination.
|
||||||
|
# 2.1.2 - Added EMAIL_TO and EMAIL_FROM variables for reports.
|
||||||
|
# 2.1.3 - Added DOCKERUSER variable for account to use for launching contaners
|
||||||
|
# 2.1.4 - Restic snapshot details (ID, parent, file/dir counts, data added,
|
||||||
|
# total size, duration) are now written to the logs.
|
||||||
|
# restic backup now runs with --json; snapshot size is read from the
|
||||||
|
# backup summary instead of parsing 'restic snapshots' table output,
|
||||||
|
# so it always refers to this run's snapshot.
|
||||||
|
# Removed dependency on bc (awk used for arithmetic).
|
||||||
|
# Added total new data uploaded to end of log.
|
||||||
|
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
@@ -45,10 +54,13 @@ set -Eeuo pipefail
|
|||||||
# Configuration
|
# Configuration
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
COMPOSE_ROOT="/home/ackitsme/docker"
|
COMPOSE_ROOT="/home/docker"
|
||||||
LOG_DIR="/home/ackitsme/docker/backuplogs"
|
LOG_DIR="/home/docker/backuplogs"
|
||||||
mkdir -p "${LOG_DIR}"
|
mkdir -p "${LOG_DIR}"
|
||||||
LOG_FILE="${LOG_DIR}/docker-backup_$(date +%Y%m%d-%H%M%S).log"
|
LOG_FILE="${LOG_DIR}/docker-backup_$(date +%Y%m%d-%H%M%S).log"
|
||||||
|
EMAIL_TO="charlie@kiscc.com"
|
||||||
|
EMAIL_FROM="kisprodweb.dockerbackup@kiscc.com"
|
||||||
|
DOCKERUSER="csilverman"
|
||||||
|
|
||||||
# Restic repository configuration
|
# Restic repository configuration
|
||||||
export RESTIC_ENV_FILE="/root/.restic-env"
|
export RESTIC_ENV_FILE="/root/.restic-env"
|
||||||
@@ -61,6 +73,7 @@ TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
|||||||
|
|
||||||
RUN_LOG="${LOG_DIR}/docker-backup_run_${TIMESTAMP}.log"
|
RUN_LOG="${LOG_DIR}/docker-backup_run_${TIMESTAMP}.log"
|
||||||
CURRENT_STAGING_DIR=""
|
CURRENT_STAGING_DIR=""
|
||||||
|
RESTIC_OUTPUT=""
|
||||||
|
|
||||||
touch "${RUN_LOG}"
|
touch "${RUN_LOG}"
|
||||||
|
|
||||||
@@ -68,7 +81,7 @@ touch "${RUN_LOG}"
|
|||||||
# Verify Requirements
|
# Verify Requirements
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
for cmd in docker restic tar gzip jq; do
|
for cmd in docker restic tar gzip jq awk; do
|
||||||
command -v "$cmd" >/dev/null 2>&1 || {
|
command -v "$cmd" >/dev/null 2>&1 || {
|
||||||
echo "Missing required command: $cmd"
|
echo "Missing required command: $cmd"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -91,8 +104,8 @@ fi
|
|||||||
|
|
||||||
## Log to screen and log file
|
## Log to screen and log file
|
||||||
log() {
|
log() {
|
||||||
local message
|
local message
|
||||||
message="[$(date '+%F %T')] $*"
|
message="[$(date '+%F %T')] $*"
|
||||||
|
|
||||||
echo "$message"
|
echo "$message"
|
||||||
|
|
||||||
@@ -100,6 +113,60 @@ log() {
|
|||||||
echo "$message" >> "$RUN_LOG"
|
echo "$message" >> "$RUN_LOG"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
## Convert a byte count to a human-readable size (e.g. 1.23GiB)
|
||||||
|
human_bytes() {
|
||||||
|
local bytes="${1:-0}"
|
||||||
|
|
||||||
|
if command -v numfmt >/dev/null 2>&1; then
|
||||||
|
numfmt --to=iec-i --suffix=B --format="%.2f" "$bytes" 2>/dev/null \
|
||||||
|
|| echo "${bytes} B"
|
||||||
|
else
|
||||||
|
echo "${bytes} B"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
## Write Restic snapshot details from a backup summary (JSON) to the log
|
||||||
|
log_snapshot_info() {
|
||||||
|
local summary="$1"
|
||||||
|
|
||||||
|
local snapshot_id parent_id
|
||||||
|
local files_new files_changed files_unmodified
|
||||||
|
local dirs_new dirs_changed dirs_unmodified
|
||||||
|
local data_added data_added_packed
|
||||||
|
local total_files total_bytes total_duration
|
||||||
|
|
||||||
|
snapshot_id=$(jq -r '.snapshot_id // "none"' <<< "$summary")
|
||||||
|
parent_id=$(restic snapshots "$snapshot_id" --json 2>/dev/null \
|
||||||
|
| jq -r '.[0].parent // "none"' 2>/dev/null || echo "unknown")
|
||||||
|
|
||||||
|
files_new=$(jq -r '.files_new // 0' <<< "$summary")
|
||||||
|
files_changed=$(jq -r '.files_changed // 0' <<< "$summary")
|
||||||
|
files_unmodified=$(jq -r '.files_unmodified // 0' <<< "$summary")
|
||||||
|
dirs_new=$(jq -r '.dirs_new // 0' <<< "$summary")
|
||||||
|
dirs_changed=$(jq -r '.dirs_changed // 0' <<< "$summary")
|
||||||
|
dirs_unmodified=$(jq -r '.dirs_unmodified // 0' <<< "$summary")
|
||||||
|
data_added=$(jq -r '.data_added // 0' <<< "$summary")
|
||||||
|
data_added_packed=$(jq -r '.data_added_packed // empty' <<< "$summary")
|
||||||
|
total_files=$(jq -r '.total_files_processed // 0' <<< "$summary")
|
||||||
|
total_bytes=$(jq -r '.total_bytes_processed // 0' <<< "$summary")
|
||||||
|
total_duration=$(jq -r '.total_duration // 0' <<< "$summary")
|
||||||
|
|
||||||
|
log "---------------- Restic Snapshot -----------------"
|
||||||
|
log "Snapshot ID: ${snapshot_id:0:8} (${snapshot_id})"
|
||||||
|
log "Parent snapshot: ${parent_id:0:8}"
|
||||||
|
log "Tags: docker, compose, ${project_name}, ${HOSTNAME_SHORT}"
|
||||||
|
log "Files: ${files_new} new, ${files_changed} changed, ${files_unmodified} unmodified"
|
||||||
|
log "Directories: ${dirs_new} new, ${dirs_changed} changed, ${dirs_unmodified} unmodified"
|
||||||
|
if [[ -n "$data_added_packed" ]]; then
|
||||||
|
log "Data added: $(human_bytes "$data_added") ($(human_bytes "$data_added_packed") stored)"
|
||||||
|
else
|
||||||
|
log "Data added: $(human_bytes "$data_added")"
|
||||||
|
fi
|
||||||
|
log "Snapshot size: $(human_bytes "$total_bytes") in ${total_files} files"
|
||||||
|
log "Duration: $(awk -v d="$total_duration" 'BEGIN{printf "%.1f", d}') seconds"
|
||||||
|
log "--------------------------------------------------"
|
||||||
|
}
|
||||||
|
|
||||||
## Check if specified project is in the exception list
|
## Check if specified project is in the exception list
|
||||||
should_skip() {
|
should_skip() {
|
||||||
local project="$1"
|
local project="$1"
|
||||||
@@ -123,7 +190,7 @@ restart_stack() {
|
|||||||
|
|
||||||
log "Restarting stack: ${compose_dir}"
|
log "Restarting stack: ${compose_dir}"
|
||||||
|
|
||||||
su - ackitsme -c \
|
su - $DOCKERUSER -c \
|
||||||
"cd '${compose_dir}' && docker compose up -d" \
|
"cd '${compose_dir}' && docker compose up -d" \
|
||||||
|| log "WARNING: Failed to restart stack ${compose_dir}"
|
|| log "WARNING: Failed to restart stack ${compose_dir}"
|
||||||
}
|
}
|
||||||
@@ -132,21 +199,7 @@ restart_stack() {
|
|||||||
send_email_log() {
|
send_email_log() {
|
||||||
local subject="$1"
|
local subject="$1"
|
||||||
|
|
||||||
if command -v mailx >/dev/null 2>&1; then
|
mailx -s "$subject" -r "$EMAIL_FROM" "$EMAIL_TO" < "$RUN_LOG"
|
||||||
|
|
||||||
mailx -s "$subject" \
|
|
||||||
ackitsme3@gmail.com < "$RUN_LOG"
|
|
||||||
|
|
||||||
elif command -v mail >/dev/null 2>&1; then
|
|
||||||
|
|
||||||
mail -s "$subject" \
|
|
||||||
ackitsme3@gmail.com < "$RUN_LOG"
|
|
||||||
|
|
||||||
else
|
|
||||||
|
|
||||||
log "WARNING: mail/mailx command not found. Email not sent."
|
|
||||||
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
## Cleanup Function
|
## Cleanup Function
|
||||||
@@ -161,6 +214,10 @@ cleanup_staging() {
|
|||||||
log "WARNING: Failed to remove ${CURRENT_STAGING_DIR}"
|
log "WARNING: Failed to remove ${CURRENT_STAGING_DIR}"
|
||||||
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${RESTIC_OUTPUT:-}" ]]; then
|
||||||
|
rm -f "${RESTIC_OUTPUT}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
## Error Handler
|
## Error Handler
|
||||||
@@ -185,7 +242,7 @@ error_handler() {
|
|||||||
|
|
||||||
## Interrupt Handler
|
## Interrupt Handler
|
||||||
interrupt_handler() {
|
interrupt_handler() {
|
||||||
trap - SIGINT SIGTERM
|
trap - SIGINT SIGTERM
|
||||||
|
|
||||||
log "ERROR: Backup interrupted"
|
log "ERROR: Backup interrupted"
|
||||||
|
|
||||||
@@ -194,7 +251,7 @@ interrupt_handler() {
|
|||||||
send_email_log \
|
send_email_log \
|
||||||
"INTERRUPTED: Docker Backup (${HOSTNAME_SHORT})"
|
"INTERRUPTED: Docker Backup (${HOSTNAME_SHORT})"
|
||||||
|
|
||||||
rm -f "${RUN_LOG}"
|
rm -f "${RUN_LOG}"
|
||||||
|
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
@@ -240,6 +297,7 @@ fi
|
|||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
total_size=0.00
|
total_size=0.00
|
||||||
|
total_added=0
|
||||||
|
|
||||||
for compose_file in "${COMPOSE_FILES[@]}"; do
|
for compose_file in "${COMPOSE_FILES[@]}"; do
|
||||||
|
|
||||||
@@ -257,7 +315,7 @@ for compose_file in "${COMPOSE_FILES[@]}"; do
|
|||||||
log "=================================================="
|
log "=================================================="
|
||||||
|
|
||||||
STAGING_DIR="/home/Backups/docker-backup-${project_name}"
|
STAGING_DIR="/home/Backups/docker-backup-${project_name}"
|
||||||
CURRENT_STAGING_DIR="${STAGING_DIR}"
|
CURRENT_STAGING_DIR="${STAGING_DIR}"
|
||||||
|
|
||||||
mkdir -p \
|
mkdir -p \
|
||||||
"${STAGING_DIR}/images" \
|
"${STAGING_DIR}/images" \
|
||||||
@@ -327,8 +385,8 @@ for compose_file in "${COMPOSE_FILES[@]}"; do
|
|||||||
|
|
||||||
log "Saving image: $image"
|
log "Saving image: $image"
|
||||||
|
|
||||||
docker save "$image" \
|
docker save "$image" \
|
||||||
> "${STAGING_DIR}/images/${safe_name}.tar"
|
> "${STAGING_DIR}/images/${safe_name}.tar"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
done < "${STAGING_DIR}/metadata/images.list"
|
done < "${STAGING_DIR}/metadata/images.list"
|
||||||
@@ -380,43 +438,53 @@ for compose_file in "${COMPOSE_FILES[@]}"; do
|
|||||||
|
|
||||||
log "Uploading project backup to Restic..."
|
log "Uploading project backup to Restic..."
|
||||||
|
|
||||||
|
# Capture restic's JSON output (outside the staging dir so it is
|
||||||
|
# not part of the backup). Errors/warnings still go to stderr.
|
||||||
|
RESTIC_OUTPUT="$(mktemp /tmp/restic-backup-XXXXXX.json)"
|
||||||
|
|
||||||
restic backup \
|
restic backup \
|
||||||
"${STAGING_DIR}" \
|
"${STAGING_DIR}" \
|
||||||
|
--json \
|
||||||
--tag docker \
|
--tag docker \
|
||||||
--tag compose \
|
--tag compose \
|
||||||
--tag "${project_name}" \
|
--tag "${project_name}" \
|
||||||
--tag "${HOSTNAME_SHORT}"
|
--tag "${HOSTNAME_SHORT}" \
|
||||||
|
> "${RESTIC_OUTPUT}"
|
||||||
|
|
||||||
###################################################
|
###################################################
|
||||||
# Get backup size
|
# Log snapshot details and get backup size
|
||||||
###################################################
|
###################################################
|
||||||
# lastsnap=$(restic ls latest)
|
|
||||||
# lastsnapid=$(echo "$lastsnap" | awk '{print $2}')
|
summary=$(jq -cR 'fromjson? | select(.message_type == "summary")' \
|
||||||
lastsnapid=$(restic snapshots --json | jq -r '.[-1].short_id')
|
"${RESTIC_OUTPUT}" | tail -n 1)
|
||||||
# lastsnapid=$(restic snapshots latest --compact | awk 'NR==3 {print $1}')
|
|
||||||
snapdata=$(restic snapshots "$lastsnapid")
|
rm -f "${RESTIC_OUTPUT}"
|
||||||
snapnum=$(echo $snapdata | awk '{print $14}')
|
RESTIC_OUTPUT=""
|
||||||
snapsu=$(echo $snapdata | awk '{print $15}')
|
|
||||||
case "$snapsu" in
|
if [[ -n "$summary" ]]; then
|
||||||
"TiB")
|
log_snapshot_info "$summary"
|
||||||
snapnum=$(echo "scale=2;$snapnum * 1024" | bc)
|
|
||||||
;;
|
snap_bytes=$(jq -r '.total_bytes_processed // 0' <<< "$summary")
|
||||||
"MiB")
|
added_bytes=$(jq -r '.data_added // 0' <<< "$summary")
|
||||||
snapnum=$(echo "scale=2;$snapnum / 1024" | bc)
|
else
|
||||||
;;
|
log "WARNING: Restic returned no snapshot summary for ${project_name}"
|
||||||
esac
|
snap_bytes=0
|
||||||
snapnum=$(printf "%.2f\n" "$snapnum")
|
added_bytes=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
snapnum=$(awk -v b="$snap_bytes" 'BEGIN{printf "%.2f", b / 1073741824}')
|
||||||
|
|
||||||
###################################################
|
###################################################
|
||||||
# Cleanup Local Backup
|
# Cleanup Local Backup
|
||||||
###################################################
|
###################################################
|
||||||
|
|
||||||
rm -rf "${STAGING_DIR}"
|
rm -rf "${STAGING_DIR}"
|
||||||
CURRENT_STAGING_DIR=""
|
CURRENT_STAGING_DIR=""
|
||||||
|
|
||||||
log "Project complete: ${project_name} ${snapnum} GiB"
|
log "Project complete: ${project_name} ${snapnum} GiB"
|
||||||
log ""
|
log ""
|
||||||
total_size=$(bc <<< "$total_size + $snapnum")
|
total_size=$(awk -v a="$total_size" -v b="$snapnum" 'BEGIN{printf "%.2f", a + b}')
|
||||||
|
total_added=$(awk -v a="$total_added" -v b="$added_bytes" 'BEGIN{printf "%.0f", a + b}')
|
||||||
done
|
done
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
@@ -433,6 +501,7 @@ restic forget \
|
|||||||
|
|
||||||
log "All compose projects backed up successfully."
|
log "All compose projects backed up successfully."
|
||||||
log "Total Backup Size: $total_size GiB"
|
log "Total Backup Size: $total_size GiB"
|
||||||
|
log "Total New Data Uploaded: $(human_bytes "$total_added")"
|
||||||
|
|
||||||
send_email_log \
|
send_email_log \
|
||||||
"SUCCESS: Docker Backup (${HOSTNAME_SHORT})" || \
|
"SUCCESS: Docker Backup (${HOSTNAME_SHORT})" || \
|
||||||
|
|||||||
Reference in new issue
Block a user