MERA: бэкенд оценки крутится в один воркер uvicorn — тяжёлый /estimate внутри event loop блокирует всех #3083

Open
opened 2026-08-24 16:55:49 +00:00 by bot-backend · 3 comments
Collaborator

Смежная задача — #3082 (нет ограничителя одновременности на /estimate). Там же разбор, почему рейт-лимит и квота эту проблему не закрывают, и пункт про asyncio.gather. Здесь — только про число процессов.

Пути от tradein-mvp/.

Симптом

Прод-бэкенд MERA запускается ОДНИМ процессом uvicorn, --workers не задан — docker-compose.prod.yml:281 (комментарий :276-280: «один process = единственный asyncio event loop», см. также :254-255). Dev-образ такой же — backend/Dockerfile:111.

Роут оценки async def estimate (backend/app/api/v1/trade_in.py:408-409) исполняется в этом event loop, а не в threadpool. Один воркер = один процесс: тяжёлый синхронный участок внутри запроса блокирует всех остальных клиентов, сколько бы свободного CPU ни было рядом.

Почему это не лечится «просто добавить воркеров»

Число процессов — не первая ручка, которую тут стоит крутить, и вот почему.

  • Каждый воркер держит свой пул соединений к БД: N воркеров = N × пул. Реальный размер пула в engine надо смотреть глазами — цифры 5+10 известны только из комментария api/public/mera.py:120-121, это не факт о конфиге.
  • Есть in-process состояние, завязанное на единственности процесса: api/v1/support.py:21, core/password.py:173-178, api/v1/auth.py:105. С несколькими воркерами оно расходится по процессам.
  • Семафор из #3082 живёт в памяти процесса. Четыре воркера = четыре независимых семафора, то есть фактический лимит умножается на число воркеров. Эти две задачи надо согласовать, а не делать вслепую порознь.

Что сделать (чеклист)

  • Сначала замерить, во что упирается один воркер — CPU или память. Без этого замера остальные пункты бессмысленны
  • Вынести CPU-bound и синхронные участки estimate_quality (services/estimator.py:3852) из event loop — либо в threadpool, либо ограничить конкурентность семафором из #3082
  • Сверить реальный размер пула SQLAlchemy в engine и посчитать, сколько воркеров БД выдержит
  • Проверить перечисленное in-process состояние (support.py:21, password.py:173-178, auth.py:105) на пригодность к многопроцессности
  • Только после этого — --workers / WEB_CONCURRENCY. NB: в репозитории они не заданы, но рантайм-окружение пишется вручную на VPS (docker-compose.prod.yml:282-285), поэтому override оттуда из кода не виден — проверять на самой машине

Как проверить

  • N параллельных POST /estimate + фоновый лёгкий запрос: до фикса p95 лёгкого растёт с N, после — нет
  • количество процессов uvicorn в контейнере соответствует ожидаемому
  • пул БД не исчерпывается под той же нагрузкой

Не покрыто разведкой

Лимит одновременности на стороне Caddy; значения estimate_yandex_valuation_timeout_s / estimate_cian_valuation_timeout_s.

Смежная задача — #3082 (нет ограничителя одновременности на `/estimate`). Там же разбор, почему рейт-лимит и квота эту проблему не закрывают, и пункт про `asyncio.gather`. Здесь — только про число процессов. Пути от `tradein-mvp/`. ## Симптом Прод-бэкенд MERA запускается ОДНИМ процессом uvicorn, `--workers` не задан — `docker-compose.prod.yml:281` (комментарий `:276-280`: «один process = единственный asyncio event loop», см. также `:254-255`). Dev-образ такой же — `backend/Dockerfile:111`. Роут оценки `async def estimate` (`backend/app/api/v1/trade_in.py:408-409`) исполняется в этом event loop, а не в threadpool. Один воркер = один процесс: тяжёлый синхронный участок внутри запроса блокирует всех остальных клиентов, сколько бы свободного CPU ни было рядом. ## Почему это не лечится «просто добавить воркеров» Число процессов — не первая ручка, которую тут стоит крутить, и вот почему. - Каждый воркер держит **свой** пул соединений к БД: N воркеров = N × пул. Реальный размер пула в engine надо смотреть глазами — цифры 5+10 известны только из комментария `api/public/mera.py:120-121`, это не факт о конфиге. - Есть in-process состояние, завязанное на единственности процесса: `api/v1/support.py:21`, `core/password.py:173-178`, `api/v1/auth.py:105`. С несколькими воркерами оно расходится по процессам. - Семафор из #3082 живёт в памяти процесса. Четыре воркера = четыре независимых семафора, то есть фактический лимит умножается на число воркеров. Эти две задачи надо согласовать, а не делать вслепую порознь. ## Что сделать (чеклист) - [ ] **Сначала замерить**, во что упирается один воркер — CPU или память. Без этого замера остальные пункты бессмысленны - [ ] Вынести CPU-bound и синхронные участки `estimate_quality` (`services/estimator.py:3852`) из event loop — либо в threadpool, либо ограничить конкурентность семафором из #3082 - [ ] Сверить реальный размер пула SQLAlchemy в engine и посчитать, сколько воркеров БД выдержит - [ ] Проверить перечисленное in-process состояние (`support.py:21`, `password.py:173-178`, `auth.py:105`) на пригодность к многопроцессности - [ ] Только после этого — `--workers` / `WEB_CONCURRENCY`. NB: в репозитории они не заданы, но рантайм-окружение пишется вручную на VPS (`docker-compose.prod.yml:282-285`), поэтому override оттуда из кода не виден — проверять на самой машине ## Как проверить - N параллельных `POST /estimate` + фоновый лёгкий запрос: до фикса p95 лёгкого растёт с N, после — нет - количество процессов uvicorn в контейнере соответствует ожидаемому - пул БД не исчерпывается под той же нагрузкой ## Не покрыто разведкой Лимит одновременности на стороне Caddy; значения `estimate_yandex_valuation_timeout_s` / `estimate_cian_valuation_timeout_s`.
bot-backend added the
scope/devops
performance
tech-debt
priority/p2
tradein
scope/backend
labels 2026-08-24 16:55:49 +00:00
Author
Collaborator

Первый чекбокс («сначала замерить») — что установлено фактами 26.08, Poincare

Пул SQLAlchemy — подтверждён конфигом, а не комментарием. app/core/db.py:8: create_engine(..., pool_pre_ping=True, future=True) — ни pool_size, ни max_overflow не заданы → дефолт 5+10 на процесс. Цифра из комментария public/mera.py:120-121 оказалась верной.

Память. Контейнер tradein-backend: RSS 131 MiB в покое при лимите 768 MiB (docker inspect: mem=805306368, cpus — без лимита). 4 воркера ≈ 4×130 MiB только базы + пики сериализации — впритык; поднимать --workers без поднятия mem-лимита нельзя.

CPU. Хост 12 ядер, load average 0.06–0.44, контейнер без CPU-квоты. На текущем трафике (9 оценок за последние 3 суток) CPU не является узким местом вообще.

In-process состояние — все три места из чеклиста уже документируют свой per-process потолок:

  • support.py:19-23 — комментарий прямо опирается на «один uvicorn-процесс без --workers»;
  • password.py:173-178 — «потолок держится ПРОЦЕССОМ», и главное: REDIS_URL в окружении tradein-backend не задан вовсе (находка эпика #2674) — переносить лимитеры «в Redis» сегодня некуда;
  • auth.py:103-108 — «появятся воркеры → потолок делится на N, переносить в Redis»;
  • плюс новый семафор #3082 — тоже per-process (задокументировано у него).

Вывод по замеру. Наблюдаемой проблемы, которую решали бы --workers N, на текущем трафике нет: блокировка event loop реальна архитектурно, но её горячая часть теперь ограничена семафором (#3082, 4 слота), а нагрузка — единицы оценок в сутки. Идти в многопроцессность имеет смысл только при появлении реального пилотного трафика, и тогда пакетом: семафор → общий (Redis), три лимитера → Redis (которого в окружении нет), mem-лимит вверх, пул × N против max_connections.

Не проверено, с датой: CPU-vs-IO разрез одной оценки под нагрузкой не снят. Попытка 26.08 упёрлась в то, что Caddy basic_auth после переезда отвергает прежние учётки (401 даже на корне gendsgn.ru) — видимо, ротация периметра при cutover. Замер повторить при восстановленном доступе или на первом реальном трафике; критерий тот же — доля времени в БД/внешних тирax против CPU-времени процесса за окно оценки.

## Первый чекбокс («сначала замерить») — что установлено фактами 26.08, Poincare **Пул SQLAlchemy — подтверждён конфигом, а не комментарием.** `app/core/db.py:8`: `create_engine(..., pool_pre_ping=True, future=True)` — ни `pool_size`, ни `max_overflow` не заданы → дефолт **5+10 на процесс**. Цифра из комментария `public/mera.py:120-121` оказалась верной. **Память.** Контейнер `tradein-backend`: RSS **131 MiB** в покое при лимите **768 MiB** (`docker inspect`: mem=805306368, cpus — без лимита). 4 воркера ≈ 4×130 MiB только базы + пики сериализации — впритык; поднимать `--workers` без поднятия mem-лимита нельзя. **CPU.** Хост 12 ядер, load average 0.06–0.44, контейнер без CPU-квоты. На текущем трафике (9 оценок за последние 3 суток) CPU не является узким местом вообще. **In-process состояние — все три места из чеклиста уже документируют свой per-process потолок:** - `support.py:19-23` — комментарий прямо опирается на «один uvicorn-процесс без --workers»; - `password.py:173-178` — «потолок держится ПРОЦЕССОМ», и главное: **`REDIS_URL` в окружении tradein-backend не задан вовсе** (находка эпика #2674) — переносить лимитеры «в Redis» сегодня некуда; - `auth.py:103-108` — «появятся воркеры → потолок делится на N, переносить в Redis»; - плюс новый семафор #3082 — тоже per-process (задокументировано у него). **Вывод по замеру.** Наблюдаемой проблемы, которую решали бы `--workers N`, на текущем трафике нет: блокировка event loop реальна архитектурно, но её горячая часть теперь ограничена семафором (#3082, 4 слота), а нагрузка — единицы оценок в сутки. Идти в многопроцессность имеет смысл только при появлении реального пилотного трафика, и тогда пакетом: семафор → общий (Redis), три лимитера → Redis (которого в окружении нет), mem-лимит вверх, пул × N против max_connections. **Не проверено, с датой:** CPU-vs-IO разрез одной оценки под нагрузкой не снят. Попытка 26.08 упёрлась в то, что Caddy basic_auth после переезда отвергает прежние учётки (401 даже на корне gendsgn.ru) — видимо, ротация периметра при cutover. Замер повторить при восстановленном доступе или на первом реальном трафике; критерий тот же — доля времени в БД/внешних тирax против CPU-времени процесса за окно оценки.
Author
Collaborator

Поправка к моему замеру от 26.08: пункт «REDIS_URL в окружении tradein-backend не задан вовсе — переносить лимитеры в Redis некуда» устарел после переезда. Проверено 27.08: REDIS_URL=redis://gendesign-redis:6379/1 задан, DNS резолвится, redis-ошибок в логах за 6ч — ноль (дыра из #2674 закрыта env-доливкой). То есть путь «семафор/лимитеры → Redis» при переходе на воркеры теперь технически открыт — остальные выводы замера без изменений.

Поправка к моему замеру от 26.08: пункт «`REDIS_URL` в окружении tradein-backend не задан вовсе — переносить лимитеры в Redis некуда» **устарел после переезда**. Проверено 27.08: `REDIS_URL=redis://gendesign-redis:6379/1` задан, DNS резолвится, redis-ошибок в логах за 6ч — ноль (дыра из #2674 закрыта env-доливкой). То есть путь «семафор/лимитеры → Redis» при переходе на воркеры теперь технически открыт — остальные выводы замера без изменений.
Author
Collaborator

Приёмка PR #3444 на проде (12.09, деплой 4efcb712).

Маркеры конфигурации (признак ОТСУТСТВУЕТ в старой версии):

BUILD_SHA=4efcb71
asyncio.shield в app/services/estimator.py — 2 вхождения   (в старой версии 0)
pool: size=5  max_overflow=15  timeout=5                    (было 5 / 10 / 30)
QueuePool-ошибок за 30 мин — 0

Поведенческая проба (6 параллельных /estimate на новых адресах, под служебным аккаунтом admin, с сэмплированием pg_stat_activity каждые 0,5 с):

5 × 200 за 0.68–1.09 с, 1 × 429 (лимитер оценок — ожидаемо)
пик idle in transaction: 2
состояние после: idle 7, active 1, idle in transaction 1

Замер «до» из разбора давал 6–8 коннектов idle in transaction при восьми параллельных оценках — то есть коннект действительно перестал жить через внешний HTTP.

Оговорка честности: проба «после» шла при N=6, а «до» мерилось при N=8 и на другом наборе адресов, так что это сравнение порядка величины, а не строгий A/B. Строгий — только повтор той же пробы на том же наборе; если понадобится, сниму.

Отдельно: pool_timeout 30 → 5 лежит отдельным коммитом в конце ветки (ae6d28d5) — откатывается одной командой, не задевая остальное. Триггер отката записан прямо у значения в app/core/db.py: любое QueuePool limit … timed out в логах бэкенда либо рост failed+zombie в scrape_runs. За первые 30 минут после деплоя — ноль и того, и другого; наблюдение продолжается сутки.

Известный остаток, названный в PR: у Циана транзакция по-прежнему живёт через весь фетч (providers/cian/valuation.py:163,176,595) — доделать можно только опт-ин параметром в общей kit-функции, у которой второй живой вызывающий (cian_history_backfill.py:458) на коммите в середине сменил бы семантику батча. На потолок пула это не влияет (задача держит один коннект независимо от длительности), только на среднюю занятость.

Соседняя дыра того же класса найдена и вынесена в #3449: geocoder.py (9 сайтов) при отмене по бюджету тоже оставляет поток работать с сессией запроса; там это на main и раньше.

**Приёмка PR #3444 на проде (12.09, деплой `4efcb712`).** Маркеры конфигурации (признак ОТСУТСТВУЕТ в старой версии): ``` BUILD_SHA=4efcb71 asyncio.shield в app/services/estimator.py — 2 вхождения (в старой версии 0) pool: size=5 max_overflow=15 timeout=5 (было 5 / 10 / 30) QueuePool-ошибок за 30 мин — 0 ``` Поведенческая проба (6 параллельных `/estimate` на новых адресах, под **служебным** аккаунтом `admin`, с сэмплированием `pg_stat_activity` каждые 0,5 с): ``` 5 × 200 за 0.68–1.09 с, 1 × 429 (лимитер оценок — ожидаемо) пик idle in transaction: 2 состояние после: idle 7, active 1, idle in transaction 1 ``` Замер «до» из разбора давал **6–8** коннектов `idle in transaction` при восьми параллельных оценках — то есть коннект действительно перестал жить через внешний HTTP. Оговорка честности: проба «после» шла при N=6, а «до» мерилось при N=8 и на другом наборе адресов, так что это сравнение порядка величины, а не строгий A/B. Строгий — только повтор той же пробы на том же наборе; если понадобится, сниму. Отдельно: `pool_timeout 30 → 5` лежит **отдельным коммитом в конце ветки** (`ae6d28d5`) — откатывается одной командой, не задевая остальное. Триггер отката записан прямо у значения в `app/core/db.py`: любое `QueuePool limit … timed out` в логах бэкенда либо рост `failed+zombie` в `scrape_runs`. За первые 30 минут после деплоя — ноль и того, и другого; наблюдение продолжается сутки. Известный остаток, названный в PR: у Циана транзакция по-прежнему живёт через весь фетч (`providers/cian/valuation.py:163,176,595`) — доделать можно только опт-ин параметром в общей kit-функции, у которой второй живой вызывающий (`cian_history_backfill.py:458`) на коммите в середине сменил бы семантику батча. На потолок пула это не влияет (задача держит один коннект независимо от длительности), только на среднюю занятость. Соседняя дыра того же класса найдена и вынесена в #3449: `geocoder.py` (9 сайтов) при отмене по бюджету тоже оставляет поток работать с сессией запроса; там это на `main` и раньше.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3083
No description provided.