Куда уходит время? Боремся за миллисекунды в Kubernetes

в 14:26, , рубрики: cpu, highload, kubernetes, ozon tech, Блог компании Ozon Tech, высокая нагрузка, высокая производительность, микросервисы, системное администрирование

Привет! Меня зовут Вова, я разрабатываю observability-платформу в Ozon. Как-то раз в наш уголок на 42 этаже заглянули коллеги — и поделились наблюдением. Если открыть рядом графики времён запросов и ответов двух живущих в Kubernetes и общающихся между собой микросервисов, то иногда можно наблюдать большую разницу в высоких квантилях: клиент считает, что один ответ из сотни ему приходит за сто миллисекунд, сервер же говорит, что успевает ответить за десять.

Куда ушло время? Можно ли его вернуть? Сегодня расскажу о том, с какими граблями может столкнуться микросервис, живущий в типичной инсталляции Kubernetes.

Куда уходит время? Боремся за миллисекунды в Kubernetes - 1

От YAML до прерываний — один шаг

Прежде всего нужно отметить, что в Ozon приложения написаны на базе собственного фреймворка — платформы с интегрированным service discovery. Когда-нибудь расскажем об этом подробнее, но сейчас важно сказать две вещи:

  1. Приложения ходят друг в друга напрямую: без сетевых и L7-балансировщиков, прокси-серверов, — словом, накладные расходы минимизированы, насколько это возможно. Это помогает в дебаге — простые вещи лучше поддаются пониманию и ремонту.

  2. Платформа предоставляет готовое инструментирование: из коробки любой сервис будет покрыт богатым набором метрик и обмазан распределённым Jaeger-трейсингом. 

Собственно, трейс проблемного кейса будет выглядеть как-то так:

Куда уходит время? Боремся за миллисекунды в Kubernetes - 2

На основе трейсов сварили метрику, показывающую распределение разниц времён запросов и ответов для 100% запросов. Назвали незамысловато — сlient-server span gap.

Для всех запросов?

В отличие от стандартной инсталляции Jaeger, использующей head-based sampling, мы сохраняем (пусть и ненадолго) и анализируем весь поток прилетающих спанов.

Итак, наш попугаеметр показывает условную сотню пар сервисов, имеющих проблему. Дело за малым — найти причину и починить. Первый кандидат — собственные ошибки и неверные допущения. Инструментирование в случае gRPC реализовано с помощью стандартного механизма — интерцепторов. Это значит, что наша метрика будет включать время на маршалинг и анмаршалинг ответа, который может быть произвольно большим. Исключим из рассмотрения пары сервисов с ответами размером больше сотни килобайт — и видим, что 99 из 100 сервисов по-прежнему зааффекчены.

Куда уходит время? Боремся за миллисекунды в Kubernetes - 3

:more_cpu:

Следующий типичный ответ: «Давайте зальём проблему железом, а там разберёмся». Однако тут есть два нюанса:

  1. Глобальный дефицит чипов несколько затрудняет процесс.

  2. Далеко не от каждой проблемы можно отделаться, кидаясь в неё пачками денег.

Второй, разумеется, критичнее.

Берём в руки шашку и пишем пару сервисов — танк и мишень — обменивающихся раз в десять миллисекунд минимальными сообщениями в стиле ping-pong и логирующих trace_id проблемных запросов для удобства. Видим, что проблема по-прежнему иногда воспроизводится, даже если запустить приложения в guaranteed-подах на хостах со static cpu policy.

Что делать? Как всегда, достаём микроскоп и начинаем забивать гвозди. Берём нашу пару тестовых приложений, расчехляем tcpdump на обоих концах, оставляем работать до появления в логах проблемных trace_id. После открываем два окна Wireshark и начинаем зачитывать полученные дампы по ролям. Показания клиента и сервера действительно отличаются:

Куда уходит время? Боремся за миллисекунды в Kubernetes - 4

Клиент думает, что отправляет запросы один за одним, а потом одним махом получает пачку ответов. С точки зрения сервера же всё происходит штатно: видны последовательные запросы и ответы без задержек.

«Виноваты NOC-и!»

— воскликнет типичный разработчик, если его разбудить среди ночи и сказать, что видны какие-то затупы. После чашки кофе, однако, возникают сомнения в верности этой народной мудрости. Ведь data plane типовых датацентровых маршрутизаторов обрабатывает пакеты не центральным процессором общего назначения, а специально заточенными под это дело чипами, укомплектованными небольшим (12-32 мегабайт) объёмом быстрой памяти под буферы, распределение которой между физическими портами более-менее статично. Несложно подсчитать, что для буферизации одной миллисекунды загруженного линка в 25 Гб/с требуется чуть менее 3 мегабайт памяти. И таких линков в стойке 20-40 штук: Top of Rack-маршрутизатору попросту негде хранить пакеты в течение сотни миллисекунд, и потеря пакетов на порядки более вероятна такой задержки. Потому будем продолжать искать проблему у себя.

Расскажите поподробнее

Пользуясь случаем, рекомендую к прочтению 14-ю часть «Сетей для самых маленьких» за авторством @eucariot

We need to go deeper

Предельно упрощённо воркфлоу нашего тестового серверного приложения выглядит  следующим образом:

Приложение должно требовать процессорное время не реже раза в 10 миллисекунд
Приложение должно требовать процессорное время не реже раза в 10 миллисекунд

А выделяется ли приложению процессорное время? Убеждаемся с помощью утилитки 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
Посмотрим на физическую топологию: ядра должны принадлежать процессору, на PCI-линиях которого сидит сетевая карта
Посмотрим на физическую топологию: ядра должны принадлежать процессору, на PCI-линиях которого сидит сетевая карта
$ 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, который можно менять в рантайме за единицы секунд. Применим на всём кластере — и получим заметный эффект:

0.99, 0.95 и 0.9 квантили нашего попугаеметра span gap. Изменения применялись в интервале с 15:00 до 16:45
0.99, 0.95 и 0.9 квантили нашего попугаеметра span gap.
Изменения применялись в интервале с 15:00 до 16:45

Span gap уменьшился и стал менее шумным: клиенты получают ответы за предсказуемое время.

Жёлтым показан RPS, оттенками синего — 0.99, 0.95 и 0.9 квантили времён ответа
Жёлтым показан RPS, оттенками синего — 0.99, 0.95 и 0.9 квантили времён ответа

Итак, сегодня мы применили широкоизвестные инструменты диагностики Linux-based систем, и с помощью CPU affinity разделили обработку сетевого стека и исполнение приложений. В результате этого ускорилось кросс-сервисное взаимодействие приложений в нашем Kubernetes-кластере.

Безусловно, на этом рано ставить точку: хочется тратить на сеть меньше процессора — и перед нами возникает множество путей, о которых расскажем в следующий раз. Не переключайтесь и читайте Брендана Грегга.

Автор:
Pono

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js