Предистория
Схема входящего траффика в кластер Kubernetes простая: web → Envoy Gateway → Ingress Nginx → backend. За Ingress Nginx, помимо обычного HTTP, живут долгоживущие WebSocket-соединения. Штатная нагрузка - около 100 RPS. Ничего экзотического.
В один прекрасный день всё в кластере легло. Клиенты получают 503/500. В логах Envoy - флаг UF и upstream_reset_before_response_started{connection_timeout}. То есть ingress-nginx просто перестал отвечать.
Дальше - два часа разбора и довольно красивая цепочка причин, которая началась с банального reload, а закончилась на том, как ядро считает лимит потоков при старте виртуалки.
Если вы хотите узнать какого черта у меня в инфраструктуре и Envoy Gateway и Ingress Nginx работают одновременно - то это хороший вопрос.
Сейчас мы в статусе переезда с Ingress Nginx на Envoy Gateway и если у какого-то домена не написан HTTPRoute - трафик автоматически полетит на Ingress (с надеждой, что если у домена нет HTTPRoute, возможно, у него есть Ingress)
Симптом: все легло, а error log пустой
Первое, что сбивает с толку - в error.log nginx пусто. Контроллер не ругается, но и не работает:
nginx_statusна127.0.0.1:10246-connection refused;следом readiness-проба контроллера:
status.go: "POD is not ready";pod выпадает из ротации, Envoy видит дохлый upstream, наружу - 503/UF.
Когда сервис лежит, а error log молчит - значит сломалось не в конфиге, а уровнем ниже. Так и оказалось.
Лечилось это, кстати, тривиально - рестартом подов ingress-контроллера, и всё поднималось за минуту. Но оставлять прод в состоянии "когда-нибудь оно снова стрельнет в колено" - так себе идея. Поэтому быстрый фикс быстрым фиксом, а мы пошли копать, почему вообще дошло до отказа.
Раскручиваем: почему nginx перестал поднимать ворекеры
Идём в journalctl и сразу находим виновника:
journalctl --since "14:20" --until "14:45" | grep -iE "fork|thread|resource temporarily|cannot allocate" kernel: cgroup: fork rejected by pids controller in /kubepods.slice/.../cri-containerd-....scope
fork rejected by pids controller. Контейнер ingress-контроллера упёрся в pids.max своей cgroup. nginx не может сделать fork/clone - то есть не может поднять воркер ("cannot be respawned"). Контроллер клинит, статус-порт ложится, readiness падает. Всё сходится.pids.max у контейнера был 975. И у нас сразу появляется 2 вопроса:
кто наплодил под тысячу процессов в одном ingress-поде?
откуда появился лимит в 975?

Откуда лишние процессы: websocket + reload
А вот тут начинается интересное. Виноват штатный reload контроллера в связке с долгоживущими соединениями.
worker-shutdown-timeout = 240s. При reload старые воркеры не убиваются сразу - им дают до 240 секунд на graceful drain.Но долгоживущие WebSocket'ы сами не закрываются. Старый воркер не может дренироваться - и честно висит все 240 секунд.
Reload'ы идут чаще, чем раз в 240с. Поколения воркеров сосуществуют: ~10 текущих + ~10 предыдущих + ~10 позапрошлых ≈ 30 процессов вместо десяти.
Включён aio thread pool (
thread_pool ... threads=32). Каждый воркер тащит ~33 задачи. 30 процессов × 33 ≈ 990 задач.
Дальше арифметика беспощадна: 990 > pids.max=975 → ядро отклоняет fork → nginx не поднимает воркер → контроллер мёртв. Цепочка целиком:
reload → старые воркеры висят на websocket'ах 240s → 3 поколения воркеров (~30 проц.) → × aio-threads (~33) ≈ 990 задач → упор в pids.max=975 → fork rejected → воркер не поднимается → :10246 refused → readiness fail → 503/UF
Почему pids.max именно 975
pids.max контейнера в нашем случае диктует systemd:
DefaultTasksMax = 15% от kernel.threads-max
Считаем: kernel.threads-max = 6501, 15% × 6501 = 975. И каждый контейнерный scope получает TasksMax/pids.max = 975.
systemctl show -p DefaultTasksMax DefaultTasksMax=975
То есть корень не в nginx и не в Kubernetes. Корень в том, что kernel.threads-max на ноде был 6501 - это примерно в 50 раз ниже нормы (на здоровой ноде там сотни тысяч).
Корень: виртуалка, которая подросла
Почему threads-max оказался таким мизерным? Ядро считает этот лимит один раз при старте, исходя из объёма доступной памяти. Чем меньше RAM на момент загрузки - тем меньше threads-max.
Случилось вот что: KVM-виртуалка стартовала с очень малой RAM, а потом доросла до 16 ГБ через balloon/hotplug. Память приехала, а threads-max ядро не пересчитало - он так и остался с тех времён, когда памяти было мало.
Дальше всё уже видели: маленький threads-max → DefaultTasksMax = 15% → pids.max = 975 на каждый контейнер → ingress-контроллер под websocket-штормом выбивает этот лимит и умирает.
Бонус-грабля: шторм 101 на ровном месте
Отдельно стоит упомянуть, что добивало систему. Когда старые воркеры всё-таки достигали worker-shutdown-timeout, они разом принудительно закрывали все ещё открытые websocket'ы. Тысячи клиентов одновременно идут на реконнект: всплеск 101 больше 10000/с при норме ~100 RPS. Синхронный реконнект-шторм - сам по себе хороший способ добить контроллер.
Вывод
Инцидент собрался из четырёх безобидных по отдельности вещей:
reload,
долгоживущие websocket'ы,
aio-threads,
заниженный threads-max
По отдельности - ерунда. Вместе - двухчасовой инцидент.
Главный takeaway, ради которого всё это писалось: маленькая виртуалка, которую потом расширили, может однажды выстрелить вам в колено. kernel.threads-max считается на старте по памяти и не пересчитывается после hotplug/balloon. Если у вас VM рождаются маленькими и растут - проверьте threads-max и DefaultTasksMax на нодах прямо сейчас, не дожидаясь своего инцидент.
