Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Привет, Хабр! Меня зовут Вова, я разрабатываю observability-платформу в Ozon. Как-то раз в наш уголок на 42 этаже заглянули коллеги — и поделились наблюдением. Если открыть рядом графики времён запросов и ответов двух живущих в Kubernetes и общающихся между собой микросервисов, то иногда можно наблюдать большую разницу в высоких квантилях: клиент считает, что один ответ из сотни ему приходит за сто миллисекунд, сервер же говорит, что успевает ответить за десять.
Куда ушло время? Можно ли его вернуть? Сегодня расскажу о том, с какими граблями может столкнуться микросервис, живущий в типичной инсталляции Kubernetes.
От YAML до прерываний — один шаг
Прежде всего нужно отметить, что в Ozon приложения написаны на базе собственного фреймворка — платформы с интегрированным service discovery. Когда-нибудь расскажем об этом подробнее, но сейчас важно сказать две вещи:
Приложения ходят друг в друга напрямую: без сетевых и L7-балансировщиков, прокси-серверов, — словом, накладные расходы минимизированы, насколько это возможно. Это помогает в дебаге — простые вещи лучше поддаются пониманию и ремонту.
Платформа предоставляет готовое инструментирование: из коробки любой сервис будет покрыт богатым набором метрик и обмазан распределённым Jaeger-трейсингом.
Собственно, трейс проблемного кейса будет выглядеть как-то так:
На основе трейсов сварили метрику, показывающую распределение разниц времён запросов и ответов для 100% запросов. Назвали незамысловато — сlient-server span gap.
Для всех запросов?
В отличие от стандартной инсталляции Jaeger, использующей head-based sampling, мы сохраняем (пусть и ненадолго) и анализируем весь поток прилетающих спанов.
Итак, наш попугаеметр показывает условную сотню пар сервисов, имеющих проблему. Дело за малым — найти причину и починить. Первый кандидат — собственные ошибки и неверные допущения. Инструментирование в случае gRPC реализовано с помощью стандартного механизма — интерцепторов. Это значит, что наша метрика будет включать время на маршалинг и анмаршалинг ответа, который может быть произвольно большим. Исключим из рассмотрения пары сервисов с ответами размером больше сотни килобайт — и видим, что 99 из 100 сервисов по-прежнему зааффекчены.
:more_cpu:
Следующий типичный ответ: «Давайте зальём проблему железом, а там разберёмся». Однако тут есть два нюанса:
Глобальный дефицит чипов несколько затрудняет процесс.
Далеко не от каждой проблемы можно отделаться, кидаясь в неё пачками денег.
Второй, разумеется, критичнее.
Берём в руки шашку и пишем пару сервисов — танк и мишень — обменивающихся раз в десять миллисекунд минимальными сообщениями в стиле ping-pong и логирующих trace_id проблемных запросов для удобства. Видим, что проблема по-прежнему иногда воспроизводится, даже если запустить приложения в guaranteed-подах на хостах со static cpu policy.
Что делать? Как всегда, достаём микроскоп и начинаем забивать гвозди. Берём нашу пару тестовых приложений, расчехляем tcpdump на обоих концах, оставляем работать до появления в логах проблемных trace_id. После открываем два окна Wireshark и начинаем зачитывать полученные дампы по ролям. Показания клиента и сервера действительно отличаются:
Клиент думает, что отправляет запросы один за одним, а потом одним махом получает пачку ответов. С точки зрения сервера же всё происходит штатно: видны последовательные запросы и ответы без задержек.
«Виноваты NOC-и!»
— воскликнет типичный разработчик, если его разбудить среди ночи и сказать, что видны какие-то затупы. После чашки кофе, однако, возникают сомнения в верности этой народной мудрости. Ведь data plane типовых датацентровых маршрутизаторов обрабатывает пакеты не центральным процессором общего назначения, а специально заточенными под это дело чипами, укомплектованными небольшим (12-32 мегабайт) объёмом быстрой памяти под буферы, распределение которой между физическими портами более-менее статично. Несложно подсчитать, что для буферизации одной миллисекунды загруженного линка в 25 Гб/с требуется чуть менее 3 мегабайт памяти. И таких линков в стойке 20-40 штук: Top of Rack-маршрутизатору попросту негде хранить пакеты в течение сотни миллисекунд, и потеря пакетов на порядки более вероятна такой задержки. Потому будем продолжать искать проблему у себя.
Расскажите поподробнее
Пользуясь случаем, рекомендую к прочтению 14-ю часть «Сетей для самых маленьких» за авторством @eucariot
We need to go deeper
Предельно упрощённо воркфлоу нашего тестового серверного приложения выглядит следующим образом:
А выделяется ли приложению процессорное время? Убеждаемся с помощью утилитки cpudist из проекта BCC. C ключом --offcpu
она покажет распределение off-CPU-отрезков времени:
$ sudo /usr/share/bcc/tools/cpudist --offcpu --milliseconds --pids --pid 54512 5
<...>
pid = 54512 test-grpc-shoot
msecs : count distribution
0 -> 1 : 68269 |****************************************|
2 -> 3 : 316 | |
4 -> 7 : 123 | |
8 -> 15 : 11 | |
16 -> 31 : 4 | |
Видим четыре паузы продолжительностью от 16 до 31 миллисекунд, что неожиданно. Тестовые приложения мы запускаем в guaranteed-подах на хостах со static cpu policy, то есть приложение прибито к конкретным процессорным ядрам, на которые K8s обещает не селить другие контейнеры. Разобраться поможет perf: соберём стектрейсы с ядер приложения раз в миллисекунду и найдём те, что не относятся к приложению:
$ sudo perf record -F 1000 -g --cpu 0
$ perf script
<...>
:6789 6789 [003] 27757.080040: 1000000 cpu-clock:pppH:
ffffffffc011c3fa e1000_clean+0x46a (/lib/modules/5.13.0-39-generic/kernel/drivers/net/ethernet/intel/e1000>
ffffffff96bc9571 __napi_poll+0x31 (/usr/lib/debug/boot/vmlinux-5.13.0-39-generic)
ffffffff96bc9a4f net_rx_action+0x23f (/usr/lib/debug/boot/vmlinux-5.13.0-39-generic)
ffffffff972000cf __do_softirq+0xcf (/usr/lib/debug/boot/vmlinux-5.13.0-39-generic)
ffffffff962aa7f4 irq_exit_rcu+0xa4 (/usr/lib/debug/boot/vmlinux-5.13.0-39-generic)
ffffffff96df732a common_interrupt+0x4a (/usr/lib/debug/boot/vmlinux-5.13.0-39-generic)
ffffffff97000cde asm_common_interrupt+0x1e (/usr/lib/debug/boot/vmlinux-5.13.0-39-generic)
NAPI — фреймворк для написания драйверов сетевых карт в Linux, стандарт для general-purpose-драйверов. В основе дизайна — простая идея: нужно балансировать latency и накладные расходы на обработку прерываний, обрабатывая накопившуюся пачку пакетов разом, — и уступить процессор следующему потребителю. Для большинства задач это работает хорошо, но не в тех случаях, когда каждая миллисекунда на счету. Потому мы унесём сетевые прерывания на специальные ядра:
# Снизим параллельность обработки пакетов на сетевой карте
$ sudo ethtool -L eno1 combined 4
# Посмотрим на номера назначенных сетевушке прерываний
$ cat /proc/interrupts | fgrep eno1
146: <...> IR-PCI-MSI 13631489-edge i40e-eno1-TxRx-0
147: <...> IR-PCI-MSI 13631490-edge i40e-eno1-TxRx-1
148: <...> IR-PCI-MSI 13631491-edge i40e-eno1-TxRx-2
149: <...> IR-PCI-MSI 13631492-edge i40e-eno1-TxRx-3
$ lstopo-no-graphics
Machine (376GB total)
NUMANode L#0 (P#0 187GB)
Package L#0 + L3 L#0 (36MB)
L2 L#0 (1024KB) + L1d L#0 (32KB) + L1i L#0 (32KB) + Core L#0
PU L#0 (P#0)
PU L#1 (P#48)
L2 L#1 (1024KB) + L1d L#1 (32KB) + L1i L#1 (32KB) + Core L#1
PU L#2 (P#1)
PU L#3 (P#49)
L2 L#2 (1024KB) + L1d L#2 (32KB) + L1i L#2 (32KB) + Core L#2
PU L#4 (P#2)
PU L#5 (P#50)
<...>
HostBridge L#2
PCIBridge
PCIBridge
PCIBridge
PCI 8086:37d0
Net L#0 "eno1"
<...>
NUMANode L#1 (P#1 189GB)
Package L#1 + L3 L#1 (36MB)
Не забудем на время отключить irqbalance, чтобы наши изменения не были перезаписаны. После экспериментов же irqbalance можно будет включить обратно, подсунув ему policyscript.
$ sudo systemctl stop irqbalance.service
CPU0 и его SMP-пару не рекомендуется использовать для критичной к задержкам нагрузке, потому что на нём исполняются прерывания системных таймеров. Потому начинаем с единицы:
$ echo 1 | sudo tee /proc/irq/146/smp_affinity_list
$ echo 49 | sudo tee /proc/irq/147/smp_affinity_list
$ echo 2 | sudo tee /proc/irq/148/smp_affinity_list
$ echo 50 | sudo tee /proc/irq/149/smp_affinity_list
Но унести прерывания мало. Необходимо также обеспечить возможность softirq-хендлерам перекладывать пакеты, когда это требуется. В основном этому будут мешать userspace-приложения, которые выполняют долгие (по нашим меркам) системные вызовы. Традиционный способ, доступный с 2004 года (ядро 2.6.9) — использование boot-флага ядра isolcpus
: на обозначенные ядра по умолчанию не будут шедулиться userspace-приложения. Это работает для любого дистрибутива, поскольку не предъявляет требований к способу запуска приложений. У этого подхода, однако, есть существенный недостаток: его применение требует перезагрузки, что ограничивает cadence, с которым можно тюнить настройки. Для современных систем, так или иначе заворачивающих всё в cgroups, рекомендуется использовать cpuset. В случае systemd — расставить CPUAffinity и перезапустить сервисы. Но можно ли обойтись без перезапусков? Мы как лауреаты премии «Золотой костыль» нашли лазейку. Создадим отдельную пустую cgroup с флагом cpu_exclusive и отдадим в её распоряжение «сетевые» ядра:
# Уносим системные процессы в общий пул ядер
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
# Ядерные треды подвинуть не получится, потому ошибки придётся потерпеть
set +e
xargs --arg-file="/sys/fs/cgroup/cpuset/tasks" -I {} bash -c \
"echo {} > /sys/fs/cgroup/cpuset/systemtasks/tasks 2>/dev/null"
set -e
# Уносим на общие ядра дерево kubernetes
# /sys/fs/cgroup/cpuset/kubepods/QoS_CLASS/POD/CONTAINER/
# Контейнеры
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 > {}"
# QoS-классы
find /sys/fs/cgroup/cpuset/kubepods -mindepth 2 -maxdepth 2 -name cpuset.cpus -print0 \
| xargs -0 -I {} bash -c "echo 0,3-48,51-95 > {}"
# Корень.
echo 0,3-48,51-95 > /sys/fs/cgroup/cpuset/kubepods/cpuset.cpus
# Добрались до сети
mkdir /sys/fs/cgroup/cpuset/networkcpus
echo "1-2,49-50" > /sys/fs/cgroup/cpuset/systemtasks/cpuset.cpus
cat /sys/fs/cgroup/cpuset/cpuset.mems > /sys/fs/cgroup/cpuset/systemtasks/cpuset.mems
echo 1 > /sys/fs/cgroup/cpuset/cpuset.cpu_exclusive
echo 1 > /sys/fs/cgroup/cpuset/networkcpus/cpuset.cpu_exclusive
Было:
/sys/fs/cgroup/cpuset/ (системное барахло и ядерные треды)
├── kubepods (поды k8s)
(96 ядер)
Стало:
/sys/fs/cgroup/cpuset/ (ядерные треды)
├── kubepods
(92 ядра)
├── networkcpus (пустая)
(4 ядра)
├── systemtasks (порождённые systemd процессы и их наследники)
(92 ядра)
Таким нехитрым способом мы получили функциональный эквивалент isolcpus, который можно менять в рантайме за единицы секунд. Применим на всём кластере — и получим заметный эффект:
Span gap уменьшился и стал менее шумным: клиенты получают ответы за предсказуемое время.
Итак, сегодня мы применили широкоизвестные инструменты диагностики Linux-based систем, и с помощью CPU affinity разделили обработку сетевого стека и исполнение приложений. В результате этого ускорилось кросс-сервисное взаимодействие приложений в нашем Kubernetes-кластере.
Безусловно, на этом рано ставить точку: хочется тратить на сеть меньше процессора — и перед нами возникает множество путей, о которых расскажем в следующий раз. Не переключайтесь и читайте Брендана Грегга.