Docker + Laravel + RoadRunner = ❤

Хотелось бы начать с того, что оказалось «не лучшими практиками» в контексте предыдущей статьи:

  • Необходимость изменять структуру файлов в репозитории
  • Использование FPM. Если мы хотим производительности от своих приложений то, пожалуй, одним из лучших решений ещё на стадии выбора технологий будет отказ от него в пользу чего-то более быстрого и «приспособленного» к тому, что память может утекать. RoadRunner тут оказывается как никогда кстати
  • Отдельный образ с исходниками и ассетами. Не смотря на то, что используя такой подход мы можем реюзать один и тот же образ для построения более сложной маршрутизации входящих запросов (nginx на фронте для отдачи статики; запросы на динамику обслуживает другой контейнер, в который прокинут volume с теми-же исходниками — для лучшего масштабирования) — данная схема показала себя довольно сложной в продуктовой эксплуатации. И более того — RR сам прекрасно отдает статику, а если статики много (или ресурс умеет загружать и отображать пользовательский контент) — выносим её в CDN (связка S3 + CloudFront + CloudFlare работает отлично) и забываем об этой проблеме в принципе
  • Сложный CI. Это стало реальной проблемой, когда начался период активного «наращивания мяса» на этапы сборки и автоматического тестирования. Чуваку, который ранее не поддерживал этот CI, становится очень сложно вносить в него правки без боязни что-либо поломать.

Теперь, зная какие проблемы необходимо устранить и с пониманием как это сделать — предлагаю приступить к их устранению. Набор «инструментов разработчика» у нас не изменился — это всё тот-же docker-ce, docker-compose и могучий Makefile.

В результате мы получим:

  • Самостоятельный контейнер с приложением без необходимости монтирования дополнительных volume
  • Пример использования git-hooks — будем ставить нужные зависимости после git pull автоматически и запретим пушить код, если тесты не проходят (хуки будут храниться под гитом, естественно)
  • Обработкой HTTP(s) запросов будет заниматься RoadRunner
  • Разработчики смогут как и раньше выполнять dd(..) и dump(..) для отладки, и при этом ничего не будет крашиться в их браузере
  • Тесты можно будет запускать прямо из IDE PHPStorm, при этом запускаться они будут в контейнере с приложением
  • CI будет собирать для нас образы при публикации нового тега версии приложения
  • Возьмем для себя строгое правило ведения файлов CHANGELOG.md и ENVIRONMENT.md

Наглядное внедрение нового подхода

Для наглядной демонстрации — весь процесс разобью на несколько этапов, изменения в рамках которых будут оформлены в виде отдельных MR (после слияния все бранчи останутся на своих местах; ссылки на MR в заголовках «шагов»). Отправная точка — это скелетон Laravel приложения созданный с помощью composer create-project latavel/laravel:

$ docker run \
  --rm -i \
  -v "$(pwd):/src" \
  -u "$(id -u):$(id -g)" \
  composer composer create-project --prefer-dist laravel/laravel \
  /src/laravel-in-docker-with-rr "5.8.*"

Шаг 1 — Докеризация + RR

Первым делом необходимо научить приложение запускаться в контейнере. Для этого нам нужны Dockerfile, docker-compose.yml для описания «как поднимать и линковать контейнеры», и Makefile для того, чтобы свести и без того упрощенный процесс к одной-двум командам.

Dockerfile

Базовый образ использую php:X.X.X-alpine как наиболее легкий и содержащий то, что надо для запуска. Более того — все последующие обновления интерпретатора сводятся к тому, чтобы просто изменить значение в этой строчке (обновить PHP теперь проще некуда).

Composer и бинарный файл RoadRunner доставляются в контейнер с помощью multistage и COPY --from=... — это очень удобно, да и все значения связанные с версиями не «разбросаны», а находятся в начале файла. Работает это быстро, и без зависимостей от curl / git clone / make build. Образы 512k/roadrunner поддерживаются мною, если хотите — можете собирать бинарный файл самостоятельно.

Интересная история приключилась с переменной окружения PS1 (отвечает за prompt в шелле) — оказывается, использовать в ней emoji можно, и всё локально работает, но стоит попытаться запустить образ с переменной содержащей emoji в, скажем, rancher — он будет крашиться (в swarm всё работает без проблем).

В Dockerfile я запускаю генерацию самоподписанного SSL сертификата для того, что бы его использовать для входящих HTTPS запросов. Естественно — ничего не мешает использовать «нормальный» сертификат.

Отдельно хочется сказать про:

COPY ./composer.* /app/
RUN set -xe \
    && composer install --no-interaction --no-ansi --no-suggest --prefer-dist  \
        --no-autoloader --no-scripts \
    && composer install --no-dev --no-interaction --no-ansi --no-suggest \
        --prefer-dist --no-autoloader --no-scripts

Тут смысл следующий — отдельным слоем в образ доставляются файлы composer.lock и composer.json, после чего выполняется установка всех зависимостей, описанных в них. Делается это для того, чтобы при последующих сборках образа с использованием --cache-from, если состав и версии установленных зависимостей не изменились, то composer install не выполнялся, взяв этот слой из кэша, тем самым экономя время сборки и трафик (за идею спасибо jetexe).

composer install выполняется дважды (второй раз с --no-dev) для «прогрева» кэша dev-зависимостей, чтобы когда мы на CI для запуска тестов поставили все зависимости, они ставились из кэша composer-а что уже есть в образе, а не тянулись из далеких галактик.

Последний инструкцией RUN мы выводим версии установленного ПО и состав модулей PHP как для истории в логах сборки, так и для того, чтобы убедиться, что «оно как минимум есть и как-то запускается».

Entrypoint использую тоже свой, так как перед тем как запустить приложение где-то в кластере очень хочется проверить доступность зависимых сервисов — БД, redis, rabbit и прочих.

RoadRunner

Для интеграции RoadRunner с Laravel-приложением был написан пакет, который сводит всю интеграцию к паре команд в шелле (выполнив docker-compose run app sh):

$ composer require avto-dev/roadrunner-laravel "^2.0"
$ ./artisan vendor:publish --provider='AvtoDev\RoadRunnerLaravel\ServiceProvider' --tag=rr-config

Добавляем APP_FORCE_HTTPS=true в файл ./docker/docker-compose.env, и указываем путь до SSL сертификата в контейнере в файлах .rr*.yaml.

Для того, чтобы была возможность использовать dump(..) и dd(..) и всё при этом работало, есть другой пакет — avto-dev/stacked-dumper-laravel. Всё, что потребуется — это добавлять пефикс к этим хэлперам, а именно \dev\dd(..) и \dev\dump(..) соответственно. Без этого будете наблюдать ошибку вида:

worker error: invalid data found in the buffer (possible echo)

После всех манипуляций выполняем docker-compose up -d и вуа-ля:

screenshot

База данных PostgeSQL, redis и воркеры RoadRunner успешно запущены в контейнерах.

Шаг 2 — Makefile и тесты

Как уже писал ранее, Makefile — очень недооцененная штука. Зависимые цели, свой синтаксический сахар, 99% вероятность того, что на linux/mac машине разработчика он уже стоит, автокомплит «из коробки» — малый список его преимуществ.

Добавив его в наш проект и выполнив make без параметров, мы можем наблюдать:

screenshot

Для запуска юнит-тестов мы можем как выполнить make test, так и получив шелл внутрь контейнера с приложением (make shell) выполнить composer phpunit. Для получения coverage отчета достаточно выполнить make test-cover, и перед запуском тестов в контейнер доставится xdebug с его зависимостями, и запустятся тесты (так как эта процедура выполняется не часто и не силами CI — это решение кажется лучшим, чем держать отдельный образ со всеми dev-примочками).

Git Hooks

Хуки в нашем случае будут выполнять 2 важные роли — не позволять пушить в origin код, тесты которого не выполняются успешно; и автоматически ставить все необходимые зависимости, если стянув изменения себе на машину окажется, что composer.lock изменился. В Makefile для этого существует отдельный target:

cwd = $(shell pwd)
git-hooks: ## Install (reinstall) git hooks (required after repository cloning)
    -rm -f "$(cwd)/.git/hooks/pre-push" "$(cwd)/.git/hooks/pre-commit" "$(cwd)/.git/hooks/post-merge"
    ln -s "$(cwd)/.gitlab/git-hooks/pre-push.sh" "$(cwd)/.git/hooks/pre-push"
    ln -s "$(cwd)/.gitlab/git-hooks/pre-commit.sh" "$(cwd)/.git/hooks/pre-commit"
    ln -s "$(cwd)/.gitlab/git-hooks/post-merge.sh" "$(cwd)/.git/hooks/post-merge"

Выполнение make git-hooks просто сносит имеющиеся хуки, и ставит на их место те, что находятся в директории .gitlab/git-hooks. Их исходники можно посмотреть по этой ссылке.

Запуск тестов из PhpStorm

Не смотря на то, что это довольно просто и удобно — сам довольно долго пользовался ./vendor/bin/phpunit --group=foo вместо того, чтоб просто нажимать хоткей прямо во время написания теста или кода, с ним связанного.

Нажимаем File > Settings > Languages & Frameworks > PHP > CLI interpreter > [...] > [+] > From Docker, Vargant, VM, Remote. Выбираем Docker compose, и имя сервиса app.

screenshot

Второй шаг — это указание phpunit-у необходимость использовать интерпретатор из контейнера: File > Settings > Test frameworks > [+] > PHPUnit by remote interpreter и выбрать ранее созданный удаленный интерпретатор. В поле Path to script указываем /app/vendor/autoload.php, а в Path mappings указываем корневую директорию проекта как монтируемую в /app.

screenshot

И теперь мы можем запускать тесты прямо из IDE используя интерпретатор внутри образа с приложением, нажимая (по дефолту, Linux) Ctrl + Shift + F10.

Шаг 3 — Автоматизация

Всё, что нам остается сделать — это автоматизировать процесс запуска тестов и сборки образа. Для этого создаем файл .gitlab-ci.yml в корневой директории приложения, наполняя его примерно следующим содержанием. Основная идея данной конфигурации — быть максимально простой, но не терять в функциональности при этом.

Сборка образа производится на каждом бранче, на каждом коммите. Используя --cache-from сборка образа при повторном коммите производится очень быстро. Необходимость пересборки обусловлена тем, что на каждом бранче у нас есть образ с теми изменениями, которые были в рамках этого бранча сделаны, а как следствие — ничего нам не мешает его раскатать на swarm/k8s/etc для того, что бы «вживую» убедиться в том, что всё работает, и работает как надо ещё до мерджа с master-веткой.

После сборки — запускаем unit-тесты и проверяем запуск приложения в контейнере, отправляя на health-check endpoint запросы curl-ом (данное действие опционально, но несколько раз данный тест меня очень выручал).

Для «выпуска релиза» — просто публикуем тег вида vX.X.X (если вы ещё и будете придерживаться семантического версионирования — будет очень круто) — CI соберет образ, прогонит тесты, и выполнит действия, что вы укажете в deploy to somewhere.

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

CHANGELOG.md и ENVIRONMENT.md

Перед тем, как принять тот или иной MR — проверяющий должен в обязательном порядке проверить на соответствие файлы CHANGELOG.md и ENVIRONMENT.md. Если с первым всё более и менее понятно, то вот относительного второго дам пояснения. Данный файл служит для описания всех переменных окружения, на которые реагирует контейнер с приложением. Т.е. если разработчик добавляет или удаляет поддержку той или иной переменной окружения — это обязательно должно быть отражено в этом файле. И в момент, когда возникает вопрос «Нам нужно срочно переопределить то-то и то-то» — никто судорожно не начинает копаться в документации или исходниках — а смотрит в одном-единственном файле. Очень удобно.

Заключение

А данной статье мы рассмотрели довольно безболезненный процесс переноса разработки и запуска приложения в Docker-окружение, интегрировали RoadRunner и используя простой CI сценарий автоматизировали сборку и тестирование образа с нашим приложением.

Разработчикам остается после клонирования репозитория выполнить make git-hooks && make install && make up и начать писать полезный код. Товарищам *ops-ам — брать образ с нужным тегом и раскатывать его на своих кластерах.

Естественно — данная схема тоже является упрощенной, и на «боевых» проектах накручиваю ещё много всего, но если изложенный в статье подход поможет кому-то — я буду знать, что потратил время не зря.