feat(deploy): healthcheck api и worker, отдельный migrate-сервис, лимит загрузки отчёта

Миграции вынесены из команды api в one-shot сервис migrate, api и worker стартуют после него. /health отвечает 503 при недоступной БД. Отчёт больше MAX_UPLOAD_BYTES (25 МиБ) получает 413, Caddy режет на 30 МБ раньше. Том uploads убран, секреты env_file передаются сервисам явно. В CI добавлена сборка образа бэкенда без push, test_migrations сверяет модели с историей Alembic.
This commit is contained in:
Dmitry
2026-09-19 21:55:33 +03:00
parent 600496048f
commit 4a0b4e0532
11 changed files with 139 additions and 21 deletions
+13
View File
@@ -41,6 +41,19 @@ jobs:
uv run fintracker openapi /tmp/openapi.json uv run fintracker openapi /tmp/openapi.json
diff -u ../openapi/openapi.json /tmp/openapi.json diff -u ../openapi/openapi.json /tmp/openapi.json
backend-image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
# build only (no push): a broken Dockerfile should fail here, not on the VPS
- uses: docker/build-push-action@v6
with:
context: backend
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
app: app:
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
-1
View File
@@ -21,7 +21,6 @@ COPY --from=builder /app/src /app/src
COPY --from=builder /app/alembic.ini /app/alembic.ini COPY --from=builder /app/alembic.ini /app/alembic.ini
COPY --from=builder /app/alembic /app/alembic COPY --from=builder /app/alembic /app/alembic
COPY certs /app/certs COPY certs /app/certs
RUN mkdir -p /data/uploads && chown -R app /data
USER app USER app
EXPOSE 8000 EXPOSE 8000
CMD ["fintracker", "serve", "--host", "0.0.0.0", "--port", "8000"] CMD ["fintracker", "serve", "--host", "0.0.0.0", "--port", "8000"]
+1 -1
View File
@@ -74,7 +74,7 @@ include = ["src", "tests"]
extraPaths = ["src"] extraPaths = ["src"]
pythonVersion = "3.12" pythonVersion = "3.12"
typeCheckingMode = "standard" typeCheckingMode = "standard"
reportMissingImports = "warning" reportMissingImports = "error"
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["src"] pythonpath = ["src"]
+4 -2
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter from fastapi import APIRouter, Response, status
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import text from sqlalchemy import text
@@ -17,10 +17,12 @@ class Health(BaseModel):
@router.get("/health", name="check") @router.get("/health", name="check")
async def check(session: SessionDep) -> Health: async def check(session: SessionDep, response: Response) -> Health:
"""503 when the database cannot be reached, so a container healthcheck can trust the code."""
try: try:
await session.execute(text("SELECT 1")) await session.execute(text("SELECT 1"))
db = "ok" db = "ok"
except Exception as exc: except Exception as exc:
db = f"error: {type(exc).__name__}" db = f"error: {type(exc).__name__}"
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return Health(status="ok" if db == "ok" else "degraded", version=__version__, database=db) return Health(status="ok" if db == "ok" else "degraded", version=__version__, database=db)
@@ -20,7 +20,7 @@ from typing import Annotated
from fastapi import APIRouter, File, Form, Query, UploadFile from fastapi import APIRouter, File, Form, Query, UploadFile
from sqlalchemy import select from sqlalchemy import select
from fintracker.api.deps import CurrentUser, SessionDep from fintracker.api.deps import CurrentUser, SessionDep, SettingsDep
from fintracker.api.errors import Problem from fintracker.api.errors import Problem
from fintracker.api.schemas.imports import ( from fintracker.api.schemas.imports import (
AccountSuggestion, AccountSuggestion,
@@ -64,13 +64,19 @@ def _problem(exc: ImportProblem) -> Problem:
@router.post("", name="create") @router.post("", name="create")
async def create_import( async def create_import(
session: SessionDep, session: SessionDep,
settings: SettingsDep,
_: CurrentUser, _: CurrentUser,
file: Annotated[UploadFile, File(description="the report itself")], file: Annotated[UploadFile, File(description="the report itself")],
account_id: Annotated[int | None, Form(description="target account, if known")] = None, account_id: Annotated[int | None, Form(description="target account, if known")] = None,
parser: Annotated[str | None, Form(description="force a parser from registry.names()")] = None, parser: Annotated[str | None, Form(description="force a parser from registry.names()")] = None,
) -> ImportPreview: ) -> ImportPreview:
"""Upload a report, parse it and show what committing it would do. Writes no event.""" """Upload a report, parse it and show what committing it would do. Writes no event."""
data = await file.read() limit = settings.max_upload_bytes
data = await file.read(limit + 1)
if len(data) > limit:
raise Problem(
413, "Payload Too Large", f"Report is larger than {limit // (1024 * 1024)} MiB"
)
try: try:
outcome = await report_import.upload( outcome = await report_import.upload(
session, session,
+13
View File
@@ -459,3 +459,16 @@ async def test_pending_rows_are_created_by_the_preview_alone(app, http, auth_hea
async with get_sessionmaker()() as session: async with get_sessionmaker()() as session:
assert await session.scalar(select(func.count()).select_from(PendingInstrument)) == 1 assert await session.scalar(select(func.count()).select_from(PendingInstrument)) == 1
assert await session.scalar(select(func.count()).select_from(Event)) == 0 assert await session.scalar(select(func.count()).select_from(Event)) == 0
async def test_oversized_report_is_413(app, http, auth_headers, parser, monkeypatch):
from fintracker.config import get_settings
monkeypatch.setenv("MAX_UPLOAD_BYTES", "1024")
get_settings.cache_clear()
try:
response = await upload(http, auth_headers, b"x" * 2048)
finally:
monkeypatch.undo()
get_settings.cache_clear()
assert response.status_code == 413, response.text
+17
View File
@@ -4,3 +4,20 @@ async def test_health_reports_db(client):
body = r.json() body = r.json()
assert body["status"] == "ok" assert body["status"] == "ok"
assert body["database"] == "ok" assert body["database"] == "ok"
async def test_health_is_503_when_the_database_is_down(app, client):
from fintracker.api.deps import get_session
class _Broken:
async def execute(self, *_a, **_k):
raise ConnectionRefusedError
async def broken():
yield _Broken()
app.dependency_overrides[get_session] = broken
r = await client.get("/api/v1/health")
assert r.status_code == 503
assert r.json()["status"] == "degraded"
assert r.json()["database"].startswith("error")
+20
View File
@@ -0,0 +1,20 @@
"""Models and Alembic history must describe the same schema — the `alembic check` of this
project. `migrated` upgrades a throwaway database to head, so a model change committed
without its revision shows up here as a non-empty diff."""
from __future__ import annotations
from alembic.autogenerate import compare_metadata
from alembic.migration import MigrationContext
async def test_migrations_match_the_models(migrated: str):
import fintracker.models # noqa: F401 — registers every table on Base.metadata
from fintracker.db import get_engine
from fintracker.db.base import Base
async with get_engine().connect() as conn:
diff = await conn.run_sync(
lambda c: compare_metadata(MigrationContext.configure(c), Base.metadata)
)
assert diff == [], f"models changed without a migration (run `just revision`): {diff}"
+4
View File
@@ -2,6 +2,10 @@
encode zstd gzip encode zstd gzip
handle /api/* { handle /api/* {
# outer guard; the API itself answers 413 for a report over MAX_UPLOAD_BYTES (25 MiB)
request_body {
max_size 30MB
}
reverse_proxy api:8000 reverse_proxy api:8000
} }
+50 -13
View File
@@ -2,8 +2,10 @@ services:
db: db:
image: docker.io/library/postgres:17-alpine image: docker.io/library/postgres:17-alpine
restart: unless-stopped restart: unless-stopped
env_file: .env
environment: environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --no-locale" POSTGRES_INITDB_ARGS: "--encoding=UTF8 --no-locale"
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
@@ -13,16 +15,39 @@ services:
timeout: 3s timeout: 3s
retries: 20 retries: 20
# one-shot: api and worker start only after the schema is at head, so neither races the other
migrate:
build: ./backend
environment:
DATABASE_URL: ${DATABASE_URL}
command: ["alembic", "upgrade", "head"]
depends_on:
db:
condition: service_healthy
api: api:
build: ./backend build: ./backend
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
command: ["sh", "-c", "alembic upgrade head && exec fintracker serve --host 0.0.0.0 --port 8000"] command: ["fintracker", "serve", "--host", "0.0.0.0", "--port", "8000"]
# not curl: the slim image has none. An empty ProxyHandler because a host proxy baked into
# the image at build time (see docs/ai/ops.md) would otherwise swallow 127.0.0.1.
healthcheck:
test:
- CMD
- python
- -c
- >-
import sys, urllib.request as u;
o = u.build_opener(u.ProxyHandler({}));
sys.exit(0 if o.open("http://127.0.0.1:8000/api/v1/health", timeout=4).status == 200 else 1)
interval: 30s
timeout: 6s
retries: 3
start_period: 30s
depends_on: depends_on:
db: migrate:
condition: service_healthy condition: service_completed_successfully
volumes:
- uploads:/data/uploads
expose: expose:
- "8000" - "8000"
@@ -31,16 +56,28 @@ services:
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
command: ["fintracker", "worker"] command: ["fintracker", "worker"]
# the worker has no port; it touches a file every 10 s (`worker/scheduler.py: _heartbeat`)
healthcheck:
test:
- CMD
- python
- -c
- >-
import os, sys, time;
sys.exit(0 if time.time() - os.path.getmtime("/tmp/fintracker-worker-heartbeat") < 60 else 1)
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
depends_on: depends_on:
db: migrate:
condition: service_healthy condition: service_completed_successfully
volumes:
- uploads:/data/uploads
caddy: caddy:
image: docker.io/library/caddy:2-alpine image: docker.io/library/caddy:2-alpine
restart: unless-stopped restart: unless-stopped
env_file: .env environment:
DOMAIN: ${DOMAIN:?set DOMAIN in .env}
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
@@ -55,7 +92,6 @@ services:
pg-backup: pg-backup:
image: docker.io/library/postgres:17-alpine image: docker.io/library/postgres:17-alpine
restart: unless-stopped restart: unless-stopped
env_file: .env
entrypoint: ["sh", "-c"] entrypoint: ["sh", "-c"]
# nightly logical dump, keep 14 days; copy ./backups off-site with rclone/cron on the host # nightly logical dump, keep 14 days; copy ./backups off-site with rclone/cron on the host
command: command:
@@ -67,6 +103,8 @@ services:
sleep 86400 sleep 86400
done done
environment: environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
PGPASSWORD: ${POSTGRES_PASSWORD} PGPASSWORD: ${POSTGRES_PASSWORD}
volumes: volumes:
- ./backups:/backups - ./backups:/backups
@@ -76,6 +114,5 @@ services:
volumes: volumes:
pgdata: pgdata:
uploads:
caddy_data: caddy_data:
caddy_config: caddy_config:
+9 -2
View File
@@ -4,13 +4,19 @@
```bash ```bash
cp .env.example .env # заполнить POSTGRES_PASSWORD, JWT_SECRET (openssl rand -hex 32), DOMAIN, токены cp .env.example .env # заполнить POSTGRES_PASSWORD, JWT_SECRET (openssl rand -hex 32), DOMAIN, токены
just up # docker compose up -d --build: db, api (миграции при старте), worker, caddy, pg-backup just up # docker compose up -d --build: db, migrate (one-shot `alembic upgrade head`), api, worker, caddy, pg-backup
docker compose exec api fintracker create-user you@example.com docker compose exec api fintracker create-user you@example.com
``` ```
- Caddy: авто-TLS на `DOMAIN`, `/api/*` → api:8000, остальное — Flutter web из `app/build/web`. - Caddy: авто-TLS на `DOMAIN`, `/api/*` → api:8000, остальное — Flutter web из `app/build/web`.
- Worker — единственный экземпляр планировщика; ручной синк через `POST /api/v1/sync/{source}` - Worker — единственный экземпляр планировщика; ручной синк через `POST /api/v1/sync/{source}`
ставит задачу в `sync_job`, worker забирает её раз в 5 с. ставит задачу в `sync_job`, worker забирает её раз в 5 с.
- Расписание — `worker/jobs.default_schedule()`: zenmoney каждые 30 мин, cbr 13:45 и 18:00 МСК,
tinvest каждые 3 ч с 8:10, moex в 10:20/14:20/19:20/23:20, tinvest_events и moex_payouts раз в
сутки утром. tinvest* без `TINVEST_TOKEN` в расписание не берутся (в логе worker'а — warning).
- Healthcheck: `api` ходит на `/api/v1/health` (503, если БД недоступна), `worker` пишет файл
`/tmp/fintracker-worker-heartbeat` каждые 10 с, проверка смотрит на его возраст (< 60 с).
Compose сам не перезапускает unhealthy-контейнер — статус виден в `docker compose ps`.
- `pg-backup` делает `pg_dump -Fc` раз в сутки в `./backups`, хранит 14 дней; off-site копию - `pg-backup` делает `pg_dump -Fc` раз в сутки в `./backups`, хранит 14 дней; off-site копию
настраивает хост (rclone/cron), см. открытый вопрос в плане. настраивает хост (rclone/cron), см. открытый вопрос в плане.
@@ -26,6 +32,7 @@ docker compose up -d
## Секреты ## Секреты
Только `.env` на сервере. В чат и в git не попадают. T-Invest токен — read-only. Только `.env` на сервере. В чат и в git не попадают. T-Invest токен — read-only.
В compose секреты раздаются по потребности: `api`/`worker` читают весь `.env`, `migrate` получает только `DATABASE_URL`, `db` и `pg-backup``POSTGRES_*`, `caddy``DOMAIN`.
## Проверено с podman (2026-09-17) ## Проверено с podman (2026-09-17)
@@ -33,4 +40,4 @@ docker compose up -d
- Имена образов в `docker-compose.yml` и `Dockerfile` полностью квалифицированы (`docker.io/library/...`) — podman без `unqualified-search-registries` короткие имена не резолвит. - Имена образов в `docker-compose.yml` и `Dockerfile` полностью квалифицированы (`docker.io/library/...`) — podman без `unqualified-search-registries` короткие имена не резолвит.
- **Прокси хоста впекается в образ** при `podman build`/`compose --build` (`HTTP_PROXY` попадает в `Config.Env`), после чего запросы к `localhost` внутри контейнера идут в недостижимый прокси. Поэтому `just up` собирает с `env -u …proxy…`. На VPS без прокси это ни на что не влияет. - **Прокси хоста впекается в образ** при `podman build`/`compose --build` (`HTTP_PROXY` попадает в `Config.Env`), после чего запросы к `localhost` внутри контейнера идут в недостижимый прокси. Поэтому `just up` собирает с `env -u …proxy…`. На VPS без прокси это ни на что не влияет.
- `env_file: .env` в compose — буквальный путь; `--env-file` меняет только подстановку `${…}` в самом compose-файле. - `env_file: .env` в compose — буквальный путь; `--env-file` меняет только подстановку `${…}` в самом compose-файле.
- Команда `api` использует `exec`, иначе `sh -c` не передаёт SIGTERM uvicorn'у и compose добивает контейнер по таймауту. - Миграции идут в one-shot сервисе `migrate`; `api` и `worker` ждут его `service_completed_successfully`. Для podman-compose это условие не проверялось.