Сказ о 12 мегапикселях и потерянном времени
Пользователь Татьяна пытается скормить нашему AI-сервису фото со своего айфона. Картинка обычная: весит около 5 МБ, разрешение – 12 мегапикселей. Татьяна жмет кнопку “Сгенерировать” и видит лаконичную заглушку: “Что-то пошло не так”. Вторая попытка с другим стилем дает тот же результат.
Естественно, у девелоперов на локалках и тестовых стендах всё летает. Баг воспроизводится только у Татьяны и только на её конкретном файле. Мы открываем логи в надежде сразу всё починить, но там нас встречает классическая заглушка:
resp = provider.generate(image_bytes)
resp.raise_for_status() # 400, Bad Request, лови стектрейс, развлекайся
Метод raise_for_status() честно упал, завалив лог стандартным трейсом. Но само тело ответа от внешнего провайдера, где человеческим языком была написана причина, никто не догадался сохранить. Мы знаем, что упало, но понятия не имеем, почему.
В итоге пришлось заниматься ручным саппортом: выпрашивать у Татьяны исходный файл через мессенджеры и ковырять его руками. Оказалось, картинка – 4032×3024, честные 12.2 Мп, упакованные в контейнер MPO. И наш замечательный провайдер режет входящие файлы не по весу в байтах (тут мы укладывались), а по габаритам. Лимит эндпоинта – ровно 12 Мп, а у Татьяны чуть больше.
Два часа высокооплачиваемого инженерного времени ушли на то, что автоматика должна была выплюнуть в одну строку:
resp = provider.generate(image_bytes)
if not resp.ok:
log.error("provider rejected image",
status=resp.status_code,
body=resp.text[:2000], # Сэкономило бы два часа жизни
px=f"{w}x{h}", mp=round(w*h/1e6, 1),
bytes=len(image_bytes), user_id=user_id)
resp.raise_for_status()
Вменяемое логирование ошибок – это не просто умение обмазать код конструкциями try/except или вывалить сырой трейс в Kibana. Это сбор контекста. Если вы не записали, что пришло на вход, что ответил бэкенд и под каким user_id это произошло, ваш лог – просто цифровой мусор.
Без этого контекста построить адекватный мониторинг инцидентов невозможно. Системе на вход нужны конкретные факты: “упало, потому что превышен лимит в 12 Мп у юзера 458”. И раз уж мы заговорили об автоматизации этого процесса, давайте разберем модный тренд – попытку запихнуть LLM в детекторы инцидентов.
Почему LLM не стоит ставить в цепочку оповещения
Откуда вообще в статье про регистрацию ошибок взялись нейросети? А вот откуда. Разбирая кейс Татьяны, мы пошли смотреть, как разбор логов автоматизируют другие, и нашли на Хабре показательный пример: команда взяла локальную LLM и прикрутила её прямо к мониторингу – модель сама читает логи и решает, что критично, а что нет. Звучит модно, по факту – попытка скрестить ужа с ежом: детерминированный алертинг и недетерминированную генерацию в одном флаконе. Раз уж такие схемы поехали в продакшен и в корпоративные блоги, разберём по косточкам, почему в цепочке оповещения это не работает.
Сам рецепт обычно выглядит так: грепаем journalctl -f, скармливаем отфильтрованные строки локальной языковой модели, а она пускай вешает теги critical / warning и шлет алерты в телеграм. На бумаге красиво, на практике в продакшене подводит ровно там, где нужна надёжность.
Во-первых, если ваш первичный фильтр уже отобрал строки по ключевым словам вроде oom, panic, machine check или i/o error, то задача классификации уже решена. Эти строки критичны сами по себе. Зачем здесь LLM? Чтобы переклеить ярлык с задержкой в пару секунд и сожрать ресурсы GPU?
Во-вторых, недетерминизм. В мониторинге предсказуемость – это всё. Квантованная модель на одну и ту же строчку лога в зависимости от фазы луны и температуры на Марсе может выдать разные вердикты. Даже JSON она начинает отдавать стабильно только под жесткими grammar-констрейнтами, а не потому, что вы вежливо попросили об этом в промпте. Худшее, что может сделать мониторинг – это молча проглотить critical, присвоив ему статус ignore. Ложная уверенность, что “всё под контролем”, гораздо опаснее полного отсутствия алертов.
Ну и классика – деградация под нагрузкой. Когда падает коммутатор или начинается OOM-шторм, логи сыплются тысячами строк в секунду. Очередь к вашей LLM мгновенно забивается, GPU захлебывается, и критический алерт прилетает тогда, когда сервер уже остывает в серверной. А если сам детектор крутится на той же инфраструктуре, он просто тихо умрет от нехватки памяти, и вы даже не узнаете об этом.
Языковые модели полезны, но ставить их барьером между упавшим сервисом и дежурным инженером не стоит. Модель должна стоять сбоку от цепочки оповещения.
Трехуровневая архитектура здравого смысла
Tier 0: Детерминированное ядро (без магии)
Это единственный уровень, который находится в цепочке оповещения. Здесь всё подчинено правилу: “пришло событие – ушел алерт”. Никакого AI.
- Железо смотрим по метрикам, а не по логам. Пытаться предсказать смерть диска по строчкам в
journald– глупость. Для этого естьsmartctl_exporter(смотрим на reallocated и pending sectors). Для памяти –node_exporterс метрикойnode_edac_correctable_errors_totalилиrasdaemon. - Логи – только для прикладного софта. Но вместо нейросети используем жесткий список сигнатур и регулярных выражений, зафиксированный в Git.
- Дедупликация шаблонов. Вместо хэширования сырой строки (где из-за разницы в PID или таймстампах хэши всегда будут уникальными) используем
drain3. Он группирует логи поtemplate_id. Похожие ошибки схлопываются в один инцидент. - Приемник алертов. Всё это уходит вебхуком в нормальную alert-платформу (например, Pusk): она дедуплицирует всплески от ретраев, даёт ACK одной кнопкой с авто-silence в Alertmanager и пуш на телефон, а не сыплет в рабочий чатик.
Tier 1: Контроль за контролером (Dead-man switch)
Детектор каждые N секунд подаёт сигнал “я жив” (heartbeat). Но само отсутствие сигнала ловит не приёмник алертов, а мониторинг – absent() в Prometheus или штатный Watchdog в Alertmanager; сработавшее “детектор молчит” падает в ту же платформу обычным алертом. Мониторинг не имеет права умирать молча.
Tier 2: Пакетный LLM-анализ (офлайн)
Вот сюда мы и отправляем нейросеть. Она работает не в реальном времени, а, скажем, раз в сутки или раз в час, разгребая “хвост” того, что не подошло ни под одно жесткое правило из Tier 0.
[Лог-строка] ──► [Tier 0: Регулярки] ──(Есть совпадение)──► [Алерт мгновенно]
│
(Нет совпадения)
│
▼
[Tier 2: Накопление в хвост] ──► [Раз в сутки: LLM] ──► [Предложение нового правила человеку]
- Скрипт собирает уникальные шаблоны за день, которые
drain3пометил как новые и неизвестные. - Эти шаблоны скармливаются LLM с включенным структурированным выводом (JSON-schema).
- Модель генерирует не алерт инженеру в 3 часа ночи, а pull request или тикет: “Вот этот шаблон похож на отвал базы данных. Предлагаю добавить регулярку X, severity – critical”.
- Человек аппрувит правило, и оно уезжает в детерминированный Tier 0.
Система сама дообучает свое жесткое ядро. Со временем количество неизвестных строк стремится к нулю, а надежность – к сотне процентов.
Как это выглядит в коде
Простейший скелет для цепочки оповещения (Tier 0), который не упадет под нагрузкой и не выдумает галлюцинаций:
import re, subprocess
from drain3 import TemplateMiner
from sink import alert, heartbeat # alert() -> вебхук в Pusk; heartbeat() -> pushgateway/watchdog
# Явный, понятный и проверяемый список правил
RULES = [
(re.compile(r"\b(kernel panic|machine check|mce:)", re.I), "critical"),
(re.compile(r"\bout of memory|oom-kill", re.I), "critical"),
(re.compile(r"\bI/O error|EXT4-fs error|medium error", re.I), "critical"),
(re.compile(r"\bedac|ecc.*(corrected|error)", re.I), "warning"),
]
miner = TemplateMiner()
# Читаем journalctl в реальном времени
proc = subprocess.Popen(["journalctl", "-f", "-n", "0", "-o", "cat"],
stdout=subprocess.PIPE, text=True)
for line in proc.stdout:
# Схлопываем похожие строки по шаблону через drain3
tmpl = miner.add_log_message(line)["cluster_id"]
# Ищем совпадения по нашей базе сигнатур
sev = next((s for rx, s in RULES if rx.search(line)), None)
if sev:
# Уходит понятный алерт с дедупликацией по ID шаблона
alert(sev, line, dedup_key=tmpl)
else:
# Всё незнакомое бережно складываем для последующего разбора моделью
unknown_sink.append(tmpl, line)
# Сигнализируем, что цикл жив и работает
heartbeat()
Возвращаемся к Татьяне (помните её?)
Мы оставили Татьяну с её 12.2 мегапикселями и двумя часами ручного саппорта. Логичный вопрос: а как это выглядит, когда сделано по-человечески? На самом деле проблема лечится в два захода – сначала нужно нормально увидеть ошибку, а затем перекрыть ей кислород на входе.
Заход первый: регистрация и контекст
Тот самый log.error с метаданными в продакшене обычно живет не в текстовом файлике на сервере, а в трекере ошибок. Самый ходовой инструмент здесь – Sentry. Но тут всплывает ровно та мысль, с которой мы начинали: Sentry сам по себе не знает про скрытые мотивы вашего внешнего провайдера. Он перехватит исключение и построит стектрейс, но тело ответа (resp.text) вы обязаны положить туда руками.
Выглядит это примерно так:
import sentry_sdk
try:
resp = provider.generate(image_bytes)
resp.raise_for_status()
except Exception as exc:
with sentry_sdk.new_scope() as scope: # В SDK 2.x push_scope депрекейтнут
scope.set_user({"id": user_id})
scope.set_tag("provider", "image-edit")
scope.set_tag("image.mp", round(w * h / 1e6, 1))
scope.set_context("provider_response", {
"status": resp.status_code,
"body": resp.text[:2000], # Вот ради этой строчки всё и затевалось
"px": f"{w}x{h}",
"bytes": len(image_bytes),
})
# Все отказы провайдера схлопываются в один issue, а не плодят 47 разных карточек
scope.fingerprint = ["provider-reject", str(resp.status_code)]
sentry_sdk.capture_exception(exc)
raise
Теперь вместо абстрактного сообщения “у кого-то что-то сломалось” в панели появляется одна вменяемая карточка. Внутри – точный ответ провайдера, габариты картинки, айдишник пользователя и счетчик, показывающий, сколько еще человек наткнулись на эти грабли. Это и есть правильный Tier 0 для прикладного уровня: поймали эксепшен, прикрепили контекст, сгруппировали и отправили дежурным. И никакой магии.
Если тащить полноценный Sentry с его тяжелым обвесом из PostgreSQL, Redis и ClickHouse нет желания или ресурсов, есть лайтовый вариант – GlitchTip. Это минималистичный open-source трекер, который полностью совместим с протоколом Sentry. Он использует тот же sentry_sdk на клиенте, но сам по себе представляет собой один легковесный контейнер и базу данных. Отличное решение, которое ставится за пять минут рядом с тем же Pusk и закрывает вопрос сбора контекста без лишней головной боли.
Заход второй: исправление
Сбор контекста помог нам мгновенно понять причину, но пользователю от этого не легче – картинка-то не сгенерировалась. Раз провайдер жестко режет файлы по мегапикселям, глупо надеяться, что клиенты сами начнут предварительно сжимать свои фотографии. Проблему нужно решать на нашей стороне, прямо на границе взаимодействия с внешним API:
import io
from PIL import Image
MAX_MP = 12_000_000 # Лимит эндпоинта провайдера в пикселях
def normalize_for_edit(image_bytes: bytes) -> bytes:
# Контейнер MPO с айфона Pillow прочитает без проблем, взяв первый кадр
img = Image.open(io.BytesIO(image_bytes))
if img.width * img.height <= MAX_MP:
return image_bytes # Картинка в лимите – не тратим ресурсы
scale = (MAX_MP / (img.width * img.height)) ** 0.5
new_size = (int(img.width * scale), int(img.height * scale))
# Ресайзим с сохранением пропорций
img = img.convert("RGB").resize(new_size, Image.Resampling.LANCZOS)
out = io.BytesIO()
img.save(out, format="JPEG", quality=92)
return out.getvalue()
Добавляем вызов normalize_for_edit() перед отправкой провайдеру – и кейс Татьяны закрывается раз и навсегда. Нам больше не нужно дебажить этот баг в мессенджерах. При этом трекер ошибок остается стоять на посту, готовый поймать следующую неожиданную проблему, которую мы не предусмотрели. Получается точно такая же петля обратной связи, как и в случае с инфраструктурными логами: произошел инцидент → мы собрали контекст → внесли одно детерминированное правило в код.
Сухой остаток
- Контекст решает всё. Прежде чем вызывать
raise, запишите в лог параметры запроса и ответ сервера. Иначе вы проведёте незабываемые часы, выпрашивая у клиентов исходники файлов. - Сначала зарегистрируй, потом чини. Поймал ошибку – положи её в трекер с контекстом (Sentry или его лайтовый собрат GlitchTip), и только потом лечи источник. Кейс Татьяны закрывается нормализацией картинки на входе, но увидеть его без записанного тела ответа было невозможно.
- Детерминизм в приоритете. В цепочке оповещения не должно быть нейросетей. Только жесткие правила, регулярные выражения и метрики.
- LLM – это ассистент, а не дежурный админ. Оставьте модели роль офлайн-аналитика. Пусть она разгребает логи постфактум и помогает вам писать новые регулярки для ядра.
- Доставка – половина успеха. Мало поймать ошибку, её нужно донести до правильной смены. Для этого мы у себя используем Pusk (наша self-hosted платформа для алертов): принимает вебхук из мониторинга, дедуплицирует всплески от ретраев, даёт ACK одной кнопкой и пуш на телефон – чтобы инцидент не потонул в общем чате.