FastCGI-приложение на Perl

Материал из OpenWiki
Перейти к: навигация, поиск

Некоторое время назад мне поручили написать движок для онлайн-аукциона. Передо мной встал выбор - сделать движок как обычный CGI или же сделать что-то поинтереснее. Я принял решение применить в разработке движка технологию FastCGI. Эта статья является отражением полученного опыта.

Часть первая. Простое приложение

FastCGI - это клиент-серверный протокол, обеспечивающий взаимодействие между веб-сервером и приложением. FastCGI является дальнейшим развитием протокола CGI.

Разница между CGI и FastCGI

В случае обычного CGI-приложения взаимодействие между веб-сервером и приложением осуществляется через STDIN и STDOUT. В случае же, когда применяется FastCGI, взаимодействие между клиентом и сервером осуществляется через Unix Sockets или через TCP/IP.

Более интересным из этих двух вариантов является взаимодействие через TCP/IP. Этот вариант позволяет получить два ключевых преимущества:

  1. FastCGI-приложения могут быть запущены не только на том же сервере, на котором работает веб-сервер, но и на любом другом. Таких серверов и запущенных на них FastCGI-приложений может быть сколько угодно, а это, в свою очередь, дает практически неограниченный простор для масштабирования системы. Система перестала справляться с нагрузками? Ничего страшного - можно очень просто, совершенно ничего не перестраивая, добавить нужное количество серверов. Максимальное вмешательство в существующую систему будет заключаться, всего лишь, в добавлении строчки со списком новых IP в конфиг веб-сервера.
  1. FastCGI-приложение может быть реализовано в виде демона, т.е. оно само может являться сервером. Обычное CGI-приложение запускается веб-сервером при каждом новом запросе. На запуск приложения уходит львиная часть времени, зачастую запуск занимает больше времени, чем выполняемая им далее полезная работа. А FastCGI-приложение запущено всегда, ему нет нужды тратить время на запуск, ему достаточно только выполнять полезную работу.

За эти прелести нужно платить. Во-первых, написание FastCGI-приложения несколько сложнее, чем написание CGI-приложения. Во-вторых, веб-сервер требует некоторой дополнительной настройки, а, возможно, и замены.

Настройка веб-сервера

Веб-сервер в нашем случае будет играть роль исключительно маленькую, он не будет ни разруливать виртуальные хосты, ни выполнять скрипты, ни отдавать статику. Вся его работа будет сводиться к передаче запроса от браузера к FastCGI-приложению. Для такой задачи хорошо подойдет nginx.

Для настройки nginx'а на работу с нашим приложением в конфиг nginx'а нужно добавить следующую конструкцию:

# Любые запросы, в которых путь будет начинаться с директории fcgi-bin,
# будут передаваться на обработку FastCGI-приложению.
location /fcgi-bin/ {
    # Куда передавать запросы
    # в данном случае FastCGI-приложение находится на этом же компе и слушает порт 9000
    fastcgi_pass   localhost:9000;
}

(за подробностями настройки nginx'а обращаться к первоисточнику)

Запускаем nginx. Допустим, в nginx'е был настроен виртуальный хост test.host, тогда запрос в браузере к адресу http://test.host/fcgi-bin/ будет передан nginx'ом нашему FastCGI-приложению. Пока самого приложения еще нет, в браузер будет выдано что-то типа такого:

The page you are looking for is temporarily unavailable. Please try again later.

Это нормально. Так и должно быть.

Написание FastCGI-приложения

Протокол FastCGI реализован в Perl в виде модуля FCGI. Обратите внимание - на CPAN'е в наличии есть и другие модули, позволяющие работать с FastCGI, например, CGI::Fast, но все они являются просто надстройками над FCGI. Мы будем работать напрямую с "фундаментальным" модулем FCGI.

#!/usr/bin/perl

# Для пущего порядку
use strict;
use warnings;

# Этот модуль реализует протокол FastCGI.
use FCGI;

# Открываем сокет
# наш скрипт будет слушать порт 9000
# длина очереди соединений (backlog)- 5 штук
my $socket = FCGI::OpenSocket(":9000", 5);

# Начинаем слушать
my $request = FCGI::Request(\*STDIN, \*STDOUT, \*STDERR, \%ENV, $socket);

my $count = 1;

# Бесконечный цикл
# при каждом принятом запросе выполняется один "оборот" цикла.
while($request->Accept() >= 0) {
    # Внутри цикла происходит выполнение всей полезной работы
    print "Content-Type: text/plain\r\n\r\n";
    print $count++;
};

Некоторые тонкости

Функция OpenSocket открывает сокет и биндит на него порт 9000. Не забывайте - именно этот порт мы раньше указали в конфиге nginx'а. Еще не забывайте - порты ниже 1024 нельзя открыть, не будучи пользователем root.

Параметр backlog этой функции задает количество соединений, которые будут ждать в очереди, пока обслуживаются предыдущие соединения. Честно говоря, я не понял, как работает этот параметр. Теоретически, если очередь заполнена, новые соединения должны сбрасываться. Но у меня изменение этого параметра видимого эффекта не имеет, какое число не указывай - все соединения все-равно выстраиваются в очередь, ни одно не сбрасывается. Вероятно, я где-то что-то упускаю, но на работоспособность скрипта это, похоже, никак не влияет.

Функция Request создает обработчик запросов. Обработчик "перехватывает" стандартные дескрипторы и связывает их с веб-сервером. Это означает, в частности, что если при запросе в скрипте произойдет какая-то ошибка, то сообщение о ней будет выведено не на консоль, а отдано вебсерверу. Искать это сообщение нужно в логе nginx'а. Ну, в общем-то, у обычных CGI-скриптов поведение в этом плане аналогично, с той только разницей, что логи CGI-приложения находятся рядом, а логи FastCGI-приложения могут находиться совсем на другом сервере.

Не пренебрегайте выводом заголовка "Content-Type" (ну, или хоть какого-нибудь заголовка). Без этого nginx откажется что-либо выводить в браузер. В общем, такое поведение также аналогично поведению веб-сервера при работе с обычным CGI-приложением.

Проверка результата

Хорошо, написали скрипт, запускаем его. Если вы нигде не допустили синтаксической ошибки, то скрипт запустится молча, ничего не выведет и не отпустит консоль - это нормально, так и должно быть. Сейчас скрипт должен слушать порт 9000 и ждать, пока к нему не обратится веб-сервер.

Теперь запускаем браузер и открываем адрес http://test.host/fcgi-bin/ - помните, вы его уже открывали, когда проверяли настройку nginx'а? Но на этот раз вместо сообщения о недоступности страницы должна появиться незатейливая цифра 1. Понажимайте кнопку F5. Цифра должна увеличиваться.

Часть вторая. Демонизация приложения

Написанное в предыдущей части FastCGI-приложение сковано одной цепью с тем терминалом, из которого его запустили. Приложение будет работать ровно до тех пор, пока открыт терминал. Как только вы закроете терминал, приложение будет немедленно убито.

Причина в том, что всякая программа, запущенная из терминала, становится потомком этого терминала, а терминал, соответственно, родителем этой программы. Существование же потомков, не имеющих родителей, не допускается. Соответственно, при закрытии родителя-терминала немедленно закрываются все зависящие от него потомки и, в частности, наше FastCGI-приложение.

Для того, чтобы FastCGI-приложение перестало зависеть от родительского терминала, оно должно быть преобразовано из простого скрипта в демон.

Создание демона

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

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

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

#!/usr/bin/perl

# Для пущего порядку
use strict;
use warnings;

# Этот модуль реализует протокол FastCGI
use FCGI;

# Демонизация {
    # Этот модуль для разговора с операционкой по понятиям:)
    use POSIX;
   
    # Форк
    # избавляемся от родителя
    fork_proc() && exit 0;
   
    # Начать новую сессию
    # наш демон будет родоначальником новой сессии
    POSIX::setsid() or die "Can't set sid: $!";
   
    # Перейти в корневую директорию
    # чтобы не мешать отмонтированию файловой системы
    chdir '/' or die "Can't chdir: $!";
   
    # Сменить пользователя на nobody
    # мы же параноики, ага?
    POSIX::setuid(65534) or die "Can't set uid: $!";

    # Переоткрыть стандартные дескрипторы на /dev/null
    # больше не разговариваем с пользователем
    reopen_std();
# }

# Открываем сокет
# наш демон будет слушать порт 9000
# длина очереди запросов - 5 штук
my $socket = FCGI::OpenSocket(":9000", 5);

# Начинаем слушать
# демон будет перехватывать стандартные дескрипторы
my $request = FCGI::Request(\*STDIN, \*STDOUT, \*STDERR, \%ENV, $socket);

my $count = 1;

# Бесконечный цикл
# при каждом принятом запросе выполняется один "оборот" цикла.
while($request->Accept() >= 0) {
    # Внутри цикла происходит выполнение всех требуемых действие
    print "Content-Type: text/plain\r\n\r\n";
    print $count++;
};

# Форк
sub fork_proc {
    my $pid;
   
    FORK: {
        if (defined($pid = fork)) {
            return $pid;
        }
        elsif ($! =~ /No more process/) {
            sleep 5;
            redo FORK;
        }
        else {
            die "Can't fork: $!";
        };
    };
};

# Переоткрыть стандартные дескрипторы на /dev/null
sub reopen_std {   
    open(STDIN,  "+>/dev/null") or die "Can't open STDIN: $!";
    open(STDOUT, "+>&STDIN") or die "Can't open STDOUT: $!";
    open(STDERR, "+>&STDIN") or die "Can't open STDERR: $!";
};

Некоторые тонкости

У тех, кто в теме, может возникнуть вопрос - почему я сделал демонизацию вручную, вместо того, чтобы использовать готовый модуль Proc::Daemon?

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

Сама демонизация, в общем, не содержит никаких особых отличий от примера из кукбука, а реализация функции fork_proc взята почти один в один из кэмелбука.

Отдельно стоит отметить смену пользователя на nobody.

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

Число 65534 в функции setuid - это uid пользователя nobody. Уточнить uid можно с помощью специальной команды:

$ id nobody

Да, обратите внимание - сменить uid может только root, поэтому запускать демона тоже должен root.

У особо въедливых может возникнуть еще один вопрос - почему я не предусмотрел обработчики сигналов для корректного завершения?

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

Проверка результата

Запускаем демона. Если нет никаких ошибок - демон запустится молча, ничего не выведет и отцепится от консоли. Теперь можно закрыть терминал, демон на это не обратит никакого внимания и продолжит работу.

Откройте в браузере test.host/fcgi-bin/ - демон отвечает как и раньше. Теперь заткнуть его можно только вооружившись командой kill.

Часть третья. Распараллеливание

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

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

Для того, чтобы FastCGI-приложение могло обслуживать несколько запросов одновременно, оно должно уметь создавать собственные копии. В этом случае одновременно пришедшие запросы будут обрабатываться параллельно несколькими экземплярами приложения.

Создание копий приложения

На самом деле, нафоркать копий процесса - дело не хитрое. Управлять сонмом созданных копий - вот задача. Превращать нашего демона в агента Смита мы будем с помощью модуля FCGI::ProcManager.

Модуль FCGI::ProcManager выполняет три основные задачи:

  1. Создает рабочие копии демона - обработчики или воркеры (или серверы, в терминологии самого модуля)
  2. Контролирует состояние обработчиков в процессе работы
  3. Управляет поведением обработчиков в случае внешнего вмешательства

Помимо процессов-обработчиков FCGI::ProcManager запускает еще процесс-менеджер. Процесс-менеджер не обслуживает клиентские запросы, его задача - управление обработчиками.

Меры предосторожности

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

Рассмотрим участок кода из предыдущей части статьи (в сокращенном виде):

# Демонизация {
    #...тут я сократил...

    POSIX::setuid(65534) or die "Can't set uid: $!";

    reopen_std();
# }

my $socket = FCGI::OpenSocket(":9000", 5);

my $request = FCGI::Request(\*STDIN, \*STDOUT, \*STDERR, \%ENV, $socket);

while($request->Accept() >= 0) {

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

Ситуация станет еще хуже после того, как будет прикручено распараллеливание. Обработчик, вызвавший ошибку, будет убит, но на его место процессом-менеджером тут же будет запущен другой. В нем снова произойдет ошибка, он снова будет убит и так по кругу. Внешне все это будет выглядеть вполне безобидно - демон запустился, никаких ошибок не вывел, в списке процессов четко видно наличие заданного количества обработчиков. Однако, на запросы демон не отвечает и, что еще хуже, через некоторое время вы вдруг заметите, что система стала беспощадно тормозить, процессор занят на 100% и load average неумолимо растет.

Система - сюрприз! - будет занята диспетчеризацией непрерывно и с бешеной скоростью умирающих и вновь запускающихся процессов. Потребуется большая внимательность, чтобы заметить, что pid'ы обработчиков непрерывно изменяются, сообразить, что это означает и принять меры.

Во избежание такого сюрприза вызов функции reopen_std нужно отделить от остального кода из блока демонизации. Разместить вызов этой функции нужно непосредственно перед циклом обработки запросов.

Возьмем все тот же участок кода и внесем изменения:

# Демонизация {
    #...тут я сократил...

    POSIX::setuid(65534) or die "Can't set uid: $!";
# }

my $socket = FCGI::OpenSocket(":9000", 5);

my $request = FCGI::Request(\*STDIN, \*STDOUT, \*STDERR, \%ENV, $socket);

# Демонизация {
    reopen_std();
# }

while($request->Accept() >= 0) {

Как видите, при этом команды OpenSocket и Request окажутся как-бы "внутри" процесса демонизации. Другими словами, процесс демонизации будет разделен на две части, что было бы невозможно, если бы мы использовали для демонизации готовый модуль.

Теперь сообщение о любой ошибке, произошедшей до вызова команды reopen_std, будет выведено на консоль. Соответственно, мы сможем сразу увидеть, что что-то пойдет не так.

Распараллеливание приложения

Ну, а теперь возьмем код демона из предыдущей части статьи и встроим в него распараллеливание:

#!/usr/bin/perl

# Для пущего порядку
use strict;
use warnings;

# Этот модуль реализует протокол FastCGI
use FCGI;

# Этот модуль для разговора с операционкой по понятиям:)
use POSIX;

# Распараллеливание {
    # Этот модуль обеспечивает параллельную обработку запросов
    use FCGI::ProcManager qw(pm_manage pm_pre_dispatch pm_post_dispatch);
# }

# Форк
# избавляемся от родителя
fork_proc() && exit 0;

# Начать новую сессию
# наш демон будет родоначальником новой сесcии
POSIX::setsid() or die "Can't set sid: $!";

# Перейти в корневую директорию
# чтобы не мешать отмонтированию файловой системы
chdir '/' or die "Can't chdir: $!";

# Сменить пользователя на nobody
# мы же параноики, ага?
POSIX::setuid(65534) or die "Can't set uid: $!";

# Открываем сокет
# наш демон будет слушать порт 9000
# длина очереди запросов - 5 штук
my $socket = FCGI::OpenSocket(":9000", 5);

# Начинаем слушать
# демон будет перехватывать стандартные дескрипторы
my $request = FCGI::Request(\*STDIN, \*STDOUT, \*STDERR, \%ENV, $socket);

# Распараллеливание
    # Запуск обработчиков
    # будет запущено указанное количество обработчиков (в данном случае 2)
    pm_manage(n_processes => 2);
# }

# Специфика {
    # Тут должен располагаться код, специфичный для каждого конкретного обработчика
    # например, открытие соединения с базой
# }

# Переоткрыть стандартные дескрипторы на /dev/null
# больше не разговариваем с пользователем
reopen_std();

my $count = 1;

# Бесконечный цикл
# при каждом принятом запросе выполняется один "оборот" цикла.
while($request->Accept() >= 0) {
    # Распараллеливание
        # Управление обработчиками
        # реагирует на внешнее вмешательство
        pm_pre_dispatch();
    # }
   
    # Внутри цикла происходит выполнение всех требуемых действие
    print "Content-Type: text/plain\r\n\r\n";
    print "$$: ".$count++;

    # Распараллеливание
        # Управление обработчиками
        # реагирует на внешнее вмешательство
         pm_post_dispatch();
    # }
};

# Форк
sub fork_proc {
    my $pid;
   
    FORK: {
        if (defined($pid = fork)) {
            return $pid;
        }
        elsif ($! =~ /No more process/) {
            sleep 5;
            redo FORK;
        }
        else {
            die "Can't fork: $!";
        };
    };
};

# Переоткрыть стандартные дескрипторы на /dev/null
sub reopen_std {   
    open(STDIN,  "+>/dev/null") or die "Can't open STDIN: $!";
    open(STDOUT, "+>&STDIN") or die "Can't open STDOUT: $!";
    open(STDERR, "+>&STDIN") or die "Can't open STDERR: $!";
};

Некоторые тонкости

Прежде всего, обратите внимание на расположение команды pm_manage.

С одной стороны, команды, общие для всего FastCGI-приложения (такие, как создание сокета и начало прослушки) должны выполняться ДО запуска обработчиков. Нельзя запустить обработчики, а потом биндится на один сокет несколькими процессами, это приведет к ошибке.

С другой стороны, команды, специфичные для каждого конкретного обработчика (такие, как открытие соединения с базой данных) должны располагаться ПОСЛЕ запуска обработчиков. Нельзя создать соединение к БД, а потом расшаривать его по процессам, это приведет к ненужным проблемам.

Ну и, еще раз напоминаю, команда reopen_std должна располагаться после всех подготовительных команд, непосредственно перед началом цикла.

Цикл должен начинаться и заканчиваться командами pm_pre_dispatch и pm_post_dispatch соответственно. Эти две команды управляют поведением обработчиков в случае внешнего вмешательства. Под внешним вмешательством подразумевается получение FastCGI-приложением сигнала, например, от команды kill. Без них обработчики не будут реагировать на сигналы правильным образом.

Проверка результата

Запускаем демона. При запуске демон выведет на консоль примерно следующее:

# ./test.pl
FastCGI: manager (pid 1858): initialized
FastCGI: manager (pid 1858): server (pid 1859) started
FastCGI: server (pid 1859): initialized
FastCGI: manager (pid 1858): server (pid 1860) started
FastCGI: server (pid 1860): initialized

Здесь мы видим сообщения о том, что запустились процесс-менеджер (pid 1858) и два обработчика (pid'ы 1859 и 1860).

Посмотрим список процессов:

# ps -aux | grep perl
nobody   1858  0,0  0,2  5852  3816  ??  Is   21:09     0:00,00 perl-fcgi-pm (perl5.8.8)
nobody   1859  0,0  0,2  5852  3848  ??  I    21:09     0:00,00 perl-fcgi (perl5.8.8)
nobody   1860  0,0  0,2  5852  3848  ??  I    21:09     0:00,00 perl-fcgi (perl5.8.8)

Здесь мы видим, что процесс-менеджер отличается от обработчиков незатейливым суффиксом "pm".

Управление приложением

Для управления FastCGI-приложением сигналы нужно посылать процессу-менеджеру, а не обработчикам. Процесс-менеджер разруливает два сигнала, HUP и TERM. Делает он это следующим образом:

При получении сигнала HUP процесс-менеджер отправляет всем обработчикам сигнал TERM. Обработчики умирают, процесс-менеджер запускает новые. Таким образом осуществляется решение проблем с зависшими обработчиками.

При получении сигнала TERM процесс-менеджер посылает всем обработчикам сигнал TERM, ждет, пока они умрут, затем умирает сам. Если обработчики не желают умирать добровольно, процесс-менеджер посылает им сигнал KILL, от которого уже не отвертеться.

Интересный момент: в обоих случаях обработчики не умирают сразу, давая возможность обрабатываемым запросам быть обработанными до конца. Получив сигнал TERM, обработчик доделывает полезную работу, выдает клиенту ответ и только после этого умирает.

Это поведение можно изменить, добавив в вызов функции Request еще один параметр - FCGI::FAIL_ACCEPT_ON_INTR (это константа, экспортируемая модулем FCGI):

my $request = FCGI::Request(\*STDIN, \*STDOUT, \*STDERR, \%ENV, $socket, FCGI::FAIL_ACCEPT_ON_INTR);

После добавления этого параметра обработчики будут умирать немедленно, как только был получен сигнал. Этот параметр удобно использовать на этапе отладки, чтобы не ждать, пока все обработчики доделают свою работу.

Ссылки

Оригинал статьи: часть 1, часть 2, часть 3