20 October 2019

Запускаем PHP-скриптики через php-fpm без web-сервера. Или свой FastCGI-клиент (под капотом)

PHPNginx*nix

Приветствую всех читателей "Хабра".


Дисклеймер


Статья получилась довольно длинная и тем кто не хочет читать предысторию, а хочет перейти сразу к сути прошу прямиком к главе "Решение".


Вступление


В данной статье хотелось бы рассказать о решении довольно нестандартной задачи, с которой пришлось столкнуться во время рабочего процесса. А именно, нам понадобилось запускать в цикле кучу 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) обозначается 1 байтом ('C')
  2. тип записи. Для открытия, закрытия соединения и др. все не буду рассматривать, далее рассмотрю только то что понадобится для конкретной задачи, если нужны другие — добро пожаловать сюда спецификация. Обозначается 1 байтом ('C').
  3. ID запроса, произвольное число, обозначается 2 байтами ('n')
  4. длинна тела записи (данных), обозначается 2 байтами ('n')
  5. длинна выравнивающих данных и зарезервированные данные, по одному байту (тут не нужно особо обращать внимания, дабы не отвлекаться от главного в нашем случае всегда будет 0)

Далее идет само тело записи:


  1. сами данные (тут то именно и передаются переменные), могут иметь довольно большой размер (до 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";

то в ответе получим в итоге


image


Итоги


Много тут не буду писать итак статья длинная получилась. Надеюсь она кому то поможет. И приведу сам итоговый скрипт, он получился совсем небольшой. Конечно он в таком виде довольно мало может, и в нем нет обработки ошибок и всего этого, но ему это и не надо, он нужен как пример, чтобы показать основы.


Полная версия скрипта
<?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);
Tags:php php-fpm nginx fastcgi
Hubs: PHP Nginx *nix
+24
10k 64
Comments 17
Popular right now
PHP разработчик
from 90,000 to 100,000 ₽ИМАГМоскваRemote job
Backend-программист на PHP
from 160,000 ₽ЧемпионРязань
PHP разработчик
from 60,000 to 120,000 ₽EmsoftМоскваRemote job
Senior PHP Developer (100% REMOTE)
from 3,000 $HyprrRemote job