АйТи Фреш
Главная / Статьи / Сети
Сети

Почему Linux-шлюз исчерпывает таблицу nf_conntrack

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~19 мин чтения
Почему Linux-шлюз исчерпывает таблицу nf_conntrack
Иллюстрация к статье «Почему Linux-шлюз исчерпывает таблицу nf_conntrack».

Интернет едва занят, процессор скучает, но новые сайты не открываются, телефония срывается, а ядро пишет `nf_conntrack: table full, dropping packet`. Я, Семёнов Евгений Сергеевич, разбираю эту аварию не по мегабитам, а по состояниям потоков: какой счётчик сравнивать с лимитом, почему таблицу забивают маленькие пакеты и как найти конкретный источник без разрушительной очистки всех соединений.

Канал здесь вообще ни при чём

nf_conntrack учитывает состояния сетевых потоков, а не загрузку интерфейса. Один короткий UDP-пакет может занять запись на десятки секунд, хотя передал сотню байт. Поэтому тысяча новых запросов в секунду способна заполнить таблицу быстрее, чем оператор заметит трафик на графике. В этот момент уже установленные соединения иногда продолжают работать, а новые открываются через раз: для знакомого потока запись существует, для нового ядру нечего выделить.

В современных ядрах, включая ветку Linux 6.12, при превышении nf_conntrack_max ядро сначала пытается вытеснить подходящую запись. Если это не удаётся, оно отказывает в создании состояния, увеличивает статистику потерь и выводит сообщение с ограничением частоты. Проверяю это так:

sudo sysctl net.netfilter.nf_conntrack_count \
  net.netfilter.nf_conntrack_max \
  net.netfilter.nf_conntrack_buckets
cat /proc/sys/net/netfilter/nf_conntrack_count
sudo conntrack -C
sudo conntrack -S
sudo journalctl -k -g 'nf_conntrack.*table full'

Главная пара — nf_conntrack_count и nf_conntrack_max; тот же счётчик лежит в /proc/sys/net/netfilter/nf_conntrack_count, а conntrack -C по man conntrack(8) показывает счётчик таблицы. buckets влияет на устройство хеш-таблицы и скорость поиска, но не является непосредственно проверяемым лимитом записей.

Если шлюз уже роняет бизнес-трафик, я сначала фиксирую показатели и при наличии памяти временно увеличиваю nf_conntrack_max. Это возвращает время для расследования. Затем ищу доминирующий протокол, адрес и состояние. Перезагрузка шлюза или conntrack -F — плохая первая реакция: симптом исчезнет, доказательства тоже, а действующие NAT-сопоставления и сессии оборвутся.

Сообщение `table full` означает дефицит записей состояний. Оно не доказывает нехватку пропускной способности, мощности процессора или NAT-портов.
Порядок действий: Канал здесь вообще ни при чём — схема
Порядок действий: Канал здесь вообще ни при чём. Открыть схему в полном размере

Что ядро считает одним соединением

Одна запись — это двунаправленный поток, который ядро описывает исходным и ответным кортежами: семейство адресов, протокол, адреса, порты или соответствующие протоколу идентификаторы, а при использовании — ещё и зона conntrack. Для TCP это похоже на привычную пятёрку src/dst/protocol/sport/dport. UDP не имеет рукопожатия, но conntrack всё равно создаёт для него временное состояние. Отслеживаются также ICMP, ICMPv6, SCTP, GRE и другие поддерживаемые протоколы.

В документации ядра сказано, что запись добавляется в хеш-таблицу дважды: для original и reply. Здесь регулярно ошибаются. Это не две единицы в nf_conntrack_count, а один объект потока с двумя узлами поиска. Поэтому один TCP-сеанс обычно увеличивает счётчик на единицу. SNAT или DNAT также не создаёт вторую запись: преобразование хранится в том же состоянии. Зато отдельное RELATED-соединение, например канал данных протокола с helper, получит собственную запись.

nf_conntrack_count показывает число выделенных flow-объектов в текущем сетевом пространстве имён. Туда могут кратковременно попадать ещё не подтверждённые и уже уничтожаемые объекты, поэтому результат иногда немного выше числа строк, возвращённых conntrack -L или conntrack -C. Большая и устойчивая разница заслуживает отдельного расследования, но обычно она мала. В контейнерной инфраструктуре команда из контейнера и команда на хосте могут показать разные значения — я всегда проверяю путь пакета и нужный namespace.

Не сравнивайте `nf_conntrack_count` с количеством TCP-сокетов из `ss`. Conntrack видит транзитный трафик и UDP, а `ss` показывает сокеты локального сетевого стека.
Почему Linux-шлюз исчерпывает таблицу nf_conntrack — схема
Схема к статье. Открыть схему в полном размере

Почему записи накапливаются

Рабочая оценка проста: число записей примерно равно скорости появления новых потоков, умноженной на среднее время их жизни, плюс долгоживущие состояния. Если приложение создаёт 800 новых UDP-кортежей в секунду, а тайм-аут равен 90 секундам, получается около 72 000 записей. Такой поток маленьких DNS-пакетов займёт считаные мегабиты в секунду, но шлюз с лимитом 65 536 уже ляжет.

Документация актуального ядра указывает базовые значения: nf_conntrack_udp_timeout — 30 секунд, nf_conntrack_udp_timeout_stream — 120, TCP SYN_SENT — 120, TIME_WAIT — 120, CLOSE_WAIT — 60, ESTABLISHED — 432 000 секунд, то есть пять суток; nf_conntrack_icmp_timeout — 30, nf_conntrack_generic_timeout для неизвестных L4-протоколов — 600. «Потоковым» UDP становится после обмена в обе стороны, тогда и действует 120 секунд. Таймер обновляется подходящими пакетами. Записи копятся из-за запросов к мёртвому адресу, повторных попыток, сканирования, незакрытых TCP-сессий и асимметричной маршрутизации, при которой ответ проходит через другой шлюз. Последний случай часто выдаёт масса [UNREPLIED].

Из раза в раз вижу два неверных лечения. Первое — поставить nf_conntrack_max=2000000 и забыть. Авария отложена, источник продолжает шуметь, память и длина хеш-цепочек растут. Второе — глобально сократить nf_conntrack_tcp_timeout_established до часа. Это может разрушить состояние тихих SSH-, VPN- или прикладных соединений. Я меняю тайм-аут только после классификации записей; предпочтительнее исправить генератор трафика или применить отдельную timeout policy к известному классу потоков.

Пятеричный TCP-тайм-аут в пять суток выглядит страшно, но сам по себе редко является причиной аварии. Без высокой скорости появления или большого числа реально долгих потоков таблица не заполнится.
Цифры и версии: Почему записи накапливаются — схема
Цифры и версии: Почему записи накапливаются. Открыть схему в полном размере

Стенд «Зодчий сада»: 65 тысяч записей от DNS

Разберу случай из практики, название условное: студия ландшафтного дизайна «Зодчий сада», 32 рабочих места — проектировщики с тяжёлыми CAD- и 3D-файлами, сметный отдел, бухгалтерия и планшеты бригад, которые подключаются к офису через WireGuard. Все выходят через виртуальный шлюз с 2 vCPU, 4 ГиБ RAM и двумя virtio-net по 1 Гбит/с. На шлюзе стояли Debian 13, ядро серии 6.12, nftables 1.1.3 и conntrack-tools 1.4.8. Он делал SNAT в интернет, маршрутизировал VLAN офиса, гостевого Wi-Fi и видеонаблюдения питомника, держал WireGuard для бригад. Лимит никто не задавал: ядро само выставило 65 536 buckets для машины больше 1 ГиБ памяти, max равен им по умолчанию.

После обновления клиента синхронизации сметной программы проектировщики пожаловались, что новые HTTPS-соединения и видеозвонки с заказчиками открываются со второй-третьей попытки, а облачное хранилище с чертежами «отваливается». Максимум на внешнем интерфейсе был 38 Мбит/с, CPU — 14 %. Зато диагностика показала почти точное упирание в предел:

net.netfilter.nf_conntrack_count = 65531
net.netfilter.nf_conntrack_max = 65536
net.netfilter.nf_conntrack_buckets = 65536

nf_conntrack: table full, dropping packet

За десять минут сумма дельт insert_failed и drop выросла на 18 427, early_drop — на 617. Это уже не предположение, а подтверждённая потеря новых состояний.

Один снимок обеих таблиц семейств с закрытыми правами показал картину: 91 % записей были UDP, 84 % всех записей шли на 10.77.0.53:53, почти все с [UNREPLIED]. Двадцать восемь рабочих станций и двенадцать планшетов в сумме создавали 730–780 новых DNS-кортежей в секунду — около 18–20 на устройство. В старом файле настройки шлюза дополнительно сохранилось nf_conntrack_udp_timeout=90. Получалось до 70 тысяч живых записей при трафике около 1,8 Мбит/с.

Причина оказалась двойной. Внутренний DNS перенесли с 10.77.0.53 на 10.77.0.54, а новая версия клиента синхронизации держала старый адрес в собственном конфиге и на каждый ретрай открывала новый UDP-сокет с новым исходным портом. Сначала я временно поднял лимит до 262 144, затем исправил адрес в централизованной конфигурации и перезапустил клиентов группами. Тайм-аут вернул к штатным 30 секундам. Через две минуты счётчик снизился до 5 200; после наблюдения закрепили max и buckets по 131 072 — памяти на это хватает с большим запасом. За следующие 14 суток пик составил 9 400, а insert_failed больше не рос.

Временное увеличение лимита было осознанной мерой восстановления, а не окончательным исправлением. Без замены DNS-адреса таблица заполнилась бы снова.

Как я нахожу виновника

Сначала снимаю лёгкие показатели дважды с интервалом в минуту. Так видна скорость роста, а не случайная фотография. conntrack -S выдаёт строки по CPU, поэтому сравниваю сумму дельт, особенно insert_failed, drop, early_drop и search_restart. Одновременно проверяю доступную память и размер slab-кеша:

sudo sysctl net.netfilter.nf_conntrack_count \
  net.netfilter.nf_conntrack_max \
  net.netfilter.nf_conntrack_buckets
sudo conntrack -S
free -h
sudo grep '^nf_conntrack ' /proc/slabinfo

Универсальной стоимости одной записи в байтах нет: она зависит от архитектуры, сборки ядра и расширений accounting, timestamp, labels и NAT. Поэтому цифру «320 байт навсегда» я не использую.

Если таблица большая, делаю один дамп и работаю с файлом. В нём есть внутренние адреса, поэтому задаю права и после расследования удаляю:

sudo sh -c 'umask 077; { 
  conntrack -L -f ipv4 -o extended 2>/dev/null
  conntrack -L -f ipv6 -o extended 2>/dev/null
} > /run/conntrack.snapshot'

sudo grep -oE 'UNREPLIED|SYN_SENT|SYN_RECV|ESTABLISHED|TIME_WAIT' \
  /run/conntrack.snapshot | sort | uniq -c | sort -nr

sudo awk '{
  src=""; dst=""; dport=""
  for (i=1; i<=NF; i++) {
    if (src=="" && $i ~ /^src=/) src=$i
    if (dst=="" && $i ~ /^dst=/) dst=$i
    if (dport=="" && $i ~ /^dport=/) dport=$i
  }
  print src, dst, dport
}' /run/conntrack.snapshot | sort | uniq -c | sort -nr | head -30

Первое вхождение src, dst и dport относится к original-направлению. Этого обычно достаточно, чтобы увидеть один адрес или сервис, который создаёт львиную долю состояний.

Дальше проверяю гипотезу пакетным захватом на нужном интерфейсе, маршрутом до назначения и обратным маршрутом. Не надо сразу захватывать весь гигабитный интерфейс: фильтр по найденному хосту и порту даст ответ быстрее. После работы удаляю снимок командой sudo rm -f /run/conntrack.snapshot. Если подозреваю короткий всплеск, на минуту запускаю conntrack -E -e NEW, но помню: при высокой скорости событий Netlink-буфер может переполниться, поэтому отсутствие всех событий не доказывает отсутствие потоков.

Полный дамп на сотнях тысяч записей расходует CPU и создаёт большой Netlink-ответ. Не ставьте `conntrack -L` в `watch` с секундным интервалом.
Порядок действий: Как я нахожу виновника — схема
Порядок действий: Как я нахожу виновника. Открыть схему в полном размере

NOTRACK для DNS и мониторинга: где уместно

Исключение из conntrack — не способ «лечить» переполнение, а инструмент для трафика, которому состояние не нужно в принципе. Типичные кандидаты — сам шлюз, отвечающий как DNS-резолвер для офиса, и частые опросы мониторинга: SNMP, ICMP-проверки, Zabbix или Prometheus с десятков точек. Каждый такой запрос без NOTRACK создаёт запись, которая живёт 30–120 секунд. На маленьком шлюзе «Зодчего сада» это не проблема, а на узле, который опрашивает сотни хостов, счёт идёт на десятки тысяч. Правило должно сработать раньше, чем conntrack, который регистрируется на приоритете −200, — поэтому цепочка с priority raw (−300):

table inet raw {
  chain prerouting {
    type filter hook prerouting priority raw; policy accept;
    iifname "lan0" udp dport 53 ip daddr 10.77.0.1 notrack
    iifname "lan0" tcp dport 53 ip daddr 10.77.0.1 notrack
  }
  chain output {
    type filter hook output priority raw; policy accept;
    oifname "lan0" udp sport 53 notrack
    oifname "lan0" tcp sport 53 notrack
  }
}

На старых системах с iptables то же делается целью CT --notrack в таблице raw; устаревшая цель NOTRACK — её алиас:

sudo iptables -t raw -A PREROUTING -i lan0 -p udp --dport 53 -j CT --notrack
sudo iptables -t raw -A OUTPUT -o lan0 -p udp --sport 53 -j CT --notrack

Исключать нужно оба направления: запрос на входе в prerouting и ответ шлюза в output, иначе ответ всё равно создаст запись. Дальше обязательная часть, о которой забывают: пакеты получают состояние untracked, и правило ct state established,related accept их не пропустит. В фильтре нужно явно разрешить ct state untracked для этих портов, иначе после «оптимизации» перестанет работать сам DNS. К трафику, который проходит через SNAT/DNAT, NOTRACK неприменим: без записи нет и трансляции адресов. Транзитный DNS пользователей к внешним резолверам через NAT исключать нельзя — там помогает только локальный кеширующий резолвер на шлюзе.

Внимание: NOTRACK на трафик с DNAT (например, проброс порта или Kubernetes Service) ломает трансляцию — пакеты уйдут на исходный адрес и потеряются. Перед включением проверьте, что для этого потока в `nft list ruleset` нет ни `dnat`, ни `masquerade`.

Docker и Kubernetes-узлы: свой счётчик и чужие sysctl

На контейнерных хостах conntrack загружен практически всегда: Docker публикует порты через DNAT и маскарадит исходящий трафик контейнеров, kube-proxy строит Service через DNAT в режимах iptables и nftables. Счётчик nf_conntrack_count в исходниках 6.12 ведётся для каждого network namespace отдельно и сравнивается с nf_conntrack_max, а nf_conntrack_buckets можно менять только из начального namespace. Трафик контейнера, выходящий через bridge и NAT хоста, учитывается в namespace хоста — поэтому команда conntrack -C внутри пода ничего не скажет о переполнении узла. Смотреть нужно на хосте или через nsenter -t 1 -n.

У Kubernetes есть ловушка, которую я встречаю регулярно: kube-proxy при старте сам пишет sysctl. По справочнику kube-proxy --conntrack-max-per-core по умолчанию 32768, --conntrack-min — 131072, итоговый лимит — большее из «ядра × 32768» и минимума; --conntrack-tcp-timeout-established — 24 часа, --conntrack-tcp-timeout-close-wait — 1 час. Значения из /etc/sysctl.d будут перезаписаны при каждом рестарте kube-proxy, так что менять их нужно в его конфигурации. Вторая типовая беда — UDP DNS подов: каждый запрос к CoreDNS проходит DNAT и оставляет запись на 30 секунд, а параллельные A/AAAA-запросы дают гонки при вставке и рост insert_failed. Документация Kubernetes прямо рекомендует NodeLocal DNSCache, который обходит DNAT и conntrack и переводит обращения к CoreDNS на TCP.

Не выполняйте `conntrack -F` на рабочем узле Kubernetes или Docker-хосте: вместе с записями исчезнут NAT-трансляции Service и опубликованных портов, и активные соединения к подам оборвутся. Удаляйте записи точечно, например `conntrack -D -p udp --dport 53`, и только после снятия снимка.

Как задать лимит и не перенести проблему

При аварии и достаточном MemAvailable лимит можно увеличить без очистки существующих записей:

sudo sysctl -w net.netfilter.nf_conntrack_max=262144

Это изменение обратимо и начинает действовать сразу. Я наблюдаю память, загрузку CPU и статистику ошибок, пока устраняю источник. Если память уже под давлением, слепое увеличение опасно. Полный conntrack -F оставляю для согласованного аварийного окна: он удаляет состояния firewall и NAT, поэтому активные соединения могут оборваться.

Полезно понимать, откуда берётся стартовое значение. В nf_conntrack-sysctl.rst сказано, что без параметра модуля hashsize число buckets считается от объёма памяти (делением на 16384), не меньше 1024 и не больше 262 144, а nf_conntrack_max по умолчанию равен nf_conntrack_buckets. В исходниках 6.12 это выглядит конкретнее: на 64-битной системе с памятью больше 4 ГиБ сразу ставится 262 144, больше 1 ГиБ — 65 536, на маленьких машинах — производная от RAM, но не ниже 1024. Поэтому шлюз на 2 и на 4 ГиБ получает одинаковые 65 536, а добавление памяти в ВМ до 8 ГиБ после перезагрузки молча учетверяет лимит. Размер хеша можно задать и при загрузке модуля:

echo 'options nf_conntrack hashsize=131072' | sudo tee /etc/modprobe.d/nf_conntrack.conf
cat /sys/module/nf_conntrack/parameters/hashsize

Если явно задан hashsize, ядро исходит из прежнего множителя 8, и max по умолчанию станет в восемь раз больше buckets — я всё равно фиксирую max явно в sysctl.

Постоянный предел рассчитываю от измерений: пиковая скорость новых потоков умножается на характерное время жизни, отдельно прибавляются долгоживущие соединения. Затем даю запас минимум в два раза; для нестабильной нагрузки — в три-четыре. В «Зодчем сада» после исправления пик 9 400 позволял оставить и 65 536, но при 4 ГиБ памяти я закрепил 131 072 с запасом на рост. Размер хеш-таблицы я держу того же порядка, что и max: документация ядра по умолчанию делает их равными и прямо предупреждает, что запись попадает в хеш дважды, поэтому заполненная таблица при равных значениях даёт среднюю длину цепочки 2. Слишком мало buckets при огромном max увеличивает цепочки и стоимость поиска.

Итоговый файл на этом шлюзе выглядел так:

# /etc/sysctl.d/90-nf-conntrack.conf
net.netfilter.nf_conntrack_buckets = 131072
net.netfilter.nf_conntrack_max = 131072
net.netfilter.nf_conntrack_udp_timeout = 30
net.netfilter.nf_conntrack_udp_timeout_stream = 120

Применили через sudo sysctl --system и обязательно проверили значения после перезагрузки. Изменение buckets означает перестройку хеша; на загруженном боевом шлюзе я планирую это на окно работ. Если команда выполняется внутри контейнера, изменение buckets может быть запрещено: документация разрешает запись только из начального network namespace.

В мониторинг ставлю заполнение таблицы и дельты ошибок. Предупреждение — 70 %, критический уровень — устойчивые 85–90 %; это мой эксплуатационный выбор, а не правило ядра. insert_failed > 0 требует разбора даже после автоматического восстановления. Для этого достаточно двух чисел — nf_conntrack_count и nf_conntrack_max — и дельты insert_failed из conntrack -S; снимать полный дамп агентом мониторинга не нужно, и для контейнерных узлов метрику собираю в корневом namespace хоста.

Увеличение `nf_conntrack_max` лечит дефицит ёмкости, но не объясняет его. Если таблица растёт почти линейно, новый предел лишь переносит момент следующей аварии.

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

Какой лимит вызывает `nf_conntrack: table full`?

Ядро сравнивает число выделенных flow-записей с `net.netfilter.nf_conntrack_max`. `nf_conntrack_buckets` задаёт размер хеш-таблицы и влияет на поиск, но это не лимит соединений.

Один TCP-сеанс считается два раза из-за original и reply?

Нет. Это один объект conntrack и обычно одна единица `nf_conntrack_count`. Original и reply помещаются в хеш как два направления поиска.

Почему `conntrack -L` показывает меньше, чем `nf_conntrack_count`?

Sysctl считает выделенные flow-объекты, включая краткоживущие unconfirmed или dying. `conntrack -L` обычно показывает подтверждённую основную таблицу. Небольшая разница нормальна; большая устойчивая разница требует проверки namespace и обработки пакетов.

Можно ли сразу выполнить `conntrack -F`?

Технически можно, но это удалит все состояния и NAT-сопоставления, оборвёт часть активных соединений и уничтожит данные для диагностики. Я сначала снимаю показатели и дамп, а затем временно увеличиваю лимит, если хватает памяти.

Какой `nf_conntrack_max` поставить для 1 Гбит/с?

По скорости порта это не рассчитывается. Нужны пиковая скорость создания новых потоков, их тайм-ауты, число долгоживущих соединений и запас. Два шлюза на 1 Гбит/с могут требовать 32 тысячи и миллион записей соответственно.

Поможет ли `notrack`?

Иногда, для заранее известного stateless-трафика без NAT. Но пакет станет `UNTRACKED`, а правила `ct state established,related` его не пропустят автоматически. Исключение нужно проектировать вместе с явными правилами обоих направлений.

Какой лимит ядро ставит по умолчанию?

`nf_conntrack_max` равен `nf_conntrack_buckets`, а buckets считаются от памяти. В ядре 6.12 на 64-битной машине больше 4 ГиБ это 262 144, больше 1 ГиБ — 65 536, меньше — производная от RAM, но не ниже 1024. Kubernetes-узлы переопределяет kube-proxy.

Стоит ли уменьшать `nf_conntrack_tcp_timeout_established` с 432000?

Только по результатам классификации записей. Глобальное сокращение рвёт тихие долгие сессии; надёжнее найти источник или назначить отдельную `ct timeout`-политику в nftables для конкретного класса трафика.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 💬 MAX: +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи