Перейти к содержимому

Уведомление о всплеске 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 bash
set -eu
set -a; source /etc/notifly.env; set +a
LOG=/var/log/nginx/access.log
WARN=10 # 5xx за минуту -> предупреждение
CRIT=50 # 5xx за минуту -> критический алерт
STATE_DIR=/var/lib/notifly-nginx
OFFSET_FILE="$STATE_DIR/offset"
FLAG=/tmp/notifly-nginx-5xx.flag
DEBOUNCE=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"' EXIT
dd if="$LOG" bs=1 skip="$OFFSET" count=$((SIZE - OFFSET)) 2>/dev/null > "$CHUNK" || true
echo "$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 0
touch "$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" 9
else
/usr/local/bin/notifly-send \
"⚠️ Всплеск 5xx на $HOST" \
"nginx отдал $COUNT ошибок 5xx за последнюю минуту (порог $WARN).
По кодам:
$BY_CODE
Топ URL:
$BY_URL" 5
fi

Сделайте исполняемым:

Окно терминала
sudo chmod +x /usr/local/bin/notifly-nginx-5xx
Окно терминала
sudo crontab -e

Добавьте строку — запуск каждую минуту:

* * * * * /usr/local/bin/notifly-nginx-5xx >/dev/null 2>&1

/etc/systemd/system/notifly-nginx-5xx.service:

[Unit]
Description=Notifly nginx 5xx rate check
[Service]
Type=oneshot
ExecStart=/usr/local/bin/notifly-nginx-5xx

/etc/systemd/system/notifly-nginx-5xx.timer:

[Unit]
Description=Run notifly-nginx-5xx every minute
[Timer]
OnBootSec=2min
OnUnitActiveSec=1min
[Install]
WantedBy=timers.target

Активируем:

Окно терминала
sudo systemctl daemon-reload
sudo systemctl enable --now notifly-nginx-5xx.timer
sudo systemctl list-timers notifly-nginx-5xx.timer

На 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-IIS-5xx.ps1
. 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 1
if (-not $log) { exit 0 }
# Последние ~2000 строк — с запасом на минуту трафика
$lines = Get-Content $log.FullName -Tail 2000
# Индекс поля sc-status берём из заголовка "#Fields: ..."
$header = $lines | Where-Object { $_ -like "#Fields:*" } | Select-Object -Last 1
if (-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 Highest
Register-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.
  • О приоритетах и полях сообщения — в разделах отправка пушей и мониторинг.