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