Уведомление о всплеске 5xx-ошибок в nginx
Всплеск 5xx означает, что пользователи прямо сейчас видят ошибки: упал upstream, кончился пул воркеров PHP-FPM, отвалилась база или деплой поехал не так. В графане это видно, но в графану никто не смотрит в три часа ночи. Пусть nginx сам звонит, как только частота 502/503/504 переходит порог.
Что мы будем делать
Заголовок раздела «Что мы будем делать»Скрипт раз в минуту дочитывает свежие строки /var/log/nginx/access.log,
считает в них ответы со статусом 5xx (поле $9 combined-формата) и:
- если > 10 5xx за минуту — присылает обычное предупреждение (приоритет 5);
- если > 50 5xx за минуту — присылает громкий алерт (приоритет 9).
Чтобы не перечитывать весь лог каждый раз (и не считать старые ошибки заново), храним в state-файле байтовый оффсет — позицию, до которой лог уже прочитан. За один запуск читаем только «хвост» с этого оффсета до конца файла. Это работает и на гигабайтных логах: мы никогда не парсим больше, чем накопилось за минуту.
В сообщение кладём топ проблемных URL и разбивку по статус-кодам — чтобы сразу было видно, весь сайт лёг или отвалился один эндпоинт.
Сохраните как /usr/local/bin/notifly-nginx-5xx:
#!/usr/bin/env bashset -euset -a; source /etc/notifly.env; set +a
LOG=/var/log/nginx/access.logWARN=10 # 5xx за минуту -> предупреждениеCRIT=50 # 5xx за минуту -> критический алертSTATE_DIR=/var/lib/notifly-nginxOFFSET_FILE="$STATE_DIR/offset"FLAG=/tmp/notifly-nginx-5xx.flagDEBOUNCE=600 # не повторять алерт чаще, чем раз в 10 минутmkdir -p "$STATE_DIR"
HOST=$(hostname -s)
# Текущий размер лога и позиция, до которой уже дочитали в прошлый разSIZE=$(stat -c %s "$LOG" 2>/dev/null || echo 0)OFFSET=$(cat "$OFFSET_FILE" 2>/dev/null || echo 0)
# Лог мог быть повёрнут logrotate: если он стал короче — читаем с начала[ "$OFFSET" -gt "$SIZE" ] && OFFSET=0
# Дочитываем только новые байты, разом обновляя оффсетCHUNK=$(mktemp)trap 'rm -f "$CHUNK"' EXITdd if="$LOG" bs=1 skip="$OFFSET" count=$((SIZE - OFFSET)) 2>/dev/null > "$CHUNK" || trueecho "$SIZE" > "$OFFSET_FILE"
# Считаем 5xx: $9 в combined-формате — это HTTP-статус ответаCOUNT=$(awk '$9 ~ /^5[0-9][0-9]$/' "$CHUNK" | wc -l)
LEVEL=ok[ "$COUNT" -gt "$WARN" ] && LEVEL=warn[ "$COUNT" -gt "$CRIT" ] && LEVEL=crit[ "$LEVEL" = ok ] && exit 0
# Дебаунс: пока флагу меньше DEBOUNCE секунд — молчимNOW=$(date +%s)LAST=$(stat -c %Y "$FLAG" 2>/dev/null || echo 0)[ $((NOW - LAST)) -lt "$DEBOUNCE" ] && exit 0touch "$FLAG"
# Топ статус-кодов и топ URL среди 5xx-запросовBY_CODE=$(awk '$9 ~ /^5[0-9][0-9]$/{print $9}' "$CHUNK" | sort | uniq -c | sort -rn | head -5)BY_URL=$(awk '$9 ~ /^5[0-9][0-9]$/{print $7}' "$CHUNK" | sort | uniq -c | sort -rn | head -5)
if [ "$LEVEL" = crit ]; then /usr/local/bin/notifly-send \ "🔥 КРИТИЧНО: $COUNT×5xx/мин на $HOST" \ "nginx отдал $COUNT ошибок 5xx за последнюю минуту.
По кодам:$BY_CODE
Топ URL:$BY_URL" 9else /usr/local/bin/notifly-send \ "⚠️ Всплеск 5xx на $HOST" \ "nginx отдал $COUNT ошибок 5xx за последнюю минуту (порог $WARN).
По кодам:$BY_CODE
Топ URL:$BY_URL" 5fiСделайте исполняемым:
sudo chmod +x /usr/local/bin/notifly-nginx-5xxЗапуск по расписанию
Заголовок раздела «Запуск по расписанию»Через cron
Заголовок раздела «Через cron»sudo crontab -eДобавьте строку — запуск каждую минуту:
* * * * * /usr/local/bin/notifly-nginx-5xx >/dev/null 2>&1Через systemd timer (рекомендуется)
Заголовок раздела «Через systemd timer (рекомендуется)»/etc/systemd/system/notifly-nginx-5xx.service:
[Unit]Description=Notifly nginx 5xx rate check
[Service]Type=oneshotExecStart=/usr/local/bin/notifly-nginx-5xx/etc/systemd/system/notifly-nginx-5xx.timer:
[Unit]Description=Run notifly-nginx-5xx every minute
[Timer]OnBootSec=2minOnUnitActiveSec=1min
[Install]WantedBy=timers.targetАктивируем:
sudo systemctl daemon-reloadsudo systemctl enable --now notifly-nginx-5xx.timersudo systemctl list-timers notifly-nginx-5xx.timerWindows: PowerShell + Task Scheduler
Заголовок раздела «Windows: PowerShell + Task Scheduler»На Windows-серверах роль access-лога nginx играют логи IIS: они лежат в
C:\inetpub\logs\LogFiles\W3SVC*\ в W3C-формате, где статус ответа хранится в
поле sc-status. Порядок полей задаётся строкой #Fields: в начале файла, но в
типовой конфигурации sc-status идёт после метода и URI. Ниже мы читаем «хвост»
свежего лог-файла, находим индекс поля sc-status по заголовку и считаем 5xx.
Скрипт использует общую функцию Send-Notifly из
шаблона sysadmin/index.
. C:\scripts\Notifly.ps1
$Warn = 10$Crit = 50$FlagFile = "$env:TEMP\notifly-iis-5xx.flag"$Host = $env:COMPUTERNAME
# Самый свежий лог-файл IIS среди всех сайтов$log = Get-ChildItem "C:\inetpub\logs\LogFiles\W3SVC*\*.log" -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1if (-not $log) { exit 0 }
# Последние ~2000 строк — с запасом на минуту трафика$lines = Get-Content $log.FullName -Tail 2000
# Индекс поля sc-status берём из заголовка "#Fields: ..."$header = $lines | Where-Object { $_ -like "#Fields:*" } | Select-Object -Last 1if (-not $header) { exit 0 }$fields = ($header -replace "^#Fields:\s*", "") -split "\s+"$statusIdx = [Array]::IndexOf($fields, "sc-status")$uriIdx = [Array]::IndexOf($fields, "cs-uri-stem")if ($statusIdx -lt 0) { exit 0 }
# Только строки за текущую минуту (поля date/time идут первыми в W3C-логе)$minute = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm")$rows = $lines | Where-Object { $_ -and $_[0] -ne "#" -and $_.StartsWith($minute) } | ForEach-Object { , ($_ -split "\s+") } | Where-Object { $_[$statusIdx] -match "^5\d\d$" }
$count = @($rows).Count$level = "ok"if ($count -gt $Warn) { $level = "warn" }if ($count -gt $Crit) { $level = "crit" }if ($level -eq "ok") { exit 0 }
# Дебаунс на 10 минутif (Test-Path $FlagFile) { $age = (Get-Date) - (Get-Item $FlagFile).LastWriteTime if ($age.TotalMinutes -lt 10) { exit 0 }}New-Item -ItemType File -Path $FlagFile -Force | Out-Null
$byCode = $rows | Group-Object { $_[$statusIdx] } | Sort-Object Count -Descending | Select-Object -First 5 | ForEach-Object { " $($_.Count)× $($_.Name)" }$byUrl = if ($uriIdx -ge 0) { $rows | Group-Object { $_[$uriIdx] } | Sort-Object Count -Descending | Select-Object -First 5 | ForEach-Object { " $($_.Count)× $($_.Name)" }} else { @() }
$msg = "IIS отдал $count ошибок 5xx за последнюю минуту.`n`nПо кодам:`n" + ($byCode -join "`n") + "`n`nТоп URL:`n" + ($byUrl -join "`n")
if ($level -eq "crit") { Send-Notifly -Title "🔥 КРИТИЧНО: $count×5xx/мин — $Host" -Message $msg -Priority 9} else { Send-Notifly -Title "⚠️ Всплеск 5xx на $Host" -Message $msg -Priority 5}Регистрация в Task Scheduler (от администратора, каждую минуту от SYSTEM):
$Action = New-ScheduledTaskAction ` -Execute "powershell.exe" ` -Argument "-NoProfile -ExecutionPolicy Bypass -File C:\scripts\Notifly-IIS-5xx.ps1"$Trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) ` -RepetitionInterval (New-TimeSpan -Minutes 1)$Princ = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel HighestRegister-ScheduledTask -TaskName "Notifly IIS 5xx" ` -Action $Action -Trigger $Trigger -Principal $Princ -Description "Notifly: всплеск 5xx в IIS"- Вы узнаёте о проблеме раньше пользователей — телефон звонит в момент всплеска, а не когда придёт жалоба в поддержку.
- Разбивка по кодам и URL прямо в пуше — сразу видно, лёг ли весь сайт
(502 по всем URL) или сломался один эндпоинт (504 на
/api/report). - Байтовый оффсет = дёшево на любых объёмах — за минуту читается только новый «хвост» лога, поэтому скрипт одинаково быстр и на 10 МБ, и на 10 ГБ access-лога.
- Дебаунс исключает спам: во время затяжной аварии придёт одно сообщение раз в 10 минут, а не 60 подряд.
Что улучшить дальше
Заголовок раздела «Что улучшить дальше»- Отправлять «✅ 5xx вернулись к норме», когда после алерта частота снова
падает ниже порога — по аналогии с логикой
ok → warn → critиз рецепта про диск. - Прикладывать полный список проблемных запросов через поле
extras, чтобы не раздувать текст пуша. - Считать не абсолютное число, а долю 5xx от всех запросов — это точнее на сайтах с сильно разным трафиком днём и ночью.
- Связать с уведомлением о падении сервисов: всплеск 5xx часто идёт следом за упавшим upstream или PHP-FPM.
- О приоритетах и полях сообщения — в разделах отправка пушей и мониторинг.