DevOps

PHP и Docker в CI/CD: построение воспроизводимой среды разработки и тестирования с нуля

Ruslan Ismailov Опубликовано 12 мин чтения
P

Введение: проблема «works on my machine» в 2026 году

«У меня работает» — фраза, которая по-прежнему стоит командам недели отладки. Разные версии PHP, расширений, конфигурации nginx и переменных окружения между ноутбуком разработчика, стейджингом и продакшеном порождают баги, которые почти невозможно воспроизвести. В 2026 году, когда релизные циклы сократились до часов, а не дней, воспроизводимость среды — это не опция, а обязательное условие выживания команды.

Docker решает эту проблему на уровне инфраструктуры: один и тот же образ запускается на MacBook разработчика, в GitHub Actions и на продакшен-сервере. В связке с CI/CD-пайплайном это даёт полностью автоматизированный путь от коммита до деплоя с гарантированно идентичным окружением на каждом этапе. Эта статья — практическое руководство: никаких абстракций, только конкретные файлы и команды.

Базовая структура Docker-окружения для PHP-проекта

Типичный PHP-проект требует минимум трёх контейнеров: приложения (php-fpm), веб-сервера (nginx) и базы данных. Структура директорий задаёт тон всему проекту:

project/
├── docker/
│   ├── php/
│   │   ├── Dockerfile
│   │   └── php.ini
│   └── nginx/
│       └── default.conf
├── src/          # исходный код PHP
├── docker-compose.yml
├── docker-compose.override.yml  # локальные переопределения
└── .env.example

Ключевые принципы организации:

  • Один процесс — один контейнер. php-fpm не занимается маршрутизацией, nginx не выполняет PHP.
  • Volumes только для разработки. В продакшен-образе код должен быть встроен внутрь.
  • Общая сеть. Все сервисы находятся в одной Docker-сети для изолированной коммуникации.

Написание правильного Dockerfile для PHP

Выбор базового образа

В 2026 году рекомендуемая база — php:8.3-fpm-alpine. Alpine даёт минимальный размер образа (~30 МБ против ~400 МБ у debian-вариантов) и меньшую поверхность атаки. Для продакшена избегайте тегов latest — фиксируйте минорную версию.

# docker/php/Dockerfile
FROM php:8.3-fpm-alpine3.19 AS base

# Системные зависимости
RUN apk add --no-cache \
    git \
    curl \
    libpng-dev \
    libzip-dev \
    icu-dev \
    oniguruma-dev \
    && docker-php-ext-install \
        pdo_mysql \
        mbstring \
        zip \
        gd \
        intl \
        opcache

# Устанавливаем Composer
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer

# Настройки PHP
COPY docker/php/php.ini /usr/local/etc/php/conf.d/custom.ini

# Безопасность: не запускаем от root
RUN addgroup -g 1000 appgroup && adduser -u 1000 -G appgroup -s /bin/sh -D appuser

# Стадия для продакшена
FROM base AS production
WORKDIR /var/www/html
COPY --chown=appuser:appgroup src/ .
RUN composer install --no-dev --optimize-autoloader --no-interaction
USER appuser

# Стадия для разработки
FROM base AS development
RUN apk add --no-cache $PHPIZE_DEPS \
    && pecl install xdebug \
    && docker-php-ext-enable xdebug
WORKDIR /var/www/html
USER appuser

Нюансы безопасности

  • Никогда не запускайте контейнер от root в продакшене — используйте USER appuser.
  • Не копируйте .env в образ — передавайте переменные через среду запуска.
  • Добавьте .dockerignore, чтобы исключить node_modules, .git, тесты из продакшен-образа.
  • Регулярно сканируйте образы через docker scout cves или Trivy.

Docker Compose для локальной разработки

# docker-compose.yml
services:
  php:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
      target: development
    volumes:
      - ./src:/var/www/html
      - composer_cache:/root/.composer
    environment:
      APP_ENV: local
      DB_HOST: db
      DB_PORT: 3306
      DB_DATABASE: ${DB_DATABASE}
      DB_USERNAME: ${DB_USERNAME}
      DB_PASSWORD: ${DB_PASSWORD}
    depends_on:
      db:
        condition: service_healthy
    networks:
      - app_network

  nginx:
    image: nginx:1.27-alpine
    ports:
      - "8080:80"
    volumes:
      - ./src:/var/www/html:ro
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - php
    networks:
      - app_network

  db:
    image: mysql:8.4
    environment:
      MYSQL_DATABASE: ${DB_DATABASE}
      MYSQL_USER: ${DB_USERNAME}
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - app_network

volumes:
  db_data:
  composer_cache:

networks:
  app_network:
    driver: bridge

Обратите внимание на healthcheck для базы данных. Без него PHP-контейнер стартует раньше, чем MySQL принимает соединения, и приложение падает при запуске. Опция condition: service_healthy в depends_on решает эту проблему на уровне Docker Compose.

Интеграция с CI/CD: пайплайн на GitHub Actions

GitHub Actions — де-факто стандарт для PHP Docker CI/CD в open-source и большинстве коммерческих проектов. Пайплайн состоит из четырёх этапов: линтинг, тесты, сборка образа, публикация в registry.

# .github/workflows/ci.yml
name: PHP CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  lint:
    name: Code Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: PHP CS Fixer
        run: |
          docker run --rm \
            -v ${{ github.workspace }}/src:/app \
            cytopia/php-cs-fixer:3 fix --dry-run --diff /app

  test:
    name: Unit & Integration Tests
    runs-on: ubuntu-latest
    needs: lint
    services:
      db:
        image: mysql:8.4
        env:
          MYSQL_DATABASE: test_db
          MYSQL_USER: test_user
          MYSQL_PASSWORD: test_pass
          MYSQL_ROOT_PASSWORD: root_pass
        ports:
          - 3306:3306
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build test image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: docker/php/Dockerfile
          target: development
          load: true
          tags: php-app:test
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Run PHPUnit
        run: |
          docker run --rm \
            --network host \
            -e APP_ENV=testing \
            -e DB_HOST=127.0.0.1 \
            -e DB_DATABASE=test_db \
            -e DB_USERNAME=test_user \
            -e DB_PASSWORD=test_pass \
            php-app:test \
            ./vendor/bin/phpunit --coverage-text

  build-and-push:
    name: Build & Push Image
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main'
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=semver,pattern={{version}}
            type=sha,prefix=sha-
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: docker/php/Dockerfile
          target: production
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Кэширование слоёв Docker в CI

Без кэширования каждая сборка в CI скачивает все зависимости заново — это 3–5 минут потерянного времени на каждый пуш. В GitHub Actions доступны два основных подхода:

  • GitHub Actions Cache (type=gha) — встроенный механизм, не требует сторонних сервисов. Рекомендуется для большинства проектов.
  • Registry Cache (type=registry) — хранит кэш прямо в container registry. Подходит для self-hosted runners без общего кэша.

Ключевая стратегия для PHP — правильный порядок слоёв в Dockerfile. Сначала копируйте composer.json и composer.lock, устанавливайте зависимости, и только потом копируйте исходный код. Это гарантирует, что слой с vendor/ будет инвалидирован только при изменении зависимостей, а не при каждом изменении кода.

# Оптимизированная часть Dockerfile для кэширования
WORKDIR /var/www/html

# Сначала только файлы зависимостей
COPY src/composer.json src/composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-scripts --no-interaction

# Потом весь код (этот слой инвалидируется чаще)
COPY src/ .
RUN composer run-script post-install-cmd

Параллельный запуск тестов в Docker внутри пайплайна

Для крупных тест-сьютов (1000+ тестов) последовательный запуск — узкое место. GitHub Actions позволяет разбить тесты на группы через matrix-стратегию:

  test-parallel:
    name: Tests (shard ${{ matrix.shard }}/${{ matrix.total }})
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
        total: [4]
    steps:
      - uses: actions/checkout@v4

      - name: Run PHPUnit shard
        run: |
          docker run --rm \
            -e APP_ENV=testing \
            php-app:test \
            ./vendor/bin/phpunit \
              --testsuite=Unit \
              --group=shard${{ matrix.shard }}

Альтернатива — использовать paratest внутри одного контейнера для параллелизации через процессы. Для PHP Docker CI/CD это часто быстрее, чем несколько матричных джобов из-за overhead на запуск контейнеров.

Управление переменными окружения и секретами

Один из главных источников инцидентов — утечка секретов через Docker-образы или логи CI. Правила работы с секретами в PHP Docker CI/CD:

  • Никогда не добавляйте .env в Docker-образ. Добавьте его в .dockerignore.
  • В GitHub Actions храните секреты в Repository Secrets или Environment Secrets (для раздельного управления стейджингом и продакшеном).
  • Передавайте секреты в контейнер через -e KEY=${{ secrets.KEY }}, не через build arguments.
  • Для сложных проектов используйте HashiCorp Vault или AWS Secrets Manager с динамической выдачей токенов.
      - name: Run migrations
        run: |
          docker run --rm \
            -e APP_KEY=${{ secrets.APP_KEY }} \
            -e DB_PASSWORD=${{ secrets.DB_PASSWORD }} \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
            php artisan migrate --force

Build secrets (через --secret id=... в Buildkit) позволяют передавать чувствительные данные во время сборки образа без их попадания в финальные слои. Используйте этот механизм для Composer с приватными репозиториями.

Артефакты и версионирование образов

Правильное версионирование образов — основа надёжного rollback. Рекомендуемая стратегия тегирования:

  • latest — всегда указывает на последний стабильный образ из ветки main.
  • sha-<commit-hash> — неизменяемый тег для точного воспроизведения любого деплоя.
  • v1.2.3 — семантическая версия из Git-тега для релизов.
  • develop-<date> — временные образы для тестирования feature-веток.

Для rollback в продакшен достаточно изменить тег образа на предыдущий SHA и перезапустить сервис. Никогда не полагайтесь только на latest для rollback — это антипаттерн.

Типичные ошибки и как их избежать

  • Xdebug в продакшен-образе. Подключайте Xdebug только в development стадии Dockerfile. Он снижает производительность в 3–5 раз.
  • Монтирование vendor/ через volume в CI. Это ломает кэш слоёв и замедляет пайплайн. В CI зависимости должны быть внутри образа.
  • Использование docker-compose up в CI без явных команд. Всегда используйте конкретные сервисы и команды, не запускайте весь стек целиком.
  • Отсутствие .dockerignore. Без него контекст сборки включает node_modules (сотни МБ), .git и тестовые данные.
  • Хранение секретов в переменных окружения образа (ENV). Они видны через docker inspect. Передавайте секреты только в рантайме.
  • Разные версии PHP между локальной средой и CI. Фиксируйте точную версию образа в обоих местах, используйте одну переменную PHP_VERSION.

Чеклист готового PHP CI/CD с Docker

  1. Dockerfile использует multi-stage build с отдельными стадиями development и production.
  2. Базовый образ зафиксирован до минорной версии (например, php:8.3.10-fpm-alpine3.19).
  3. Контейнер запускается не от root, создан непривилегированный пользователь.
  4. Файл .dockerignore исключает .git, node_modules, .env, тестовые файлы.
  5. Docker Compose настроен с healthcheck для всех сервисов с состоянием (БД, Redis).
  6. CI-пайплайн включает этапы: lint → test → build → push.
  7. Кэширование слоёв настроено через type=gha в docker/build-push-action.
  8. Зависимости (composer.json) копируются отдельным слоем до исходного кода.
  9. Секреты передаются в рантайме через переменные среды, не через ENV в Dockerfile и не через build args.
  10. Образы тегируются SHA коммита и семантической версией, latest не используется для деплоя в продакшен.
  11. Rollback задокументирован и протестирован: смена тега + перезапуск сервиса.
  12. Параллельный запуск тестов настроен для тест-сьютов более 500 тестов.

Воспроизводимая среда разработки с PHP Docker CI/CD — это не разовая настройка, а живая система. Регулярно обновляйте базовые образы, следите за CVE через автоматические сканеры и ревьюйте пайплайн при изменении архитектуры проекта. Команда, которая один раз правильно настроила эту инфраструктуру, экономит часы на каждом спринте.

Технологии

Теги

Руслан Исмаилов

Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →