Прод на Pi: деплой из 2 файлов (compose + .env), ключ туннеля из .env

Теперь на Pi нужны только docker-compose.yml и .env — без репозитория, сборки и файла ключа.

- docker-compose.yml: pull_policy: always (образы тянутся из реестра, сборка не запускается);
  ключ туннеля больше не монтируется томом — берётся из .env (TUNNEL_KEY_B64, base64);
  у VPS_TUNNEL_HOST убран :? (ломал бы buildx bake на ПК, где переменная не задана) — проверка
  обязательности перенесена в tunnel.sh (рантайм).
- deploy/tunnel/tunnel.sh: приватный ключ из TUNNEL_KEY_B64 (base64) с фолбэком на смонтированный
  /key/id_tunnel (для dev/test, где репозиторий на хосте).
- .env.example: TUNNEL_KEY_B64 + как сгенерировать base64 (Git Bash / PowerShell).
- deploy/pi/README: полный поток «голый Pi → 2 файла»: Docker, curl compose+.env из Gitea, up;
  обновление, автозапуск, бэкап как опция.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 21:48:40 +03:00
co-authored by Claude Opus 4.8
parent c544f72063
commit 029a043bfc
4 changed files with 102 additions and 79 deletions
+8
View File
@@ -27,6 +27,14 @@ VPS_TUNNEL_USER=tunnel
# Раскомментируй и переопредели, только если нужен нестандартный слот. # Раскомментируй и переопредели, только если нужен нестандартный слот.
#VPS_TUNNEL_PORT=9001 #VPS_TUNNEL_PORT=9001
# Приватный ключ туннеля в base64 — чтобы на Pi хватило только docker-compose.yml + .env
# (без файла deploy/tunnel/id_tunnel). Нужен ТОЛЬКО для прод-контейнера на Pi; для dev/test
# на ПК ключ берётся из файла. Сгенерируй ключ на ПК и закодируй БЕЗ переносов строк:
# Git Bash / Linux: base64 -w0 deploy/tunnel/id_tunnel
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes("deploy/tunnel/id_tunnel"))
# Pubkey (deploy/tunnel/id_tunnel.pub) добавь в authorized_keys у tunnel@VPS.
TUNNEL_KEY_B64=
# ─── АДМИНИСТРАТОР (вход по логину/паролю, отдельно от Telegram) ────────────── # ─── АДМИНИСТРАТОР (вход по логину/паролю, отдельно от Telegram) ──────────────
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-admin-password ADMIN_PASSWORD=change-me-admin-password
+72 -62
View File
@@ -1,89 +1,99 @@
# Raspberry Pi — ПРОД (`forbiddenstars.ru`) # Raspberry Pi — ПРОД (`forbiddenstars.ru`)
Pi запускает приложение **и туннель** в Docker: `docker compose up` поднимает два сервиса — На Pi нужны только **ДВА файла**: `docker-compose.yml` и `.env`. Репозиторий, сборка и файл
`app` (FastAPI + SPA) и `tunnel` (ssh-контейнер). У `app` **портов на хост нет**, наружу он ключа туннеля не нужны:
доступен только через туннель-контейнер (`ssh -R 9000:app:8000` к VPS). Публичная точка — - образы (`app` + `tunnel`) тянутся из Gitea-реестра (`pull_policy: always`);
VPS, домен `forbiddenstars.ru`. Снаружи Pi недоступен (CGNAT) — туннель сам стучится наружу. - приватный ключ туннеля лежит в `.env` как `TUNNEL_KEY_B64` (base64).
## 1. Ключ для туннеля `docker compose up` поднимает два контейнера: `app` (FastAPI+SPA, портов на хост нет) и
Туннель-контейнер ходит на `tunnel@VPS` по ключу из `deploy/tunnel/id_tunnel` (gitignored). `tunnel` (`ssh -R 9000:app:8000` к VPS). Публичная точка — VPS, домен `forbiddenstars.ru`
Создай ключ и добавь его pubkey в `authorized_keys` пользователя `tunnel` на VPS: (Pi за CGNAT, туннель стучится наружу сам).
> Предпосылки: VPS настроен (Caddy `forbiddenstars.ru` → :9000, юзер `tunnel`) — см.
> `deploy/vps/README.md`.
---
## A. На ПК (один раз + при каждом релизе)
**Ключ туннеля и его base64 для `.env`** (один раз):
```bash ```bash
ssh-keygen -t ed25519 -f deploy/tunnel/id_tunnel -N "" ssh-keygen -t ed25519 -f deploy/tunnel/id_tunnel -N ""
ssh-copy-id -i deploy/tunnel/id_tunnel.pub tunnel@186.246.51.17 ssh-copy-id -i deploy/tunnel/id_tunnel.pub tunnel@186.246.51.17 # pubkey → authorized_keys на VPS
# (или вручную: содержимое deploy/tunnel/id_tunnel.pub -> /home/tunnel/.ssh/authorized_keys на VPS) # base64 приватного ключа БЕЗ переносов строк (значение для TUNNEL_KEY_B64 в .env):
# Git Bash / Linux: base64 -w0 deploy/tunnel/id_tunnel
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes("deploy/tunnel/id_tunnel"))
``` ```
## 2. .env **Сборка и пуш образов под arm64** (при каждом релизе):
Все настройки приложения и туннеля — в одном `.env` (контейнер форсит `APP_ENV=production`): ```powershell
docker login gitea.arseniev.info
.\scripts\build-push.ps1 # *nix: bash scripts/build-push.sh
```
## B. На Pi
**1. Docker (один раз):**
```bash ```bash
cp .env.example .env sudo apt update && sudo apt -y full-upgrade
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
sudo systemctl enable --now docker # автозапуск после ребута
newgrp docker # применить группу (или перезайти по SSH)
``` ```
Минимально заполнить на Pi:
**2. Положить два файла** (любой каталог, напр. `~/forbidden-stars`):
```bash
mkdir -p ~/forbidden-stars && cd ~/forbidden-stars
# docker-compose.yml из Gitea (raw); branch — main (или dev, если ещё не смёржено):
curl -fsSLO https://gitea.arseniev.info/NotBigGhost/ForbiddenStarsApp/raw/branch/main/docker-compose.yml
# .env: можно взять шаблон оттуда же и заполнить
curl -fsSL https://gitea.arseniev.info/NotBigGhost/ForbiddenStarsApp/raw/branch/main/.env.example -o .env
nano .env
``` ```
SECRET_KEY=... # python -c "import secrets;print(secrets.token_urlsafe(48))" Заполнить в `.env`:
```
SECRET_KEY=... # python3 -c "import secrets;print(secrets.token_urlsafe(48))"
ADMIN_USERNAME=... # логин/пароль секретной админки ADMIN_USERNAME=... # логин/пароль секретной админки
ADMIN_PASSWORD=... ADMIN_PASSWORD=...
TELEGRAM_BOT_TOKEN=... # бот @BotFather; /setdomain → forbiddenstars.ru TELEGRAM_BOT_TOKEN=... # бот @BotFather; /setdomain → forbiddenstars.ru
TELEGRAM_BOT_USERNAME=... TELEGRAM_BOT_USERNAME=...
VPS_TUNNEL_HOST=186.246.51.17 # читает сервис tunnel VPS_TUNNEL_HOST=186.246.51.17
VPS_TUNNEL_USER=tunnel TUNNEL_KEY_B64=... # base64 ключа из шага A (одной строкой)
IMAGE_REGISTRY=gitea.arseniev.info/notbigghost # уже значение по умолчанию
IMAGE_TAG=latest
``` ```
`VPS_TUNNEL_PORT` задавать не нужно — прод-контейнер по умолчанию берёт слот **9000**. `APP_ENV` менять не нужно (контейнер форсит `production`); `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`, далее при каждом релизе:
```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 ```bash
docker login gitea.arseniev.info # один раз (доступ к пакету) docker login gitea.arseniev.info # один раз (доступ к реестру)
git pull # нужны docker-compose.yml, .env, deploy/tunnel/id_tunnel docker compose up -d # pull_policy: always → сам тянет образы, без сборки
docker compose pull # тянет свежие образы из реестра
docker compose up -d
docker compose ps # app healthy → поднимется tunnel docker compose ps # app healthy → поднимется tunnel
docker compose logs -f tunnel # ждём: [tunnel] -R 9000:app:8000 -> tunnel@... docker compose logs -f tunnel # ждём: [tunnel] -R 9000:app:8000 -> tunnel@...
``` ```
Открой `https://forbiddenstars.ru` — должен отдать прод. На старте контейнер сам применит миграции, засидит справочники и создаст админа из `.env`.
Открой `https://forbiddenstars.ru`. У @BotFather `/setdomain` → добавь `forbiddenstars.ru`.
> Альтернатива без реестра (Pi 4/5 ≥4ГБ потянет сборку): `docker compose up -d --build` прямо на Pi.
> Реестр Gitea должен быть по HTTPS с валидным сертификатом; иначе на Pi добавь его в
> `/etc/docker/daemon.json` → `insecure-registries`.
## Обновление ## Обновление
```bash ```bash
# ПК: scripts/build-push.sh # ПК: .\scripts\build-push.ps1
# Pi: git pull && docker compose pull && docker compose up -d # Pi: docker compose up -d # always-pull подтянет свежий образ
``` ```
## Бэкап (полный + оффсайт на VPS) ## Автозапуск после перезагрузки
`scripts/backup.sh` делает консистентный онлайн-снимок и собирает **полный** архив Обеспечен: `systemctl enable docker` + `restart: unless-stopped`. После `sudo reboot`
`fs_<TS>.tar.gz` (БД + `uploads/` + `achievements/`) в `backups/` с ротацией. Если в `.env` контейнеры поднимаются сами (используют локальный образ, без повторного pull).
задан `BACKUP_VPS_HOST` — копия дополнительно уходит на VPS (оффсайт; Pi → VPS push, т.к. CGNAT).
Настройка оффсайта (один раз): ## Бэкап (опционально)
```bash Бэкап-скриптам нужен доступ к Docker и к самому `docker-compose.yml` (он уже на Pi). Скопируй
# 1) отдельный ключ бэкапа + его pubkey на VPS (юзер backup — см. deploy/vps/README.md): рядом ещё `scripts/backup.sh` и `scripts/restore.sh` (или склонируй репозиторий для бэкапов).
ssh-keygen -t ed25519 -f deploy/backup/id_backup -N "" Затем — отдельный ключ `backup@VPS`, `BACKUP_VPS_HOST` в `.env`, cron. Подробнее — комментарии
ssh-copy-id -i deploy/backup/id_backup.pub backup@186.246.51.17 в `scripts/backup.sh` и `deploy/vps/README.md` §8. Восстановление: `scripts/restore.sh <архив>`.
# 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_<TS>.tar.gz` (ДЕСТРУКТИВНО — заменяет текущие ## Если что-то не так
данные; сначала проверь на тест-контуре). Тома — `db-data` / `uploads-data` / `achievements-data`. - `https://forbiddenstars.ru` отдаёт заглушку/502 → туннель не поднят: `docker compose logs tunnel`
(чаще: pubkey не в `authorized_keys` на VPS, пустой `TUNNEL_KEY_B64`, или на VPS занят слот 9000
Логи: `docker compose logs -f app` / `... tunnel`. Если `https://forbiddenstars.ru` отдаёт → `sudo fuser -k 9000/tcp` на VPS, затем `docker compose restart tunnel`).
502 — значит туннель не поднят: смотри `docker compose logs tunnel` (чаще всего ключ не в - `pull` не проходит → проверь `docker login gitea.arseniev.info` и что реестр по HTTPS с валидным
`authorized_keys` на VPS или не заданы `VPS_TUNNEL_*` в `.env`). сертификатом (иначе хост в `/etc/docker/daemon.json` → `insecure-registries`, `systemctl restart docker`).
+12 -10
View File
@@ -9,18 +9,20 @@ VPS_TUNNEL_USER="${VPS_TUNNEL_USER:-tunnel}"
: "${VPS_TUNNEL_PORT:?VPS_TUNNEL_PORT is required}" : "${VPS_TUNNEL_PORT:?VPS_TUNNEL_PORT is required}"
UPSTREAM="${UPSTREAM:-app:8000}" UPSTREAM="${UPSTREAM:-app:8000}"
# Ключ должен быть смонтирован файлом. Если тут пусто/папка — Docker создал каталог, # Источник приватного ключа: либо TUNNEL_KEY_B64 (base64 в .env — прод: только compose+env),
# потому что файла deploy/tunnel/id_tunnel на хосте нет. # либо смонтированный файл /key/id_tunnel (dev/test, где репозиторий есть на хосте).
if [ ! -f /key/id_tunnel ]; then mkdir -p /root/.ssh
echo "[tunnel] ERROR: /key/id_tunnel is not a regular file." KEY=/root/.ssh/id_tunnel
echo "[tunnel] Put your private key at deploy/tunnel/id_tunnel on the host (its pubkey must be in authorized_keys of tunnel@VPS)." if [ -n "${TUNNEL_KEY_B64:-}" ]; then
echo "$TUNNEL_KEY_B64" | base64 -d > "$KEY"
elif [ -f /key/id_tunnel ]; then
cp /key/id_tunnel "$KEY"
else
echo "[tunnel] ERROR: нет ключа — задай TUNNEL_KEY_B64 в .env (base64) или примонтируй /key/id_tunnel."
echo "[tunnel] base64 ключа без переносов: base64 -w0 deploy/tunnel/id_tunnel"
exit 1 exit 1
fi fi
chmod 600 "$KEY"
# Копируем ключ и выставляем строгие права (с хоста права могут не годиться, особенно с Windows).
mkdir -p /root/.ssh
cp /key/id_tunnel /root/.ssh/id_tunnel
chmod 600 /root/.ssh/id_tunnel
echo "[tunnel] -R ${VPS_TUNNEL_PORT}:${UPSTREAM} -> ${VPS_TUNNEL_USER}@${VPS_TUNNEL_HOST}" echo "[tunnel] -R ${VPS_TUNNEL_PORT}:${UPSTREAM} -> ${VPS_TUNNEL_USER}@${VPS_TUNNEL_HOST}"
exec ssh -N \ exec ssh -N \
+10 -7
View File
@@ -1,9 +1,11 @@
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# ПРОД на Raspberry Pi (forbiddenstars.ru) — единый compose: приложение + туннель. # ПРОД на Raspberry Pi (forbiddenstars.ru) — единый compose: приложение + туннель.
# Все настройки и секреты — в корневом .env (см. .env.example). Этот файл их только # На Pi нужны только ДВА файла: этот docker-compose.yml и .env. Репозиторий, сборка и
# пробрасывает; ничего секретного тут нет. # файл ключа не нужны: образы тянутся из реестра (pull_policy: always), а приватный ключ
# туннеля лежит в .env (TUNNEL_KEY_B64, base64). build: оставлен для сборки/пуша на ПК
# (scripts/build-push.*) — на Pi он не используется.
# #
# Запуск/обновление: docker compose up -d --build # Запуск/обновление: docker compose up -d (сам тянет свежие образы)
# Логи: docker compose logs -f app (или: ... tunnel) # Логи: docker compose logs -f app (или: ... tunnel)
# Остановка: docker compose down (данные в томах сохраняются) # Остановка: docker compose down (данные в томах сохраняются)
# Бэкап/restore: scripts/backup.sh / scripts/restore.sh # Бэкап/restore: scripts/backup.sh / scripts/restore.sh
@@ -18,6 +20,7 @@ services:
# Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.sh) и тянется # Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.sh) и тянется
# на Pi через `docker compose pull`. build: оставлен как локальный фолбэк (сборка на Pi). # на Pi через `docker compose pull`. build: оставлен как локальный фолбэк (сборка на Pi).
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars:${IMAGE_TAG:-latest} image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars:${IMAGE_TAG:-latest}
pull_policy: always # на Pi всегда тянем образ из реестра (без сборки)
restart: unless-stopped restart: unless-stopped
init: true # PID 1 пробрасывает сигналы → корректный SIGTERM init: true # PID 1 пробрасывает сигналы → корректный SIGTERM
stop_grace_period: 30s # время на чек-пойнт WAL SQLite перед остановкой stop_grace_period: 30s # время на чек-пойнт WAL SQLite перед остановкой
@@ -46,22 +49,22 @@ services:
max-file: "5" max-file: "5"
# SSH reverse-туннель: VPS:${VPS_TUNNEL_PORT:-9000} → app:8000 (домен forbiddenstars.ru). # SSH reverse-туннель: VPS:${VPS_TUNNEL_PORT:-9000} → app:8000 (домен forbiddenstars.ru).
# Ключ — ./deploy/tunnel/id_tunnel (в git не идёт); его pubkey в authorized_keys у tunnel@VPS. # Приватный ключ — в .env как TUNNEL_KEY_B64 (base64); pubkey — в authorized_keys у tunnel@VPS.
tunnel: tunnel:
build: ./deploy/tunnel build: ./deploy/tunnel
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars-tunnel:${IMAGE_TAG:-latest} image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars-tunnel:${IMAGE_TAG:-latest}
pull_policy: always
restart: unless-stopped restart: unless-stopped
init: true init: true
depends_on: depends_on:
app: app:
condition: service_healthy condition: service_healthy
environment: environment:
VPS_TUNNEL_HOST: ${VPS_TUNNEL_HOST:?VPS_TUNNEL_HOST is required in .env} VPS_TUNNEL_HOST: ${VPS_TUNNEL_HOST} # обязателен; tunnel.sh упадёт с ошибкой, если пуст
VPS_TUNNEL_USER: ${VPS_TUNNEL_USER:-tunnel} VPS_TUNNEL_USER: ${VPS_TUNNEL_USER:-tunnel}
VPS_TUNNEL_PORT: ${VPS_TUNNEL_PORT:-9000} # прод-слот (forbiddenstars.ru) VPS_TUNNEL_PORT: ${VPS_TUNNEL_PORT:-9000} # прод-слот (forbiddenstars.ru)
UPSTREAM: app:8000 UPSTREAM: app:8000
volumes: TUNNEL_KEY_B64: ${TUNNEL_KEY_B64:-} # приватный ключ туннеля (base64), из .env
- ./deploy/tunnel/id_tunnel:/key/id_tunnel:ro
mem_limit: 64m mem_limit: 64m
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true