Почему один процесс NGINX берёт на себя всю работу?

в 15:02, , рубрики: epoll, nginx, системное администрирование

Метод масштабирования TCP-серверов, как правило, очевиден. Начни с одного процесса, когда будет нужно — просто добавь ещё. Так делают многие приложения, включая HTTP-серверы типа Apache, NGINX или Lighttpd.

Почему один процесс NGINX берёт на себя всю работу? - 1

Увеличение количества обслуживающих процессов это отличный метод решения проблемы использования всего одного ядра CPU, правда, ценой возникновения других проблем.

Существует три способа организации TCP-сервера относительно производительности:

а) один пассивный сокет, один обслуживающий процесс;

б) один пассивный сокет, множество обслуживающих процессов;

в) множество обслуживающих процессов, каждый имеет свой пассивный сокет.

Почему один процесс NGINX берёт на себя всю работу? - 2

Способ "а" наиболее простой за счёт ограничения в один доступный для обработки запросов CPU. Единственный процесс принимает соединения вызовом accept() и сам же их обслуживает. Этот способ является предпочтительным для Lighttpd.

Почему один процесс NGINX берёт на себя всю работу? - 3

При способе "б" новые соединения находятся в одной структуре данных ядра (пассивном сокете). Множество обслуживающих процессов вызывают на этом сокете accept() и обрабатывают полученные запросы. Способ позволяет в некоторых пределах балансировать входящие соединения между несколькими CPU и является стандартным для NGINX.

Почему один процесс NGINX берёт на себя всю работу? - 4

При помощи опции сокета SO_REUSEPORT возможно снабдить каждый процесс своей структурой ядра (пассивным сокетом). Это позволяет избежать конкуренции обслуживающих процессов за доступ к единственному сокету, что, впрочем, не должно быть особой проблемой, если только ваш трафик не сопоставим с Google. Также способ помогает лучше балансировать нагрузку, что будет показано ниже.

В Cloudflare мы используем NGINX, и потому лучше знакомы со способом "б". В статье будет описана его специфическая проблема.

Распределяем нагрузку accept()

Немногие знают, что есть два пути распределения новых соединений между несколькими процессами. Рассмотрим два листинга псевдокода. Назовём первый blocking-accept:

sd = bind(('127.0.0.1', 1024))  
for i in range(3):  
    if os.fork () == 0:
        while True:
            cd, _ = sd.accept()
            cd.close()
            print 'worker %d' % (i,)

Одна очередь новых соединений разделяется между процессами путём блокирующего вызова accept() в каждом из них.

Второй путь назовём epoll-and-accept:

sd = bind(('127.0.0.1', 1024))  
sd.setblocking(False)  
for i in range(3):  
    if os.fork () == 0:
        ed = select.epoll()
        ed.register(sd, EPOLLIN | EPOLLEXCLUSIVE)
        while True:
            ed.poll()
            cd, _ = sd.accept()
            cd.close()
            print 'worker %d' % (i,)

В каждом процессе имеется собственный событийный цикл на базе epoll. Неблокирующий accept() будет вызван только тогда, когда epoll возвестит о наличии новых соединений. Обычную в этом случае проблему thundering herd (когда при наступлении даже единственного события "просыпаются" все процессы, которые могут его обработать — прим. перев.) избегаем, используя флаг EPOLLEXCLUSIVE. Полный код доступен здесь.

Обе программы выглядят похоже, но поведение их слегка различается. Вот что будет, когда мы попробуем установить несколько соединений с каждой из них:

Примечание

Разумеется, сравнивать блокирующий accept() с циклом событий epoll() нечестно. Epoll является более мощным средством и позволяет создавать полноценные событийно-ориентированные программы. Использование же блокирующего приёма соединений громоздко. Для осмысленности такого подхода в реальных условиях потребуется скрупулёзное многопоточное программирование с выделением потока на каждый запрос.

Ещё один сюрприз — использование блокирующего accept() на Linux технически неверно! Alan Burlison указал, что при выполнении close() на пассивном сокете выполняющиеся на нём же блокирующие вызовы accept() не будут прерваны. Это может вылиться во внезапное поведение: успешный accept() на пассивном сокете, который более не существует. При малейших сомнениях избегайте использования блокирующего accept() в многопоточных программах. Обходное решение заключается в вызове shutdown() перед close(), но оно не соответствует стандарту POSIX. Чёрт ногу сломит.

$ ./blocking-accept.py &
$ for i in `seq 6`; do nc localhost 1024; done
worker 2  
worker 1  
worker 0  
worker 2  
worker 1  
worker 0

$ ./epoll-and-accept.py &
$ for i in `seq 6`; do nc localhost 1024; done
worker 0  
worker 0  
worker 0  
worker 0  
worker 0  
worker 0  

В программе blocking-accept соединения распределились между всеми обслуживающими процессами: каждый получил ровно два. В программе epoll-and-accept все соединения получил исключительно первый процесс, остальные же не получили ничего. Дело в том, что Linux по-разному осуществляет балансировку запросов в этих двух случаях.

В первом случае Linux сделает FIFO циклическое распределение. Каждый процесс, ожидающий возврата вызова accept(), становится в очередь и новые соединения получает также в порядке очереди.

В случае epoll-and-accept алгоритм другой: Linux, кажется, выбирает процесс, который был добавлен в очередь ожидания новых соединений последним, т.е. LIFO. Такое поведение приводит к получению большинства новых соединений самым "занятым" процессом, который только что вернулся к ожиданию новых событий после обработки запроса.

Это распределение мы и наблюдаем в NGINX. Ниже представлен вывод команды top с веб-сервера во время синтетического теста, при котором один из обслуживающих процессов получает больше нагрузки, а остальные — сравнительно меньше.

Почему один процесс NGINX берёт на себя всю работу? - 5

Обратите внимание, что последний в списке процесс практически не занят (менее 1% CPU), а первый потребляет 30% CPU.

SO_REUSEPORT спешит на помощь

Linux поддерживает опцию сокета SO_REUSEPORT, которая позволяет обойти описанную проблему балансировки нагрузки. Мы уже объясняли использование этой опции в способе "в", при котором входящие соединения распределяются по нескольким очередям вместо одной. Как правило, используется одна очередь на обслуживающий процесс.

В этом режиме Linux раздаёт новые соединения с помощью хеширования, что приводит к статистически равномерному распределению новых соединений и, как следствие, примерно равному количеству трафика для каждого процесса:

Почему один процесс NGINX берёт на себя всю работу? - 6

Теперь разброс загруженности процессов не так велик: лидер потребляет 13.2% CPU, а аутсайдер — 9.3%.

Что ж, распределение нагрузки стало лучше, но это ещё не вся история. Иногда разделение очередей приёма соединений ухудшает распределение задержки обработки запросов! Хорошее объяснение этому есть у The Engineer guy:

Я называю эту проблему "кассиры Waitrose против кассиров Tesco" (распространённые розничные сети в Британии — прим. перев.). Модель Waitrose "одна очередь ко всем кассирам" лучше уменьшает максимальную задержку. Один застопорившийся кассир не будет значительно влиять на остальных клиентов в очереди, ведь они пойдут к менее занятым сотрудникам. Модель же Tesco "каждому кассиру своя очередь" в этом же случае приведёт к увеличению времени обслуживания и конкретного клиента, и всех, кто стоит за ним.

В случае повышенной нагрузки способ "б" хоть и не распределяет нагрузку равномерно, но обеспечивает лучшее время ожидания ответа. Это можно показать синтетическим тестом. Ниже представлено распределение времени ответа для 100000 относительно требовательных к CPU запросов, 200 одновременных запросов без HTTP keepalive, обслуженных NGINX-ом при конфигурации по способу "б" (одна очередь на все процессы).

$ ./benchhttp -n 100000 -c 200 -r target:8181 http://a.a/
        | cut -d " " -f 1
        | ./mmhistogram -t "Duration in ms (single queue)"
min:3.61 avg:30.39 med=30.28 max:72.65 dev:1.58 count:100000  
Duration in ms (single queue):  
 value |-------------------------------------------------- count
     0 |                                                   0
     1 |                                                   0
     2 |                                                   1
     4 |                                                   16
     8 |                                                   67
    16 |************************************************** 91760
    32 |                                              **** 8155
    64 |                                                   1

Легко видно, что время ответа предсказуемо. Медиана почти равна среднему значению, а среднеквадратичное отклонение мало.

Результаты этого же теста, проведённого с NGINX при конфигурации по методу "в" с применением опции SO_REUSEPORT:

$ ./benchhttp -n 100000 -c 200 -r target:8181 http://a.a/
        | cut -d " " -f 1
        | ./mmhistogram -t "Duration in ms (multiple queues)"
min:1.49 avg:31.37 med=24.67 max:144.55 dev:25.27 count:100000  
Duration in ms (multiple queues):  
 value |-------------------------------------------------- count
     0 |                                                   0
     1 |                                                 * 1023
     2 |                                         ********* 5321
     4 |                                 ***************** 9986
     8 |                  ******************************** 18443
    16 |    ********************************************** 25852
    32 |************************************************** 27949
    64 |                              ******************** 11368
   128 |                                                   58

Среднее значение сравнимо с таковым в предыдущем тесте, медиана снизилась, но максимальное значение существенно увеличилось и, что самое важное, среднеквадратичное отклонение теперь просто огромное. Время обслуживания изменяется в широких пределах — определённо не та ситуация, которую хотелось бы иметь на боевом сервере.

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

Примечание

Для использования NGINX и SO_REUSEPORT необходимо соблюдение нескольких условий. Сперва убедитесь, что используется NGINX версии 1.13.6 и выше, либо примените этот патч. Во-вторых, помните, что из-за дефекта в Linux-реализации TCP REUSEPORT уменьшение количества очередей REUSEPORT вызовет отбрасывание некоторых ожидающих TCP-соединений.

Заключение

Проблема балансировки входящих соединений между несколькими процессами одного приложения далека от решения. Использование одной очереди по методу "б" хорошо масштабируется и позволяет держать максимальное время ответа на приемлемом уровне, но из-за механики работы epoll нагрузка будет распределяться неравномерно.

Для нагрузок, требующих равномерного распределения между обслуживающими процессами, может быть полезно использовать флаг SO_REUSEPORT по методу "в". К сожалению, в ситуации высокой нагрузки распределение времени ответа может значительно испортиться.

Лучшим решением кажется смена стандартного поведения epoll с LIFO на FIFO. Jason Baron из Akamai уже пытался это сделать (1, 2, 3), но пока эти изменения не попали в ядро.

Разъяснение: переводчик никак не связан с Cloudflare, Inc. Перевод сделан из любви к искусству, все права у их обладателей. Автор КДПВ Paul Townsend, CC BY-SA 2.0.

Автор: rzerda

Источник


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


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