АйТи Фреш
Главная / Статьи / DevOps и автоматизация
DevOps и автоматизация

Почему Nginx в Docker получает Address not available под нагрузкой

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~8 мин чтения
Почему Nginx в Docker получает Address not available под нагрузкой

В `error.log` появляется `connect() failed (99: Cannot assign requested address) while connecting to upstream`, а в чате уже предлагают ещё раз поднять `nofile`. Я, Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш», в этот момент прошу остановиться. Код 99 говорит не о том, что backend отказал, и не о том, что Nginx исчерпал файловые дескрипторы. Чаще всего Linux не смог выделить исходящий локальный порт. Но Docker добавляет ещё два слоя — NAT и conntrack, поэтому по одному HTTP 502 гадать нельзя. Ниже покажу порядок, который быстро локализует слой отказа и не требует крутить все sysctl подряд.

Errno 99 — не нехватка файловых дескрипторов

Я начинаю с полного текста ошибки, включая имя системного вызова. Для незабинденного Internet-сокета Linux документирует `EADDRNOTAVAIL`: при `connect()` ядро попыталось автоматически назначить ephemeral port и не нашло свободного в `ip_local_port_range`. Это локальный отказ до нормального разговора с upstream. Поэтому фраза «upstream упал» здесь обычно неверна: пакет мог вообще не выйти из namespace контейнера. Именно так описан код в [connect(2)](https://man7.org/linux/man-pages/man2/connect.2.html).

У `nofile` другой почерк: `socket() failed (24: Too many open files)` или `accept4() failed (24: Too many open files)`. `worker_connections` тоже отдельный потолок; он считает и клиентские, и upstream-соединения, а фактическое число ограничено лимитом открытых файлов процесса. Это прямо оговорено в [документации ядра Nginx](https://nginx.org/en/docs/ngx_core_module.html). Поднять Compose `ulimits.nofile`, забыв про `worker_connections`, бессмысленно, но даже идеальная настройка обоих параметров не создаст ни одного нового TCP-порта.

Есть исключение, о котором легко забыть: `proxy_bind` с удалённым из контейнера IP. Тогда `bind()` также возвращает `EADDRNOTAVAIL`. Я проверяю `nginx -T`, адреса и маршрут, особенно после миграции VRRP или смены сети. Смотрите на глагол в логе: `bind(...) failed` ведёт к адресу, `connect() failed (99...)` без явной привязки — к ephemeral pool. А `Connection refused` и `Connection timed out` — уже другие ветки расследования. Смешивать их в одну «сетевую ошибку Nginx» дорого.

Не лечите цифру 99 цифрой `nofile`. Сначала установите, какой системный вызов упал и в каком network namespace он выполнялся.

Где на самом деле заканчиваются порты, NAT и conntrack

У bridge-контейнера свой network namespace: сокеты, номера портов и часть `/proc/sys/net`. Типичный диапазон Linux — `32768 60999`, то есть 28 232 значения. Соединение уникально по протоколу и source/destination IP:port, поэтому один локальный порт можно использовать с разными endpoint. Но к одной паре source IP → `10.40.12.34:8080` пространство быстро заканчивается. Базовый TIME-WAIT-таймер Linux — 60 секунд; расчёт относится к non-loopback без допустимого reuse. При 1 000 активных закрытий в секунду `28232 / 1000 ≈ 28 с` предсказывает кейс; константа видна в [исходном коде Linux](https://github.com/torvalds/linux/blob/master/include/net/tcp.h).

Для обычного rootful Docker Engine на Linux пакет из bridge обычно проходит MASQUERADE: адрес контейнера заменяется адресом хоста. NAT подбирает уникальный внешний tuple; пространство конечно для одного egress-IP, протокола и destination IP:port. Пять контейнеров расширят внутренние пулы, но не внешний адрес. Так errno 99 можно убрать и упереться в SNAT. NAT allocator не ограничен host `ip_local_port_range`, поэтому расширять его на хосте «для MASQUERADE» бесполезно. Rootless, Docker Desktop, host, macvlan/ipvlan и routed bridge требуют отдельной схемы; базовый путь описан в [Docker Docs](https://docs.docker.com/engine/network/port-publishing/).

Conntrack — третий ресурс. `nf_conntrack_count` привязан к network namespace; для bridge/MASQUERADE я читаю его в initial namespace хоста, а значение из контейнера не показывает NAT-таблицу. Под давлением может расти `early_drop`; если ядро не освободило запись, оно дропает пакет и пишет о полной таблице. Лог rate-limited, его отсутствие не алиби; `insert_failed` бывает и при конфликте tuple. Поэтому conntrack или NAT allocator обычно даёт SYN retransmit и timeout, не немедленный errno 99. Socket TIME-WAIT контейнера тоже не равен состоянию conntrack: upstream default его host-таймера — 120 секунд. Проверяйте runtime; детали видны в коде [conntrack](https://github.com/torvalds/linux/blob/master/net/netfilter/nf_conntrack_core.c) и [NAT](https://github.com/torvalds/linux/blob/master/net/netfilter/nf_nat_core.c).

Четвёртая причина — churn из-за отсутствия upstream keepalive. Клиент может держать HTTPS-соединение с Nginx хоть час; это ничего не говорит о второй TCP-ноге от Nginx к приложению. До Nginx 1.29.7 для HTTP upstream требовались `keepalive` в блоке `upstream`, HTTP/1.1 и очищенный `Connection`. Начиная с 1.29.7 keepalive включён по умолчанию: 32 idle-соединения на worker, а HTTP/1.1 стал стандартом. В stable 1.30.4 это уже есть, но на старых образах 1.26/1.28 — нет. Для implicit upstream из прямого `proxy_pass http://IP:port` и runtime-переменной автоматический кеш не создаётся, поэтому я объявляю именованный `upstream`. Изменение версии разобрано в [официальной публикации Nginx](https://blog.nginx.org/blog/keep-alive-to-upstreams-is-now-default-in-nginx-1-29-7).

`proxy_socket_keepalive on` включает TCP-пробы SO_KEEPALIVE. Он не создаёт кеш повторно используемых HTTP upstream-соединений и сам по себе портовый churn не устраняет.
Почему Nginx в Docker получает Address not available под нагрузкой — схема

Мой порядок измерений: контейнер, затем хост

Первые пять минут я ничего не меняю. Фиксирую время ошибки, RPS, полный upstream-адрес и эффективный конфиг. Нужны именно `nginx -T` и `nginx -V`, а не файл из Git: include, переменная в `proxy_pass` или старый смонтированный volume часто меняют картину. Одновременно смотрю, не исчез ли IP из `proxy_bind`. Один срез на простое и три-четыре среза во время разгона нагрузки полезнее красивого графика после аварии.

Затем захожу в network namespace контейнера. В минимальном Alpine-образе может не быть `ss`, поэтому запускаю его с хоста через `nsenter`. Считаю общий `TCP_tw`, но решение принимаю по паре source IP → destination IP:port. Если диапазон содержит 28 232 порта, а в этой паре видно 27–28 тысяч соединений и число растёт вместе с errno 99, доказательство уже сильное. Низкий FD и свободный conntrack его укрепляют.

Лишь после этого перехожу в начальный namespace хоста: сравниваю conntrack count/max, снимаю дельту `conntrack -S`, читаю kernel log и смотрю правила NAT. Значение `count/max` без динамики почти бесполезно: 70% стабильного фона бывает безопаснее, чем рост с 20 до 60% за минуту. И наоборот, таблица на 30% не оправдывает её удвоение. Мне нужны корреляция со стартом теста и совокупность сигналов, а не одна большая цифра.

Не запускайте частый полный `conntrack -L` или бесконечный `ss` на загруженном узле: само перечисление большой таблицы стоит CPU. Для постоянного мониторинга используйте счётчики, а подробный dump снимайте кратко и с фильтром. И никогда не применяйте `conntrack -F` как уборку: команда удаляет всю state table и рвёт действующие NAT/stateful-соединения.
Цифры и версии: Считаю новые соединения, а не только запросы — схема
Цифры и версии: Считаю новые соединения, а не только запросы

Условный проект «Вектор»: 28 232 порта за 30 секунд

Покажу обезличенный стенд «ООО „Вектор“» — название условное, а схема собрана из ситуаций, которые я не раз разбирал руками. Виртуальная машина: 8 vCPU, 16 Гбайт RAM, Ubuntu Server 24.04 LTS, Linux 6.8, Docker Engine 29.6.2. В bridge-сети Nginx имел `172.20.0.10`; работал образ `nginx:1.26.3-alpine`, четыре workers, внешний Java API — `10.40.12.34:8080`. Генератор держал 400 клиентских keepalive-соединений и 1 000 запросов/с, p95 приложения был 38 мс.

Команда уже выставила `nofile` 262144 и `worker_connections 32768`. В location присутствовали `proxy_http_version 1.1` и пустой `Connection`, но в `upstream` не было `keepalive`. Это классическая половина настройки: backend разрешает сохранить TCP-соединение, а старый Nginx не кладёт его в upstream cache и закрывает после ответа. Мы измерили около 950 новых соединений/с. Через 27 секунд `TCP_tw` переваливал за 25 тысяч, на 30-й появлялся errno 99, а доля 502 доходила до 8,1%.

В момент сбоя `ss` показывал 28 117 записей для `172.20.0.10 → 10.40.12.34:8080`, из них 27 984 TIME-WAIT. Диапазон был `32768–60999`. У workers вместе — меньше 4 300 FD; host `nf_conntrack_count` — 41 806 при `max=buckets=262144`, а `early_drop`, `insert_failed` и `drop` не росли. Дождались возврата socket states и host conntrack к baseline, расширили диапазон до `10240–65535`: отказ сдвинулся на 58-ю секунду. Хороший эксперимент — ведро стало больше, кран остался открыт.

На том же Nginx 1.26.3 изменили только конфиг keepalive и повторили тест: при 3 500 запросах/с 30 минут connect rate был 3–6/с, TIME-WAIT — около 220, conntrack — фоновые 9–11 тысяч. Среднее upstream-время — 34 мс, p95 — 44 мс; errno 99 и сетевых 502 не было. Затем обновились до stable 1.30.4 и тем же профилем подтвердили отсутствие регрессии. Диапазон оставили как запас, но причинный эффект отдельно доказал keepalive.

`keepalive 64` — максимум idle-соединений на каждый worker, не общий лимит и не ограничение активных подключений. При четырёх workers backend должен выдержать до 256 простаивающих соединений плюс активные.

Что менять и в какой последовательности

Мой первый выбор — убрать лишние открытия TCP. `RPS × среднее upstream-время` даёт лишь стартовую оценку активной конкуренции по закону Литтла, не размер idle cache; деление на workers тоже приблизительно. В «Векторе» `3500 × 0,034 ≈ 119` активных соединений суммарно, а измеренный пик самого занятого worker был 46. Я начал с `keepalive 64` и подтвердил падение connect rate. Тысячу ставить незачем. Затем сверяю общий пул с лимитом backend: директива ограничивает только idle-соединения, не активные.

На 5 сентября 2026 года stable Nginx — 1.30.4, mainline — 1.31.5. С 1.29.7 upstream keepalive включён по умолчанию, но образов 1.26/1.28 ещё много. Я оставляю HTTP/1.1, очищенный `Connection` и явный `keepalive`: намерение видно при ревью. Переносимый `keepalive 64` на 1.30+ может делить кеш между location; если нужна новая изоляция, пишите `keepalive 64 local`, но старые версии `local` не знают. Backend без persistent connections сначала придётся исправить или заменить.

Вторым шагом расширяю `ip_local_port_range` именно в namespace сервиса, если расчёт и тест показывают нужный запас. Нижнюю границу нельзя выбирать вслепую: сверяю локальные listeners и `ip_local_reserved_ports`. Диапазон `10240 65535` дал 55 296 значений и подошёл нашему стенду, но это не универсальный рецепт. Docker Compose умеет задавать namespaced `net.*` через `sysctls`; после redeploy я обязательно читаю значение из работающего namespace, а не верю YAML.

Третий шаг зависит от потолка. `nf_conntrack_max` — лимит flow, `buckets/hashsize` — размер хеша, не второй лимит; один flow считается один раз, но хешируется в обоих направлениях. Поднимать только max можно ценой длинных цепочек и CPU, поэтому читаю оба и считаю память. Для SNAT нужны дополнительные egress-IP либо маршрутизация; новый узел за тем же внешним NAT не поможет. `--network host` убирает Docker NAT, но не conntrack firewall, делит портовый pool с хостом и запрещает container `net.*` sysctl. Это архитектурное решение.

Я не включаю `net.ipv4.tcp_tw_reuse=1` первым движением. В актуальном Linux значение по умолчанию 2 разрешает reuse только для loopback, а документация ядра прямо советует не менять параметр без экспертного разбора. `tcp_fin_timeout` управляет orphaned FIN-WAIT-2, не TIME-WAIT; старого `tcp_tw_recycle` в современных ядрах вообще нет.
Памятка: Исправляю upstream keepalive и учитываю изменения 2026 года — схема
Памятка: Исправляю upstream keepalive и учитываю изменения 2026 года

Что оставить в мониторинге после ремонта

После успешного теста я оставляю четыре временных ряда: частоту `EADDRNOTAVAIL` в error.log, TCP states в namespace Nginx, скорость создания соединений к каждому upstream и conntrack count/max с ошибками на хосте. Одного `stub_status` мало: он показывает клиентские соединения, но не upstream pool и TIME-WAIT. Метрика должна сохранять source IP и destination IP:port, иначе перегретая пара потеряется в общей сумме.

Порог я привязываю к проверенному профилю. Для такого портового пула начинаю предупреждение примерно при 70% доступного диапазона и аварийный уровень при 85%, если рост устойчивый; для conntrack использую похожие стартовые ориентиры, но добавляю прогноз времени до заполнения. Это не стандарты Linux, а эксплуатационные границы, которые дают дежурному время. Если normal load уже держится на 65%, не поднимайте порог до 95% — уменьшайте churn или добавляйте адресное пространство.

Итого мой порядок жёсткий: точный errno и effective config; сокеты и порты внутри контейнера; затем conntrack и NAT на хосте; только потом изменение настроек. В большинстве HTTP-прокси, которые я видел, явный и рассчитанный upstream keepalive даёт больше, чем десяток сетевых sysctl. На `nofile` можно перестать смотреть, когда фактические FD низкие и в логе код 99. На NAT нельзя забить, если несколько контейнеров выходят через один адрес к одному внешнему API. Всё остальное — уже оптимизация после измерений.

Главный практический вывод: расширение лимита покупает время, повторное использование соединений снижает потребление. Сначала закрывайте причину churn, затем добавляйте запас.

Частые вопросы

Почему увеличение nofile не убирает Address not available?

Это разные ресурсы. Исчерпанный `nofile` даёт errno 24 `Too many open files`; errno 99 при `connect()` без bind означает, что нет доступного ephemeral port.

Может ли переполненный conntrack дать тот же 502?

Тот же 502/504 — да, тот же механизм — обычно нет. Полный conntrack даёт drops, SYN retransmit, timeout и сообщения ядра. Немедленный errno 99 сначала ведёт в namespace контейнера.

Сколько ставить в upstream keepalive?

Измерьте активные соединения по workers и connect rate. Если метрик нет, `RPS × средняя latency` оценит активную конкуренцию, но не размер кеша. `keepalive N` — idle connections на worker.

Нужно ли вручную включать keepalive в Nginx 1.30?

С 1.29.7 для именованного upstream default — HTTP/1.1 и 32 idle-соединения на worker. Я задаю параметры явно ради рассчитанной ёмкости и совместимости со старыми образами.

Поможет ли tcp_tw_reuse или уменьшение tcp_fin_timeout?

`tcp_fin_timeout` относится к orphaned FIN-WAIT-2, не TIME-WAIT. `tcp_tw_reuse=1` ядро не советует менять без экспертного разбора; сначала устраняйте churn через HTTP keepalive.

Найдём реальный сетевой предел
Если Nginx под нагрузкой падает в 502, команда «АйТи-Фреш» разложит путь соединения по namespace, NAT и conntrack и найдёт фактический потолок. Мы настроим keepalive и сетевые лимиты, проведём повторный нагрузочный тест и оставим метрики, по которым проблему видно до аварии.
Бесплатная консультация →
© ООО «АйТи-Фреш» · Москва · Все статьи