Alexander Bond • 01.11.2026 • 15 мин чтения self healingai агентыsre

Архитектура самовосстанавливающихся роев AI-агентов (Self-Healing Clusters): обнаружение деградации сессий, авто-перезапуск контейнеров и непрерывная оркестрация без простоев

Инженерное руководство по построению отказоустойчивой инфраструктуры AI-агентов: эвристические health-чеки, изоляция деградировавших нод, реконсиляция состояния и Session Re-hydration без потери лидов

Инженерное руководство по построению отказоустойчивой инфраструктуры AI-агентов: эвристические health-чеки, изоляция деградировавших нод, реконсиляция состояния и Session Re-hydration без потери лидов

1. Эксплуатационный кошмар агентных ферм: анатомия скрытой деградации

Автономный агент, который пишет по базе в Telegram, отвечает на вопросы и передаёт менеджеру горячих клиентов, живёт в headless-браузере или в Redroid-контейнере. Это принципиально иная среда эксплуатации, чем stateless HTTP-сервис: у агента есть долгоживущее состояние, внешняя авторизация и недетерминированный UI. First Touch прошёл этот путь от одиночных воркеров до самовосстанавливающегося кластерного контура — ниже анатомия отказов и архитектура, которая их гасит.

Chromium (Playwright/Puppeteer) течёт предсказуемо: 40–90 МБ/час на активную вкладку, рост RSS без возврата после GC, фрагментация кучи, накопление зомби-процессов renderer/GPU. Redroid течёт быстрее: binder-транзакции, незакрытые курсоры SQLite, утечки SurfaceFlinger, деградация adbd после 30–50 тыс. ADB-команд. IPC-сокеты (/dev/binder, DevTools unix-socket) фрагментируются — соединение формально установлено, ответ не приходит.

Скрытые отказы опаснее падений. Процесс жив, контейнер Up, pgrep зелёный — а агент мёртв: cookie/токен тихо инвалидирован, DOM рассинхронизирован (SPA перерисовался, селектор указывает на detached node), модальный системный диалог перекрыл UI. Это «зомби-узел»: CPU 100%, прогресс 0, задачи не выполняются, но нода продолжает получать трафик. Отсюда и статистика: 90% роёв умирают в первые 48 часов без ручного вмешательства — накопленная деградация съедает пул, зомби-ноды маскируются под живые, лиды теряются молча.

2. Архитектура самовосстановления: эвристический мониторинг без остановки сессий

Принцип неинвазивного зондирования: супервизор не перезапускает, не блокирует и не инструментирует бизнес-логику. Три независимых сигнала жизнеспособности:

  • **Liveness** — контейнер существует, PID 1 жив, RSS > 0 (`docker inspect`).
  • **Readiness** — узел готов брать задачу: `adb shell getprop` отвечает <500 мс, CDP `Target.getTargets` отвечает, очередь ноды в пределах лимита.
  • **Sanity / Cognitive Probe** — агент адекватен: детерминированный мини-тест (открыть `about:blank`, выполнить JS, снять DOM-дамп и сверить хэш структуры; для Redroid — `dumpsys window | grep mCurrentFocus` + короткий UI-дамп с проверкой, что авторизованная сессия возвращает ожидаемый экран, а не форму логина).
  • Эвристики «залипания»: focus_age_s > 90 при непустой очереди; ui_delta == 0 при CPU > 80% на серии циклов; линейный рост памяти > 45 МБ/мин на пяти замерах; last_progress_ts вне SLA шага диалога; sanity_score < 0.55. Каждый сигнал проходит гистерезис: SUSPECT → DEGRADED только после 2–3 подтверждений подряд, чтобы не убивать медленные, но живые операции.

    3. Изоляция деградировавших нод (Degraded Isolation)

    Draining Phase. Нода мгновенно отзывается из пула диспетчеризации (status=DRAINING), новые задачи не выдаются, текущая транзакция получает grace-таймаут 20 с на коммит; незавершённые шаги возвращаются в очередь с сохранённым idempotency-key.

    Crash-Forensics. До завершения процесса собирается инцидентный контекст: docker logs --tail 500, dumpsys/DOM-снапшот, скриншот UI, top -b -n1 внутри контейнера, снимок памяти процесса, хэш последнего успешного состояния. Всё пишется в объектное хранилище с меткой node_id + incident_id — это топливо и для постмортемов, и для обучения классификатора деградации.

    Protected Standby. Warm Reserved Pool из 2–4 предпрогретых контейнеров с уже смонтированными томами и валидированными токенами. Резерв неприкосновенен — планировщик задач его не видит. Это исключает просадку пропускной способности в момент, когда 2–3 ноды одновременно уходят в рестарт.

    4. Динамическая инъекция сессий (Session Re-hydration) и холодный перезапуск

    Ключевое архитектурное решение — разделение State и Compute. На Persistent Volume (per-account) лежит: SQLite приложения, cookies + localStorage, device fingerprint (build.prop, android_id, разрешение, часовой пояс, TLS-отпечаток), история диалога. Контейнер — расходный материал.

    Алгоритм: docker kill -9 + docker rm -f и prune dangling-слоёв → подъём чистого образа с монтированием тома R/W и теми же labels → adb wait-for-device и sys.boot_completed → инъекция fingerprint и cookies → прогрев приложения → валидация токена контрольным запросом с ожидаемым маркером. Бюджет полного цикла — 45 секунд.

    Гарантия At-least-once. Транзакционный append-only журнал диалога (message_id + state machine) фиксирует шаг до отправки и подтверждает после. Рестарт ноды не теряет сообщение B2B-лида: неподтверждённый шаг переотправляется с дедупликацией по message_id.

    5. Машиночитаемый манифест знаний: llms.txt в оркестрации

    Открытая спецификация llms.txt используется как машиночитаемый контракт кластера: лимиты памяти/CPU на ноду, SLA времени ответа, допустимые модели, retry-политики, правила эскалации и интерфейсы диагностических AI-супервизоров. Один файл — единый источник правды (Single Source of Truth): супервизор читает его при старте и на каждом hot-reload, генерируя политики самовосстановления декларативно, без правок кода. Изменение порога утечки памяти становится изменением конфигурации, а не релизом.

    6. Боевой кейс: 180 дней аптайма 24 мобильных Redroid-агентов

    Продакшен-контур First Touch: рой из 24 мобильных Redroid-контейнеров и веб-агентов в режиме 24/7/365.

    | Метрика | Значение |

    |---|---|

    | Аптайм кластера за 180+ дней | 99.98% |

    | MTTR упавшей ноды | 84 с (цель < 90 с) |

    | Потеряно лидов при сбоях контейнеров | 0 (транзакционная очередь + Session Re-hydration) |

    | Доля рестартов без ручного вмешательства | > 97% |

    Конфигурация политик и SLA описана в открытом виде — llms.txt.

    7. Production-grade реализация: класс SelfHealingClusterManager на Python

    import asyncio
    import logging
    import time
    from enum import Enum
    from typing import Dict, List, Optional
    
    from pydantic import BaseModel, Field
    
    log = logging.getLogger("self.healing.cluster")
    
    
    class NodeHealthStatus(str, Enum):
        HEALTHY = "healthy"
        SUSPECT = "suspect"
        DEGRADED = "degraded"
        DRAINING = "draining"
        RESTARTING = "restarting"
        STANDBY = "standby"
        DEAD = "dead"
    
    
    class NodeProbe(BaseModel):
        cpu_pct: float = 0.0
        mem_pct: float = 0.0
        mem_growth_mb_min: float = 0.0
        ui_delta: int = 0
        focus_age_s: float = 0.0
        last_progress_ts: float = 0.0
        sanity_score: float = 1.0
        adb_ok: bool = True
        cdp_ok: bool = True
    
    
    class AgentNode(BaseModel):
        node_id: str
        container_id: str
        kind: str = "redroid"          # redroid | chromium
        account_id: str
        volume_ref: str                # persistent state volume
        image: str = "redroid:13-arm64"
        status: NodeHealthStatus = NodeHealthStatus.HEALTHY
        probe: NodeProbe = Field(default_factory=NodeProbe)
        suspect_since: Optional[float] = None
        restarts: int = 0
        auth_valid: bool = True
    
    
    class ClusterMetrics(BaseModel):
        ts: float = Field(default_factory=time.time)
        total: int = 0
        healthy: int = 0
        degraded: int = 0
        standby: int = 0
        mttr_avg_s: float = 0.0
        restarts_total: int = 0
        leads_lost: int = 0
    
    
    class SelfHealingClusterManager:
        """Неинвазивный супервизор роя: probe -> isolate -> rehydrate -> pool."""
    
        CPU_STUCK_PCT = 80.0
        MEM_LEAK_MB_MIN = 45.0
        UI_STUCK_S = 90.0
        SANITY_MIN = 0.55
        SUSPECT_TTL = 30.0
        DRAIN_GRACE_S = 20.0
        REHYDRATE_BUDGET_S = 45.0
    
        def __init__(self, docker, session_store, task_queue, journal,
                     reserved_pool: int = 3, probe_interval: float = 10.0):
            self.docker = docker          # aiodocker-подобный клиент
            self.store = session_store    # restore/inject/token_alive
            self.queue = task_queue       # revoke/drain/requeue/release
            self.journal = journal        # append-only лог диалога (at-least-once)
            self.nodes: Dict[str, AgentNode] = {}
            self.reserved_pool = reserved_pool
            self.probe_interval = probe_interval
            self.metrics = ClusterMetrics()
            self._mttr: List[float] = []
            self._lock = asyncio.Lock()
    
        # ---------- probing ----------
        async def _probe(self, node: AgentNode) -> NodeProbe:
            try:
                stats, cdp = await asyncio.gather(
                    asyncio.wait_for(self.docker.stats(node.container_id), 5),
                    asyncio.wait_for(self.docker.cdp_dump(node.container_id), 5),
                )
                return NodeProbe(
                    cpu_pct=stats.get("cpu_pct", 0.0),
                    mem_pct=stats.get("mem_pct", 0.0),
                    mem_growth_mb_min=stats.get("mem_growth_mb_min", 0.0),
                    ui_delta=len(cdp.get("changes", [])),
                    focus_age_s=cdp.get("focus_age_s", 0.0),
                    last_progress_ts=cdp.get("last_progress_ts", 0.0),
                    sanity_score=await self._sanity(node),
                    adb_ok=stats.get("adb_ok", True),
                    cdp_ok=True,
                )
            except asyncio.TimeoutError:
                return NodeProbe(sanity_score=0.0, adb_ok=False, cdp_ok=False)
    
        async def _sanity(self, node: AgentNode) -> float:
            score = 0.0
            try:
                if node.kind == "redroid":
                    focus = await self.docker.adb(node.container_id,
                                                  "dumpsys window | grep mCurrentFocus")
                    if focus and "Not Responding" not in focus:
                        score += 0.5
                if await self.docker.dom_roundtrip(node.container_id):  # about:blank + JS + hash
                    score += 0.3
                if await self.store.token_alive(node.account_id, timeout=5):
                    score += 0.2
            except Exception:
                return 0.0
            return round(min(score, 1.0), 2)
    
        # ---------- detection ----------
        async def detect_degraded_nodes(self) -> List[str]:
            now = time.time()
            degraded: List[str] = []
            for node in list(self.nodes.values()):
                if node.status in (NodeHealthStatus.STANDBY, NodeHealthStatus.DRAINING,
                                   NodeHealthStatus.RESTARTING):
                    continue
                node.probe = await self._probe(node)
                p = node.probe
                reasons = []
                if not (p.adb_ok and p.cdp_ok):
                    reasons.append("ipc_dead")
                if p.cpu_pct > self.CPU_STUCK_PCT and p.ui_delta == 0:
                    reasons.append("cpu_stuck_ui_frozen")
                if p.mem_growth_mb_min > self.MEM_LEAK_MB_MIN:
                    reasons.append("memory_leak")
                if p.focus_age_s > self.UI_STUCK_S and p.last_progress_ts < now - self.UI_STUCK_S:
                    reasons.append("focus_stuck")
                if p.sanity_score < self.SANITY_MIN:
                    reasons.append("sanity_fail")
    
                if not reasons:
                    node.suspect_since = None
                    node.status = NodeHealthStatus.HEALTHY
                    continue
    
                node.suspect_since = node.suspect_since or now
                hard = "ipc_dead" in reasons or "sanity_fail" in reasons
                if hard or len(reasons) >= 2 or now - node.suspect_since > self.SUSPECT_TTL:
                    node.status = NodeHealthStatus.DEGRADED
                    degraded.append(node.node_id)
                    log.warning("node %s degraded: %s", node.node_id, ",".join(reasons))
                else:
                    node.status = NodeHealthStatus.SUSPECT
            return degraded
    
        # ---------- isolation ----------
        async def isolate_and_drain(self, node_id: str) -> bool:
            async with self._lock:
                node = self.nodes[node_id]
                node.status = NodeHealthStatus.DRAINING
            self.queue.revoke(node_id)                       # новые задачи не выдаются
            try:
                await asyncio.wait_for(self.queue.drain(node_id), self.DRAIN_GRACE_S)
            except asyncio.TimeoutError:
                await self.queue.requeue(node_id)            # idempotency-key сохранён
            await self._forensics(node)
            return True
    
        async def _forensics(self, node: AgentNode) -> None:
            incident = f"{node.node_id}-{int(time.time())}"
            try:
                await self.docker.dump_incident(
                    node.container_id, incident,
                    logs=True, ui_snapshot=True, screenshot=True, mem_snapshot=True,
                )
            except Exception as exc:
                log.error("forensics failed for %s: %s", incident, exc)
    
        # ---------- restart + rehydration ----------
        async def restart_and_rehydrate(self, node_id: str) -> bool:
            node = self.nodes[node_id]
            node.status = NodeHealthStatus.RESTARTING
            started = time.time()
            try:
                await self.docker.kill(node.container_id, signal="SIGKILL")
                await self.docker.remove(node.container_id, force=True)
                state = await self.store.restore(node.account_id)   # cookies/sqlite/fingerprint
    
                cid = await self.docker.run(
                    image=node.image,
                    volumes={node.volume_ref: {"bind": "/data", "mode": "rw"}},
                    labels={"node_id": node.node_id, "account_id": node.account_id},
                )
                await self.docker.wait_boot(cid, timeout=25)
                await self.docker.adb(cid, f"setprop ro.serialno {state['fingerprint']['serial']}")
                await self.store.inject(node.account_id, state)
                if not await self.store.token_alive(node.account_id, timeout=10):
                    raise RuntimeError("token_validation_failed")
    
                node.container_id = cid
                node.auth_valid = True
                node.status = NodeHealthStatus.HEALTHY
                node.suspect_since = None
                node.restarts += 1
                self.metrics.restarts_total += 1
                self._mttr.append(time.time() - started)
                await self.queue.release(node_id)
                log.info("node %s rehydrated in %.1fs", node_id, time.time() - started)
                return True
            except Exception as exc:
                log.error("rehydrate failed %s: %s", node_id, exc)
                node.status = NodeHealthStatus.DEAD
                return False
    
        # ---------- reserved pool ----------
        async def maintain_reserved_pool(self) -> None:
            standby = [n for n in self.nodes.values() if n.status == NodeHealthStatus.STANDBY]
            for _ in range(max(0, self.reserved_pool - len(standby))):
                node = await self._spawn_standby()
                if node:
                    self.nodes[node.node_id] = node
                    log.info("warm standby ready: %s", node.node_id)
    
        async def _spawn_standby(self) -> Optional[AgentNode]:
            try:
                account = await self.store.pick_cold_account()
                node = AgentNode(node_id=f"warm-{account}-{int(time.time())}",
                                 container_id="", account_id=account,
                                 volume_ref=f"state-{account}")
                ok = await self.restart_and_rehydrate_ref(node)
                if ok:
                    node.status = NodeHealthStatus.STANDBY
                    return node
            except Exception as exc:
                log.error("standby spawn failed: %s", exc)
            return None
    
        # ---------- main loop ----------
        async def run(self) -> None:
            while True:
                try:
                    for node_id in await self.detect_degraded_nodes():
                        await self.isolate_and_drain(node_id)
                        await self.restart_and_rehydrate(node_id)
                    await self.maintain_reserved_pool()
                    self._recompute_metrics()
                except Exception as exc:
                    log.exception("supervisor cycle failed: %s", exc)
                await asyncio.sleep(self.probe_interval)
    
        def _recompute_metrics(self) -> None:
            nodes = list(self.nodes.values())
            tail = self._mttr[-50:]
            self.metrics.ts = time.time()
            self.metrics.total = len(nodes)
            self.metrics.healthy = sum(n.status == NodeHealthStatus.HEALTHY for n in nodes)
            self.metrics.degraded = sum(n.status == NodeHealthStatus.DEGRADED for n in nodes)
            self.metrics.standby = sum(n.status == NodeHealthStatus.STANDBY for n in nodes)
            self.metrics.mttr_avg_s = round(sum(tail) / max(1, len(tail)), 1)

    Цикл идемпотентен: повторный вызов detect_degraded_nodes() не ломает гистерезис, а restart_and_rehydrate() при провале валидации токена оставляет ноду в DEAD до следующей итерации — резервный пул при этом уже держит throughput.

    8. Дорожная карта внедрения за 4 спринта (30 дней)

    Спринт 1 (дни 1–7). Неинвазивные Sanity Probes и жёсткое разделение Stateful (сессии, куки, fingerprint) и Stateless (контейнеры, рантайм) слоёв. Переносим все авторизационные артефакты на Persistent Volumes.

    Спринт 2 (дни 8–14). Draining и изоляция нод: revoke → drain → requeue, Crash-Forensics с дампом логов, UI-снапшотом и снимком памяти, интеграция инцидентов в хранилище.

    Спринт 3 (дни 15–21). Автоматизация Session Re-hydration и холодного перезапуска через Docker/Podman API с обязательной верификацией токена в бюджет 45 с; Warm Reserved Pool.

    Спринт 4 (дни 22–30). Chaos Engineering: принудительный kill нод, эмуляция утечек и зависаний диалогов, проверка leads_lost == 0. Интеграция супервизора с llms.txt, вывод в прод и включение алертинга по MTTR.

    Заключение

    Самовосстановление — это не «перезапускать по таймеру», а детекция семантической деградации, изоляция с сохранением улик, разделение состояния и вычислений, и транзакционная гарантия доставки. Для архитектурного аудита устойчивости агентных кластеров и развёртывания Self-Healing инфраструктуры под вашу нагрузку — First Touch: бот пишет по вашей базе с ваших аккаунтов, отвечает на вопросы и передаёт менеджеру заинтересованных клиентов.

    ---

    Александр Бонд, AI-разработчик и архитектор автономных систем, First Touch.


    🚀 Проект автора: First Touch (https://aifirsttouch.ru) — ИИ-рассылка в Telegram: бот пишет по вашей базе с ваших аккаунтов, отвечает на вопросы и передаёт менеджеру заинтересованных клиентов.

    Автор: Alexander Bond — AI-разработчик и архитектор автономных систем


    Теги: self healing, ai агенты, sre, devops, оркестрация, first touch

    Автоматизируйте привлечение B2B-клиентов

    Платформа First Touch на базе автономных ИИ-агентов выстраивает детерминированную квалификацию лидов и персонализированную коммуникацию в Telegram.

    Попробовать First Touch