Docker + Laravel + RoadRunner = ❤

    picture


    Данный пост написан по заявкам трудящихся, которые с завидной периодичностью спрашивают о том "Как запустить 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 laravel/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-ам — брать образ с нужным тегом и раскатывать его на своих кластерах.


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

    • +22
    • 10,8k
    • 9
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 9

      +1
      Спасибо за статью. Есть несколько вопросов.
      1. В docker-compose.yml используется переменная `CURRENT_USER`, которая прокидыавется только из Makefile. Т.е. руками не предполагается работа с docker-compose?
      2. Под каким пользователем работает RR на проде и локальнов в контейнере?
      3. В docker-compose.yml указан volume `home-dir`, но нигде не описан. Предполагается, что пользователь сам отдельно его создаст?
      4. Директория в контейнере /home/user — как она становится домашней для текущего пользователя в контейнере?
      5. В образ для прода попадают dev-зависимости composer?
      6. Происходит ли и как происходит удаление устаревших образов из docker registry?
      7. Как собирается фронт в таком подходе?
        +1
        1. Можно и руками, только каждый раз писать --user "$(id -u):$(id -g)" довольно утомительно. Поэтому и живут эти штуки в Makefile. CURRENT_USER нужен там, где нужно как-то переопределить пользователя (остальные сервисы должны запускаться от рука, как обычно). Не замечал, чтоб после появления Makefile разработчики что-то делали уже без него, но руками запускать docker-compose никто не запрещает
        2. Рут, рут (надо бы сменить на локального пользователя, да)
        3. ~24 строка в docker-compose.yml
        4. cat ./docker/docker-compose.env | grep HOME
        5. Кэш composer-а (zip / tar) архивы, не сами зависимости, только кэш
        6. Отдельный шудлер, который дергает нужную ручку (резилы + master всегда остаются)
        7. Например: docker run --rm -v "$(pwd):/app:rw" --workdir "/app" "$NODE_IMAGE" npm install && docker run --rm -v "$(pwd):/app:rw" --workdir "/app" -e "NODE_ENV=production" "$NODE_IMAGE" npm run prod перед сборкой самого приложения (+ кэш и "всё такое"). Но лучше разделять такие штуки
        0
        Про схему боевых проэктов тоже было бы интересно узнать, хотя бы в теории. спасибо
          –1
          Уточните, данную сборку используете dev или на проде тоже?
          Если на проде тоже, то у Вас выходит все компоненты в одном образе (и база, и fpm, и nginx). Тогда смысл использовать docker теряется, т.к. подразумевается, что один контейнер используется для бд, другой для fpm и т.д. ИМХО
            –2

            На проде БД в докер-контейнерах использовать нельзя.


            P.S. А я вот подумал, кажется можно, если нормально ФС примонтировать. Хотя всё равно это чревато, имхо.

              0

              Знакомый DevOps говорит что это не плохое решение (правильно примонтировав ФС), но… Что на счет достижения консистентности данных в случае, если контейнер с БД на node-1 упадет, а поднимется на node-2 (физически другом сервере) очень быстро — что обеспечит тот факт, что состояние ФС на node-2 будет точно таким-же, какое было на node-1 в момент падения контейнера? Разве что силами Gluster FS. Для БД с не критичными данными, мол, так ещё можно делать. А для инстансов где данные важны — юзать Stolon. Но это же уже не "просто монтировать ФС"

                0

                Короче, это можно подытожить тем, что для решений на VDS/VPS — это вполне допустимо. В остальных случаях не стоит. Нет профита запускать БД на сервере, где только БД внутри докера.


                Так ведь?

                  +1

                  Я являюсь сторонником запуска критичных сервисов (шины данных, БД) на bare-metal, но и знаю ребят, что всё пускают в контейнерах

                  0

                  Как вариант: прибивать базу к конкретной ноде. Везде, где видел-слышал про базу в докере на проде, только водном случае она не была прибит арукми к ноде.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое