Постмортем: как мы уронили прод на 47 минут одной строчкой в values.yaml

Рейтинг: 52.9% · 8 голосов
Технические статьи, разборы и лонгриды от сообщества Cyberlake.
Ответить
Аватара пользователя
togashi
Сообщения: 50
Зарегистрирован: 10 май 2026, 23:57

Постмортем: как мы уронили прод на 47 минут одной строчкой в values.yaml

Сообщение togashi »

Пишу по горячим следам, инцидент был три недели назад, разбор закончили, NDA позволяет рассказать без названия компании. Это e-commerce, средний по меркам СНГ: пиковая нагрузка около 1200 rps на API, кластер на 14 нод в Яндекс Облаке, Kubernetes 1.31, деплой через Helm + ArgoCD. Даунтайм составил 47 минут в вечерний пик, финансисты насчитали примерно 2.3 млн рублей недополученной выручки. И все это из-за одной строчки в values.yaml.

Что случилось:

В четверг приехал MR с оптимизацией ресурсов. Идея здравая: по графикам Grafana основной API-сервис (Go, монолитный, держит in-memory кеш каталога) в среднем ел 800-900 Mi памяти, а limit стоял 2Gi. Коллега порезал limit до 1Gi, чтобы плотнее паковать поды и сэкономить пару нод. Ревью прошло, потому что все смотрели на average, а не на пики. Проблема в том, что раз в 20 минут сервис перестраивает кеш каталога: старая копия еще в памяти, новая уже строится, и потребление на 30-60 секунд подскакивает до 1.4-1.6Gi. На графике с агрегацией avg за 5 минут этот пик размазывается и его просто не видно. Надо было смотреть max(container_memory_working_set_bytes), а не avg.

В пятницу в 18:40 ArgoCD синкнул изменения. Дальше классика. Новые поды поднялись, прошли readiness, начали принимать трафик. Через 20 минут первый цикл перестройки кеша, и поды начали ловить OOMKilled по очереди:

Код: Выделить всё

$ kubectl get pods -n shop -l app=catalog-api
NAME                           READY   STATUS      RESTARTS   AGE
catalog-api-7d9f8b6c4-2xkqp    0/1     OOMKilled   3          24m
catalog-api-7d9f8b6c4-8wmzn    1/1     Running     2          24m
catalog-api-7d9f8b6c4-jl4vt    0/1     CrashLoopBackOff   4   23m
Каскад:

Дальше система начала добивать себя сама. HPA у нас был настроен на CPU (target 70%). Выжившие поды приняли трафик умерших, CPU вырос, HPA отмасштабировал deployment с 12 до 26 реплик. Каждая новая реплика на старте строит кеш и почти сразу упирается в тот же limit. Cluster-autoscaler увидел Pending-поды и заказал еще 4 ноды, они поднимались по 2-3 минуты, и на них происходило ровно то же самое. Мы платили за железо, которое конвейером производило OOMKilled.

Вишенка: каждый рестарт пода это холодный кеш, а холодный кеш это прямые запросы в PostgreSQL. Нагрузка на базу выросла в 9 раз, время ответа API уползло с 80ms до 4-6 секунд, ingress-nginx начал отдавать 502 и 504. PgBouncer уперся в пул, и легли даже те эндпоинты, которые к каталогу отношения не имеют. В 19:05 мониторинг засыпал дежурного алертами, в 19:12 собрался war room.

Как чинили в моменте:

Первые минут десять ушли впустую, потому что дежурный лечил симптом: руками удалял CrashLoop-поды и ждал, что пересоздадутся нормально. Не пересоздались, естественно. В 19:22 догадались посмотреть последний релиз:

Код: Выделить всё

$ kubectl rollout history deployment/catalog-api -n shop
$ kubectl describe pod catalog-api-7d9f8b6c4-2xkqp -n shop | grep -A3 Last
    Last State:  Terminated
      Reason:    OOMKilled
      Exit Code: 137
Exit code 137 и diff в git ответили на все вопросы. Откат:

Код: Выделить всё

$ kubectl rollout undo deployment/catalog-api -n shop
$ kubectl rollout status deployment/catalog-api -n shop --timeout=5m
И тут вторая засада: ArgoCD с selfHeal: true увидел drift и через 40 секунд откатил наш откат обратно на сломанную версию. Пришлось сначала выключить auto-sync на приложении и только потом катить undo. Запомните этот момент, если у вас GitOps: kubectl rollout undo против ArgoCD не работает, правильный путь это revert коммита в git, но в панике до этого додумались не сразу.

В 19:31 поды со старым limit поднялись, но легче не стало: 26 реплик одновременно ломанулись греть кеш в полумертвую базу. Classic thundering herd. Спасли две вещи: руками зажали HPA (kubectl scale до 12 и временно maxReplicas=12) и включили на ingress лимит 200 rps на /api/catalog, чтобы база продышалась. К 19:47 latency вернулся в норму, инцидент закрыли. Итого 47 минут деградации, из них 25 полноценного даунтайма по основным сценариям покупки.

Что внедрили после разбора:

1. Ревью ресурсов только по перцентилям и максимумам. В дашборд добавили панель max_over_time(container_memory_working_set_bytes[7d]) по каждому сервису. Правило: limit не ниже p100 за неделю плюс 25%. Для catalog-api это вышло 2Gi, ровно как и было.

2. Канареечный rollout вместо RollingUpdate для всего, что трогает resources. Взяли Argo Rollouts: 10% подов, пауза 30 минут (два цикла перестройки кеша), автоматический abort по метрике kube_pod_container_status_restarts_total. Сломанный limit теперь убьет один под, а не прод.

3. Починили сам сервис: перестройка кеша теперь инкрементальная, без второй полной копии в памяти. Пик потребления упал с 1.6Gi до 1.1Gi. Это, кстати, главный технический вывод: limit был не велик, это сервис был прожорлив, но узнали мы это в худший из возможных моментов.

4. HPA перевели с CPU на RPS через custom metrics (prometheus-adapter). CPU-based HPA при каскадных отказах делает только хуже, мы в этом убедились на собственные 2.3 млн.

5. Регламент на пятничные релизы так и не запретили (и я считаю это правильным, запрет релизов лечит страх, а не процесс), но релизы инфраструктурных параметров теперь требуют второго аппрува от SRE.

6. Прогнали игровой день: руками устроили OOM в стейдже и проверили, что дежурный за 10 минут доходит до rollout undo через revert в git. Первый прогон заняли 28 минут, второй уже 9.

Самое неприятное в этой истории то, что каждый отдельный механизм (HPA, autoscaler, selfHeal, readiness) работал ровно как задуман. Авария родилась из их взаимодействия. Kubernetes не падает от одной ошибки, он падает тогда, когда автоматика начинает усердно тиражировать вашу ошибку быстрее, чем вы способны думать. Вопросы и критику разбора welcome, для того и пишу.
👍1 ❤️1 🔥2 😄1 🤔
✔ Лучший ответ сформирован автоматически — leochir
togashi писал(а):CPU-based HPA при каскадных отказах делает только хуже, мы в этом убедились на собственные 2.3 млн Вот тут не соглашусь, что проблема в CPU как метрике. Проблема в том, что у вас HPA скейлил сервис, который не мог стартовать. RPS-based HPA в той же ситуации точно так же наплодил бы реплик, потому что обслуженный rps на под падал. Реальное лечение это startupProbe + нормальный bac…
Перейти к ответу →
Аватара пользователя
coder_vlad
Сообщения: 72
Зарегистрирован: 11 май 2026, 01:57

Re: Постмортем: как мы уронили прод на 47 минут одной строчкой в values.yaml

Сообщение coder_vlad »

@togashi, Спасибо за честный разбор, такое редко выносят наружу. Про ArgoCD selfHeal против rollout undo прям в точку, мы на эти же грабли наступали в 2024, с тех пор у дежурных в runbook первым пунктом стоит argocd app set --sync-policy none и только потом любые ручные действия. Удивлен, что у вас этого не было до инцидента.
👍 ❤️ 🔥 😄 🤔
Аватара пользователя
maja33
Сообщения: 38
Зарегистрирован: 12 май 2026, 10:17

Re: Постмортем: как мы уронили прод на 47 минут одной строчкой в values.yaml

Сообщение maja33 »

А почему вообще не было нагрузочного теста перед выкаткой изменения лимитов? Звучит как процессная дыра побольше, чем avg на графике. Ну и вопрос: memory request у вас был равен limit или меньше? Если меньше, то вы еще и от node memory pressure могли словить эвикты соседей, про это в посте ни слова.
👍2 ❤️ 🔥1 😄 🤔
Аватара пользователя
leochir
Сообщения: 20
Зарегистрирован: 11 май 2026, 01:44

Re: Постмортем: как мы уронили прод на 47 минут одной строчкой в values.yaml

Сообщение leochir »

✔ Лучший ответ — сформирован автоматически
togashi писал(а):CPU-based HPA при каскадных отказах делает только хуже, мы в этом убедились на собственные 2.3 млн
Вот тут не соглашусь, что проблема в CPU как метрике. Проблема в том, что у вас HPA скейлил сервис, который не мог стартовать. RPS-based HPA в той же ситуации точно так же наплодил бы реплик, потому что обслуженный rps на под падал. Реальное лечение это startupProbe + нормальный backoff, чтобы под не считался кандидатом на трафик пока кеш не прогрет, и лимит maxReplicas, который вы в итоге руками и зажимали. Метрику поменяли, а защиту от поведения 'скейлим то, что умирает' так и не описали.
👍1 ❤️ 🔥2 😄 🤔
Аватара пользователя
rotov
Сообщения: 17
Зарегистрирован: 12 май 2026, 06:14

Re: Постмортем: как мы уронили прод на 47 минут одной строчкой в values.yaml

Сообщение rotov »

Хороший текст. Добавлю из своего опыта: max_over_time за 7 дней мало, если у вас есть месячные паттерны (распродажи, закрытие периода). Мы держим 30d и отдельно смотрим VPA в режиме recommendation only, он такие пики ловит неплохо. И да, инкрементальная перестройка кеша это правильно, но double-buffer кеш с пиком x2 по памяти это настолько известный паттерн, что закладывать его в limit надо было еще на этапе написания сервиса.
👍1 ❤️ 🔥2 😄 🤔
Аватара пользователя
infern
Сообщения: 87
Зарегистрирован: 11 май 2026, 10:23

Re: Постмортем: как мы уронили прод на 47 минут одной строчкой в values.yaml

Сообщение infern »

maja33 писал(а):memory request у вас был равен limit или меньше?
Присоединяюсь к вопросу, это важно. И еще момент: судя по описанию, у вас не стояла GOMEMLIMIT. Для Go-сервисов с кешем это маст: ставишь GOMEMLIMIT процентов на 90 от container limit, и GC начинает агрессивнее собирать вместо того, чтобы ловить kill от ядра. Не панацея при честном double-buffer, но окно между 'память кончается' и 'exit 137' сильно расширяет, иногда хватает чтобы пережить пик.
👍1 ❤️ 🔥 😄 🤔
Аватара пользователя
cmout098
Сообщения: 15
Зарегистрирован: 11 май 2026, 00:49

Re: Постмортем: как мы уронили прод на 47 минут одной строчкой в values.yaml

Сообщение cmout098 »

28 минут на первом игровом прогоне это вы еще хорошо отделались, у нас дежурный однажды час дебажил networkpolicy, которой не существовало. По делу: рейт-лимит на ingress в момент инцидента вы как накатывали, через annotations nginx? Просто limit-rps у ingress-nginx считается на воркер, при 4 воркерах ваши 200 rps превращаются в 800 суммарно, классическая ловушка. Если база продышалась, значит либо повезло, либо лимит реально был жестче заявленного.
👍1 ❤️2 🔥 😄 🤔
Ответить
Поделиться темой: ✈ Telegram VK

Вернуться в «Статьи и лонгриды»

Кто сейчас на конференции

Сейчас этот форум просматривают: нет зарегистрированных пользователей и 1 гость