perf(deploy): не пересобирать окружение uv в рантайме контейнеров
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled

Образ собран с `uv sync --frozen --no-dev`, но `uv run` перед каждым запуском
заново синхронизирует venv и подтягивает dev-группу. В логах старта
vidconf-backend-1 и vidconf-worker-1 на проде это видно как «Downloading ruff /
mypy / pygments» и «Installed 12 packages». Хуже всего healthcheck'и: они
выполняют ту же синхронизацию каждые 15 секунд всю жизнь контейнера.

Проверено на локально собранном образе, одна и та же команда:

  uv run             — качает 12 пакетов, venv 456 → 574 МБ
  uv run --no-sync   — не качает ничего, venv остаётся 456 МБ

Флаг добавлен во все вызовы в прод-путях: CMD образа, command/entrypoint/
healthcheck всех сервисов compose, миграции и seed в install.sh, те же команды
в docs/deploy. Локальная разработка (dev-setup.md, backend/README.md, CI) не
затронута — там dev-зависимости нужны. Заодно убрано устаревшее объяснение
ретрая `up -d --wait`: первый старт больше не синхронизирует окружение.

Версия uv в образе — 0.11.33, `--no-sync` поддерживается.
This commit is contained in:
2026-07-28 23:25:56 +03:00
parent eb4e5ea83f
commit 7a5e9d2d8a
6 changed files with 34 additions and 21 deletions

View File

@@ -45,5 +45,12 @@ HEALTHCHECK --interval=10s --timeout=5s --retries=10 --start-period=15s \
# #
# `sh -c` нужен ради подстановки переменной (exec-форма её не делает), # `sh -c` нужен ради подстановки переменной (exec-форма её не делает),
# `exec` — чтобы uvicorn получил PID 1 и корректно принимал SIGTERM. # `exec` — чтобы uvicorn получил PID 1 и корректно принимал SIGTERM.
#
# `--no-sync`: окружение уже собрано выше (`uv sync --frozen --no-dev`), и
# пересобирать его в рантайме незачем. Без флага `uv run` перед каждым
# запуском заново синхронизирует venv И ПОДТЯГИВАЕТ dev-группу (ruff, mypy,
# pytest — ~30 МБ загрузок на каждый старт контейнера, dev-инструменты в
# проде и раздутый venv). То же касается healthcheck'ов в
# deploy/docker-compose.yml, которые дёргают `uv run` каждые 15 секунд.
CMD ["sh", "-c", \ CMD ["sh", "-c", \
"exec uv run uvicorn main:create_app --factory --host 0.0.0.0 --port 8000 --workers ${UVICORN_WORKERS:-2}"] "exec uv run --no-sync uvicorn main:create_app --factory --host 0.0.0.0 --port 8000 --workers ${UVICORN_WORKERS:-2}"]

View File

@@ -125,7 +125,13 @@ services:
context: ../backend context: ../backend
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker", "-B", # `--no-sync` во ВСЕХ вызовах `uv run` в этом файле: окружение собрано на
# этапе build образа (`uv sync --frozen --no-dev`, backend/Dockerfile), а
# без флага `uv run` синхронизирует venv заново при каждом запуске — и
# тянет dev-группу (ruff, mypy, pytest), которой в проде делать нечего.
# Для healthcheck'ов это особенно дорого: они дёргаются каждые 15 секунд
# всю жизнь контейнера.
command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker", "-B",
"-Q", "celery,summarize,notify", "--loglevel=info"] "-Q", "celery,summarize,notify", "--loglevel=info"]
env_file: env_file:
- ../.env - ../.env
@@ -152,7 +158,7 @@ services:
# нет HTTP-сервера на 8000. Проверяем воркер через `celery ... inspect # нет HTTP-сервера на 8000. Проверяем воркер через `celery ... inspect
# ping`, как рекомендует документация Celery. # ping`, как рекомендует документация Celery.
healthcheck: healthcheck:
test: ["CMD", "uv", "run", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5"] test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5"]
interval: 15s interval: 15s
timeout: 10s timeout: 10s
retries: 5 retries: 5
@@ -186,7 +192,7 @@ services:
build: build:
context: ../backend context: ../backend
dockerfile: Dockerfile dockerfile: Dockerfile
entrypoint: ["uv", "run", "python", "/download-model.py"] entrypoint: ["uv", "run", "--no-sync", "python", "/download-model.py"]
environment: environment:
WHISPER_MODEL: ${WHISPER_MODEL:-small} WHISPER_MODEL: ${WHISPER_MODEL:-small}
WHISPER_MODELS_ROOT: /models/whisper WHISPER_MODELS_ROOT: /models/whisper
@@ -221,7 +227,7 @@ services:
# `worker`, и `celery inspect ping` без `--destination` опросит ВЕСЬ # `worker`, и `celery inspect ping` без `--destination` опросит ВЕСЬ
# кластер — упавший worker-transcriber остался бы "healthy", потому что # кластер — упавший worker-transcriber остался бы "healthy", потому что
# ответил бы базовый worker. # ответил бы базовый worker.
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker", command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker",
"-Q", "transcription", "--pool=solo", "--concurrency=1", "-Q", "transcription", "--pool=solo", "--concurrency=1",
"--hostname=worker-transcriber@localhost", "--loglevel=info"] "--hostname=worker-transcriber@localhost", "--loglevel=info"]
env_file: env_file:
@@ -252,7 +258,7 @@ services:
# HTTP-эндпоинта нет — пинг celery, но именно этого узла (см. --hostname # HTTP-эндпоинта нет — пинг celery, но именно этого узла (см. --hostname
# в command выше), а не первого ответившего в общем кластере. # в command выше), а не первого ответившего в общем кластере.
healthcheck: healthcheck:
test: ["CMD", "uv", "run", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber@localhost"] test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber@localhost"]
interval: 15s interval: 15s
timeout: 10s timeout: 10s
retries: 5 retries: 5
@@ -277,7 +283,7 @@ services:
args: args:
WITH_GPU_EXTRA: "true" WITH_GPU_EXTRA: "true"
restart: unless-stopped restart: unless-stopped
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker", command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker",
"-Q", "transcription", "--pool=solo", "--concurrency=1", "-Q", "transcription", "--pool=solo", "--concurrency=1",
"--hostname=worker-transcriber-gpu@localhost", "--loglevel=info"] "--hostname=worker-transcriber-gpu@localhost", "--loglevel=info"]
env_file: env_file:
@@ -310,7 +316,7 @@ services:
whisper-model-init: whisper-model-init:
condition: service_completed_successfully condition: service_completed_successfully
healthcheck: healthcheck:
test: ["CMD", "uv", "run", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber-gpu@localhost"] test: ["CMD", "uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "inspect", "ping", "--timeout", "5", "--destination", "worker-transcriber-gpu@localhost"]
interval: 15s interval: 15s
timeout: 10s timeout: 10s
retries: 5 retries: 5

View File

@@ -400,7 +400,7 @@ docker exec vidconf-postgres-1 pg_dump -U vidconf vidconf | gzip > db-$(date +%F
```bash ```bash
gunzip -c db-2026-07-25.sql.gz | docker exec -i vidconf-postgres-1 psql -U vidconf vidconf gunzip -c db-2026-07-25.sql.gz | docker exec -i vidconf-postgres-1 psql -U vidconf vidconf
# Догнать миграции, если бэкап снят на более старой версии кода: # Догнать миграции, если бэкап снят на более старой версии кода:
docker compose -f deploy/docker-compose.yml --env-file .env run --rm backend uv run alembic upgrade head docker compose -f deploy/docker-compose.yml --env-file .env run --rm backend uv run --no-sync alembic upgrade head
docker compose -f deploy/docker-compose.yml --env-file .env restart backend worker docker compose -f deploy/docker-compose.yml --env-file .env restart backend worker
``` ```

View File

@@ -63,7 +63,7 @@ CPU-only — отдельного GPU-варианта профилей для
Поведение: Поведение:
- **Дефолт (Enter):** применяет матрицу выбранного пресета к настройкам БД - **Дефолт (Enter):** применяет матрицу выбранного пресета к настройкам БД
(defs: `docker compose exec -T backend uv run python -m scripts.apply_preset_settings --force`) (defs: `docker compose exec -T backend uv run --no-sync python -m scripts.apply_preset_settings --force`)
- **Отказ (`n`):** сохраняет ручные правки админа; переменные `BOOTSTRAP_*` в `.env` - **Отказ (`n`):** сохраняет ручные правки админа; переменные `BOOTSTRAP_*` в `.env`
обновляются, но скрипт применения настроек НЕ запускается обновляются, но скрипт применения настроек НЕ запускается
- **Флаг `--yes`:** автоматически применяет пресет без вопроса (для CI/CD) - **Флаг `--yes`:** автоматически применяет пресет без вопроса (для CI/CD)
@@ -111,7 +111,7 @@ Grafana/Prometheus не поднимаются автоматически — с
собирает фронтенд-SPA и вкомпилирует статику, `frontend/Dockerfile`). собирает фронтенд-SPA и вкомпилирует статику, `frontend/Dockerfile`).
4. Поднимает `postgres`/`redis` (`up -d --wait`) и применяет **до старта 4. Поднимает `postgres`/`redis` (`up -d --wait`) и применяет **до старта
backend** миграции и seed одноразовыми контейнерами: backend** миграции и seed одноразовыми контейнерами:
`docker compose run --rm backend uv run alembic upgrade head` + `docker compose run --rm backend uv run --no-sync alembic upgrade head` +
`... python -m scripts.seed`. Порядок критичен: `backend.lifespan` `... python -m scripts.seed`. Порядок критичен: `backend.lifespan`
бутстрапит `instance_settings` при каждом старте приложения, поэтому на бутстрапит `instance_settings` при каждом старте приложения, поэтому на
чистой БД таблицы обязаны существовать до первого запуска backend — иначе чистой БД таблицы обязаны существовать до первого запуска backend — иначе
@@ -122,9 +122,8 @@ Grafana/Prometheus не поднимаются автоматически — с
настройки инстанса не перетираются). настройки инстанса не перетираются).
5. Поднимает остальной стек: `docker compose <--profile ...> up -d --wait` 5. Поднимает остальной стек: `docker compose <--profile ...> up -d --wait`
(backend, worker, nginx с фронтом + сервисы активных профилей). Команда (backend, worker, nginx с фронтом + сервисы активных профилей). Команда
идемпотентна и обёрнута в ретрай (до 3 попыток): первый старт backend/worker идемпотентна и обёрнута в ретрай (до 3 попыток): на слабой/загруженной
включает `uv run` (синхронизация окружения + компиляция байткода), и на машине healthcheck может не успеть за отведённые
слабой/загруженной машине healthcheck может не успеть за отведённые
retries — повтор лишь дожидается уже стартующих контейнеров. retries — повтор лишь дожидается уже стартующих контейнеров.
6. Печатает сводку: URL фронтенда/бэкенда, учётные данные администратора, 6. Печатает сводку: URL фронтенда/бэкенда, учётные данные администратора,
команда для `--profile monitoring`. команда для `--profile monitoring`.

View File

@@ -63,7 +63,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
# Без -B: beat уже запущен на базовом worker (см. правило выше). # Без -B: beat уже запущен на базовом worker (см. правило выше).
command: ["uv", "run", "celery", "-A", "workers.celery_app", "worker", command: ["uv", "run", "--no-sync", "celery", "-A", "workers.celery_app", "worker",
"-Q", "summarize", "--hostname=worker-summarize-%h@%h", "--loglevel=info"] "-Q", "summarize", "--hostname=worker-summarize-%h@%h", "--loglevel=info"]
env_file: env_file:
- ../.env - ../.env

View File

@@ -526,13 +526,14 @@ docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --wait postgres r
# exist" и healthcheck (--wait) никогда не проходит. `compose run` запускает # exist" и healthcheck (--wait) никогда не проходит. `compose run` запускает
# одноразовый контейнер с нужной командой, не поднимая uvicorn/lifespan. # одноразовый контейнер с нужной командой, не поднимая uvicorn/lifespan.
echo "[install] Применяю миграции Alembic и seed (админ/справочники) — до старта backend" echo "[install] Применяю миграции Alembic и seed (админ/справочники) — до старта backend"
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm backend uv run alembic upgrade head # `--no-sync`: окружение собрано в образе, повторная синхронизация в рантайме
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm backend uv run python -m scripts.seed # только тянула бы dev-группу (см. комментарий в backend/Dockerfile).
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm backend uv run --no-sync alembic upgrade head
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" run --rm backend uv run --no-sync python -m scripts.seed
echo "[install] docker compose up -d --wait (backend/worker/nginx и остальные сервисы профиля)" echo "[install] docker compose up -d --wait (backend/worker/nginx и остальные сервисы профиля)"
# Первый старт backend/worker включает `uv run` (синхронизация окружения + # На слабой/загруженной машине healthcheck может не успеть пройти за отведённые
# компиляция байткода) — на слабой/загруженной машине healthcheck может не # retries, и `--wait` вернёт "container is
# успеть пройти за отведённые retries, и `--wait` вернёт "container is
# unhealthy", хотя сервис через несколько секунд становится healthy. Команда # unhealthy", хотя сервис через несколько секунд становится healthy. Команда
# идемпотентна, поэтому повторяем её несколько раз: повтор лишь дожидается # идемпотентна, поэтому повторяем её несколько раз: повтор лишь дожидается
# уже стартующих контейнеров, ничего не пересоздавая. # уже стартующих контейнеров, ничего не пересоздавая.
@@ -573,7 +574,7 @@ if [ "$FRESH_ENV" != "1" ]; then
fi fi
if [ "$APPLY_PRESET_SETTINGS" = "1" ]; then if [ "$APPLY_PRESET_SETTINGS" = "1" ]; then
echo "[install] Применяю настройки модулей инстанса под пресет ${PRESET}" echo "[install] Применяю настройки модулей инстанса под пресет ${PRESET}"
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" exec -T backend uv run python -m scripts.apply_preset_settings --force docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" exec -T backend uv run --no-sync python -m scripts.apply_preset_settings --force
else else
echo "[install] Настройки модулей инстанса НЕ изменены — сохранены ручные правки администратора" echo "[install] Настройки модулей инстанса НЕ изменены — сохранены ручные правки администратора"
fi fi