При подготовке использовались материалы статьи “CPU considerations for Java applications running in Docker and Kubernetes”, автор – Christopher Batey.
Reactor-netty
Как гласит Википедия, Netty – это неблокирующий, event-driven, клиент-серверный фреймворк для разработки сетевых Java-приложений. Reactor-Netty, в свою очередь – TCP/HTTP/UDP клиент /сервер, который использует Java Reactor поверх Netty.
Для обеспечения максимальной доступности сервера, reactor-netty
(как и сам Netty) используют так называемый Event Loop, то есть бесконечный цикл, в котором несколько потоков ввода-вывода поочерёдно обрабатывают входящие запросы. Так как использование блокирующих операций в парадигме реактивного программирования не желательно (либо их следует выполнять в отдельном потоке или пуле, дабы не блокировать I/O), то потоки ввода-вывода довольно быстро завершают текущую задачу (ведь все вызовы – неблокирующие) и возвращаются в пул для обработки следующего запроса.
С таким подходом достигается высокая гибкость и масштабируемость. Используя несколько потоков в event loop
можно неплохо экономить ресурсы и повысить параллелизм приложения. Для сравнения, Tomcat
по умолчанию держит пул на 200 I/O потоков. Очевидно, что при работе на хосте, у которого ядер меньше, чем количество потоков, оверхэд, связанный с переключением контекста между потоками может быть значителен.
Масштабирование пулов в reactor-netty
Если заглянуть в исходники reactor-netty
на GitHub, то увидим, из каких соображений выбирается количество потоков I/O в пуле:
int DEFAULT_IO_WORKER_COUNT = Integer.parseInt(System.getProperty( ReactorNetty.IO_WORKER_COUNT, "" + Math.max(Runtime.getRuntime() .availableProcessors(), 4)));
Минимальное – 4, максимальное – равно числу ядер на машине. То есть на двухъядерном процессоре будет создано 4 потока, на восьмиядерном – все 8.
Такое масштабирование позволяет достигнуть приемлемого уровня параллелизма. Выходит, что Runtime.getRuntime().availableProcessors()
– важный метод, на который опирается множество фреймворков, он может значительно влиять на производительность приложения.
Внутри контейнера
До недавнего времени Java не брала в расчёт факт запуска JVM в контейнере и результат availableProcessors()
основывался только на ресурсах хоста, а не ресурсах, выделенных контейнеру. Обычно это приводило к тому, что пулы потоков внутри приложения были значительно больше, чем позволяли ресурсы контейнера.
С версий после 8u131 JVM принимает в расчёт ресурсы контейнера (баг-трекер OpenJDK).
Docker использует Linux Control Groups (cgroups) для ограничения использования ЦПУ контейнером.
cgroups
Прежде давайте посмотрим, как можно лимитировать использование ЦПУ контейнером.
cpu_shares – это относительное значение, своего рода вес каждого контейнера, показывает какую часть ресурсов процессора ему разрешено потребить. Само по себе оно ничего не показывает и имеет смысл только в сравнении со другими значениями, установленными для других контейнеров на этом же хосте. Оно так же никоим образом не указывает на количество ядер. Значение по умолчанию – 1024.
Например, если один контейнер имеет 512 shares, а другой – 1024, то последний получит в два раза больше процессорного времени, чем первый.
Если не установлено ограничений на количество циклов процессора, то каждый контейнер может занимать всё процессорное время хоста. Если рассматривать пример выше, то в случае простоя второго контейнера, первый может использовать все время ЦПУ , несмотря на более низкий приоритет.
cpu_quota – количество микросекунд на --cpu-period
, выделяемых на контейнер, прежде чем он начнёт троттлить.
Это количество процессорного времени, которое может быть использовано в каждый cpu-period
. Период по умолчанию равен 100 микросекундам, поэтому установка квоты в 50 микросекунд эквивалентна предоставлению контейнеру половины ядра, а 200 – двух ядер.
Например, хост с 64 ядрами и приложение с 20 потоками, для которого установлена квота в 200 микросекунд и время его работы за cpu-period
составляет 10 микросекунд. В таком случае все 20 потоков будут троттлить на оставшееся время периода (90 микросекунд).
Квоты позволяют получить более предсказуемую пропускную способность и серьезно повлиять на latency из-за возможного троттлинга.
Шэры и квоты могут применяться совместно: шэры определяют как должно распределиться процессорное время прежде чем контейнер достигнет своей квоты.
Квоты и шэры в Kubernetes
В Kubernetes есть собственная абстракция, которая называется millicores
. 1000 millicores примерно эквивалентны одному ядру.
Millicores также могут быть установлены в качестве limit
или request
. Request устанавливает share для приложения, при этом 1000 millicores = 1024 shares.
Если у нода в кластере Kubernetes есть 10 ядер, то он может отдавать контейнерам до 10 000 millicores или 10 240 shares. При таком подходе значение millicore request – это количество процессорного времени, которое получит текущий контейнер, когда остальные контейнеры на хосте находятся под нагрузкой.
Если же остальные контейнеры на хосте простаивают (или их просто нет), то текущий контейнер может использовать всё процессорное время.
Limit
Kubernetes’а играет роль квоты. Для каждых 1000 millicore Kubernetes даёт контейнеру время ЦПУ в размере квоты. Это устанавливает жёсткий лимит на потребление процессорного времени даже если часть циклов процессора простаивают.
Распространённый подход заключается в установке request без limit. В результате получается приемлемое распределение ресурсов, высокую утилизацию и справедливое распределение ресурсов под нагрузкой.
Обратная сторона такого подхода – сложности в планировании, так как количество ресурсов, доступных контейнеру, меняются в зависимости от того, что еще запущено на этом хосте.
Поддержка в JVM
До версии 8u131 JVM игнорировала шэры. В более поздних версиях Java делит количество шэр на 1024, чтобы рассчитать количество ядер, то есть в терминах виртуальной машины 1024 shares = 1 ядру. Такой перевод имеет и обратную сторону – даже если вы не устанавливали квоту, приложение может не задействовать всех ресурсов простаивающего хоста, так как ему просто не хватит потоков для покрытия всех ядер.
Конфигурирование
Для оптимальной производительности лучше использовать JDK 11 и устанавливать shares в соответствии с количеством ядер, на которое вы рассчитываете, умноженное на 1024.
Выводы
В нашем случае наибольшая производительность реактивного приложения на reactor-netty очевидно достигается при минимальном количестве ядер равном 4 (минимальное число потоков I/O). Так как Java приложения обычно имеют намного больше потоков чем имеется ядер в процессоре (например Java Reactor позволяет часть вызовов передавать в другие пулы, методы CompletableFuture асинхронное выполняются в ForkJoinPool, который, в свою очередь, масштабируется по количеству доступных ядер и т. д.), то не имеет смысла разбивать его на большое количество контейнеров, для каждого из которых выделено минимум ресурсов. Оптимальный вариант – это разбиение на среднее количество контейнеров со средним количеством ресурсов.
1 Response
[…] We’ve sampled CPU utilization for many times, profiling and tuning out JVM, even swapped from JDK8 to JDK11 since there was a bug in the early HotSpot 8 versions with Runtime.getRuntime().availableProcessors() (see my article in Russian here). […]