Blue-Green деплой Laravel-приложений с Docker и Redis: переключение без потери сессий
Введение: зачем Laravel нужен blue-green деплой
Стандартный rolling update хорошо работает для stateless-сервисов, но Laravel-приложения редко бывают полностью stateless. Файловый кэш, локальные сессии, очереди, artisan-команды, которые нельзя запускать параллельно в двух версиях одновременно — всё это делает rolling update рискованным. Пока один контейнер обновляется, старый и новый код могут одновременно обрабатывать запросы одного пользователя, что приводит к ошибкам десериализации сессий, конфликтам миграций и непредсказуемому поведению.
Blue-green деплой решает эту проблему принципиально иначе: вы держите два идентичных окружения — blue (текущий прод) и green (новая версия). Трафик переключается атомарно после того, как green полностью готов и прошёл health checks. Если что-то пошло не так, откат занимает секунды.
В этой статье мы разберём полную реализацию blue-green деплоя для Laravel с использованием Docker, Redis для сессий и CI/CD через GitHub Actions. Целевая аудитория — PHP-разработчики и DevOps-инженеры, которые хотят получить настоящий zero-downtime деплой в продакшене.
Архитектура blue-green окружения с Docker Compose
Базовая идея: nginx выступает единственной точкой входа и знает, какой стек сейчас активен. Два стека — blue и green — запущены параллельно, но трафик получает только один.
Структура проекта:
.
├── docker-compose.blue.yml
├── docker-compose.green.yml
├── docker-compose.nginx.yml
├── nginx/
│ ├── nginx.conf
│ ├── upstream-blue.conf
│ └── upstream-green.conf
├── scripts/
│ ├── switch.sh
│ └── healthcheck.sh
└── .env.blue
.env.green
Файл docker-compose.blue.yml описывает «синий» стек:
version: '3.9'
services:
app_blue:
image: ${APP_IMAGE}:${APP_VERSION}
container_name: laravel_blue
env_file: .env.blue
networks:
- app_net
depends_on:
- redis
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
worker_blue:
image: ${APP_IMAGE}:${APP_VERSION}
container_name: laravel_worker_blue
env_file: .env.blue
command: php artisan queue:work --sleep=3 --tries=3
networks:
- app_net
restart: unless-stopped
networks:
app_net:
external: true
Файл docker-compose.green.yml идентичен, но с суффиксом _green у всех контейнеров. Nginx читает конфигурацию upstream из файла, который мы подменяем при переключении:
# nginx/nginx.conf
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/conf.d/upstream.conf;
server {
listen 80;
location /health {
return 200 'ok';
add_header Content-Type text/plain;
}
location / {
proxy_pass http://laravel_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}
# nginx/upstream-blue.conf
upstream laravel_upstream {
server laravel_blue:9000;
}
# nginx/upstream-green.conf
upstream laravel_upstream {
server laravel_green:9000;
}
Управление сессиями через Redis: почему sticky sessions — антипаттерн
Sticky sessions (привязка пользователя к конкретному контейнеру по cookie или IP) кажутся простым решением, но создают серьёзные проблемы при blue-green деплое. При переключении трафика пользователь, «приклеенный» к blue-контейнеру, внезапно оказывается на green — и его сессия теряется, потому что она хранилась локально в файловой системе старого контейнера.
Правильное решение — хранить сессии в Redis, который является общим для обоих стеков. Тогда при переключении трафика с blue на green пользователь даже не замечает перехода: его сессия читается из Redis и остаётся валидной.
Настройка в .env:
SESSION_DRIVER=redis
SESSION_LIFETIME=120
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=your_secure_password
REDIS_SESSION_DB=1
CACHE_DRIVER=redis
REDIS_CACHE_DB=2
В config/session.php убедитесь, что используется правильное подключение:
'connection' => env('REDIS_SESSION_CONNECTION', 'session'),
В config/database.php добавьте отдельное Redis-соединение для сессий:
'redis' => [
'session' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_SESSION_DB', '1'),
],
],
Redis должен быть запущен отдельно от обоих стеков и доступен обоим через общую сеть Docker. Это критично: Redis не должен быть частью ни blue, ни green стека — только внешней инфраструктурой.
Пошаговый сценарий переключения трафика
Полный сценарий переключения выглядит так:
- Определить текущий активный стек (blue или green).
- Поднять новый стек (противоположный).
- Дождаться успешных health checks нового стека.
- Запустить миграции БД (backward-compatible).
- Переключить nginx upstream на новый стек (атомарная операция).
- Подождать завершения текущих соединений на старом стеке.
- Остановить старый стек (держать ещё несколько минут для быстрого rollback).
Скрипт переключения scripts/switch.sh:
#!/bin/bash
set -euo pipefail
ACTIVE_COLOR_FILE="/var/run/laravel-active"
NGINX_CONF_DIR="/etc/nginx/conf.d"
NGINX_CONTAINER="nginx_proxy"
# Определяем текущий активный стек
if [ -f "$ACTIVE_COLOR_FILE" ]; then
CURRENT=$(cat "$ACTIVE_COLOR_FILE")
else
CURRENT="blue"
fi
if [ "$CURRENT" == "blue" ]; then
NEXT="green"
else
NEXT="blue"
fi
echo "[deploy] Current: $CURRENT → Next: $NEXT"
# Поднимаем новый стек
docker compose -f docker-compose.${NEXT}.yml up -d --build
# Ждём health check нового стека
echo "[deploy] Waiting for health checks..."
MAX_RETRIES=30
RETRY=0
while ! docker inspect --format='{{.State.Health.Status}}' "laravel_${NEXT}" | grep -q 'healthy'; do
RETRY=$((RETRY+1))
if [ "$RETRY" -ge "$MAX_RETRIES" ]; then
echo "[deploy] ERROR: Health check failed. Rolling back."
docker compose -f docker-compose.${NEXT}.yml down
exit 1
fi
sleep 5
done
echo "[deploy] Health check passed."
# Запускаем миграции
echo "[deploy] Running migrations..."
docker exec "laravel_${NEXT}" php artisan migrate --force
# Переключаем nginx upstream
cp "${NGINX_CONF_DIR}/upstream-${NEXT}.conf" "${NGINX_CONF_DIR}/upstream.conf"
docker exec "$NGINX_CONTAINER" nginx -s reload
echo "$NEXT" > "$ACTIVE_COLOR_FILE"
echo "[deploy] Switched to $NEXT."
# Graceful shutdown старого стека (ждём 30 секунд для завершения соединений)
echo "[deploy] Stopping $CURRENT stack in 30s..."
sleep 30
docker compose -f docker-compose.${CURRENT}.yml stop
echo "[deploy] Done."
Для быстрого отката достаточно повторить переключение в обратную сторону. Поскольку старый стек только остановлен (не удалён), rollback занимает секунды:
#!/bin/bash
# scripts/rollback.sh
set -euo pipefail
ACTIVE_COLOR_FILE="/var/run/laravel-active"
CURRENT=$(cat "$ACTIVE_COLOR_FILE")
if [ "$CURRENT" == "blue" ]; then
PREV="green"
else
PREV="blue"
fi
echo "[rollback] Activating $PREV..."
docker compose -f docker-compose.${PREV}.yml start
cp "nginx/upstream-${PREV}.conf" "/etc/nginx/conf.d/upstream.conf"
docker exec nginx_proxy nginx -s reload
echo "$PREV" > "$ACTIVE_COLOR_FILE"
echo "[rollback] Done. Active: $PREV"
Миграции базы данных в blue-green стратегии
Самое сложное в blue-green деплое для Laravel — это миграции. В момент переключения оба стека должны работать с одной и той же базой данных, поэтому новая схема должна быть совместима со старым кодом.
Используйте паттерн expand/contract (расширение/сужение):
- Expand (деплой N): добавляем новую колонку как nullable или с дефолтным значением. Старый код игнорирует её, новый — заполняет.
- Contract (деплой N+1): удаляем старую колонку, когда убедились, что весь трафик идёт через новый код.
Пример backward-compatible миграции: переименование колонки user_name в username.
Шаг 1 — Expand (деплой N):
// database/migrations/2024_01_01_add_username_column.php
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// Добавляем новую колонку, сохраняя старую
$table->string('username')->nullable()->after('user_name');
});
// Копируем данные
DB::statement('UPDATE users SET username = user_name WHERE username IS NULL');
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('username');
});
}
Шаг 2 — Contract (деплой N+1, уже после полного переключения):
// database/migrations/2024_01_15_drop_user_name_column.php
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('user_name');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('user_name')->nullable();
});
}
Никогда не выполняйте деструктивные миграции в том же деплое, что и переключение трафика. Всегда разделяйте expand и contract на два отдельных релиза.
Автоматизация переключения через CI/CD
Интегрируем blue-green деплой в GitHub Actions. Пайплайн состоит из трёх этапов: сборка образа, деплой и переключение трафика, опциональный rollback при ошибке.
# .github/workflows/deploy.yml
name: Blue-Green Deploy
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: type=sha,prefix=,suffix=,format=short
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
export APP_IMAGE=ghcr.io/${{ github.repository }}
export APP_VERSION=${{ needs.build.outputs.image_tag }}
cd /opt/laravel-app
# Подтягиваем новый образ
docker pull ${APP_IMAGE}:${APP_VERSION}
# Запускаем переключение
bash scripts/switch.sh
script_stop: true
rollback-on-failure:
needs: deploy
runs-on: ubuntu-latest
if: failure()
steps:
- name: Rollback
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: bash /opt/laravel-app/scripts/rollback.sh
Мониторинг во время переключения
Автоматизация деплоя — это хорошо, но без мониторинга вы узнаете о проблемах от пользователей, а не от системы. Вот что нужно отслеживать во время переключения:
- HTTP error rate: резкий рост 5xx после переключения — сигнал к немедленному rollback.
- Latency P95/P99: деградация времени ответа говорит о проблемах с новой версией.
- Redis connections: убедитесь, что оба стека корректно подключаются к Redis без гонки за соединения.
- Queue job failures: ошибки в очередях часто проявляются позже, чем HTTP-ошибки.
- Database slow queries: новые миграции могут создать неожиданную нагрузку.
Простой bash-скрипт для мониторинга error rate во время переключения:
#!/bin/bash
# scripts/monitor-switch.sh
set -euo pipefail
THRESHOLD=5 # максимум 5% ошибок
DURATION=120 # мониторить 2 минуты после переключения
INTERVAL=10
echo "[monitor] Watching error rate for ${DURATION}s..."
END=$((SECONDS + DURATION))
while [ $SECONDS -lt $END ]; do
# Получаем статистику из nginx access log
TOTAL=$(docker exec nginx_proxy awk '{print $9}' /var/log/nginx/access.log | wc -l)
ERRORS=$(docker exec nginx_proxy awk '$9 >= 500 {count++} END {print count+0}' /var/log/nginx/access.log)
if [ "$TOTAL" -gt 0 ]; then
ERROR_RATE=$(echo "scale=2; $ERRORS * 100 / $TOTAL" | bc)
echo "[monitor] Error rate: ${ERROR_RATE}% (${ERRORS}/${TOTAL})"
if (( $(echo "$ERROR_RATE > $THRESHOLD" | bc -l) )); then
echo "[monitor] ERROR RATE TOO HIGH! Initiating rollback..."
bash /opt/laravel-app/scripts/rollback.sh
exit 1
fi
fi
sleep $INTERVAL
done
echo "[monitor] Switch successful. No degradation detected."
Практический пример полной docker-compose конфигурации
Вот полная конфигурация для production-окружения с комментариями. Файл docker-compose.nginx.yml — отдельный, постоянный сервис nginx:
# docker-compose.nginx.yml
version: '3.9'
services:
nginx:
image: nginx:1.25-alpine
container_name: nginx_proxy
ports:
- "80:80"
- "443:443"
volumes:
# Конфигурация nginx
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
# Upstream-конфиг — подменяется при переключении
- /etc/nginx/conf.d:/etc/nginx/conf.d
# SSL-сертификаты
- ./ssl:/etc/nginx/ssl:ro
# Логи для мониторинга
- nginx_logs:/var/log/nginx
networks:
- app_net
restart: unless-stopped
# Redis — единый для обоих стеков
redis:
image: redis:7.2-alpine
container_name: redis_shared
command: >
redis-server
--requirepass ${REDIS_PASSWORD}
--maxmemory 512mb
--maxmemory-policy allkeys-lru
--save 60 1000
volumes:
- redis_data:/data
networks:
- app_net
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
redis_data:
nginx_logs:
networks:
app_net:
name: app_net
driver: bridge
Файл docker-compose.blue.yml с полной конфигурацией Laravel:
# docker-compose.blue.yml
version: '3.9'
services:
app_blue:
image: ${APP_IMAGE}:${APP_VERSION}
container_name: laravel_blue
env_file: .env.blue
working_dir: /var/www
volumes:
# Только storage/logs монтируем наружу
- storage_blue:/var/www/storage
networks:
- app_net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost/health || exit 1"]
interval: 10s
timeout: 5s
retries: 6
start_period: 30s
labels:
- "stack=blue"
- "app=laravel"
worker_blue:
image: ${APP_IMAGE}:${APP_VERSION}
container_name: laravel_worker_blue
env_file: .env.blue
working_dir: /var/www
# Один воркер на стек; при переключении старый доделывает задачи
command: php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
volumes:
- storage_blue:/var/www/storage
networks:
- app_net
restart: unless-stopped
labels:
- "stack=blue"
- "app=laravel-worker"
scheduler_blue:
image: ${APP_IMAGE}:${APP_VERSION}
container_name: laravel_scheduler_blue
env_file: .env.blue
working_dir: /var/www
# Планировщик — запускаем только в активном стеке!
# Управляется через SCHEDULER_ENABLED в .env
command: |
sh -c 'while true; do
if [ "$${SCHEDULER_ENABLED}" = "true" ]; then
php artisan schedule:run;
fi;
sleep 60;
done'
networks:
- app_net
restart: unless-stopped
volumes:
storage_blue:
networks:
app_net:
external: true
Обратите внимание на переменную SCHEDULER_ENABLED в конфигурации планировщика. В .env.blue ставим SCHEDULER_ENABLED=true только для активного стека. Перед переключением скрипт меняет это значение в нужном порядке, чтобы планировщик работал только в одном стеке одновременно.
Dockerfile для Laravel-приложения:
# Dockerfile
FROM php:8.3-fpm-alpine AS base
RUN apk add --no-cache \
curl \
libpng-dev \
libzip-dev \
redis \
&& docker-php-ext-install pdo_mysql zip gd \
&& pecl install redis \
&& docker-php-ext-enable redis
WORKDIR /var/www
# Composer
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer
# Зависимости
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
# Код приложения
COPY . .
RUN composer dump-autoload --optimize \
&& php artisan config:cache \
&& php artisan route:cache \
&& php artisan view:cache \
&& chown -R www-data:www-data storage bootstrap/cache
# Health check endpoint
RUN echo ' /var/www/public/health.php
EXPOSE 9000
CMD ["php-fpm"]
Заключение
Blue-green деплой с Docker и Redis — это не просто модная архитектурная практика, а реальный способ обеспечить zero-downtime деплой для Laravel-приложений в продакшене. Ключевые принципы, которые мы рассмотрели:
- Два идентичных стека, между которыми nginx переключает трафик атомарно.
- Redis как единое хранилище сессий — пользователи не замечают переключения.
- Backward-compatible миграции по паттерну expand/contract — никакой деструктивной схемы в момент переключения.
- Автоматизация через GitHub Actions с автоматическим rollback при ошибке деплоя.
- Мониторинг error rate в течение нескольких минут после переключения.
Начните с малого: настройте Redis-сессии и простой скрипт переключения. Как только освоитесь с механикой, интегрируйте CI/CD и добавьте мониторинг. Такой подход даёт уверенность в деплое и возможность откатиться за секунды, а не минуты.
Технологии
Теги
Руслан Исмаилов
Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →