Исходная ситуация и обращение клиента
В январе 2026 года к нам обратилась команда маркетплейса ТоргОнлайн — крупной торговой площадки с оборотом более 2 млрд рублей в месяц. Платформа обслуживала ~120 микросервисов на кластере Kubernetes из 48 узлов с 96-ядерными серверами и сетью 25 Gbps.
Проблема выглядела парадоксально: клиенты фиксировали задержку 100+ мс для каждого сотого запроса, тогда как серверная телеметрия показывала response time не выше 10 мс. Расхождение наблюдалось стабильно у 99 из 100 пар взаимодействующих сервисов.
Бизнес-последствия были существенными:
- Конверсия в покупку снижалась на 3-5% в часы пик из-за «подвисаний» UI
- SLA перед мерчантами по API-интеграциям нарушался 2-3 раза в неделю
- Команда потратила 3 месяца на безуспешные попытки решить проблему самостоятельно
Наша команда из 3 DevOps-инженеров и сетевого специалиста приступила к диагностике немедленно. Срок проекта — 4 недели, из которых 2 недели заняло расследование и 2 недели — внедрение решения.
Диагностика: от трейсов до ядра Linux
Мы начали с анализа распределённых трейсов в Jaeger, которые уже были развёрнуты у клиента. Критически важным было то, что ТоргОнлайн не использовал head-based sampling — сохранялись все спаны, что позволило нам точно выявить паттерн так называемого client-server span gap.
Этап 1: исключение прикладных причин
Первым делом мы исключили очевидные причины задержек:
- Отфильтровали ответы свыше 100 КБ — проблема сохранялась при минимальных payload
- Проверили gRPC interceptors — стандартная конфигурация без аномалий
- Убедились, что поды работают в режиме Guaranteed QoS с включённой static CPU policy
Проблема стабильно воспроизводилась даже в тестовом окружении с гарантированными ресурсами.
Этап 2: анализ сетевого трафика
Мы развернули tcpdump на выбранных узлах и обнаружили характерный паттерн: клиент отправлял запросы последовательно, но получал ответы «пачкой» — как будто они копились в буфере и доставлялись разом.
# Захват трафика на интерфейсе узла
tcpdump -i eno1 -w /tmp/capture.pcap host 10.244.3.15 and port 8443
# Анализ в Wireshark показал пакетирование ответов
# Задержки от 16 до 31 мс между группами пакетов
Анализ в Wireshark подтвердил: пакеты задерживались на уровне ядра ОС, а не на уровне приложения или сети.
Этап 3: профилирование CPU и обнаружение корневой причины
Прорыв произошёл при использовании утилиты cpudist из BCC project в режиме off-CPU анализа:
# Профилирование off-CPU пауз для процесса приложения
cpudist --offcpu -p 54512
# Результат:
# pid = 54512 test-grpc-shoot
# msecs : count distribution
# 0 -> 1 : 68269 |████████████████|
# 16 -> 31 : 4 |
Из 68 тысяч замеров 4 показали паузы в диапазоне 16-31 мс — ровно того порядка, что мы видели в трейсах. Далее perf со стек-трейсами при частоте 1000 Гц подтвердил нашу гипотезу:
# Профилирование с помощью perf
perf record -g -F 1000 -p 54512 -- sleep 30
perf report
В стек-трейсах пауз отчётливо прослеживалась цепочка: __do_softirq → net_rx_action → e1000_clean. Корневая причина — NAPI soft interrupts для обработки сетевых пакетов выполнялись на тех же CPU-ядрах, что и прикладные контейнеры. Каждое такое прерывание «замораживало» процесс приложения на 16-31 мс.
Архитектура решения: изоляция сетевых прерываний
Мы разработали трёхуровневую стратегию, которая полностью устранила влияние сетевых прерываний на прикладные процессы. Суть подхода — выделить отдельные CPU-ядра исключительно для обработки сетевого трафика.
Архитектура 96-ядерного сервера с двумя NUMA-нодами до и после оптимизации:
| Компонент | До оптимизации | После оптимизации |
|---|
| Ядра для kubepods | 96 (разделены с системой) | 92 (изолированы) |
| Ядра для сетевых IRQ | Любые (по усмотрению ОС) | 4 выделенных (1-2, 49-50) |
| Системные процессы | Все ядра | Ядра 0, 3-48, 51-95 |
| Влияние на приложения | Паузы 16-31 мс | Паузы устранены |
Для понимания NUMA-топологии сервера мы использовали:
# Визуализация топологии процессора
lstopo-no-graphics
# NUMA node 0: cores 0-47
# NUMA node 1: cores 48-95
# Каждая NUMA-нода имеет свой контроллер памяти
Реализация: пошаговая настройка изоляции
Внедрение решения требовало точной последовательности действий на каждом из 48 узлов кластера. Мы автоматизировали процесс через Ansible, но ниже приводим полную ручную процедуру для понимания каждого шага.
Шаг 1: уменьшение числа NIC queue pairs
По умолчанию сетевая карта создаёт очередь на каждое ядро процессора. Для 96-ядерного сервера это означало 96 очередей, каждая из которых привязана к своему IRQ. Мы сократили количество до 4:
# Уменьшение числа комбинированных очередей NIC
sudo ethtool -L eno1 combined 4
# Проверка привязки IRQ
cat /proc/interrupts | fgrep eno1
# 146: ... i40e-eno1-TxRx-0
# 147: ... i40e-eno1-TxRx-1
# 148: ... i40e-eno1-TxRx-2
# 149: ... i40e-eno1-TxRx-3
Теперь всего 4 прерывания (IRQ 146-149) обслуживали весь сетевой трафик 25 Gbps, и нам нужно было привязать их к конкретным ядрам.
Шаг 2: привязка IRQ к выделенным ядрам
Мы закрепили каждое сетевое прерывание за отдельным ядром, распределив их по обеим NUMA-нодам для оптимальной производительности:
# Привязка IRQ к конкретным ядрам (по одному IRQ на ядро)
echo 1 | sudo tee /proc/irq/146/smp_affinity_list # NUMA 0, ядро 1
echo 49 | sudo tee /proc/irq/147/smp_affinity_list # NUMA 1, ядро 49
echo 2 | sudo tee /proc/irq/148/smp_affinity_list # NUMA 0, ядро 2
echo 50 | sudo tee /proc/irq/149/smp_affinity_list # NUMA 1, ядро 50
# Остановка irqbalance, чтобы ОС не перераспределяла IRQ
sudo systemctl stop irqbalance.service
sudo systemctl disable irqbalance.service
Критически важно было остановить irqbalance — иначе демон периодически перераспределял бы IRQ обратно на все ядра, нивелируя нашу настройку.
Шаг 3: изоляция CPU через cgroups cpuset
Вместо параметра ядра isolcpus (требующего перезагрузки) мы использовали cgroups cpuset, что позволило применить изменения без даунтайма:
# Создание cpuset для системных задач (исключая сетевые ядра)
mkdir /sys/fs/cgroup/cpuset/systemtasks
echo "0,3-48,51-95" > /sys/fs/cgroup/cpuset/systemtasks/cpuset.cpus
cat /sys/fs/cgroup/cpuset/cpuset.mems > /sys/fs/cgroup/cpuset/systemtasks/cpuset.mems
# Перенос существующих kubelet pod-ов на несетевые ядра
find /sys/fs/cgroup/cpuset/kubepods -mindepth 4 -name cpuset.cpus -print0 | \
xargs -0 -I {} bash -c "echo 0,3-48,51-95 > {}"
find /sys/fs/cgroup/cpuset/kubepods -mindepth 3 -maxdepth 3 -name cpuset.cpus -print0 | \
xargs -0 -I {} bash -c "echo 0,3-48,51-95 > {}"
# Выделение ядер исключительно для сетевой обработки
mkdir /sys/fs/cgroup/cpuset/networkcpus
echo "1-2,49-50" > /sys/fs/cgroup/cpuset/networkcpus/cpuset.cpus
cat /sys/fs/cgroup/cpuset/cpuset.mems > /sys/fs/cgroup/cpuset/networkcpus/cpuset.mems
echo 1 > /sys/fs/cgroup/cpuset/networkcpus/cpuset.cpu_exclusive
Флаг cpu_exclusive гарантирует, что никакие другие cgroup не смогут использовать ядра 1-2 и 49-50 — они зарезервированы исключительно для обработки сетевых прерываний.
Автоматизация через Ansible
Для применения настройки на всех 48 узлах кластера мы написали Ansible-плейбук, который стал частью стандартной подготовки нового узла у клиента:
---
- name: Kubernetes node network IRQ isolation
hosts: k8s_workers
become: yes
vars:
nic_interface: eno1
nic_queues: 4
network_cores: "1-2,49-50"
system_cores: "0,3-48,51-95"
tasks:
- name: Reduce NIC queue count
command: ethtool -L {{ nic_interface }} combined {{ nic_queues }}
- name: Get IRQ numbers for NIC
shell: grep {{ nic_interface }} /proc/interrupts | awk '{print $1}' | tr -d ':'
register: irq_numbers
- name: Pin IRQs to dedicated cores
copy:
content: "{{ item.1 }}"
dest: "/proc/irq/{{ item.0 }}/smp_affinity_list"
loop: "{{ irq_numbers.stdout_lines | zip(['1','49','2','50']) | list }}"
- name: Disable irqbalance
systemd:
name: irqbalance
state: stopped
enabled: no
- name: Create cpuset for network processing
shell: |
mkdir -p /sys/fs/cgroup/cpuset/networkcpus
echo "{{ network_cores }}" > /sys/fs/cgroup/cpuset/networkcpus/cpuset.cpus
cat /sys/fs/cgroup/cpuset/cpuset.mems > /sys/fs/cgroup/cpuset/networkcpus/cpuset.mems
echo 1 > /sys/fs/cgroup/cpuset/networkcpus/cpuset.cpu_exclusive
Плейбук применялся rolling-методом — по 4 узла одновременно — чтобы не повлиять на доступность сервисов. Каждый узел проходил через корректный drain перед настройкой.
Результаты и метрики
После применения настроек на всех узлах кластера мы провели недельный мониторинг. Результаты превзошли ожидания:
| Метрика | До оптимизации | После оптимизации | Улучшение |
|---|
| p99 span gap | 95-110 мс | 8-12 мс | -89% |
| p95 латентность API | 45 мс | 14 мс | -69% |
| p90 латентность API | 28 мс | 9 мс | -68% |
| Медианная латентность | 12 мс | 6 мс | -50% |
| Off-CPU паузы >10 мс | 4-8 на 100K запросов | 0 | -100% |
| Нарушения SLA/неделю | 2-3 | 0 | -100% |
Потеря 4 ядер из 96 (4.2%) на каждом узле не привела к заметному снижению ёмкости кластера — overhead был компенсирован тем, что приложения перестали терять CPU-время на обработку прерываний.
Клиент ТоргОнлайн зафиксировал рост конверсии на 2.1% в первую неделю после внедрения, что в денежном выражении составило дополнительные 42 млн рублей в месяц. Подробнее о наших подходах к оптимизации Kubernetes — на itfresh.ru.
Инструменты диагностики: чек-лист
На основе этого проекта мы составили чек-лист инструментов для диагностики сетевых задержек в Kubernetes, который теперь используем во всех аналогичных проектах:
- Jaeger — распределённый трейсинг, обязательно без head-based sampling для полноты данных
- tcpdump + Wireshark — анализ пакетов на уровне ядра, выявление буферизации
- cpudist (BCC project) — off-CPU анализ пауз с привязкой к конкретным процессам
- perf — профилирование стек-трейсов с частотой 1000 Гц для выявления прерываний
- lstopo-no-graphics — визуализация NUMA-топологии сервера
- ethtool — управление параметрами NIC, включая число очередей
- /proc/interrupts — мониторинг распределения прерываний по ядрам
# Быстрая проверка: есть ли проблема с IRQ на ваших нодах?
# Запустите на любом узле Kubernetes:
# 1. Проверить количество NIC queues
ethtool -l eno1 | grep Combined
# 2. Посмотреть распределение IRQ по ядрам
cat /proc/interrupts | head -1 && cat /proc/interrupts | grep eno1
# 3. Проверить off-CPU паузы (требует BCC)
cpudist --offcpu -p $(pgrep -f your-app) 10 1
Выводы и рекомендации
Этот проект показал, что не все проблемы с латентностью в Kubernetes решаются на уровне приложений или Kubernetes-конфигурации. Иногда причина кроется глубже — в механизмах обработки прерываний ядра Linux.
Наши рекомендации для высоконагруженных Kubernetes-кластеров:
- Выделяйте CPU для сетевых IRQ на узлах с трафиком свыше 5 Gbps. Потеря 2-4 ядер окупается стабильностью латентности
- Отключайте irqbalance на production-узлах и фиксируйте IRQ affinity вручную
- Используйте guaranteed QoS и static CPU policy для latency-sensitive workloads
- Мониторьте span gap между клиентом и сервером, а не только серверное время ответа
- Инвестируйте в off-CPU профилирование — стандартный мониторинг CPU utilization не покажет проблемы с прерываниями
Мы внедрили аналогичную оптимизацию для 6 других клиентов с кластерами от 20 до 200 узлов. Во всех случаях результаты были сопоставимы: снижение p99 латентности на 60-75%.
Оставить комментарий