Микросервисы

Микросервисы в современной веб-разработке это архитектурный подход по разделению изначального монолитного приложения на независимые системные (linux) процессы. Необходимость в таком разделении возникает когда монолит становится слишком медленным для одного синхронного процесса, когда код тянет слишком много зависимостей и когда повышается риск что-то сломать в этой длинной цепочке обработки данных.

Микросервисы — не панацея и тоже усложняют всё приложение в плане транзактивности, логов, и обработки ошибок, управления конфигураций, версионирования, деплоя, возникает дублирование bootstrap-кода из-за изоляции сервисов. Поэтому стоит осторожно подходить к тому, что вы хотите выделить в микросервис и что он даст по характеристикам приложения (performance, стабильность, масштабирование, разделение нагрузки). Как правило, веб-приложения создаются сначала монолитом, а потом разделяются на сервисы — так эффективней и проще эволюционировать.

Паттерны взаимодействия

Общение между сервисами можно делать по-разному:

1. Синхронно — сервис вызывают явно как веб-сервис по HTTP/REST, делая работу синхронно по-старинке, подобно веб-серверу, тогда как клиент ждёт ответа (подобно ajax)

2. Асинхронно — cервис вызывают явно по HTTP/REST, регистрируется задача и клиент тут же получает job ID. Клиент обязан сам проверить состояние задачи, периодически спрашивая у сервиса (polling, aws transcoder)

3. Асинхронно — сервис как работник (демон) без публичного доступа. Слушает какой-то ресурс и генерирует новые вызовы. Это может быть

  • единая шина сообщений (RabbitMQ,Gearman0MQ, Kafka, AWS SQS, AWS Kinesis)>
  • хранилище с локами (файлы, память, Memcache, Redis)
  • другие ресурсы (процессы, сеть, железо)

4. Асинхронно, не используя MQ, регистрирует слушателей в себе и сам знает к кому куда стучать

Главное что-бы подход был единый, с предсказуемым форматом данных (JSONprotobufthriftmessagepack).Для удобной конфигурации и дружбы между сервисами, надо ставить ConsulETCDили ZooKeeper — они позволят абстрагироваться от конкретных IP адресов и PID процессов.

Пример

Например пользователь приложения имеет возможность загружать файлы.Обработка файла очень тяжёлая по CPU, генерирует несколько результатов и занимает много времени из-за долгих внутренних сетевых запросов по загрузке готовых обработанных файлов в хранилище. Допустим эта синхронная операция upload+resize+store занимает 20 секунд в монолитном приложении. В микросервисном приложении, вы решаете создать сервис обработки картинки, а для очередей сначала создаёте табличку в БД. Отлично, это даёт возможность сервис положить на другую машину и не нагружать основной сайт. Теперь возникают практические вопросы — как это всё сделать? Практически, если раньше в монолите разные слои приложения проверяли всё за вас, то теперь чуть ли не все значимые классы надо будет выделять отдельные сервисы авторизацию, логирование, менеджер картинок, менеджер транзакций и сам ресайз картинок.

Веб-сервис на PHP

Если ваш сервис должен иметь публичный веб-интерфейс, проще всего запустить php в качестве сервера под конкретную папку и она будет по запросу дёргаться. Тут пригодятся микро-фреймворки типа SilexSlimили Lumen

#run me as:
#php -S 0.0.0.0:80 -t /var/www/silex
#install me with:
#composer require silex/silex:~1.3
$loader = require __DIR__.'/vendor/autoload.php';
$app = new Silex\Application();
#синхронная работа
$app->post('/resizeImage', function(Request $request) use($app) {
    #загрузить imagemagick, сделать resize, сохранить
    return "{success:true}";
});
#асинхронная работа
$app->post('/resizeImageAsync', function(Request $request) use($app) {
    #зарегистрировать новую работу и передать её демону через RabbitMQ, см. ниже
    #клиента оповестим автоматом через websocketы
    return "{success:true}";
});

Демон-работник на PHP

Хотя php изначально разрабатывался как интерпретатор html файлов, современный php может работать в постоянном режиме как сервис если его запустить в CLI режиме. В реальной жизни, php демоны как правило нестабильны из-за утечек памяти и не так эффективны по скорости как сервисы на nodejs или go. Запустить демон можно из коммандной строки..

php -f /var/www/app/daemon/myImageProcessor.php
#let it live after terminal exit:
nohup php -f myImageProcessor.php &

С демонами есть несколько особенностей

  • Работая в бесконечном цикле надо чистить ресурсы (DB-соединения, file handles)
  • Надо мониторить рост своей памяти что-бы не умереть от fatal error
  • Надо поддерживать graceful stop при получении SIGTERM и прочих сигналов что-бы не повредить данные на случай если кто-то остановит демона из консоли или перезапустит сервис
  • Надо ловить все исключения, делать альтернативную функциональность бэкапов и откатов, формировать красивый log
  • Перезапуск сервера должен запускать демоны заново — через крон или через регистрацию демона в виде системного сервиса

У вас может быть несколько экземпляров (instance) одного и того же демона в виде разных процессов. Тогда очень важно не попасть в проблемы многопоточности — дедлоки транзакций, запись в один log файл параллельно и обработка одного и того же задания. Обо всём этом чуть позже..

А пока, вот простой пример с бесконечным циклом и псевдо-кодом:

//myImageProcessor.php
//set custom process name
cli_set_process_title('php - daemon - myImageProcessor.php');
//enable process interruption handling
declare(ticks = 1);
$interrupted = false;
function handleSignal(){
    $interrupted = true;
}
pcntl_signal(SIGTERM, "handleSignal");
pcntl_signal(SIGHUP, "handleSignal");
pcntl_signal(SIGUSR1, "handleSignal");
pcntl_signal(SIGINT, "handleSignal");
while(true){
    $db->connect();
    $db->query("SELECT * FROM images WHERE processed = 0");
    //do some work here with resize & upload
    $db->disconnect();
    //memory cleanup & check
    gc_collect_cycles();
    const MAX_MEMORY_EXTRA = 10 * 1024 * 1024;
    if (convertToBytes(ini_get('memory_limit')) < (memory_get_usage(true) + MAX_MEMORY_EXTRA)) {
        exit();
    }
    //graceful shutdown
    if($interrupted){
        exit();
    }
    //wait for some time to decrease load on DB above
    sleep(10);
}

Управление процессами

Проверку на УЖЕ запущенного демона можно сделать как на уровне bash, так и на уровне самого php с использованием экзотических флагов в базе, флагов во временных файлах или же используя такой же доступ к shell. С такой проверкой, комманду можно смело класть в cron и демон будет запускаться автоматом (раз в минуту)

Bash

#check if daemon is not running yet
#add logs
[ "$(ps -ef| grep -v grep | grep myImageProcessor|wc -l)" -eq 0 ] && php -f myImageProcessor.php > /var/www/logs/myImageProcessor.log
#up to 5 instances!
[ "$(ps -ef| grep -v grep | grep imageResizer|wc -l)" -lt 5 ] && php myImageProcessor.php > /var/logs/myImageProcessor.log

Остановить демона можно так же из шелла..

sudo /usr/bin/kill -9 `ps aux | grep 'myImageProcessor' | grep -v grep | awk '{print $2}'`

PM2

Для более могучего nodejs, существует отличный process manager, который может управлять и php-демонами! С его помощью можно сразу видеть только процессы демонов. Кроме того:

  • перезапускать демона сразу после его падения
  • следить за изменениями файла что-бы перезапустить демон (watch)
  • следить за памятью и CPU и перезапускать при их превышениях

Менеджеры попроще — forever и guvnor

Очереди и RabbitMQ

Как только вы начали использовать демонов, вероятней всего вы станете использовать и паттерн Command, по которому микросервисы выполняют роль работников (workers). MQ-сервер выступает в роли брокера и шины, связывающей асинхронные процессы воедино. Он отвечает за надёжность передачи сообщения сервису и за то что несколько сервисов не получат одну и ту же комманду. Реализовывать это на стандартной БД было бы сложней

 

Для PHP-демонов, подключение rabbitMQ делается с помощью php-amqplib библиотеки и бесконечный цикл в этом случае начинает зависеть от неё..

$callback = function($msg){
    //actual work here
    $data = json_decode($msg->body, true);
    //Ack message that everything is done
    $msg->delivery_info['channel']->basic_ack($msg->delivery_info['delivery_tag']);
}
//technical details..
$connection = new AMQPStreamConnection('localhost', 5672, 'root', 'pass');
$channel    = $connection->channel();
//new queue if it was missing
$channel->queue_declare($queue, false, true, false, false);
//one entry at a time
$channel->basic_qos(null, 1, null);
$channel->basic_consume($queue, $receiverName, false, false, false, false, $callback);
//almost infinite cycle
while (count($channel->callbacks)) {
    $channel->wait(null);
}
$channel->close();
$connection->close();

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

$queue = 'datasync';
$message = ['imageID'=>34, 'status'=>'processed'];
$message = json_encode($message);
$connection = new AMQPStreamConnection($server, $port, $login, $pass);
$channel    = $connection->channel();
$channel->queue_declare($queue, false, true, false, false);
$channel->basic_publish(new AMQPMessage($message, ['delivery_mode' => 2]), '', $routeKey);
$channel->close();
$connection->close();

Перспективы

Более серьёзные сервисы пишутся на nodejs, а лучше на Go.

Грубо говоря, если прогнать нагрузочный тест, то с PHP вы получите 300 req/s, тогда как с node 7000 req/s, а с go 9000 req/s.

С Go можно копать в сторону go-micro

См. также