Сайдкар МЕРЫ: не больше трёх живых браузеров, простаивающий закрывается, зомби-процессы собирает init #3581
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#3581
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/browser-instances-limit"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Refs #3412 — задача в комментарии от 07.09 переопределена как «потолок живых инстансов и корректное вытеснение». Этот PR делает именно это и заодно убирает зомби-процессы. Закрывать issue по мержу не предлагаю: память на проде упирается не только в число инстансов (см. «Что НЕ сделано»).
Что было
Прод,
ssh poincare, только чтение, 17.09.2026 около 09:50 UTC.1. Зомби. У
tradein-browserHostConfig.Init=<nil>, PID 1 —python server.py. С рестарта 16.09 19:08 (около 14 ч) накопилось 2296 процессов в состоянииZ: по 574 штукиforkserver,Utility,SocketиRDD, плюс 5Web*.pids.current= 2722 при трёх живыхcamoufox-bin. Механизм такой: при закрытии браузера его дочерние content-процессы переходят к PID 1, а Python их не собирает.2. Число инстансов не ограничено. В
main_close_browserвызывался только при relaunch, крэше и shutdown, поэтому каждый поставщик держал свой camoufox вечно. По логу запусков и закрытий текущего контейнера (15.09 16:55 → 17.09) видно, сколько инстансов жило одновременно:Пять живых инстансов простояли 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_attest_eviction_keeps_victim_pacing:assert None == 12345.0_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']_launchingtest_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'] == []init: truetest_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;Что НЕ сделано
memory.currentбыл 96% лимита, и одинcamoufox-binзанимал 1.58 ГиБ RSS. Даже при 3 живых 21 из 402 точек выше 90% лимита. Потолок по числу это не лечит, нужен отдельный разбор: какой поставщик разрастается и почему recycle не спасает._last_goto_atв этом пути оставлен как был.playwright/driver/nodeбез дочернегоcamoufox-bin(domclick, запущен 08:03 и в логе не закрыт), то есть мёртвый инстанс, который считается живым. Вытеснение пройдёт по нему через тот жеcm.__aexit__, но на проде это не проверено.🤖 Generated with Claude Code