feat(tradein): отдельный scraper-контейнер — scheduler вне API-процесса #1183

Merged
lekss361 merged 2 commits from feat/tradein-scraper-container into main 2026-06-12 08:01:24 +00:00
4 changed files with 232 additions and 5 deletions

View file

@ -28,6 +28,7 @@ jobs:
frontend: ${{ steps.filter.outputs.frontend }} frontend: ${{ steps.filter.outputs.frontend }}
browser: ${{ steps.filter.outputs.browser }} browser: ${{ steps.filter.outputs.browser }}
infra: ${{ steps.filter.outputs.infra }} infra: ${{ steps.filter.outputs.infra }}
scraper: ${{ steps.filter.outputs.scraper }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dorny/paths-filter@v3 - uses: dorny/paths-filter@v3
@ -44,6 +45,11 @@ jobs:
- 'tradein-mvp/docker-compose.prod.yml' - 'tradein-mvp/docker-compose.prod.yml'
- 'tradein-mvp/deploy/**' - 'tradein-mvp/deploy/**'
- '.forgejo/workflows/deploy-tradein.yml' - '.forgejo/workflows/deploy-tradein.yml'
scraper:
- 'tradein-mvp/backend/app/services/scrapers/**'
- 'tradein-mvp/backend/app/services/scheduler.py'
- 'tradein-mvp/backend/app/scheduler_main.py'
- 'tradein-mvp/backend/app/tasks/**'
# Quality gate: pytest MUST pass before any image is built/deployed (#666). # Quality gate: pytest MUST pass before any image is built/deployed (#666).
# Runs the tradein-mvp/backend suite; a red test blocks build + deploy. # Runs the tradein-mvp/backend suite; a red test blocks build + deploy.
@ -203,12 +209,13 @@ jobs:
env: env:
IMAGE_TAG: latest IMAGE_TAG: latest
GHCR_PAT: ${{ secrets.GHCR_PAT }} GHCR_PAT: ${{ secrets.GHCR_PAT }}
SCRAPER_CHANGED: ${{ needs.changes.outputs.scraper == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' }}
with: with:
host: ${{ secrets.DEPLOY_HOST }} host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }} username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }} key: ${{ secrets.DEPLOY_SSH_KEY }}
port: ${{ secrets.DEPLOY_PORT }} port: ${{ secrets.DEPLOY_PORT }}
envs: IMAGE_TAG,GHCR_PAT envs: IMAGE_TAG,GHCR_PAT,SCRAPER_CHANGED
script: | script: |
set -euo pipefail set -euo pipefail
cd /opt/gendesign cd /opt/gendesign
@ -238,7 +245,17 @@ jobs:
export IMAGE_TAG="$IMAGE_TAG" export IMAGE_TAG="$IMAGE_TAG"
docker compose -p gendesign-tradein -f docker-compose.prod.yml pull docker compose -p gendesign-tradein -f docker-compose.prod.yml pull
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d
# Селективный up: scraper НЕ пересоздаём на каждый backend-деплой (#1182) —
# один image :latest на backend+scraper, блочный up -d рестартовал бы scraper
# и убивал бегущий sweep. Scraper поднимается только при scraper/infra изменениях.
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres browser backend frontend
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
echo "→ scraper paths changed — recreating tradein-scraper"
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps scraper
else
echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)"
fi
# Применяем SQL миграции (если есть backend/data/sql/*.sql) # Применяем SQL миграции (если есть backend/data/sql/*.sql)
# Postgres init load *.sql из /docker-entrypoint-initdb.d ТОЛЬКО при первом # Postgres init load *.sql из /docker-entrypoint-initdb.d ТОЛЬКО при первом

View file

@ -0,0 +1,106 @@
"""Standalone entrypoint для tradein-scraper контейнера (#1182).
Зачем: API-деплой (docker restart tradein-backend) не должен прерывать
бегущие scraper sweep'ы (avito/cian/rosreestr). Вынесен в отдельный
контейнер tradein-scraper с тем же backend-образом, но другой командой.
Запуск: python -m app.scheduler_main
Zombie-reap: reap_zombies() вызывается в начале каждого тика scheduler_loop()
(см. app/services/scheduler.py), дублировать здесь не нужно.
"""
from __future__ import annotations
import asyncio
import logging
import os
import signal
import sys
from contextlib import suppress
from app.core.config import settings
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
# Только integrations без Starlette/FastAPI (у нас нет ASGI-приложения здесь).
# SqlalchemyIntegration + HttpxIntegration + LoggingIntegration покрывают scraper-слой.
if settings.glitchtip_dsn:
import sentry_sdk
from sentry_sdk.integrations.httpx import HttpxIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from app.observability.sentry_scrub import scrub_pii_event
sentry_sdk.init(
dsn=settings.glitchtip_dsn,
environment=settings.environment,
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
traces_sample_rate=0.0,
send_default_pii=False,
before_send=scrub_pii_event,
integrations=[
SqlalchemyIntegration(),
HttpxIntegration(),
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
],
)
logger.info("GlitchTip monitoring enabled (scheduler_main)")
def _should_run() -> bool:
"""Проверить kill-switch SCHEDULER_ENABLE перед запуском asyncio loop."""
return settings.scheduler_enable
async def _run() -> None:
"""Async entrypoint: запустить scheduler_loop() с чистой отменой по SIGTERM/SIGINT."""
from app.services.scheduler import scheduler_loop
task = asyncio.create_task(scheduler_loop())
loop = asyncio.get_running_loop()
def _cancel(_signum: int, _frame: object = None) -> None:
logger.info("scheduler_main: signal received — cancelling scheduler task")
task.cancel()
try:
loop.add_signal_handler(signal.SIGTERM, lambda: _cancel(signal.SIGTERM))
loop.add_signal_handler(signal.SIGINT, lambda: _cancel(signal.SIGINT))
except NotImplementedError:
# Windows dev: signal handlers через loop не поддерживаются
logger.warning("scheduler_main: loop.add_signal_handler not supported (Windows dev)")
with suppress(asyncio.CancelledError):
await task
logger.info("scheduler_main: scheduler cancelled cleanly")
if __name__ == "__main__":
if not _should_run():
logger.warning("scheduler_main: scheduler disabled via SCHEDULER_ENABLE — exiting")
sys.exit(0)
# Best-effort FDW bootstrap: чтобы ensure_fdw_user_mapping отработал при старте
# scraper-контейнера (postgres_fdw нужен для rosreestr_dkp_import).
try:
from app.core.db import SessionLocal
from app.core.fdw import ensure_fdw_user_mapping
with SessionLocal() as db:
ensure_fdw_user_mapping(db)
except Exception:
logger.exception(
"scheduler_main: FDW user mapping bootstrap failed — cadastral queries may fail"
)
asyncio.run(_run())

View file

@ -0,0 +1,72 @@
"""Тесты для scheduler_main.py — standalone scraper-контейнер (#1182).
(a) модуль импортируется без реального DB/сети.
(b) _should_run() возвращает False при SCHEDULER_ENABLE=false.
(c) _run() отменяется чисто без unhandled exceptions.
"""
from __future__ import annotations
import asyncio
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
import app.scheduler_main as sm
# (a) Импорт модуля
def test_scheduler_main_importable() -> None:
"""scheduler_main импортируется без ошибок."""
assert sm is not None
assert hasattr(sm, "_should_run")
assert hasattr(sm, "_run")
# (b) Kill-switch SCHEDULER_ENABLE=false
def test_should_run_false_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""_should_run() == False когда SCHEDULER_ENABLE отключён."""
monkeypatch.setattr(sm.settings, "scheduler_enable", False)
assert sm._should_run() is False
def test_should_run_true_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""_should_run() == True при включённом scheduler."""
monkeypatch.setattr(sm.settings, "scheduler_enable", True)
assert sm._should_run() is True
# (c) _run() отменяется чисто
@pytest.mark.asyncio
async def test_run_cancels_cleanly(monkeypatch: pytest.MonkeyPatch) -> None:
"""_run() завершается без unhandled exceptions при отмене задачи."""
cancelled = asyncio.Event()
async def _stub_loop() -> None:
"""Короткая заглушка: ждёт отмены, имитируя бесконечный scheduler_loop."""
cancelled.set()
await asyncio.sleep(999)
# Патчим источник импорта: _run() делает `from app.services.scheduler import scheduler_loop`
# на каждый вызов, поэтому патч модуля-источника достаточен.
import app.services.scheduler as _sched_mod
monkeypatch.setattr(_sched_mod, "scheduler_loop", _stub_loop)
task = asyncio.create_task(sm._run())
# Ждём пока _stub_loop запустится (флаг)
await asyncio.wait_for(cancelled.wait(), timeout=2.0)
task.cancel()
# Не должно бросить никакого исключения
try:
await task
except asyncio.CancelledError:
pass # CancelledError от самого task — допустимо
# Тест прошёл если не было unhandled exception
assert task.done()

View file

@ -54,9 +54,9 @@ services:
container_name: tradein-backend container_name: tradein-backend
# Prod: uvicorn БЕЗ --reload (Dockerfile CMD несёт --reload только для dev hot-reload, # Prod: uvicorn БЕЗ --reload (Dockerfile CMD несёт --reload только для dev hot-reload,
# где app/ bind-mount'ится). В prod --reload = лишний WatchFiles-наблюдатель + риск # где app/ bind-mount'ится). В prod --reload = лишний WatchFiles-наблюдатель + риск
# reload в середине запроса. Single worker ОБЯЗАТЕЛЕН: in-app планировщик # reload в середине запроса. Single worker сохраняется для предсказуемости
# (app.services.scheduler, tick=60s) живёт в процессе backend — несколько uvicorn # (один process = единственный asyncio event loop, нет shared-state race).
# воркеров = дублирование scheduled-джоб (cian/rosreestr запустятся N раз). Без --workers. # Scheduler переехал в tradein-scraper (#1182) — SCHEDULER_ENABLE=false ниже.
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# .env.runtime пишется вручную на VPS (см. DEPLOY.md / scripts/README.md). # .env.runtime пишется вручную на VPS (см. DEPLOY.md / scripts/README.md).
# Контейнер читает YANDEX_GEOCODER_API_KEY, COOKIE_ENCRYPTION_KEY и пр. из # Контейнер читает YANDEX_GEOCODER_API_KEY, COOKIE_ENCRYPTION_KEY и пр. из
@ -81,6 +81,9 @@ services:
# Valuation Calculator (Stage 9). Empty = Calculator scraper disabled + # Valuation Calculator (Stage 9). Empty = Calculator scraper disabled +
# cookies upload endpoint returns 503. Generate with `openssl rand -hex 32`. # cookies upload endpoint returns 503. Generate with `openssl rand -hex 32`.
COOKIE_ENCRYPTION_KEY: "${COOKIE_ENCRYPTION_KEY:-}" COOKIE_ENCRYPTION_KEY: "${COOKIE_ENCRYPTION_KEY:-}"
# Scheduler отключён в API-процессе — он живёт в tradein-scraper (#1182).
# Это предотвращает двойной запуск sweep'ов при наличии обоих контейнеров.
SCHEDULER_ENABLE: "false"
depends_on: depends_on:
browser: browser:
condition: service_started condition: service_started
@ -96,6 +99,35 @@ services:
- tradein-net - tradein-net
- gendesign_shared # чтобы Caddy gendesign-стека достучался - gendesign_shared # чтобы Caddy gendesign-стека достучался
# Отдельный scraper-контейнер (#1182): scheduler_loop() живёт здесь, а не в API.
# Мотивация: docker restart tradein-backend убивал бегущие sweep'ы (avito/cian/
# rosreestr могут идти часами). Теперь backend-деплой не трогает scraper.
# Образ тот же что у backend (один Dockerfile), команда другая.
# НЕ пересоздаётся при каждом backend-деплое — селективный up в deploy-tradein.yml
# поднимает scraper только при изменениях scraper/infra путей (см. SCRAPER_CHANGED).
scraper:
image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest}
container_name: tradein-scraper
command: ["python", "-m", "app.scheduler_main"]
env_file:
- path: ./backend/.env.runtime
required: false
environment:
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein"
ENVIRONMENT: "production"
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
GENDESIGN_FDW_PASSWORD: "${GENDESIGN_FDW_PASSWORD:-}"
COOKIE_ENCRYPTION_KEY: "${COOKIE_ENCRYPTION_KEY:-}"
SCHEDULER_ENABLE: "true"
depends_on:
postgres:
condition: service_healthy
browser:
condition: service_started
restart: unless-stopped
networks:
- tradein-net
frontend: frontend:
image: ghcr.io/lekss361/gendesign-tradein-frontend:${IMAGE_TAG:-latest} image: ghcr.io/lekss361/gendesign-tradein-frontend:${IMAGE_TAG:-latest}
container_name: tradein-frontend container_name: tradein-frontend