Пятница, 23:47. Прод лежит. Я в ванной. Классика.
Привет, коллеги по несчастью. Меня зовут Максим, я продуктовый DevOps с десятью годами шрамов на психике и подгоревшим нервным окончанием там, где у нормальных людей находится чувство покоя. Сегодня я расскажу вам историю, которую в каждой IT-компании мира знают наизусть, но всё равно каждый раз проживают как первый раз. Историю о том, как прод падает именно тогда, когда тебе это меньше всего нужно.
Итак. Декабрь. Пятница. Мы только что задеплоили «маленький hotfix» — ну там, буквально пять строчек, ничего серьёзного. Я уже мысленно дома, уже открываю холодильник, уже слышу шипение открываемой банки. И тут — дзынь. Алерт в Telegram. Потом ещё один. Потом ещё пять. Потом просто поток, как будто кто-то открыл кран с тревогами.
🔴 CRITICAL: Response time > 30s
🔴 CRITICAL: Error rate 78%
🔴 CRITICAL: Database connections exhausted
🔴 CRITICAL: Redis timeout
🔴 CRITICAL: Payment service unreachable
Пять алертов за 40 секунд. Это рекорд, кстати. Я горжусь.
Анатомия катастрофы
Теперь давайте по-серьёзному, потому что случай был действительно интересный с технической точки зрения, и на ithub.uno такие постморtem-разборы ценятся.
Итак, что мы имели на тот момент:
Стек: PHP 8.2, CodeIgniter 4.5, MySQL 8.0 (кластер primary + 2 replica), Redis 7.0 Cluster, Nginx, всё это добро в k8s на трёх нодах
Трафик: ~3500 RPS в пике, средний — около 800 RPS
Hotfix: изменили одну строчку в модели, которая отвечала за выборку пользовательских настроек
Что могло пойти не так? Всё. Абсолютно всё.
Первое, что я сделал — зашёл на Grafana. Там картина маслом: RPS упал с 800 до 120, латентность взлетела с 80ms до 28 секунд (ДВАДЦАТИ ВОСЬМИ, Карл!), количество активных connections к MySQL упёрлось в потолок — 500/500, CPU на всех подах — 95%+.
Классический симптом. Я такое уже видел. Это называется «connection pool exhaustion» в сочетании с «slow query лавиной». Но причина была нетривиальной.
Копаем
Первым делом — slow query log на MySQL:
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.1;
Через 30 секунд смотрю лог и вижу:
SELECT u.*, us.*, p.*, pr.*, c.*
FROM users u
LEFT JOIN user_settings us ON us.user_id = u.id
LEFT JOIN profiles p ON p.user_id = u.id
LEFT JOIN preferences pr ON pr.user_id = u.id
LEFT JOIN cart c ON c.user_id = u.id AND c.status = 'active'
WHERE u.id = 12345
Запрос сам по себе несложный. Выполняется за 0.003 секунды. Но... EXPLAIN показывает Using temporary; Using filesort на таблице cart. И вот тут начинается детективная история.
Дело в том, что за час до деплоя наш аналитик (земля ему пухом) запустил миграцию, которая добавила в таблицу cart новое поле meta_json TEXT. При этом индекс idx_cart_user_status не пересоздавался. Он просто... перестал эффективно работать после изменения статистики таблицы. MySQL решил, что full scan выгоднее. При этом таблица cart содержала 47 миллионов строк.
А наш «маленький hotfix»? Он убрал кэширование этого запроса. Буквально одну строку:
// Было:
return cache()->remember('user_data_' . $userId, 300, fn() => $this->buildUserData($userId));
// Стало (hotfix убрал кэш "для дебага"):
return $this->buildUserData($userId);
И вот оно. Идеальный шторм. Медленный запрос × отсутствие кэша × высокий трафик = прод в нокауте.
Решение в боевых условиях
Времени на красивые решения нет. Алгоритм действий:
Шаг 1: Feature flag
У нас есть система feature flags на Redis. Мгновенно включаю maintenance mode для новых пользователей, пропуская залогиненных:
// CI4 Filter
class MaintenanceFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
if (cache()->get('maintenance_mode') && !auth()->check()) {
return redirect()->to('/maintenance');
}
}
}
Трафик упал на 40%. Дышим.
Шаг 2: Откат
kubectl rollout undo deployment/app --to-revision=15
Это не решение проблемы с индексом, но снимает острую боль — кэш вернулся, запросы снова летают.
Шаг 3: Экстренное создание индекса
Пока прод восстанавливается, параллельно:
-- На реплике сначала тестируем
CREATE INDEX idx_cart_user_status_new
ON cart(user_id, status)
INCLUDE (id, created_at, meta_json)
ALGORITHM=INPLACE, LOCK=NONE;
ALGORITHM=INPLACE, LOCK=NONE — это не просто красивые слова. На таблице в 47 миллионов строк это разница между «индекс создаётся 4 минуты без локов» и «БД заблокирована на 25 минут, прощайте».
Через 6 минут индекс на реплике. Тестируем — запрос летает за 1.2ms. Прогоняем на primary. Ещё 4 минуты. Готово.
Шаг 4: Хотфикс хотфикса
return cache()->remember('user_data_' . $userId, 300, fn() => $this->buildUserData($userId));
Да, просто вернули строку обратно. Иногда лучшее решение — отмотать назад.
Постмортем и уроки
Через два дня я провёл постмортем. Вот ключевые выводы, которые я теперь вколачиваю в голову каждому новому разработчику:
1. Никогда не убирайте кэш "для дебага" в прод Это как снять шлем "чтобы лучше видеть". Дебажьте на стейджинге. Там специально и создано это место.
2. Любая миграция схемы БД требует аудита индексов Мы внедрили правило: к каждому PR с миграцией прилагается EXPLAIN до и после на production-like данных (у нас есть анонимизированный дамп).
3. Алерты должны быть actionable Пять одновременных алертов — это не пять проблем. Это одна проблема с пятью симптомами. Мы перенастроили alertmanager с группировкой и подавлением дублей.
4. Connection pool — ваш первый друг и первый враг В CI4 мы теперь явно конфигурируем пул:
// app/Config/Database.php
public array $default = [
'DBDriver' => 'MySQLi',
'hostname' => env('DB_HOST'),
'pconnect' => false, // persistent connections OFF в highload!
'DBDebug' => false,
// ...
];
Persistent connections в highload — это бомба замедленного действия. Отключайте.
5. Chaos engineering — не роскошь, а необходимость После этого инцидента мы раз в квартал намеренно убиваем случайный под в прод-кластере. Да, в проде. Нет, это не безумие — это единственный способ убедиться, что система действительно resilient.
Финал
В 01:23 прод поднялся полностью. Показатели вернулись к норме. Я наконец открыл ту банку. Она была тёплой.
Но знаете что? Этот инцидент стоил нам примерно $4,000 потерянной выручки и несколько седых волос. Зато мы получили бесценный опыт и полностью переработали процесс деплоя. Теперь у нас есть автоматическая проверка slow queries перед каждым деплоем, обязательный review индексов при миграциях и — самое главное — правило: никаких деплоев в пятницу после 18:00.
Это правило нарушают только те, кто ещё не прожил свою первую пятничную аварию. После первой — никогда.
Recommended Comments