Инженерное руководство по построению отказоустойчивой инфраструктуры 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. Архитектура самовосстановления: эвристический мониторинг без остановки сессий
Принцип неинвазивного зондирования: супервизор не перезапускает, не блокирует и не инструментирует бизнес-логику. Три независимых сигнала жизнеспособности:
Эвристики «залипания»: 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