Despliegue Blue-Green de aplicaciones Laravel con Docker y Redis: cambio de tráfico sin pérdida de sesiones
Introducción: por qué Laravel necesita el despliegue blue-green
El rolling update estándar funciona bien para servicios stateless, pero las aplicaciones Laravel rara vez son completamente stateless. La caché en archivos, las sesiones locales, las colas y los comandos artisan que no pueden ejecutarse en paralelo en dos versiones simultáneamente hacen que el rolling update sea arriesgado. Mientras un contenedor se actualiza, el código antiguo y el nuevo pueden procesar peticiones del mismo usuario al mismo tiempo, lo que provoca errores de deserialización de sesiones, conflictos de migraciones y comportamiento impredecible.
El despliegue blue-green resuelve este problema de una forma fundamentalmente diferente: se mantienen dos entornos idénticos — blue (producción actual) y green (nueva versión). El tráfico se conmuta de forma atómica una vez que green está completamente listo y ha pasado los health checks. Si algo falla, el rollback toma segundos.
En este artículo veremos la implementación completa del despliegue blue-green para Laravel usando Docker, Redis para sesiones y CI/CD con GitHub Actions. El público objetivo son desarrolladores PHP e ingenieros DevOps que quieren un despliegue con zero-downtime real en producción.
Arquitectura del entorno blue-green con Docker Compose
La idea básica: nginx actúa como único punto de entrada y sabe qué stack está activo en cada momento. Los dos stacks — blue y green — se ejecutan en paralelo, pero solo uno recibe tráfico.
Estructura del proyecto:
.
├── 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
El archivo docker-compose.blue.yml describe el stack «azul»:
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
El archivo docker-compose.green.yml es idéntico, pero con el sufijo _green en todos los contenedores. Nginx lee la configuración del upstream desde un archivo que se reemplaza al hacer el cambio:
# 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;
}
Gestión de sesiones con Redis: por qué las sticky sessions son un antipatrón
Las sticky sessions (vinculación del usuario a un contenedor específico por cookie o IP) parecen una solución sencilla, pero generan problemas graves en el despliegue blue-green. Al cambiar el tráfico, el usuario «pegado» al contenedor blue acaba de repente en green — y su sesión se pierde porque estaba almacenada localmente en el sistema de archivos del contenedor antiguo.
La solución correcta es almacenar las sesiones en Redis, que es compartido por ambos stacks. Así, al conmutar el tráfico de blue a green, el usuario ni siquiera nota la transición: su sesión se lee desde Redis y sigue siendo válida.
Configuración en .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
En config/session.php asegúrate de que se usa la conexión correcta:
'connection' => env('REDIS_SESSION_CONNECTION', 'session'),
En config/database.php agrega una conexión Redis separada para las sesiones:
'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 debe ejecutarse de forma independiente a ambos stacks y ser accesible para los dos a través de una red Docker compartida. Esto es crítico: Redis no debe ser parte ni del stack blue ni del green, sino únicamente de la infraestructura externa.
Escenario paso a paso para el cambio de tráfico
El escenario completo de cambio es el siguiente:
- Determinar el stack activo actual (blue o green).
- Levantar el nuevo stack (el contrario).
- Esperar a que los health checks del nuevo stack sean exitosos.
- Ejecutar las migraciones de base de datos (backward-compatible).
- Cambiar el upstream de nginx al nuevo stack (operación atómica).
- Esperar a que las conexiones activas del stack antiguo finalicen.
- Detener el stack antiguo (mantenerlo algunos minutos para un rollback rápido).
Script de cambio 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"
# Determinamos el stack activo actual
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"
# Levantamos el nuevo stack
docker compose -f docker-compose.${NEXT}.yml up -d --build
# Esperamos el health check del nuevo stack
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."
# Ejecutamos las migraciones
echo "[deploy] Running migrations..."
docker exec "laravel_${NEXT}" php artisan migrate --force
# Cambiamos el upstream de nginx
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 del stack antiguo (esperamos 30 segundos para que finalicen las conexiones)
echo "[deploy] Stopping $CURRENT stack in 30s..."
sleep 30
docker compose -f docker-compose.${CURRENT}.yml stop
echo "[deploy] Done."
Para un rollback rápido basta con repetir el cambio en sentido inverso. Como el stack antiguo solo está detenido (no eliminado), el rollback toma segundos:
#!/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"
Migraciones de base de datos en la estrategia blue-green
Lo más complejo del despliegue blue-green en Laravel son las migraciones. En el momento del cambio, ambos stacks deben trabajar con la misma base de datos, por lo que el nuevo esquema debe ser compatible con el código antiguo.
Utiliza el patrón expand/contract (expansión/contracción):
- Expand (despliegue N): se añade la nueva columna como nullable o con valor por defecto. El código antiguo la ignora, el nuevo la rellena.
- Contract (despliegue N+1): se elimina la columna antigua una vez confirmado que todo el tráfico pasa por el nuevo código.
Ejemplo de migración backward-compatible: renombrar la columna user_name a username.
Paso 1 — Expand (despliegue N):
// database/migrations/2024_01_01_add_username_column.php
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// Añadimos la nueva columna conservando la antigua
$table->string('username')->nullable()->after('user_name');
});
// Copiamos los datos
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');
});
}
Paso 2 — Contract (despliegue N+1, ya después del cambio completo):
// 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();
});
}
Nunca ejecutes migraciones destructivas en el mismo despliegue que el cambio de tráfico. Separa siempre expand y contract en dos releases distintos.
Automatización del cambio con CI/CD
Integramos el despliegue blue-green en GitHub Actions. El pipeline consta de tres etapas: construcción de la imagen, despliegue y cambio de tráfico, y rollback opcional en caso de error.
# .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
# Descargamos la nueva imagen
docker pull ${APP_IMAGE}:${APP_VERSION}
# Iniciamos el cambio
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
Monitoreo durante el cambio de tráfico
Automatizar el despliegue está bien, pero sin monitoreo te enterarás de los problemas por los usuarios, no por el sistema. Esto es lo que debes vigilar durante el cambio:
- Tasa de errores HTTP: un aumento brusco de 5xx tras el cambio es señal de rollback inmediato.
- Latencia P95/P99: una degradación en el tiempo de respuesta indica problemas con la nueva versión.
- Conexiones a Redis: asegúrate de que ambos stacks se conectan correctamente a Redis sin competencia por las conexiones.
- Fallos en jobs de colas: los errores en colas suelen aparecer más tarde que los errores HTTP.
- Consultas lentas en base de datos: las nuevas migraciones pueden generar una carga inesperada.
Script bash sencillo para monitorear la tasa de errores durante el cambio:
#!/bin/bash
# scripts/monitor-switch.sh
set -euo pipefail
THRESHOLD=5 # máximo 5% de errores
DURATION=120 # monitorear 2 minutos después del cambio
INTERVAL=10
echo "[monitor] Watching error rate for ${DURATION}s..."
END=$((SECONDS + DURATION))
while [ $SECONDS -lt $END ]; do
# Obtenemos estadísticas del log de acceso de nginx
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."
Ejemplo práctico de configuración completa con docker-compose
A continuación la configuración completa para un entorno de producción con comentarios. El archivo docker-compose.nginx.yml es un servicio nginx independiente y permanente:
# docker-compose.nginx.yml
version: '3.9'
services:
nginx:
image: nginx:1.25-alpine
container_name: nginx_proxy
ports:
- "80:80"
- "443:443"
volumes:
# Configuración de nginx
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
# Config de upstream — se reemplaza al hacer el cambio
- /etc/nginx/conf.d:/etc/nginx/conf.d
# Certificados SSL
- ./ssl:/etc/nginx/ssl:ro
# Logs para monitoreo
- nginx_logs:/var/log/nginx
networks:
- app_net
restart: unless-stopped
# Redis — compartido por ambos stacks
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
Archivo docker-compose.blue.yml con la configuración completa de 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:
# Solo montamos storage/logs hacia afuera
- 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
# Un worker por stack; al cambiar, el antiguo termina sus tareas
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
# El scheduler solo se ejecuta en el stack activo
# Se controla mediante SCHEDULER_ENABLED en .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
Presta atención a la variable SCHEDULER_ENABLED en la configuración del scheduler. En .env.blue establecemos SCHEDULER_ENABLED=true solo para el stack activo. Antes del cambio, el script modifica este valor en el orden correcto para que el scheduler funcione en un único stack a la vez.
Dockerfile para la aplicación 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
# Dependencias
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
# Código de la aplicación
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
# Endpoint de health check
RUN echo ' /var/www/public/health.php
EXPOSE 9000
CMD ["php-fpm"]
Conclusión
El despliegue blue-green con Docker y Redis no es solo una práctica arquitectónica de moda, sino una forma real de garantizar zero-downtime en aplicaciones Laravel en producción. Los principios clave que hemos visto:
- Dos stacks idénticos entre los que nginx conmuta el tráfico de forma atómica.
- Redis como almacén único de sesiones: los usuarios no notan el cambio.
- Migraciones backward-compatible con el patrón expand/contract: ningún cambio destructivo de esquema en el momento del cambio de tráfico.
- Automatización con GitHub Actions y rollback automático ante errores de despliegue.
- Monitoreo de la tasa de errores durante varios minutos tras el cambio.
Empieza por lo más sencillo: configura las sesiones en Redis y un script básico de cambio. Una vez que domines la mecánica, integra CI/CD y añade monitoreo. Este enfoque te da confianza en cada despliegue y la posibilidad de revertir en segundos, no en minutos.
Tecnologías
Etiquetas
Ruslan Ismailov
Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →