Mid📖Теория7 min

Управление инцидентами

Incident response, postmortems, on-call ротации и политики эскалации

Управление инцидентами

Что такое инцидент

Инцидент — это незапланированное событие, которое приводит к нарушению или деградации сервиса. Не каждый баг — инцидент. Инцидент — это когда пользователи затронуты.

Классификация инцидентов

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: система управления инцидентами

PHPGo
<?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

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 Увеличивать
PHPGo
<?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,
	}
}
## Communication во время инцидента

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)

Диагностика:

  1. Определить какой процесс потребляет CPU:
    • top -c / htop
    • docker stats (если контейнеры)
  2. Проверить текущий RPS: Grafana → API Dashboard → Request Rate
  3. Проверить наличие 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

Диагностика:

  1. Проверить текущий 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;
    
  2. Проверить нагрузку на primary: CPU, IOPS, connections
  3. Проверить сеть между primary и replica: latency, bandwidth
  4. Проверить наличие 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%

Диагностика:

  1. Проверить какой контейнер убит: docker events --filter event=oom
  2. Проверить текущее потребление: docker stats
  3. Проверить memory trend в Grafana за последние 24 часа
  4. PHP: проверить memory_get_peak_usage() в логах
  5. Go: профилирование pprofgo 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 Проверка устойчивости через контролируемые сбои