diff --git a/.env.prod.example b/.env.prod.example index c9793b7..0f88a23 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -12,3 +12,7 @@ LMS_WEB_PORT=8090 # Домен (используется в deploy-скрипте/nginx). LMS_DOMAIN=lms.our-place.ru LETSENCRYPT_EMAIL=admin@our-place.ru + +# Резервное копирование (см. deploy/BACKUP.md). +LMS_BACKUP_DIR=/opt/lms/backups +LMS_BACKUP_KEEP_DAYS=14 diff --git a/.gitignore b/.gitignore index 1422e8b..bae232f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ build/ .env .env.prod /data/ +/backups/ node_modules/ # Локальные сессии Kilo Code — не часть репозитория. .kilo/ diff --git a/README.md b/README.md index ef096d4..03858fc 100644 --- a/README.md +++ b/README.md @@ -69,12 +69,15 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`. ```bash cp .env.prod.example .env.prod # заполнить LMS_DB_PASSWORD -./deploy/deploy.sh up # сборка + запуск (postgres, 5 сервисов) +./deploy/deploy.sh up # backup + сборка + запуск (postgres, 5 сервисов) ./deploy/deploy.sh logs web ./deploy/deploy.sh ps ./deploy/deploy.sh down ``` +Перед обновлением `deploy.sh up` обязательно делает backup БД и файлов. +Ежедневное расписание, retention и восстановление — в [`deploy/BACKUP.md`](deploy/BACKUP.md). + Наружу публикуется только web на `127.0.0.1:8090`; reverse proxy — nginx (конфиг и полная инструкция по деплою на `lms.our-place.ru` в [`deploy/DEPLOY.md`](deploy/DEPLOY.md) и `deploy/nginx/lms.our-place.ru.conf`). diff --git a/deploy/BACKUP.md b/deploy/BACKUP.md new file mode 100644 index 0000000..7a3076b --- /dev/null +++ b/deploy/BACKUP.md @@ -0,0 +1,114 @@ +# Резервное копирование LMS + +Резервное копирование обязательно: + +- **перед каждым обновлением** (`./deploy/deploy.sh up` делает backup автоматически); +- **ежедневно** по расписанию (systemd timer); +- перед миграциями и любыми ручными изменениями схемы/данных. + +## Что входит в backup + +Один каталог `backups/lms-/`: + +| Файл | Содержимое | +|---|---| +| `db.dump` | `pg_dump --format=custom` всей БД `lms` | +| `submissions.tar.gz` | volume `lms_lms_submissions` — решения студентов | +| `assets.tar.gz` | volume `lms_lms_assets` — файлы материалов | +| `SHA256SUMS` | контрольные суммы | +| `manifest.txt` | дата, БД, пользователь, проект, retention | + +Каталоги бэкапов создаются с правами `700`: внутри персональные данные. + +## Настройки + +В `.env.prod`: + +```dotenv +LMS_BACKUP_DIR=/opt/lms/backups +LMS_BACKUP_KEEP_DAYS=14 +``` + +`LMS_BACKUP_KEEP_DAYS` — сколько дней хранить. `0` отключает автоудаление. + +## Ручные команды + +```bash +./deploy/backup.sh backup # создать backup +./deploy/backup.sh backup --if-running # пропустить, если стек не запущен +./deploy/backup.sh list # список backup-каталогов +./deploy/backup.sh verify # проверить суммы и целостность +./deploy/backup.sh restore # восстановить (destructive) +``` + +`restore` требует ввести `RESTORE`, останавливает прикладные сервисы, +пересоздаёт БД, восстанавливает файлы и поднимает стек. PostgreSQL при этом +не удаляется. + +## Автоматически перед обновлением + +`deploy/deploy.sh up` вызывает: + +```bash +./deploy/backup.sh backup --if-running +``` + +Если стек ещё не запущен (первый деплой), шаг пропускается. Если backup +завершился ошибкой — деплой прерывается. + +## Ежедневное расписание (systemd) + +```bash +sudo cp deploy/systemd/lms-backup.service /etc/systemd/system/ +sudo cp deploy/systemd/lms-backup.timer /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now lms-backup.timer +systemctl list-timers lms-backup.timer +``` + +По умолчанию — каждый день в 03:00 с разбросом до 30 минут. `Persistent=true` +означает, что пропущенный запуск выполнится после включения сервера. + +Если systemd недоступен, cron: + +```cron +0 3 * * * cd /opt/lms && ./deploy/backup.sh backup >>/var/log/lms-backup.log 2>&1 +``` + +## Восстановление + +```bash +./deploy/backup.sh verify /opt/lms/backups/lms-20261023T030000Z +./deploy/backup.sh restore /opt/lms/backups/lms-20261023T030000Z +``` + +Проверка после восстановления: + +```bash +./deploy/deploy.sh ps +curl -fsS http://127.0.0.1:8090/actuator/health +``` + +## Внешняя копия + +Backup на том же сервере не защищает от потери сервера. Настройте выгрузку: + +```bash +rsync -a --delete /opt/lms/backups/ backup-host:/srv/lms-backups/ +``` + +Требования: + +- шифрование при передаче и хранении (backups содержат ПДн); +- отдельный хост/хранилище; +- проверка восстановления не реже раза в месяц на staging; +- не хранить backup в git и в каталоге проекта без `.gitignore`. + +## Проверка + +Минимум раз в месяц: + +1. `./deploy/backup.sh verify `. +2. Развернуть копию на staging. +3. Восстановить БД и файлы. +4. Проверить вход, группу, лабу, сдачу и скачивание файла. diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md index 112f345..6393485 100644 --- a/deploy/DEPLOY.md +++ b/deploy/DEPLOY.md @@ -96,24 +96,38 @@ sudo certbot --nginx -d lms.our-place.ru --redirect -m admin@our-place.ru --agre ```bash cd /opt/lms git pull -./deploy/deploy.sh up # пересоберёт изменённые образы и перезапустит +./deploy/deploy.sh up # backup → сборка → миграции → перезапуск ``` -Миграции БД (Flyway) применяются автоматически при старте сервисов. +`deploy.sh up` **обязательно** делает backup перед обновлением. Если backup +завершился ошибкой, деплой прерывается. Миграции БД (Flyway) применяются +автоматически при старте сервисов. ## 8. Резервное копирование -```bash -# БД -docker compose -f docker-compose.prod.yml --env-file .env.prod exec -T postgres \ - pg_dump -U "$LMS_DB_USER" "$LMS_DB_NAME" | gzip > lms-$(date +%F).sql.gz +Полная инструкция — [`BACKUP.md`](BACKUP.md). Кратко: -# Файлы (решения и материалы) -docker run --rm -v lms_lms_submissions:/sub -v lms_lms_assets:/assets -v "$PWD":/backup alpine \ - tar czf /backup/lms-files-$(date +%F).tar.gz -C / sub assets +```bash +./deploy/backup.sh backup # БД + решения + материалы +./deploy/backup.sh list +./deploy/backup.sh verify +./deploy/backup.sh restore # destructive, требует ввода RESTORE ``` -Восстановление БД: `gunzip -c backup.sql.gz | docker compose ... exec -T postgres psql -U lms lms`. +Backup включает `pg_dump` БД, volume решений и volume материалов, контрольные +суммы и manifest. По умолчанию хранится 14 дней (`LMS_BACKUP_KEEP_DAYS`), +каталог — `/opt/lms/backups` (`LMS_BACKUP_DIR`). + +Ежедневное расписание: + +```bash +sudo cp deploy/systemd/lms-backup.service deploy/systemd/lms-backup.timer /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now lms-backup.timer +``` + +Backup на том же сервере — не единственная защита: настройте выгрузку на +отдельный хост и периодически проверяйте восстановление на staging. ## 9. Диагностика diff --git a/deploy/backup.sh b/deploy/backup.sh new file mode 100755 index 0000000..3e3b9ac --- /dev/null +++ b/deploy/backup.sh @@ -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 +# ./deploy/backup.sh restore # 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 + deploy/backup.sh restore + +Переменные окружения: 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" <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 diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 034b566..4eb94e3 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -30,6 +30,8 @@ cmd="${1:-up}" case "$cmd" in up) + echo ">> Резервная копия перед обновлением (обязательно)..." + ./deploy/backup.sh backup --if-running echo ">> Сборка образов (Gradle внутри Docker, сервер Java не требует)..." $COMPOSE build echo ">> Запуск сервисов..." @@ -52,8 +54,20 @@ case "$cmd" in restart) $COMPOSE restart "${2:-}" ;; + backup) + ./deploy/backup.sh backup + ;; + backups) + ./deploy/backup.sh list + ;; + verify) + ./deploy/backup.sh verify "${2:-}" + ;; + restore) + ./deploy/backup.sh restore "${2:-}" + ;; *) - echo "usage: $0 [up|ps|logs |restart [service]|down]" >&2 + echo "usage: $0 [up|ps|logs |restart [service]|down|backup|backups|verify |restore ]" >&2 exit 1 ;; esac diff --git a/deploy/systemd/lms-backup.service b/deploy/systemd/lms-backup.service new file mode 100644 index 0000000..e50351e --- /dev/null +++ b/deploy/systemd/lms-backup.service @@ -0,0 +1,15 @@ +[Unit] +Description=LMS backup (PostgreSQL + files) +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +WorkingDirectory=/opt/lms +ExecStart=/opt/lms/deploy/backup.sh backup +Nice=10 +IOSchedulingClass=best-effort +IOSchedulingPriority=7 +# Если сервис запускается не от root, раскомментируйте и укажите пользователя +# с доступом к Docker и каталогу /opt/lms: +# User=lms diff --git a/deploy/systemd/lms-backup.timer b/deploy/systemd/lms-backup.timer new file mode 100644 index 0000000..e9b1bb2 --- /dev/null +++ b/deploy/systemd/lms-backup.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Daily LMS backup + +[Timer] +OnCalendar=*-*-* 03:00:00 +Persistent=true +RandomizedDelaySec=1800 + +[Install] +WantedBy=timers.target