У нас было подключено 63 000 IoT-устройств, когда все просто… остановилось. Не было аварии. Не было ошибки. Все просто застыло на 63 000, словно мы уперлись в какой-то невидимый потолок.

Новые устройства пытались подключиться. Отказ в подключении. Пробовали снова. То же самое. Мои графики показывали загрузку процессора на 15%, использование памяти — 8 ГБ из 64 ГБ доступных. Вроде проблемы не было. Но на самом деле она была. 

Я три дня был уверен, что проблема кроется в моем коде. Проверил пулы соединений с базой данных — они были не при чем. Переписал обработчики сообщений — ничего не изменилось. Начал подозревать балансировщик нагрузки — дело было не в нем. Самая кошмарная отладка — это та, когда начинаешь сомневаться, понимаешь ли ты вообще, как работают компьютеры.

В конце концов нашел причину: ulimit -n показывал 65 536. Каждое сетевое подключение в Linux — это файловый дескриптор (FD). Достигаешь этого лимита — и ядро просто перестает принимать что-то новое. Попытки установить соединение вызывали ошибки EMFILE, которые мой цикл accept() даже не логировал. Клиенты получали отказ в подключении. Мой сервер считал, что все в порядке, потому что сбои происходили еще до того, как код мог их обнаружить.

А что было самое поразительное? Да то, что я сам установил этот лимит шесть месяцев назад. Выполнил команду и почувствовал себя щедрым. «64 тысячи файловых дескрипторов? Куда больше, чем нам понадобится!» Тот прошлый я был так уверен в себе. И так ошибся. 

Почему наша проблема оказалась такой странной (и почему мы не могли просто «горизонтально масштабироваться»)

Наша рабочая нагрузка была странной: миллионы в основном бездействующих WebSocket-соединений, остающихся открытыми неделями. Промышленный IoT-мониторинг — датчики температуры на складах, трекеры влажности в дата-центрах, мониторы оборудования на заводах. Каждое устройство поддерживало одно соединение, отправляло JSON-объект при изменении показаний и снова замолкало.

Большинство систем работают по-другому. У них есть HTTP-запросы, которые приходят и уходят, запросы к базам данных, короткие всплески активности. Наши устройства должны были быть постоянно подключенными, потому что мы продавали «мониторинг в реальном времени», а не принцип «проверяйте через каждые 30 секунд в надежде, что ничего не сломалось».

Все говорили: «просто масштабируйтесь горизонтально». Запустите 100 серверов по 50 тысяч соединений каждый, балансируйте нагрузку — и готово.

Что ж, хорошо, но вот во что это выливается: один экземпляр c6i.8xlarge в AWS в нашем регионе обходился примерно в $800 в месяц. Подход со 100 серверами? Около $8000 ежемесячно. Даже если я ошибся на 20-30%, это всё равно в 10 раз дороже.

К тому же каждый дополнительный сервер означает больше агентов мониторинга, больше конвейеров для логов, больше сложностей с DNS, больше TLS-рукопожатий. Балансировщику нагрузки требуется собственная избыточность. Клиентский код должен работать со 100 разными конечными точками. Нам пришлось бы как-то шардировать соединения. Как именно? По ID устройства? По географическому признаку? Что произойдет, если устройства будут перемещаться?

Математика была ясна. Достичь этого не представлялось возможным.

Две недели оптимизации в неправильном направлении 

Я поступил так, как поступает любой разработчик, когда все ломается по непонятной причине: начал оптимизировать код приложения. Переписал обработчики, заменил JSON на MessagePack, настраивал пулы соединений, которые даже не были «горячими». Каждый профилировщик показывал: «все в порядке». Каждый график говорил: «все в порядке». Не в порядке оказалось только мое понимание того, что на самом деле происходит.

Затем я прочитал старые инструкции по настройке — знаете, ту документацию, которую пишешь для будущего, а в настоящем не читаешь — и увидел заметку про ulimits. Просто случайно запустил ulimit -n в продакшене.

65536

Ох.

Поднял до 6 миллионов. Перезапустил. Почувствовал себя умным. Смотрел, как сервер принимает 200 000 соединений, а потом замирает уже совершенно по-новому.

Очередь на принятие TCP-соединений переполнялась. В ядре есть параметр под названием somaxconn, который определяет, сколько установленных соединений может ждать в очереди, прежде чем их обработает приложение. По умолчанию — 128. При высокой частоте подключений эта очередь заполняется за миллисекунды. Рукопожатие завершается, соединение пытается встать в очередь, очередь переполнена — ядро его отбрасывает.

Это происходит после трехэтапного рукопожатия, но до того, как accept() это увидит. Чистый отказ на уровне ядра. Никаких логов. Никакой видимости. Просто тихий сбой.

Тогда я и понял: у ядра есть свои представления о лимитах соединений, и эти представления «живут» в конфигурационных файлах, разбросанных по всей системе. Их никто никогда не трогает.

Что на самом деле помогло (и почему эти цифры важны)

Вот что сработало после недель тестирования. Эти настройки специфичны для нашей рабочей нагрузки — ваши цифры будут другими. Но понимание, почему каждый параметр важен, значит больше, чем точные значения.

Ограничения на количество файловых дескрипторов:

fs.file-max = 6000000  #  системный лимит, должен иметь запас выше целевого значения
* soft nofile 6000000  #  лимит на процесс, который нас подвел
* hard nofile 6000000

Установка соединений под нагрузкой:

net.core.somaxconn = 4096  # завершенные квитирования (handshakes), ожидающие accept()
net.ipv4.tcp_max_syn_backlog = 8192  # полуоткрытые соединения (half-open) во время квитирования
net.core.netdev_max_backlog = 5000  # очередь сетевого интерфейса (NIC) перед обработкой ядром

Мы постоянно видели «TCP: Possible SYN flooding» в dmesg, пока не увеличили syn backlog до 8192. При нашей тестовой скорости 10 тысяч новых соединений в секунду именно это значение остановило предупреждения.

Верхние границы буферов (не фактические выделения):

net.core.rmem_max = 16777216  # Максимальный размер буфера приема: 16 МБ
net.core.wmem_max = 16777216  # Максимальный размер буфера отправки: 16 МБ
net.ipv4.tcp_rmem = 4096 87380 16777216  # мин., по умолч., макс.
net.ipv4.tcp_wmem = 4096 65536 16777216

Когда я впервые увидел «16MB на сокет», я запаниковал: 5M × 16MB = 80TB оперативной памяти. Но это всего лишь верхние границы. TCP «договаривается» об уменьшении в зависимости от фактического использования. Наши простаивающие соединения использовали около 4KB. Во время всплесков данных — возможно, 256KB. 

Максимум в 16MB нужен для автоматической настройки TCP при высокой пропускной способности — для нас это редкость, но когда случается, становится критичным. 

После перезагрузки (этого требует limits.conf) ядро смогло отслеживать миллионы соединений. Но мое приложение все еще не могло эффективно с ними работать, потому что я мыслил в терминах блокирующего ввода-вывода.

Почему epoll изменил все

Традиционный ввод-вывод работает так: вы вызываете select() со своим списком FD, ядро перебирает каждый из них, проверяя, готовы ли данные. Сложность O(n).

При 5 тысячах соединений это работает медленно. При 500 тысячах ваш процессор крутится вхолостую, проверяя одни и те же бездействующие сокеты: «Сокет 47,382 готов? №47,383? №47,384? Нет…» — в то время как данные ждут в буферах.

epoll работает иначе. Один раз регистрируете FD через epoll_ctl(). Затем epoll_wait() возвращает только активные. Ядро использует красно-черное дерево и список готовых дескрипторов — операции O(1). Ваше вмешательство нужно только тогда, когда что-то требует внимания.

Перешел на I/O и обработку соединений на основе epoll, и нагрузка на процессор упала на 75%. Та же самая рабочая нагрузка. Просто изменился способ обращения к ядру.

Go скрывает сложность epoll, что одновременно и здорово, и опасно:

// Работает при 10К-50К подключений,
// при миллионах это будет для вас катастрофой
func acceptConnections(listener net.Listener) {
    for {
        conn, err := listener.Accept()  // ожидает подключения
        if err != nil {
            log.Printf("error: %v", err)  // логируйте их!
            continue
        }
        go handleClient(conn)  // горутина на соединение - не масштабируется
    }
}

func handleClient(conn net.Conn) {
    defer conn.Close()  // очистка
    buf := make([]byte, 4096)  // 4KB × 5M = 20GB только на буферах
    for {
        n, err := conn.Read(buf)  // внутри передает управление epoll
        if err != nil { return }
        processMessage(buf[:n])
    }
}

Это прекрасно работает при десятках тысяч соединений. При миллионах те самые 4 КБ на соединение превращаются в 20 ГБ, которые в основном простаивают. Каждая горутина занимает несколько килобайт стека. Вы платите памятью за неиспользуемые ресурсы.

Что делает Go — умно: conn.Read() не блокирует поток ОС. Он регистрируется в epoll и передает управление горутине. Когда данные прибывают, epoll запускает планировщик, который возобновляет работу этой горутины. Такой способ позволяет мультиплексировать сотни тысяч горутин на небольшое количество потоков.

Для масштаба в 5 миллионов соединений уже нужны пулы буферов и другие паттерны. Структура решения важнее, чем точная реализация.

Три среды разработки, три разные проблемы

Python + asyncio. Быстрая разработка. Но примерно на 35 тысячах соединений RSS достигла 12 ГБ, а задержки подскочили за 200 мс. Каждое соединение — это Python-объект с накладными расходами. GIL становится реальной проблемой. Можно было бы продвинуться дальше, но пришлось бы бороться с архитектурой Python.

Go. То, что было реализовано. На 5 миллионах соединений с настройками по умолчанию мы наблюдали паузы сборки мусора по 50–100 мс каждые 30–40 секунд. После внедрения пулов буферов и настройки GOGC на значение 50 паузы сократились до 20–40 мс. Заметно, но приемлемо. Мы выпустили продукт по графику.

Rust + Tokio. Создали прототип. Те же 5 миллионов соединений при 16 ГБ RSS против 23 ГБ у Go. P99-задержка ниже 5 мс даже во время всплесков соединений. Невероятная производительность — время разработки в 3 раза дольше. Два дня потратили на отладку use-after-free в пуле буферов. Контроллер заимствований предотвратил катастрофы в продакшене, но сильно замедлил весь процесс.

Rust был быстрее. С Go выпустили на три недели раньше. Сроки выпуска оказались важнее.

Но самое важное оказалось вот что: SO_REUSEPORT.

// Масштабирование на многоядерных системах с использованием SO_REUSEPORT
async fn start_server() -> std::io::Result<()> {
    let socket = Socket::new(Domain::IPV4, Type::STREAM, None)?;
    socket.set_reuse_port(true)?;  // несколько процессов, один порт
    socket.bind(&"0.0.0.0:8080".parse().unwrap())?;
    socket.listen(4096)?;  // соответствует somaxconn
    let listener = TcpListener::from_std(socket.into())?;
    loop {
        let (stream, _) = listener.accept().await?;
        tokio::spawn(async move { /* handle */ });
    }
}

Несколько процессов привязываются к одному порту. Ядро автоматически балансирует нагрузку. Никаких пользовательских прокси, никакой конкуренции. Запускаем по процессу на ядро, позволяем ядру осуществлять распределение. 

До: один цикл epoll обрабатывал 5 миллионов соединений в одном потоке, 15 ядер простаивали. После: 16 циклов, каждый управляет примерно 300 тысячами соединений, каждое ядро работает.

Исчезнувшая память

Достигли 2 миллионов соединений в тестах. Все выглядело хорошо. Затем: ошибка OOM.

RSS приложения: 2.3 ГБ. Системная память: использовано 59 ГБ. Куда делись 57 ГБ?

Потратил день на поиск утечек. Запускал Valgrind, логировал выделение памяти. Мое приложение действительно использовало только 2.3 ГБ. Память была не в пользовательском пространстве.

Ее использовало ядро. У каждого TCP-соединения есть состояние в ядре: машина состояний TCP, буферы, информация о маршрутизации, отслеживание соединений. Из slabtop:

At 2M connections:
tcp_sock:      ~1.5KB  →  ~3.0GB
inet_sock:     ~0.7KB  →  ~1.4GB
nf_conntrack:  ~0.9KB  →  ~1.8GB

Примерно 3.1 КБ на соединение без учета буферов. При 2 миллионах — это 6.2 ГБ только для отслеживания состояния.

nf_conntrack добил меня — мы не использовали iptables по существу, но отслеживание соединений было включено по умолчанию, потребляя 1.8 ГБ впустую.

Исправление:

iptables -t raw -A PREROUTING -p tcp --dport 8080 -j NOTRACK
iptables -t raw -A OUTPUT -p tcp --sport 8080 -j NOTRACK

Освободили гигабайты мгновенно. Начали отслеживать снимки slabtop и количество tcp_sock/nf_conntrack в каждом нагрузочном тесте. Когда эти цифры расходились с ожиданиями, мы немедленно разбирались в проблеме. 

Также настроили keepalive:

net.ipv4.tcp_keepalive_time = 300  # 5 минут, не 2 часа
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 3

«Неживые» соединения теперь очищаются за 6 минут вместо 2+ часов. При 5 миллионах каждый «зомби»-объект имеет значение.

Когда стандартные инструменты ломаются

Достигли 5 миллионов ESTABLISHED-соединений на стейджинге. Сервер чувствовал себя хорошо — 19% CPU, стабильная память.

Попытался отладить одно проблемное соединение. Запустил netstat. Терминал завис на 90 секунд, затем вывалил 5 миллионов строк. Попробовал ss — лучше, но медленно. Запустил tcpdump — утонул в шуме.

Стандартные UNIX-инструменты рассчитаны на сотни или тысячи соединений, а не на миллионы.

Написал пользовательские eBPF-программы для наблюдения за конкретными паттернами, чтобы не тонуть в шуме. Обнаружил баг в ядре 4.19 при ~4.7 миллионах соединений — переработка TIME_WAIT вызывала случайные зависания на 30+ секунд. После обновления до 5.4 проблема исчезла. Обнаружил ее только благодаря недельному A/B-тестированию, так как симптомы были непостоянными.

Развиваешь странное терпение, отлаживая проблемы, существующие только в масштабах, которые твой ноутбук не может воспроизвести. Проблемы, которые невозможно протестировать модульно, невозможно увидеть локально, они видны только в продакшене при определенной нагрузке.

Но это сработало: 100 серверов ($8000/месяц, операционный кошмар) превратились в 3 сервера с резервированием ($2400/месяц, действительно понятная архитектура). Устройства оставались подключенными, дэшборды обновлялись в реальном времени, затраты упали на 70%.

Что я понял, когда все заработало

Когда Grafana показала 5 000 000 ESTABLISHED-соединений и нагрузку CPU на уровне 18%, это не было похоже на победу в бенчмарке. У меня было такое чувство, будто я наконец начал понимать разговор, который в течение месяца вел неправильно — осознал, что ядро на самом деле может делать, чего не может, и почему.

Потом мы заметили, что следующее узкое место — не соединения, а копирование байтов. Обработка миллионов крошечных сообщений означает, что ядро выполняет шокирующий объем memcpy между буферами. Вот где начинают иметь значение технологии с нулевым копированием, такие как sendfile(), когда ты задаешься вопросом, должно ли ядро вообще касаться «горячих» данных.

За пределами определенного масштаба ты уже не выстраиваешь ничего поверх ОС — ты начинаешь с ней договариваться. Понимаешь ее «мнение», работаешь в установленных ею рамках, иногда борешься с предположениями о «нормальных» нагрузках. Это изнурительно и временами бесит, когда крайние случаи с ядром случаются только при паттернах трафика, которых твой ноутбук никогда не заметит.

Но в ту ночь, когда Grafana показала 5 000 000 и 18% загрузки CPU, все стало на свои места. Тогда я осознал главное: нужно уметь говорить на языке ядра вместо того, чтобы кричать на него из пользовательского пространства.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи The Speed Engineer: How to Engineer a Single Backend Server for 5M Concurrent Connections

Предыдущая статьяНавыки фронтенд-разработчика, которые будут важны в 2026 году