From 32c74abc432244f17005b191db7baa4ac31f8a5b Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Fri, 26 Jun 2026 23:36:58 +0300 Subject: [PATCH 1/4] =?UTF-8?q?=D0=91=D1=8D=D0=BA=D0=B0=D0=BF:=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9=20=D0=BE=D1=84=D1=84=D1=81=D0=B0?= =?UTF-8?q?=D0=B9=D1=82-=D0=B1=D1=8D=D0=BA=D0=B0=D0=BF=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D0=B0=20=D0=BD=D0=B0=20VPS=20(=D0=91=D0=94=20+=20u?= =?UTF-8?q?ploads=20+=20achievements)=20+=20=D0=B2=D0=BE=D1=81=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/backup.sh: консистентный снимок SQLite (.backup) + архив fs_.tar.gz с uploads и achievements в backups/, локальная ротация; при заданном BACKUP_VPS_HOST — push на VPS (scp отдельным ключом/юзером) с удалённой ротацией. Настройки читаются из .env (BACKUP_*). - scripts/restore.sh: восстановление архива в прод (stop app → очистка тома → docker cp → start). - .env.example: блок BACKUP_* (host/user/dir/key/keep). .gitignore: id_backup* (ключ бэкапа). - deploy/pi/README, deploy/vps/README: настройка ключа и юзера backup, /srv/fs-backups, cron, restore. Co-Authored-By: Claude Opus 4.8 --- .env.example | 11 +++++++ .gitignore | 1 + deploy/pi/README.md | 22 +++++++++++-- deploy/vps/README.md | 15 +++++++++ scripts/backup.sh | 76 +++++++++++++++++++++++++++++++++++--------- scripts/restore.sh | 47 +++++++++++++++++++++++++++ 6 files changed, 155 insertions(+), 17 deletions(-) mode change 100644 => 100755 scripts/backup.sh create mode 100755 scripts/restore.sh diff --git a/.env.example b/.env.example index 78da8c3..a3d6987 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,17 @@ PROD_DATABASE_URL=sqlite:////data/forbidden_stars.db DEV_UPLOAD_DIR=./data/dev/uploads PROD_UPLOAD_DIR=/data/uploads +# ─── БЭКАП (оффсайт-копия на VPS; читает scripts/backup.sh на хосте Pi) ─────── +# Полный бэкап (БД + uploads + achievements) кладётся локально в backups/, и — если задан +# BACKUP_VPS_HOST — копируется на VPS (Pi → VPS push, т.к. Pi за CGNAT). Пусто = только локально. +# Ключ бэкапа — deploy/backup/id_backup (в git НЕ идёт); его pubkey → authorized_keys у backup@VPS. +BACKUP_VPS_HOST= +BACKUP_VPS_USER=backup +BACKUP_VPS_DIR=/srv/fs-backups +BACKUP_VPS_KEY=deploy/backup/id_backup +BACKUP_KEEP_LOCAL=14 +BACKUP_KEEP_REMOTE=30 + # ─── ПРОЧЕЕ ─────────────────────────────────────────────────────────────────── # Часовой пояс приложения (фикс. смещение в часах; МСК = 3) APP_TZ_OFFSET_HOURS=3 diff --git a/.gitignore b/.gitignore index a8293a5..8a2ce9f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ backend/dev.db* *.pem *.key id_tunnel* +id_backup* deploy/**/certs/ # Node / фронт diff --git a/deploy/pi/README.md b/deploy/pi/README.md index ef16b60..970181a 100644 --- a/deploy/pi/README.md +++ b/deploy/pi/README.md @@ -38,8 +38,26 @@ git pull docker compose up -d --build ``` -## Бэкап БД -`scripts/backup.sh` (консистентная онлайн-копия SQLite; по cron на хосте). Том БД — `db-data`. +## Бэкап (полный + оффсайт на VPS) +`scripts/backup.sh` делает консистентный онлайн-снимок и собирает **полный** архив +`fs_.tar.gz` (БД + `uploads/` + `achievements/`) в `backups/` с ротацией. Если в `.env` +задан `BACKUP_VPS_HOST` — копия дополнительно уходит на VPS (оффсайт; Pi → VPS push, т.к. CGNAT). + +Настройка оффсайта (один раз): +```bash +# 1) отдельный ключ бэкапа + его pubkey на VPS (юзер backup — см. deploy/vps/README.md): +ssh-keygen -t ed25519 -f deploy/backup/id_backup -N "" +ssh-copy-id -i deploy/backup/id_backup.pub backup@186.246.51.17 +# 2) в .env заполнить BACKUP_VPS_HOST=186.246.51.17 (по желанию — BACKUP_VPS_USER/DIR/KEEP_*) +# 3) разовый прогон и проверка, что архив доехал на VPS: +scripts/backup.sh +# 4) cron (ежедневно в 04:00): +crontab -e +# 0 4 * * * /home/pi/ForbidenStarsApp/scripts/backup.sh >> /var/log/fs-backup.log 2>&1 +``` + +Восстановление: `scripts/restore.sh backups/fs_.tar.gz` (ДЕСТРУКТИВНО — заменяет текущие +данные; сначала проверь на тест-контуре). Тома — `db-data` / `uploads-data` / `achievements-data`. Логи: `docker compose logs -f app` / `... tunnel`. Если `https://forbiddenstars.ru` отдаёт 502 — значит туннель не поднят: смотри `docker compose logs tunnel` (чаще всего ключ не в diff --git a/deploy/vps/README.md b/deploy/vps/README.md index 06307bb..830334a 100644 --- a/deploy/vps/README.md +++ b/deploy/vps/README.md @@ -108,3 +108,18 @@ systemctl reload caddy (HTTP 503), это ожидаемо. Логи Caddy: `journalctl -u caddy -f`. + +## 8. Приём бэкапов с Pi (оффсайт-копии) +Pi ежедневно шлёт полный архив (`scripts/backup.sh`) на VPS отдельным пользователем `backup` +(не путать с `tunnel`). Каталог — **вне** веб-корней Caddy, права 700. +```bash +useradd -m -s /bin/bash backup +install -d -m 700 -o backup -g backup /home/backup/.ssh +install -m 600 -o backup -g backup /dev/null /home/backup/.ssh/authorized_keys +install -d -m 700 -o backup -g backup /srv/fs-backups +# Публичный ключ бэкапа с Pi (deploy/backup/id_backup.pub) добавь в authorized_keys: +# echo '<содержимое id_backup.pub>' >> /home/backup/.ssh/authorized_keys +``` +> Ротацию на VPS делает сам скрипт с Pi (`BACKUP_KEEP_REMOTE`, по умолч. 30). Каталог +> `/srv/fs-backups` не отдаётся Caddy (нет `root`/`file_server` на него) → бэкапы не публичны. +> Проверка приёма: после `scripts/backup.sh` на Pi → `ls -1 /srv/fs-backups/` на VPS. diff --git a/scripts/backup.sh b/scripts/backup.sh old mode 100644 new mode 100755 index 69e1fc9..ce4db80 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -1,28 +1,74 @@ #!/usr/bin/env bash -# Консистентный онлайн-бэкап SQLite из работающего контейнера. -# Запуск по cron на хосте Pi, например ежедневно в 04:00: +# Полный консистентный онлайн-бэкап прода (БД + uploads + achievements) с работающего +# контейнера + оффсайт-копия на VPS. Запуск по cron на хосте Pi, например ежедневно в 04:00: # 0 4 * * * /home/pi/ForbidenStarsApp/scripts/backup.sh >> /var/log/fs-backup.log 2>&1 +# +# Настройки берутся из корневого .env (BACKUP_*), их можно переопределить переменными +# окружения. Если BACKUP_VPS_HOST пуст — делается только локальная копия в backups/. set -euo pipefail PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -BACKUP_DIR="${BACKUP_DIR:-$PROJECT_DIR/backups}" -KEEP="${KEEP:-14}" +COMPOSE="docker compose -f $PROJECT_DIR/docker-compose.yml" SERVICE="app" TS="$(date +%Y%m%d_%H%M%S)" +# Безопасно прочитать ОДИН ключ из .env (НЕ source: в .env есть спецсимволы # & и т.п.). +read_env() { # $1=ключ $2=дефолт + local val + val="$(grep -E "^$1=" "$PROJECT_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r')" + val="${val%\"}"; val="${val#\"}" # снять обрамляющие кавычки, если есть + printf '%s' "${val:-$2}" +} + +BACKUP_DIR="${BACKUP_DIR:-$PROJECT_DIR/backups}" +KEEP_LOCAL="${BACKUP_KEEP_LOCAL:-$(read_env BACKUP_KEEP_LOCAL 14)}" +KEEP_REMOTE="${BACKUP_KEEP_REMOTE:-$(read_env BACKUP_KEEP_REMOTE 30)}" +VPS_HOST="${BACKUP_VPS_HOST:-$(read_env BACKUP_VPS_HOST "")}" +VPS_USER="${BACKUP_VPS_USER:-$(read_env BACKUP_VPS_USER backup)}" +VPS_DIR="${BACKUP_VPS_DIR:-$(read_env BACKUP_VPS_DIR /srv/fs-backups)}" +VPS_KEY="${BACKUP_VPS_KEY:-$(read_env BACKUP_VPS_KEY "$PROJECT_DIR/deploy/backup/id_backup")}" + mkdir -p "$BACKUP_DIR" +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT -# .backup внутри контейнера (sqlite3 модуль Python) → консистентная копия в /data. -docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T "$SERVICE" \ - python -c "import sqlite3; s=sqlite3.connect('/data/forbidden_stars.db'); d=sqlite3.connect('/data/backup_tmp.db'); s.backup(d); d.close(); s.close()" +CID="$($COMPOSE ps -q "$SERVICE")" +if [ -z "$CID" ]; then + echo "[backup] ОШИБКА: контейнер '$SERVICE' не запущен." >&2 + exit 1 +fi -# Копируем во вне контейнера и сжимаем. -CID="$(docker compose -f "$PROJECT_DIR/docker-compose.yml" ps -q "$SERVICE")" -docker cp "$CID:/data/backup_tmp.db" "$BACKUP_DIR/fs_$TS.db" -docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T "$SERVICE" rm -f /data/backup_tmp.db -gzip -f "$BACKUP_DIR/fs_$TS.db" +echo "[backup] Консистентный снимок БД…" +$COMPOSE exec -T "$SERVICE" python -c "import sqlite3; s=sqlite3.connect('/data/forbidden_stars.db'); d=sqlite3.connect('/data/backup_tmp.db'); s.backup(d); d.close(); s.close()" +docker cp "$CID:/data/backup_tmp.db" "$STAGE/forbidden_stars.db" +$COMPOSE exec -T "$SERVICE" rm -f /data/backup_tmp.db -# Ротация: оставить последние $KEEP. -ls -1t "$BACKUP_DIR"/fs_*.db.gz 2>/dev/null | tail -n +"$((KEEP + 1))" | xargs -r rm -f +echo "[backup] Копирование медиа и титулов…" +docker cp "$CID:/data/uploads" "$STAGE/uploads" +docker cp "$CID:/data/achievements" "$STAGE/achievements" -echo "[backup] Готово: $BACKUP_DIR/fs_$TS.db.gz" +ARCHIVE="$BACKUP_DIR/fs_$TS.tar.gz" +tar -czf "$ARCHIVE" -C "$STAGE" forbidden_stars.db uploads achievements +echo "[backup] Локальный архив: $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))" + +# Локальная ротация: оставить последние $KEEP_LOCAL (архив только что создан → ≥1 файл). +ls -1t "$BACKUP_DIR"/fs_*.tar.gz 2>/dev/null | tail -n +"$((KEEP_LOCAL + 1))" | xargs -r rm -f + +# Оффсайт-копия на VPS (push: Pi за CGNAT, входящих нет). +if [ -n "$VPS_HOST" ]; then + if [ ! -f "$VPS_KEY" ]; then + echo "[backup] ОШИБКА: ключ $VPS_KEY не найден — оффсайт-копия пропущена." >&2 + exit 1 + fi + echo "[backup] Отправка на VPS $VPS_USER@$VPS_HOST:$VPS_DIR …" + SSH_OPTS=(-i "$VPS_KEY" -o StrictHostKeyChecking=accept-new -o BatchMode=yes) + scp "${SSH_OPTS[@]}" "$ARCHIVE" "$VPS_USER@$VPS_HOST:$VPS_DIR/" + # Удалённая ротация (sh на VPS без pipefail → пустой ls не уронит команду). + ssh "${SSH_OPTS[@]}" "$VPS_USER@$VPS_HOST" \ + "ls -1t '$VPS_DIR'/fs_*.tar.gz 2>/dev/null | tail -n +$((KEEP_REMOTE + 1)) | xargs -r rm -f" + echo "[backup] Оффсайт-копия отправлена." +else + echo "[backup] BACKUP_VPS_HOST не задан — только локальная копия." +fi + +echo "[backup] Готово." diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100755 index 0000000..3057571 --- /dev/null +++ b/scripts/restore.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Восстановление прода из архива бэкапа (fs_.tar.gz): БД + uploads + achievements. +# ДЕСТРУКТИВНО — заменяет текущие данные тома. Сначала проверьте на тест-контуре! +# Использование: +# scripts/restore.sh backups/fs_YYYYmmdd_HHMMSS.tar.gz [--yes] +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +COMPOSE="docker compose -f $PROJECT_DIR/docker-compose.yml" +SERVICE="app" + +ARCHIVE="${1:-}" +CONFIRM="${2:-}" +if [ -z "$ARCHIVE" ] || [ ! -f "$ARCHIVE" ]; then + echo "Использование: $0 <путь к fs_*.tar.gz> [--yes]" >&2 + exit 1 +fi +if [ "$CONFIRM" != "--yes" ]; then + printf "ВНИМАНИЕ: текущие БД/uploads/achievements будут заменены из %s\nПродолжить? [y/N] " "$ARCHIVE" + read -r ans + case "$ans" in y|Y|yes|да|Да) ;; *) echo "Отменено."; exit 1 ;; esac +fi + +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT +tar -xzf "$ARCHIVE" -C "$STAGE" +if [ ! -f "$STAGE/forbidden_stars.db" ]; then + echo "ОШИБКА: в архиве нет forbidden_stars.db." >&2 + exit 1 +fi + +echo "[restore] Остановка приложения…" +$COMPOSE stop "$SERVICE" + +echo "[restore] Очистка тома (старая БД, WAL/SHM, медиа, титулы)…" +$COMPOSE run --rm --no-deps -T --entrypoint sh "$SERVICE" -c \ + "rm -f /data/forbidden_stars.db /data/forbidden_stars.db-wal /data/forbidden_stars.db-shm; rm -rf /data/uploads/* /data/achievements/*" + +CID="$($COMPOSE ps -aq "$SERVICE")" +echo "[restore] Запись данных в контейнер…" +docker cp "$STAGE/forbidden_stars.db" "$CID:/data/forbidden_stars.db" +[ -d "$STAGE/uploads" ] && docker cp "$STAGE/uploads/." "$CID:/data/uploads" +[ -d "$STAGE/achievements" ] && docker cp "$STAGE/achievements/." "$CID:/data/achievements" + +echo "[restore] Запуск приложения…" +$COMPOSE start "$SERVICE" +echo "[restore] Готово. Проверьте https://forbiddenstars.ru и логи: $COMPOSE logs -f app" From b3db5a40472521aa0b369017f8eec7b374170c7f Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Fri, 26 Jun 2026 23:46:33 +0300 Subject: [PATCH 2/4] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=B4=20=D0=BD=D0=B0=20P?= =?UTF-8?q?i:=20=D0=BA=D0=B0=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=B5=D0=B4=D0=B8=D0=BD=D1=8B=D0=B9=20com?= =?UTF-8?q?pose,=20=D0=B2=D1=81=D0=B5=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B8=20=D0=B2=20.env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose.yml: слот туннеля параметризован (${VPS_TUNNEL_PORT:-9000}), VPS_TUNNEL_HOST стал обязательным (${...:?}), лимиты ресурсов из .env (APP_MEM_LIMIT/APP_CPUS), stop_grace_period 30s (чек-пойнт WAL SQLite), security_opt no-new-privileges на обоих сервисах; healthcheck/логирование/тома сохранены (имена томов не менялись — данные не осиротеют). - .env.example: VPS_TUNNEL_PORT по умолчанию НЕ задаётся (каждый контур берёт свой слот: dev→9001, prod→9000, test→9001); добавлены ADMIN_NICKNAME, DEV/PROD_ACHIEVEMENTS_DIR, APP_MEM_LIMIT/APP_CPUS. - deploy/pi/README: чек-лист .env для Pi; запуск одним `docker compose up -d --build`. Co-Authored-By: Claude Opus 4.8 --- .env.example | 18 +++++++++++++-- deploy/pi/README.md | 14 +++++++++--- docker-compose.yml | 54 +++++++++++++++++++++++++++------------------ 3 files changed, 59 insertions(+), 27 deletions(-) diff --git a/.env.example b/.env.example index a3d6987..7842b1f 100644 --- a/.env.example +++ b/.env.example @@ -22,11 +22,15 @@ LOCAL_PUBLIC=local # Ключ туннеля — в deploy/tunnel/id_tunnel (в git не идёт); pubkey → authorized_keys у tunnel@VPS. VPS_TUNNEL_HOST=186.246.51.17 VPS_TUNNEL_USER=tunnel -VPS_TUNNEL_PORT=9001 # слот VPS для dev-туннеля (forbidden-stars.ru); контейнеры: prod=9000, test=9001 +# VPS_TUNNEL_PORT обычно НЕ задают — каждый контур берёт свой слот по умолчанию: +# dev (run.ps1) → 9001, прод-контейнер → 9000, test-контейнер → 9001. +# Раскомментируй и переопредели, только если нужен нестандартный слот. +#VPS_TUNNEL_PORT=9001 # ─── АДМИНИСТРАТОР (вход по логину/паролю, отдельно от Telegram) ────────────── ADMIN_USERNAME=admin ADMIN_PASSWORD=change-me-admin-password +ADMIN_NICKNAME=Администратор ADMIN_BOOTSTRAP_ENABLED=true # ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ─────────────────────────────────────────────────── @@ -57,10 +61,20 @@ COOKIE_DOMAIN= DEV_DATABASE_URL=sqlite:///./data/dev/forbidden_stars.db PROD_DATABASE_URL=sqlite:////data/forbidden_stars.db -# Каталог загрузок (зарезервировано под фото/видео), тоже раздельно. +# Каталог загрузок (фото партий), раздельно для dev и prod. DEV_UPLOAD_DIR=./data/dev/uploads PROD_UPLOAD_DIR=/data/uploads +# Каталог определений титулов (файлы: meta.json, condition.py, icon.*), раздельно. +DEV_ACHIEVEMENTS_DIR=./data/dev/achievements +PROD_ACHIEVEMENTS_DIR=/data/achievements + +# ─── РЕСУРСЫ ПРОД-КОНТЕЙНЕРА (docker-compose.yml) ───────────────────────────── +# Лимиты под Raspberry Pi. Не заданы → дефолты compose (512m / 1.5 CPU). +# Подними, если у Pi больше RAM/ядер. +#APP_MEM_LIMIT=512m +#APP_CPUS=1.5 + # ─── БЭКАП (оффсайт-копия на VPS; читает scripts/backup.sh на хосте Pi) ─────── # Полный бэкап (БД + uploads + achievements) кладётся локально в backups/, и — если задан # BACKUP_VPS_HOST — копируется на VPS (Pi → VPS push, т.к. Pi за CGNAT). Пусто = только локально. diff --git a/deploy/pi/README.md b/deploy/pi/README.md index 970181a..a975d0a 100644 --- a/deploy/pi/README.md +++ b/deploy/pi/README.md @@ -15,14 +15,22 @@ ssh-copy-id -i deploy/tunnel/id_tunnel.pub tunnel@186.246.51.17 ``` ## 2. .env +Все настройки приложения и туннеля — в одном `.env` (контейнер форсит `APP_ENV=production`): ```bash -cp .env.example .env # заполни секреты; APP_ENV контейнер форсит в production +cp .env.example .env ``` -Проверь, что заданы параметры VPS (их читает сервис `tunnel`): +Минимально заполнить на Pi: ``` -VPS_TUNNEL_HOST=186.246.51.17 +SECRET_KEY=... # python -c "import secrets;print(secrets.token_urlsafe(48))" +ADMIN_USERNAME=... # логин/пароль секретной админки +ADMIN_PASSWORD=... +TELEGRAM_BOT_TOKEN=... # бот @BotFather; /setdomain → forbiddenstars.ru +TELEGRAM_BOT_USERNAME=... +VPS_TUNNEL_HOST=186.246.51.17 # читает сервис tunnel VPS_TUNNEL_USER=tunnel ``` +`VPS_TUNNEL_PORT` задавать не нужно — прод-контейнер по умолчанию берёт слот **9000**. +Ресурсы (`APP_MEM_LIMIT`/`APP_CPUS`) и оффсайт-бэкап (`BACKUP_*`) — по желанию. ## 3. Запуск ```bash diff --git a/docker-compose.yml b/docker-compose.yml index c16c27c..d41cc23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,42 +1,50 @@ +# ───────────────────────────────────────────────────────────────────────────── +# ПРОД на Raspberry Pi (forbiddenstars.ru) — единый compose: приложение + туннель. +# Все настройки и секреты — в корневом .env (см. .env.example). Этот файл их только +# пробрасывает; ничего секретного тут нет. +# +# Запуск/обновление: docker compose up -d --build +# Логи: docker compose logs -f app (или: ... tunnel) +# Остановка: docker compose down (данные в томах сохраняются) +# Бэкап/restore: scripts/backup.sh / scripts/restore.sh +# +# Контейнер ВСЕГДА production: APP_ENV форсится здесь и игнорирует значение из .env. +# Порты на хост НЕ публикуются — наружу приложение выставляет только сервис tunnel +# (SSH reverse-туннель к VPS). Из LAN/localhost оно недоступно. +# ───────────────────────────────────────────────────────────────────────────── services: app: build: . image: forbidden-stars:latest restart: unless-stopped - init: true + init: true # PID 1 пробрасывает сигналы → корректный SIGTERM + stop_grace_period: 30s # время на чек-пойнт WAL SQLite перед остановкой env_file: - - .env # секреты/настройки (один общий формат для dev и prod) + - .env # единый источник настроек/секретов всего приложения environment: - # Прод обособлен: контейнер ВСЕГДА production и ИГНОРИРУЕТ APP_ENV из .env - # (хоть development, хоть test). БД=/data, STATIC_DIR=/app/static — из Dockerfile. - APP_ENV: production - # Портов на хост НЕТ: приложение доступно только изнутри сети compose. - # Наружу его выставляет сервис tunnel (ниже) через VPS. Из LAN/localhost — недоступно. + APP_ENV: production # прод обособлен: игнорируем APP_ENV из .env volumes: - - db-data:/data # БД SQLite + WAL-сайдкары - - uploads-data:/data/uploads # задел под фото/видео - - achievements-data:/data/achievements # определения ачивок (файлы) + - db-data:/data # БД SQLite + WAL-сайдкары + - uploads-data:/data/uploads # медиа партий + - achievements-data:/data/achievements # определения титулов (файлы) healthcheck: - test: - - CMD - - python - - -c - - "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] interval: 30s timeout: 5s retries: 3 start_period: 40s - mem_limit: 512m - cpus: 1.5 + mem_limit: ${APP_MEM_LIMIT:-512m} # лимиты под Pi; переопределяются в .env + cpus: ${APP_CPUS:-1.5} + security_opt: + - no-new-privileges:true logging: driver: json-file options: max-size: "10m" max-file: "5" - # SSH reverse-туннель к VPS: forbiddenstars.ru (VPS:9000) -> app:8000 (по сети compose). - # Ключ — в ./deploy/tunnel/id_tunnel (gitignore), его pubkey в authorized_keys у tunnel@VPS. - # VPS_TUNNEL_HOST/USER берутся из .env (на Pi заполни их там же). + # SSH reverse-туннель: VPS:${VPS_TUNNEL_PORT:-9000} → app:8000 (домен forbiddenstars.ru). + # Ключ — ./deploy/tunnel/id_tunnel (в git не идёт); его pubkey в authorized_keys у tunnel@VPS. tunnel: build: ./deploy/tunnel image: forbidden-stars-tunnel:latest @@ -46,13 +54,15 @@ services: app: condition: service_healthy environment: - VPS_TUNNEL_HOST: ${VPS_TUNNEL_HOST} + VPS_TUNNEL_HOST: ${VPS_TUNNEL_HOST:?VPS_TUNNEL_HOST is required in .env} VPS_TUNNEL_USER: ${VPS_TUNNEL_USER:-tunnel} - VPS_TUNNEL_PORT: "9000" # forbiddenstars.ru (прод); у test — 9001 + VPS_TUNNEL_PORT: ${VPS_TUNNEL_PORT:-9000} # прод-слот (forbiddenstars.ru) UPSTREAM: app:8000 volumes: - ./deploy/tunnel/id_tunnel:/key/id_tunnel:ro mem_limit: 64m + security_opt: + - no-new-privileges:true volumes: db-data: From 1a61b05923af1c127e99988b515cddef4e9b4aa2 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sat, 27 Jun 2026 00:16:04 +0300 Subject: [PATCH 3/4] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=B4-=D0=B4=D0=B5=D0=BF?= =?UTF-8?q?=D0=BB=D0=BE=D0=B9=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20Gitea-?= =?UTF-8?q?=D1=80=D0=B5=D0=B5=D1=81=D1=82=D1=80:=20arm64-=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D1=8B=20=D1=81=D0=BE=D0=B1=D0=B8=D1=80=D0=B0?= =?UTF-8?q?=D1=8E=D1=82=D1=81=D1=8F=20=D0=BD=D0=B0=20=D0=9F=D0=9A,=20Pi=20?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B0=D0=B5=D1=82=20pull?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose.yml: image у app и tunnel → путь реестра ${IMAGE_REGISTRY}/...:${IMAGE_TAG} (build: оставлен фолбэком для сборки на Pi). Тома/настройки прежние. - scripts/build-push.sh: buildx bake под linux/arm64 + push обоих образов в реестр (запуск на ПК). - .env.example: блок ОБРАЗЫ ПРОДА (IMAGE_REGISTRY=gitea.arseniev.info/notbigghost, IMAGE_TAG=latest). - deploy/pi/README: запуск/обновление через docker compose pull; альтернатива — сборка на Pi; заметка про HTTPS-реестр / insecure-registries. Co-Authored-By: Claude Opus 4.8 --- .env.example | 7 +++++++ deploy/pi/README.md | 27 +++++++++++++++++++++------ docker-compose.yml | 6 ++++-- scripts/build-push.sh | 24 ++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 8 deletions(-) create mode 100755 scripts/build-push.sh diff --git a/.env.example b/.env.example index 7842b1f..d97dfdd 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,13 @@ PROD_UPLOAD_DIR=/data/uploads DEV_ACHIEVEMENTS_DIR=./data/dev/achievements PROD_ACHIEVEMENTS_DIR=/data/achievements +# ─── ОБРАЗЫ ПРОДА (реестр для docker compose pull на Pi) ────────────────────── +# Образы собираются под arm64 на ПК (scripts/build-push.sh) и пушатся в Gitea-реестр, +# а Pi их тянет (docker compose pull). Owner в пути — строчными. Тег можно версионировать. +# Перед пушем/пуллом: docker login gitea.arseniev.info +IMAGE_REGISTRY=gitea.arseniev.info/notbigghost +IMAGE_TAG=latest + # ─── РЕСУРСЫ ПРОД-КОНТЕЙНЕРА (docker-compose.yml) ───────────────────────────── # Лимиты под Raspberry Pi. Не заданы → дефолты compose (512m / 1.5 CPU). # Подними, если у Pi больше RAM/ядер. diff --git a/deploy/pi/README.md b/deploy/pi/README.md index a975d0a..8fd378b 100644 --- a/deploy/pi/README.md +++ b/deploy/pi/README.md @@ -32,18 +32,33 @@ VPS_TUNNEL_USER=tunnel `VPS_TUNNEL_PORT` задавать не нужно — прод-контейнер по умолчанию берёт слот **9000**. Ресурсы (`APP_MEM_LIMIT`/`APP_CPUS`) и оффсайт-бэкап (`BACKUP_*`) — по желанию. -## 3. Запуск +## 3. Запуск (образ из реестра) +Образы (`app` + `tunnel`) собираются под arm64 на ПК и пушатся в Gitea-реестр, Pi их тянет +(см. `IMAGE_REGISTRY`/`IMAGE_TAG` в `.env`). Pi 4/5 — мощный, но реестр держим как основной путь. + +На ПК (Docker Desktop) — один раз `docker login gitea.arseniev.info`, далее при каждом релизе: ```bash -docker compose up -d --build # поднимет app + tunnel -docker compose ps # оба up; tunnel стартует после healthy app -docker compose logs -f tunnel # ждём строку: [tunnel] -R 9000:app:8000 -> tunnel@... +scripts/build-push.sh # buildx: arm64-сборка app+tunnel и push в реестр +``` +На Pi: +```bash +docker login gitea.arseniev.info # один раз (доступ к пакету) +git pull # нужны docker-compose.yml, .env, deploy/tunnel/id_tunnel +docker compose pull # тянет свежие образы из реестра +docker compose up -d +docker compose ps # app healthy → поднимется tunnel +docker compose logs -f tunnel # ждём: [tunnel] -R 9000:app:8000 -> tunnel@... ``` Открой `https://forbiddenstars.ru` — должен отдать прод. +> Альтернатива без реестра (Pi 4/5 ≥4ГБ потянет сборку): `docker compose up -d --build` прямо на Pi. +> Реестр Gitea должен быть по HTTPS с валидным сертификатом; иначе на Pi добавь его в +> `/etc/docker/daemon.json` → `insecure-registries`. + ## Обновление ```bash -git pull -docker compose up -d --build +# ПК: scripts/build-push.sh +# Pi: git pull && docker compose pull && docker compose up -d ``` ## Бэкап (полный + оффсайт на VPS) diff --git a/docker-compose.yml b/docker-compose.yml index d41cc23..21800e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,9 @@ services: app: build: . - image: forbidden-stars:latest + # Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.sh) и тянется + # на Pi через `docker compose pull`. build: оставлен как локальный фолбэк (сборка на Pi). + image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars:${IMAGE_TAG:-latest} restart: unless-stopped init: true # PID 1 пробрасывает сигналы → корректный SIGTERM stop_grace_period: 30s # время на чек-пойнт WAL SQLite перед остановкой @@ -47,7 +49,7 @@ services: # Ключ — ./deploy/tunnel/id_tunnel (в git не идёт); его pubkey в authorized_keys у tunnel@VPS. tunnel: build: ./deploy/tunnel - image: forbidden-stars-tunnel:latest + image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars-tunnel:${IMAGE_TAG:-latest} restart: unless-stopped init: true depends_on: diff --git a/scripts/build-push.sh b/scripts/build-push.sh new file mode 100755 index 0000000..b345818 --- /dev/null +++ b/scripts/build-push.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Сборка прод-образов (app + tunnel) под linux/arm64 для Raspberry Pi и пуш в реестр. +# Запускается на машине РАЗРАБОТКИ с Docker Desktop/buildx (не на Pi). +# Реестр и тег берутся из корневого .env (IMAGE_REGISTRY / IMAGE_TAG) — те же, что в +# docker-compose.yml. Перед запуском один раз залогиниться в реестр: +# docker login gitea.arseniev.info +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$PROJECT_DIR" + +# Билдер с поддержкой push разных архитектур (driver docker-container) — создаём при отсутствии. +if ! docker buildx inspect fsbuilder >/dev/null 2>&1; then + docker buildx create --name fsbuilder --driver docker-container >/dev/null +fi + +# Эмуляция arm64 на x86-хосте (в Docker Desktop обычно уже стоит; команда идемпотентна). +docker run --privileged --rm tonistiigi/binfmt --install arm64 >/dev/null + +echo "[build-push] Сборка app+tunnel под linux/arm64 и пуш в реестр…" +# bake читает docker-compose.yml: контексты build: и теги image: (с подстановкой из .env). +docker buildx bake -f docker-compose.yml --builder fsbuilder --set "*.platform=linux/arm64" --push + +echo "[build-push] Готово. На Pi: docker compose pull && docker compose up -d" From 886ff5d4e2f3380df717a43c45941106077af008 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sat, 27 Jun 2026 00:23:49 +0300 Subject: [PATCH 4/4] =?UTF-8?q?=D0=94=D0=B5=D0=BF=D0=BB=D0=BE=D0=B9:=20Pow?= =?UTF-8?q?erShell-=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20build-push=20(?= =?UTF-8?q?=D0=BD=D0=B0=20Windows=20bash=20=D1=83=D1=85=D0=BE=D0=B4=D0=B8?= =?UTF-8?q?=D1=82=20=D0=B2=20WSL=20=D0=B8=20=D0=BF=D0=B0=D0=B4=D0=B0=D0=B5?= =?UTF-8?q?=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/build-push.ps1: arm64 buildx bake + push, аналог build-push.sh для Windows-дева (ASCII). - deploy/pi/README: указать .ps1 для Windows и почему `bash ...` в PowerShell не работает. Co-Authored-By: Claude Opus 4.8 --- deploy/pi/README.md | 6 ++++-- scripts/build-push.ps1 | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100755 scripts/build-push.ps1 diff --git a/deploy/pi/README.md b/deploy/pi/README.md index 8fd378b..ab5a77b 100644 --- a/deploy/pi/README.md +++ b/deploy/pi/README.md @@ -37,9 +37,11 @@ VPS_TUNNEL_USER=tunnel (см. `IMAGE_REGISTRY`/`IMAGE_TAG` в `.env`). Pi 4/5 — мощный, но реестр держим как основной путь. На ПК (Docker Desktop) — один раз `docker login gitea.arseniev.info`, далее при каждом релизе: -```bash -scripts/build-push.sh # buildx: arm64-сборка app+tunnel и push в реестр +```powershell +.\scripts\build-push.ps1 # Windows (PowerShell); на *nix — bash scripts/build-push.sh ``` +> `bash scripts/build-push.sh` в PowerShell уходит в WSL и падает, если нет дистрибутива — +> используй `.ps1`. Команды внутри — обычный `docker buildx`, работают и напрямую в PowerShell. На Pi: ```bash docker login gitea.arseniev.info # один раз (доступ к пакету) diff --git a/scripts/build-push.ps1 b/scripts/build-push.ps1 new file mode 100755 index 0000000..262d148 --- /dev/null +++ b/scripts/build-push.ps1 @@ -0,0 +1,26 @@ +# Build prod images (app + tunnel) for linux/arm64 (Raspberry Pi) and push to the registry. +# Run on the DEV machine with Docker Desktop / buildx. Registry and tag come from the root +# .env (IMAGE_REGISTRY / IMAGE_TAG) - the same values docker-compose.yml uses. +# Log in to the registry once before running: docker login gitea.arseniev.info +# Keep this file ASCII-only (Windows PowerShell 5.1 mangles non-ASCII without a BOM). +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +# Builder with multi-arch push support (docker-container driver) - create if missing. +docker buildx inspect fsbuilder *> $null +if ($LASTEXITCODE -ne 0) { + docker buildx create --name fsbuilder --driver docker-container | Out-Null +} + +# arm64 emulation on an x86 host (usually already present in Docker Desktop; idempotent). +docker run --privileged --rm tonistiigi/binfmt --install arm64 | Out-Null + +Write-Host "Building app+tunnel for linux/arm64 and pushing to the registry..." -ForegroundColor Green +docker buildx bake -f docker-compose.yml --builder fsbuilder --set "*.platform=linux/arm64" --push +if ($LASTEXITCODE -ne 0) { + Write-Host "Build/push FAILED (exit $LASTEXITCODE)." -ForegroundColor Red + exit 1 +} + +Write-Host "Done. On the Pi: docker compose pull && docker compose up -d" -ForegroundColor Green