Хабрахабр

Разработка гибридных 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 — исключается, по крайней мере без DB Pool. Однако подход существенно повышает сложность разработки.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Сервер RoadRunner

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

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

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

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

Как в go-php, например, где PHP-интерпретатор интегрирован в Golang. Embedding: встраивание 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-процессов

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

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

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

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

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

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

В частности, создаем несколько воркеров и помещаем их в данный канал в виде LIFO-стека.

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

После того, как задача обработана, ответ возвращается пользователю, а воркер встает в конец стека. Если воркер не удается аллоцировать за определенное количество времени, то пользователь получает ошибку типа «Timeout Error». Мы просим Worker Pool и Worker Factory создать идентичный процесс и заменить его в стеке. Он снова готов выполнять следующую задачу.

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

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

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

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

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

HTTP-стек

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

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

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

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

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

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

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

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

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

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

На стороне 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.IDj &rpc.Service{}) rr.Container.Register(http.ID, Shttp.Service{}) rr.Container.Register(static.ID, &static.Service{}) rr.Container.Register(limit.ID, Slimit.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, чтобы собирать статистику по скорости ответа точек, по количеству ошибок.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

gRPC для PHP

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

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

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

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

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

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

Можем обрабатывать данные запросы как на 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.

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

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

Сообщество помогло нам написать определенный набор расширений, улучшений и поверить, что решение имеет право на жизнь. С момента публикации 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.
Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть