Построение отказоустойчивого CI/CD пайплайна для PHP-приложений с Docker и Kubernetes
Введение: CI/CD для PHP-проектов в 2026 году
В 2026 году CI/CD — это не опциональная практика, а фундаментальное требование к любой серьёзной PHP-разработке. Ручной деплой, забытые миграции и «оно работало на моей машине» — симптомы команд, которые ещё не построили надёжный пайплайн автоматизации.
Для PHP-проектов, особенно построенных на Laravel или Symfony, непрерывная доставка кода несёт дополнительные риски: артефакты Composer, конфигурации OPcache, миграции базы данных, очереди задач. Без чёткой автоматизации каждый деплой — это ручной стресс-тест для команды.
В этой статье мы построим полный, отказоустойчивый CI/CD пайплайн для PHP-приложения с использованием Docker, Kubernetes, GitLab CI и GitHub Actions. Разберём каждый этап: от линтинга кода до Canary Releases и автоматического отката.
Обзор инструментов: GitHub Actions vs GitLab CI для PHP/Docker
Выбор CI/CD платформы — это стратегическое решение. Рассмотрим два лидера применительно к PHP и Docker:
GitLab CI/CD — встроен в GitLab, конфигурируется через
.gitlab-ci.yml. Поддерживает встроенный Container Registry, Auto DevOps, развитую систему окружений. Идеален для self-hosted инфраструктуры и корпоративных команд. Нативная интеграция с Kubernetes через GitLab Agent.GitHub Actions — конфигурируется через YAML в директории
.github/workflows/. Огромная экосистема готовых actions, бесплатный тариф для публичных репозиториев. Идеален для open-source проектов и команд, уже использующих GitHub.
Для PHP/Docker оба инструмента справляются одинаково хорошо. GitLab CI выигрывает при self-hosted деплое и встроенном registry. GitHub Actions — при скорости старта и экосистеме готовых компонентов.
В этой статье мы покажем примеры для обоих, с акцентом на GitLab CI как более полном решении для enterprise PHP-проектов.
Контейнеризация PHP-приложения: оптимальный Dockerfile
Основа любого CI/CD пайплайна для PHP — правильный Dockerfile. Используем многоэтапную сборку (multi-stage build), Alpine Linux и OPcache для оптимальной производительности.
# Dockerfile
# ==========================================
# Stage 1: Composer dependencies
# ==========================================
FROM composer:2.7 AS composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--no-interaction \
--prefer-dist \
--optimize-autoloader \
--no-scripts
COPY . .
RUN composer dump-autoload --optimize --classmap-authoritative
# ==========================================
# Stage 2: Assets build (если есть frontend)
# ==========================================
FROM node:20-alpine AS assets
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# ==========================================
# Stage 3: Production PHP image
# ==========================================
FROM php:8.3-fpm-alpine AS production
# Системные зависимости
RUN apk add --no-cache \
nginx \
supervisor \
libpq \
libzip \
&& apk add --no-cache --virtual .build-deps \
libpq-dev \
libzip-dev \
autoconf \
gcc \
g++ \
make \
&& docker-php-ext-install \
pdo_pgsql \
opcache \
zip \
pcntl \
bcmath \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apk del .build-deps
# OPcache конфигурация для продакшена
RUN echo "opcache.enable=1" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.memory_consumption=256" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.interned_strings_buffer=16" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.max_accelerated_files=20000" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.revalidate_freq=0" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.validate_timestamps=0" >> /usr/local/etc/php/conf.d/opcache.ini
WORKDIR /var/www/html
# Копируем зависимости из предыдущих стадий
COPY --from=composer /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
COPY . .
# Права доступа
RUN chown -R www-data:www-data storage bootstrap/cache \
&& chmod -R 775 storage bootstrap/cache
# Nginx конфиг
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
COPY docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
EXPOSE 80
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
Ключевые принципы этого Dockerfile:
Многоэтапная сборка — финальный образ не содержит Composer, Node.js и build-зависимостей
Alpine Linux — минимальный базовый образ (~5 МБ против ~900 МБ у debian)
OPcache с validate_timestamps=0 — максимальная производительность в продакшене
Слои кешируются —
composer.jsonиcomposer.lockкопируются отдельно до остального кода
Этап CI: линтинг, тесты и сборка образа
PHP_CodeSniffer и PHPStan
Статический анализ — первый барьер в пайплайне. Настройте phpcs.xml и phpstan.neon в корне проекта:
# phpstan.neon
parameters:
level: 8
paths:
- app
- tests
excludePaths:
- vendor
checkMissingIterableValueType: false
PHPUnit конфигурация
<!-- phpunit.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
stopOnFailure="false">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory suffix=".php">./app</directory>
</include>
</coverage>
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
</php>
</phpunit>
Полный .gitlab-ci.yml пайплайн
Ниже — production-ready конфиг GitLab CI для PHP/Laravel с деплоем в Kubernetes:
# .gitlab-ci.yml
variables:
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: "/certs"
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
IMAGE_LATEST: $CI_REGISTRY_IMAGE:latest
KUBERNETES_NAMESPACE: production
stages:
- validate
- test
- build
- migrate
- deploy
- verify
# ==========================================
# Шаблоны
# ==========================================
.php_template: &php_template
image: php:8.3-cli-alpine
before_script:
- apk add --no-cache git unzip libzip-dev
- docker-php-ext-install zip
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin --filename=composer
- composer install --no-interaction --prefer-dist --optimize-autoloader
# ==========================================
# Stage: validate
# ==========================================
phpcs:
<<: *php_template
stage: validate
script:
- vendor/bin/phpcs --standard=PSR12 app/ --report=checkstyle --report-file=phpcs-report.xml
artifacts:
reports:
codequality: phpcs-report.xml
when: always
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
phpstan:
<<: *php_template
stage: validate
script:
- vendor/bin/phpstan analyse --memory-limit=512M --error-format=gitlab > phpstan-report.json || true
artifacts:
reports:
codequality: phpstan-report.json
when: always
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
# ==========================================
# Stage: test
# ==========================================
unit_tests:
<<: *php_template
stage: test
services:
- name: postgres:16-alpine
alias: postgres
variables:
POSTGRES_DB: test_db
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
DB_HOST: postgres
DB_DATABASE: test_db
DB_USERNAME: test_user
DB_PASSWORD: test_password
before_script:
- apk add --no-cache git unzip libzip-dev libpq-dev
- docker-php-ext-install zip pdo_pgsql
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin --filename=composer
- composer install --no-interaction --prefer-dist
- cp .env.testing .env
- php artisan key:generate
- php artisan migrate --force
script:
- vendor/bin/phpunit --coverage-text --coverage-cobertura=coverage.xml
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
when: always
coverage: '/^\s*Lines:\s*\d+\.?\d*%/'
# ==========================================
# Stage: build
# ==========================================
build_image:
stage: build
image: docker:24
services:
- docker:24-dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build
--cache-from $IMAGE_LATEST
--build-arg BUILDKIT_INLINE_CACHE=1
--tag $IMAGE_TAG
--tag $IMAGE_LATEST
.
- docker push $IMAGE_TAG
- docker push $IMAGE_LATEST
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_BRANCH =~ /^release\/.*/
# ==========================================
# Stage: migrate
# ==========================================
run_migrations:
stage: migrate
image:
name: bitnami/kubectl:latest
entrypoint: [""]
script:
- kubectl config use-context $KUBE_CONTEXT
- |
kubectl run migration-$CI_COMMIT_SHORT_SHA \
--image=$IMAGE_TAG \
--restart=Never \
--namespace=$KUBERNETES_NAMESPACE \
--env="APP_ENV=production" \
--command -- php artisan migrate --force
- kubectl wait --for=condition=complete \
job/migration-$CI_COMMIT_SHORT_SHA \
--timeout=300s \
--namespace=$KUBERNETES_NAMESPACE || \
(kubectl logs -l job-name=migration-$CI_COMMIT_SHORT_SHA --namespace=$KUBERNETES_NAMESPACE && exit 1)
- kubectl delete pod migration-$CI_COMMIT_SHORT_SHA --namespace=$KUBERNETES_NAMESPACE
rules:
- if: $CI_COMMIT_BRANCH == "main"
needs:
- build_image
# ==========================================
# Stage: deploy (Rolling Update)
# ==========================================
deploy_production:
stage: deploy
image:
name: bitnami/kubectl:latest
entrypoint: [""]
environment:
name: production
url: https://app.example.com
script:
- kubectl config use-context $KUBE_CONTEXT
- kubectl set image deployment/php-app
php-app=$IMAGE_TAG
--namespace=$KUBERNETES_NAMESPACE
- kubectl rollout status deployment/php-app
--namespace=$KUBERNETES_NAMESPACE
--timeout=300s
rules:
- if: $CI_COMMIT_BRANCH == "main"
needs:
- run_migrations
# ==========================================
# Stage: verify (smoke tests)
# ==========================================
smoke_tests:
stage: verify
image: curlimages/curl:latest
script:
- sleep 10
- curl -f -s -o /dev/null https://app.example.com/health || (echo "Health check failed" && exit 1)
- echo "Deployment verified successfully"
needs:
- deploy_production
rules:
- if: $CI_COMMIT_BRANCH == "main"
GitHub Actions: эквивалентный workflow
# .github/workflows/deploy.yml
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2, phpcs, phpstan
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: PHP CodeSniffer
run: vendor/bin/phpcs --standard=PSR12 app/
- name: PHPStan
run: vendor/bin/phpstan analyse --memory-limit=512M
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: test_db
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo_pgsql, zip, redis
coverage: xdebug
- run: composer install --no-interaction --prefer-dist
- run: cp .env.testing .env && php artisan key:generate
- run: php artisan migrate --force
- run: vendor/bin/phpunit --coverage-clover coverage.xml
- uses: codecov/codecov-action@v4
with:
file: coverage.xml
build-and-push:
needs: [validate, test]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=raw,value=latest
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build-and-push
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: azure/setup-kubectl@v4
- name: Configure kubectl
run: |
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > kubeconfig.yaml
export KUBECONFIG=kubeconfig.yaml
- name: Deploy to Kubernetes
run: |
export KUBECONFIG=kubeconfig.yaml
kubectl set image deployment/php-app \
php-app=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }} \
--namespace=production
kubectl rollout status deployment/php-app \
--namespace=production --timeout=300s
Стратегии деплоя в Kubernetes
Rolling Update — стандартная стратегия
Rolling Update — наиболее распространённая стратегия для PHP-приложений. Kubernetes постепенно заменяет старые поды новыми:
# kubernetes/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: php-app
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: php-app
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # +1 новый под во время обновления
maxUnavailable: 0 # 0 подов недоступно во время обновления
template:
metadata:
labels:
app: php-app
version: "{{ .Values.image.tag }}"
spec:
containers:
- name: php-app
image: registry.example.com/php-app:{{ .Values.image.tag }}
ports:
- containerPort: 80
readinessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 30
periodSeconds: 10
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
envFrom:
- secretRef:
name: php-app-secrets
- configMapRef:
name: php-app-config
Blue-Green Deployment
Blue-Green позволяет держать два идентичных окружения и переключать трафик мгновенно. Для PHP это особенно полезно при крупных миграциях:
# Деплой green-версии
kubectl apply -f kubernetes/deployment-green.yaml
# Ждём готовности green
kubectl rollout status deployment/php-app-green --timeout=300s
# Переключаем Service на green
kubectl patch service php-app-service \
-p '{"spec":{"selector":{"version":"green"}}}'
# Проверяем и при необходимости откатываемся
kubectl patch service php-app-service \
-p '{"spec":{"selector":{"version":"blue"}}}'
Canary Releases
Canary Releases позволяют направить небольшой процент трафика (5-10%) на новую версию и постепенно увеличивать его:
# deployment-canary.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: php-app-canary
namespace: production
spec:
replicas: 1 # 1 из 10 подов = 10% трафика
selector:
matchLabels:
app: php-app
track: canary
template:
metadata:
labels:
app: php-app
track: canary
spec:
containers:
- name: php-app
image: registry.example.com/php-app:new-version
Управление секретами: Kubernetes Secrets и HashiCorp Vault
Никогда не храните секреты в Git-репозитории. Есть два основных подхода:
Kubernetes Secrets (базовый уровень)
# Создание секрета из файла .env
kubectl create secret generic php-app-secrets \
--from-literal=APP_KEY=base64:ваш_ключ \
--from-literal=DB_PASSWORD=секретный_пароль \
--from-literal=REDIS_PASSWORD=redis_пароль \
--namespace=production
# Или через манифест (значения должны быть base64-encoded)
apiVersion: v1
kind: Secret
metadata:
name: php-app-secrets
namespace: production
type: Opaque
data:
APP_KEY: YmFzZTY0OmtleQ==
DB_PASSWORD: c2VjcmV0
HashiCorp Vault (продакшен-уровень)
Для production-окружений используйте Vault с Vault Agent Injector:
# kubernetes/vault-annotations.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: php-app
spec:
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "php-app"
vault.hashicorp.com/agent-inject-secret-env: "secret/data/php-app/production"
vault.hashicorp.com/agent-inject-template-env: |
{{- with secret "secret/data/php-app/production" -}}
export APP_KEY={{ .Data.data.app_key }}
export DB_PASSWORD={{ .Data.data.db_password }}
{{- end }}
В GitLab CI секреты передаются через переменные окружения с маскировкой: Settings → CI/CD → Variables → Masked. В GitHub Actions используйте Settings → Secrets and variables → Actions.
Автоматические миграции базы данных
Миграции — критический момент деплоя. Есть два паттерна:
Init Container (рекомендуется для Kubernetes)
# В deployment.yaml добавьте initContainers
spec:
initContainers:
- name: run-migrations
image: registry.example.com/php-app:{{ .Values.image.tag }}
command: ["php", "artisan", "migrate", "--force"]
envFrom:
- secretRef:
name: php-app-secrets
- configMapRef:
name: php-app-config
Init Container запустится перед основными подами и завершится. Только после успешного завершения Kubernetes запустит основной контейнер. Это гарантирует, что миграции будут выполнены до старта приложения.
Принципы безопасных миграций
Backward-compatible миграции: новая версия кода должна работать со старой схемой БД (expand-contract паттерн)
Никогда не удаляйте колонки в той же версии, где убираете их из кода
Используйте транзакции в миграциях для атомарности
Тестируйте откат: каждая миграция должна иметь метод
down()
Откат деплоя: стратегии и автоматизация
Ручной откат через kubectl
# Просмотр истории деплоев
kubectl rollout history deployment/php-app --namespace=production
# Откат на предыдущую версию
kubectl rollout undo deployment/php-app --namespace=production
# Откат на конкретную ревизию
kubectl rollout undo deployment/php-app \
--to-revision=3 \
--namespace=production
# Проверка статуса после отката
kubectl rollout status deployment/php-app --namespace=production
Автоматический откат в GitLab CI
# Добавьте в .gitlab-ci.yml стадию автоотката
auto_rollback:
stage: verify
image:
name: bitnami/kubectl:latest
entrypoint: [""]
script:
- kubectl config use-context $KUBE_CONTEXT
- |
# Проверяем health endpoint 3 раза с интервалом 10 секунд
for i in 1 2 3; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://app.example.com/health)
if [ "$STATUS" != "200" ]; then
echo "Health check failed (attempt $i), HTTP $STATUS"
if [ "$i" -eq 3 ]; then
echo "Rolling back deployment..."
kubectl rollout undo deployment/php-app --namespace=$KUBERNETES_NAMESPACE
exit 1
fi
sleep 10
else
echo "Health check passed"
exit 0
fi
done
needs:
- deploy_production
when: on_success
Мониторинг пайплайна и уведомления
Отказоустойчивый пайплайн без мониторинга — это иллюзия надёжности. Интегрируйте следующие инструменты:
Уведомления в Slack/Telegram из GitLab CI
# Добавьте в конец .gitlab-ci.yml
notify_success:
stage: .post
image: curlimages/curl:latest
script:
- |
curl -X POST $SLACK_WEBHOOK_URL \
-H 'Content-type: application/json' \
--data '{"text":"✅ Деплой *'$CI_PROJECT_NAME'* успешен. Коммит: '$CI_COMMIT_SHORT_SHA' ('$CI_COMMIT_AUTHOR')"}'
when: on_success
rules:
- if: $CI_COMMIT_BRANCH == "main"
notify_failure:
stage: .post
image: curlimages/curl:latest
script:
- |
curl -X POST $SLACK_WEBHOOK_URL \
-H 'Content-type: application/json' \
--data '{"text":"🚨 Деплой *'$CI_PROJECT_NAME'* ПРОВАЛИЛСЯ! Pipeline: '$CI_PIPELINE_URL'"}'
when: on_failure
rules:
- if: $CI_COMMIT_BRANCH == "main"
Метрики пайплайна: что мониторить
Deployment Frequency — как часто деплоится команда (DORA-метрика)
Lead Time for Changes — время от коммита до продакшена
Change Failure Rate — процент деплоев, потребовавших отката
Mean Time to Recovery (MTTR) — среднее время восстановления после сбоя
Для мониторинга Kubernetes используйте связку Prometheus + Grafana. Для трейсинга деплоев — ArgoCD или встроенный GitLab Environments dashboard.
Endpoint /health для PHP/Laravel
ReadinessProbe и LivenessProbe в Kubernetes требуют работающего health endpoint. Добавьте в Laravel:
<?php
// routes/web.php
Route::get('/health', function () {
try {
// Проверяем соединение с БД
DB::select('SELECT 1');
// Проверяем Redis
Redis::ping();
return response()->json([
'status' => 'ok',
'timestamp' => now()->toISOString(),
'version' => config('app.version'),
], 200);
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'message' => $e->getMessage(),
], 503);
}
})->middleware('throttle:60,1');
Чеклист: признаки production-ready CI/CD пайплайна
Все секреты хранятся в Vault или CI/CD variables (не в коде)
Пайплайн не деплоит при падении тестов
Docker-образы имеют конкретные теги (не только
latest)Миграции выполняются через Init Container, а не вручную
ReadinessProbe настроен — Kubernetes не шлёт трафик на неготовый под
Откат занимает не более 2 минут
Команда получает уведомления о статусе каждого деплоя
Покрытие тестами минимум 70%, отчёт публикуется в MR/PR
Сборка занимает не более 10 минут (иначе разработчики игнорируют пайплайн)
Заключение
Построение отказоустойчивого CI/CD пайплайна для PHP с Docker и Kubernetes — это инвестиция, которая окупается с первого же спасённого деплоя. Начните с базового пайплайна: линтинг → тесты → сборка → деплой. Затем итерируйте: добавьте управление секретами через Vault, настройте автоматический откат, внедрите Canary Releases.
Ключевой принцип: каждый шаг пайплайна должен быть автоматизирован, идемпотентен и наблюдаем. Если что-то нельзя автоматически откатить — это риск, который нужно устранить до следующего деплоя.
Инвестируйте время в качество пайплайна сегодня, и ваша команда будет деплоить в пятницу без страха.
Технологии
Теги
Руслан Исмаилов
Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →