Docker + Laravel = ❤ // Часть 2
Данный пост написан по заявкам трудящихся, которые с завидной периодичностью спрашивают о том «Как запустить Illuminate / Symfony / MyOwnPsr7 приложение в докере». Давать ссылку на ранее написанный пост уже не хочется, так как взгляды относительно того, как следует решать поставленную задачу, довольно сильно изменились.
Всё, что будет написано ниже, является субъективным опытом, который (как и всегда) не претендует на право считаться единственно верным решением, но некоторые подходы и решения, возможно, тебе покажутся интересными и полезными.
В качестве приложения так же буду использовать Laravel, так как он мне наиболее знаком и довольно широко распространен. Адаптировать под другие PSR-7-based фреймворки/компоненты возможно, но этот рассказ не про это.
Работа над ошибками
Хотелось бы начать с того, что оказалось «не лучшими практиками» в контексте предыдущей статьи:
- Необходимость изменять структуру файлов в репозитории
- Использование 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
Наглядное внедрение нового подхода
Для наглядной демонстрации — весь процесс разобью на несколько этапов, изменения в рамках которых будут оформлены в виде отдельных 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.*"
Первым делом необходимо научить приложение запускаться в контейнере. Для этого нам нужны 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
и вуа-ля:
База данных PostgeSQL, redis и воркеры RoadRunner успешно запущены в контейнерах.
Как уже писал ранее, Makefile — очень недооцененная штука. Зависимые цели, свой синтаксический сахар, 99% вероятность того, что на linux/mac машине разработчика он уже стоит, автокомплит «из коробки» — малый список его преимуществ.
Добавив его в наш проект и выполнив make
без параметров, мы можем наблюдать:
Для запуска юнит-тестов мы можем как выполнить 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.
Второй шаг — это указание phpunit-у необходимость использовать интерпретатор из контейнера: File > Settings > Test frameworks > [+] > PHPUnit by remote interpreter
и выбрать ранее созданный удаленный интерпретатор. В поле Path to script
указываем /app/vendor/autoload.php
, а в Path mappings
указываем корневую директорию проекта как монтируемую в /app
.
И теперь мы можем запускать тесты прямо из IDE используя интерпретатор внутри образа с приложением, нажимая (по дефолту, Linux) Ctrl + Shift + F10.
Всё, что нам остается сделать — это автоматизировать процесс запуска тестов и сборки образа. Для этого создаем файл .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-ам — брать образ с нужным тегом и раскатывать его на своих кластерах.
Естественно — данная схема тоже является упрощенной, и на «боевых» проектах накручиваю ещё много всего, но если изложенный в статье подход поможет кому-то — я буду знать, что потратил время не зря.