Сайдкар МЕРЫ: не больше трёх живых браузеров, простаивающий закрывается, зомби-процессы собирает init #3581
4 changed files with 339 additions and 10 deletions
|
|
@ -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
|
||||
|
|
@ -34,6 +34,9 @@ Per-provider модель (#1793):
|
|||
|
||||
Переменные окружения:
|
||||
BROWSER_PORT — TCP-порт HTTP-сервера (default: 3000)
|
||||
BROWSER_MAX_INSTANCES — потолок одновременно живых camoufox на весь сайдкар
|
||||
(default: 3, #3412). Перед запуском нового инстанса
|
||||
закрывается простаивающий чужой; занятый — никогда.
|
||||
BROWSER_RECYCLE_PAGES — страниц в одном сеансе браузера до перезапуска, глобальный
|
||||
дефолт для провайдеров без код-дефолта (default: 15).
|
||||
per-provider код-дефолт см. BROWSER_RECYCLE_PAGES_{PROVIDER}.
|
||||
|
|
@ -141,6 +144,7 @@ Per-provider модель (#1793):
|
|||
import asyncio
|
||||
import base64
|
||||
import gzip
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
|
|
@ -164,6 +168,14 @@ ENVIRONMENT: str = os.environ.get("ENVIRONMENT", "dev")
|
|||
IS_PROD: bool = ENVIRONMENT == "production"
|
||||
|
||||
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_PAGES_BY_PROVIDER. Env BROWSER_RECYCLE_PAGES по-прежнему читается —
|
||||
# внутри _resolve_recycle_pages, как один из уровней приоритета. Константу убрали
|
||||
|
|
@ -849,6 +861,14 @@ _last_nav_ms: dict[str, int | None] = {}
|
|||
# смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим,
|
||||
# если запрошенный proxy совпадает с текущим. 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 из
|
||||
# разных корутин гонок не даёт (нет await между read-modify-write), но держим
|
||||
|
|
@ -927,12 +947,20 @@ async def _launch_browser(provider: str, proxy_override: str | None = None) -> N
|
|||
proxy is not None,
|
||||
_RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK),
|
||||
)
|
||||
# #3412: потолок живых — здесь, а не в _ensure_browser: сюда сходятся ВСЕ пути
|
||||
# запуска (первый запрос, смена прокси, 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
|
||||
_browsers[provider] = browser
|
||||
_page_counters[provider] = 0
|
||||
_launched_proxy[provider] = proxy_url
|
||||
_last_used[provider] = next(_use_seq) # свежий инстанс — не первая жертва LRU
|
||||
logger.info(
|
||||
"tradein-browser[%s]: браузер запущен (proxy_override=%s)",
|
||||
provider,
|
||||
|
|
@ -1050,9 +1078,22 @@ async def _close_reusable_context(provider: str) -> None:
|
|||
|
||||
|
||||
async def _close_browser(provider: str) -> None:
|
||||
"""Закрывает per-provider браузер + переиспользуемый context (если есть, #3118)."""
|
||||
await _close_reusable_context(provider)
|
||||
cm = _browser_cms.get(provider)
|
||||
"""Закрывает per-provider браузер + переиспользуемый context (если есть, #3118).
|
||||
|
||||
Инстанс снимается со словарей ДО первого 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:
|
||||
try:
|
||||
await cm.__aexit__(None, None, None) # type: ignore[attr-defined]
|
||||
|
|
@ -1063,11 +1104,56 @@ async def _close_browser(provider: str) -> None:
|
|||
provider,
|
||||
type(exc).__name__,
|
||||
)
|
||||
_browsers.pop(provider, None)
|
||||
_browser_cms.pop(provider, None)
|
||||
_page_counters[provider] = 0
|
||||
_last_goto_at.pop(provider, None)
|
||||
_launched_proxy.pop(provider, None)
|
||||
|
||||
|
||||
def _pick_victim(provider: str) -> str | None:
|
||||
"""Чей простаивающий инстанс закрыть под новый инстанс ``provider`` (#3412).
|
||||
|
||||
Кандидаты — живые инстансы ЧУЖИХ поставщиков со свободным _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:
|
||||
|
|
@ -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)
|
||||
logger.info("tradein-browser[%s]: перезапуск браузера", provider)
|
||||
await _close_browser(provider)
|
||||
_last_goto_at.pop(provider, None)
|
||||
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)
|
||||
if _browsers.get(provider) is not None:
|
||||
if _launched_proxy.get(provider) == desired_proxy:
|
||||
_last_used[provider] = next(_use_seq)
|
||||
return True
|
||||
# Реальная смена прокси → пересобрать инстанс с новым прокси.
|
||||
logger.info(
|
||||
|
|
@ -1145,6 +1233,7 @@ async def _ensure_browser(provider: str, proxy_override: str | None = None) -> b
|
|||
proxy_override is not None,
|
||||
)
|
||||
await _close_browser(provider)
|
||||
_last_goto_at.pop(provider, None)
|
||||
|
||||
await _try_launch_browser(provider, proxy_override=proxy_override)
|
||||
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: block-images %s", _BLOCK_IMAGES_BY_PROVIDER)
|
||||
logger.info("tradein-browser: recycle-pages %s", _RECYCLE_PAGES_BY_PROVIDER)
|
||||
logger.info("tradein-browser: потолок живых инстансов %d", BROWSER_MAX_INSTANCES)
|
||||
_warn_retired_env()
|
||||
|
||||
|
||||
|
|
|
|||
209
tradein-mvp/browser/test_server_instance_limit.py
Normal file
209
tradein-mvp/browser/test_server_instance_limit.py
Normal 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}
|
||||
|
|
@ -82,6 +82,12 @@ services:
|
|||
# (грубо ~2.5g на каждую доп. параллельную страницу).
|
||||
mem_limit: 2560m
|
||||
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(), которое
|
||||
# само ловит SIGTERM (aiohttp.web.GracefulExit) и даёт себе внутренний
|
||||
# shutdown_timeout=60s (aiohttp default, здесь не переопределён) на закрытие
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue