История создания идеального Docker для Laravel

от автора

Казалось бы, упаковать PHP в контейнер и настроить GitHub Actions — дело пяти минут. Но как часто бывает, реальность оказалась сложнее. Это история о том, как я вернулся к разработке на PHP и решал накопившиеся проблемы с деплоем Laravel-проекта. О том, как готовил Docker-образ, несколько раз переписывал процесс деплоя, находил компромиссы там, где это было возможно, и полностью перестраивал архитектуру там, где компромиссы были неприемлемы.

История одного проекта

Всё началось с пет-проекта — чат-бота для спикеров. Проект небольшой: бэкенд для Telegram и админка, ничего сложного. Стек технологий тоже достаточно простой:

  • PHP-FPM 8.4 с Nginx

  • Laravel в качестве основного фреймворка

  • Filament для админ-панели (изначально был Orchid)

  • Redis для сессий, очередей и кэширования

  • Docker и Docker Compose для развертывания

  • OrbStack для локальной разработки

  • GitHub Actions для CI/CD

За время существования проект прошёл через три большие итерации. В первой я набросал базовую логику, задеплоил, появились первые пользователи. Функционала хватало, но управление мероприятиями, основная часть проекта, было неудобным. Когда появилось свободное время, решил написать админку на Orchid (тут небольшое отступление — в последние пару лет я чаще работаю с Go, Ruby и Python). Набросал основные страницы, но времени закончить не хватило. Так проект и жил какое-то время, пока количество пользователей росло.

Когда я наконец вернулся к проекту, посмотрел на свой код свежим взглядом и решил переписать админку на Filament — платформе, о которой в последнее время много слышал. Было интересно и решить накопившиеся проблемы, и попробовать что-то новое. Админку написал быстро, локально всё работало отлично, но, как это часто бывает, на проде она не открылась. И тут началось самое интересное.

Изначально у меня был классический набор из четырёх GitHub Actions workflow — для линтинга, тестирования, сборки Docker-образа и деплоя. В workflow для линтинга использовался Laravel Pint:

lint.yml
name: Laravel Linting      on:     push:       branches:         - main     pull_request:       branches:         - main      jobs:     lint:       runs-on: ubuntu-24.04          steps:         - name: Checkout repository           uses: actions/checkout@v4            - name: Cache Composer dependencies           uses: actions/cache@v4           with:             path: vendor             key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}             restore-keys: ${{ runner.os }}-composer-            - name: Set up PHP           uses: shivammathur/setup-php@v2           with:             php-version: '8.3'             tools: composer            - name: Install Composer dependencies           run: composer install --no-progress --no-suggest --prefer-dist --optimize-autoloader            - name: Run Laravel Pint           run: composer lint:pint            - name: Check security vulnerabilities           run: composer security-check 

Тестирование проводилось с помощью PHPUnit:

test.yml
name: Laravel Testing      on:     push:       branches:         - main     pull_request:       branches:         - main      jobs:     test:       runs-on: ubuntu-24.04       services:         postgres:           image: postgres:17           env:             POSTGRES_USER: postgres             POSTGRES_PASSWORD: postgres             POSTGRES_DB: test           ports:             - 5432:5432           options: >-             --health-cmd="pg_isready -U postgres"             --health-interval=10s             --health-timeout=5s             --health-retries=5          steps:         - name: Checkout repository           uses: actions/checkout@v4            - name: Cache Composer dependencies           uses: actions/cache@v4           with:             path: vendor             key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}             restore-keys: ${{ runner.os }}-composer-            - name: Set up PHP           uses: shivammathur/setup-php@v2           with:             php-version: '8.3'             extensions: mbstring, pdo_pgsql             coverage: xdebug             tools: composer            - name: Install Composer dependencies           run: composer install --no-progress --no-suggest --prefer-dist --optimize-autoloader            - name: Set up application environment           run: |             cp .env.example .env             php artisan key:generate             sed -i 's/DB_HOST=pgsql/DB_HOST=127.0.0.1/' .env             sed -i 's/DB_DATABASE=app/DB_DATABASE=test/' .env             sed -i 's/DB_USERNAME=user/DB_USERNAME=postgres/' .env             sed -i 's/DB_PASSWORD=/DB_PASSWORD=postgres/' .env            - name: Run migrations and seed database           run: php artisan migrate --seed            - name: Run tests           run: php artisan test 

Сборка образа выполнялась через docker/build-push-action@v3, используя GitHub Container Registry:

build.yml
name: Build and Push Docker Image      on:     push:       tags:         - '*'      jobs:     build:       runs-on: ubuntu-latest          steps:         - name: Checkout repository           uses: actions/checkout@v4            - name: Set up QEMU           uses: docker/setup-qemu-action@v2            - name: Set up Docker Buildx           uses: docker/setup-buildx-action@v2            - name: Log in to GitHub Container Registry           uses: docker/login-action@v2           with:             registry: ghcr.io             username: ${{ github.repository_owner }}             password: ${{ secrets.GITHUB_TOKEN }}            - name: Cache Docker layers           uses: actions/cache@v4           with:             path: /tmp/.buildx-cache             key: ${{ runner.os }}-docker-${{ hashFiles('**/deploy/php-fpm/Dockerfile') }}             restore-keys: ${{ runner.os }}-docker-            - name: Build and push Docker image           uses: docker/build-push-action@v3           with:             context: .             file: deploy/php-fpm/Dockerfile             target: production             push: true             tags: ghcr.io/${{ github.repository_owner }}/${{ github.repository }}:latest             cache-from: type=local,src=/tmp/.buildx-cache             cache-to: type=local,dest=/tmp/.buildx-cache-new            - name: Update Docker cache           if: always()           run: |             rm -rf /tmp/.buildx-cache             mv /tmp/.buildx-cache-new /tmp/.buildx-cache 

А деплой осуществлялся по SSH с помощью appleboy/ssh-action@v1.0.3:

deploy.yml
name: Deploy to Production      on:     push:       tags:         - '*'      jobs:     deploy:       runs-on: ubuntu-latest       environment: Production          steps:         - name: Deploy to production server via SSH           uses: appleboy/ssh-action@v1.0.3           with:             host: ${{ vars.SERVER_HOST }}             username: ${{ vars.SERVER_USER }}             key: ${{ secrets.SERVER_SSH_KEY }}             script: |               echo "${{ secrets.DOCKER_PAT }}" | docker login ghcr.io -u "${{ github.repository_owner }}" --password-stdin               cd /var/www/domain.ru              docker compose -f docker-compose.prod.yml pull               docker compose -f docker-compose.prod.yml up -d --build               docker compose -f docker-compose.prod.yml exec app php artisan migrate --force 

Dockerfile
FROM php:8.3-fpm-alpine AS base      WORKDIR /app      RUN apk update && apk add --no-cache \       git \       curl \       oniguruma-dev \       libxml2-dev \       libzip-dev \       libpng-dev \       libwebp-dev \       libjpeg-turbo-dev \       freetype-dev \       postgresql-dev \       libmemcached-dev \       zlib-dev \       zip \       unzip \       autoconf \       g++ \       make \       linux-headers \       $PHPIZE_DEPS  # PHP build dependencies      RUN rm -rf /var/cache/apk/*      RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \       && docker-php-ext-install gd \       && docker-php-ext-install pdo pdo_pgsql mbstring zip exif pcntl bcmath opcache      RUN pecl install redis memcached && docker-php-ext-enable redis memcached      COPY --from=composer:2 /usr/bin/composer /usr/bin/composer      COPY . /app      RUN cp .env.example .env      RUN chown -R www-data:www-data /app \       && chmod -R 777 /app/storage \       && chmod -R 775 /app/bootstrap/cache      FROM base AS production      RUN composer install --no-dev --optimize-autoloader --no-interaction --no-progress      RUN php artisan key:generate      RUN chown -R www-data:www-data /app/vendor \       && chmod -R 775 /app/vendor      CMD ["php-fpm"]      FROM base AS development      RUN composer install --optimize-autoloader --no-interaction --no-progress      RUN pecl install xdebug \       && docker-php-ext-enable xdebug \       && echo "xdebug.mode=debug" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \       && echo "xdebug.client_host=${HOST_IP}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \       && echo "xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \       && echo "xdebug.idekey=PHPSTORM" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini      ENV PHP_IDE_CONFIG="serverName=stage"      RUN chown -R www-data:www-data /app/vendor \       && chmod -R 775 /app/vendor      CMD ["php-fpm"]      FROM development AS testing      CMD ["vendor/bin/phpunit"] 

Тут классическая multistage сборка, от которой я кайфанул — отдельно для прода, для запуска тестов и для дева с xdebug.

Вот docker-compose (их три под каждое окружение, но они похожи, поэтому для статьи думаю достаточно продовской):

Скрытый текст
services:     app:       image: ghcr.io/nemirlev/speakerhub-php-bot:latest       container_name: app       restart: unless-stopped       depends_on:         - pgsql       environment:           APP_ENV: production           APP_DEBUG: false           TELEGRAPH_WEBHOOK_DEBUG: false           DB_PASSWORD: ${{ secrets.DB_PASSWORD }}       networks:         - nginx-proxy         - backend     webserver:       image: nginx:alpine       container_name: webserver       restart: unless-stopped       volumes:         - ./deploy/nginx/nginx.stage.conf:/etc/nginx/conf.d/default.conf       environment:         VIRTUAL_HOST: domain.ru       VIRTUAL_PORT: 80         VIRTUAL_PROTO: http         LETSENCRYPT_HOST: domain.ru       LETSENCRYPT_EMAIL: email       depends_on:         - app       networks:         - nginx-proxy         - backend     pgsql:       image: postgres:15       container_name: pgsql       restart: unless-stopped       ports:         - "5432:5432"       environment:         POSTGRES_USER: user         POSTGRES_PASSWORD: ${{ secrets.DB_PASSWORD }}         POSTGRES_DB: app       volumes:         - pgdata:/var/lib/postgresql/data       networks:         - backend        redis:       image: redis:alpine       container_name: redis       restart: unless-stopped       ports:         - "6379:6379"       volumes:         - redisdata:/data       networks:         - backend      memcached:       image: memcached:alpine       container_name: memcached       restart: unless-stopped       ports:         - "11211:11211"       networks:         - backend       volumes:         - memcacheddata:/data   volumes:     pgdata:       driver: local     redisdata:       driver: local     memcacheddata:       driver: local   networks:     nginx-proxy:       external: true     backend:       internal: true 

Первая проблема обнаружилась со стилями — они просто не отображались в браузере в админке, хотя локально всё было в порядке. Сначала я, конечно, проверил права и кэш, но потом нашёл настоящую причину. Внимательный читатель, наверное, уже заметил — для nginx не был указан volume. После добавления необходимых маппингов всё заработало.

app:       image: ghcr.io/nemirlev/speakerhub-php-bot:latest       container_name: app       restart: unless-stopped       depends_on:         - pgsql     volumes:      - **laravel_app:/app**     environment:           APP_ENV: production           APP_DEBUG: false           TELEGRAPH_WEBHOOK_DEBUG: false           DB_PASSWORD: ${{ secrets.DB_PASSWORD }}       networks:         - nginx-proxy         - backend   webserver:   image: nginx:alpine   container_name: webserver   restart: unless-stopped   volumes:     - ./deploy/nginx/nginx.stage.conf:/etc/nginx/conf.d/default.conf     - **laravel_app:/app** environment:     VIRTUAL_HOST: domain.ru   VIRTUAL_PORT: 80     VIRTUAL_PROTO: http     LETSENCRYPT_HOST: domain.ru   LETSENCRYPT_EMAIL: email   depends_on:     - app   networks:     - nginx-proxy     - backend   

Но на этом приключения не закончились. При деплое новые изменения почему-то не применялись. Я почти сразу исключил проблемы с кэшем и правами, когда залез в контейнер и не увидел там физически новых файлов. Думал, что проблема в GitHub Actions, объединял все в один workflow, чтобы деплой гарантированно шёл после сборки, менял скрипты. Но потом, когда построчно стал проверять и моделировать влияние каждого действия, понял — проблема в volume. Он просто не обновлялся.

Временным решением стало удаление и пересоздание volume при деплое:

cd /var/www/domain.ru.v2   %% docker compose down   %% docker compose pull   %% docker volume rm domainru_laravel_app   %% docker compose up -d --build   docker compose exec app php artisan migrate --force   docker compose exec app php artisan filament:optimize

Но это было неудобно, да и преимущества volume в данном случае оказались под вопросом — особой разницы с монтированием локальной папки не было. Поэтому я заменил volume на прямое монтирование:

services:   app:     volumes:        - ./:/app   webserver:     volumes:       - ./deploy/nginx/nginx.stage.conf:/etc/nginx/conf.d/default.conf       - ./:/app 

После этого я решил копнуть глубже и подумать, как это всё сделать более элегантно. Исследование пошло в двух направлениях. Первое — сборка nginx вместе с файлами, но это означало бы необходимость собирать два тяжёлых образа. Второе, более интересное — заменить php-fpm на что-то, что не требовало бы отдельного nginx для работы приложения. После изучения существующих решений я остановился на nginx unit. Рассматривал также Roadrunner, Swoole и FrankenPHP (последний особенно понравился возможностью собирать бинарник), но опасался сложностей с настройкой и необходимостью адаптировать код, о которых писали в интернете. Времени на полноценное тестирование всех вариантов просто не было.

Переход на Nginx Unit оказался удивительно простым — достаточно было заменить базовый образ в Dockerfile и добавить загрузку конфига. Изначально конфигурация unit выглядела так:

{       "listeners": {           "*:80": {               "pass": "routes"         }       },       "routes": [           {               "match": {                   "uri": "!/index.php"               },               "action": {                   "share": "/app/public$uri",                   "fallback": {                       "pass": "applications/laravel"                   }               }           }       ],       "applications": {           "laravel": {               "type": "php",               "root": "/app/public/",               "script": "index.php"           }       }   } 

Приложение стало работать визуально быстрее, хотя специальных замеров я не проводил. Но когда выложил на прод, появились нюансы с HTTPS — браузер блокировал загрузку стилей из-за смешанного контента ([Warning] [blocked] The page at https://site.com/admin/login requested insecure content from http://site.com/css/filament/support/support.css?v=3.2.140.0. This content was blocked and must be served over HTTPS. (login, line 51)). Проблема решилась добавлением правильной обработки заголовков:

{     "listeners": {         "*:80": {             "pass": "routes",             "forwarded": {                 "protocol": "X-Forwarded-Proto",                 "client_ip": "X-Forwarded-For",                 "source": [                     "172.19.0.0/16"                 ]             }         }     },     // остальная конфигурация без изменений } 

Что дальше

Текущий результат меня полностью устраивает, но планов ещё много. Хочу переделать pipeline CI/CD, попробовать уменьшить размер образа хотя бы до 300 мегабайт. В планах также внедрение мониторинга: Victoria Metrics для метрик, Loki для логов и Vector для их сбора. А ещё хочется упаковать все эти наработки в отдельный репозиторий, чтобы было под рукой. Если эта статья вызовет интерес — обязательно напишу продолжение про реализацию этих планов.

Итоговый Dockerfile:
FROM unit:php8.4 AS builder      USER root      RUN apt-get update && apt-get install -y \       gcc \       make \       autoconf \       pkg-config \       libicu-dev \       libpq-dev \       libzip-dev \       && rm -rf /var/lib/apt/lists/*      COPY --from=composer:2 /usr/bin/composer /usr/bin/composer      WORKDIR /tmp/build      RUN docker-php-ext-install pdo_pgsql opcache intl zip      RUN pecl install redis      RUN pecl install xdebug \       && mv /usr/local/lib/php/extensions/*/xdebug.so /tmp/build/xdebug.so      ##################################################   # ##################################################   FROM builder AS prod-composer      WORKDIR /app      COPY . /app      RUN composer install --no-dev --optimize-autoloader --no-interaction --no-progress      ##################################################   # ##################################################   FROM builder AS dev-composer      WORKDIR /app      COPY . /app      RUN composer install --optimize-autoloader --no-interaction --no-progress      ##################################################   # ##################################################   FROM unit:php8.4 AS production      RUN apt-get update && apt-get install -y --no-install-recommends \       libpq-dev \       libicu-dev \       libzip-dev \       libfcgi-bin \       procps \       && apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*      ENV PHP_INI_DIR=/usr/local/etc/php   RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" || true      COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/   COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/   COPY --from=builder /usr/local/bin/docker-php-ext-* /usr/local/bin/      RUN docker-php-ext-enable redis      COPY --from=prod-composer /app /app      RUN rm -rf .env       COPY .env.example /app/.env      WORKDIR /app      RUN php artisan key:generate && php artisan config:clear && php artisan cache:clear && php artisan config:cache && php artisan config:cache && php artisan route:cache && php artisan view:cache      RUN chown -R unit:unit /app/public \       && chmod -R 755 /app/public \       && chown -R unit:unit /app/storage \       && chmod -R 775 /app/storage \       && chmod -R 775 /app/bootstrap/cache      COPY ./deploy/unit/config.json /docker-entrypoint.d/config.json      EXPOSE 8080   CMD ["unitd", "--no-daemon"]         ##################################################   # ##################################################   FROM unit:php8.4 AS development      RUN apt-get update && apt-get install -y --no-install-recommends \       libpq-dev \       libicu-dev \       libzip-dev \       libfcgi-bin \       procps \       && apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*      ENV PHP_INI_DIR=/usr/local/etc/php   RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" || true      COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/   COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/   COPY --from=builder /usr/local/bin/docker-php-ext-* /usr/local/bin/      COPY --from=builder /tmp/build/xdebug.so /usr/local/lib/php/extensions/no-debug-non-zts-20240924/xdebug.so      RUN docker-php-ext-enable redis       ARG XDEBUG_ENABLED=true   ARG XDEBUG_MODE=develop,coverage,debug,profile   ARG XDEBUG_HOST=host.docker.internal   ARG XDEBUG_IDE_KEY=PHPSTORM   ARG XDEBUG_LOG=/dev/stdout   ARG XDEBUG_LOG_LEVEL=0      RUN if [ "${XDEBUG_ENABLED}" = "true" ]; then \       echo "xdebug.mode=${XDEBUG_MODE}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \       echo "xdebug.idekey=${XDEBUG_IDE_KEY}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \       echo "xdebug.log=${XDEBUG_LOG}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \       echo "xdebug.log_level=${XDEBUG_LOG_LEVEL}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \       echo "xdebug.client_host=${XDEBUG_HOST}" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini ; \       echo "xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini ; \   fi      COPY --from=dev-composer /app /app      COPY ./deploy/unit/config.json /docker-entrypoint.d/config.json      WORKDIR /app      EXPOSE 8080   CMD ["unitd", "--no-daemon"] 

Github Action:
name: Build, Lint, Test, and Deploy      on:     push:       tags:         - '*'      jobs:     lint:       runs-on: ubuntu-latest       continue-on-error: true         steps:         - name: Checkout repository           uses: actions/checkout@v4            - name: Set up PHP           uses: shivammathur/setup-php@v2           with:             php-version: '8.4'            - name: Install dependencies           run: composer install --no-interaction --prefer-dist --no-progress            - name: Run Linter (PHPStan)           run: vendor/bin/phpstan analyse --memory-limit=1G        test:       runs-on: ubuntu-latest       continue-on-error: true          steps:         - name: Checkout repository           uses: actions/checkout@v4            - name: Set up PHP           uses: shivammathur/setup-php@v2           with:             php-version: '8.4'            - name: Install dependencies           run: composer install --no-interaction --prefer-dist --no-progress            - name: Generate key           run: php artisan key:generate            - name: Run Tests           run: vendor/bin/phpunit --testdox        deploy:       runs-on: ubuntu-latest       needs: [ lint, test ]         environment: Production          steps:         - name: Deploy to Production Server           uses: appleboy/ssh-action@v1.0.3           with:             host: ${{ vars.SERVER_HOST }}             username: ${{ vars.SERVER_USER }}             key: ${{ secrets.SERVER_SSH_KEY }}             script: |               cd /var/www/domain.ru.v2               docker compose pull               docker compose up -d --build               docker compose exec app php artisan migrate --force               docker compose exec app php artisan filament:optimize 

Docker compose:
services:       app:           build:               context: .               dockerfile: deploy/unit/Dockerfile               target: production           container_name: app           restart: unless-stopped           depends_on:               - pgsql           environment:               DB_PASSWORD: ${{ secrets.DB_PASSWORD }}             VIRTUAL_HOST: domain.ru              VIRTUAL_PORT: 80               VIRTUAL_PROTO: http               LETSENCRYPT_HOST: domain.ru              LETSENCRYPT_EMAIL: email@me.ru          networks:               - nginx-proxy               - backend       pgsql:           image: postgres:17           container_name: pgsql           restart: unless-stopped           environment:               POSTGRES_USER: speakerhub               POSTGRES_DB: speakerhub               POSTGRES_PASSWORD: ${{ secrets.DB_PASSWORD }}         volumes:               - pgdata:/var/lib/postgresql/data           networks:               - backend               - nginx-proxy       redis:           image: redis:alpine           container_name: redis           restart: unless-stopped           volumes:               - redisdata:/data           networks:               - backend               - nginx-proxy       memcached:           image: memcached:alpine           container_name: memcached           restart: unless-stopped           networks:               - backend           volumes:               - memcacheddata:/data   volumes:       pgdata:           driver: local       redisdata:           driver: local       memcacheddata:           driver: local   networks:       nginx-proxy:           external: true       backend:           internal: true 

Nginx unit conf
{       "listeners": {           "*:80": {               "pass": "routes",               "forwarded": {                   "protocol": "X-Forwarded-Proto",                   "client_ip": "X-Forwarded-For",                   "source": [                       "172.19.0.0/16"                   ]               }           }       },       "routes": [           {               "match": {                   "uri": "!/index.php"               },               "action": {                   "share": "/app/public$uri",                   "fallback": {                       "pass": "applications/laravel"                   }               }           }       ],       "applications": {           "laravel": {               "type": "php",               "root": "/app/public/",               "script": "index.php"           }       }   }

Если остались вопросы или хотите обсудить тему подробнее — пишите в комментариях и подписывайтесь на мой канал в телеграмме.


ссылка на оригинал статьи https://habr.com/ru/articles/883300/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *