Как мы пережили Black Friday: postmortem, который я не хотел писать
Это не та история, которой гордятся. Это история, которую рассказывают тихо, за пивом, другим DevOps'ам — чтобы они не совершили те же ошибки. Но знаете что? Я решил рассказать её громко. Потому что честность важнее репутации, а реальные истории учат лучше, чем придуманные кейсы.
Black Friday. Наш e-commerce на CI4. Трафик × 15 от обычного. Что могло пойти не так?
Спойлер: всё.
Подготовка (которой, как оказалось, было недостаточно)
За три недели до BF мы провели "подготовку". По тем временам нам казалось, что мы сделали всё правильно:
✅ Провели нагрузочный тест на 5× нормального трафика
✅ Настроили автомасштабирование k8s
✅ Прогрели CDN-кэш для статики
✅ Оптимизировали топ-20 медленных запросов
✅ Увеличили connection pool MySQL
✅ Настроили алерты
Что мы не сделали (и о чём потом пожалели):
❌ Не протестировали 15× трафика (только 5×)
❌ Не протестировали scenario с высоким числом одновременных checkout операций
❌ Не проверили поведение при частичном отказе зависимостей
❌ Не подготовили runbook для дежурной команды
Хронология событий
00:00 — Midnight madness старт
Трафик начал расти в 23:45. К полуночи — ×4 от нормы. Всё отлично, k8s масштабируется, метрики в норме, team в Teams чатится позитивно.
00:23 — Первый звонок
🔴 ALERT: Checkout error rate > 5%
5% checkout'ов не проходят. Это много. Начинаем копать.
В логах:
Deadlock found when trying to get lock; try restarting transaction
MySQL deadlock. Два процесса пытались обновить одну и ту же запись в таблице inventory (остатки товаров) одновременно. При ×4 трафике вероятность коллизии выросла критически.
Быстрое решение: добавили SELECT ... FOR UPDATE с retry-логикой:
public function decrementStock(int $productId, int $quantity): bool
{
$maxRetries = 3;
$retryDelay = 100; // ms
for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
$this->db->transStart();
try {
$product = $this->db
->table('inventory')
->where('product_id', $productId)
->lockForUpdate()
->get()
->getRowArray();
if ($product['stock'] < $quantity) {
$this->db->transRollback();
return false;
}
$this->db->table('inventory')
->where('product_id', $productId)
->update(['stock' => $product['stock'] - $quantity]);
$this->db->transCommit();
return true;
} catch (\Exception $e) {
$this->db->transRollback();
if ($attempt < $maxRetries && str_contains($e->getMessage(), 'Deadlock')) {
usleep($retryDelay * 1000 * $attempt);
continue;
}
throw $e;
}
}
return false;
}
Деплой. Прошло. Продолжаем.
01:47 — Главное событие
🔴 CRITICAL: Database primary unreachable
🔴 CRITICAL: All checkout endpoints down
🔴 CRITICAL: Payment service timeout
MySQL primary упал. Не лёг — упал. Полностью. Причина выяснилась позже: диск заполнился из-за бинарных логов (binary log retention не был настроен для ситуации с × 15 write операциями).
df -h /var/lib/mysql
# Filesystem: 100% used (512GB / 512GB)
512 гигабайт. Всё. Диск кончился. MySQL не может писать — MySQL падает. Элегантно, ничего не скажешь.
Автоматический failover сработал — реплика стала primary. Это заняло 23 секунды. 23 секунды — 100% ошибок на checkout. При трафике BF это $147,000 потерянной выручки. За двадцать три секунды.
Затем выяснилось: наша реплика не была настроена на роль primary. У неё не было некоторых критических stored procedures. Checkout стал работать, но с ошибками 15%.
Следующие 40 минут команда в поту накатывала stored procedures на новый primary, чистила binlogs на упавшем сервере (он был ещё нужен), настраивала новую репликацию.
02:47 — Всё относительно стабильно
Error rate 2.3%. Для BF — терпимо, но не хорошо.
04:15 — Redis OOM
OOM command not allowed when used memory > 'maxmemory'
Redis закончил память. Eviction policy была noeviction — вместо того чтобы выкидывать старые ключи, Redis начал отклонять все write-операции. Приложение посыпалось.
Быстрый фикс:
redis-cli CONFIG SET maxmemory-policy allkeys-lru
LRU eviction включён. Redis начал вытеснять старые ключи. Кэш-хиты упали с 94% до 61%, нагрузка на MySQL снова выросла, но хотя бы приложение работало.
Постмортем: что пошло не так
Причина 1: Мы не тестировали реальный сценарий
Нагрузочный тест в ×5 — это не BF при ×15. Мы протестировали "всё нормально", а не "всё горит". Нужно было делать chaos testing: убивать primary во время нагрузки, заполнять диск, устраивать OOM Redis.
Причина 2: MySQL binlog retention
-- Должно быть настроено!
SET GLOBAL binlog_expire_logs_seconds = 86400; -- 24 часа
Это одна строчка. ОДНА. И она бы предотвратила заполнение диска.
Причина 3: Replica не была готова к роли primary
Наша "репликация" была настроена для read scaling, а не для failover. Stored procedures, triggers, специфичные настройки — ничего из этого не дублировалось. Это фундаментальная ошибка в архитектуре HA.
Причина 4: Redis maxmemory-policy noeviction
Кто-то (я) настроил noeviction "чтобы данные не терялись". Логика благородная. Результат катастрофический. В production eviction лучше, чем полный отказ сервиса.
Что изменилось после
1. GameDay — обязательная практика
Раз в квартал мы проводим "день катастрофы": специально ломаем production-like окружение и смотрим как команда реагирует. Сценарии: упал primary MySQL, заполнился диск, OOM Redis, одна нода k8s недоступна.
2. Runbook для каждого critical alert
Каждый алерт в PagerDuty теперь ссылается на confluence-страницу с пошаговым runbook. Дежурный не должен думать — он должен читать и выполнять.
3. Pre-BF чеклист
# Automated pre-event checks
./scripts/pre-event-check.sh
# Checks:
# ✓ MySQL disk usage < 50%
# ✓ Binlog retention configured
# ✓ Redis maxmemory-policy = allkeys-lru
# ✓ Replica can promote to primary (stored procs present)
# ✓ HPA max replicas sufficient
# ✓ CDN cache warm
# ✓ All alerts configured and tested
# ✓ On-call rotation confirmed
# ✓ Rollback plan documented
Финансовые итоги
Общие потери от инцидентов BF: ~$230,000 (прямые потери выручки + компенсации + репутационный ущерб).
Следующий год, после всех изменений: BF прошёл без единого critical инцидента. Error rate — 0.08%. Выручка выросла на 340% по сравнению с прошлым BF.
Цена нормальной подготовки: 3 недели работы команды + $15,000 на gameday инфраструктуру.
Разница: 230,000 vs 15,000. Выбор очевиден.
Делитесь похожими историями — на ithub.uno такие postmortem'ы читают и обсуждают живее всего. Потому что в них — настоящий опыт. 💀➡️🧠
Recommended Comments