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