RoadRunner: PHP не создан, чтобы умирать, или Golang спешит на помощь

За последние годы PHP сильно продвинулся вперёд: улучшился сборщик мусора, повысился уровень стабильности — сегодня на PHP можно без особых проблем писать демоны и долгоживущие скрипты. Это позволило Spiral Scout пойти дальше: RoadRunner, в отличие от PHP-FPM, не очищает память между запросами, что даёт дополнительный выигрыш в производительности (хотя этот подход и  усложняет процесс разработки). Мы сейчас экспериментируем с этим инструментом, но у нас пока нет результатов, которыми можно было бы поделиться. Чтобы ждать их было веселее, публикуем перевод анонса RoadRunner от Spiral Scout.

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

В последние десять лет мы создавали приложения и для компаний из списка Fortune 500, и для бизнеса с аудиторией не более 500 пользователей. Всё это время наши инженеры разрабатывали бекенд преимущественно на PHP. Но два года назад кое-что сильно повлияло не только на производительность наших продуктов, но и на их масштабируемость — мы ввели Golang (Go) в наш стек технологий.

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

Мы расскажем, как связка Go и PHP помогает решать реальные задачи разработки и как она превратилась для нас в инструмент, способный избавить от части проблем, связанных с моделью «умирания» PHP.

Ваша повседневная среда PHP-разработки

Прежде чем рассказывать, как с помощью Go можно оживить модель «умирания» PHP, давайте рассмотрим вашу стандартную среду PHP-разработки.

В большинстве случаев вы запускаете приложение с помощью комбинации веб-сервера nginx и сервера PHP-FPM. Первый обслуживает статичные файлы и перенаправляет в PHP-FPM специфические запросы, а сам PHP-FPM исполняет PHP-код. Возможно, вы используете менее популярную связку из Apache и mod_php. Но хотя она работает чуть иначе, принципы те же.

Рассмотрим, как PHP-FPM исполняет код приложения. Когда приходит запрос, PHP-FPM инициализирует дочерний PHP-процесс, а детали запроса передаёт как часть его состояния (_GET, _POST, _SERVER и т. д.). 

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

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

Недостатки и неэффективность обычной PHP-среды

Если вы занимаетесь профессиональной разработкой на PHP, то знаете, с чего нужно начинать новый проект, — с выбора фреймворка. Он представляет собой библиотеки для внедрения зависимостей, ORM’ы, переводы и шаблоны. И, конечно же, все входные пользовательские данные можно удобно поместить в один объект (Symfony/HttpFoundation или PSR-7). Фреймворки — это клёво!

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

PHP-инженеры годами искали способы решения этой проблемы, использовали продуманные методики «ленивой» загрузки, микрофреймворки, оптимизированные библиотеки, кеш и т. д. Но в конечном итоге всё равно приходится сбрасывать всё приложение и начинать сначала, опять и опять. (Примечание переводчика: частично эта проблема будет решена с появлением preload в PHP 7.4)

Может ли PHP с помощью Go пережить больше одного запроса?


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

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

Ситуация улучшилась с выходом PHP 7: появился надёжный сборщик мусора, стало легче обрабатывать ошибки, а расширения ядра теперь защищены от утечек. Правда, инженерам всё ещё нужно осторожно обращаться с памятью и помнить о проблемах состояния в коде (а существует ли язык, в котором можно не уделять внимание этим вещам?). И всё же в PHP 7 нас подстерегает меньше неожиданностей.

Можно ли взять модель работы с долгоживущими PHP-скриптами, адаптировать её под более тривиальные задачи вроде обработки HTTP-запросов и тем самым избавиться от необходимости загружать всё с нуля при каждом запросе?

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

Мы знали, что сможем написать веб-сервер на чистом PHP (PHP-PM) или с использованием С-расширения (Swoole). И хотя у каждого способа есть свои достоинства, оба варианта нас не устраивали — хотелось чего-то большего. Нужен был не просто веб-сервер — мы рассчитывали получить решение, способное избавить нас от проблем, связанных с «тяжёлым стартом» в PHP, которое при этом можно легко адаптировать и расширять под конкретные приложения. То есть нам нужен был сервер приложений.

Может ли Go помочь в этом? Мы знали, что может, потому что этот язык компилирует приложения в одиночные бинарные файлы; он кроссплатформенный; использует собственную, очень элегантную, модель параллельной обработки (concurrency) и библиотеку для работы с HTTP; и, наконец, нам будут доступны тысячи open-source-библиотек и интеграций.

Трудности объединения двух языков программирования

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

Например, с помощью прекрасной библиотеки Алекса Палаэстраса можно было реализовать совместное использование памяти процессами PHP и Go (аналогично mod_php в Apache). Но эта библиотека обладает особенностями, ограничивающими её применение для решения нашей задачи.

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

Для начала мы создали несложный бинарный протокол для обмена данными между процессами и обработки ошибок передачи. В своей простейшей форме протокол этого типа похож на netstring с заголовком пакета фиксированного размера (в нашем случае 17 байт), который содержит информацию о типе пакета, его размер и двоичную маску для проверки целостности данных.

На стороне PHP мы использовали функцию pack, а на стороне Go — библиотеку encoding/binary.

Одного протокола нам показалось мало — и мы добавили возможность вызывать Go-сервисы net/rpc прямо из PHP. Позднее нам это очень помогло в разработке, поскольку мы могли легко интегрировать Go-библиотеки в PHP-приложения. Результат этой работы можно увидеть, например, в другом нашем open-source-продукте Goridge.

Распределение задач по нескольким PHP-воркерам

После реализации механизма взаимодействия мы стали думать, как эффективнее всего передавать задачи PHP-процессам. Когда приходит задача, сервер приложений должен выбрать для её выполнения свободный воркер. Если воркер/процесс завершил работу с ошибкой или «умер», мы избавляемся от него и создаём новый взамен. А если воркер/процесс отработал успешно, мы возвращаем его в пул воркеров, доступных для выполнения задач.

Для хранения пула активных воркеров мы использовали буферизированный канал, для удаления из пула неожиданно «умерших» воркеров добавили механизм отслеживания ошибок и состояний воркеров.

В результате мы получили рабочий PHP-сервер, способный обрабатывать любые запросы, представленные в бинарном виде.

Чтобы наше приложение начало работать как веб-сервер, пришлось выбрать надёжный PHP-стандарт для представления любых входящих HTTP-запросов. В нашем случае мы просто преобразуем net/http-запрос из Go в формат PSR-7, чтобы он был совместим с большинством доступных сегодня PHP-фреймворков.

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

Представляем RoadRunner — высокопроизводительный сервер PHP-приложений

Нашей первой тестовой задачей стал API-бекенд, на котором периодически непредсказуемо возникали всплески запросов (гораздо чаще обычного). Хотя в большинстве случаев возможностей nginx было достаточно, мы регулярно сталкивались с ошибкой 502, потому что не могли достаточно быстро балансировать систему под ожидаемое увеличение нагрузки.

Для замены этого решения в начале 2018 года мы развернули наш первый PHP/Go-сервер приложений. И сразу получили невероятный эффект! Мы не только полностью избавились от ошибки 502, но ещё и смогли на две трети уменьшить количество серверов, сэкономив кучу денег и таблеток от головной боли для инженеров и менеджеров продуктов.

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

Как RoadRunner может улучшить ваш стек разработки

Применение RoadRunner позволило нам использовать Middleware net/http на стороне Go, чтобы проводить JWT-верификацию ещё до того, как запрос попадает в PHP, а также чтобы обрабатывать WebSockets и глобально агрегировать состояния в Prometheus. 

Благодаря встроенному RPC можно открывать API любых Go-библиотек для PHP без написания экстеншенов-обёрток. Что ещё важнее, с помощью RoadRunner можно развёртывать новые серверы, отличающиеся от HTTP. В качестве примеров можно привести запуск в PHP обработчиков AWS Lambda, создание надёжных разборщиков очередей и даже добавление gRPC в наши приложения.

С помощью сообществ PHP и Go мы повысили стабильность решения, в некоторых тестах увеличили производительность приложений до 40 раз, усовершенствовали инструменты отладки, реализовали интеграцию с фреймворком Symfony и добавили поддержку HTTPS, HTTP/2, плагинов и PSR-17.

Заключение

Некоторые всё ещё находятся в плену устаревшего представления о PHP как о медленном громоздком языке, пригодном только для написания плагинов под WordPress. Эти люди даже могут сказать, что у PHP есть такое ограничение: когда приложение становится достаточно большим, приходится выбирать более «зрелый» язык и переписывать накопившуюся за много лет базу кода.

На всё это хочется ответить: подумайте ещё раз. Мы считаем, что только вы сами задаёте какие-то ограничения для PHP. Вы можете потратить всю жизнь на переходы с одного языка на другой, пытаясь найти идеальное сочетание с вашими потребностями, или можете начать воспринимать языки как инструменты. Мнимые недостатки языка вроде PHP на самом деле могут быть причинами его успеха. А если объединить его с другим языком вроде Go, то вы создадите гораздо более мощные продукты, чем если бы вы ограничились использованием какого-то одного языка.

Поработав со связкой Go и PHP, мы можем утверждать, что полюбили их. Мы не планируем жертвовать одним в пользу другого — напротив, будем искать способы извлечь ещё больше пользы из этого двойного стека.