feat(ops): обязательный backup БД и файлов перед деплоем

This commit is contained in:
2026-10-26 15:47:22 +03:00
parent de9535d35c
commit 7c45de8d9f
9 changed files with 447 additions and 12 deletions

260
deploy/backup.sh Executable file
View File

@@ -0,0 +1,260 @@
#!/usr/bin/env bash
# Резервное копирование и восстановление LMS.
#
# ./deploy/backup.sh backup # БД + файлы, с retention
# ./deploy/backup.sh backup --if-running # пропустить, если стек не запущен
# ./deploy/backup.sh list
# ./deploy/backup.sh verify <dir>
# ./deploy/backup.sh restore <dir> # destructive, требует ввода RESTORE
#
# Переменные:
# LMS_BACKUP_DIR каталог бэкапов (по умолчанию ./backups)
# LMS_BACKUP_KEEP_DAYS сколько дней хранить (по умолчанию 14)
# LMS_COMPOSE_PROJECT имя compose-проекта (по умолчанию lms)
set -euo pipefail
cd "$(dirname "$0")/.."
COMPOSE_FILE="docker-compose.prod.yml"
ENV_FILE=".env.prod"
BACKUP_ROOT="${LMS_BACKUP_DIR:-$PWD/backups}"
KEEP_DAYS="${LMS_BACKUP_KEEP_DAYS:-14}"
PROJECT="${LMS_COMPOSE_PROJECT:-lms}"
die() {
echo "Ошибка: $*" >&2
exit 1
}
info() {
echo ">> $*"
}
usage() {
cat >&2 <<'EOF'
usage:
deploy/backup.sh backup [--if-running]
deploy/backup.sh list
deploy/backup.sh verify <backup-dir>
deploy/backup.sh restore <backup-dir>
Переменные окружения: LMS_BACKUP_DIR, LMS_BACKUP_KEEP_DAYS, LMS_COMPOSE_PROJECT.
EOF
exit 1
}
require_docker() {
command -v docker >/dev/null 2>&1 || die "нужен Docker"
docker compose version >/dev/null 2>&1 || die "нужен Docker Compose v2"
}
load_env() {
[[ -f "$ENV_FILE" ]] || die "не найден $ENV_FILE"
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
: "${LMS_DB_NAME:=lms}"
: "${LMS_DB_USER:=lms}"
# Переменные из .env.prod должны применяться и к путям backup.
BACKUP_ROOT="${LMS_BACKUP_DIR:-$PWD/backups}"
KEEP_DAYS="${LMS_BACKUP_KEEP_DAYS:-14}"
PROJECT="${LMS_COMPOSE_PROJECT:-lms}"
}
compose() {
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "$@"
}
postgres_running() {
compose ps --status running --services 2>/dev/null | grep -qx 'postgres'
}
ensure_volumes() {
local volume
for volume in "${PROJECT}_lms_submissions" "${PROJECT}_lms_assets"; do
docker volume inspect "$volume" >/dev/null 2>&1 || die "не найден volume $volume"
done
}
backup() {
local if_running=0
[[ "${1:-}" == "--if-running" ]] && if_running=1
require_docker
load_env
if ! postgres_running; then
if ((if_running)); then
info "PostgreSQL не запущен — backup пропущен"
return 0
fi
die "PostgreSQL не запущен; запустите стек или используйте --if-running"
fi
ensure_volumes
mkdir -p "$BACKUP_ROOT"
chmod 700 "$BACKUP_ROOT"
# Не даём двум backup-процессам писать одновременно.
exec 9>"$BACKUP_ROOT/.backup.lock"
command -v flock >/dev/null 2>&1 || die "нужен flock (util-linux)"
flock -n 9 || die "уже выполняется другой backup"
local timestamp dir tmp
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
dir="$BACKUP_ROOT/lms-$timestamp"
tmp="$dir.tmp"
[[ ! -e "$dir" ]] || die "каталог уже существует: $dir"
rm -rf "$tmp"
mkdir -p "$tmp"
# shellcheck disable=SC2064
trap "rm -rf '$tmp'" EXIT
info "Дамп базы данных..."
compose exec -T postgres sh -c \
'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom --no-owner --no-privileges' \
>"$tmp/db.dump"
[[ -s "$tmp/db.dump" ]] || die "pg_dump вернул пустой файл"
info "Архив файлов (решения и материалы)..."
docker run --rm \
-v "${PROJECT}_lms_submissions:/submissions:ro" \
-v "${PROJECT}_lms_assets:/assets:ro" \
-v "$tmp":/backup \
postgres:18 sh -c \
'tar czf /backup/submissions.tar.gz -C /submissions . && tar czf /backup/assets.tar.gz -C /assets .'
gzip -t "$tmp/submissions.tar.gz"
gzip -t "$tmp/assets.tar.gz"
cat >"$tmp/manifest.txt" <<EOF
backup_version=1
created_at=$timestamp
db_name=$LMS_DB_NAME
db_user=$LMS_DB_USER
compose_project=$PROJECT
keep_days=$KEEP_DAYS
EOF
(
cd "$tmp"
sha256sum db.dump submissions.tar.gz assets.tar.gz >SHA256SUMS
)
chmod 700 "$tmp"
mv "$tmp" "$dir"
trap - EXIT
info "Backup готов: $dir"
du -sh "$dir"/* | sed 's/^/ /'
if [[ "$KEEP_DAYS" =~ ^[0-9]+$ ]] && ((KEEP_DAYS > 0)); then
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'lms-*' \
-mtime +"$KEEP_DAYS" -print -exec rm -rf {} + 2>/dev/null || true
fi
}
list_backups() {
if [[ -f "$ENV_FILE" ]]; then
load_env
fi
if [[ ! -d "$BACKUP_ROOT" ]]; then
echo "Каталог бэкапов не найден: $BACKUP_ROOT"
return 0
fi
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'lms-*' -printf '%f\n' | sort
}
verify_backup() {
local dir="${1:-}"
[[ -n "$dir" ]] || usage
[[ -d "$dir" ]] || die "нет каталога: $dir"
dir="$(cd "$dir" && pwd)"
[[ -f "$dir/SHA256SUMS" ]] || die "нет SHA256SUMS"
[[ -s "$dir/db.dump" ]] || die "нет db.dump"
[[ -s "$dir/submissions.tar.gz" ]] || die "нет submissions.tar.gz"
[[ -s "$dir/assets.tar.gz" ]] || die "нет assets.tar.gz"
require_docker
(
cd "$dir"
sha256sum -c SHA256SUMS
)
gzip -t "$dir/submissions.tar.gz"
gzip -t "$dir/assets.tar.gz"
docker run --rm -v "$dir":/backup:ro postgres:18 \
pg_restore --list /backup/db.dump >/dev/null
info "Backup корректен: $dir"
}
restore_backup() {
local dir="${1:-}"
[[ -n "$dir" ]] || usage
[[ -d "$dir" ]] || die "нет каталога: $dir"
dir="$(cd "$dir" && pwd)"
verify_backup "$dir"
load_env
postgres_running || die "PostgreSQL не запущен"
ensure_volumes
echo
echo "Будут восстановлены БД '$LMS_DB_NAME' и файлы из:"
echo " $dir"
echo "Текущие данные будут удалены."
read -r -p "Введите RESTORE для продолжения: " answer
[[ "$answer" == "RESTORE" ]] || die "восстановление отменено"
info "Останавливаю прикладные сервисы..."
compose stop web gateway identity academic progress >/dev/null 2>&1 || true
info "Пересоздаю базу данных..."
compose exec -T postgres sh -c \
'dropdb -U "$POSTGRES_USER" --if-exists "$POSTGRES_DB" && createdb -U "$POSTGRES_USER" "$POSTGRES_DB"'
info "Восстанавливаю БД..."
compose exec -T postgres sh -c \
'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --no-owner --no-privileges' \
<"$dir/db.dump"
info "Восстанавливаю файлы..."
docker run --rm \
-v "${PROJECT}_lms_submissions:/submissions" \
-v "$dir":/backup:ro \
postgres:18 sh -c \
'find /submissions -mindepth 1 -maxdepth 1 -exec rm -rf {} +; tar xzf /backup/submissions.tar.gz -C /submissions'
docker run --rm \
-v "${PROJECT}_lms_assets:/assets" \
-v "$dir":/backup:ro \
postgres:18 sh -c \
'find /assets -mindepth 1 -maxdepth 1 -exec rm -rf {} +; tar xzf /backup/assets.tar.gz -C /assets'
info "Запускаю сервисы..."
compose up -d
local attempt
for attempt in $(seq 1 40); do
if curl -fsS "http://127.0.0.1:${LMS_WEB_PORT:-8090}/actuator/health" >/dev/null 2>&1; then
info "Восстановление завершено, web здоров"
return 0
fi
sleep 2
done
die "сервисы не поднялись за 80 секунд; проверьте ./deploy/deploy.sh logs web"
}
case "${1:-backup}" in
backup)
shift
backup "${1:-}"
;;
list)
list_backups
;;
verify)
verify_backup "${2:-}"
;;
restore)
restore_backup "${2:-}"
;;
*)
usage
;;
esac