Привет, Хабр! Меня зовут Вова, я разрабатываю observability-платформу в Ozon. Как-то раз в наш уголок на 42 этаже заглянули коллеги — и поделились наблюдением. Если открыть рядом графики времён запросов и ответов двух живущих в Kubernetes и общающихся между собой микросервисов, то иногда можно наблюдать большую разницу в высоких квантилях: клиент считает, что один ответ из сотни ему приходит за сто миллисекунд, сервер же говорит, что успевает ответить за десять. Куда ушло время? Можно ли его вернуть? Сегодня расскажу о том, с какими граблями может столкнуться микросервис, живущий в типичной инсталляции Kubernetes. Прежде всего нужно отметить, что в Ozon приложения написаны на базе собственного фреймворка — платформы с интегрированным service discovery. Когда-нибудь расскажем об этом подробнее, но сейчас важно сказать две вещи: Приложения ходят друг в друга напрямую: без сетевых и L7-балансировщиков, прокси-серверов, — словом, накладные расходы минимизированы, насколько это возможно. Это помогает в дебаге — простые вещи лучше поддаются пониманию и ремонту. Платформа предоставляет готовое инструментирование: из коробки любой сервис будет покрыт богатым набором метрик и обмазан распределённым Jaeger-трейсингом.  Собственно, трейс проблемного кейса будет выглядеть как-то так: На основе трейсов сварили метрику, показывающую распределение разниц времён запросов и ответов для 100% запросов. Назвали незамысловато — сlient-server span gap. В отличие от стандартной инсталляции Jaeger, использующей head-based sampling, мы сохраняем (пусть и ненадолго) и анализируем весь поток прилетающих спанов. Итак, наш попугаеметр показывает условную сотню пар сервисов, имеющих проблему. Дело за малым — найти причину и починить. Первый кандидат — собственные ошибки и неверные допущения. Инструментирование в случае gRPC реализовано с помощью стандартного механизма — интерцепторов. Это значит, что наша метрика будет включать время на маршалинг и анмаршалинг ответа, который может быть произвольно большим. Исключим из рассмотрения пары сервисов с ответами размером больше сотни килобайт — и видим, что 99 из 100 сервисов по-прежнему зааффекчены. Следующий типичный ответ: «Давайте зальём проблему железом, а там разберёмся». Однако тут есть два нюанса: Глобальный дефицит чипов несколько затрудняет процесс. Далеко не от каждой проблемы можно отделаться, кидаясь в неё пачками денег. Второй, разумеется, критичнее. Берём в руки шашку и пишем пару сервисов — танк и мишень — обменивающихся раз в десять миллисекунд минимальными сообщениями в стиле ping-pong и логирующих trace_id проблемных запросов для удобства. Видим, что проблема по-прежнему иногда воспроизводится, даже если запустить приложения в guaranteed-подах на хостах со static cpu policy. Что делать? Как всегда, достаём микроскоп и начинаем забивать гвозди. Берём нашу пару тестовых приложений, расчехляем tcpdump на обоих концах, оставляем работать до появления в логах проблемных trace_id. После открываем два окна Wireshark и начинаем зачитывать полученные дампы по ролям. Показания клиента и сервера действительно отличаются: Клиент думает, что отправляет запросы один за одним, а потом одним махом получает пачку ответов. С точки зрения сервера же всё происходит штатно: видны последовательные запросы и ответы без задержек. — воскликнет типичный разработчик, если его разбудить среди ночи и сказать, что видны какие-то затупы. После чашки кофе, однако, возникают сомнения в верности этой народной мудрости. Ведь data plane типовых датацентровых маршрутизаторов обрабатывает пакеты не центральным процессором общего назначения, а специально заточенными под это дело чипами, укомплектованными небольшим (12-32 мегабайт) объёмом быстрой памяти под буферы, распределение которой между физическими портами более-менее статично. Несложно подсчитать, что для буферизации одной миллисекунды загруженного линка в 25 Гб/с требуется чуть менее 3 мегабайт памяти. И таких линков в стойке 20-40 штук: Top of Rack-маршрутизатору попросту негде хранить пакеты в течение сотни миллисекунд, и потеря пакетов на порядки более вероятна такой задержки. Потому будем продолжать искать проблему у себя. Пользуясь случаем, рекомендую к прочтению 14-ю часть «Сетей для самых маленьких» за авторством @eucariot Предельно упрощённо воркфлоу нашего тестового серверного приложения выглядит  следующим образом: А выделяется ли приложению процессорное время? Убеждаемся с помощью утилитки cpudist из проекта BCC. C ключом --offcpu она покажет распределение off-CPU-отрезков времени: Видим четыре паузы продолжительностью от 16 до 31 миллисекунд, что неожиданно. Тестовые приложения мы запускаем в guaranteed-подах на хостах со static cpu policy, то есть приложение прибито к конкретным процессорным ядрам, на которые K8s обещает не селить другие контейнеры. Разобраться поможет perf: соберём стектрейсы с ядер приложения раз в миллисекунду и найдём те, что не относятся к приложению: NAPI — фреймворк для написания драйверов сетевых карт в Linux, стандарт для general-purpose-драйверов. В основе дизайна — простая идея: нужно балансировать latency и накладные расходы на обработку прерываний, обрабатывая накопившуюся пачку пакетов разом, — и уступить процессор следующему потребителю. Для большинства задач это работает хорошо, но не в тех случаях, когда каждая миллисекунда на счету. Потому мы унесём сетевые прерывания на специальные ядра: Не забудем на время отключить irqbalance, чтобы наши изменения не были перезаписаны. После экспериментов же irqbalance можно будет включить обратно, подсунув ему policyscript. CPU0 и его SMP-пару не рекомендуется использовать для критичной к задержкам нагрузке, потому что на нём исполняются прерывания системных таймеров. Потому начинаем с единицы: Но унести прерывания мало. Необходимо также обеспечить возможность softirq-хендлерам перекладывать пакеты, когда это требуется. В основном этому будут мешать userspace-приложения, которые выполняют долгие (по нашим меркам) системные вызовы. Традиционный способ, доступный с 2004 года (ядро 2.6.9) — использование boot-флага ядра isolcpus: на обозначенные ядра по умолчанию не будут шедулиться userspace-приложения. Это работает для любого дистрибутива, поскольку не предъявляет требований к способу запуска приложений. У этого подхода, однако, есть существенный недостаток: его применение требует перезагрузки, что ограничивает cadence, с которым можно тюнить настройки. Для современных систем, так или иначе заворачивающих всё в cgroups, рекомендуется использовать cpuset. В случае systemd — расставить CPUAffinity и перезапустить сервисы. Но можно ли обойтись без перезапусков? Мы как лауреаты премии «Золотой костыль» нашли лазейку. Создадим отдельную пустую cgroup с флагом cpu_exclusive и отдадим в её распоряжение «сетевые» ядра: Было: Стало: Таким нехитрым способом мы получили функциональный эквивалент isolcpus, который можно менять в рантайме за единицы секунд. Применим на всём кластере — и получим заметный эффект: Span gap уменьшился и стал менее шумным: клиенты получают ответы за предсказуемое время. Итак, сегодня мы применили широкоизвестные инструменты диагностики Linux-based систем, и с помощью CPU affinity разделили обработку сетевого стека и исполнение приложений. В результате этого ускорилось кросс-сервисное взаимодействие приложений в нашем Kubernetes-кластере.  Безусловно, на этом рано ставить точку: хочется тратить на сеть меньше процессора — и перед нами возникает множество путей, о которых расскажем в следующий раз. Не переключайтесь и читайте Брендана Грегга.