В данной статье я расскажу о своём опыте "заворачивания" 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/artisan
→ art
, composer
→ c
) должно быть без какого либо дискомфорта. Более того — он же будет использоваться для запуска 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
в корне репозитория:
[user@host ~/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)
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 ) |
Сборка на
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
в своём любимом браузере.
… Пользуясь случаем
В данный момент я работаю TL на проекте "автокод", мы ищем талантливых php-разработчиков и системных администраторов (офис разработки находится в г. Екатеринбург). Если ты относишь себя к первым или вторым — напиши нашему HR письмо с текстом "Хочу в команду разработки, резюме: %ссылка_на_резюме%" на электопочту hr@avtocod.ru
, помогаем с релокацией.
Автор: cmepthuk