Приветствую всех читателей "Хабра".
Дисклеймер
Статья получилась довольно длинная и тем кто не хочет читать предысторию, а хочет перейти сразу к сути прошу прямиком к главе "Решение".
Вступление
В данной статье хотелось бы рассказать о решении довольно нестандартной задачи, с которой пришлось столкнуться во время рабочего процесса. А именно, нам понадобилось запускать в цикле кучу php скриптов. О причинах и о спорности подобного архитектурного решения в данной статье распространяться не буду, т.к. собственно она и не про это вовсе, просто была задача, ее нужно было решить и решение показалось мне достаточно интересным чтобы им поделиться с Вами, тем более манов по данному вопросу в интернете я не нашел совсем (ну разумеется кроме официальных спецификаций). Спеки конечно это хорошо и в них конечно все есть, но думаю вы согласитесь, что если вы не особо знакомы с темой, да и еще и ограничены по времени то разбираться в них то еще удовольствие.
Для кого эта статья
Для всех кто работает с web-ом и о протоколе FastCgi знает лишь что это протокол в соответствии с котороым web-сервер запускает php скриптики, но хочет более детально его изучить и заглянуть под капот.
Обоснование (зачем эта статья)
В общем как я уже писал выше когда мы столкнулись с необходимостью запускать много php скриптов без участия web-сервера (грубо говоря из другого php скрипта), то первое что пришло на ум это ...
shell_exec('php \path\to\script.php')
Но при запуске каждого скрипта, будет создаваться окружение, запускаться отдельный процесс, в общем как то затратно по ресурсам нам показалось. Данную реализацию отвергли. Второе что пришло на ум это конечно же php-fpm, он ведь такой крутой, всего один раз запускает окружение, следит за памятью, все там логирует, корректно запускает и останавливает скрипты, в общем все делает круто, и нам конечно же этот путь понравился больше.
Но вот незадача, в теории то мы знали как это работает, в общих чертах (как оказалось в очень общих), но вот реализовать этот протокол на практике без участия web-сервера оказалось довольно трудно. Чтение спецификаций и пару часов безуспешных попыток показали что для реализации потребуется время, которого у нас на тот момент не было. Манов по реализации данной затеи, в которых было бы просто и понятно описано данное взаимодействие не нашлось, спеки наскоком взять тоже не удалось, из готовых решений нашли питоновский скрипт и пыховскую либу на гитхабе, которую в итоге не захотели тащить к себе в проект (может это и не правлиьно но не особо мы любим всякие сторонние библиотеки да еще и не очень то и популярные, а значит и не проверенные). В общем по итогу от этой идеи мы отказались и реализовали все это через старых добрый rabbitmq.
Хоть задачу в итоге и решили, но разобраться в FastCgi детально я все таки решил, и в добавок решил написать об этом статью, в которой будет просто и подробно описано как заставить php-fpm запустить php скрипт без web-сервера, а точнее в качестве web-сервера будет другой скрипт, далее его буду называть Fcgi клиент. В общем надеюсь что данная статья поможет тем кто столкнулся с такой же задачей как и мы и прочитав ее сможет быстро все написать как ему надо.
Творческий поиск (ложный путь)
Итак проблема обозначена, надо приступать к решению. Естественно как любой "нормальный" программист для решения задачи, про которую ни где не написано что делать и что вводить в консоль, я не стал читать и переводить спецификацию, а сразу же придумал свое "гениальное" решение. Суть его в следующем, я знаю что nginx (мы используем nginx и чтобы не писать далее дурацкое — web-сервер, буду писать nginx, так как то посимпатичнее) что то передает в php-fpm, это что то php-fpm обрабатывает и на основе него запускает скрипт, что ж вроде все просто, возьму да залогирую то что передает nginx и передам то же самое.
Тут поможет великолепный netcat (UNIX-утилита для работы с сетевым трафиком, которая по моему может практически все). Итак ставим netcat на прослушивание локального порта, а nginx настраиваем на работу с php файлами через сокет (естественно сокет на том же порту который слушает netcat)
слушаем 9000 порт
nc -l 9000
Проверить что все ок, можно обратившись через браузер на адрес 127.0.0.1:9000 должна быть следующая картина

настраиваем nginx чтобы он php скрипты обрабатывал через сокет на 9000 порту (в настройках '/etc/nginx/sites-available/default', конечно могут отличаться)
location ~ \.php$ { include snippets/fastcgi-php.conf; fastcgi_pass 127.0.0.1:9000; }
После этих манипуляций проверим что же получилось, обратившись к php скрипту через браузер

Видно что nginx отправил переменные окружения, а также непечатаемые символы, то есть данные были переданы в двоичной кодировке, а это значит что так просто их нельзя скопировать и послать в сокет php-fpm. Если сохранить их в файл например то они сохраняться в 16-ричной кодировке, выглядеть это будет примено так

Но это тоже мало что нам дает, наверное чисто теоретически их можно перевести в двоичную кодировку, каким то образом (даже не представляю каким) их отправить в сокет fpm, и даже есть вероятность что весь этот велосипед как то сработает, и даже запустит какой то скрипт, но уж как то все это страшненько и кривенько.
Стало ясно что данный путь совершенно неверный, сами видите насколько все это убого выглядит, и тем более все эти действия не позволят нам управлять соединением, и ни как не приблизят к пониманию взаимодействия между php-fpm и nginx.
Все пропало, изучения спецификации не миновать!
Решение (тут собственно начинается вся соль данной статьи)
Теоретическая подготовка
Давайте теперь рассмотрим как же все таки происходит соединение и обмен данными между nginx и php-fpm. Немного теории, все общение происходит как уже понятно через сокеты, далее будем рассматривать конкретно соединение через TCP сокет.
Единицей информации в протоколе FastCgi является cgi запись. Такие записи сервер отправляет приложению и точно такие же записи получает в ответ.
Немного теории (структуры)
Далее рассмотрим структуру записи. Для понимания из чего состоит запись нужно понимать что из себя представляют Си подобные структуры и понимать их обозначения. Для тех кто не знает далее это будет кратко (но достаточно для понимания) описано. Описать постараюсь как можно проще, в детали углубляться тут нет смысла, да и боюсь что в деталях запутаюсь, главное чтобы было общее понимание.
Структуры представляют собой просто напросто набор байтов, и нотацию к ним позволяющую их интерпретировать. То есть у вас есть просто последовательность нулей и единиц, и в этой последовательности зашифрованы какие то данные, но пока у вас к этой последовательности нет аннотации то эти данные для вас не представляют никакой ценности, т.к. интерпретировать их вы не можете.
//допустим вам пришли такие данные 1101111000000010010110000010011100010000
Что тут видно, у нас есть некоторые биты, что это за биты мы понятия не имеем. Ну давайте попробуем например их разделить на байты и представить в десятичной системе
//получилось у нас 5 байт 11011110 00000010 01011000 00100111 00010000 //переведем в десятичную систему 222 2 88 39 16
Отлично мы интерпретировали их и получили какие то результаты, допустим что эти данные отвечают за то сколько определенная квартира должна за электроэнергию. Получается что в доме 222 квартира номер 2 должна заплатить 88 рублей. А что еще за две цифры, что с ними делать просто отбросить? Конечно нет! дело в том что мы не имели нотации (формата) которая подсказала бы нам как интерпретировать данные, и интерпретировали их по своему, в связи с этим получили не только бесполезный, но и вредный результат. В итоге квартира 2 заплатила совершенно не то что должна была. (примеры конечно надуманные и служат лишь для того чтобы более понятно объяснить ситуацию)
Теперь посмотрим как же мы должны были интерпретировать правильно эти данные, имея нотацию (формат). Далее буду называть вещи своими именами, а именно нотация = формат (вот тут форматы).
//формат следующий "Cnn" //расшифровка формата //C - беззнаковый символ (char) (8 бит) //n - беззнаковый short (16 бит) //разобьем данные в соответствии с форматом 11011110 0000001001011000 0010011100010000 //переведем в десятичную систему 222 600 10000
Теперь все сходиться в доме №222 квартира 600 за электричество должна 1000 рублей Думаю теперь ясна важность формата, и теперь понятно как примерно выглядит условно Си подобная структура. (прошу обратить внимания, тут цель не детально объяснить что такое эти структуры, а дать общее понимание что это такое и как это работает)
Условное обозначение данной структуры будет такое
struct { unsigned char houseNumber; unsigned char flatNumperA1; unsigned char flatNumperA2; unsigned char summB1; unsigned char summB2; }; //одинаковые имена, с разными окончаниями означают что в них хранится одно значение // houseNumber - дом // flatNumperA1 && flatNumperA2 - квартира // summB1 && summB2 - сумма долга
Еще немного теории (FastCgi записи)
Как я уже сказал выше единицей информации в протоколе FastCgi являются записи. Записи сервер отправляет приложению и такие же записи получает в ответ. Запись состоит из заголовка и тела с данными.
Структура заголовка:
- версия протокола (всегда 1) обозначается 1 байтом ('C')
- тип записи. Для открытия, закрытия соединения и др. все не буду рассматривать, далее рассмотрю только то что понадобится для конкретной задачи, если нужны другие — добро пожаловать сюда спецификация. Обозначается 1 байтом ('C').
- ID запроса, произвольное число, обозначается 2 байтами ('n')
- длинна тела записи (данных), обозначается 2 байтами ('n')
- длинна выравнивающих данных и зарезервированные данные, по одному байту (тут не нужно особо обращать внимания, дабы не отвлекаться от главного в нашем случае всегда будет 0)
Далее идет само тело записи:
- сами данные (тут то именно и передаются переменные), могут иметь довольно большой размер (до 65535 байт)
Вот пример самой простой FastCgi записи в двоичном виде с форматом
struct { //Заголовок unsigned char version; unsigned char type; unsigned char idA1; unsigned char idA2; unsigned char bodyLengthB1; unsigned char bodyLengthB2; unsigned char paddingLength; unsigned char reserved; //Тело записи unsigned char contentData; //до 65535 байт unsigned char paddingData; };
Практика
Скрипт клиент и передающий сокет
Для передачи данных будем использовать стандартное php расширение socket. И первое что нужно будет сделать — это настроить php-fpm на прослушивание порта на локальном хосте, например 9000. Это делается в большинстве случаем в файле '/etc/php/7.3/fpm/pool.d/www.conf', путь конечно зависит от настроек вашей системы. Там нужно прописать примерно следующее (всю портянку привожу чтобы можно было сориентироваться, главная секция здесь listen)
; The address on which to accept FastCGI requests. ; Valid syntaxes are: ; 'ip.add.re.ss:port' - to listen on a TCP socket to a specific IPv4 address on ; a specific port; ; '[ip:6:addr:ess]:port' - to listen on a TCP socket to a specific IPv6 address on ; a specific port; ; 'port' - to listen on a TCP socket to all addresses ; (IPv6 and IPv4-mapped) on a specific port; ; '/path/to/unix/socket' - to listen on a unix socket. ; Note: This value is mandatory. ;listen = /run/php/php7.3-fpm.sock listen = 127.0.0.1:9002
После настройки fpm, следующим этапом будет подключение к сокету
$service_port = 9000; $address = '127.0.0.1'; $socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); $result = socket_connect($socket, $address, $service_port);
Начало запроса FCGI_BEGIN_REQUEST
Для открытия соединения мы должны отправить запись с типом FCGI_BEGIN_REQUEST = 1 Заголовок записи будет такой (для приведения числовых значений к бинарной строке с заданным форматом будет использована php функция pack())
socket_write($socket, pack('CCnnCx', 1, 1, 1, 8, 0)); //версия протокола - 1 //тип записи - 1 - FCGI_BEGIN_REQUEST //id - 1 //длинна тела запроса - 8 бит //выравнивание - 0
Тело записи для открытия соединения должно содержать роль записи и флаг управляющий соединением
//структура тела записи для открытия соединения //struct { // unsigned char roleB1; // unsigned char roleB0; // unsigned char flags; // unsigned char reserved[5]; //}; //php реализация socket_write($socket, pack('nCxxxxx', 1, 0)); //роль - 1 - открытие //флаг - 1 - если упростить то 1 значит удерживать соединение
Итак запись для открытия соединения успешно отправлена, php-fpm ее примет и далее будет ожидать от нас дальнейшей записи в которой нужно передать данные для разворачивания окружения и запуска скрипта.
Передача параметров окружения FCGI_PARAMS
В данной записи мы будем передавать все параметры которые нужны для разворачивания окружения, а так же имя скрипта который нам надо будет запустить.
Минимальные необходимые параметры окружения
$url = '/path/to/script.php' $env = [ 'REQUEST_METHOD' => 'GET', 'SCRIPT_FILENAME' => $url, ];
Первое что нам тут нужно сделать — это подготовить необходимые переменные, то есть пары имя => значение, которые мы передадим приложению.
Структура пар имя значение будет такая
//для пар в которых значение имени и данных в менее 128 байт typedef struct { unsigned char nameLength; unsigned char valueLength; unsigned char nameData unsigned char valueData; }; //имя и значение кодируется 1 байтом
Идет сначала 1 байт — длинна имени, потом 1 байт значение
//для пар в которых значение имени и данных более 128 байт typedef struct { unsigned char nameLengthA1; unsigned char nameLengthA2; unsigned char nameLengthA3; unsigned char nameLengthA4; unsigned char valueLengthB1; unsigned char valueLengthB2; unsigned char valueLengthB3; unsigned char valueLengthB4; unsigned char nameData unsigned char valueData; }; //имя и значение кодируется 4 байтами
В нашем случае и имя и значения короткие и подходят под первый вариант, по этому его и будем рассматривать.
Закодируем наши переменные в соответствии форматом
$keyValueFcgiString = ''; foreach ($env as $key => $value) { //длинна разных по длине значений кодируется по разному //если меньше 128 байт то одним байтом если больше то четырьмя $keyLen = strlen($key); $lenKeyChar = $keyLen < 128 ? chr($keyLen) : pack('N', $keyLen); $valLen = strlen($value); $valLenChar = $valLen < 128 ? chr($valLen) : pack('N', $valLen); $keyValueFcgiString .= $lenKeyChar . $valLenChar . $key . $value; }
Тут значения меньше 128 бит кодируются функцией chr($keyLen), больше pack('N', $valLen), где 'N' обозначает 4 байта. И затем все это слепляется в одну строку в соответствии с форматом структуры. Тело записи готово.
В заголовке записи передаем все то же самое как и в предыдущей записи, кроме типа (он будет FCGI_PARAMS = 4) и длинны данных (она будет равна длине пар имя => значение, или длине строки $keyValueFcgiString которую ранее мы сформировали).
//отправка заголовка socket_write($socket, pack('CCnnCx', 1, 4, 1, strlen($keyValueFcgiString), 0)); //отправка body socket_write($socket, $keyValueFcgiString); //для перевода приложения в режим выполнения и отправки ответа посылаем еще одну запись //с нулевым body socket_write($socket, pack('CCnnCx', 1, 4, 1, 0, 0));
Получение ответа FCGI_PARAMS
Собственно после того как все предыдущее проделано, и приложению отправлено все что оно ожидает, оно начинает работу и нам остается только забрать результат этой работы из сокета.
Помним что в ответ мы получаем такие же записи и нам их тоже нужно интерпретировать.
Получаем заголовок, он всегда равен 8 байт (получать данные будем по байту)
$buf = ''; $arrData = []; $len = 8; while ($len) { socket_recv($socket, $buf, 1, MSG_WAITALL); //получаем данные по 1 байту и пишем их в массив $arrData[] = $buf; $len--; } //интерпретируем заголовок в соответствии с форматом 'CCnnCx' $protocol = unpack('C', $arrData[0]); $type = unpack('C', $arrData[1]); $id = unpack('n', $arrData[2] . $arrData[3]); $dataLen = unpack('n', $arrData[4] . $arrData[5])[1]; //длинна данных в ответе, их нам надо будет получить после заголовка (unpack возвращает массив, по этому там индекс) $foo = unpack('C', $arrData[6]); var_dump($dataLen); //сколько байт будет в теле ответа
Теперь в соответствии с полученной длинной тела ответа сделаем еще одно чтение из сокета
$buf2 = ''; $result = []; while ($dataLen) { socket_recv($socket, $buf2, 1, MSG_WAITALL); $result[] = $buf2; $dataLen--; } var_dump(implode('', $result)); //тут будет то что отдаст искомый скрипт socket_close($socket);
Ура все сработало! Наконец то!
Что мы имеем в ответе, если например в этом файле
$url = '/path/to/script.php' //переменная окружения которую задали ранее
мы пропишем
<?php echo "My fcgi script";
то в ответе получим в итоге

Итоги
Много тут не буду писать итак статья длинная получилась. Надеюсь она кому то поможет. И приведу сам итоговый скрипт, он получился совсем небольшой. Конечно он в таком виде довольно мало может, и в нем нет обработки ошибок и всего этого, но ему это и не надо, он нужен как пример, чтобы показать основы.
<?php $url = '/path/to/script.php'; $env = [ 'REQUEST_METHOD' => 'GET', 'SCRIPT_FILENAME' => $url, ]; $service_port = 9000; $address = '127.0.0.1'; $socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); $result = socket_connect($socket, $address, $service_port); //ОТКРЫТИЕ СОЕДИНЕНИЯ //запрос на начало сессии с php-fpm //параметры в пордке следования версия, тип записи (что эта запись будет делать), id запроса, длинна тела записи, длинна данных для выравнивания socket_write($socket, pack('CCnnCx', 1, 1, 1, 8, 0)); //тело записи для открытия соединения //параметры роль, флаг управляющий закрытыием соединения socket_write($socket, pack('nCxxxxx', 1, 0)); $keyValueFcgiString = ''; foreach ($env as $key => $value) { //длинна разных по длинне значений кодируется по разному //если меньше 128 байт то одним байтом если больше то четырьмя $keyLen = strlen($key); $lenKeyChar = $keyLen < 128 ? chr($keyLen) : pack('N', $keyLen); $valLen = strlen($value); $valLenChar = $valLen < 128 ? chr($valLen) : pack('N', $valLen); $keyValueFcgiString .= $lenKeyChar . $valLenChar . $key . $value; } //следующая запись, тут уже будем передавать в php-fpm параметры в каком окружении и какой скрипт мы хотим запустить //из особенностей опишу параметры которые передаю //1-версия (по старому), 4-тип записи (новое, означает передачу пар имя-значение FCGI_PARAMS), id запроса (тот же), длинна тела записи (длинна моих пар ключ-значение), длинна данных для выравнивания socket_write($socket, pack('CCnnCx', 1, 4, 1, strlen($keyValueFcgiString), 0)); //отправляем пары ключ значение на сервер socket_write($socket, $keyValueFcgiString); //финишируем запрос socket_write($socket, pack('CCnnCx', 1, 4, 1, 0, 0)); $buf = ''; $arrData = []; $len = 8; while ($len) { socket_recv($socket, $buf, 1, MSG_WAITALL); //получаем данные по 1 байту и пишем их в массив $arrData[] = $buf; $len--; } //интерпритируем заголовок в соответствии с форматом 'CCnnCx' $protocol = unpack('C', $arrData[0]); $type = unpack('C', $arrData[1]); $id = unpack('n', $arrData[2] . $arrData[3]); $dataLen = unpack('n', $arrData[4] . $arrData[5])[1]; //длинна данных в ответе, их нам надо будет получить после заголовка (unpack возвращает массив, по этому там индекс) $foo = unpack('C', $arrData[6]); $buf2 = ''; $result = []; while ($dataLen) { socket_recv($socket, $buf2, 1, MSG_WAITALL); $result[] = $buf2; $dataLen--; } var_dump(implode('', $result)); //тут будет то что отдас искомый скрипт socket_close($socket);
