Тюнинг Linux-сервера для 100 000 одновременных соединений: кейс игровой студии

Исходная ситуация: игровой бэкенд не справляется с нагрузкой

Игровая студия ГеймДев — команда из 25 человек, разрабатывающая мобильную MMORPG. В бета-тесте участвовали 15 000 игроков, но при открытом запуске ожидали 100 000+ одновременных подключений. Бэкенд: Go-сервер (game logic), nginx (WebSocket proxy), PostgreSQL 16 (игровые данные), Redis (кеш сессий).

Серверы: 2 bare-metal в Hetzner — 64 vCPU (AMD EPYC 7763), 256 GB RAM, 2x NVMe SSD 1.92 TB в RAID 1. Операционная система: Ubuntu 24.04 LTS.

Нагрузочное тестирование показало проблемы уже на 30 000 соединений:

# Тест через wrk + lua-скрипт для WebSocket
./wrk -t12 -c30000 -d60s --timeout 10s http://game.example.com/ws

# Результат:
# 30000 connections
# Requests/sec:  12456 (ожидали 50000+)
# Errors: connect 8432, timeout 3201
# Latency avg: 890ms (ожидали <50ms)

# Ошибки в dmesg:
dmesg | tail -20
# [14523.456] nf_conntrack: table full, dropping packet
# [14524.789] TCP: request_sock_TCP: Possible SYN flooding on port 80
# [14525.012] printk: 4523 messages suppressed

Три главные проблемы: conntrack table overflow, SYN flood protection блокирует легитимный трафик, файловые дескрипторы заканчиваются. Всё это — ограничения дефолтной конфигурации ядра Linux, рассчитанной на обычный сервер, а не на 100K соединений.

Тюнинг ядра: sysctl для высоких нагрузок

Дефолтные параметры ядра Linux рассчитаны на сервер с несколькими сотнями соединений. Для 100K+ нужно перенастроить десятки параметров. Вот полный конфиг, который мы применили:

# /etc/sysctl.d/99-gamedev-tuning.conf

# ═══════════════════════════════════════
# Сетевой стек
# ═══════════════════════════════════════

# Очередь входящих соединений (дефолт: 4096)
net.core.somaxconn = 65535

# Буферы приёма/отправки по умолчанию и максимум
net.core.rmem_default = 262144
net.core.rmem_max = 16777216
net.core.wmem_default = 262144
net.core.wmem_max = 16777216

# TCP буферы: min, default, max (в байтах)
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216

# Очередь бэклога (пакеты до обработки приложением)
net.core.netdev_max_backlog = 65536

# ═══════════════════════════════════════
# TCP оптимизация
# ═══════════════════════════════════════

# Повторное использование TIME_WAIT сокетов
net.ipv4.tcp_tw_reuse = 1

# Быстрое освобождение FIN_WAIT соединений
net.ipv4.tcp_fin_timeout = 15

# Keepalive: проверка живости через 60 сек, 5 проверок с интервалом 15 сек
net.ipv4.tcp_keepalive_time = 60
net.ipv4.tcp_keepalive_intvl = 15
net.ipv4.tcp_keepalive_probes = 5

# Максимум соединений в очереди SYN (полуоткрытые)
net.ipv4.tcp_max_syn_backlog = 65536

# SYN cookies — защита от SYN flood без потери легитимных соединений
net.ipv4.tcp_syncookies = 1

# Диапазон эфемерных портов (дефолт: 32768-60999)
net.ipv4.ip_local_port_range = 1024 65535

# Максимум orphaned сокетов (дефолт: 65536)
net.ipv4.tcp_max_orphans = 262144

# TCP Fast Open (клиент + сервер)
net.ipv4.tcp_fastopen = 3

# ═══════════════════════════════════════
# Conntrack (отслеживание соединений)
# ═══════════════════════════════════════

# Максимум отслеживаемых соединений (дефолт: 65536)
net.netfilter.nf_conntrack_max = 1048576

# Размер хеш-таблицы conntrack
net.netfilter.nf_conntrack_buckets = 262144

# Уменьшаем таймауты conntrack
net.netfilter.nf_conntrack_tcp_timeout_established = 600
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 30
net.netfilter.nf_conntrack_tcp_timeout_fin_wait = 30

# ═══════════════════════════════════════
# Виртуальная память
# ═══════════════════════════════════════

# Максимум файловых дескрипторов (дефолт: ~1M)
fs.file-max = 2097152

# Уменьшаем swappiness — на сервере с 256 GB RAM swap почти не нужен
vm.swappiness = 10

# Dirty pages — когда начинать сбрасывать на диск
vm.dirty_ratio = 40
vm.dirty_background_ratio = 10

# Overcommit — разрешаем (для fork() в PostgreSQL)
vm.overcommit_memory = 1
# Применяем без перезагрузки
sysctl -p /etc/sysctl.d/99-gamedev-tuning.conf

# Проверяем ключевые параметры
sysctl net.core.somaxconn net.netfilter.nf_conntrack_max fs.file-max
# net.core.somaxconn = 65535
# net.netfilter.nf_conntrack_max = 1048576
# fs.file-max = 2097152

После применения sysctl ошибки nf_conntrack: table full и SYN flooding полностью прекратились.

Файловые дескрипторы и ulimits

Каждое TCP-соединение — это файловый дескриптор. 100 000 соединений = 100 000 FD минимум. Дефолтный лимит Linux — 1024 на процесс. Это первое, что ломается при высоких нагрузках:

# Проверяем текущие лимиты
ulimit -n
# 1024  ← это дефолт, катастрофически мало

ulimit -n -H
# 1048576  ← hard limit из ядра (после нашего fs.file-max)

# Проверяем лимиты конкретного процесса nginx
cat /proc/$(pgrep -f 'nginx: master')/limits | grep 'Max open files'
# Max open files    1024     1024     files

Настраиваем лимиты на нескольких уровнях:

# 1. Системный лимит: /etc/security/limits.conf
*               soft    nofile          1048576
*               hard    nofile          1048576
root            soft    nofile          1048576
root            hard    nofile          1048576

# 2. PAM: /etc/pam.d/common-session
session required pam_limits.so

# 3. Systemd: для каждого сервиса отдельно
# /etc/systemd/system/nginx.service.d/override.conf
[Service]
LimitNOFILE=1048576

# /etc/systemd/system/gameserver.service.d/override.conf
[Service]
LimitNOFILE=1048576
LimitNPROC=65535

# 4. Nginx: в конфиге
worker_rlimit_nofile 1048576;
# Применяем и проверяем
systemctl daemon-reload
systemctl restart nginx gameserver

# Проверяем лимиты nginx worker
cat /proc/$(pgrep -f 'nginx: worker' | head -1)/limits | grep 'Max open files'
# Max open files    1048576  1048576  files

# Мониторинг использования FD в реальном времени
watch -n1 'cat /proc/sys/fs/file-nr'
# 45678  0  2097152
# ↑ используется  ↑ максимум

Оптимизация nginx для WebSocket и высоких нагрузок

Nginx выступал как reverse proxy для Go-бэкенда и терминатор WebSocket-соединений. Дефолтная конфигурация nginx рассчитана на тысячи, а не сотни тысяч соединений:

# /etc/nginx/nginx.conf — оптимизированный для 100K+ соединений

# Количество worker-процессов = количество CPU ядер
worker_processes auto;  # 64 на нашем сервере

# Максимум файловых дескрипторов на worker
worker_rlimit_nofile 1048576;

# Привязка workers к CPU (NUMA-aware)
worker_cpu_affinity auto;

events {
    # Максимум соединений на один worker
    # 100000 / 64 workers ≈ 1563, берём с запасом
    worker_connections 16384;
    
    # epoll — самый эффективный метод для Linux
    use epoll;
    
    # Принимать все соединения из очереди за один цикл
    multi_accept on;
}

http {
    # Отключаем access_log для production (или пишем в буфер)
    access_log /var/log/nginx/access.log combined buffer=512k flush=5s;
    
    # Таймауты для долгоживущих WebSocket-соединений
    keepalive_timeout 3600s;   # 1 час для WebSocket
    keepalive_requests 10000;
    
    # Отключаем буферизацию для WebSocket
    proxy_buffering off;
    
    # TCP оптимизация
    tcp_nopush on;
    tcp_nodelay on;
    sendfile on;
    
    # Сброс соединений по таймауту (освобождение ресурсов)
    reset_timedout_connection on;
    
    # Gzip для HTTP API (не WebSocket)
    gzip on;
    gzip_comp_level 2;
    gzip_min_length 1000;
    gzip_types application/json text/plain;
    
    # Upstream к Go-бэкенду с keepalive-пулом
    upstream gameserver {
        server 127.0.0.1:8080;
        server 127.0.0.1:8081;
        server 127.0.0.1:8082;
        server 127.0.0.1:8083;
        keepalive 512;  # Постоянные соединения к upstream
    }
    
    server {
        listen 443 ssl http2 reuseport backlog=65535;
        server_name game.example.com;
        
        ssl_certificate /etc/letsencrypt/live/game.example.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/game.example.com/privkey.pem;
        ssl_session_cache shared:SSL:50m;
        ssl_session_timeout 1d;
        ssl_session_tickets off;
        
        # WebSocket proxy
        location /ws {
            proxy_pass http://gameserver;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            
            # Таймауты для WebSocket
            proxy_read_timeout 3600s;
            proxy_send_timeout 3600s;
        }
        
        # REST API
        location /api/ {
            proxy_pass http://gameserver;
            proxy_http_version 1.1;
            proxy_set_header Connection "";
        }
    }
}

Ключевое слово reuseport в директиве listen — это SO_REUSEPORT, который позволяет каждому worker-процессу иметь собственный accept-сокет. Без него все workers конкурируют за один сокет, создавая lock contention. С reuseport мы увидели 3x прирост в accepts/sec.

PostgreSQL: shared_buffers, work_mem и PgBouncer

PostgreSQL — хранилище игровых данных: профили игроков, инвентарь, достижения, таблицы лидеров. На сервере с 256 GB RAM дефолтные настройки PostgreSQL были преступно малы:

# Дефолтные значения PostgreSQL 16 (Ubuntu)
SHOW shared_buffers;        -- 128MB
SHOW work_mem;              -- 4MB
SHOW effective_cache_size;  -- 4GB
SHOW max_connections;       -- 100

# Оптимизированные настройки для 256 GB RAM, NVMe SSD, 64 CPU
# /etc/postgresql/16/main/conf.d/performance.conf

# ── Память ──
shared_buffers = 64GB              # 25% RAM
effective_cache_size = 192GB       # 75% RAM (подсказка планировщику)
work_mem = 512MB                   # Для сортировок и хеш-таблиц
maintenance_work_mem = 4GB         # Для VACUUM, CREATE INDEX
huge_pages = try                   # Transparent Huge Pages для shared_buffers

# ── WAL ──
wal_buffers = 256MB                # 1/256 от shared_buffers
max_wal_size = 8GB
min_wal_size = 2GB
wal_compression = zstd             # Сжатие WAL (PG16+)
checkpoint_completion_target = 0.9

# ── Параллелизм ──
max_worker_processes = 32
max_parallel_workers_per_gather = 8
max_parallel_workers = 32
max_parallel_maintenance_workers = 4

# ── IO для NVMe SSD ──
random_page_cost = 1.1             # Дефолт 4.0 для HDD!
effective_io_concurrency = 200     # Дефолт 1
seq_page_cost = 1.0

# ── Соединения ──
max_connections = 200              # Небольшое число! PgBouncer впереди

# ── Логирование медленных запросов ──
log_min_duration_statement = 500   # Логировать запросы > 500мс
shared_preload_libraries = 'pg_stat_statements'
# Huge Pages — снижают overhead на управление памятью
# Для 64 GB shared_buffers нужно ~33000 huge pages (2MB каждая)

# Считаем количество
echo $(( (64 * 1024 + 512) / 2 )) # 32768 + запас

# /etc/sysctl.d/99-hugepages.conf
vm.nr_hugepages = 33000

sysctl -p /etc/sysctl.d/99-hugepages.conf
grep Huge /proc/meminfo
# HugePages_Total:   33000
# HugePages_Free:    33000
# Hugepagesize:      2048 kB

PgBouncer — обязателен для игрового сервера. Каждый Go-процесс может открыть сотни соединений при всплеске. Без пулинга PostgreSQL упадёт:

# /etc/pgbouncer/pgbouncer.ini
[databases]
gamedb = host=127.0.0.1 port=5432 dbname=gamedb

[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

# Transaction pooling — идеально для коротких игровых запросов
pool_mode = transaction

# Лимиты
max_client_conn = 10000          # Максимум от приложения
default_pool_size = 50            # Реальных соединений к PG
min_pool_size = 10
reserve_pool_size = 10
reserve_pool_timeout = 3

# Таймауты
server_idle_timeout = 300
query_timeout = 10               # Игровые запросы должны быть быстрыми
client_idle_timeout = 600

# Логирование
log_connections = 0
log_disconnections = 0
stats_period = 60

Результат: Go-бэкенд подключается к PgBouncer на порту 6432, PgBouncer поддерживает 50 реальных соединений к PostgreSQL, обслуживая до 10 000 клиентских соединений через переиспользование.

Cgroups, IO scheduler и системная оптимизация

На сервере с несколькими критичными сервисами важно изолировать ресурсы — один сервис не должен «убить» другие.

Cgroups v2 для изоляции ресурсов:

# Systemd автоматически создаёт cgroups для каждого сервиса
# Устанавливаем лимиты через drop-in файлы

# /etc/systemd/system/gameserver.service.d/resources.conf
[Service]
# CPU: 80% от всех ядер (51 из 64)
CPUQuota=5100%
# RAM: максимум 128 GB
MemoryMax=128G
MemoryHigh=120G
# IO: высокий приоритет
IOWeight=500

# /etc/systemd/system/postgresql.service.d/resources.conf
[Service]
CPUQuota=3200%
MemoryMax=96G
MemoryHigh=80G
IOWeight=800        # PostgreSQL получает приоритет IO
OOMScoreAdjust=-900 # Последний в очереди OOM Killer

# /etc/systemd/system/nginx.service.d/resources.conf
[Service]
CPUQuota=1600%
MemoryMax=16G
LimitNOFILE=1048576

IO Scheduler — для NVMe SSD лучший выбор — none (noop), так как NVMe имеет собственную очередь и планировщик ОС только добавляет латентность:

# Проверяем текущий планировщик
cat /sys/block/nvme0n1/queue/scheduler
# [mq-deadline] kyber bfq none

# Устанавливаем none для NVMe
echo none > /sys/block/nvme0n1/queue/scheduler
echo none > /sys/block/nvme1n1/queue/scheduler

# Постоянно через udev-правило
# /etc/udev/rules.d/60-io-scheduler.rules
ACTION=="add|change", KERNEL=="nvme*", ATTR{queue/scheduler}="none"
ACTION=="add|change", KERNEL=="sd*", ATTR{queue/rotational}=="0", ATTR{queue/scheduler}="mq-deadline"

# Глубина очереди NVMe
echo 1024 > /sys/block/nvme0n1/queue/nr_requests

NUMA-aware конфигурация — на серверах с 2+ процессорами важно привязать процессы к правильным NUMA-нодам:

# Проверяем NUMA-топологию
numactl --hardware
# available: 2 nodes (0-1)
# node 0 cpus: 0-31
# node 0 size: 128000 MB
# node 1 cpus: 32-63
# node 1 size: 128000 MB

# Привязка PostgreSQL к node 0 (где данные)
# /etc/systemd/system/postgresql.service.d/numa.conf
[Service]
ExecStart=
ExecStart=/usr/bin/numactl --cpunodebind=0 --membind=0 \
    /usr/lib/postgresql/16/bin/postgres \
    -D /var/lib/postgresql/16/main \
    -c config_file=/etc/postgresql/16/main/postgresql.conf

# Привязка gameserver к node 1
[Service]
ExecStart=/usr/bin/numactl --cpunodebind=1 --membind=1 /opt/gameserver/gameserver

Бенчмаркинг и результаты

После всех оптимизаций провели повторное нагрузочное тестирование. Использовали комбинацию инструментов:

# 1. wrk2 — HTTP/WebSocket нагрузочный тест с точным rate-limiting
./wrk -t16 -c100000 -d300s -R200000 --latency http://game.example.com/api/health

# Результат ПОСЛЕ оптимизации:
# 100000 connections
# Requests/sec:   189456 (было 12456)
# Latency:
#   50%:    2.3ms   (было 245ms)
#   99%:   18.7ms   (было 3200ms)
#   99.9%: 45.1ms   (было timeout)
# Errors: 0 (было 11633)

# 2. pgbench — тест PostgreSQL
pgbench -c 50 -j 16 -T 300 -P 10 gamedb

# Результат:
# tps = 48723 (включая соединения через PgBouncer)
# latency average = 1.026 ms
# latency stddev = 0.834 ms

# 3. Мониторинг ресурсов во время теста
# CPU: 55% utilization (есть запас)
# RAM: 210 GB used (shared_buffers + OS cache + apps)
# Disk IO: 12% utilization (NVMe справляется)
# Network: 4.2 Gbit/s из 10 Gbit/s

Сводная таблица результатов:

МетрикаДо оптимизацииПосле оптимизацииУлучшение
Максимум одновременных соединений~25 000120 000+4.8x
Requests/sec12 456189 45615x
P50 latency245 мс2.3 мс107x
P99 latency3 200 мс18.7 мс171x
Connection errors8 4320
PostgreSQL TPS8 90048 7235.5x
PostgreSQL cache hit ratio72%99.2%

Рекомендации от инженеров itfresh.ru для серверов с высокими нагрузками:

  • Начинайте с sysctl — 80% проблем при высоких нагрузках решаются тюнингом ядра, а не переписыванием кода
  • Мониторьте файловые дескрипторыcat /proc/sys/fs/file-nr должен быть в вашем дашборде
  • PgBouncer обязателен — прямые соединения к PostgreSQL от приложения — антипаттерн
  • IO scheduler = none для NVMe — любой другой планировщик только добавляет латентность
  • NUMA awareness — на 2-сокетных серверах неправильная привязка процессов может стоить 30% производительности
  • Бенчмаркинг ДО и ПОСЛЕ — без измерений оптимизация — это гадание

Часто задаваемые вопросы

Топ-5: net.core.somaxconn (очередь соединений, ставьте 65535), net.netfilter.nf_conntrack_max (таблица отслеживания, минимум 1M), fs.file-max (файловые дескрипторы, 2M+), net.ipv4.tcp_tw_reuse (переиспользование TIME_WAIT), vm.swappiness (снижайте до 10 на серверах с большим RAM). Без этих настроек Linux начинает отбрасывать соединения уже при 20-30K.
Стандартная формула: 25% от общей RAM сервера. На сервере с 256 GB — ставьте 64 GB. Больше 40% не рекомендуется — PostgreSQL использует двойное кеширование через OS page cache. Включите huge_pages = try для снижения overhead на управление большими объёмами памяти. Проверяйте эффективность через: SELECT pg_size_pretty(count(*) * 8192) FROM pg_buffercache WHERE usagecount > 3.
PostgreSQL создаёт отдельный процесс для каждого соединения (~10 MB RAM + context switch overhead). При 10 000 соединений это 100 GB RAM только на процессы плюс деградация из-за contention на shared_buffers. PgBouncer в режиме transaction pooling обслуживает тысячи клиентов через 50-100 реальных соединений к PostgreSQL, экономя RAM и устраняя contention.
none (noop) — NVMe-контроллер имеет собственные очереди с глубиной до 65535 команд и свой внутренний планировщик. Любой IO scheduler ОС (mq-deadline, bfq, kyber) только добавляет латентность и CPU overhead. Для SATA SSD используйте mq-deadline, для HDD — bfq (если нужна fairness) или mq-deadline.
Три способа: 1) cat /proc/sys/fs/file-nr — показывает allocated, unused, max для всей системы. 2) ls /proc/PID/fd | wc -l — количество FD конкретного процесса. 3) node_exporter для Prometheus автоматически экспортирует метрику process_open_fds. Настройте алерт на 80% от лимита: если у nginx-worker открыто 800K из 1M FD, скоро начнутся ошибки 'Too many open files'.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#тюнинг linux сервера#sysctl оптимизация#net.core.somaxconn#nginx worker optimization#postgresql shared_buffers#pgbouncer настройка#ulimit файловые дескрипторы#cgroups ресурсные лимиты
Комментарии 0

Оставить комментарий

загрузка...