Сайдкар МЕРЫ: не больше трёх живых браузеров и никаких зомби-процессов (#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

Инстанс 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>
This commit is contained in:
bot-backend 2026-09-17 15:04:25 +05:00
parent 85d455e387
commit 30d40e5747
4 changed files with 339 additions and 10 deletions

View file

@ -0,0 +1,24 @@
"""#3412: у tradein-browser PID 1 — init, который собирает осиротевших детей camoufox.
Без `init: true` PID 1 контейнера `python server.py`; дочерние процессы закрытого
браузера переходят к нему и остаются зомби (прод 17.09.2026: 2296 процессов в Z за 14 ч).
Проверка по значению: разобранный compose, сервис с образом tradein-browser.
"""
from pathlib import Path
import yaml
_COMPOSE = Path(__file__).resolve().parents[2] / "docker-compose.prod.yml"
def test_browser_service_runs_under_init() -> None:
services = yaml.safe_load(_COMPOSE.read_text())["services"]
browser = {
name: svc
for name, svc in services.items()
if "gendesign-tradein-browser" in svc.get("image", "")
}
# Контроль разбора: пустой словарь дал бы зелёный тест ни о чём.
assert set(browser) == {"browser"}, sorted(browser)
assert browser["browser"].get("init") is True

View file

@ -34,6 +34,9 @@ Per-provider модель (#1793):
Переменные окружения: Переменные окружения:
BROWSER_PORT TCP-порт HTTP-сервера (default: 3000) BROWSER_PORT TCP-порт HTTP-сервера (default: 3000)
BROWSER_MAX_INSTANCES потолок одновременно живых camoufox на весь сайдкар
(default: 3, #3412). Перед запуском нового инстанса
закрывается простаивающий чужой; занятый никогда.
BROWSER_RECYCLE_PAGES страниц в одном сеансе браузера до перезапуска, глобальный BROWSER_RECYCLE_PAGES страниц в одном сеансе браузера до перезапуска, глобальный
дефолт для провайдеров без код-дефолта (default: 15). дефолт для провайдеров без код-дефолта (default: 15).
per-provider код-дефолт см. BROWSER_RECYCLE_PAGES_{PROVIDER}. per-provider код-дефолт см. BROWSER_RECYCLE_PAGES_{PROVIDER}.
@ -141,6 +144,7 @@ Per-provider модель (#1793):
import asyncio import asyncio
import base64 import base64
import gzip import gzip
import itertools
import logging import logging
import os import os
import random import random
@ -164,6 +168,14 @@ ENVIRONMENT: str = os.environ.get("ENVIRONMENT", "dev")
IS_PROD: bool = ENVIRONMENT == "production" IS_PROD: bool = ENVIRONMENT == "production"
BROWSER_PORT: int = int(os.environ.get("BROWSER_PORT", "3000")) BROWSER_PORT: int = int(os.environ.get("BROWSER_PORT", "3000"))
# #3412: до потолка инстанс поднимался лениво на поставщика и не закрывался никогда —
# пять поставщиков давали пять вечных camoufox в контейнере с mem_limit 2560m.
# Откуда 3: cadvisor working_set tradein-browser за 41 ч (15.09 16:55 → 17.09 09:53),
# сопоставленный с числом живых инстансов по логу запусков/закрытий: медиана 1311 МиБ
# при 2 живых, 1494 при 3, 1956 при 4, 2108 при 5; 5 живых простояли 2.4 ч прямо перед
# резким рестартом контейнера 16.09 19:08. Потолок не тормозит работу: занятый инстанс
# не закрывается, лишний запуск идёт сверх потолка (см. _trim_instances).
BROWSER_MAX_INSTANCES: int = max(1, int(os.environ.get("BROWSER_MAX_INSTANCES", "3")))
# Глобальной константы recycle больше нет (#3205): порог стал поставщик-зависимым и # Глобальной константы recycle больше нет (#3205): порог стал поставщик-зависимым и
# живёт в _RECYCLE_PAGES_BY_PROVIDER. Env BROWSER_RECYCLE_PAGES по-прежнему читается — # живёт в _RECYCLE_PAGES_BY_PROVIDER. Env BROWSER_RECYCLE_PAGES по-прежнему читается —
# внутри _resolve_recycle_pages, как один из уровней приоритета. Константу убрали # внутри _resolve_recycle_pages, как один из уровней приоритета. Константу убрали
@ -849,6 +861,14 @@ _last_nav_ms: dict[str, int | None] = {}
# смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим, # смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим,
# если запрошенный proxy совпадает с текущим. None = запущен без прокси (прямое). # если запрошенный proxy совпадает с текущим. None = запущен без прокси (прямое).
_launched_proxy: dict[str, str | None] = {} _launched_proxy: dict[str, str | None] = {}
# #3412: поставщики, чей camoufox поднимается прямо сейчас. Считаются живыми для
# потолка: запуск длится секунды, и два параллельных запуска иначе оба увидели бы
# свободное место.
_launching: set[str] = set()
# #3412: provider → номер последнего использования (LRU выбора жертвы). Счётчик, а не
# часы: два обращения в один тик часов неразличимы.
_last_used: dict[str, int] = {}
_use_seq = itertools.count(1)
# Guard на ленивое создание per-provider локов: setdefault на обычном dict из # Guard на ленивое создание per-provider локов: setdefault на обычном dict из
# разных корутин гонок не даёт (нет await между read-modify-write), но держим # разных корутин гонок не даёт (нет await между read-modify-write), но держим
@ -927,12 +947,20 @@ async def _launch_browser(provider: str, proxy_override: str | None = None) -> N
proxy is not None, proxy is not None,
_RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK), _RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK),
) )
cm = AsyncCamoufox(**kwargs) # type: ignore[arg-type] # #3412: потолок живых — здесь, а не в _ensure_browser: сюда сходятся ВСЕ пути
browser = await cm.__aenter__() # запуска (первый запрос, смена прокси, recycle/краш, фоновый retry).
await _trim_instances(provider)
_launching.add(provider)
try:
cm = AsyncCamoufox(**kwargs) # type: ignore[arg-type]
browser = await cm.__aenter__()
finally:
_launching.discard(provider)
_browser_cms[provider] = cm _browser_cms[provider] = cm
_browsers[provider] = browser _browsers[provider] = browser
_page_counters[provider] = 0 _page_counters[provider] = 0
_launched_proxy[provider] = proxy_url _launched_proxy[provider] = proxy_url
_last_used[provider] = next(_use_seq) # свежий инстанс — не первая жертва LRU
logger.info( logger.info(
"tradein-browser[%s]: браузер запущен (proxy_override=%s)", "tradein-browser[%s]: браузер запущен (proxy_override=%s)",
provider, provider,
@ -1050,9 +1078,22 @@ async def _close_reusable_context(provider: str) -> None:
async def _close_browser(provider: str) -> None: async def _close_browser(provider: str) -> None:
"""Закрывает per-provider браузер + переиспользуемый context (если есть, #3118).""" """Закрывает per-provider браузер + переиспользуемый context (если есть, #3118).
await _close_reusable_context(provider)
cm = _browser_cms.get(provider) Инстанс снимается со словарей ДО первого await (#3412): закрыть его может и чужой
запуск по потолку (_trim_instances), не держащий _locks[provider]. Запрос этого
поставщика, вошедший под лок во время close(), должен увидеть «инстанса нет» и
поднять свой, а не получить полузакрытый браузер.
_last_goto_at НЕ трогает: пейсинг ритм обращений к площадке, он переживает
инстанс. Сбрасывают его те, кто перезапускает инстанс сам (_relaunch_browser,
смена прокси в _ensure_browser), ровно как было до #3412.
"""
cm = _browser_cms.pop(provider, None)
_browsers.pop(provider, None)
_page_counters[provider] = 0
_launched_proxy.pop(provider, None)
await _close_reusable_context(provider) # context/якорь снимает до своего await
if cm is not None: if cm is not None:
try: try:
await cm.__aexit__(None, None, None) # type: ignore[attr-defined] await cm.__aexit__(None, None, None) # type: ignore[attr-defined]
@ -1063,11 +1104,56 @@ async def _close_browser(provider: str) -> None:
provider, provider,
type(exc).__name__, type(exc).__name__,
) )
_browsers.pop(provider, None)
_browser_cms.pop(provider, None)
_page_counters[provider] = 0 def _pick_victim(provider: str) -> str | None:
_last_goto_at.pop(provider, None) """Чей простаивающий инстанс закрыть под новый инстанс ``provider`` (#3412).
_launched_proxy.pop(provider, None)
Кандидаты живые инстансы ЧУЖИХ поставщиков со свободным _locks: вся работа
поставщика идёт под его локом, занятый лок = страница в работе. Первыми идут
инстансы без context'а и якорной вкладки: их перезапуск стоит только процесса.
С context'ом (avito/domclick) теряются прогретые куки площадки и вкладка выдачи —
4-5 с на карточку против 17-52 с без неё (замер 29.08, см. _anchor_pages). Внутри
класса давнее всех использованный. None закрыть некого.
"""
idle = [
p
for p in _browsers
if p != provider and not (p in _locks and _locks[p].locked())
]
if not idle:
return None
return min(idle, key=lambda p: (p in _contexts or p in _anchor_pages, _last_used.get(p, 0)))
async def _trim_instances(provider: str) -> None:
"""Освобождает место под новый инстанс ``provider``, пока живых >= потолка (#3412).
ponytail: потолок мягкий если все прочие инстансы заняты, поднимаемся сверх него
с предупреждением, а не отказываем запросу и не рвём чужую навигацию. Очередь на
слот если предупреждение станет регулярным. Цена мягкости и места проверки: запуск,
который потом упадёт (InvalidIP), успевает закрыть один простаивающий инстанс.
"""
while len(_browsers.keys() | _launching) >= BROWSER_MAX_INSTANCES:
victim = _pick_victim(provider)
if victim is None:
logger.warning(
"tradein-browser[%s]: живых инстансов %d при потолке %d, все заняты — "
"запуск сверх потолка",
provider,
len(_browsers.keys() | _launching),
BROWSER_MAX_INSTANCES,
)
return
logger.info(
"tradein-browser[%s]: живых инстансов %d при потолке %d — закрываем "
"простаивающий %s",
provider,
len(_browsers.keys() | _launching),
BROWSER_MAX_INSTANCES,
victim,
)
await _close_browser(victim)
async def _relaunch_browser(provider: str, proxy_override: str | None = None) -> None: async def _relaunch_browser(provider: str, proxy_override: str | None = None) -> None:
@ -1083,6 +1169,7 @@ async def _relaunch_browser(provider: str, proxy_override: str | None = None) ->
effective = proxy_override if proxy_override is not None else _launched_proxy.get(provider) effective = proxy_override if proxy_override is not None else _launched_proxy.get(provider)
logger.info("tradein-browser[%s]: перезапуск браузера", provider) logger.info("tradein-browser[%s]: перезапуск браузера", provider)
await _close_browser(provider) await _close_browser(provider)
_last_goto_at.pop(provider, None)
await _launch_browser(provider, proxy_override=effective) await _launch_browser(provider, proxy_override=effective)
@ -1137,6 +1224,7 @@ async def _ensure_browser(provider: str, proxy_override: str | None = None) -> b
desired_proxy = proxy_override if proxy_override is not None else _provider_proxy(provider) desired_proxy = proxy_override if proxy_override is not None else _provider_proxy(provider)
if _browsers.get(provider) is not None: if _browsers.get(provider) is not None:
if _launched_proxy.get(provider) == desired_proxy: if _launched_proxy.get(provider) == desired_proxy:
_last_used[provider] = next(_use_seq)
return True return True
# Реальная смена прокси → пересобрать инстанс с новым прокси. # Реальная смена прокси → пересобрать инстанс с новым прокси.
logger.info( logger.info(
@ -1145,6 +1233,7 @@ async def _ensure_browser(provider: str, proxy_override: str | None = None) -> b
proxy_override is not None, proxy_override is not None,
) )
await _close_browser(provider) await _close_browser(provider)
_last_goto_at.pop(provider, None)
await _try_launch_browser(provider, proxy_override=proxy_override) await _try_launch_browser(provider, proxy_override=proxy_override)
if _browsers.get(provider) is not None: if _browsers.get(provider) is not None:
@ -1211,6 +1300,7 @@ async def _on_startup(app: web.Application) -> None:
logger.info("tradein-browser: page-intervals %s", _MIN_PAGE_INTERVAL_BY_PROVIDER) logger.info("tradein-browser: page-intervals %s", _MIN_PAGE_INTERVAL_BY_PROVIDER)
logger.info("tradein-browser: block-images %s", _BLOCK_IMAGES_BY_PROVIDER) logger.info("tradein-browser: block-images %s", _BLOCK_IMAGES_BY_PROVIDER)
logger.info("tradein-browser: recycle-pages %s", _RECYCLE_PAGES_BY_PROVIDER) logger.info("tradein-browser: recycle-pages %s", _RECYCLE_PAGES_BY_PROVIDER)
logger.info("tradein-browser: потолок живых инстансов %d", BROWSER_MAX_INSTANCES)
_warn_retired_env() _warn_retired_env()

View file

@ -0,0 +1,209 @@
"""test_server_instance_limit.py — потолок живых camoufox и выбор жертвы (#3412).
До #3412 инстанс поставщика поднимался лениво и не закрывался никогда: пять
поставщиков = пять вечных браузеров в контейнере с mem_limit 2560m. Каждый тест
ниже закрывает один дефект ревью прошлой попытки (ветка fix/3412-cian-instance-
relaunch) или сам потолок; проверка по тому, какие инстансы ОСТАЛИСЬ живыми.
camoufox не запускается: модуль camoufox.async_api подменён заглушкой.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_instance_limit.py -q
"""
from __future__ import annotations
import asyncio
import importlib.util
import sys
import types
from pathlib import Path
import pytest
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
assert _spec is not None and _spec.loader is not None
server = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(server)
class _FakeCM:
"""AsyncCamoufox-заглушка: запуск по желанию медленный, закрытие пишет журнал."""
launch_delay = 0.0
closed: list[str] = []
# provider → был ли он ещё в server._browsers в момент СВОЕГО закрытия
visible_while_closing: dict[str, bool] = {}
def __init__(self, **kwargs: object) -> None:
pass
async def __aenter__(self) -> object:
await asyncio.sleep(_FakeCM.launch_delay)
return object()
async def __aexit__(self, *args: object) -> None:
provider = next(p for p, cm in _cms_by_provider.items() if cm is self)
_FakeCM.visible_while_closing[provider] = provider in server._browsers
await asyncio.sleep(0)
_FakeCM.closed.append(provider)
_cms_by_provider: dict[str, _FakeCM] = {}
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
for name in ("_browsers", "_browser_cms", "_contexts", "_anchor_pages", "_anchor_origins",
"_page_counters", "_locks", "_retry_tasks", "_last_goto_at",
"_launched_proxy", "_last_used"):
monkeypatch.setattr(server, name, {})
monkeypatch.setattr(server, "_launching", set())
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
monkeypatch.setattr(server, "BROWSER_MAX_INSTANCES", 2)
_FakeCM.launch_delay = 0.0
_FakeCM.closed = []
_FakeCM.visible_while_closing = {}
_cms_by_provider.clear()
fake_mod = types.ModuleType("camoufox.async_api")
fake_mod.AsyncCamoufox = _FakeCM # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "camoufox", types.ModuleType("camoufox"))
monkeypatch.setitem(sys.modules, "camoufox.async_api", fake_mod)
async def _launch(provider: str) -> None:
await server._launch_browser(provider, proxy_override="http://u:p@pool:8080")
_cms_by_provider[provider] = server._browser_cms[provider]
def _live() -> set[str]:
return set(server._browsers)
def test_launch_at_cap_closes_idle_instance() -> None:
async def _run() -> None:
await _launch("cian")
await _launch("yandex")
await _launch("generic")
asyncio.run(_run())
assert _live() == {"yandex", "generic"}
assert _FakeCM.closed == ["cian"]
def test_victim_is_cheap_instance_not_avito_with_anchor() -> None:
"""Дефект ревью 1: LRU закрывал avito с якорной вкладкой, оставляя cian без context'а."""
async def _run() -> None:
await _launch("avito") # использован ДАВНЕЕ всех, но с прогретым context'ом
server._contexts["avito"] = types.SimpleNamespace(close=_noop)
server._anchor_pages["avito"] = object()
await _launch("cian")
await _launch("yandex")
asyncio.run(_run())
assert _live() == {"avito", "yandex"}
assert _FakeCM.closed == ["cian"]
async def _noop() -> None:
return None
def test_eviction_keeps_victim_pacing() -> None:
"""Дефект ревью 2: вытеснение сбрасывало _last_goto_at чужого поставщика."""
async def _run() -> None:
await _launch("cian")
server._last_goto_at["cian"] = 12345.0
await _launch("yandex")
await _launch("generic")
asyncio.run(_run())
assert "cian" not in _live()
assert server._last_goto_at.get("cian") == 12345.0
def test_background_retry_path_respects_cap() -> None:
"""Дефект ревью 3: retry-путь _try_launch_browser поднимал инстанс мимо потолка."""
async def _run() -> bool:
await _launch("cian")
await _launch("yandex")
return await server._try_launch_browser("avito")
assert asyncio.run(_run()) is True
assert len(_live()) == 2
assert "avito" in _live()
def test_fresh_instance_is_not_the_next_victim() -> None:
"""Дефект ревью 4: свежий инстанс без отметки использования уходил первым."""
async def _run() -> None:
await _launch("cian")
assert await server._ensure_browser("cian", proxy_override="http://u:p@pool:8080")
await _launch("yandex") # свежий, после последнего обращения к cian
await _launch("generic") # должен закрыть давний cian, а не свежий yandex
asyncio.run(_run())
assert _FakeCM.closed == ["cian"]
def test_use_refreshes_lru_order() -> None:
async def _run() -> None:
await _launch("cian")
await _launch("yandex")
assert await server._ensure_browser("cian", proxy_override="http://u:p@pool:8080")
await _launch("generic")
asyncio.run(_run())
assert _FakeCM.closed == ["yandex"]
def test_busy_instance_is_never_closed() -> None:
async def _run() -> None:
await _launch("cian")
await _launch("yandex")
for provider in ("cian", "yandex"):
await (await server._lock_for(provider)).acquire()
await _launch("generic")
asyncio.run(_run())
assert _FakeCM.closed == []
assert _live() == {"cian", "yandex", "generic"}
def test_parallel_launches_reserve_their_slot() -> None:
"""Два запуска разных поставщиков одновременно не должны оба увидеть свободное место."""
async def _run() -> None:
await _launch("cian")
_FakeCM.launch_delay = 0.01
await asyncio.gather(_launch("avito"), _launch("yandex"))
asyncio.run(_run())
assert _live() == {"avito", "yandex"}
def test_evicted_instance_disappears_before_close_awaits() -> None:
"""Жертву закрывает чужая корутина без её лока: во время close() браузер уже снят."""
async def _run() -> None:
await _launch("cian")
await _launch("yandex")
await _launch("generic")
asyncio.run(_run())
assert _FakeCM.visible_while_closing == {"cian": False}

View file

@ -82,6 +82,12 @@ services:
# (грубо ~2.5g на каждую доп. параллельную страницу). # (грубо ~2.5g на каждую доп. параллельную страницу).
mem_limit: 2560m mem_limit: 2560m
memswap_limit: 3g memswap_limit: 3g
# init (#3412): tini как PID 1. Без него PID 1 — `python server.py`, который не
# собирает осиротевших детей: дочерние процессы закрытого camoufox (forkserver,
# Utility, Socket, RDD) переходят к PID 1 и висят зомби навсегда. Прод 17.09.2026
# за 14 ч с рестарта: 2296 процессов в состоянии Z (по 574 каждого вида),
# pids.current 2722 при трёх живых браузерах.
init: true
# stop_grace_period: browser/server.py — bare aiohttp web.run_app(), которое # stop_grace_period: browser/server.py — bare aiohttp web.run_app(), которое
# само ловит SIGTERM (aiohttp.web.GracefulExit) и даёт себе внутренний # само ловит SIGTERM (aiohttp.web.GracefulExit) и даёт себе внутренний
# shutdown_timeout=60s (aiohttp default, здесь не переопределён) на закрытие # shutdown_timeout=60s (aiohttp default, здесь не переопределён) на закрытие