Docker + Laravel = ❤
Внимание! Данный пост был опубликован более года назад и, возможно, уже утратил свою былую актуальность. Но это не точно.

Данная статья является копией публикации на хабре

В данной статье я расскажу о своём опыте “заворачивания” Laravel-приложения в Docker-контейнер да так, что бы и локально с ним могли работать frontend и backend разработчики, и запуск его на production был максимально прост. Так же CI будет автоматически запускать статические анализаторы кода, phpunit-тесты, производить сборку образов.

“А в чём, собственно, сложность?” - можешь сказать ты, и будешь отчасти прав. Дело в том, что этой теме посвящено довольно много обсуждений в русскоязычных и англоязычных комьюнити, и почти все изученные треды я бы условно разделил на следующие категории:

  • “Использую докер для локальной разработки. Ставлю laradock и беды не знаю”. Круто, но как обстоят дела с автоматизацией и запуском на production?
  • “Собираю один контейнер (монолит) на базе fedora:latest (~230 Mb), ставлю в него все сервисы (nginx, бд, кэш, etc), запускаю всё супервизором внутри”. Тоже отлично, прост в запуске, но как на счёт идеологии “один контейнер - один процесс”? Как обстоят дела с балансировкой и управлением процессами? Как же размер образа?
  • “Вот вам куски конфигов, приправляем выдержками из sh-скриптов, добавим магических env-значений, пользуйтесь”. Спасибо, но как же на счёт хотя бы одного живого примера, который я бы мог форкнуть и полноценно поиграться?

Всё, что ты прочитаешь ниже - является субъективным опытом, который не претендует быть истиной в последней инстанции. Если у тебя будут дополнения или указания на неточности - welcome to comments.

Для нетерпеливых - ссылка на репозиторий, склонировав который ты сможешь запустить Laravel-приложение одной командой. Так же не составит труда его запустить на том же rancher, правильно “слинковав” контейнеры, или использовать продуктовый вариант docker-compose.yml как отправную точку.

Часть теоретическая

Какие инструменты мы будем использовать в своей работе, и на что сделаем акценты? Первым делом нам понадобятся установленные на хосте:

  • docker - на момент написания статьи использовал версию 18.06.1-ce
  • docker-compose - он отлично справляется с линковкой контейнеров и хранением необходимых environment значений; версия 1.22.0
  • make - возможно ты удивишься, но он отлично “вписывается” в контекст работы с докером

Поставить docker на debian-like системы можно командой curl -fsSL get.docker.com | sudo sh, а вот docker-compose лучше ставь с помощью pip, так как в его репозиториях обитают наиболее свежие версии (apt сильно отстают, как правило).

На этом список зависимостей можно завершить. Что ты будешь использовать для работы с исходниками - phpstorm, netbeans или трушный vim - только тебе решать.

Дальше - импровизированный QA в контексте (не побоюсь этого слова) проектирования образов:

  • Q: Базовый образ - какой лучше выбрать?
  • A: Тот, что “потоньше”, без излишеств. На базе alpine (~5 Mb) можно собрать всё, что душе угодно, но скорее всего придётся поиграться со сборкой сервисов из исходников. Как альтернатива - jessie-slim (~30 Mb). Или же использовать тот, что наиболее часто используется у вас на проектах.

  • Q: Почему вес образа - это важно?

  • A: Снижение объёма трафика, снижение вероятности ошибки при скачивании (меньше данных - меньше вероятность), снижение потребляемого места. Правило “Тяжесть — это надёжно” (© “Snatch”) тут не очень работает.

  • Q: А вот мой друг %friend_name% говорит, что “монолитный” образ со всеми-всеми зависимостями - это самый лучший путь.

  • A: Давай просто посчитаем. Приложение имеет 3 зависимости - PG, Redis, PHP. И тебе захотелось протестировать как оно у тебя будет себя вести в связках различных версий этих зависимостей. PG - версии 9.6 и 10, Redis - 3.2 и 4.0, PHP - 7.0 и 7.2. В случае, если каждая зависимость это отдельный образ - тебе их потребуется 6 штук, которые даже собирать не надо - всё уже готово и лежит на hub.docker.com. Если же по идеологическим соображениям все зависимости “упакованы” в один контейнер, тебе придётся его ручками пересобрать… 8 раз? А теперь добавь условие, что ты ещё хочешь и с opcache поиграться. В случае декомпозиции - это просто изменение тегов используемых образов. Монолит проще запускать и обслуживать, но это путь в никуда.

  • Q: Почему супервизор в контейнере - это зло?

  • A: Потому что PID 1. Не хочешь обилия проблем с зомби-процессами и иметь возможность гибко “добавлять мощностей” там, где это необходимо - старайся запускать один процесс на контейнер. Своеобразными исключениями является nginx со своими воркерами и php-fpm, которые имеют свойство плодить процессы, но с этим приходится мириться (более того - они не плохо умеют реагировать на SIGTERM, вполне корректно “убивая” своих воркеров). Запустив же всех демонов супервизором - фактически наверняка ты обрекаешь себя на проблемы. Хотя, в некоторых случаях - без него сложно обойтись, но это уже исключения.

Определившись с основными подходами давай перейдём к нашему приложению. Оно должно уметь:

  • web|api - отдавать статику силами nginx, а динамический контент генерировать силами fpm
  • scheduler - запускать родной планировщик задач
  • queue - обрабатывать задания из очередей

Базовый набор, который при необходимости можно будет расширить. Теперь перейдём к образам, которые нам предстоит собрать для того, что бы наше приложение “взлетело” (в скобках приведены их кодовые имена):

  • PHP + PHP-FPM (app) - среда, в которой будет выполняться наш код. Так как версии PHP и FPM у нас будут совпадать - собираем их в одном образе. Так и с конфигами легче управляться, и состав пакетов будет идентичный. Разумеется - FPM и процессы приложения будут запускаться в разных контейнерах
  • nginx (nginx) - что бы не заморачиваться с доставкой конфигов и опциональных модулей для nginx - будем собирать отдельный образ с ним. Так как он является отдельным сервисом - у него свой докер-файл и свой контекст
  • Исходники приложения (sources) - доставка исходников будет производиться используя отдельный образ, монтируя volume с ними в контейнер с app. Базовый образ - alpine, внутри - только исходники с установленными зависимостями и собранными с помощью webpack asset-ами (артефакты сборки)

Остальные сервисы для разработки запускаются в контейнерах, стянув их с hub.docker.com; на production же - они запущены на отдельных серверах, объединенных в кластеры. Всё что нам останется - это сказать приложению (через environment) по каким адресам\портам и с какими реквизитами необходимо до них стучаться. Ещё круче - это использовать в этих целях service-discovery, но об этом не в этот раз.

Определившись с частью теоретической - предлагаю перейти к следующей части.

Часть практическая

Организовать файлы в репозитории предлагаю следующим образом:

.
├── docker  # Директория для хранения докер-файлов необходимых сервисов
│   ├── app
│   │   ├── Dockerfile
│   │   └── ...
│   ├── nginx
│   │   ├── Dockerfile
│   │   └── ...
│   └── sources
│       ├── Dockerfile
│       └── ...
├── src  # Исходники приложения
│   ├── app
│   ├── bootstrap
│   ├── config
│   ├── artisan
│   └── ...
├── docker-compose.yml  # Compose-конфиг для локальной разработки
├── Makefile
├── CHANGELOG.md
└── README.md

Ознакомиться со структурой и файлами ты можешь перейдя по этой ссылке.

Для сборки того или иного сервиса можно воспользоваться командой:

$ docker build \
  --tag %local_image_name% \
  -f ./docker/%service_directory%/Dockerfile ./docker/%service_directory%

Единственным отличием будет сборка образа с исходниками - для него необходимо контекст сборки (крайний аргумент) указать равным ./src.

Правила именования образов в локальном registry рекомендую использовать те, что использует docker-compose по умолчанию, а именно: %root_directory_name%_%service_name%. Если директория с проектом называется my-awesome-project, а сервис носит имя redis, то имя образа (локального) лучше выбрать my-awesome-project_redis соответственно.

Для ускорения процесса сборки можно сказать докеру использовать кэш ранее собранного образа, и для этого используется параметр запуска --cache-from %full_registry_name%. Таким образом демон докера перед запуском той или иной инструкции в Dockerfile посмотрит - изменились ли она? И если нет (хэш сойдётся) - он пропустит инструкцию, используя уже готовый слой из образа, который ты укажешь ему использовать в качестве кэша. Эта штука не плохо так бустит процесс пересборки, особенно, если ничего не изменилось :)

Обрати внимание на ENTRYPOINT скрипты запуска контейнеров приложения.

Образ среды для запуска приложения (app) собирался с учётом того, что он будет работать не только на production, но ещё и локально разработчикам необходимо с ним эффективно взаимодействовать. Установка и удаление composer-зависимостей, запуск unit-тестов, tail логов и использование привычных алиасов (php /app/artisanart, composerc) должно быть без какого либо дискомфорта. Более того - он же будет использоваться для запуска unit-тестов и статических анализаторов кода (phpstan в нашем случае) на CI. Именно поэтому его Dockerfile, к примеру, содержит строчку установки xdebug, но сам модуль не включен (он включается только с использованием CI).

Так же для composer глобально ставится пакет hirak/prestissimo, который сильно бустит процесс установки всех зависимостей.

На production мы монтируем внутрь него в директорию /app содержимое директории /src из образа с исходниками (sources). Для разработки - “прокидываем” локальную директорию с исходниками приложения (-v "$(pwd)/src:/app:rw").

И вот тут кроется одна сложность - это права доступа на файлы, которые создаются из контейнера. Дело в том что по умолчанию процессы, запущенные внутри контейнера - запускаются от рута (root:root), создаваемые этими процессами файлы (кэш, логи, сессии, etc) - тоже, и как следствие - “локально” с ними ты уже ничего не сможешь сделать, не выполнив sudo chown -R $(id -u):$(id -g) /path/to/sources.

Как один из вариантов решения - это использование fixuid, но это решение прям “так себе”. Лучшим путём мне показался проброс локальных USER_ID и его GROUP_ID внутрь контейнера, и запуск процессов с этими значениями. По умолчанию подставляя значения 1000:1000 (значения по умолчанию для первого локального пользователя) избавился от вызова $(id -u):$(id -g), а при необходимости - ты всегда их можешь переопределить ($ USER_ID=666 docker-compose up -d) или сунуть в .env файл docker-compose.

Так же при локальном запуске php-fpm не забудь отключить у него opcache - иначе куча “да что за чертовщина!” тебе будут обеспечены.

Для “прямого” подключения к redis и postgres - прокинул дополнительные порты “наружу” (16379 и 15432 соответственно), так что проблем с тем, чтоб “подключиться да посмотреть что да как там на самом деле” не возникает в принципе.

Контейнер с кодовым именем app держу запущенным (--command keep-alive.sh) с целью удобного доступа к приложению.

Вот несколько примеров решения “бытовых” задач с помощью docker-compose:

Операция Выполняемая команда
Установка compose-пакета $ docker-compose exec app composer require package/name
Запуск phpunit $ docker-compose exec app php ./vendor/bin/phpunit --no-coverage
Установка всех node-зависимостей $ docker-compose run --rm node npm install
Установка node-пакета $ docker-compose run --rm node npm i package_name
Запуск “живой” пересборки asset-ов $ docker-compose run --rm node npm run watch

Все детали запуска ты сможешь найти в файле docker-compose.yml.

Цой make жив!

Набивать одни и те же команды каждый раз становится скучно после второго раза, и так как программисты по своей натуре - существа ленивые, давай займёмся их “автоматизацией”. Держать набор sh-скриптов - вариант, но не такой привлекательный, как один Makefile, тем более что его применимость в современной разработке сильно недооценена.

Полный русскоязычный мануал по нему ты сможешь найти по этой ссылке.

Давай посмотри как выглядит запуск make в корне репозитория:

[[email protected] ~/projects/app] $ make

  help            Show this help
  app-pull        Application - pull latest Docker image (from remote registry)
  app             Application - build Docker image locally
  app-push        Application - tag and push Docker image into remote registry
  sources-pull    Sources - pull latest Docker image (from remote registry)
  sources         Sources - build Docker image locally
  sources-push    Sources - tag and push Docker image into remote registry
  nginx-pull      Nginx - pull latest Docker image (from remote registry)
  nginx           Nginx - build Docker image locally
  nginx-push      Nginx - tag and push Docker image into remote registry
  pull            Pull all Docker images (from remote registry)
  build           Build all Docker images
  push            Tag and push all Docker images into remote registry
  login           Log in to a remote Docker registry
  clean           Remove images from local registry
  --------------- ---------------
  up              Start all containers (in background) for development
  down            Stop all started for development containers
  restart         Restart all started for development containers
  shell           Start shell into application container
  install         Install application dependencies into application container
  watch           Start watching assets for changes (node)
  init            Make full application initialization (install, seed, build assets, etc)
  test            Execute application tests

  Allowed for overriding next properties:

    PULL_TAG - Tag for pulling images before building own
              ('latest' by default)
    PUBLISH_TAGS - Tags list for building and pushing into remote registry
                   (delimiter - single space, 'latest' by default)

  Usage example:
    make PULL_TAG='v1.2.3' PUBLISH_TAGS='latest v1.2.3 test-tag' app-push

Он очень хорош зависимостью целей. Например, для запуска watch (docker-compose run --rm node npm run watch) необходимо что бы приложение было “поднято” - тебе достаточно указать цель up как зависимую - и можешь не беспокоиться о том, что ты забудешь это сделать перед вызовом watch - make сам всё сделает за тебя. То же касается запуска тестов и статических анализаторов, например, перед коммитом изменений - выполни make test и вся магия произойдет за тебя!

Стоит ли говорить о том, что для сборки образов, их скачивания, указания --cache-from и всего-всего - уже не стоит беспокоиться?

Ознакомиться с содержанием Makefile ты можешь по этой ссылке.

Часть автоматическая

Приступим к финальной части данной статьи - это автоматизация процесса обновления образов в Docker Registry. Хоть в моём примере и используется GitLab CI - перенести идею на другой сервис интеграции, думаю, будет вполне возможно.

Первым делом определимся и именованием используемых тегов образов:

Имя тега Предназначение
latest Образы, собранные с ветки master. Состояние кода является самым “свежим”, но ещё не готовым к тому, что бы попасть в релиз
some-branch-name Образы, собранные на бранче some-branch-name. Таким образом мы можем на любом окружении “раскатать” изменения которые были реализованы только в рамках конкретного бранча ещё до их сливания с master-веткой - достаточно “вытянуть” образы с этим тегом. И - да, изменения могут касаться как кода, так и образов всех сервисов в целом!
vX.X.X Собственно, релиз приложения (использовать для разворачивания конкретной версии)
stable Алиас, для тега со самым свежим релизом (использовать для разворачивания самой свежей стабильной версии)

Для ускорения сборки используется кэширование директорий ./src/vendor и ./src/node_modules + --cache-from для docker build, и состоит из следующих этапов (stages):

Имя этапа Предназначение
prepare Подготовительный этап - сборка образов всех сервисов кроме образа с исходниками
test Тестирование приложения (запуск phpunit, статических анализаторов кода) используя образы, собранные на этапе prepare
build Установка всех composer зависимостей (--no-dev), сборка assets силами webpack, и сборка образа с исходниками включая полученные артефакты (vendor/*, app.js, app.css)

pipelines screenshot

Сборка на master-ветке, производящая push с тегами latest и master

В среднем, все этапы сборки занимают 4 минуты, что довольно хороший результат (параллельное выполнение задач - наше всё).

Ознакомиться с содержанием конфигурации (.gitlab-ci.yml) сборщика можешь ознакомиться по этой ссылке.

Вместо заключения

Как видишь - организовать работу с php-приложением (на примере Laravel) используя Docker не так то и сложно. В качестве теста можешь форкнуть репозиторий, и заменив все вхождения tarampampam/laravel-in-docker на свои - попробовать всё “в живую” самостоятельно.

Для локального запуска - выполни всего 2 команды:

$ git clone https://gitlab.com/tarampampam/laravel-in-docker.git ./laravel-in-docker && cd $_
$ make init

После чего открой http://127.0.0.1:9999 в своём любимом браузере.