company_banner

Разработка гибридных PHP/Go приложений с использованием RoadRunner

    Классическое PHP-приложение — однопоточность, тяжелая загрузка (если вы, конечно, не пишите на микрофреймворках) и неизбежная смерть процесса после каждого запроса… Такое приложение тяжелое и медленное, но мы можем дать ему вторую жизнь гибридизацией. Чтобы ускорить — демонизируем и оптимизируем утечки памяти, чтобы добиться большей производительности — внедрим собственный сервер РНР-приложений RoadRunner на Golang, чтобы добавить гибкости — упростим PHP-код, расширим стек и разделим ответственность между сервером и приложением. По сути, заставим наше приложение работать, как если бы мы писали его на Java или другом языке.

    Благодаря гибридизации ранее медленное приложение перестало страдать 502 ошибками под нагрузками, уменьшилось среднее время ответа на запросы, производительность увеличилась, а деплой и сборка стали проще за счет унификации приложения и избавления от лишней обвязки в виде nginx + php-fpm.


    Антон Титов (Lachezis) — технический директор и соучредитель SpiralScout LLC с опытом активной коммерческой разработки на PHP в 12 лет. Последние несколько лет активно внедряет Golang в стек разработки компании. Об одном из примеров Антон рассказал на PHP Russia 2019.

    Жизненный цикл РНР-приложения


    Схематично устройство абстрактного приложения с неким фреймворком выглядит так.



    Когда мы отправляем запрос к процессу, происходит:

    • инициализация проекта;
    • загрузка общих библиотек, фреймворков и ORM;
    • загрузка библиотек, необходимых для конкретного проекта;
    • роутинг;
    • запрос роутинга на конкретный контроллер;
    • генерация ответа.

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

    Lazy-loading


    Стандартный и простой способ ускорения — внедрение системы Lazy-loading или On-demand-загрузки библиотек.



    С помощью Lazy-loading мы запрашиваем только необходимый код.

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

    Кэшируем частые вычисления


    Способ сложнее и активно используется, например, во фреймворке Symfony, шаблонизаторах, схемах ORM и роутинге. Это не кэширование вроде memcached или Redis для данных пользователя. Эта система прогревает части кода заранее. При первом запросе система генерирует код или кэш-файл, и при последующих запросах эти вычисления, необходимые, например, для компиляции шаблона, выполняться уже не будут.



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

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


    При получении запроса от внешнего сервера PHP-FPM точка входа запроса и инициализация будут совпадать.

    Получается, что запрос клиента — это состояние нашего процесса.

    Единственный способ изменить это состояние — полностью уничтожить воркер и начать заново с новым запросом.



    Это однопоточная классическая модель со своими плюсами.

    • Все воркеры в конце запросов умирают.
    • Утечки памяти, race condition, deadlocks не присущи PHP. Можно из-за этого не волноваться.
    • Код простой: пишем, обрабатываем запрос, умираем и двигаемся дальше.

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

    Как это работает на сервере


    Вероятнее всего, будет работать связка из nginx и PHP. Nginx будет работать как reverse proxy: отдавать пользователям часть статики, а часть запросов делегировать менеджеру PHP-процессов PHP-FPM ниже. Уже менеджер поднимает отдельный воркер под запрос и обрабатывает. После этого воркер уничтожается или очищается. Дальше создается новый воркер для следующего запроса.



    Такая модель работает стабильно — приложение почти невозможно убить. Но при больших нагрузках объем работ для инициализации и уничтожения воркеров влияет на производительность системы, ведь даже для простого GET запроса нам часто приходится тянуть кучу зависимостей и заново поднимать соединение с базой данных.

    Ускоряем приложение


    Как ускорить классическое приложение после введения кэша и Lazy-loading? Какие варианты еще есть?

    Обратиться к самому языку.

    • Использовать OPCache. Думаю, никто не запускает PHP на продакшн без включенного OPCache?
    • Дождаться RFC: Preloading. Он позволит предзагружать набор файлов в виртуальную машину.
    • JIT — серьезно ускоряет работу приложения на CPU-bound tasks. К сожалению, с задачами, связанными с базами данных, он не сильно поможет.

    Использовать альтернативы. Например, виртуальную машину HHVM от Facebook. Она выполняет код в более оптимизированной среде. К сожалению, HHVM не полностью совместима с синтаксисом PHP. Как альтернатива альтернативе — компиляторы kPHP от ВК или PeachPie, который полностью преобразует код в .NET C#.

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

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

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

    Переносим точку входа — демонизация


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



    Адаптируем приложение


    Интересно, что мы можем сфокусироваться на оптимизации только той части приложения, которая будет выполняться в runtime: контроллеры, бизнес-логика. В таком случае можно отказаться от модели Lazy-loading. Часть bootstrapping проекта вынесем в начало — в момент инициализации. Предварительные вычисления: роутинг, шаблоны, настройки, схемы ORM раздуют инициализацию, но в будущем сэкономят время обработки одного конкретного запроса.



    Компилировать шаблоны при загрузке воркера я не рекомендую, но загрузить, например, все конфигурации — полезно.

    Сравним модели


    Сравним демонизированную (слева) и классическую модели.



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

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

    Проблемы долгоживущей модели


    Несмотря на преимущества, у модели есть набор ограничений.

    Утечки памяти. Приложение лежит в памяти очень долго, а если использовать «кривые» библиотеки, неправильные зависимости или глобальные состояния — память начнет утекать. В какой-то момент проявится фатальная ошибка, которая сломает запрос пользователя.

    Проблема решается двумя путями.

    • Пишите аккуратный код, используйте проверенные библиотеки.
    • Активно мониторьте воркеры. Если подозреваете, что внутри процесса утекает память — превентивно меняйте его на аналог с меньшим лимитом, то есть просто на новую копию которая еще не успела накопить неочищенную память.

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

    Проблема решается на уровне архитектуры приложения.

    • Не храните активного пользователя в глобальном контексте. Все данные, которые специфичны контексту запроса сбрасываем и очищаем перед последующим запросом.
    • Аккуратно обращайтесь с данными сессий. Сессии в PHP — при классическом подходе это глобальный объект. Заворачивайте его правильно, чтобы при последующем запросе он сбрасывался.

    Управление ресурсами.

    • Контролируйте соединения к БД. Если приложение висит в памяти месяц или два, то открытое соединение, скорее всего, за это время закроется: базу передеплоят, перезагрузят или firewall сбросит соединение. На уровне кода учитывайте reconnect или после каждого запроса сбрасывайте соединение и поднимайте его заново при следующем запросе.
    • Избегайте долгоживущих file lock. Если ваш воркер пишет какую-то информацию в файл — проблем нет. Но если этот файл открыт и имеет на себе блокировку, то ни один другой процесс в вашей системе не будет иметь к нему доступа до момента освобождения блокировки.


    Исследуем долгоживущую модель


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

    Неблокирующий подход


    Используем асинхронный PHP — загружаем приложение один раз в память и обрабатываем входящие HTTP-запросы внутри приложения. Теперь приложение и сервер — один процесс. Когда поступает запрос — создаем отдельную корутину или в event loop даем promise, обрабатываем и отдаем его пользователю.



    Неоспоримый плюс подхода — максимальная производительность. Также есть возможность использовать интересные инструменты, например, настраивать WebSocket непосредственно на вашем приложении.

    Однако подход существенно повышает сложность разработки. Необходимо ставить ELDO, помнить, что не все драйверы баз данных будут поддерживаться, а библиотека PDO — исключается.

    Для решения проблем в случае демонизации с неблокирующим подходом можно использовать известные инструменты: ReactPHP, amphp и Swoole — интересная разработка в виде C-расширения. Эти инструменты работают быстро, у них хороший комьюнити и неплохая документация.

    Блокирующий подход


    Мы не поднимаем корутины внутри приложения, а делаем это извне.



    Просто поднимаем несколько процессов приложения, как это делало бы PHP-FPM. Вместо того, чтобы передавать данные запросы в виде состояния процесса, мы доставляем их извне в виде протокола или обмена сообщениями.

    Мы пишем тот же самый известный нам однопоточный код, используем все те же библиотеки и тот же PDO. Всю тяжелую работу по работе с сокетами, HTTP и другими инструментами выполняем вне PHP-приложения.

    Из минусов: мы должны следить за памятью и помнить, что общение между двумя разными процессами не бесплатно, а нам нужно передавать данные. Это будет создавать небольшой overhead.

    Для решения проблемы уже есть инструмент РНР-РМ, который написан на PHP. На библиотеке ReactPHP у него есть интеграция с несколькими фреймворками. Однако, PHP-PM очень медленный, на уровне сервера у него утекает память и под нагрузками показывает не настолько большой прирост, как РНР-FРМ.

    Пишем свой сервер приложений


    Мы написали свой сервер приложений, который похож на РНР-РМ, но функционала больше. Что мы хотели от сервера?

    Совместить с существующими фреймворками. Мы бы хотели иметь гибкую интеграцию практически со всеми фреймворками на рынке. Не хочется писать инструмент, который работает только в конкретном частном случае.

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

    Высокая скорость и стабильность работы. Все-таки HTTP-сервер пишем.

    Легкая расширяемость. Хотим использовать сервер не только как HTTP-Server, но и для отдельных сценариев вроде сервера очередей либо gRPC-сервера.

    Работа из коробки везде, где только возможно: Windows, Linux, ARM CPU.

    Возможность писать очень быстрые многопоточные расширения, специфичные нашему приложению.

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

    Сервер RoadRunner


    Для создания РНР-сервера необходимо решить 4 основные проблемы:

    • Наладить коммуникацию между Golang и РНР-процессами.
    • Управление процессами: создание, уничтожение, мониторинг воркеров.
    • Балансирование задач — эффективная раздача задач воркерам. Поскольку мы реализуем систему, которая блокирует отдельный воркер на отдельную конкретную приходящую задачу, важно сделать систему, которая быстро бы говорила, что процесс закончил работу и готов принимать следующую задачу.
    • HTTP-стек — передача данных HTTP-запроса воркеру. Это простая задача — написать входящую точку, в которую пользователь посылает запрос, который передается PHP и возвращается обратно.

    Варианты взаимодействия между процессами


    Сначала решим проблему общения между Golang и РНР-процессами. У нас есть несколько путей.

    Embedding: встраивание PHP-интерпретатора непосредственно в Golang. Это возможно, но требует кастомной сборки РНР, сложной настройки и общего процесса для сервера и РНР. Как в go-php, например, где PHP-интерпретатор интегрирован в Golang.

    Shared Memory — использование общего пространства памяти,где процессы делят это пространство. Здесь требуется кропотливая работа. При обмене данных придется синхронизировать состояние вручную и объем ошибок, которые могут возникнуть, достаточно велик. А еще Shared Memory зависит от ОС.

    Пишем свой транспортный протокол — Goridge


    Мы пошли по простому пути, который применен практически во всех решениях на Linux-системах — использовали транспортный протокол. Он написан поверх стандартных PIPES и UNIX/TCP SOCKETS.

    Он имеет возможность передавать данные в обе стороны, обнаруживать ошибки, и также тегировать запросы и проставлять им заголовки. Важный нюанс для нас — возможность реализовать протокол без зависимостей как на стороне PHP, так и Golang — без C-расширений на чистом языке.

    Как в любом протоколе, основа — это пакет данных. В нашем случае у пакета фиксированный заголовок размером 17 байт.



    Первый байт выделяется на определение типа пакета. Это может быть поток либо флаг, который указывает на тип сериализации данных. Затем два раза мы упаковываем размер данных в Little Endian и Big Endian. Это наследство мы используем для обнаружения ошибок передачи. Если мы видим, что размер упакованных данных в двух разных порядках не совпадает, скорее всего, произошла ошибка передачи данных. Затем передаются данные.



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

    Для реализации протокола на языке Golang и РНР мы использовали стандартные инструменты.

    На Golang: библиотеки encoding/binary и библиотеки io и net для работы со стандартными пайпами и UNIX/TCP сокетами.

    На PHP: всем знакомая функция для работы с бинарными данными pack/unpack и расширения streams и sockets для пайпов и сокетов.

    Во время реализации возник интересный побочный эффект. Мы провели его интеграцию со стандартной библиотекой Golang net/rpc, что позволяет вызывать сервисный код из Golang непосредственно в приложении.

    Пишем сервис:

    // Арр sample 
    type Арр struct{}
    
    // Hi returns greeting message.
    func (a *App) Hi(name string, r *string) error {
        *r = fmt.Sprintf("Неllо, %s!", name) 
        return nil
    }

    Небольшим объемом кода вызываем его из приложения:

    <?php
    use Spiral\Goridge;
    
    require "vendor/autoload.php";
    
    $rpc = new Goridge\RPC(
        new Goridge\SocketRelay("127.0.0.1", 6001)
    );
    
    echo $rpc->call("App.Hi", "Antony");

    Менеджер PHP-процессов


    Следующая часть сервера — управление РНР-воркерами.


    Воркер — это PHP-процесс, за которым мы постоянно наблюдаем со стороны Golang. Мы собираем лог его ошибок в файл STDERR, общаемся с воркером посредством транспортного протокола Goridge, и собираем статистику потребления памяти, выполнению задач и блокировке.

    Реализация простая — это стандартный функционал os/exec, runtime, sync, atomic. Для создания воркеров используем Worker Factory.


    Почему Worker Factory? Потому что мы хотим общаться как по стандартным пайпам, так и по сокетам. В данном случае процесс инициализации немножко отличается. При создании воркера, который общается по пайпам, можем создать его сразу и напрямую отправлять данные. В случае с сокетами необходимо создать воркер, дождаться, пока он достучится в систему, сделать PID handshake, и только после этого продолжать работу.

    Балансировщик задач


    Третья часть сервера — самая важная для производительности.

    Для реализации используем стандартную функциональность Golang — буферизованный канал. В частности, создаем несколько воркеров и помещаем их в данный канал в виде LIFO-стека.

    При получении задач от пользователя посылаем запрос LIFO-стеку и просим выдать первый свободный воркер. Если воркер не удается аллоцировать за определенное количество времени, то пользователь получает ошибку типа «Timeout Error». Если же воркер аллоцирован — он достается из стека, блокируется, после чего принимает задачу от пользователя.

    После того, как задача обработана, ответ возвращается пользователю, а воркер встает в конец стека. Он снова готов выполнять следующую задачу.

    Если возникнет ошибка, то и пользователь получит ошибку, так как воркер будет уничтожен. Мы просим Worker Pool и Worker Factory создать идентичный процесс и заменить его в стеке. Это позволяет системе работать даже в случае фатальных ошибок просто пересоздавая воркеры по аналогии с PHP-FPM.


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

    Проактивный мониторинг


    Отдельная часть и менеджера процессов, и балансировщика задач — система проактивного мониторинга.


    Это система, которая раз в секунду опрашивает воркеры и следит за показателями: смотрит сколько памяти они потребляют, сколько в ней находятся, IDLE ли они. Кроме слежения, система контролирует утечки памяти. Если воркер превысит определенный лимит, мы это увидим и аккуратно уберем его из системы до возникновения фатальной утечки.

    HTTP-стек


    Последняя и простая часть.

    Как реализуется:

    • поднимает HTTP-точку на стороне Golang;
    • получаем запрос;
    • преобразуем в формат PSR-7;
    • передаем запрос первому свободному воркеру;
    • распаковываем запрос в PSR-7-объект;
    • обрабатываем;
    • генерируем ответ.

    Для реализации мы использовали стандартную библиотеку Golang NET/HTTP. Это известная библиотека, со множеством расширений. Умеет работать как по HTTPS, так по HTTP/2 протоколу.

    На стороне PHP мы использовали стандарт PSR-7. Это независимый фреймворк, со множеством расширений и Middlewares. По дизайну PSR-7 иммутабелен, что хорошо вписывается в концепцию долгоживущих приложений и позволяет избежать ошибок глобального запроса.

    Обе структуры как в Golang, так и в PSR-7, похожи, что существенно сэкономило время на маппинг запроса из одного языка в другой.

    Для запуска сервера требуется минимальная обвязка:

    http:
        address: 0.0.0.0:8080 
        workers:
            command: "php psr-worker.php" 
            pool:
                numWorkers: 4

    Причем, с версии 1.3.0 последнюю часть конфига можно опустить.

    Скачиваем бинарный файл сервера, кладем его в Docker-контейнер или в папку с проектом. Как вариант — глобально пишем небольшой конфигурационный файл, который описывает, какой именно pod мы собираемся слушать, какой воркер — точка входа, и сколько их требуется.

    На стороне PHP мы пишем primary loop, который получает PSR-7-запрос, обрабатывает его и возвращает обратно серверу ответ либо ошибку.

    while ($req = $psr7->acceptRequest()) {
        try {
            $resp = new \Zend\Diactoros\Response();
            $resp->getBody()->write("hello world");
    
            $psr7->respond($resp);
        } catch (\Throwable $e) {
            $psr7->getWorker()->error((string)$e);
        }
    }

    Сборка. Для реализации сервера выбрали архитектуру с компонентным подходом. Это дает возможность собирать сервер под нужды проекта, добавляя или убирая отдельные куски в зависимости от требований приложения.

    func main() {
        rr.Container.Register(env.ID, &env.Service{}) 
        rr.Container.Register(rpc.ID, &rpc.Service{}) 
        rr.Container.Register(http.ID, &http.Service{}) 
        rr.Container.Register(static.ID, &static.Service{}) 
        rr.Container.Register(limit.ID, &limit.Service{}
    
        // you can register additional commands using cmd.CLI
         rr.Execute()
    }

    Варианты использования


    Рассмотрим варианты использования сервера и модифицикации структуры. Для начала рассмотрим классический pipeline — работу сервера с запросами.

    Модульность


    Сервер получает запрос в HTTP-точку и пропускает его через набор Middleware, которые написаны на Golang. Входящий запрос преобразуется в задачу, которую понимает воркер. Сервер отдает воркеру задачу и возвращает обратно.



    Одновременно с этим воркер, используя Goridge-протокол, общается с сервером, контролирует его состояние и передает ему данные.

    Middleware на Golang: авторизация


    Это первое, что можно сделать. В нашем приложении мы писали Middleware для авторизации пользователя по JWT токену. Аналогично пишутся Middleware для любого другого типа авторизации. Очень банальная и простая реализация — это написание Rate-Limiter либо Circuit-Breaker.



    Авторизация получается быстрой. Если запрос не валидный — просто не отсылаем его PHP-приложению и не тратим ресурсы на обработку бесполезных задач.

    Мониторинг


    Второй вариант использования. Мы можем интегрировать систему мониторинга непосредственно в Golang Middleware. Например, Prometheus, чтобы собирать статистику по скорости ответа точек, по количеству ошибок.



    Также можно комбинировать мониторинг с метриками, специфичными приложению (доступно в стандартной поставке с 1.4.5). Например, можем отсылать количество запросов в базу данных или количество обработанных определенных запросов в Golang-сервер, а затем в Prometheus.

    Распределенный трейсинг и логирование


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



    Также мы можем тегировать запросы, отдавать им определенный ID и передавать этот ID во все нижестоящие сервисы или системы общения между ними. В итоге мы можем построить распределенный трейс и видеть как ложатся логи приложения.

    Записываем историю запросов


    Это небольшой модуль, который записывает все входящие запросы и хранит их во внешней БД. Модуль позволяет сделать replay запросов в проекте и реализовать автоматическую систему тестирования, систему нагрузочного тестирования или просто проверку работы API.



    Как мы реализовали модуль?

    Обрабатываем часть запросов на Golang. Мы пишем Middleware на Golang и часть запросов можем отправить в Handler, который также написан на Golang. Если какая-то точка приложения вызывает беспокойство с точки зрения производительности — переписываем ее на Golang и перетаскиваем стек с одного языка на другой.



    Пишем WebSocket-сервер. Реализация WebSocket-сервера или сервера пуш-уведомлений становится тривиальной задачей.

    • Golang-сервис на уровне сервера.
    • Для коммуникации используем Goridge.
    • Тонкий сервисный слой на PHP.
    • Реализуем сервер уведомлений.

    Мы получаем запрос и поднимаем WebSocket-соединение. Если приложению необходимо отправить какое-то уведомление пользователю, оно запускает это сообщение по RPC-протоколу в WebSocket-сервер.



    Управляем окружением PHP. RoadRunner при создании Worker Pool имеет полный контроль над состоянием переменных окружения и позволяет их менять, как захочется. Если пишем большое распределенное приложение, то можем использовать единый источник данных конфигураций и подключить его в виде системы для настройки окружения. Если поднимаем набор сервисов, все эти сервисы будут стучаться в одну единую систему, настраиваться и после этого работать. Это может значительно упростить деплой, а также позволит избавиться от .env файлов.



    Интересно, что env-переменные, которые доступны внутри воркера, не глобальны внутри системы. Это немного повышает безопасность контейнеров.

    Интеграция Golang-библиотеки в PHP


    Этот вариант мы использовали на официальном сайте RoadRunner. Это интеграция практически полноценной БД с полнотекстовым поиском BleveSearch внутри сервера.



    Мы индексировали страницы документации: поместили их в Bolt DB, после чего выполнили полнотекстовый поиск без реальной БД вроде MySQL, и без поискового кластера вроде Elasticsearch. Получился небольшой проект, где часть функциональности на PHP, но поиск на Golang.

    Реализация Lambda-функций


    Можно пойти дальше и полностью избавиться от HTTP-слоя. В этом случае реализация, например, Lambda-функций простая задача.



    Для реализации мы используем стандартный runtime от AWS для Lambda-функции. Пишем маленькую обвязку, полностью выпиливаем HTTP-серверы и посылаем данные в бинарном формате воркерам. У нас также есть доступ к настройкам окружения, что позволяет писать функции, которые конфигурируются непосредственно из админки Амазона.

    Воркеры находятся в памяти все время жизни процесса и Lambda-функция после изначального запроса остается в памяти прогретой 15 минут. В это время код не загружается и отвечает быстро. В синтетических тестах мы получали до 0.5 мс на один входящий запрос.

    gRPC для PHP


    Вариант сложнее — заменить HTTP-слой на слой gRPC. Этот пакет доступен на GitHub.


    Мы можем полностью проксировать все входящие Protobuf-запросы нижестоящему PHP-приложению, там их распаковывать, обрабатывать и отвечать обратно. Код можем писать как на PHP, так и на Golang, комбинируя и перенося функционал с одного стека на другой. Сервис поддерживает Middleware. Может работать как standalone-приложение, так и в связке с HTTP.

    Сервер очередей


    Последний и самый интересный вариант — реализация сервера очередей.


    На стороне PHP все, что мы делаем, это получаем бинарный payload, распаковываем его, выполняем работу и сообщаем серверу об успешном выполнении. На стороне же Golang мы полностью занимаемся работой по управлению соединениями с брокерами. Это может быть RabbitMQ, Amazon SQS или Beanstalk.

    На стороне Golang реализуем «Graceful shutdown» воркеров. Мы можем красиво дождаться реализации «durable connection» — если соединение с брокером теряется, то сервер некоторое время ждет, используя «back-off стратегию», переподнимает соединение и приложение этого даже не заметит.

    Можем обрабатывать данные запросы как на PHP, так и на Golang, и ставить их в очередь с обеих сторон:

    • со стороны PHP посредством Goridge-протокол Goridge RPC;
    • со стороны Golang — общаясь с SDK-библиотекой.

    Если payload падает, то падает не весь Consumer, а только один отдельный процесс. Система его сразу поднимает, задача отправляется следующему воркеру. Это позволяет выполнять задачи нон-стоп.

    Мы реализовали один из брокеров непосредственно в памяти сервера и использовали функционал Golang. Это позволяет нам писать приложение с использованием очередей еще до выбора финального стека. Поднимаем приложение локально, запускаем и у нас есть очереди, которые работают в памяти и ведут себя так же, как они вели бы себя на RabbitMQ, Amazon SQS или Beanstalk.

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

    Разделяем доменные области


    Golang — это многопоточный и быстрый язык, который подходит для написания инфраструктурной логики и логики мониторинга и авторизации пользователей.

    Он также полезен для реализации кастомных драйверов для доступа к источникам данных — это очереди, например, Kafka, Cassandra.

    PHP — отличный язык для написания бизнес-логики.

    Это хорошая система для HTML-рендеринга, ORM и работе с БД.

    Сравнение инструментов


    Несколько месяцев назад на Хабре сравнили PHP-FPM, PHP-PM, React-PHP, Roadrunner и другие инструменты. Бенчмарк провели на проекте с реальным Symfony 4.

    RoadRunner под нагрузками показывает неплохие результаты и опережает все серверы. Если сравнивать с PHP-FPM, то производительность в 6–8 раз больше.


    В том же бенчмарке RoadRunner не потерял ни одного запроса, все было отработано на 100 %. К сожалению, React-PHP потерял под нагрузками 8–9 запросов — это неприемлемо. Мы хотели бы, чтобы сервер не падал и работал стабильно.


    С момента публикации RoadRunner в публичном доступе на GitHub мы получили больше 30 000 установок. Сообщество помогло нам написать определенный набор расширений, улучшений и поверить, что решение имеет право на жизнь.

    RoadRunner хорош, если вы хотите существенно ускорить приложение, но еще не готовы прыгать в асинхронное PHP. Это компромисс, который потребует определенного количества усилий, но не настолько значительных, как полное переписывание кодовой базы.

    Берите RoadRunner, если хотите больше контролировать жизненный цикл РНР, если возможностей РНР не хватает, например, для системы очередей или Kafka и когда вашу проблему решает популярнаяGolang-библиотека, которой нет на PHP, а написание требует времени, которого у вас тоже нет.

    Итоги


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

    • Увеличили скорость реакции точек приложения в 4 раза по сравнению с PHP-FPM.
    • Полностью избавились от ошибок 502 под нагрузками. При пиковых нагрузках сервер просто ждет чуть дольше и отвечает так, как если бы нагрузок не было.
    • После оптимизации утечек памяти воркеры висят в памяти до 2-х месяцев. Это помогает при написании распределенных приложений, поскольку все запросы между сервисами уже прокэшированы на уровне сокетов.
    • Используем Keep-Alive. Это существенно ускоряет общение между распределенной системой.
    • Внутри реальной инфраструктуры все помещаем в Alpine Docker в Kubernetes. Система деплоя и сборки проекта теперь проще. Все, что требуется — это собрать кастомный RoadRunner build под проект, положить в проект в Docker, залить Docker-образ, и после этого спокойно загружать наш pod в Kubernetes.
    • По реальному таймингу одного из проектов на отдельные точки, которые не имеют доступа к БД, среднее время ответа 0,33 мс.

    Следующая профессиональная конференция для PHP-разработчиков PHP Russia только в следующем году. Пока предлагаем следующее:

    • Обратить внимание на GolangConf, если вас заинтересовала часть про Go и вы хотите узнать больше подробностей или услышать аргументы в пользу перехода на этот язык. Если сами готовы делиться опытом — скорее присылайте тезисы.
    • Принять участие в HighLoad++ в Москве, если вам важно все, что связано с высокой производительностью, — подать доклад до 7 сентября, или забронировать билет.
    • Подписаться на рассылку и telegram-канал, чтобы раньше других получить приглашение на PHP Russia 2020.
    • +42
    • 7,9k
    • 2
    Конференции Олега Бунина (Онтико)
    759,83
    Конференции Олега Бунина
    Поделиться публикацией

    Комментарии 2

      +3
      Около года назад я проводил сравнение swoole/workerman/roadrunner и roadrunner был медленнее раз в 5-15.
      Мне было не совсем понятно, почему он настолько медленнее, пока я не наткнулся в описании, что при взаимодействии php и go используется pack/unpack. Тогда я вспомнил, что в рамках моего исследования двухгодовалой давности я заметил, что pack/unpack хоть и даёт лучшие цифры по сжатию, но вот по скорости проигрывает хотя бы тому же swoole_pack где-то в 10 раз.
      Было бы не плохо если в roadrunner добавили опцию, которая позволяла переключиться с pack на swoole_pack, что гипотетически должно повысить скорость roadrunner в разы.
      PS: завёл тикет с предложением на гитхабе.
        +3

        Спасибо за предложение, к 2.0 планируем исследования альтернативных способов общения между процессами вроде system v msq, попробуем пробенчить и swoole_pack.

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

      Самое читаемое