Алгоритмы в Unicorn

Unicorn это один из самых популярных веб-серверов для Ruby. Если кратко, то это Rack-совместимый классический pre-fork веб-сервер. Он следует принципу “Do one thing, do it well” и поэтому не умеет HTTPS, keep-alive, HTTP pipelining и не предназначен для обработки медленных клиентов. Кроме того, он поддерживает только Unix системы.

Я начинал разбиратсья с Unicorn просто из любопытства и неожиданно для себя начал погружаться в тонкости программирования под Unix. Оказалось, что Unicorn интенсивно использует системные вызовы для работы с процессами и коммуникации между ними. Также мне было интересно разбираться каким образом эти системные вызовы доступны в стандартных библиотеках Ruby.

В этом посте мы разберем по шагам как Unicorn работает изнутри, как организовано управление процессами, как обрабатываются входящие HTTP запросы и все то, что не описано в документации.

Содержание

Кратко об архитектуре

Unicorn использует традиционную для Unix многопроцессную модель конкурентности. Среди альтернативных вариантом можно упомянуть многопоточность, event loop, user-space threads (aka coroutines/goroutines) и, наконец, акторы.

В подходе с множеством процессов каждый входящий HTTP запрос обрабатывается в своем системном процессе операционной системы. Соответственно, количество одновременно обрабатываемых запросов ограничивается количеством запущенных процессов. В Unicorn запускается сконфигурированное количество процессов, которое подбирается экспериментально обычно исходя из количества ядер процессора и характера ожидаемой нагрузки (пропорции CPU-bounded и IO-bounded операций в обработке запросов).

Unicorn запускает один главный master процесс и процессы для обработки HTTP запросов, worker‘ы. Основная задача master процесса - управлять worker‘ами. В свою очередь master‘ом можно управлять системными сигналами, которые посылаются из консоли вручную или из shell-скрипта используя команду kill <сигнал> <PID> (например kill -s QUIT 2378). После запуска веб-сервера worker‘ы ожидают входящие сетевые соединения на сконфигурированных портах. Если говорить точнее, то можно задать не только локальный порт но и IP-адрес, если сервер имеет несколько сетевых интерфейсов. Поддерживаются как TCP так и UNIX сокеты.

Если посмотреть на вывод команды ps, можно легко различить master процесс и worker‘ы:

$ ps -f --forest -C ruby
UID        PID  PPID  C STIME TTY          TIME CMD
deployer 20398     1  0 13:02 ?        00:00:20 unicorn_rails master -c /var/www/sites/my-blog/current/config/unicorn.rb -D
deployer 21066 20398  0 13:03 ?        00:00:09  \_ unicorn_rails worker[0] -c /var/www/sites/my-blog/current/config/unicorn.rb -D
deployer 21068 20398  0 13:03 ?        00:00:10  \_ unicorn_rails worker[1] -c /var/www/sites/my-blog/current/config/unicorn.rb -D

В данной конфигурации поднято всего два дочерних worker процесса. Обратите внимание, что PPID worker‘ов совпадает с PID master процесса.

Может показаться странным, как несколько процессов могут совместно обрабатывать входящие соединения на одном и том же порту. Ведь обычно если один процесс слушает какой-то порт, он (этот порт) становится занятым и другой процесс уже не может открыть новый серверный сокет на этом порту. Совместное использование порта становится возможно из-за особенности архитектуры Unix и механизма создания дочерних процессов. master процесс при запуске читает конфигурацию и открывает серверные сокеты. Затем он порождает дочерние процессы worker‘ы, которые наследуют все его системные ресурсы. В том числе и файловые дескрипторы - открытые файлы, сокеты и Unix-pipe‘ы (подробнее о pipe‘ах здесь). Поэтому, фактически, в операционной системе создается только один серверный сокет с одной backlog-очередью входящих соединений, который доступен во всех worker‘ах. В этом случае операционная система сама распределяет входящие соединения из backlog-очереди среди всех свободных worker‘ов.

На самом деле есть и другой более универсальных механизм совместного использования порта произвольными не родственными процессами и Unicorn его даже поддерживает (reuseport опция команды listen в конфигурационном файле). Это так называемая опция отктырия сокета SO_REUSEPORT. Если указать ее при первом открытии сокета на порту в одном процессе, то любой другой процесс сможет тоже совместно использовать этот порт и открывать свой собственный серверный сокет на нем. В этом случае эти серверные сокеты будут независимыми и будут иметь свои собственные backlog-очереди входящих соединений.

Ссылки:

Запуск Unicorn

parse command line options
if passed "-D" or "—daemonize" command line option
    daemonize
start HTTP server

source

Unicorn запускается консольной командой unicorn из директории приложения. Поддерживается совместимость с опциями таких команд как ruby и rackup (из gem‘а Rack).

В Unicorn намеренно избегают конфигурацию через опции командной строки в пользу конфигурационного файла, который обычно кладут в config/unicorn.rb. Это упрощает код так как в этом случае есть только один источник конфигурации вместо двух.

Пример. Запуск веб-сервера программно

Поскольку Unicorn написан на Ruby, то все его компоненты доступны программно. Если опустить шаг конфигурации и демонизации, то можно легко запустить полноценный веб-сервер с настройками по умолчанию в IRB или Ruby-скрипте. Единственное, что необходимо указать, это адрес (хост и порт), на котором Unicorn будет принимать входящие соединения.

Рассмотрим пример.

Это минимальный пример Rack-приложения, который по конвенции должен находиться в файле ‘config.ru’. Согласно Rack-спецификации application должен быть callable объект, который принимая данные о запросе будет возвращать ответ в виде массива из кода ответа, заголовков и body (массива строк). Если быть точнее, то body может быть любым объектом, не только массивом, который реализует метод each. При каждом вызове each должен возвращаться очередной фрагмент данных.

# config.ru

application = -> (env) {
  [200, {"Content-Type" => "text/plain"}, ["Hello from Rack"]]
}

run application

В этом файле мы, собственно, и запускаем веб-сервер

# launcher.rb

require 'rack'
require 'unicorn'

raw = File.read('config.ru')
app = -> {
  eval("Rack::Builder.new {(\n#{raw}\n)}.to_app")
}

options = {
  listeners: ['127.0.0.1:8080']
}

Unicorn::HttpServer.new(app, options).start.join

Здесь мы загружаем приложение из ‘config.ru’ файла, формируем его с помощью Rack::Builder и оборачиваем этот процесс в безымянную функцию, чтобы Unicorn мог выполнить загрузку приложения отложено (анализируя опцию preload_app).

Далее командой

Unicorn::HttpServer.new(app, options).start.join

запускается сам веб-сервер. Метод start запустит worker‘ы, а join будет обрабатывать управляющие системные сигналы и контролировать worker‘ы до самого завершения работы.

Если запустить следующую команду в консоле, то увидим вывод Unicirn’а при старте:

# shell

> ruby launcher.rb
I, [2019-07-03T18:53:16.635004 #9568]  INFO -- : listening on addr=127.0.0.1:8080 fd=15
I, [2019-07-03T18:53:16.635162 #9568]  INFO -- : worker=0 spawning...
I, [2019-07-03T18:53:16.637101 #9568]  INFO -- : master process ready
I, [2019-07-03T18:53:16.640607 #10127]  INFO -- : worker=0 spawned pid=10127
I, [2019-07-03T18:53:16.642166 #10127]  INFO -- : Refreshing Gem list
I, [2019-07-03T18:53:16.649230 #10127]  INFO -- : worker=0 ready
>

Если в другой консоле послать HTTP запрос, то увидим ответ приложения:

# shell

> curl 127.0.0.1:8080
Hello from Rack

Демонизация

# grandparent process:

open pair of Unix pipes
fork
wait for incoming data in Unix-pipe

if pipe closed unexpectedly
    exit 1
exit 0
# parent process:

setsid
fork
exit

source

Здесь происходит следующее. Процесс, запущенный в консоле (grandparent), открывает Unix pipe для общения с дочерними процессами. Далее он создает дочерний процесс делая системный вызов fork (fork(2)) и засыпает ожидая входящие данные в pipe‘е. Любое сообщение в нем будет означать, что веб-сервер в дочернем процессе успешно запустился. Получив это уведомление grandparent процесс успешно завершается.

Если master процесс (внук) не смог стартовать веб-сервер и неуспешно завершился, то pipe закрывается операционной системой и в него шлется признак EOF (End Of File). Grandparent понимает, что master процесс завершился с ошибкой, выводит об этом сообщение в STDOUT и сам завершается.

Дочерний (parent) процесс выполняет только одно действие - делает системный вызов setsid (setsid(2)). Как следствие создается новая сессия процессов, в ней создается новая группа процессов и parent процесс становится лидером как сессии так и группы. Все дочерние процессы (как master так и worker‘ы) будут входить в эту группу и сессию.

Далее parent процесс создает дочерний master процесс и завершается.

Ссылки:

Запуск HTTP сервера

save start context
inherit listeners
open Unix pipe (self-pipe)
set up system signal handlers

write pid file

if configured to preload application
    load application from config.ru file

bind and listen to addresses
spawn worker processes
notify grandparent process about server starting successfully
monitor workers

source

При запуске веб-сервера из консоли вначале сохраняется сама это команда и ее параметры (начальный контекст). Сохраняются $0 - путь к исполняемому файлу Unicorn, ARGV - параметры командной строки и CWD (current working directory) - путь к директории приложения, где был запущен Unicorn. Эти данные нужны позднее для перезапуска сервера. (source)

Далее активируются унаследование от старого master‘а серверные сокеты, если происходит перезапуск веб-сервера (посылкой сигнала USR2).

Затем создается еще один Unix-pipe (назовем его self-pipe). О нем будет подробнее чуть позже.

После этого регистрируются обработчики системных сигналов. Unicorn обрабатывает следующие сигналы: WINCH, QUIT, INT, TERM, USR1, USR2, HUP, TTIN, TTOU и CHLD.

В конфигурационном файле можно задать опцию preload_app. Это означает, что приложение будет загружено и инициализировано заранее в master процессе, а worker‘ы получат уже загруженное приложение, готовое к обработке входящих запросов. Это делает запуск нового worker‘а практически мгновенным. Если эта опция не выставлена, то каждый worker будет загружать и инициализировать приложение независимо. Это никак не влияет на время запуска самого Unicorn’а, но будет вызывать дополнительную задержку при запуске нового worker‘а на лету (если послать сигнал TTIN) или при автоматичеком запуске worker‘а взамен внезапно завершенному. В большом Rails приложении старт окружения может занимать 30-50 секунд, поэтому preload_app это must have опция.

Еще одно преимущество предварительной загрузки приложения это экономия оперативной памяти в процессах worker‘ах. Из-за механизма операционных систем Copy-On-Write память занимаемая Ruby-приложением может разделяться между worker‘ами и master‘ом. В большом Rails приложении процесс worker‘а может занимать до 1-1.5 Gb, поэтому экономия памяти будет очень заметной.

Загрузка самого приложение оборачивается в анонимную функцию и выполняется либо в master процессе либо в worker‘ах. Для поддержки Rack DSL используется Rack::Builder класс из Rack. (source)

Для всех сконфигурированных адресов (кроме унаследованных от предыдущего master‘а) открываются новые серверные сокеты. Задавая параметры tries и delay для каждого адреса независимо можно настроить повторные попытки открытия сокета, если адрес еще занят. По умолчанию будет 5 попыток с задержкой в 0.5 секунд. (source)

Далее master создает процессы worker‘ы, шлет сообщение в унаследованный от grandparent‘а pipe, после чего тот завершается, и переходит в режим ожидания. В этом режиме master обрабатывает входящие управляющие системные сигналы и мониторит состояние worker‘ов.

Активация унаследованных сокетов

При перезапуске Unicorn используя связку системных вызовов fork+exec создает новый master процесс, который наследует все файловые дескрипторы родительского процесса. Наследуются в том числе и открытые серверные сокеты. Хотя сами Ruby объекты сокетов при перезапуске в новом master‘е конечно становятся недоступными (после системного вызова exec запускается новая Ruby VM), но файловые дескрипторы все еще можно использовать. Файловые дескрипторы это просто числа - номера из таблицы дескрипторов процесса, а после системного вызова fork дочерний процесс получает копию родительской таблицы файловых дескрипторов. Не смотря на то, что они называются файловыми, дескрипторы могут указывать и на другие системные объекты - сокеты и pipe‘ы.

Учитывая это, чтобы избежать downtime‘а, старый master при перезапуске сохраняет дескрипторы открытых серверных сокетов (aka слушающие сокеты) в переменную окружения UNICORN_FD. Новый master их активирует и создает новые Ruby объекты сокетов продолжая принимать входящие соединения по ним и обрабатывать новые запросы. (source)

Также Unicorn поддерживает интеграцию с systemd и активацию сокетов открытых внешним супервизором. Если настроить Unicorn как сервис в systemd и сконфигурировать активацию сокетов, то systemd будет открывать серверные сокеты заранее и при запуске Unicorn’а выставлять переменные окружения LISTEN_PID и LISTEN_FDS. Сконфигурированные серверные сокеты будут доступны в процессе Unicorn’а по дескрипторам начиная с 3 (0-2 уже заняты - stdin, stdout и stderr). В LISTEN_FDS передается число активированных сокетов, а в LISTEN_PID - PID процесса, которому предназначены эти дескрипторы. Так как переменная окружения может быть доступна в других дочерних процессах, то важно активировать сокеты только в указанном процессе. Unicorn добавляет эти сокеты к другим, унаследованным при перезапуске сокетам от старого master‘а (в случае если происходит перезапуск). Фактически Unicorn реализует функцию sd_listen_fds (sd_listen_fds(3)). (source)

Ссылки:

Self-pipe трюк

Self-pipe трюк это стандартный способ в Unix обрабатывать входящие сигналы процессов. Сигналы - это один из механизмов IPC (Inter-process communication). Процесс может зарегистрировать свой обработчик конкретного сигнала и он будет вызываться операционной системой каждый раз, когда этому процессу отправят такой сигнал.

Трюк заключается в следующем. Процесс открывает Unix pipe (self-pipe), регистрирует обработчики сигналов и блокирующе ждет данные в этом pipe. Обработчики сигналов не содержат никакой логики и просто пишут в этот pipe сообщение о пришедшем сигнале, чтобы разбудить ждущий основной поток выполнения процесса (source, source)). Прочитав событие из pipe‘а процесс синхронно обрабатывает его и снова ждет новых событий.

Такой подход должен решить проблему связанную с обработкой сигналов - проблему re-entrancy. Когда процессу приходит сигнал операционная система прерывает выполнение процесса и в одноим из его потоков (в любом произвольном на самом деле) выполняет обработчик этого сигнала (стандартный или переопределенный). Код приложения может быть прерван в любом месте - может быть прерван системный вызов (и это может привести к его завершению с ошибкой EINT), может быть прерван другой обработчик сигнала и даже этот же самый обработчик, если сигнал был послан несколько раз подряд и предыдущих обработчик еще не успел завершиться. Поэтому код обработчика должен обладать свойством reentrancy, т.е. может быть безопасно прерван самим собой.

Именно поэтому обработчики сигналов здесь не содержат системных вызовов и выполняются максимально быстро - идентификатор сигнала просто дописывается в Ruby-массив для отложенной обработки. Операция добавления элемента в массив в Ruby не атомарная, но здесь, видимо, этим пренебрегают. Теоретически элемент массива может быть перезаписан параллельной операцией и потеряться.

Даже системные вызовы делятся на группы по этому признаку на reentrant и не- reentrant.

Ссылки:

Запуск процессов worker’ов

# master process:

until all workers are created
    choose next worker id (index number)
    open pair of Unix pipes
    call before_fork hook

    spawn or fork new process
# worker process:

seed OpenSSL PRNG
set up new handlers for :QUIT, :TERM, :INT signals - exit
set up handler for USR1 - reopen log files
restore default system handler for :CHLD signal

if master received signal to exit but didn't process it yet
    exit 0

call after_fork hook

if configured new user
    change process owner user

if not (configured to preload application)
    load application from config.ru file

call after_worker_ready hook
set up handler for QUIT - close sockets and stop handling requests
start serving incoming connections

source

Unicorn запускает указанное в конфиге количество worker‘ов (по умолчанию только 1). Более того, можно изменять количество worker‘ов динамически без перезапуска веб-сервера используя сигналы TTIN (увеличить на 1) и TTOU (уменьшить на 1). Процесс worker‘а может завершиться сам или быть убитым master‘ом если время обработки HTTP запроса превысило заданный timeout. В этом случае будет поднят новый процесс, чтобы сохранить заданное количество запущенных worker‘ов.

Каждый worker имеет свой уникальный номер. Он может быть повторно использован если процесс был завершен и новому процессу присвоят наименьший свободный номер.

Далее создается пара Unix-pipe‘ов. master процесс и worker‘ы могут общаться через них, ведь worker‘ы наследуют все открытые дескрипторы. master может управлять worker‘ом через один из pipe‘ов, а worker в свою очередь может определить неожиданное завершение master процесса по закрытию второго pipe‘а.

Затем запускается before_fork hook и master создает дочерний процесс делая системный вызов fork. Это обычная схема, которая работает с preload_app режимом.

Не так давно был добавлен (вот в этом коммите) другой механизм для запуска worker‘ов - fork + exec. Очевидно, что при этом появляются накладные расходы на загрузку приложения. Но таким образом усиливается безопасность. Новый процесс запускает независимую Ruby VM и не разделяет память с родительским процессов. Это мера направлена на борьбу с address space discovery attacks.

Этот механизм включается опцией worker_exec. Новый процесс worker‘а должен выполнить те же шаги, что и master процесс при перезапуске: проанализировать конфигурацию, активировать унаследованные файловые дескрипторы и загрузить приложение. Так как master процесс в этом случае не разделяет с worker‘ом ни память ни таблицу файловых дескрипторов, то для передачи worker‘у выбранного порядкового номера и двух Unix pipe‘ов для двунаправленного общения используется переменная окружения UNICORN_WORKER, в которую через запятую сохраняются эти три числа. (source)

Далее запускается after_fork hook. В него передаются два объекта (server и worker), интерфейс которых стабилен и открыт. Это, например, позволяет для отладки добавить еще один серверный сокет, специфичный для конкретного worker‘а, или запустить процесс под другим пользователем.

# config/unicorn.rb
after_fork do |server, worker|
  # per-process listener ports for debugging/admin:
  server.listen "127.0.0.1:#{9293 + worker.nr}"
  worker.user 'andrew'
end

Если приложение сконфигурировано с preload_app=true, то в after_fork обычно переоткрывают сетевые соединения, которые были созданы при загрузке приложения (например, к базе данных, Redis, RabbitMQ итд), иначе они будут одновременно использоваться всеми worker‘ами. К примеру, до Rails 5 нужно было это явно делать для pool‘а соединений к базе данных:

before_fork do |server, worker|
  ActiveRecord::Base.connection.disconnect!
end

after_fork do |server, worker|
  ActiveRecord::Base.establish_connection
end

Unicorn, в свою очередь, пытается бороться с утечкой файловых дескрипторов при перезапуске master‘а. Он закрывает все файловые дескрипторы (кроме серверных сокетов) в диапазоне 3…1024. Но при запуске worker‘а это не делается.

Если задана конфигурационная опция user, то worker‘ы будут запущены из под указанного пользователя (master останется неизменным). Меняется real и effective group ID и real и effective user ID процессов. Также меняется owner и group лог-файлов. Фактически работает та же логика определения файлов, что и при перечитывании конфиг-файлов (при обработке USR1 сигнала) - изменяются владелец и группа для всех открытых на запись/дозапись файлов в приложении. Любопытно, что для получения user id по username используется Etc.getgrnam (Std-lib) из Ruby Std-lib - это интерфейс к конфигурационным файлам из /etc в Unix’ах. (source)

Обратите внимание, что обработчик сигнала QUIT устанавливается два раза. В первый раз при старте процесса обработчик просто завершит процесс. Но после загрузки приложения и вызова всех hook‘ов и перед началом обработки входящих запросов обработчик уже выполнит “вежливую” остановку - закроет сокеты и дождется завершения текущих запросов и только затем процесс завершится.

Режим ожидания master’а

until receive :QUIT or :TERM, :INT signals
    process any unhandled incoming signals
    reap all zombie worker processes
    kill timeouted workers
    stop excessive workers or spawn missing

    wait for incoming signals with timeout

stop workers gracefully
unlink PID file

source

Согласно документации к завершению работы Unicorn’а приведут следующие сигналы:

  • QUIT - дождаться завершения обработки всех текущих HTTP запросов (graceful shutdown),
  • а TERM и INT - срочно остановить все worker‘ы (immediate shutdown)

Используя self-pipe трюк, обработчики входящих сигналов добавляют номер сигнала в Ruby-массив и записывают в Unix pipe сообщение ‘.’ (в принципе можно слать любые данные), чтобы разбудить ожидающий основной поток master‘а. master все время блокирующие читает из этого pipe‘а периодически просыпаясь по timeout‘у, чтобы проверить состояние worker‘ов. Когда приходит сообщение в pipe, он проверяет массив накопленных сигналов (обычно в нем только один элемент) и по очереди выполняет команды.

Далее проверяется есть ли завершенные дочерние процессы (“зомби”), зависшие процессы и было ли динамически изменено количество worker‘ов.

При завершении работы веб-сервера все worker‘ы “вежливо” завершаются и удаляется PID-файл.

Дочерние зомби-процессы

master процесс контролирует количество worker‘ов и поэтому должен определять когда worker процесс завершается. Возможны разные подходы, но здесь используется механизм операционной системы. Когда дочерний процесс завершается его родителю шлется системный сигнал CHLD, а сам процесс переходит в состояние “зомби”. Все его ресурсы освобождаются (оперативная память, дескрипторы, объекты ядра), но метаинформация (например код завершения) может быть полезна и остается в системе. Unicorn использует системный вызов waitpid (wait(2)) с флагом WNOHANG, чтобы без блокирования получить список PID очередных завершенный дочерних процессов.

Если есть завершенный процесс (worker), то master удаляет его из списка worker‘ов, закрывает pipe‘ы для него и вызывает after_worker_exit hook. (source)

Зависшие worker’ы

Unicorn отслеживает для каждого worker‘а время начала обработки последнего запроса. Если с этого момента прошло больше времени чем timeout, worker‘у шлется системных сигнал KILL и процесс останавливается.

Интересен механизм передачи этих данных от worker‘а master‘у. worker обновляет timestamp при получении/завершении запроса и эта информация становится доступной в процессе master‘а. Для этого используется gem raindrops, который позволяет совместно использовать счетчики всеми дочерними процессами через разделяемую память. Эта статистика даже может быть доступна на /_raindrops странице, если добавить специальное middleware (пример страницы статистики).

Занятно, но согласно реализованной логике, если worker обработал хотя бы один запрос и затем начал простаивать, то он будет завешен и поднимется новый worker с обнуленным счетчиком, который уже будет игнорироваться этим механизмом ровно до первого входящего запроса. (source)

Обработка сигналов

Управляющие сигналы обрабатываются следующим образом:

QUIT - “вежливо” (graceful) остановить веб-сервер. master рассылает всем worker‘ам команды “вежливо” остановиться. Команда передается worker‘у через заранее открытый pipe - в него просто записывается числовой номер сигнала. Далее с интервалом в 0.1 секунды master повторяет рассылку в течение сконфигурированного timeout‘а или пока все worker‘ы не остановятся и подчищает за процессами-зомби (делая системный вызов waitpid2). Если по завершению все таки остались какие-то worker‘ы, их останавливают принудительно посылкой системного сигнала KILL. (source)

Для TERM и INT процедура аналогичная, но в отличии от “вежливой” остановки, через pipe master отправляет worker‘ам номер другого сигнала - TERM вместо QUIT.

USR1 - переоткрыть файлы логов приложения. Распространена практика периодически (например раз в день) архивировать логи приложения, перемещать их в другую директорию или просто переименовывать. И здесь есть очевидная проблема - как это сделать без перезапуска приложения, ведь файл уже открыт на запись и приложение в него что-то пишет, например входящие запросы. На помощь приходят особенности организации файловых систем Unix’ов, ведь они позволяют переместить открытый процессом файл в другую директорию, переименовать его или даже удалить не прерывая операции чтение/записи в него. Только теперь надо незаметно для приложения на лету подменить открытые им файлы на вновь созданные пустые. Для этого используется функция freopen из glibc (freopen(3)). Чтобы все это выполнялось незаметно для приложения как раз и нужен сигнал USR1.

Предполагается следующий сценарий. Файлы логов перемещаются в директорию с архивами. Приложение продолжает писать в эти перемещенные файлы. Далее шлется сигнал USR1 и Unicorn принудительно переоткрывает все открытые master‘ом файлы и всем worker‘ам рассылается команда аналогично переоткрыть лог-файлы (команда USR1).

Переоткрываются только файлы удовлетворяющие следующим условиям:

  • файл открыт на запись (‘w’) или дозапись (‘a’)
  • или файл уже был перемещен и на его месте создан другой с таким же именем

В последнем случае проверяются inode number открытого старого файла и нового файла находящегося по тому же пути. После этого приложение будет писать в новые файлы с теми же путями ничего не заметив. (source)

USR2 - запустить новый master процесс Unicorn’а.

WINCH - “вежливо” завершить все процессы worker‘ы но оставить запущенным master. Это может оказаться удобным, например, при откатывании назад неудачного релиза.

Обычно при деплое запускается новый master и при старте первого worker‘а старому master‘у шлется команда завершиться (в after_fork hook‘е). Если после старта нового master‘а выяснится, что что-то пошло не так и надо откатиться назад к предыдущей версии кода, то придется заново стартовать Unicorn, что займет какое-то время нужное на загрузку приложения. Но если не завершать старый master, а просто посылать сигнал WINCH и завершать только worker‘ы, то при откате к предыдущему релизу можно практически мгновенно запустить worker‘ы послав несколько сигналов TTIN. Очевидно, что worker‘ы поднимутся мгновенно только если используется предварительная загрузка приложения (выставлена опция preload_app).

Сигнал WINCH обрабатывается только если Unicorn запущен в фоновом режиме как демон. Автоматический запуск новых worker‘ов временно блокируется, а всем запущенным worker‘ам шлется команда “мягко” завершиться (QUIT).

TTIN - увеличиваем количество worker‘ов на 1. Фактически новый процесс будет запущен на следующей итерации цикла после засыпания master‘а в ожидании сигнала. Снимаем блокировку создания новых worker‘ов если она была выставлена после сигнала WINCH.

TTOU - уменьшить количество worker‘ов на 1. Все аналогично TTIN.

HUP - перечитать Unicorn конфигурационный файл и “вежливо” пересоздать все worker‘ы. Если конфигурационный файл не был указан вообще, то выполняется просто перезапуск Unicorn’а. В противном случае выполняются следующие действия:

load Unicorn config
run after_reload hook
shutdown workers gracefully
reopen log files

if not (configured to preload application)
    load application from config.ru file

reload all application-specific gems

source

Интересно как выполняется перезагрузка gem‘ов - Unicorn использует метод Gem.refresh из Rubygems. Если приложение снова загружается (опция preload_app), то будут подхватываться изменения в его исходном коде.

Если при переоткрытии лог-файлов произошла любая ошибка, то master завершается с кодом 77 (EX_NOPERM - “You did not have sufficient permission to perform the operation”) следуя конвенции кодов ошибок

CHLD - этот сигнал посылается операционной системой если завершился один из дочерних процессов. master процесс получает этот сигнал если завершился worker процесс или новый master при перезапуске. Обработчик просто будит master, тот определяет PID‘ы завершившихся процессов и удаляет ненужную больше информацию о worker‘ах.

Timeout и время

Когда master определяет зависший worker, то ему нужно знать текущее время. Для этого используется не обычный Time.now, а делается специальный системный вызов clock_gettime (clock_getres(2)) используя Ruby обертку:

Process.clock_gettime(Process::CLOCK_MONOTONIC)

Как видно из имени константы CLOCK_MONOTONIC будет возвращено монотонное время. Здесь очень важно использовать именно монотонное (т.е. не убывающее) время так как мы сравниваем два timestamp‘а и определяем разницу между ними. Что будет, если системные часы были переведены назад на 1 минуту вручную или NTP сервисом? Мы получим совершенно некорректную отрицательную разницу там где ожидалась положительная. Это источник очень неприятных багов.

Кстати, монотонное время может даже не быть временем совсем. Или, например, может быть количеством секунд с момента включения компьютера.

Ссылки:

Пример. Переоткрытие лог-файла

Давайте разберем как Unicorn переоткрывает лог-файлы при получении сигнала USR1. Здесь применяется метод IO#reopen, который ассоциирует существующий Ruby-объект файл с новым файлом.

В приведенном примере строка 'First line' записывается в файл ‘development.log’. Затем этот файл перемещается но строка 'Second line' все еще дописывается в этот перемещенный файл ‘archive/development.log.1’. Далее файл переоткрывается используя метод File#reopen и следующая строка 'Third line' записывается в новый файл ‘development.log’.

Таким образом, “заархивированный” файл содержит первую и вторую строку, а созданный после переоткрытия файл содержит третью. Получается, что файл переместился незаметно для приложения и все лог-записи сохранились.

# reopen.rb

require 'fileutils'

file = File.open('development.log', 'a')
file.puts('First line')

FileUtils.mkdir('archive')
FileUtils.mv(file.path, 'archive/development.log.1')
file.puts('Second line')

file.reopen(file.path, 'a')
file.puts('Third line')

file.close
# shell

> cat archive/development.log.1
First line
Second line

> cat development.log
Third line

Перезапуск master процесса

# master process:
if already in re-executing state
    if new master process exists
        return

rename pid file to "#{pid}.oldbin”
fork
set process name "master (old)"
# new master process:
pack socket descriptors into UNICORN_FD ENV variable
change directory to CWD from start context
avoid leaking file unknown socket descriptors
run before_exec hook
exec

source

Здесь используется стандартный подход fork + exec. master создает дочерний процесс, который наследует все дескрипторы сокетов и затем загружает в память совершенно новую программу. Выполняется та же самая консольная команда, которой был запущен старый master (эти данные были сохранены в начальный контекст), но окружение уже может измениться:

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

В Kernel#exec кроме консольной команды передается параметры в виде Hash с парами вида { fd => socket }. Это означает перенаправление файлового дескриптора в дочернем процессе на файловый дескриптор в родительском (redirect to the file descriptor in parent process). Краткое описание параметров и редиректа можно найти в документации по Process.spawn (Core).

Новый master, как было описано выше, возьмет из переменной окружения UNICORN_FD числовые дескрипторы унаследованных серверных сокетов и активирует их. Новые worker‘ы сразу же начинают принимать входящие соединения параллельно с worker‘ами старого master‘а. Поэтому в before_fork hook‘е, который будет выполняться в процессе нового master‘а, обычно завершают старый master посылая ему системный сигнал QUIT. Пример стандартного кода:

# config/unicorn.rb

# What to do before we fork a worker
before_fork do |server, worker|
  old_pid = "/mnt/data/www/.../unicorn.pid.oldbin"

  if File.exists?(old_pid) && server.pid != old_pid
    begin
      Process.kill('QUIT', File.read(old_pid).to_i)
    rescue Errno::ENOENT, Errno::ESRCH
      # someone else did our job for us
    end
  end
end

Если новый master процесс по каким-то причинам завершается, то старый master узнает об этом используя системный вызов waitpid2 (точно так же как и для завершившихся worker процессов), и откатит сделанные изменения:

  • переименует обратно PID-файл и
  • переименует сам master процесс.

Для проверки существует ли еще новый master используется трюк с kill 0 - посылается 0-й (несуществующий) сигнала процессу нового master‘а. Из man страницы по kill (kill(2)):

“If sig is 0, then no signal is sent, but existence and permission checks are still performed; this can be used to check for the existence of a process ID or process group ID that the caller is permitted to signal.”

Чтобы избежать утечки файловых дескрипторов всем дескрипторам из системного диапазона 3-1024 кроме серверных сокетов выставляется флаг close-on-exec (FD_CLOEXEC), чтобы эти дескрипторы были закрыты в дочернем процессе. (source).

Ссылки:

Пример. Передача переменной окружения дочернему процессу

Давайте проиллюстрируем это примером на Ruby:

# exec.rb

ENV['UNICORN_FD'] = '1'
exec 'ruby puts.rb'
# puts.rb

if ENV['UNICORN_FD']
  puts "UNICORN_FD = #{ENV['UNICORN_FD']}"
else
  puts 'UNICORN_FD is nil'
end
# shell

> ruby exec.rb
#=> UNICORN_FD = 1

Как видим, переменная окружения будет доступна в дочернем процессе даже после системного вызова exec.

Пример. Активация унаследованного сокета

Давайте посмотрим на примере как работает трюк с передачей дескрипторов серверных сокетов в новый master через переменную окружения и активация сокетов.

Здесь мы создадим серверный сокет, затем запустим дочерний процесс с совершенно другим скриптом server_child.rb и передадим в него файловый дескриптор серверного сокета. В дочернем процессе мы активируем сокет и начнем принимать соединение в обоих процессах на одном и том же сокете. Далее в скрипте client.rb мы будем соединяться с сервером попадая то на один сервер то на другой, читать сообщение от сервера и печатать его.

# server.rb

require 'socket'

server = TCPServer.new 2000

fork do
  ENV['SOCKET_ID'] = server.fileno.to_s
  exec 'ruby server_child.rb', server.fileno => server
end

loop do
  client = server.accept
  client.puts "Hello from server #{$$}!"
  client.close
end
# server_child.rb

require 'socket'

fileno = Integer(ENV['SOCKET_ID'])
server = TCPServer.for_fd(fileno)

loop do
  client = server.accept
  client.puts "Hello from server child #{$$}!"
  client.close
end
# client.rb

require 'socket'

loop do
  s = TCPSocket.new 'localhost', 2000
  line = s.gets
  puts line
  s.close

  sleep 1
end
# shell

> ruby server.rb

# another shell

> ruby client.rb
Hello from server child 69055!
Hello from server 69042!
Hello from server child 69055!
Hello from server 69042!
Hello from server child 69055!
Hello from server 69042!
Hello from server 69042!
Hello from server 69042!
Hello from server child 69055!
Hello from server child 69055!

Интерес представляет строчка с exec:

exec 'ruby server_child.rb', server.fileno => server

Здесь вторым аргументом передаются Hash с правилами редиректа между дескриптором дочернего процесса и родительского. Если его опустить, то активировать сокет в дочернем процессе не получится. Будет выдаваться ошибка “Bad file descriptor”:

Traceback (most recent call last):
    1: from server_child.rb:7:in `<main>'
server_child.rb:7:in `for_fd': Bad file descriptor - fstat(2) (Errno::EBADF)

Это похоже на особенность реализации Ruby. После системного вызова fork активация работает, но после exec уже нет. В дочернем процессе можно задать произвольный файловый дескриптор и перенаправить его на заданный дескриптор родительского процесса. (source)

Проиллюстрируем это на примере с наследованием и активацией файла:

# file.rb

file = File.open('file.txt', 'w')
new_fileno = file.fileno + 100

fork do
  ENV['FILE_FD'] = new_fileno.to_s
  exec 'ruby file_child.rb', new_fileno => file
end

file.puts "Hello from file.rb"

Process.wait

puts "Bye from file.rb"
# file_child.rb

raise if ENV['FILE_FD'].nil?

fileno = Integer(ENV['FILE_FD'])
file = File.for_fd(fileno)

file.puts "Hello from file_child.rb"
file.close

puts "Bye from file_child.rb"
# shell

> ruby file.rb
Bye from file_child.rb
Bye from file.rb

> cat file.txt
Hello from file_child.rb
Hello from file.rb

Обратите внимание на строчки

new_fileno = file.fileno + 100

и

exec 'ruby file_child.rb', new_fileno => file

Мы берем произвольное незанятой число new_fileno и указываем, что в дочернем процессе по этому дескриптору будет доступен унаследованный файл. Без такого явного редиректа дескриптор не будет доступен и активация будет приводить к такому же исключению “Bad file descriptor (Errno::EBADF)”.

Что происходит в worker’е

wait for incoming connection or message from master in pipe
    if received message from master
        for each received message in pipe
            if received EOF
                handle it like QUIT signal

                set new handler for this signal - IGNORE
                call previous handler
                set previous handler again

            if received USR1
                reopen log files
    else
        for each incoming connection
            save timestamp of starting
            accept new connection
            process request

        until no incomming connections
            try to accept and process connections from these sockets

        save timestamp

        if master process dead
            return

source

Процесс worker‘а обрабатывает все входящие запросы, а также команды от master, переданные через pipe.

Процесс завершается если:

  • завершился сам master,
  • master прислал команду завершения или
  • получен системный сигнал завершиться (QUIT, TERM, INT).

Команда передается в виде номера системного сигнала и вызывается установленный ранее обработчик для этого сигнала. Чтобы обработчик не был прерван настоящим системным сигналом прием этого сигнала блокируется.

Одновременно может прийти несколько входящих соединений. worker обрабатывает их по очереди и запоминает timestamp начала обработки текущего запроса. Этот timestemp master затем использует, чтобы определить зависание worker‘а, когда время обработки запроса превысило timeout и завершить процесс.

Далее worker не спешит снова вызывать IO.select и ждать входящих соединений. Он думает: - “Раз по текущим сокетам пришли запросы, которые мы уже обработали, наверное, за это время могли прийти и другие запросы на эти сокеты. Надо это проверить.” И worker начинает принимать соединения на сокетах, которые были получены в последнем вызове select, до тех пор пока соединения не перестанут поступать. Только тогда worker закончит эту итерацию и сделает новый вызов select, проверяя соединения на всех серверных сокетов. Из этого следует любопытный вывод. Если сервер слушает несколько сокетов, то возможна ситуация, когда некоторые из них будут игнорироваться и ждать пока по другим не перестанут приходить соединения.

Далее worker проверяет не умер ли master. Непонятно зачем эта дополнительная проверка нужна ведь worker и так узнает это по закрытию pipe‘а. Но интересен сам трюк:

ppid == Process.ppid or return

Здесь сравниваются PPID (parent process id) на момент запуска worker‘a с PPID на текущий момент. На первый взгляд это не имеет смысла, но оказывается, что когда родительский процесс умирает, операционная система изменяет PPID осиротевшего дочернего процесса на PID процесса init (PID 1). На самом деле все намного сложнее и полагаться на магическое число не стоит. Как развернуто объяснили на StackExchange это “implementation-defined process”. Есть возможность задавать процесс, который “унаследует” осиротевший процесс, так называемый subreaper. Если subreaper не задан, то только тогда родителем становится процесс init. Это используют супервизоры, такие как systemd и upstart.

Для pipe‘ов используется обертка Kgio::Pipe из gem‘а kgio. Этот gem разработан специально для проекта Unicorn:

“This is a legacy project, do not use it for new projects. Ruby 2.3 and later should make this obsolete. kgio provides non-blocking I/O methods for Ruby without raising exceptions on EAGAIN and EINPROGRESS.”

В Ruby 2.3 действительно появились нативные не блокирующие операции над сокетами “_nonblock”: Socket#connect_nonblock, Socket#accept_nonblock, TCPServer#accept_nonblock, UNIXServer#accept_nonblock, BasicSocket#recv_nonblock, BasicSocket#recvmsg_nonblock, BasicSocket#sendmsg_nonblock.

Поэтому этот gem уже потерял свою актуальность и вероятно в будущих релизах будет убран из Unicorn’а вместе с поддержкой Ruby 2.3.

Обработка HTTP запроса

read partially request from socket

if check_client_connection option
    check if socket isn't closed by client

call application

if full socket hijacking
    return

if response status == 100
    send 100 in response
    call application

write a response to a socket

if response body is a file
    close it

shutdown socket
close socket

source

Unicorn читает начало HTTP запроса, парсит заголовки и подготавливает объект с информацией о запросе, чтобы передать его потом приложению. Согласно спецификации Rack этот объект называется environment и должен быть Hash объектом, в котором будут переданы HTTP заголовки, body POST запроса и метаинформация.

Unicorn читает только доступные прямо сейчас в сетевом буфере данные (ведь запрос может быть большим и пришла только часть данных) блоками по 16kb (source) не дожидаясь пока прийдет весь запрос до конца. На данном этапе главное это распарсить заголовки. Согласно спецификации сокет передается приложению в виде env['rack.input'] и оно должно дочитать запрос до конца. Парсинг multipart запроса также ложиться на плечи приложения. По спецификации Rack env['rack.input'] должен поддерживать метод rewind, и после его вызова чтение запроса продолжиться сначала. В Unicorn это работает по умолчанию, хотя и может отключаться ради ускорения обработки запросов. Чтобы сделать rack.input не- rewindable нужно указать конфигурационную опцию rewindable_input=false. По умолчанию при чтении запроса все данные будут сохраняться во временный файл, а когда запрос будет прочитан до конца, все последующие операции чтения будут делаться уже из него. (source)

Если выставлена конфигурационная опция check_client_connection, то перед передачей запроса приложению Unicorn попытается проверить не отвалился ли клиент и не закрыл ли сокет пока его запрос ждал обработки. Согласно документации это сработает только для локального клиента, который запущен на том же компьютере. Статус сокета Unicorn пытается определить из его системных опций делая системный вызов getsockopt (getsockopt(2)). Формат опций специфичен для операционной системы и если не получилось с ними то Unicorn просто пишет начало ответа (строчку “HTTP/1.1 “) ожидая получить ошибку EPIPE. Эта будет означать ошибку “The local end has been shut down”, т.е. сокет или pipe закрыт на чтение и никто из него уже не прочитает данные.

Unicorn поддерживает full socket hijacking. Это означает, что после того как веб-сервер прочитал и распарсил HTTP запрос и передал его приложению, оно получает прямой доступ к сокету и может реализовать свой произвольный протокол передачи данных свободно читая и записывая данные в сокет. Когда приложение обработало запрос, веб-сервер определяет произошел ли hijacking и в этом случае просто закрывает соединение игнорируя заголовки и body, которые вернуло приложение. Socket hijacking это часть спецификации Rack и поддерживается всеми Rack-совместимыми веб-серверами кроме WEBrick. Это нашло применение, к примеру, в реализации websocket‘ов в Rails.

Unicorn обрабатывает код возврата 100 (Continue) особым образом. HTTP протокол по дизайну statelessness, т.е. веб-сервер не имеет собственного состояния. Протокол перестает следовать этому принципе в случае с кодом 100. Клиент, предполагая, что сервер может отказаться обрабатывать запрос, может отправить только HTTP заголовки и спросить сервер, будет ли он вообще обрабатывать такой запрос. Если сервер подтверждает это, то клиент шлет body запроса. Это нужно, например, если запрос очень большой. Нет смысла слать весь запрос если сервер по заголовкам может понять, что не будет его обрабатывать. Это работает следующим образом: клиент шлет только заголовки, включая заголовок “Expect: 100-continue”, и ждет ответа от сервера. Сервер должен ответить 417 (Expectation Failed) если не принимает такой запрос и 100 (Continue) если разрешает продолжить. Далее клиент шлет body запроса и сервер возвращает обычный ответ, второй раз.

Именно поэтому если приложение возвращает код ответа 100, то Unicorn игнорирует заголовки и body и отсылает клиенту промежуточный ответ 100 (Continue). Далее он удаляет Expect заголовок из запроса и еще раз вызывает приложение. Затем он проверяет был ли socket hijacking и если нет, то отсылает ответ приложения.

Unicorn также поддерживает partial socket hijacking. Это означает, что веб-сервер отправляет заголовки, которые вернуло приложение, но игнорирует body. Вместо этого Unicorn предоставляет приложению сокет и оно шлет данные самостоятельно. Предполагается, что это поможет реализовать стриминг приложением. Это реализовано следующим образом: приложение в ответе возвращает заголовок rack.hijack, значение которого это Ruby callable объект, который Unicorn вызывает и передает аргументом сокет (source).

В конце кроме закрытия сокета close (close(2)) также делается системный вызов shutdown (shutdown(2)). Согласно комментариям в коде это нужно если само приложение сделало fork сокет разделяется родительским и дочерним процессами. И правда, системный вызов close закрывает сокет только в текущем процессе, в то время как shutdown говорит операционной системе закрыть все копии этого сокета во всех родительских и дочерних процессах.

Ссылки:

Обработка ошибок

При обработке запроса могут возникать ошибки. Если клиент, например, отвалился, веб-сервер ничего не может сделать и просто прекращает обработку этого запроса. (source)

Если превышена длина URI, то возвращается код ошибки 414. Есть и другие ограничения:

  • имя заголовка - 256b
  • значение заголовка - 80kb
  • URI - 15kb
  • path - 4kb
  • query string - 10kb

Если произошла любая ошибка парсинга запроса, то возвращается код 400. Если приложением бросается любое исключение, то веб-сервер отдает код 500.

Ссылки:

Послесловие

Unicorn это во всех смыслах традиционное Unix приложение. Он активно использует системные вызовы и просто напичкан разными трюками, например:

  • демонизация,
  • self-pipe трюк,
  • определение завершившихся дочерних процессов,
  • определение завершения родительского процесса,
  • активация сокетов
  • поддержка systemd.

Это хороший пример для знакомства как с системными вызовами Unix в общем так и с процессами и синхронизацией между ними в частности.

Давайте перечислим системные вызовы, которые здесь используются:

  • kill - для отправки master‘ом сигналов worker‘у
  • signal/sigaction - задать обработчик сигнала
  • fork - для создания нового процесса - worker‘а или master‘а
  • exec - запустить новую программу в текущем процессе
  • setsid - создать сессию и группу процессов
  • waitpid2 - для получения PID завершившийся дочерних процессов
  • getppid - получить PID родительского процесса
  • clock_gettime - получить монотонное время
  • select - для ожидания входящих соединений или данных в сокетах
  • accept - для приема нового сетевого соединения
  • getsockopt - для проверки опций и статуса сокета
  • а также открытие/закрытие/чтение/запись в сокет и pipe.

Теперь поделюсь личным впечатлением. Не смотря на все достоинства веб-сервера нельзя не отметить некоторую олдскульность проекта. Аскетичный черно-белый сайт с документацией, игнорирование современных инструментов вроде Github/Gitlab, ведение обсуждений в почтовой рассылке. Вместо pull request‘ов надо слать патчи на почту главному разработчику Eric Wong’у. Это создает некоторый барьер для потенциальный контрибуторов и, вероятно, является одной из основных причин медленного развития проекта (сравните со скоростью разработки Puma’ы). Все это кажется либо слепым подражанием какому-нибудь масштабному проекту вроде Linux kernel либо просто ретроградством.

Что касается самого исходного Ruby-кода, то при его чтении возникло сильное чувство чужеродности. Разработчики практически не использовали богатые возможности синтаксиса Ruby, его коллекции и замыкания. Если внезапно разработчик таки использовал фичу Ruby, то это скорее запутывало код чем делало его яснее и чище. Думаю, если переписать весь код на Си, то в нем мало что изменится - он останется таким же громоздким, плохо структурированным и переусложненным. В целом это неидиоматичный Ruby-код и хорошая иллюстрация как не надо писать на Ruby.

Ссылки