Вы планируете или уже переходите с монолита на микросервисы, причем докеризированные. Мы уже прошли путь от монолита до 100 с лишним PHP-сервисов на базе Docker Swarm, причем в контексте множества команд — как продуктовых (их сервисы близки к конечному пользователю), так и сервисных (их работа важная для компании). В этой статье я расскажу, как за 2020-й мы добились правильной и стабильной работы этого «зоопарка».
Когда-то вы кодили на одном большом и могучем серваке, с кучей памяти и кучей процов. Сервер был безграничен, все ваши сервисы были здесь, все ваши Redis’ы и даже зачастую MySQL-и были тут. Все ваши приложения были здесь же: какая-то аналитика, какой-то бэкенд для админки, еще десяток сервисов — все было рядом.
Но вот вы заехали в Swarm. Все приложения — это набор контейнеров. А контейнеры это, по сути, набор микросерверов со своей файловой системой, своей памятью, своими процами. И они уже не всегда рядом. Соответственно, это тянет за собой некоторые изменения.
asset:install из Symfony — наше все
В частности, например, мы больше не можем на ходу писать файлы для Nginx: построили CSS и отдали — придется забыть об этом. Поэтому только asset:install. Причем лучше делать его где-то в Dockerfile, чтобы это было в том образе, на базе которого будет построен ваш сервер. То есть — создали на базе нашего PHP-образа, здесь же запустили composer install, который внутри себя запускает команду, которая запускает asset install. Тут же мы скопировали в Nginx — и так эти файлы есть.
Dockerfile:
FROM php:7.4-fpm-buster as php
RUN composer install -oan
FROM nginx:1.17.1-alpine as nginx
COPY --from=php --chown=nginx:nginx /opt/app/public /opt/app/public
Но иногда env-файлы лежат отдельно — и приходится как-то подсовывать их для построения assert:install: потому что совсем без окружения приложения отказывается запускаться. Конечно, речь не идет о боевой базе. Что-то придется дописывать.
FROM php:7.4-fpm-buster as php
RUN composer install -oan --no-scripts
FROM php as assets
COPY --chown=www-data:www-data .env.assets /opt/app/
RUN . .env.assets && rm .env.assets && composer run-scripts post-install-cmd -n
FROM nginx:1.17.1-alpine as nginx
COPY --from=assets --chown=nginx:nginx /opt/app/public /opt/app/public
Поэтому — помним, что вашему приложению нужны какие-то настройки для assert:install. Ниже я привел пример docker-php-entrypoint, где мы в случае чего можем запустить install уже при запуске самого PHP-FPM контейнера.
#!/bin/sh
composer run-scripts post-install-cmd -n
exec php-fpm
Забыть про unix-socket
В теории мы можем построить Swarm на одной машине. Но это ненадолго. В итоге все наши сервисы окажутся на соседних машинах: синхронные коннекты к соседним сервисам начнут стоить дороже. И мы больше не постучимся unix-socket`ами ни в MySQL, ни в PHP-FPM. Никакого var/run. Поэтому все надо дергать аккуратно — параллельно или асинхронно.
В Guzzle есть pool, средство для параллельного запуска запросов.
Дергаем параллельно
$client = new \GuzzleHttp\Client();
$requests = [
new \GuzzleHttp\Psr7\Request('GET', 'https://example.com/1.png'),
new \GuzzleHttp\Psr7\Request('GET', 'https://example.com/2.png'),
new \GuzzleHttp\Psr7\Request('GET', 'https://example.com/3.png'),
];
$responses = \GuzzleHttp\Pool::batch($client, $requests);
foreach ($responses as $response) {
// do something
}
Мы можем это дергать и асинхронно, то есть на выходе у нас будет promise.
Дергаем асинхронно
use \GuzzleHttp\Client;
use \Psr\Http\Message\ResponseInterface;
use \GuzzleHttp\Exception\RequestException;
$client = new Client();
$client->getAsync('https://example.com/1.png')->then(
function (ResponseInterface $response) {
// do something good
},
function (RequestException $exception) {
// do something evil
}
);
Можно дергать и параллельно-асинхронно. Причем в разных вариантах. И так, что если что-то одно отвалится — отвалится все.
use GuzzleHttp\Client;
use GuzzleHttp\Promise;
$client = new Client();
$promises = [
'image1' => $client->getAsync('https://example.com/1.png'),
'image2' => $client->getAsync('https://example.com/2.png'),
'image3' => $client->getAsync('https://example.com/3.png'),
];
$responses = Promise\Utils::unwrap($promises);
А вот вариант, который я называю «отвалилось что-то одно — ну и фиг с ним».
use GuzzleHttp\Client;
use GuzzleHttp\Promise;
$client = new Client();
$promises = [
'image1' => $client->getAsync('https://example.com/1.png'),
'image2' => $client->getAsync('https://example.com/2.png'),
'image3' => $client->getAsync('https://example.com/3.png'),
];
$responses = Promise\Utils::settle($promises)->wait();
То есть, если вы используете Guzzle, все эти средства — уже с вами. Просто помните об этом.
Нам не надо NFS
Скажем, у вас пять контейнеров с PHP-FPM. У каждого своя файловая система. У каждого свой кэш. NFS в таком случае — это медленно. То есть, если совсем приперло — можно. Но недолго. Кэш надо настраивать с пониманием, что где будет лежать, как часто мы его пишем, как часто читаем, перезаписываем ли его вообще.
Вам нужно специфицировать кэши. Вот привычный вид кэша в Symfony — мы разворачиваем скелетон приложения, вот такие настройки кэша:
cache.yaml:
framework:
cache:
А вот такие настройки у Doctrine — мы упоминаем здесь url до базы, proxy, всякие там аннотации. Про кэш ни слова.
doctrine.yaml:
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
orm:
auto_generate_proxy_classes: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
mappings:
App:
is_bundle: false
type: annotation
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
Для большого, толстого сервака — это нормально, если у Symfony файловой кэш, а у Doctrine — массив в текущем runtime. Но в нашем случае с этим надо что-то надо делать — например, для Symfony специфицировать Redis и прокинули для Doctrine файловую систему.
cache.yaml:
framework:
cache:
app: cache.adapter.redis
default_redis_provider: redis://10.0.1.16
pools:
doctrine.system_cache_pool:
adapter: cache.adapter.filesystem
Почему c Doctrine остались на файловой системе? Мета-дате нормально лежать на файловом кэше — она строится один раз. То есть: отдеплоились, она построилась и все, она будет перезаписана только при следующем деплое. А при следующем деплое будет уже новый контейнер.
Падаем (и лежим) удобно
Упасть может все. Вы, соседи, Google API, AWS. Раньше, когда был один сервак — не было такого, что ваше приложение живо, а соседние мертвы. Вы все были мертвы. Просто все тотально. Теперь все стало интереснее.
Как быть этим стойким оловянным солдатиком и работать, когда соседей нет в строю. Есть простой паттерн Circuit Breaker: если вы сходили в соседний сервис, а он ответил 500, вы взводите в кэше ключ (скажем, на минуту), который помечает, что соседний сервис сдох. Выглядит оно примерно так: есть имя сервиса, кэш, ключ. Проверяем, если он есть — значит соседи все еще лежат, дальше у нас идет отсылка запроса, и если что — бросаем исключение и мы просто в кэш на минуту пишем единичку.
$service = 'external_api';
$cache_key = sprintf('%s_is_down', $service);
if ($cache->has($cache_key)) {
die('external api is not available');
}
try {
echo \Api::send($request)->getBody();
} catch (\Api\RequestTimedOutException $e) {
$cache->set($cache_key, 1, 60);
die($e->getMessage());
}
Есть второй вариант — использовать либу Ganesha (в Symfony вообще замечательно, она там преднастроена). С ней можно настроить кучу вариантов того, за счет этого мы признаем, что сервис жив.
use Ackintosh\Ganesha\Builder;
use Ackintosh\Ganesha\Storage\Adapter\Redis;
$ganesha = Builder::withRateStrategy()
->adapter(new Redis($redis))
->failureRateThreshold(50)
->intervalToHalfOpen(10)
->minimumRequests(10)
->timeWindow(30)
->build();
C Ganesha все выглядит читабельнее, это важно в больших системах. Вот, смотрите сами.
service = 'external_api';
if (!$ganesha->isAvailable($service)) {
die('external api is not available');
}
try {
echo \Api::send($request)->getBody();
$ganesha->success($service);
} catch (\Api\RequestTimedOutException $e) {
$ganesha->failure($service);
die($e->getMessage());
}
Мы можем реализовать и более навороченную логику: например, чтобы Ganesha приходила к выводу, что «сервис вроде он работает, но это неточно, сделаем еще три запроса». То есть, мы можем реализовать разные стратегии для разных сервисов.
Упали вы — не падайте бесшумно. Об этом, как ни странно, многие забывают. Орите. Орите в логи, в метрики. Единственное, если используете Sentry, либо какой-то еще навороченный сборщик логов и метрик, не положите его. Для этого подойдет тот же паттерн Circuit Breaker.
Как работать с логами и метриками
1. Используйте Portainer — доступ до самих контейнеров админы могут и не дать, а вот через него всего всегда удобно посмотреть и отладиться.
2. Пишите логи в вывод — это нативно. И дешево. Главное — соблюдайте единообразия формата. JSON наше все.
nginx пишет в вывод
error_log /dev/stderr;
access_log /dev/stdout main;
И ваше приложение.
monolog:
handlers:
main:
type: stream
path: "php://stderr"
info:
type: stream
path: "php://stdout"
И php-fpm
error_log = /proc/self/fd/2
3. Не забывайте писать в логе данные о контейнерах и сервисах. В случае с docker-compose мы прокидываем переменное окружение в php-fnm, куда пишем имя сервисов, имя ноды, ну и всякие, что захотим.
docker-compose.yaml
services:
php-fpm:
environment:
DOCKER_SERVICE_NAME: "{{.Service.Name}}"
DOCKER_SERVICE_NODE_HOSTNAME: "{{.Node.Hostname}}"
DOCKER_SERVICE_TASK_SLOT: "{{.Task.Slot}}"
Container ID мы достаем примерно таким кодом.
src/Monolog/Processor/EnvProcessor.php
private function getContainerId(): ?string
{
$body = @file_get_contents('/proc/self/cgroup');
if (!$body) {
return null;
}
return preg_match('/\/docker\/([a-z0-9]+)$/Ss', $body, $match)
? trim($match[1])
: null;
}
И потом все это пишем в логи.
public function __invoke(array $record): array
{
$record['extra']['tags'] += [
'docker_service_name' => getenv('DOCKER_SERVICE_NAME'),
'docker_service_node_hostname' => getenv('DOCKER_SERVICE_NODE_HOSTNAME'),
'docker_service_task_slot' => getenv('DOCKER_SERVICE_TASK_SLOT'),
'docker_container_id' => $this->getContainerId(),
];
return $record;
}
4. Cоставьте требования для логов ошибок. Например, что в Sentry надо писать быстро, в меру подробно, но не без фанатизма — чтобы зашел, увидел новый ивент, и побежали. В Kibana же может уйти всякое info и прочее: эти логи могут быть медленными, и вам нужен мегапоиск по ним — это вопрос безопасности и источник данных для ваших собственных расследований.
5. Собирайте метрики. Мы собираем через Prometheus — для Symfony есть bundle. Прометей удобен тем, что для Docker есть стандартный экспортер: просто ставите, данные начинают уходить автоматом.
Добавляем Health Check
То, чего у вас, скорее всего, не было на вашем одном большом и могучем сервере. Это самотест приложения, то есть приложение можно как-то пнуть, через веб, через какую-то консольную команду, расскажут «ну вроде я ничего». Зачем? Вот вы, когда выкатаетесь, вы же выкатывайтесь то теперь как? Теперь у вас десяток маленьких контейнеров с php-fpm, с кроном, супервизором, Nginx.
Вам надо убедиться, что все работает и что вы не положите прод, когда переключите старый набор контейнеров на новый. Для Symfony есть готовый бандл — он уже многое проверяет, а если что, его легко расширить, дописав нужное. А сами Health Check бывают двух типов.
Проверка готовности — когда и если мы ее провалили, то не выкатываемся. Здесь возникает дилемма, а что проверять: только себя («я стартанул, я молодец») или все — коннекты к базе, к кэшу, что там с файлами, Rabbit, чем-то еще. Моя рекомендация — проверьте все. Потому что, например, если MySQL лег, а вы катитесь, то, с одной стороны, это на ваше приложение не завязано, но возникает вопрос: может, не совсем хорошая мысль сейчас выкатываться, стоит подождать, поесть пойти, пока админ все поднимает.
Проверка «живости» — контейнер проверяется на ходу, раз в, скажем, 10 секунд. Если вы провалили эту проверку, то по факту — это просто reboot. Дает защиту от случаев, если PHP затупил, повис крон или супервизор. Здесь вы проверяете только себя.
P.S. А что там про отказоустойчивость?
Соответственно, не грех упасть. Грех не подняться за несколько минут. Затачивайтесь на это.
Спойлер
Здесь хорошо подойдет переделанная цитата из фильма «Военный ныряльщик».
Ваше приложение не должно сбоить.
Но если оно сбойнет, оно не упадет.
А если оно упадет, оно не потащит за собой остальных.
А если оно даже потащит за собой остальных, то не всех.
А если вот вдруг оно вообще упадет и потащит за собой остальных, то оно упадет так, чтобы ваш веб, ваши клиенты этого не заметили.
Потому что это нормально в мире микросервисов. Падайте. Вы упадете. Все когда-нибудь упадут. Но вы сделаете так, чтобы ваше расположение можно было быстро и эффективно поднять. И быстро подниметесь.
Еще немного полезного:
Как мы строим работу с логами и метриками на уровне компании — в деталях
Как мы строим работу с инцидентами на уровне компании — итоги первого года
inetstar
Звучит настолько всё сложно, что выглядит, что для обычного сервиса будет намного быстрее, проще и удобнее настроить 1 мощную машину. Ну 2-3-4, если с балансированием нагрузки и отдельным сервером под БД + сервер для репликации.
AnswerREST
На самом деле все достаточно закономерно и корректно. Единственное Я бы предложил для хелз чеков использовать Consul.