Docker + Laravel + RoadRunner = ❤
Давать ссылку на ранее написанный пост уже не хочется, так как взгляды относительно того, как следует решать поставленную задачу, довольно сильно изменились. Данный пост написан по заявкам трудящихся, которые с завидной периодичностью спрашивают о том "Как запустить Illuminate / Symfony / MyOwnPsr7 приложение в докере".
Всё, что будет написано ниже, является субъективным опытом, который (как и всегда) не претендует на право считаться единственно верным решением, но некоторые подходы и решения, возможно, тебе покажутся интересными и полезными.
Адаптировать под другие PSR-7-based фреймворки/компоненты возможно, но этот рассказ не про это. В качестве приложения так же буду использовать Laravel, так как он мне наиболее знаком и довольно широко распространен.
Работа над ошибками
Хотелось бы начать с того, что оказалось "не лучшими практиками" в контексте предыдущей статьи:
- Необходимость изменять структуру файлов в репозитории
- Использование FPM. Если мы хотим производительности от своих приложений то, пожалуй, одним из лучших решений ещё на стадии выбора технологий будет отказ от него в пользу чего-то более быстрого и "приспособленного" к тому, что память может утекать. RoadRunner by lachezis тут оказывается как никогда кстати
- Отдельный образ с исходниками и ассетами. Не смотря на то, что используя такой подход мы можем реюзать один и тот же образ для построения более сложной маршрутизации входящих запросов (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
Наглядное внедрение нового подхода
Отправная точка — это скелетон Laravel приложения созданный с помощью composer create-project latavel/laravel
: Для наглядной демонстрации — весь процесс разобью на несколько этапов, изменения в рамках которых будут оформлены в виде отдельных MR (после слияния все бранчи останутся на своих местах; ссылки на MR в заголовках "шагов").
$ 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.*"
Для этого нам нужны Dockerfile
, docker-compose.yml
для описания "как поднимать и линковать контейнеры", и Makefile
для того, чтобы свести и без того упрощенный процесс к одной-двум командам. Первым делом необходимо научить приложение запускаться в контейнере.
Dockerfile
X. Базовый образ использую php:X. Более того — все последующие обновления интерпретатора сводятся к тому, чтобы просто изменить значение в этой строчке (обновить PHP теперь проще некуда). X-alpine
как наиболее легкий и содержащий то, что надо для запуска.
Работает это быстро, и без зависимостей от curl
/ git clone
/ make build
. Composer и бинарный файл RoadRunner доставляются в контейнер с помощью multistage и COPY --from=...
— это очень удобно, да и все значения связанные с версиями не "разбросаны", а находятся в начале файла. Образы 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
Делается это для того, чтобы при последующих сборках образа с использованием --cache-from
, если состав и версии установленных зависимостей не изменились, то composer install
не выполнялся, взяв этот слой из кэша, тем самым экономя время сборки и трафик (за идею спасибо jetexe). Тут смысл следующий — отдельным слоем в образ доставляются файлы composer.lock
и composer.json
, после чего выполняется установка всех зависимостей, описанных в них.
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
.
Всё, что потребуется — это добавлять пефикс к этим хэлперам, а именно
\dev\dd(..)
и\dev\dump(..)
соответственно. Для того, чтобы была возможность использоватьdump(..)
иdd(..)
и всё при этом работало, есть другой пакет —avto-dev/stacked-dumper-laravel
. Без этого будете наблюдать ошибку вида:
worker error: invalid data found in the buffer (possible echo)
После всех манипуляций выполняем docker-compose up -d
и вуа-ля:
База данных PostgeSQL, redis и воркеры RoadRunner успешно запущены в контейнерах.
Зависимые цели, свой синтаксический сахар, 99% вероятность того, что на linux/mac машине разработчика он уже стоит, автокомплит "из коробки" — малый список его преимуществ. Как уже писал ранее, Makefile — очень недооцененная штука.
Добавив его в наш проект и выполнив make
без параметров, мы можем наблюдать:
Для получения coverage отчета достаточно выполнить make test-cover
, и перед запуском тестов в контейнер доставится xdebug с его зависимостями, и запустятся тесты (так как эта процедура выполняется не часто и не силами CI — это решение кажется лучшим, чем держать отдельный образ со всеми dev-примочками). Для запуска юнит-тестов мы можем как выполнить make test
, так и получив шелл внутрь контейнера с приложением (make shell
) выполнить composer phpunit
.
Git Hooks
В Makefile
для этого существует отдельный target: Хуки в нашем случае будут выполнять 2 важные роли — не позволять пушить в origin код, тесты которого не выполняются успешно; и автоматически ставить все необходимые зависимости, если стянув изменения себе на машину окажется, что composer.lock
изменился.
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
вместо того, чтоб просто нажимать хоткей прямо во время написания теста или кода, с ним связанного.
Выбираем Docker compose, и имя сервиса app. Нажимаем File > Settings > Languages & Frameworks > PHP > CLI interpreter > [...] > [+] > From Docker, Vargant, VM, Remote
.
В поле Path to script
указываем /app/vendor/autoload.php
, а в Path mappings
указываем корневую директорию проекта как монтируемую в /app
. Второй шаг — это указание phpunit-у необходимость использовать интерпретатор из контейнера: File > Settings > Test frameworks > [+] > PHPUnit by remote interpreter
и выбрать ранее созданный удаленный интерпретатор.
И теперь мы можем запускать тесты прямо из IDE используя интерпретатор внутри образа с приложением, нажимая (по дефолту, Linux) Ctrl + Shift + F10.
Для этого создаем файл .gitlab-ci.yml
в корневой директории приложения, наполняя его примерно следующим содержанием. Всё, что нам остается сделать — это автоматизировать процесс запуска тестов и сборки образа. Основная идея данной конфигурации — быть максимально простой, но не терять в функциональности при этом.
Используя --cache-from
сборка образа при повторном коммите производится очень быстро. Сборка образа производится на каждом бранче, на каждом коммите. Необходимость пересборки обусловлена тем, что на каждом бранче у нас есть образ с теми изменениями, которые были в рамках этого бранча сделаны, а как следствие — ничего нам не мешает его раскатать на swarm/k8s/etc для того, что бы "вживую" убедиться в том, что всё работает, и работает как надо ещё до мерджа с master
-веткой.
После сборки — запускаем unit-тесты и проверяем запуск приложения в контейнере, отправляя на health-check endpoint запросы curl-ом (данное действие опционально, но несколько раз данный тест меня очень выручал).
X. Для "выпуска релиза" — просто публикуем тег вида vX. X
(если вы ещё и будете придерживаться семантического версионирования — будет очень круто) — CI соберет образ, прогонит тесты, и выполнит действия, что вы укажете в deploy to somewhere
.
Не забудьте в настройках проекта (если это возможно) ограничить возможность публикации тегов только лицам, которым разрешено "выпускать релизы".
CHANGELOG.md
и ENVIRONMENT.md
Если с первым всё более и менее понятно, то вот относительного второго дам пояснения. Перед тем, как принять тот или иной MR — проверяющий должен в обязательном порядке проверить на соответствие файлы CHANGELOG.md
и ENVIRONMENT.md
. Т.е. Данный файл служит для описания всех переменных окружения, на которые реагирует контейнер с приложением. И в момент, когда возникает вопрос "Нам нужно срочно переопределить то-то и то-то" — никто судорожно не начинает копаться в документации или исходниках — а смотрит в одном-единственном файле. если разработчик добавляет или удаляет поддержку той или иной переменной окружения — это обязательно должно быть отражено в этом файле. Очень удобно.
Заключение
А данной статье мы рассмотрели довольно безболезненный процесс переноса разработки и запуска приложения в Docker-окружение, интегрировали RoadRunner и используя простой CI сценарий автоматизировали сборку и тестирование образа с нашим приложением.
Товарищам *ops-ам — брать образ с нужным тегом и раскатывать его на своих кластерах. Разработчикам остается после клонирования репозитория выполнить make git-hooks && make install && make up
и начать писать полезный код.
Естественно — данная схема тоже является упрощенной, и на "боевых" проектах накручиваю ещё много всего, но если изложенный в статье подход поможет кому-то — я буду знать, что потратил время не зря.