Что случилось:
В четверг приехал 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Код: Выделить всё
$ kubectl rollout undo deployment/catalog-api -n shop
$ kubectl rollout status deployment/catalog-api -n shop --timeout=5mВ 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, для того и пишу.