Предистория

Схема входящего траффика в кластер 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-maxDefaultTasksMax = 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 на нодах прямо сейчас, не дожидаясь своего инцидент.