Введение
Совсем недавно, 25 января 2022 года вышел новый релиз Nginx - 1.21.6, в котором исправлена проблема неравномерного распределения входящих соединений между несколькими worker процессами в дефолтной конфигурации на Linux системах. Если конкретнее - use epoll, accept_mutex off, reuseport выключен.
В данной конфигурации при определенном характере нагрузки большинство входящих в Nginx соединений обрабатывается лишь одним worker процессом.
Насколько я понимаю, эта проблема существует уже более пяти лет и берет начало в версии 1.11.3 (Jul 2016), когда в Nginx по умолчанию отключили accept_mutex, а вместо него стали полагаться на флаг EPOLLEXCLUSIVE, появившийся в ядре Linux 4.5.
Стоит заметить, что проблема балансировки входящих соединений при использовании механизма epoll и флага EPOLLEXCLUSIVE более глобальна и касается не только Nginx, а любых сетевых приложений, работающих по аналогичной схеме.
В этой статье мы посмотрим на историю и причины появления данной проблемы, а также рассмотрим код ее решения в новом релизе Nginx.
Появление EPOLLEXCLUSIVE
Начать стоит с 2015 года, когда Jason Baron предложил патч ядра Linux, в котором добавлялась поддержка двух новых флагов - EPOLLEXCLUSIVE и EPOLLROUNDROBIN для системного вызова epoll_ctl.
Первый из них предназначался для решения так называемой Thundering herd problem - ситуации, когда при наступлении события, которого ожидало множество процессов на одном и том же файловом дескрипторе, система пробуждала их все, но в итоге, обработать событие мог лишь один из этих процессов. Подобная ситуация характерна для случая, когда множество worker процессов заблокированы на одном и том же listening сокете в ожидании входящих соединений, используя вызов epoll_wait. При поступлении нового соединения возникает событие EPOLLIN и операционная система пробуждает все ожидающие worker процессы, однако, принять соединение вызовом accept сможет лишь один из них, остальные же при попытке вызова accept получат ошибку EAGAIN. На серверах это приводило к созданию лишней нагрузки и пустой трате ресурсов.
Введение нового флага EPOLLEXCLUSIVE позволяло решить эту проблему - из множества процессов с данным флагом, ожидающих события на общем дескрипторе, при его наступлении разблокирован должен быть лишь один из процессов. Исходя из реализации, этим процессом был первый процесс, добавивший дескриптор функцией epoll_ctl с флагом EPOLLEXCLUSIVE, и ожидающий события в epoll_wait.
Но это влекло за собой другую проблему - если после пробуждения, процесс достаточно быстро обрабатывал событие и вновь возвращался в очередь ожидания вызовом epoll_wait, то при возникновении следующего аналогичного события для его обработки ядро разблокирует тот же самый процесс. Данное поведение по своей сути не является ошибочным, а как писали в комментариях к патчу - в общем случае является желаемым и способствует cache locality процессоров, так как обработкой будет заниматься уже "прогретый" процесс. Однако, для определенных программ, таких как Nginx, это создает неравномерное распределение входящих соединений между worker процессами, так как большинство соединений будут обрабатываться одним и тем же процессом.
Наибольшую неприятность это составляет для долгоживущих соединений, когда один из воркеров может быть перегружен вплоть до превышения лимита worker_connections, а другие процессы тем временем будут простаивать.
Флаг EPOLLROUNDROBIN, предложенный в том же патче, предназначался как раз для исключения подобной ситуации. В случае добавления дескриптора с этим флагом, пробужденный процесс должен был перемещаться в конец очереди и соответственно на следующем событии пробуждался уже другой процесс, что приводило к равномерной балансировке. Однако, в ядро этот функционал так и не попал, ввиду того, что в его реализации затрагивались структуры планировщика процессов, а добавление подобного функционала того не стоило.
В конечном итоге, патч с флагом EPOLLEXCLUSIVE был включен в ядро 4.5, а затем использован в Nginx начиная с версии 1.11.3, в которой по умолчанию был отключен accept_mutex служивший для выполнения аналогичной задачи (в каждый конкретный момент принимать соединения на общем сокете мог лишь один из воркеров - первый, кому удалось захватить мьютекс). После принятия соединения, воркер отпускал мьютекс и он мог быть захвачен уже другим процессом, таким образом осуществлялась балансирока запросов между worker процессами:
Changes with nginx 1.11.3 26 Jul 2016
*) Change: now the "accept_mutex" directive is turned off by default.
*) Feature: now nginx uses EPOLLEXCLUSIVE on Linux.
Как можно прочитать в документации
There is no need to enable accept_mutex on systems that support the EPOLLEXCLUSIVE flag (1.11.3) or when using reuseport.
О проблеме, возникшей после этих изменений уже упоминалось на хабре в переводе статьи сотрудника Cloudflare Почему один процесс NGINX берёт на себя всю работу?
Хочу заметить, что автор не совсем верно указал механизм, лежащий в основе данного поведения:
В случае epoll-and-accept алгоритм другой: Linux, кажется, выбирает процесс, который был добавлен в очередь ожидания новых соединений последним, т.е. LIFO.
Насколько я понял из чтения исходного кода патча и epoll, а также комментария в новом релизе Nginx, выбирается как раз первый из процессов, добавивших дескриптор вызовом epoll_ctl, и который в данный момент ожидает события в epoll_wait.
В 2019 году все тот же сотрудник Cloudflare пытался возродить дискуссию о добавлении флага EPOLLROUNDROBIN, указывая на то, что они успешно используют данный патч на своих серверах в течении последних 6 месяцев. В итоге обсуждение ни к чему не привело, закончившись указанием одного из разработчиков на то, что эта ситуация должна решаться на уровне приложения, а не ядра.
Fix
В новом релизе Nginx данный баг наконец-то исправили:
Changes with nginx 1.21.6 25 Jan 2022
*) Bugfix: when using EPOLLEXCLUSIVE on Linux client connections were
unevenly distributed among worker processes.
Ниже приведен код новой функции для обеспечения балансировки входящих соединений между worker процессами:
static void
ngx_reorder_accept_events(ngx_listening_t *ls)
{
ngx_connection_t *c;
/*
* Linux with EPOLLEXCLUSIVE usually notifies only the process which
* was first to add the listening socket to the epoll instance. As
* a result most of the connections are handled by the first worker
* process. To fix this, we re-add the socket periodically, so other
* workers will get a chance to accept connections.
*/
if (!ngx_use_exclusive_accept) {
return;
}
#if (NGX_HAVE_REUSEPORT)
if (ls->reuseport) {
return;
}
#endif
c = ls->connection;
if (c->requests++ % 16 != 0
&& ngx_accept_disabled <= 0)
{
return;
}
if (ngx_del_event(c->read, NGX_READ_EVENT, NGX_DISABLE_EVENT)
== NGX_ERROR)
{
return;
}
if (ngx_add_event(c->read, NGX_READ_EVENT, NGX_EXCLUSIVE_EVENT)
== NGX_ERROR)
{
return;
}
}
Как можно видеть, ребалансировка осуществляется путем удаления и повторного добавления отслеживания событий на listening сокете в worker процессах после каждых 16 принятых соединений. В результате исчезает ситуация, когда при поступлении новых соединений, система постоянно пробуждает один и тот же (первый добавленный) worker процесс, и теперь входящие соединения действительно распределяются достаточно равномерно.
Вобщем, если кто-то ранее уже сталкивался с данной проблемой и еще не в курсе, то похоже пришло время обновиться.
Думаю, что присутствующие на хабре разработчики Nginx смогут указать на возможные ошибки и неточности в моем описании этой ситуации.