fix(alerts): краш при restart-политике, гистерезис закрытия, редкие правки
CI / ruff · mypy · pytest (pull_request) Successful in 24m47s

Найдено смоуком на живых контейнерах (examples/, #19):
- контейнер с restart: on-failure поднимается за секунду — между раундами
  его не видно в exited; краш теперь распознаётся и по росту RestartCount
  (сравнение со снапшотами до текущего раунда), повторы — счётчик
- алерт закрывается, только когда условия нет resolve_after (60 с): иначе
  флапающий сервис получал бы «✅ восстановлено» после каждого раунда
- правки сообщений со счётчиком — не чаще раза в минуту на алерт

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 15:52:54 +03:00
co-authored by Claude Opus 5.5
parent 6c6f766abc
commit 79ea5ce2a5
7 changed files with 77 additions and 17 deletions
+10 -2
View File
@@ -28,6 +28,7 @@ class AlertsConfig(BaseModel):
disk_path: Path = Path("/data") disk_path: Path = Path("/data")
dependency_down_for: int = Field(default=300, gt=0) dependency_down_for: int = Field(default=300, gt=0)
repeat_window: int = Field(default=600, ge=0) repeat_window: int = Field(default=600, ge=0)
resolve_after: int = Field(default=60, ge=0)
log_tail: int = Field(default=30, ge=0, le=200) log_tail: int = Field(default=30, ge=0, le=200)
@@ -56,6 +57,7 @@ class Condition:
service: str | None service: str | None
payload: dict[str, Any] = field(default_factory=dict) payload: dict[str, Any] = field(default_factory=dict)
container_id: str | None = None # for the log tail and the «📜 Логи» / «🩺 Статус» buttons container_id: str | None = None # for the log tail and the «📜 Логи» / «🩺 Статус» buttons
bump: bool = False # a fresh incident this round: if the alert is still open, its counter grows
@property @property
def key(self) -> tuple[AlertKind, str | None]: def key(self) -> tuple[AlertKind, str | None]:
@@ -69,13 +71,19 @@ def container_conditions(
d = o.details d = o.details
found: list[Condition] = [] found: list[Condition] = []
def add(kind: AlertKind, **payload: Any) -> None: def add(kind: AlertKind, bump: bool = False, **payload: Any) -> None:
found.append(Condition(kind, d.name, payload, d.id)) found.append(Condition(kind, d.name, payload, d.id, bump))
# A restart policy brings a crashed container back within a second — between two rounds it was already
# restarted, so a grown restart count is a crash too (the exit code of that run is gone by then).
before = [s for s in history if o.at is None or as_utc(s.at) < o.at] # not this round's own row
restarted = bool(before) and d.restart_count > before[-1].restart_count
if d.state in ("exited", "dead") and d.oom_killed: if d.state in ("exited", "dead") and d.oom_killed:
add(AlertKind.OOM, exit_code=d.exit_code) add(AlertKind.OOM, exit_code=d.exit_code)
elif d.state in ("exited", "dead") and d.exit_code != 0: elif d.state in ("exited", "dead") and d.exit_code != 0:
add(AlertKind.CRASH, exit_code=d.exit_code) add(AlertKind.CRASH, exit_code=d.exit_code)
elif restarted or d.state == "restarting":
add(AlertKind.CRASH, bump=restarted, exit_code=d.exit_code or "≠ 0")
window = [s for s in history if as_utc(s.at) >= now - timedelta(seconds=cfg.restart_loop_window)] window = [s for s in history if as_utc(s.at) >= now - timedelta(seconds=cfg.restart_loop_window)]
if window: if window:
+26 -8
View File
@@ -15,6 +15,7 @@ from app.db.session import SessionMaker
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
HISTORY = timedelta(days=7) # AL-6 HISTORY = timedelta(days=7) # AL-6
EDIT_EVERY = timedelta(minutes=1)
LOG_CHARS = 3000 LOG_CHARS = 3000
LogSource = Callable[[str, int], Awaitable[str]] # (container id, lines) -> tail LogSource = Callable[[str, int], Awaitable[str]] # (container id, lines) -> tail
@@ -68,6 +69,11 @@ class AlertService:
known = by_key.get(key) known = by_key.get(key)
if known is not None and known.resolved_at is None: if known is not None and known.resolved_at is None:
known.last_at = now known.last_at = now
if c.bump: # one more incident while still open (a crash that Docker already restarted)
known.count += 1
known.payload = {**known.payload, **c.payload}
if self._may_edit(known, now):
repeated.append(known)
continue continue
if ( if (
known is not None known is not None
@@ -76,7 +82,8 @@ class AlertService:
): ):
known.resolved_at, known.last_at, known.count = None, now, known.count + 1 # back within the window known.resolved_at, known.last_at, known.count = None, now, known.count + 1 # back within the window
known.payload = {**known.payload, **c.payload} known.payload = {**known.payload, **c.payload}
repeated.append(known) if self._may_edit(known, now):
repeated.append(known)
continue continue
fresh = Alert( fresh = Alert(
kind=c.kind, service=c.service, payload=await self._payload(c), opened_at=now, last_at=now kind=c.kind, service=c.service, payload=await self._payload(c), opened_at=now, last_at=now
@@ -84,12 +91,12 @@ class AlertService:
session.add(fresh) session.add(fresh)
opened.append(fresh) opened.append(fresh)
for key, a in by_key.items(): for key, a in by_key.items():
if a.resolved_at is None and key not in wanted: # hysteresis: gone for a while, not just for one round — otherwise a flapping service
# would get «✅ восстановлено» after every blip
if a.resolved_at is None and key not in wanted and now - as_utc(a.last_at) >= self._settle:
a.resolved_at = now a.resolved_at = now
resolved.append(a) resolved.append(a)
await session.flush() await session.flush()
for a in opened:
await session.refresh(a, ["deliveries"])
for a in opened: for a in opened:
await self._notifier.opened(a, admins_only=False) await self._notifier.opened(a, admins_only=False)
for a in repeated: for a in repeated:
@@ -112,18 +119,17 @@ class AlertService:
recent.count += 1 recent.count += 1
recent.last_at = now recent.last_at = now
recent.payload = {**recent.payload, **c.payload} recent.payload = {**recent.payload, **c.payload}
alert, new = recent, False alert, new, edit = recent, False, self._may_edit(recent, now)
else: else:
alert = Alert( alert = Alert(
kind=c.kind, service=c.service, payload=dict(c.payload), opened_at=now, last_at=now, resolved_at=now kind=c.kind, service=c.service, payload=dict(c.payload), opened_at=now, last_at=now, resolved_at=now
) )
session.add(alert) session.add(alert)
await session.flush() await session.flush()
await session.refresh(alert, ["deliveries"]) new, edit = True, False
new = True
if new: if new:
await self._notifier.opened(alert, admins_only=admins_only) await self._notifier.opened(alert, admins_only=admins_only)
else: elif edit:
await self._notifier.repeated(alert) await self._notifier.repeated(alert)
return alert return alert
@@ -140,6 +146,18 @@ class AlertService:
def _window(self) -> timedelta: def _window(self) -> timedelta:
return timedelta(seconds=self._cfg.repeat_window) return timedelta(seconds=self._cfg.repeat_window)
@property
def _settle(self) -> timedelta:
return timedelta(seconds=self._cfg.resolve_after)
def _may_edit(self, alert: Alert, now: datetime) -> bool:
"""Counter edits at most once a minute per alert; the count itself is always exact in the DB."""
last = alert.payload.get("edited_at")
if isinstance(last, str) and now - as_utc(datetime.fromisoformat(last)) < EDIT_EVERY:
return False
alert.payload = {**alert.payload, "edited_at": now.isoformat()}
return True
async def page(self, page: int, per_page: int = 10, now: datetime | None = None) -> tuple[list[Alert], int]: async def page(self, page: int, per_page: int = 10, now: datetime | None = None) -> tuple[list[Alert], int]:
"""Active first, then the last 7 days, newest first (AL-6). Admin-only kinds are left out.""" """Active first, then the last 7 days, newest first (AL-6). Admin-only kinds are left out."""
now = now or utcnow() now = now or utcnow()
+4 -3
View File
@@ -7,7 +7,7 @@ Old rows are removed by `app.db.maintenance`.
import asyncio import asyncio
import logging import logging
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass, replace
from datetime import datetime from datetime import datetime
from sqlalchemy import select from sqlalchemy import select
@@ -62,6 +62,7 @@ class Observation:
details: Details details: Details
stats: Stats | None # None when not running or stats failed stats: Stats | None # None when not running or stats failed
at: datetime | None = None # the round's time; its own snapshot row carries the same `at`
async def _observe(docker: DockerClient, container_id: str) -> Observation | None: async def _observe(docker: DockerClient, container_id: str) -> Observation | None:
@@ -76,7 +77,7 @@ async def _observe(docker: DockerClient, container_id: str) -> Observation | Non
stats = await docker.stats(container_id) stats = await docker.stats(container_id)
except DockerError as e: except DockerError as e:
log.warning("stats %s: %s", container_id[:12], e) log.warning("stats %s: %s", container_id[:12], e)
return Observation(details, stats) return Observation(details, stats) # `at` is set by collect_once
def _row(o: Observation, at: datetime) -> ContainerSnapshot | None: def _row(o: Observation, at: datetime) -> ContainerSnapshot | None:
@@ -96,7 +97,7 @@ async def collect_once(docker: DockerClient, store: SnapshotStore, now: datetime
"""Inspect every managed container, stats for the running ones; a snapshot row per running container.""" """Inspect every managed container, stats for the running ones; a snapshot row per running container."""
at = now or utcnow() at = now or utcnow()
found = await asyncio.gather(*(_observe(docker, c.id) for c in await docker.containers())) found = await asyncio.gather(*(_observe(docker, c.id) for c in await docker.containers()))
observations = [o for o in found if o is not None] observations = [replace(o, at=at) for o in found if o is not None]
await store.add([row for o in observations if (row := _row(o, at)) is not None]) await store.add([row for o in observations if (row := _row(o, at)) is not None])
return observations return observations
+2
View File
@@ -14,5 +14,7 @@ disk_path: /data
dependency_down_for: 300 dependency_down_for: 300
# Повтор того же алерта (тип + сервис) в этом окне, секунды, — счётчик в исходном сообщении, а не новое (AL-5) # Повтор того же алерта (тип + сервис) в этом окне, секунды, — счётчик в исходном сообщении, а не новое (AL-5)
repeat_window: 600 repeat_window: 600
# Закрывать алерт («✅ восстановлено»), когда условия нет уже столько секунд, — не после одного удачного раунда
resolve_after: 60
# Строк лога в сообщении алерта (AL-3) # Строк лога в сообщении алерта (AL-3)
log_tail: 30 log_tail: 30
+9 -2
View File
@@ -61,9 +61,16 @@ def test_crash_oom_and_clean_stop() -> None:
def test_restart_loop_counts_within_window() -> None: def test_restart_loop_counts_within_window() -> None:
history = [snap(900, restarts=0), snap(500, restarts=2), snap(60, restarts=4)] history = [snap(900, restarts=0), snap(500, restarts=2), snap(60, restarts=5)]
assert kinds(Observation(RUNNING, CALM), history) == {AlertKind.RESTART_LOOP} # 5 - 2 ≥ 3 within 10 min assert kinds(Observation(RUNNING, CALM), history) == {AlertKind.RESTART_LOOP} # 5 - 2 ≥ 3 within 10 min
assert kinds(Observation(replace(RUNNING, restart_count=4), CALM), history) == set() assert kinds(Observation(replace(RUNNING, restart_count=4), CALM), history[:2]) == {AlertKind.CRASH} # 2 → 4
def test_crash_seen_through_restart_policy() -> None:
"""Restarted between two rounds: the container is running again, but the restart count gave it away."""
[crash] = container_conditions(Observation(replace(RUNNING, restart_count=6), CALM), [snap(15)], CFG, NOW)
assert crash.kind is AlertKind.CRASH and crash.bump and crash.payload["exit_code"] == "≠ 0"
assert kinds(Observation(replace(RUNNING, state="restarting"), None), [snap(15)]) == {AlertKind.CRASH}
def test_unhealthy() -> None: def test_unhealthy() -> None:
+25 -1
View File
@@ -9,7 +9,8 @@ from app.db.models import Alert, AlertKind
from app.db.session import SessionMaker from app.db.session import SessionMaker
from tests.conftest import ROOT from tests.conftest import ROOT
CFG = load_alerts_config(ROOT / "config") CFG = load_alerts_config(ROOT / "config").model_copy(update={"resolve_after": 0})
SETTLING = CFG.model_copy(update={"resolve_after": 60})
T0 = datetime(2026, 10, 17, 12, 0, tzinfo=UTC) T0 = datetime(2026, 10, 17, 12, 0, tzinfo=UTC)
CRASH = Condition(AlertKind.CRASH, "svc", {"exit_code": 1}, container_id="c" * 64) CRASH = Condition(AlertKind.CRASH, "svc", {"exit_code": 1}, container_id="c" * 64)
@@ -114,3 +115,26 @@ async def test_broken_log_source_still_alerts(sessions: SessionMaker, rec: Recor
alert = await service.get(rec.events[0][1]) alert = await service.get(rec.events[0][1])
assert alert is not None and "log" not in alert.payload assert alert is not None and "log" not in alert.payload
async def test_resolution_waits_for_the_condition_to_settle(sessions: SessionMaker, rec: Recorder) -> None:
service = AlertService(sessions, rec, SETTLING)
await service.sync([CRASH], MONITORED, at(0))
await service.sync([], MONITORED, at(0.5)) # one good round is not recovery
await service.sync([CRASH], MONITORED, at(0.75))
await service.sync([], MONITORED, at(1))
await service.sync([], MONITORED, at(1.9))
assert [e[0] for e in rec.events] == ["opened", "resolved"]
async def test_bumps_while_open_throttle_edits(sessions: SessionMaker, rec: Recorder) -> None:
service = AlertService(sessions, rec, SETTLING)
bumped = Condition(CRASH.kind, CRASH.service, {}, CRASH.container_id, bump=True)
await service.sync([CRASH], MONITORED, at(0))
for second in (15, 30, 45, 75):
await service.sync([bumped], MONITORED, at(second / 60))
assert [(e[0], e[2]) for e in rec.events] == [("opened", 1), ("repeated", 2), ("repeated", 5)]
alert = await service.get(rec.events[0][1])
assert alert is not None and alert.count == 5
+1 -1
View File
@@ -27,7 +27,7 @@ from tests.fakes import TgClient
from tests.runtime.fake_docker import FakeDocker, now_minus from tests.runtime.fake_docker import FakeDocker, now_minus
MEMBER, BLOCKED = 5005, 6006 MEMBER, BLOCKED = 5005, 6006
CFG = load_alerts_config(ROOT / "config") CFG = load_alerts_config(ROOT / "config").model_copy(update={"resolve_after": 0}) # hysteresis: tests/alerts
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)