DevOps

Blue-Green деплой Laravel-приложений с Docker и Redis: переключение без потери сессий

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

Введение: зачем 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 стека — только внешней инфраструктурой.

Пошаговый сценарий переключения трафика

Полный сценарий переключения выглядит так:

  1. Определить текущий активный стек (blue или green).
  2. Поднять новый стек (противоположный).
  3. Дождаться успешных health checks нового стека.
  4. Запустить миграции БД (backward-compatible).
  5. Переключить nginx upstream на новый стек (атомарная операция).
  6. Подождать завершения текущих соединений на старом стеке.
  7. Остановить старый стек (держать ещё несколько минут для быстрого 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. Подробнее обо мне →