Сайдкар МЕРЫ: не больше трёх живых браузеров, простаивающий закрывается, зомби-процессы собирает init #3581

Merged
bot-backend merged 2 commits from fix/browser-instances-limit into main 2026-09-17 11:23:17 +00:00
Collaborator

Refs #3412 — задача в комментарии от 07.09 переопределена как «потолок живых инстансов и корректное вытеснение». Этот PR делает именно это и заодно убирает зомби-процессы. Закрывать issue по мержу не предлагаю: память на проде упирается не только в число инстансов (см. «Что НЕ сделано»).

Что было

Прод, ssh poincare, только чтение, 17.09.2026 около 09:50 UTC.

1. Зомби. У tradein-browser HostConfig.Init=<nil>, PID 1 — python server.py. С рестарта 16.09 19:08 (около 14 ч) накопилось 2296 процессов в состоянии Z: по 574 штуки forkserver, Utility, Socket и RDD, плюс 5 Web*. pids.current = 2722 при трёх живых camoufox-bin. Механизм такой: при закрытии браузера его дочерние content-процессы переходят к PID 1, а Python их не собирает.

2. Число инстансов не ограничено. В main _close_browser вызывался только при relaunch, крэше и shutdown, поэтому каждый поставщик держал свой camoufox вечно. По логу запусков и закрытий текущего контейнера (15.09 16:55 → 17.09) видно, сколько инстансов жило одновременно:

живых доля времени cadvisor working_set, медиана p90 точек ≥90% лимита
2 20% 1311 МиБ 1615 1 из 244
3 34% 1494 МиБ 2034 21 из 402
4 31% 1956 МиБ 2281 29 из 410
5 6% 2108 МиБ 2288 7 из 71

Пять живых инстансов простояли 2.4 ч (16.09 16:44 → 19:08), после чего контейнер резко перезапустился. memory.events с рестарта: max 5057, oom 37, oom_kill 3.

3. Прошлая попытка (fix/3412-cian-instance-relaunch, 2ad2169c) не смержена. Ревью нашло в ней четыре дефекта: жертва выбиралась только по давности, при вытеснении сбрасывался пейсинг чужого поставщика, retry-путь запускал браузер в обход потолка, свежий инстанс с seq=0 сразу становился жертвой. Пул по аренде оттуда не переношу: комментарий к issue показал, что пул для cian прогревает только процесс (около 0.6 с на запуск), а цена вытеснения достаётся avito с якорной вкладкой.

Что сделано

docker-compose.prod.yml: init: true у сервиса browser. Первым процессом становится tini (docker-init), он и собирает осиротевшие процессы. Слитый конфиг прода (prod.yml + selectel.yml, docker compose config) даёт browser.init = True.

browser/server.py: потолок BROWSER_MAX_INSTANCES (по умолчанию 3).

  • Проверка встроена в _launch_browser. Через него проходят все запуски: первый запрос, смена прокси, recycle, перезапуск после крэша, фоновый retry. Это закрывает дефект 3.
  • Если живых инстансов (_browsers плюс те, что сейчас в _launching) не меньше потолка, закрывается простаивающий инстанс другого поставщика. Инстанс с занятым _locks[p] не трогается никогда. Если закрыть некого, запуск идёт сверх потолка с WARNING, запрос не блокируется. Слот резервируется через _launching, поэтому два параллельных запуска не увидят одно и то же свободное место.
  • Жертва выбирается по ключу (есть context или якорная вкладка, давность использования). Сначала закрываются инстансы без context'а, avito и domclick с прогретыми куками и вкладкой выдачи идут последними (дефект 1). Отметка использования ставится при запуске и при каждом _ensure_browser, поэтому свежий инстанс не становится первой жертвой (дефект 4).
  • _close_browser снимает инстанс со словарей до первого await. Вытесняет чужая корутина без лока жертвы, и запрос жертвы, который войдёт под свой лок во время close(), увидит «инстанса нет», а не полузакрытый браузер.
  • Сброс _last_goto_at убран из _close_browser и перенесён в два места, которые перезапускают свой инстанс: _relaunch_browser и смену прокси в _ensure_browser. Для них поведение прежнее. Вытеснение пейсинг не трогает (дефект 2).

Почему потолок 3, а не 4: по таблице выше 3 живых дают медиану 58% лимита, 4 живых — 76%. Работу потолок не тормозит, потому что занятые инстансы не закрываются. Значение меняется через env без релиза.

Тесты

  • tradein-mvp/browser: uv run --no-project --python 3.12 --with pytest --with aiohttp python -m pytest -q261 passed, rc=0 (на main было 252, добавлено 9 в test_server_instance_limit.py).
  • tradein-mvp/backend: tests/test_3412_browser_init_reaps_zombies.py разбирает compose и проверяет значение init у сервиса с образом tradein-browser → 1 passed. Полный сьют после rebase на origin/main (85d455e3): DATABASE_URL=… uv run python -m pytest tests/ -q -p no:cacheprovider6375 passed, 44 skipped, 0 failed, rc=0. До rebase было 4 падения в test_3466_corridor_tier_a и test_estimator_radius_floor (AttributeError: estimate_corridor_clamp_*), их починил смерженный #3572.
  • uv run ruff check app tests → All checks passed; ruff format --check нового теста → already formatted.
  • init проверен на локальном docker тем же скриптом, который порождает осиротевших детей: без --init получается pid1=python … zombies=5, с --initpid1=/sbin/docker-init -- python … zombies=0.

Фальсификация

Каждую часть фикса ломал на копии server.py, прогонял test_server_instance_limit.py и возвращал исходник (diff -q пустой):

поломка что покраснело
жертва только по давности test_victim_is_cheap_instance_not_avito_with_anchor: assert {'cian', 'yandex'} == {'avito', 'yandex'}
_close_browser снова сбрасывает _last_goto_at test_eviction_keeps_victim_pacing: assert None == 12345.0
trim только в _ensure_browser, как в старой ветке test_background_retry_path_respects_cap: assert 3 == 2, where 3 = len({'avito', 'cian', 'yandex'})
нет отметки использования при запуске test_fresh_instance_is_not_the_next_victim: assert ['yandex'] == ['cian']
нет резерва слота _launching test_parallel_launches_reserve_their_slot: assert {'avito', 'cian', 'yandex'} == {'avito', 'yandex'}
словари чистятся после await close() test_evicted_instance_disappears_before_close_awaits: assert {'cian': True} == {'cian': False}
проверка лока снята test_busy_instance_is_never_closed: assert ['cian'] == []
из compose убран init: true test_browser_service_runs_under_init: assert None is True

Деплой

Правка затрагивает tradein-mvp/browser/** и docker-compose.prod.yml (infra), поэтому соберутся browser, backend и frontend с новым revision-лейблом, и пересоздадутся tradein-browser, tradein-backend, tradein-tgbot, tradein-scraper и tradein-frontend. Перед общим up -d деплой ждёт до 5 мин, пока в scrape_runs не останется running, и после этого пересоздаёт всё сразу. Пересоздание tradein-browser обрывает браузерный прогон, который не уложился в эти 5 минут (avito/cian/domclick detail, cian login). Выкатывать нужно в окно без running у браузерных источников, проверять прямо перед мержем. Миграций нет.

Приёмка на проде

Сразу после деплоя:

  • docker inspect tradein-browser -f '{{.HostConfig.Init}}'true; /proc/1/cmdline/sbin/docker-init -- python server.py;
  • в логе старта есть строка потолок живых инстансов 3.

Через 24 ч после деплоя:

  • ps -eo stat | grep -c '^Z' в контейнере меньше 10 (было 2296 за 14 ч);
  • в логе есть закрываем простаивающий, одновременно живых camoufox-bin -no-remote не больше 3; строки запуск сверх потолка посчитать, их частота решит, нужна ли очередь на слот.

Через 7 суток, не раньше 2026-09-25:

  • RestartCount не вырос, прирост oom_kill равен 0;
  • медиана working_set tradein-browser в cadvisor ниже 1600 МиБ (было 1956 при 4 живых).

Что НЕ сделано

  • Раздувание одного инстанса. По записи триажа (17.09 около 07:00, сам я этот момент не наблюдал) при трёх живых memory.current был 96% лимита, и один camoufox-bin занимал 1.58 ГиБ RSS. Даже при 3 живых 21 из 402 точек выше 90% лимита. Потолок по числу это не лечит, нужен отдельный разбор: какой поставщик разрастается и почему recycle не спасает.
  • Около 80 таймаутов навигации Циана на прогретых инстансах в комментарии к issue вынесены отдельно, здесь не трогаются.
  • Не проверял, действует ли пейсинг на domclick и yandex, которые перезапускаются при смене аренды почти перед каждым fetch (305 из 308 и 96 из 100 запусков): сброс _last_goto_at в этом пути оставлен как был.
  • На проде виден playwright/driver/node без дочернего camoufox-bin (domclick, запущен 08:03 и в логе не закрыт), то есть мёртвый инстанс, который считается живым. Вытеснение пройдёт по нему через тот же cm.__aexit__, но на проде это не проверено.

🤖 Generated with Claude Code

Refs #3412 — задача в комментарии от 07.09 переопределена как «потолок живых инстансов и корректное вытеснение». Этот PR делает именно это и заодно убирает зомби-процессы. Закрывать issue по мержу не предлагаю: память на проде упирается не только в число инстансов (см. «Что НЕ сделано»). ## Что было Прод, `ssh poincare`, только чтение, 17.09.2026 около 09:50 UTC. **1. Зомби.** У `tradein-browser` `HostConfig.Init=<nil>`, PID 1 — `python server.py`. С рестарта 16.09 19:08 (около 14 ч) накопилось 2296 процессов в состоянии `Z`: по 574 штуки `forkserver`, `Utility`, `Socket` и `RDD`, плюс 5 `Web*`. `pids.current` = 2722 при трёх живых `camoufox-bin`. Механизм такой: при закрытии браузера его дочерние content-процессы переходят к PID 1, а Python их не собирает. **2. Число инстансов не ограничено.** В `main` `_close_browser` вызывался только при relaunch, крэше и shutdown, поэтому каждый поставщик держал свой camoufox вечно. По логу запусков и закрытий текущего контейнера (15.09 16:55 → 17.09) видно, сколько инстансов жило одновременно: | живых | доля времени | cadvisor working_set, медиана | p90 | точек ≥90% лимита | |---|---|---|---|---| | 2 | 20% | 1311 МиБ | 1615 | 1 из 244 | | 3 | 34% | 1494 МиБ | 2034 | 21 из 402 | | 4 | 31% | 1956 МиБ | 2281 | 29 из 410 | | 5 | 6% | 2108 МиБ | 2288 | 7 из 71 | Пять живых инстансов простояли 2.4 ч (16.09 16:44 → 19:08), после чего контейнер резко перезапустился. `memory.events` с рестарта: `max 5057`, `oom 37`, `oom_kill 3`. **3. Прошлая попытка** (`fix/3412-cian-instance-relaunch`, 2ad2169c) не смержена. Ревью нашло в ней четыре дефекта: жертва выбиралась только по давности, при вытеснении сбрасывался пейсинг чужого поставщика, retry-путь запускал браузер в обход потолка, свежий инстанс с `seq=0` сразу становился жертвой. Пул по аренде оттуда не переношу: комментарий к issue показал, что пул для cian прогревает только процесс (около 0.6 с на запуск), а цена вытеснения достаётся avito с якорной вкладкой. ## Что сделано **`docker-compose.prod.yml`: `init: true` у сервиса `browser`.** Первым процессом становится tini (`docker-init`), он и собирает осиротевшие процессы. Слитый конфиг прода (`prod.yml` + `selectel.yml`, `docker compose config`) даёт `browser.init = True`. **`browser/server.py`: потолок `BROWSER_MAX_INSTANCES` (по умолчанию 3).** - Проверка встроена в `_launch_browser`. Через него проходят все запуски: первый запрос, смена прокси, recycle, перезапуск после крэша, фоновый retry. Это закрывает дефект 3. - Если живых инстансов (`_browsers` плюс те, что сейчас в `_launching`) не меньше потолка, закрывается простаивающий инстанс другого поставщика. Инстанс с занятым `_locks[p]` не трогается никогда. Если закрыть некого, запуск идёт сверх потолка с WARNING, запрос не блокируется. Слот резервируется через `_launching`, поэтому два параллельных запуска не увидят одно и то же свободное место. - Жертва выбирается по ключу `(есть context или якорная вкладка, давность использования)`. Сначала закрываются инстансы без context'а, avito и domclick с прогретыми куками и вкладкой выдачи идут последними (дефект 1). Отметка использования ставится при запуске и при каждом `_ensure_browser`, поэтому свежий инстанс не становится первой жертвой (дефект 4). - `_close_browser` снимает инстанс со словарей до первого `await`. Вытесняет чужая корутина без лока жертвы, и запрос жертвы, который войдёт под свой лок во время `close()`, увидит «инстанса нет», а не полузакрытый браузер. - Сброс `_last_goto_at` убран из `_close_browser` и перенесён в два места, которые перезапускают свой инстанс: `_relaunch_browser` и смену прокси в `_ensure_browser`. Для них поведение прежнее. Вытеснение пейсинг не трогает (дефект 2). Почему потолок 3, а не 4: по таблице выше 3 живых дают медиану 58% лимита, 4 живых — 76%. Работу потолок не тормозит, потому что занятые инстансы не закрываются. Значение меняется через env без релиза. ## Тесты - `tradein-mvp/browser`: `uv run --no-project --python 3.12 --with pytest --with aiohttp python -m pytest -q` → **261 passed**, rc=0 (на `main` было 252, добавлено 9 в `test_server_instance_limit.py`). - `tradein-mvp/backend`: `tests/test_3412_browser_init_reaps_zombies.py` разбирает compose и проверяет значение `init` у сервиса с образом `tradein-browser` → 1 passed. Полный сьют после rebase на `origin/main` (85d455e3): `DATABASE_URL=… uv run python -m pytest tests/ -q -p no:cacheprovider` → **6375 passed, 44 skipped, 0 failed**, rc=0. До rebase было 4 падения в `test_3466_corridor_tier_a` и `test_estimator_radius_floor` (`AttributeError: estimate_corridor_clamp_*`), их починил смерженный #3572. - `uv run ruff check app tests` → All checks passed; `ruff format --check` нового теста → already formatted. - `init` проверен на локальном docker тем же скриптом, который порождает осиротевших детей: без `--init` получается `pid1=python … zombies=5`, с `--init` — `pid1=/sbin/docker-init -- python … zombies=0`. ## Фальсификация Каждую часть фикса ломал на копии `server.py`, прогонял `test_server_instance_limit.py` и возвращал исходник (`diff -q` пустой): | поломка | что покраснело | |---|---| | жертва только по давности | `test_victim_is_cheap_instance_not_avito_with_anchor`: `assert {'cian', 'yandex'} == {'avito', 'yandex'}` | | `_close_browser` снова сбрасывает `_last_goto_at` | `test_eviction_keeps_victim_pacing`: `assert None == 12345.0` | | trim только в `_ensure_browser`, как в старой ветке | `test_background_retry_path_respects_cap`: `assert 3 == 2`, `where 3 = len({'avito', 'cian', 'yandex'})` | | нет отметки использования при запуске | `test_fresh_instance_is_not_the_next_victim`: `assert ['yandex'] == ['cian']` | | нет резерва слота `_launching` | `test_parallel_launches_reserve_their_slot`: `assert {'avito', 'cian', 'yandex'} == {'avito', 'yandex'}` | | словари чистятся после `await close()` | `test_evicted_instance_disappears_before_close_awaits`: `assert {'cian': True} == {'cian': False}` | | проверка лока снята | `test_busy_instance_is_never_closed`: `assert ['cian'] == []` | | из compose убран `init: true` | `test_browser_service_runs_under_init`: `assert None is True` | ## Деплой Правка затрагивает `tradein-mvp/browser/**` и `docker-compose.prod.yml` (infra), поэтому соберутся browser, backend и frontend с новым revision-лейблом, и пересоздадутся `tradein-browser`, `tradein-backend`, `tradein-tgbot`, `tradein-scraper` и `tradein-frontend`. Перед общим `up -d` деплой ждёт до 5 мин, пока в `scrape_runs` не останется `running`, и после этого пересоздаёт всё сразу. **Пересоздание `tradein-browser` обрывает браузерный прогон, который не уложился в эти 5 минут** (avito/cian/domclick detail, cian login). Выкатывать нужно в окно без `running` у браузерных источников, проверять прямо перед мержем. Миграций нет. ## Приёмка на проде Сразу после деплоя: - `docker inspect tradein-browser -f '{{.HostConfig.Init}}'` → `true`; `/proc/1/cmdline` → `/sbin/docker-init -- python server.py`; - в логе старта есть строка `потолок живых инстансов 3`. Через 24 ч после деплоя: - `ps -eo stat | grep -c '^Z'` в контейнере меньше 10 (было 2296 за 14 ч); - в логе есть `закрываем простаивающий`, одновременно живых `camoufox-bin -no-remote` не больше 3; строки `запуск сверх потолка` посчитать, их частота решит, нужна ли очередь на слот. Через 7 суток, не раньше 2026-09-25: - `RestartCount` не вырос, прирост `oom_kill` равен 0; - медиана working_set tradein-browser в cadvisor ниже 1600 МиБ (было 1956 при 4 живых). ## Что НЕ сделано - **Раздувание одного инстанса.** По записи триажа (17.09 около 07:00, сам я этот момент не наблюдал) при трёх живых `memory.current` был 96% лимита, и один `camoufox-bin` занимал 1.58 ГиБ RSS. Даже при 3 живых 21 из 402 точек выше 90% лимита. Потолок по числу это не лечит, нужен отдельный разбор: какой поставщик разрастается и почему recycle не спасает. - Около 80 таймаутов навигации Циана на прогретых инстансах в комментарии к issue вынесены отдельно, здесь не трогаются. - Не проверял, действует ли пейсинг на domclick и yandex, которые перезапускаются при смене аренды почти перед каждым fetch (305 из 308 и 96 из 100 запусков): сброс `_last_goto_at` в этом пути оставлен как был. - На проде виден `playwright/driver/node` без дочернего `camoufox-bin` (domclick, запущен 08:03 и в логе не закрыт), то есть мёртвый инстанс, который считается живым. Вытеснение пройдёт по нему через тот же `cm.__aexit__`, но на проде это не проверено. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bot-backend added 1 commit 2026-09-17 10:07:28 +00:00
Сайдкар МЕРЫ: не больше трёх живых браузеров и никаких зомби-процессов (#3412)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 19s
CI / changes (pull_request) Successful in 40s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 2m0s
CI Trade-In / backend-tests (pull_request) Successful in 8m5s
30d40e5747
Инстанс camoufox поднимался на поставщика и не закрывался никогда: пять
поставщиков давали пять вечных браузеров в контейнере с mem_limit 2560m. Прод
15.09-17.09: 5 живых простояли 2.4 ч прямо перед резким рестартом 16.09 19:08;
медиана working_set 1494 МиБ при 3 живых, 1956 при 4, 2108 при 5.

Потолок BROWSER_MAX_INSTANCES (по умолчанию 3) проверяется в _launch_browser,
через который идут все запуски, включая фоновый retry. На потолке закрывается
простаивающий инстанс другого поставщика: без context'а первым, по давности
использования внутри класса. Занятый лок не трогается, если закрыть некого,
запуск идёт сверх потолка с предупреждением. Слот резервируется на время
запуска. _close_browser снимает инстанс со словарей до первого await и больше
не сбрасывает пейсинг: это делают только собственный relaunch и смена прокси,
как раньше. Четыре дефекта ревью ветки fix/3412-cian-instance-relaunch
закрыты тестами, каждый краснеет при снятии своей части фикса.

init: true у сервиса browser: PID 1 был python server.py и не собирал
осиротевших детей закрытого браузера; прод 17.09 — 2296 процессов в Z за 14 ч.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Light1YT added 1 commit 2026-09-17 10:50:26 +00:00
Merge remote-tracking branch 'origin/main' into fix/browser-instances-limit
All checks were successful
CI Trade-In / changes (pull_request) Successful in 29s
CI / changes (pull_request) Successful in 33s
CI Trade-In / backend-tests (pull_request) Successful in 7m23s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 2m11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
92af3491ae
bot-backend merged commit 9e337855da into main 2026-09-17 11:23:17 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
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#3581
No description provided.