Команды подразделения Netflix Compute and Performance Engineering регулярно анализируют происшествия, связанные с падением производительности программ, работающих в нашей многоарендной среде. Первый шаг такого анализа заключается определении того, что является источником проблемы: приложение или инфраструктура. Надо отметить, что подобные изыскания часто усложняет одна неприятность, известная как проблема «шумного соседа» («noisy neighbor»). На нашей многоарендной вычислительной платформе Titus «шумный сосед» представляет собой контейнер или системный сервис, который интенсивно использует серверные ресурсы, что приводит к падению производительности близких к нему контейнеров. Обычно мы уделяем особое внимание использованию CPU, так как именно за этот ресурс чаще всего борются наши рабочие нагрузки и их «шумные соседи».
Обнаружение эффекта «шумного соседа» — задача непростая. Обычные инструменты для анализа производительности, такие как perf, могут создать серьёзную нагрузку на систему, подвергнув её риску дальнейшего падения производительности. Кроме того, подобные инструменты часто разворачивают после того, как что-то случится, то есть — слишком поздно, что мешает эффективному анализу проблем. Ещё одна трудность заключается в том, что отладка ошибок, связанных с «шумными соседями», требует специализированных инструментов и большого опыта работы с низкоуровневыми механизмами систем. В этом материале мы расскажем о том, как мы применили eBPF для того, чтобы, на постоянной основе, инструментировать планировщик Linux, обеспечив мониторинг интересующих нас показателей и создав при этом лишь небольшую дополнительную нагрузку на систему. Это позволило нам построить эффективную и самодостаточную систему для выявления проблем, связанных с «шумными соседями». Из этой статьи вы узнаете о том, как инструментация Linux сможет улучшить наблюдаемость (observability) вашей инфраструктуры благодаря получению более глубоких аналитических сведений о ней и расширению возможностей мониторинга.
Непрерывный мониторинг планировщика Linux
Для того чтобы обеспечить надёжное функционирование наших рабочих нагрузок, зависящих от скорости ответов на запросы, мы инструментировали очередь готовых к выполнению задач (run queue) каждого контейнера. А именно, мы организовали наблюдение за временем, которое процессы проводят в очередях до получения процессорного времени. Слишком длительное ожидание процесса в этой очереди может свидетельствовать о проблемах с производительностью, особенно в тех случаях, когда контейнер не использует выделенные ему ресурсы CPU в полном объёме. Непрерывный мониторинг, достигаемый посредством инструментации, чрезвычайно важен для обнаружения подобных проблем по мере их появления. Технология eBPF, хуки которой подключаются к планировщику Linux и создают минимальную дополнительную нагрузку на систему, позволила нам эффективно наблюдать за временем, которое процессы проводят в очередях.
Для получения метрики, отражающей время пребывания процессов в очереди, мы использовали три хука eBPF: sched_wakeup
, sched_wakeup_new
и sched_switch
.
Хуки sched_wakeup
и sched_wakeup_new
вызываются, когда состояние процесса меняется со sleeping
на runnable
. Они позволяют нам идентифицировать тот момент, когда процесс готов к выполнению и ожидает выделения ему процессорного времени. Когда происходит это событие — мы генерируем временную метку и сохраняем её в хеш-таблице eBPF, используя в качестве ключа ID процесса.
struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, MAX_TASK_ENTRIES); __uint(key_size, sizeof(u32)); __uint(value_size, sizeof(u64)); } runq_enqueued SEC(".maps"); SEC("tp_btf/sched_wakeup") int tp_sched_wakeup(u64 *ctx) { struct task_struct *task = (void *)ctx[0]; u32 pid = task->pid; u64 ts = bpf_ktime_get_ns(); bpf_map_update_elem(&runq_enqueued, &pid, &ts, BPF_NOEXIST); return 0; }
А хук sched_switch
срабатывает тогда, когда CPU переключается между процессами. Этот хук предоставляет нам указатели на процесс, в настоящий момент использующий CPU, и на процесс, который готовится приступить к выполнению. Мы используем ID процесса (PID) следующей задачи для получения временной метки из хеш-таблицы eBPF. Эта метка, сохранённая ранее, указывает на то время, когда процесс был поставлен в очередь. Затем мы вычисляем время, которое процесс проводит в очереди, просто находя разность между двумя временными метками.
SEC("tp_btf/sched_switch") int tp_sched_switch(u64 *ctx) { struct task_struct *prev = (struct task_struct *)ctx[1]; struct task_struct *next = (struct task_struct *)ctx[2]; u32 prev_pid = prev->pid; u32 next_pid = next->pid; // загрузить временную метку, отражающую момент постановки следующей задачи в очередь u64 *tsp = bpf_map_lookup_elem(&runq_enqueued, &next_pid); if (tsp == NULL) { return 0; // сведения о постановке в очередь не обнаружены } // вычислить показатель runq.latency до удаления сохранённой временной метки u64 now = bpf_ktime_get_ns(); u64 runq_lat = now - *tsp; // удалить pid из хеш-таблицы bpf_map_delete_elem(&runq_enqueued, &next_pid); ....
Одной из сильных сторон eBPF является возможность предоставления указателей на реальные структуры данных ядра, представляющие процессы или потоки, которые, пользуясь терминологией ядра, ещё называют задачами. Это даёт нам доступ к огромному объёму информации о процессе. Нам необходим cgroup ID — идентификатор группы процесса. Этот идентификатор позволяет установить связь между процессом и контейнером, без чего в нашей ситуации не обойтись. Но в структуре, описывающей процесс, данные о cgroup защищены RCU (Read Copy Update)-блокировкой.
Для обеспечения безопасного доступа к данным, защищённым RCU, мы, в eBPF, можем воспользоваться kfunks. Это — набор «ядерных» функций, которые можно вызывать из eBPF-программ. В этом наборе имеются функции для блокировки и разблокировки чтения критически важных данных, защищённых RCU. Эти функции обеспечивают безопасную и эффективную работу eBPF-программы, давая при этом возможность получения cgroup ID из структуры, содержащей сведения о задаче.
void bpf_rcu_read_lock(void) __ksym; void bpf_rcu_read_unlock(void) __ksym; u64 get_task_cgroup_id(struct task_struct *task) { struct css_set *cgroups; u64 cgroup_id; bpf_rcu_read_lock(); cgroups = task->cgroups; cgroup_id = cgroups->dfl_cgrp->kn->id; bpf_rcu_read_unlock(); return cgroup_id; }
После того, как данные будут готовы, мы должны их упаковать и отправить в пользовательское пространство. Для решения этой задачи мы решили воспользоваться кольцевым буфером (ring buffer) eBPF. Это — эффективный, высокопроизводительный и удобный механизм. Он может работать с записями переменной длины и позволяет организовать чтение данных без необходимости выполнения дополнительных операций копирования или выполнения системных вызовов. Но, так как в нашем случае речь идёт об огромном количестве единиц информации, оказалось, что программа, работающая в пользовательском пространстве, использует слишком много ресурсов процессора. Поэтому мы реализовали в eBPF систему ограничения частоты выборки данных.
struct { __uint(type, BPF_MAP_TYPE_RINGBUF); __uint(max_entries, RINGBUF_SIZE_BYTES); } events SEC(".maps"); struct { __uint(type, BPF_MAP_TYPE_PERCPU_HASH); __uint(max_entries, MAX_TASK_ENTRIES); __uint(key_size, sizeof(u64)); __uint(value_size, sizeof(u64)); } cgroup_id_to_last_event_ts SEC(".maps"); struct runq_event { u64 prev_cgroup_id; u64 cgroup_id; u64 runq_lat; u64 ts; }; SEC("tp_btf/sched_switch") int tp_sched_switch(u64 *ctx) { // .... // Предыдущий код // .... u64 prev_cgroup_id = get_task_cgroup_id(prev); u64 cgroup_id = get_task_cgroup_id(next); // Ограничение частоты выборки данных для cgroupid и конкретного CPU // для достижения баланса наблюдаемости и дополнительной нагрузки на систему u64 *last_ts = bpf_map_lookup_elem(&cgroup_id_to_last_event_ts, &cgroup_id); u64 last_ts_val = last_ts == NULL ? 0 : *last_ts; // проверяем ограничение частоты выборки данных для текущего cgroup_id // прежде чем делать что-то ещё if (now - last_ts_val < RATE_LIMIT_NS) { // Ограничение достигнуто, отбросить событие return 0; } struct runq_event *event; event = bpf_ringbuf_reserve(&events, sizeof(*event), 0); if (event) { event->prev_cgroup_id = prev_cgroup_id; event->cgroup_id = cgroup_id; event->runq_lat = runq_lat; event->ts = now; bpf_ringbuf_submit(event, 0); // Обновить временную метку последнего события для текущего cgroup_id bpf_map_update_elem(&cgroup_id_to_last_event_ts, &cgroup_id, &now, BPF_ANY); } return 0; }
Наше приложение, работающее в пользовательском пространстве, написано на Go. Оно обрабатывает события из кольцевого буфера и формирует метрики, которые поступают в нашу бэкенд-систему для работы с метриками, которая называется Atlas. Каждое событие включает в себя сведения о показателе, характеризующем время пребывания задач в очереди, и о cgroup ID, который мы связываем с контейнерами, выполняемыми на некоем хосте. Если подобной связи не выявлено — мы считаем процесс системным сервисом. А когда cgroup ID связан с контейнером — мы формируем для этого контейнера метрику Atlas (runq.latency
), представляющую собой перцентильный временной показатель. Мы, кроме того, инкрементируем показатель метрики sched.switch.out
, отражающей ситуацию с вытеснением задач контейнера. Доступ к prev_cgroup_id
вытесненного процесса позволяет снабжать эту метрику сведениями о причинах вытеснения, о том, вытеснен ли процесс другим процессом того же контейнера (или той же cgroup), процессом из другого контейнера, или системным сервисом.
Хочется особо отметить то, что нам, для определения того, мешает ли некоему контейнеру «шумный сосед», необходимы обе метрики — и runq.latency
, и sched.switch.out
. Именно в этом заключается наша цель. Если мы будем анализировать только метрику runq.latency
— это может привести к неверному пониманию происходящего. Предположим, контейнер работает на уровне предельных значений ресурсов CPU, выделенных для его cgroup, или требует ещё больше ресурсов. В таком случае планировщик будет его «притормаживать», что даст хорошо заметный пик показателя runq.latency
, вызванный задержками процессов в очереди. Если рассматривать только эту метрику — можно ошибочно признать виновником падения производительности «шумного соседа», а не сам контейнер, который просто исчерпал выделенные ему процессорные ресурсы. Но одновременные пики обеих метрик, особенно когда их причиной являются разные процессы контейнеров или системные процессы, чётко указывают на проблему «шумного соседа».
История о «шумном соседе»
Ниже показано графическое представление метрики runq.latency
сервера, на котором выполняется единственный контейнер и который обладает более чем достаточными процессорными ресурсами. Среднее значение 99 перцентиля находится в районе 83,4 мкс (микросекунд). Это значение играет для нас роль точки отсчёта. Хотя на графике и имеется несколько пиков, достигающих 400 мкс, задержки выполнения процессов находятся в допустимых пределах.
В 10:35 мы, на том же хосте, запускаем контейнер container2
, который потребляет все процессорные ресурсы этого хоста, что приводит к серьёзному 131-миллисекундному всплеску (это — 131000 микросекунд) в P99-метрике runq.latency
контейнера container1
. Этот всплеск оказался бы заметным в пользовательском приложении, если бы оно отвечало за отдачу HTTP-трафика. Если бы владелец приложения пользовательского пространства сообщил о необъяснимом росте задержек — мы смогли бы быстро выявить проблему «шумного соседа» благодаря метрикам, отражающим ситуацию в очереди задач, готовых к выполнению.
Метрика sched.switch.out
показывает, что причиной всплеска было усиление вытеснения процессов контейнера системными процессами. Это указывает на возникновение проблемы «шумного соседа», когда системные сервисы и контейнеры сражаются друг с другом за процессорное время. Наши метрики указывают на то, что «шумные соседи» — это системные процессы, вероятнее всего, запущенные контейнером container2
, и потребляющие все доступные процессорные мощности.
Оптимизация eBPF-кода
Мы разработали опенсорсный инструмент для мониторинга eBPF, названный bpftop. Он предназначен для измерения дополнительной нагрузки на систему, создаваемой eBPF при применении этой технологии для анализа некоего работающего процесса. Профилирование системы с помощью bpftop
показало, что инструментирование замедляет каждый хук sched_*
менее чем на 600 наносекунд. Мы провели анализ производительности Java-сервиса, работающего в контейнере, и выяснили, что инструментирование не создаёт значительной дополнительной нагрузки на систему. Разница в производительности системы, в которой включён мониторинг очереди планирования процессов, и системы, в которой мониторинг не включён, не исчисляется даже миллисекундами.
В ходе исследования того, как именно eBPF работает в ядре, вычисляя нужные нам показатели, мы увидели возможность улучшения этих вычислений. Для этого мы подготовили и отправили в репозиторий Linux этот патч, который был включён в состав ядра Linux 6.10.
Идя путём проб и ошибок, мы, применяя bpftop, идентифицировали несколько возможностей для оптимизаций, помогающих поддерживать низкий уровень дополнительной нагрузки на систему, которую создаёт eBPF-код.
-
Мы обнаружили, что структура данных типа
BPF_MAP_TYPE_HASH
показала наилучшую производительность при хранении временных меток процессов, поставленных в очередь. ИспользованиеBPF_MAP_TYPE_TASK_STORAGE
привело к почти двукратному падению производительности. Структура данныхBPF_MAP_TYPE_PERCPU_HASH
оказалась лишь немного медленнееBPF_MAP_TYPE_HASH
. Это — неожиданный результат, объяснение которого требует дальнейших исследований. -
Хеш-таблица типа
BPF_MAP_TYPE_LRU_HASH
тратит на одну операцию на 40–50 наносекунд больше, чем обычные хеш-таблицы. Изначально мы, для хранения временных меток, использовали обычные хеш-таблицы, опасаясь того, что особенности работы с PID могут привести к исчерпанию пространства, выделенного для таблиц. В итоге же мы остановились наBPF_MAP_TYPE_HASH
увеличенного размера, стремясь снизить вышеозначенный риск. -
Вызов вспомогательной функции
BPF_CORE_READ
занимает 20-30 наносекунд. В случае с исходными метками инструментирования, особенно с теми, в которых «включён BPF» (tp_btf/*
), безопаснее и эффективнее напрямую обращаться к членам структуры, описывающей задачу. Такой подход рекомендован в этом материале. -
Хуки
sched_switch
,sched_wakeup
иsched_wakeup_new
вызываются для задач ядра, идентифицировать которые можно по их PID, который представлен числом 0. Мы выяснили, что в мониторинге этих задач нет необходимости, поэтому реализовали несколько условий для раннего завершения выполнения кода. Так же мы включили в код условные конструкции, направленные на предотвращение выполнения «тяжёлых» операций. Среди них — доступ к BPF-таблицам при работе с задачей ядра. Примечательно то, что задачи ядра проходят через очередь планировщика так же, как и обычные процессы.
Итоги
Наши находки подчёркивают ценность применения eBPF для решения задач непрерывного и экономичного инструментирования ядра Linux. Мы интегрировали соответствующие метрики в панели управления клиентов, давая им полезные сведения и позволяя аргументированно рассуждать о производительности кода, работающего в многоарендных средах. Мы, кроме того, теперь можем использовать эти метрики для оптимизации стратегий изоляции CPU, направленных на минимизацию проблемы «шумных соседей». И наконец — благодаря этим метрикам мы стали лучше понимать особенности работы планировщика Linux.
Это исследование помогло нам лучше понять технологию eBPF, а так же — подчеркнуло важность инструментов наподобие bpftop
, позволяющих оптимизировать eBPF-код. Мы ожидаем, что, по мере роста популярности eBPF, эта технология будет использоваться во всё большем количестве решений, обеспечивающих наблюдаемость систем, и во всё большем количестве проектов, решающих рабочие задачи различных компаний. Один из многообещающих проектов из этой сферы называется sched_ext. Он способен произвести революцию в принятии решений, касающихся планирования выполнения процессов, и того, как эти решения соотносятся с нуждами конкретных рабочих нагрузок.
О, а приходите к нам работать? 🤗 💰
Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.
Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.
Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.
ссылка на оригинал статьи https://habr.com/ru/articles/859978/
Добавить комментарий