В одном из предыдущих проектов, с которым мне довелось работать, возникла необходимость решить две очень похожие проблемы. Наша команда столкнулась с ростом числа крон-задач, что усложнило управление ими, а также с увеличением числа консьюмеров, требовавших эффективного управления. Мы справились с этими вызовами, прибегнув к проверенному временем методу - использованию fork'ов. Хоть примеры будут представлены на языке программирования PHP, основной принцип можно адаптировать и для других технологий. В данной статье мы обсудим тактику использования fork'ов для оптимизации фоновых задач.
Хотя о fork'ах написано уже много, я не собираюсь повторяться в этой статье. Вместо этого я напомню, что при вызове fork процесс полностью клонирует сам себя, создавая два параллельных процесса, работающих независимо друг от друга.
Библиотека для иллюстрации
Для концентрации на сути статьи, а не на системных вызовах, я разработал небольшую библиотеку, которая представляет собой удобную обертку над этими вызовами. Однако, стоит учитывать, что данная библиотека все равно требует наличия расширения pcntl. В дальнейшем примеры будут демонстрироваться с использованием этой библиотеки.
Основным компонентом библиотеки является ForkManager, куда можно добавлять Worker'ы для выполнения различных задач, после чего менеджер запускает их. Подробное описание функционала можно найти в README этой библиотеки.
Важно отметить, что на практике мы не применяли данную библиотеку в реальных проектах, поскольку она еще не существовала на момент их выполнения.
Оптимизация cron-таблицы
В моей практике неоднократно возникали ситуации, когда cron-таблица становилась непрозрачной из-за множества задач. Начинаешь с одной крон-задачи, потом добавляешь еще одну, потом еще одну... И вот ты понимаешь, что список кронов многократно превышает размер экрана терминала. Это приводит к следующим проблемам:
-
Сложно отследить, какие задачи запускались, когда это происходило и с каким результатом.
-
Изменение расписания выполнения задач или добавление новых становится непростой задачей, требующей доступа к серверу и специалистов умеющих работать с cron-таблицей. Обычно это процесс, требующий идёт по маршруту обычной задачи разработки (постановка задачи - анализ -разработчик - тестирование).
-
Получить информацию о назначении каждой крон-задачи бывает сложно. Чтобы понять, что конкретная задача делает, приходится либо изучать ее код, либо запускать специальные команды. Описания часто требуются аналитикам, но им не всегда доступно понимание процесса работы крон-задач.
-
Тяжело было передавать такое решение в support, т.к. они не понимали назначение каждой джобы, а новые устанавливать было бы затруднительно.
Для решения этих проблем мы приняли следующие меры. Мы выделили полезную нагрузку в отдельные воркеры с описанием и стандартизированным интерфейсом, включая логирование. Мы сократили количество крон-задач до 5, каждая из которых представляла собой одну и ту же команду в рамках фреймворка, но с разными параметрами. Эти кроны были разделены по следующему принципу:
-
Вызывается каждую минуту.
-
Вызывается каждые 10 минут.
-
Вызывается каждый час.
-
Вызывается каждый день.
-
Вызывается каждую неделю.
Cron job'ы функционировали следующим образом. При запуске крона он запрашивал доступные воркеры, последовательно выполнял их, форкая процессы и ожидал их завершения перед переходом к следующему воркеру.
Дочерний процесс в свою очередь работал по следующему простому алгоритму. Сначала переподключал всё необходимое (в нашем случае это было только подключение к БД). Затем выполнял полезную нагрузку.
Управление тем, какие воркеры к какой крон джобе относятся мы вынесли в админку приложения и отдали работу по настройке аналитикам.
Примерный код выполняющий эту работу приведён ниже. Для начала подготовим сущности:
use AikusForkManagerWorker;
/**
* Сущность описывающая конркретный крон (раз в день/неделю/час) и т.д.
*/
class CronType
{
private int $id;
private string $alias;
private string $description;
private int $parallelLimit;
//getters and setters
}
/**
* Сущность описывающая воркер. Может быть слинкована с CronType. В этом случае
* будет выполняться во время выполнения этого крона.
*/
class CronWorker {
private int $id;
private string $workerClass;//класс выполняющий полезную работу в кроне
private string $description;
private ?CronType $cronType;
//getters and setters
}
class CrontTypeRepository {
public function findByAlias(string $alias): ?CrontType
{
/////////
}
}
class CronWorkerRepository {
public function findByCronType(CronType $cronType): array
{
/////////
}
}
/**
* Билдер, перерабатывающий CronWorker в Worker, пригодный к использованию в
* FrokManager
*/
interface ForkManagerWorkerBuilder
{
public function buildWorker(CronWorker $cronWorker): Worker;
}
Код команды будет выглядеть примерно так:
use AikusForkManagerFrokManager;
/**
* Код команды выполняемой по крону
*/
class CronComman
{
public function __construct(
private readonly CrontTypeRepository $crontTypeRepository,
private readonly CronWorkerRepository $cronWorkerRepository,
private readonly ForkManagerWorkerBuilder $builder,
)
{
}
public function run(string $crontTypeAliasParameter): void
{
$type = $this->crontTypeRepository->findByAlias(crontTypeAliasParameter);
if(!$type) {
//error output
return;
}
$manager = new FrokManager($type->getParallelLimit());
foreach($this->cronWorkerRepository->findByType($type) as $cronWorker) {
$manager->addWorker($this->builder->buildWorker($cronWorker));
}
$manager->dispatch();
}
}
Используя новую систему управления задачами с воркерами и обновленной структурой кронов, были достигнуты следующие улучшения:
-
Централизованное управление и мониторинг. Теперь задачи управляются и отслеживаются в одном месте, что обеспечивает прозрачность выполнения задач. Также настроена отправка уведомлений при возникновении проблем в работе кронов.
-
Упрощенное распределение задач. Аналитики могут управлять расписанием задач из административной панели, что способствует более удобному планированию и контролю за выполнением задач.
-
Доступ к описанию задач. Теперь все описания воркеров и крон-задач доступны для просмотра в административной панели, обеспечивая более подробное понимание каждой задачи.
-
Снижение нагрузки на поддержку. Заинтересованные стороны, включая поддержку, благоприятно приняли новую систему описания кронов, что позволило избежать необходимости создания новых крон-задач и упростило процесс обслуживания.
Оптимизация kafka-consumer'ов
После внедрения Kafka как общей шины в организации, наше приложение столкнулось с увеличением числа consumer'ов, которые взаимодействовали с внешним миром. Изначально каждый консьюмер запускался как отдельный демон в systemd, что привело к схожим с проблемами crontab недоразумениям:
-
Сложности с отслеживанием работы консьюмеров и их взаимодействием с очередями. Было затруднительно определить, какие консьюмеры активны, на каких очередях они работают и какие данные обрабатывают.
-
Проблемы с добавлением новых консьюмеров. Есть некоторые проблемы с поиском инженеров умеющих работать с crontab, а найти знающего systemd - ещё сложнее.
-
Недостаточная унификация логирования. Хотя проблем с унификацией логирования было меньше, текущее решение основывалось на неформальных соглашениях (читай держалось на честном слове).
-
Передать такое решение в сопровождение было очень тяжело. Т.к. надо было объяснять как управлять таким большим количеством демонов и зачем они нужны.
Так же был вариант слушать все топики кафки одним консьмером, но тогда к проблемам, описанным в предыдущем разделе, добавлялась проблема пропускной способности одного процесса. Php - однопоточный и в рамках фреймворка очень даже синхронный, по этому времени простоя в нём хватает. Было принято решение так же делать через fork.
Мы создали центральный демон, который на старте запрашивал список воркеров и следил за их работой. Он запускал каждого воркера в отдельном форке. Если какой-то из воркеров падал, центральный демон перезапускал его.
Центральным элементом этой системы был собственный проект нашего разработчика.
Если описать работу в контексте библиотеки из предыдущего раздела, то получится примерно следующий код. Сущности и сервисы работы с ними:
use AikusForkManagerWorker;
/**
* Класс описывающий консюмер
*/
class Consumer
{
private int $id;
private string $name;
private string $handleClass;//класс описывающий полезную работу по обработке сообщений.
private array $topics;
}
class ConsumerRepository
{
public function findAll(): array
{
/////////
}
}
interface ForkManagerWorkerBuilder
{
public function buildWorker(Consumer $consumerData): Worker;
}
И команда демона, которая и запустит все необходимые консьюмеры:
use AikusForkManagerFrokManager;
use AikusForkManagerReturnableWorker;
/**
* Код команды запускающий демон
*/
class ConsumerDaemonCommand
{
public function __construct(
private readonly ConsumerRepository $repository,
private readonly ForkManagerWorkerBuilder $builder,
)
{
}
public function run(): void
{
$manager = new FrokManager();//лимит параллельно работающих процессов не установлен.
foreach($this->repository->findAll() as $consumer) {
$manager->addWorker(
new ReturnableWorker(
//специальный декоратор, который перезапускает упавший процесс.
$this->builder->buildWorker($consumer)));
}
$manager->dispatch();
}
}
Мы успешно решили все поставленные задачи:
-
Создали удобное хранилище консьюмеров, где можно видеть, кто и что слушает.
-
Управление по-прежнему не вынесли в админку, поэтому добавлять новых консьюмеров приходилось через миграции, но это всё равно проще, чем раньше.
-
Логирование и конфигурирование происходят на уровне контрактов системы, нарушить их сложно — проще выполнить.
-
Для сопровождения этого единственного демона оказалось достаточно.
Ограничения и альтернативы
При работе с fork'ами необходимо помнить про их ограничения:
-
Они недоступны в Windows, поэтому разрабатывать, тестировать и эксплуатировать их нужно на Unix-системах.
-
Это довольно ресурсозатратная процедура, так как она подразумевает большую работу с памятью и ожидание операционной системы. Поэтому использовать форки нужно с умом.
-
При создании дочернего процесса необходимо вручную закрывать все потоки и соединения, например, соединение с базой данных, сокеты и т. д. Об этом нужно постоянно помнить, иначе могут возникнуть ошибки.
-
По умолчанию в дочернем процессе продолжается поток выполнения родителя, а нам это не нужно, поэтому эту историю надо обрабатывать отдельно.
В процессе эксплуатации мы столкнулись с проблемами использования форков в Docker, но при подготовке этого материала воспроизвести эти проблемы не удалось. В нашем проекте мы использовали другие вызовы.
Описанные случаи хорошо подходили для использования этих решений, поэтому мы их и применили.
У форка в PHP есть неплохая альтернатива — модуль parallel. Этот модуль позволяет запустить ещё один поток интерпретатора в том же процессе. Дочерний поток почти не влияет на родителя. Более того, он бы сильно упростил взаимодействие между родительским потоком и воркерами. Мы не стали использовать этот модуль, потому что на тот момент у нас была версия PHP, которая его не поддерживала.
На более свежей версии PHP выявилась другая проблема этого модуля. В новом потоке необходимо заново запустить приложение, чтобы можно было с ним работать. Для этого нужно создать обычный php-файл, где описывается поднятие приложения и запуск воркера. По этой причине мы решили остаться на форках.
Также можно использовать обычный вызов exec, который запускает другую программу через командную строку. Но он имеет недостатки и форков, и parallel, поэтому его мы даже не рассматривали.
Выводы
Если постараться объединить опыт описанных случаев, то получается, что fork'и хорошо подходят для управления фоновыми процессами. Эти процессы обычно объединены способом запуска (крон и демон), в идеале ещё и общей целью (вычитывать топики кафки). Если приложить фантазию и усилия то можно даже вынести управление в административную часть приложения не нагружая инженеров, ответственных за поддержку или разворачивания приложения.
Автор: aikus