Управление инцидентами
Что такое инцидент
Инцидент — это незапланированное событие, которое приводит к нарушению или деградации сервиса. Не каждый баг — инцидент. Инцидент — это когда пользователи затронуты.
Классификация инцидентов
| Severity | Определение | Время реакции | Пример |
|---|---|---|---|
| SEV1 | Полная недоступность сервиса | < 5 минут | Сайт лежит, платежи не проходят |
| SEV2 | Значительная деградация | < 15 минут | 50% запросов с ошибками |
| SEV3 | Частичная деградация | < 1 час | Медленный ответ, один регион |
| SEV4 | Минимальное влияние | В рабочие часы | Косметический баг, один пользователь |
Жизненный цикл инцидента
Detection → Triage → Response → Mitigation → Resolution → Postmortem
│ │ │ │ │ │
Алерт Оценка Команда Остановить Починить Выводы
severity собрана кровотечение причину и улучшения
1. Detection (Обнаружение)
Как мы узнаём об инциденте:
| Источник | Описание | Скорость |
|---|---|---|
| Мониторинг/Алерты | Автоматическое обнаружение | Секунды-минуты |
| Синтетические проверки | Регулярные health checks | Минуты |
| Жалобы пользователей | Тикеты, звонки, соцсети | Минуты-часы |
| Внутренние команды | QA, разработчики заметили | Минуты-часы |
Цель: Обнаруживать инциденты быстрее пользователей. Если о проблеме первыми узнают пользователи — это провал мониторинга.
2. Triage (Сортировка)
Быстрая оценка ситуации:
- Кто затронут? Все пользователи? Один регион? Один клиент?
- Что сломалось? Какой сервис/компонент?
- Когда началось? Коррелирует с деплоем? Изменением конфига?
- Severity? SEV1-4 по критериям выше
3. Response (Реагирование)
Формирование команды реагирования:
| Роль | Ответственность |
|---|---|
| Incident Commander (IC) | Координация, решения, коммуникация |
| Technical Lead | Техническое расследование и fix |
| Communications Lead | Обновления статуса для stakeholders |
| Scribe | Документирование timeline |
| Subject Matter Experts | Привлекаются по необходимости |
4. Mitigation (Смягчение)
Первый приоритет — остановить влияние на пользователей, даже если это не устраняет root cause.
Типичные тактики:
| Тактика | Описание |
|---|---|
| Rollback | Откатить последний деплой |
| Feature flag off | Отключить проблемную фичу |
| Failover | Переключить на резервную систему |
| Scale up | Добавить ресурсы при перегрузке |
| Rate limiting | Ограничить трафик |
| Circuit breaker | Отключить зависимость |
5. Resolution (Устранение)
Полное устранение root cause. Может произойти после mitigation.
6. Postmortem (Разбор)
Документирование инцидента и выводы (см. раздел ниже).
On-Call
On-call — практика дежурства инженеров для реагирования на инциденты в нерабочее время.
Принципы здорового on-call
| Принцип | Реализация |
|---|---|
| Справедливая ротация | Равномерное распределение нагрузки |
| Компенсация | Оплата дежурств или отгулы |
| Runbooks | Документированные процедуры для каждого алерта |
| Escalation | Чёткий путь эскалации при необходимости |
| Follow-the-sun | Распределение по часовым поясам |
| Обратная связь | Регулярный анализ on-call нагрузки |
Политика эскалации
Уровень 1 (0-15 мин): On-call инженер команды
↓ нет ответа или SEV1
Уровень 2 (15-30 мин): Tech Lead / Senior инженер
↓ нет прогресса
Уровень 3 (30-60 мин): Engineering Manager + архитектор
↓ бизнес-влияние
Уровень 4 (60+ мин): VP Engineering / CTO
PHP: система управления инцидентами
<?php
declare(strict_types=1);
namespace App\Incident;
enum Severity: string
{
case Sev1 = 'SEV1';
case Sev2 = 'SEV2';
case Sev3 = 'SEV3';
case Sev4 = 'SEV4';
public function maxResponseMinutes(): int
{
return match ($this) {
self::Sev1 => 5,
self::Sev2 => 15,
self::Sev3 => 60,
self::Sev4 => 480,
};
}
public function requiresPageOut(): bool
{
return match ($this) {
self::Sev1, self::Sev2 => true,
self::Sev3, self::Sev4 => false,
};
}
}
enum IncidentStatus: string
{
case Detected = 'detected';
case Triaged = 'triaged';
case Investigating = 'investigating';
case Mitigated = 'mitigated';
case Resolved = 'resolved';
case PostmortemPending = 'postmortem_pending';
case Closed = 'closed';
}
final class Incident
{
/** @var array<TimelineEntry> */
private array $timeline = [];
private IncidentStatus $status;
public function __construct(
public readonly string $id,
public readonly string $title,
public readonly Severity $severity,
public readonly \DateTimeImmutable $detectedAt,
public readonly string $detectedBy,
private ?string $incidentCommander = null,
) {
$this->status = IncidentStatus::Detected;
$this->addTimelineEntry('Incident detected', $detectedBy);
}
public function assignCommander(string $commander): void
{
$this->incidentCommander = $commander;
$this->addTimelineEntry("Incident Commander assigned: {$commander}", 'system');
}
public function updateStatus(IncidentStatus $status, string $updatedBy, string $note = ''): void
{
$previousStatus = $this->status;
$this->status = $status;
$message = "Status changed: {$previousStatus->value} → {$status->value}";
if ($note !== '') {
$message .= " — {$note}";
}
$this->addTimelineEntry($message, $updatedBy);
}
public function getStatus(): IncidentStatus
{
return $this->status;
}
/**
* Calculate time from detection to mitigation.
*/
public function getTimeToMitigate(): ?int
{
$mitigatedEntry = null;
foreach ($this->timeline as $entry) {
if (str_contains($entry->message, IncidentStatus::Mitigated->value)) {
$mitigatedEntry = $entry;
break;
}
}
if ($mitigatedEntry === null) {
return null;
}
return $mitigatedEntry->timestamp->getTimestamp() - $this->detectedAt->getTimestamp();
}
/**
* Check if response time exceeds SLA.
*/
public function isResponseOverdue(): bool
{
if ($this->status !== IncidentStatus::Detected) {
return false;
}
$elapsedMinutes = (time() - $this->detectedAt->getTimestamp()) / 60;
return $elapsedMinutes > $this->severity->maxResponseMinutes();
}
/** @return array<TimelineEntry> */
public function getTimeline(): array
{
return $this->timeline;
}
private function addTimelineEntry(string $message, string $author): void
{
$this->timeline[] = new TimelineEntry(
timestamp: new \DateTimeImmutable(),
message: $message,
author: $author,
);
}
}
final readonly class TimelineEntry
{
public function __construct(
public \DateTimeImmutable $timestamp,
public string $message,
public string $author,
) {}
}
package incident
import (
"fmt"
"strings"
"sync"
"time"
)
// Severity represents incident severity levels.
type Severity int
const (
Sev1 Severity = iota + 1
Sev2
Sev3
Sev4
)
// MaxResponseMinutes returns the max allowed response time.
func (s Severity) MaxResponseMinutes() int {
switch s {
case Sev1:
return 5
case Sev2:
return 15
case Sev3:
return 60
default:
return 480
}
}
// RequiresPageOut returns true if the severity requires paging.
func (s Severity) RequiresPageOut() bool {
return s == Sev1 || s == Sev2
}
func (s Severity) String() string {
return fmt.Sprintf("SEV%d", s)
}
// Status represents incident lifecycle status.
type Status string
const (
StatusDetected Status = "detected"
StatusTriaged Status = "triaged"
StatusInvestigating Status = "investigating"
StatusMitigated Status = "mitigated"
StatusResolved Status = "resolved"
StatusPostmortemPending Status = "postmortem_pending"
StatusClosed Status = "closed"
)
// TimelineEntry records a point in the incident timeline.
type TimelineEntry struct {
Timestamp time.Time
Message string
Author string
}
// Incident tracks an active or resolved incident.
type Incident struct {
mu sync.Mutex
ID string
Title string
Sev Severity
DetectedAt time.Time
DetectedBy string
IncidentCommander string
status Status
timeline []TimelineEntry
}
// NewIncident creates a new incident in detected state.
func NewIncident(id, title string, sev Severity, detectedBy string) *Incident {
inc := &Incident{
ID: id,
Title: title,
Sev: sev,
DetectedAt: time.Now(),
DetectedBy: detectedBy,
status: StatusDetected,
}
inc.addEntry("Incident detected", detectedBy)
return inc
}
// AssignCommander assigns an incident commander.
func (inc *Incident) AssignCommander(commander string) {
inc.mu.Lock()
defer inc.mu.Unlock()
inc.IncidentCommander = commander
inc.addEntry(fmt.Sprintf("Incident Commander assigned: %s", commander), "system")
}
// UpdateStatus transitions the incident status.
func (inc *Incident) UpdateStatus(status Status, updatedBy, note string) {
inc.mu.Lock()
defer inc.mu.Unlock()
prev := inc.status
inc.status = status
msg := fmt.Sprintf("Status changed: %s -> %s", prev, status)
if note != "" {
msg += " -- " + note
}
inc.addEntry(msg, updatedBy)
}
// Status returns the current status.
func (inc *Incident) Status() Status {
inc.mu.Lock()
defer inc.mu.Unlock()
return inc.status
}
// TimeToMitigate returns seconds from detection to mitigation, or -1.
func (inc *Incident) TimeToMitigate() int {
inc.mu.Lock()
defer inc.mu.Unlock()
for _, e := range inc.timeline {
if strings.Contains(e.Message, string(StatusMitigated)) {
return int(e.Timestamp.Sub(inc.DetectedAt).Seconds())
}
}
return -1
}
// IsResponseOverdue checks if the response time exceeds the SLA.
func (inc *Incident) IsResponseOverdue() bool {
inc.mu.Lock()
defer inc.mu.Unlock()
if inc.status != StatusDetected {
return false
}
elapsed := time.Since(inc.DetectedAt).Minutes()
return elapsed > float64(inc.Sev.MaxResponseMinutes())
}
func (inc *Incident) addEntry(message, author string) {
inc.timeline = append(inc.timeline, TimelineEntry{
Timestamp: time.Now(),
Message: message,
Author: author,
})
}
Postmortem — это документ, который описывает инцидент, его причины, последствия и действия по предотвращению повторения. Постмортемы — это blameless процесс (без обвинений).
Структура постмортема
| Секция | Содержание |
|---|---|
| Summary | Краткое описание инцидента |
| Impact | Кто и как был затронут, метрики |
| Timeline | Хронология событий с точностью до минут |
| Root Cause | Что вызвало инцидент |
| Contributing Factors | Что усугубило ситуацию |
| Detection | Как обнаружили, почему не раньше |
| Mitigation | Что сделали для остановки влияния |
| Resolution | Как полностью исправили |
| Action Items | Конкретные задачи с ответственными и сроками |
| Lessons Learned | Что сработало, что нет, где повезло |
Blameless Culture
Принцип: Люди не делают ошибки. Системы позволяют ошибкам случаться. Постмортем анализирует систему, а не ищет виноватых.
| Blame | Blameless |
|---|---|
| «Вася уронил прод» | «Деплой-процесс позволил небезопасный rollout» |
| «Кто-то не проверил» | «Нет автоматической проверки перед деплоем» |
| «Человеческая ошибка» | «Недостаточная защита от ошибок (guardrails)» |
Шаблон Action Items
AI-1: [P1] Добавить circuit breaker для Payment API
Owner: @backend-team | Deadline: 2024-02-15
Status: In Progress
AI-2: [P2] Настроить алерт на latency p99 > 1s для Payment Service
Owner: @sre-team | Deadline: 2024-02-10
Status: Done
AI-3: [P3] Добавить runbook для Payment Service degradation
Owner: @on-call-team | Deadline: 2024-02-20
Status: Not Started
Метрики инцидентного управления
| Метрика | Формула | Цель |
|---|---|---|
| MTTD | Mean Time To Detect | < 5 минут |
| MTTA | Mean Time To Acknowledge | < 15 минут |
| MTTM | Mean Time To Mitigate | < 30 минут |
| MTTR | Mean Time To Resolve | < 4 часа |
| MTBF | Mean Time Between Failures | Увеличивать |
<?php
declare(strict_types=1);
namespace App\Incident;
final readonly class IncidentMetrics
{
/**
* @param array<Incident> $incidents Resolved incidents for the period
* @return array{mttr_hours: float, mttm_minutes: float, count: int, by_severity: array}
*/
public function calculate(array $incidents): array
{
if (empty($incidents)) {
return [
'mttr_hours' => 0,
'mttm_minutes' => 0,
'count' => 0,
'by_severity' => [],
];
}
$totalMitigationSeconds = 0;
$mitigatedCount = 0;
$bySeverity = [];
foreach ($incidents as $incident) {
$ttm = $incident->getTimeToMitigate();
if ($ttm !== null) {
$totalMitigationSeconds += $ttm;
$mitigatedCount++;
}
$sev = $incident->severity->value;
$bySeverity[$sev] ??= 0;
$bySeverity[$sev]++;
}
return [
'mttr_hours' => 0, // Requires resolution timestamp
'mttm_minutes' => $mitigatedCount > 0
? round($totalMitigationSeconds / $mitigatedCount / 60, 1)
: 0,
'count' => count($incidents),
'by_severity' => $bySeverity,
];
}
}
package incident
// Metrics computes aggregate incident metrics.
type Metrics struct{}
// MetricsResult holds computed incident metrics.
type MetricsResult struct {
MTTRHours float64 `json:"mttr_hours"`
MTTMMinutes float64 `json:"mttm_minutes"`
Count int `json:"count"`
BySeverity map[string]int `json:"by_severity"`
}
// Calculate computes metrics for a set of resolved incidents.
func (m *Metrics) Calculate(incidents []*Incident) MetricsResult {
if len(incidents) == 0 {
return MetricsResult{BySeverity: map[string]int{}}
}
var totalMitigationSec int
mitigatedCount := 0
bySev := make(map[string]int)
for _, inc := range incidents {
ttm := inc.TimeToMitigate()
if ttm >= 0 {
totalMitigationSec += ttm
mitigatedCount++
}
bySev[inc.Sev.String()]++
}
var mttm float64
if mitigatedCount > 0 {
mttm = float64(totalMitigationSec) / float64(mitigatedCount) / 60.0
}
return MetricsResult{
MTTMMinutes: mttm,
Count: len(incidents),
BySeverity: bySev,
}
}
Status Page обновления
| Когда | Что сообщать |
|---|---|
| Обнаружение | «Мы расследуем проблему с [сервисом]» |
| Каждые 15-30 мин | Обновление статуса, что делаем |
| Mitigation | «Проблема устранена, мониторим» |
| Resolution | «Полностью решено, root cause: ...» |
Внутренняя коммуникация
- War Room — выделенный канал (Slack/Teams) для инцидента
- Регулярные апдейты — каждые 15 минут для SEV1
- Stakeholder updates — отдельный канал для менеджмента
- Customer communication — через support и status page
Chaos Engineering
Chaos Engineering — практика намеренного внесения сбоев для проверки устойчивости системы.
| Практика | Описание |
|---|---|
| Chaos Monkey | Случайное завершение инстансов |
| Latency injection | Искусственные задержки |
| Network partition | Разрыв связи между сервисами |
| Dependency failure | Отключение внешних зависимостей |
| Load spike | Резкое увеличение трафика |
Правило: Chaos engineering проводится в production с контролируемым blast radius. Если система не выдерживает хаос в тестовой среде — она точно не готова к production chaos.
Шаблоны Runbook
Runbook — это пошаговая инструкция для дежурного инженера. Хороший runbook позволяет решить проблему даже без глубокого знания системы.
Структура runbook
| Секция | Описание |
|---|---|
| Название | Краткое описание проблемы |
| Триггер | Какой алерт вызывает этот runbook |
| Severity | Critical / High / Medium / Low |
| Шаги диагностики | Что проверить в первую очередь |
| Шаги исправления | Конкретные команды |
| Верификация | Как убедиться что проблема решена |
| Эскалация | К кому обращаться если не помогло |
Runbook: Высокий CPU (>90%)
Триггер: HighCPUUtilization alert (CPU > 90% more than 5 min)
Диагностика:
- Определить какой процесс потребляет CPU:
top -c/htopdocker stats(если контейнеры)
- Проверить текущий RPS: Grafana → API Dashboard → Request Rate
- Проверить наличие long-running queries:
pg_stat_activity
Исправление:
- Если высокий RPS → проверить наличие DDoS/бот-атаки, включить rate limiting
- Если медленные SQL → найти и оптимизировать запрос, добавить index
- Если утечка в приложении → перезапустить контейнер как временная мера
- Если легитимный рост нагрузки → scale out (увеличить replicas)
Верификация: CPU < 70% в течение 10 минут.
Эскалация: Если не решено за 30 минут → вызвать senior backend engineer.
Runbook: Database Replication Lag (>10 секунд)
Триггер: HighReplicationLag alert
Диагностика:
- Проверить текущий lag:
SELECT client_addr, state, sent_lsn, write_lsn, flush_lsn, replay_lsn, (extract(epoch from now()) - extract(epoch from replay_lag))::int as lag_seconds FROM pg_stat_replication; - Проверить нагрузку на primary: CPU, IOPS, connections
- Проверить сеть между primary и replica: latency, bandwidth
- Проверить наличие long-running transactions на replica
Исправление:
- Если высокая нагрузка на primary → оптимизировать запросы, перенести analytics на replica
- Если сетевые проблемы → проверить VPC Peering / networking
- Если long transaction на replica →
pg_terminate_backend(pid) - Если lag растёт бесконечно → рассмотреть re-sync replica
Верификация: Replication lag < 1 секунда в течение 15 минут.
Эскалация: DBA team если lag > 30 секунд или replica не восстанавливается.
Runbook: Out of Memory (OOM Kill)
Триггер: ContainerOOMKilled alert или MemoryUtilization > 95%
Диагностика:
- Проверить какой контейнер убит:
docker events --filter event=oom - Проверить текущее потребление:
docker stats - Проверить memory trend в Grafana за последние 24 часа
- PHP: проверить
memory_get_peak_usage()в логах - Go: профилирование
pprof→go tool pprof http://localhost:6060/debug/pprof/heap
Исправление:
- Если постепенный рост (leak) → перезапустить контейнер, создать задачу на поиск утечки
- Если резкий скачок → проверить последний deploy, откатить если нужно
- Если легитимный рост данных → увеличить memory limit
- PHP: проверить
memory_limitв php.ini, Doctrine hydration (use iterators) - Go: проверить goroutine leak, незакрытые HTTP body, большие slice allocations
Верификация: Memory usage стабильно < 80% в течение 1 часа.
Эскалация: Backend team если OOM повторяется после restart или причина не найдена.
Итоги
| Концепция | Суть |
|---|---|
| Incident lifecycle | Detection → Triage → Response → Mitigation → Resolution → Postmortem |
| Severity levels | SEV1-4 определяют скорость реакции |
| On-call | Справедливая ротация + runbooks + эскалация |
| Postmortem | Blameless анализ с конкретными action items |
| MTTD/MTTR | Ключевые метрики для улучшения процесса |
| Chaos Engineering | Проверка устойчивости через контролируемые сбои |