Despliegue Blue-Green en un entorno local con Docker

Introducción

El despliegue Blue-Green es una estrategia de despliegue diseñada para minimizar el tiempo de inactividad y los riesgos durante las actualizaciones de las aplicaciones. Este enfoque consiste en ejecutar dos entornos separados, Blue y Green, en los que uno sirve al tráfico (el entorno activo) mientras el otro está inactivo o en proceso de actualización. Una vez validadas las actualizaciones en el entorno inactivo, el tráfico se conmuta sin problemas, garantizando la estabilidad y minimizando las interrupciones.

En esta guía, demostramos cómo implementar Blue-Green Deployment usando Docker en un entorno local con Laravel como aplicación de ejemplo. Este enfoque está diseñado con fines educativos, ofreciendo una forma práctica de comprender los fundamentos del despliegue Blue-Green.

¿Por qué Docker para el despliegue Blue-Green?

Docker proporciona una forma eficaz de aislar y simular entornos para el despliegue Blue-Green en una configuración local. Con los contenedores Docker, puede crear entornos “blue” y “green” independientes, probar los cambios de forma segura y conmutar el tráfico entre ellos.

Esta guía aprovecha Docker para:

Aunque hay otras formas de probar los despliegues localmente, este método se centra en la simplicidad y la claridad, lo que permite a los desarrolladores comprender la estrategia sin la complejidad de gestionar la infraestructura de la nube.

¿Por qué realizar pruebas a nivel local?

Probar el despliegue Blue-Green localmente ofrece varias ventajas:

Esta configuración local le permite perfeccionar con confianza las estrategias de implantación antes de escalarlas a entornos de producción.

Configuración de demostración

Para esta demostración se utilizan los siguientes componentes:

  1. Nginx: Actúa como proxy inverso para enrutar el tráfico al entorno activo.
  2. Comprobaciones de salud o health-checks: Supervisa la disponibilidad de los contenedores y decide cuándo cambiar el tráfico.
  3. Laravel: Ejemplo de aplicación para visualizar transiciones de entorno.

El papel vital de los health-checks en el despliegue Blue-Green

Uno de los componentes clave de esta configuración es el uso de comprobaciones de estado. Estas comprobaciones supervisan continuamente el estado de un contenedor para determinar si está preparado para gestionar tráfico. Las comprobaciones de estado son esenciales en el despliegue Blue-Green, ya que garantizan la estabilidad y minimizan los riesgos durante la transición entre entornos.

En esta configuración local, las comprobaciones de estado desempeñan las siguientes funciones:

  1. Simular el comportamiento de producción: Las plataformas como AWS ECS y Kubernetes dependen en gran medida de las comprobaciones de estado para determinar si un servicio está operativo. Al definir las comprobaciones de estado en nuestro archivo docker-compose.yml, replicamos este comportamiento en un entorno local, proporcionando una experiencia cercana al mundo real.
  2. Automatización de las decisiones de despliegue: El script de despliegue utiliza comprobaciones de estado para decidir si el tráfico puede dirigirse al nuevo entorno. Si un contenedor no supera las comprobaciones de estado, el script evita cambiar el tráfico y activa una reversión para mantener la disponibilidad de la aplicación.
  3. Garantizar la disponibilidad de la aplicación: Antes de conmutar el tráfico a un nuevo entorno, las comprobaciones de estado validan que el nuevo contenedor esté plenamente operativo. De este modo se evitan los tiempos de inactividad causados por despliegues incompletos o errores de aplicación.

Configuración del chequeo

healthcheck:
  test: ['CMD-SHELL', 'curl -f http://localhost || exit 1']
  interval: 30s
  timeout: 10s
  retries: 3
  start_period: 5s

Representación visual del despliegue azul y verde

He aquí un diagrama simplificado del proceso de implantación azul-verde:

+-------------------+                   +--------------------+
|                   |                   |                    |
|   Active (Blue)   |  <--- Traffic --->|    Idle (Green)    |
|                   |                   |                    |
+-------------------+                   +--------------------+
         ↑                                    ↑
         |                                    |
   Updates & Tests                     Updates & Tests
         |                                    |
         ↓                                    ↓
+-------------------+                   +--------------------+
|   New Green       |                   |   New Blue         |
|   Deployment      |                   |   Deployment       |
+-------------------+                   +--------------------+
         |                                    |
   Health Checks Passed              Health Checks Passed
         ↓                                    ↓
+-------------------+                   +--------------------+
|   Idle (Blue)     |   <--- Traffic ---|   Active (Green)   |
+-------------------+                   +--------------------+

Por qué son fundamentales los health-checks

Los health-checks son la espina dorsal de esta estrategia de despliegue azul y verde porque:

Componentes y archivos

He aquí un resumen de los archivos utilizados en esta configuración:

  1. Script de despliegue: Automatiza la creación, conmutación y reversión de entornos Azul-Verde.

abin/install.sh

#!/bin/bash

set -e  # Stop script execution on error

NGINX_CONF_PATH="./docker/nginx/active_backend.conf"
NGINX_CONTAINER="app"
ENV_FILE=".env"

build_containers() {
    echo "📦 Building Docker containers..."
    docker compose build
    echo "✅ Docker containers built successfully."
}

prepare_nginx_config() {
    if [ ! -d "./docker/nginx" ]; then
        echo "📂 Nginx directory not found. Creating it..."
        mkdir -p ./docker/nginx
        echo "✅ Nginx directory created."
    fi
}

update_nginx_config() {
    local active_color=$1
    echo "🔄 Updating Nginx configuration to route traffic to '$active_color' containers..."

    cat > "$NGINX_CONF_PATH" <<EOL
upstream app_backend {
    server $active_color:9000 max_fails=3 fail_timeout=30s;
}
EOL

    echo "📋 Copying Nginx configuration to the container..."
    docker cp "$NGINX_CONF_PATH" "$NGINX_CONTAINER:/etc/nginx/conf.d/active_backend.conf"
    echo "🔁 Reloading Nginx to apply the new configuration..."
    docker exec "$NGINX_CONTAINER" nginx -s reload >/dev/null 2>&1
    echo "✅ Nginx configuration updated and reloaded successfully."
}

wait_for_health() {
    local container_prefix=$1
    local retries=5
    local unhealthy_found
    echo "⏳ Waiting for containers with prefix '$container_prefix' to become healthy..."

    while (( retries > 0 )); do
        unhealthy_found=false

        for container_name in $(docker ps --filter "name=$container_prefix" --format "{{.Names}}"); do
            health_status=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}unknown{{end}}' "$container_name" || echo "unknown")
            if [[ "$health_status" != "healthy" ]]; then
                unhealthy_found=true
                echo "🚧 Container '$container_name' is not ready. Current status: $health_status."
            fi
        done

        if ! $unhealthy_found; then
            echo "✅ All containers with prefix '$container_prefix' are healthy."
            return 0
        fi

        echo "⏳ Retrying... ($retries retries left)"
        ((retries--))
        sleep 5
    done

    echo "❌ Error: Some containers with prefix '$container_prefix' are not healthy. Aborting deployment."
    rollback
    exit 0
}

rollback() {
    echo "🛑 Rolling back deployment. Ensuring the active environment remains intact."

    if [ -n "$PREVIOUS_COLOR" ]; then
        echo "🔄 Restoring CONTAINER_COLOR=$PREVIOUS_COLOR in .env."
        sed -i.bak "s/^CONTAINER_COLOR=.*/CONTAINER_COLOR=$PREVIOUS_COLOR/" "$ENV_FILE"
        rm -f "$ENV_FILE.bak"
        echo "✅ Restored CONTAINER_COLOR=$PREVIOUS_COLOR in .env."
    else
        echo "🚧  No previous CONTAINER_COLOR found to restore."
    fi

    if docker ps --filter "name=green" --format "{{.Names}}" | grep -q "green"; then
        echo "✅ Active environment 'green' remains intact."
        echo "🛑 Stopping and removing 'blue' containers..."
        docker compose stop "blue" >/dev/null 2>&1 || true
        docker compose rm -f "blue" >/dev/null 2>&1 || true
    elif docker ps --filter "name=blue" --format "{{.Names}}" | grep -q "blue"; then
        echo "✅ Active environment 'blue' remains intact."
        echo "🛑 Stopping and removing 'green' containers..."
        docker compose stop "green" >/dev/null 2>&1 || true
        docker compose rm -f "green" >/dev/null 2>&1 || true
    else
        echo "❌ No active environment detected after rollback. Manual intervention might be needed."
    fi

    echo "🔄 Rollback completed."
}

update_env_file() {
    local active_color=$1

    # check if .env file exists
    if [ ! -f "$ENV_FILE" ]; then
        echo "❌ .env file not found. Creating a new one..."
        echo "CONTAINER_COLOR=$active_color" > "$ENV_FILE"
        echo "✅ Created .env file with CONTAINER_COLOR=$active_color."
        return
    fi

    # backup previous CONTAINER_COLOR value
    if grep -q "^CONTAINER_COLOR=" "$ENV_FILE"; then
        PREVIOUS_COLOR=$(grep "^CONTAINER_COLOR=" "$ENV_FILE" | cut -d '=' -f 2)
        echo "♻️  Backing up previous CONTAINER_COLOR=$PREVIOUS_COLOR."
    else
        PREVIOUS_COLOR=""
    fi

    # update CONTAINER_COLOR value in .env
    if grep -q "^CONTAINER_COLOR=" "$ENV_FILE"; then
        sed -i.bak "s/^CONTAINER_COLOR=.*/CONTAINER_COLOR=$active_color/" "$ENV_FILE"
        echo "🔄 Updated CONTAINER_COLOR=$active_color in .env"
    else
        echo "CONTAINER_COLOR=$active_color" >> "$ENV_FILE"
        echo "🖋️ Added CONTAINER_COLOR=$active_color to .env"
    fi

    # remove backup file
    if [ -f "$ENV_FILE.bak" ]; then
        rm "$ENV_FILE.bak"
    fi
}

install_dependencies() {
    local container=$1
    echo "📥 Installing dependencies in container '$container'..."

    # Install Laravel dependencies
    docker exec -u root -it "$container" bash -c "composer install --no-dev --optimize-autoloader"
    docker exec -u root -it "$container" bash -c "mkdir -p database && touch database/database.sqlite"

    # Permissions setup
    docker exec -u root -it "$container" bash -c "chown www-data:www-data -R ./storage ./bootstrap ./database"
    docker exec -u root -it "$container" bash -c "chmod -R 775 ./storage ./bootstrap/cache"

    # Clear caches and run migrations
    docker exec -u root -it "$container" bash -c "php artisan cache:clear"
    docker exec -u root -it "$container" bash -c "php artisan config:clear"
    docker exec -u root -it "$container" bash -c "php artisan route:clear"
    docker exec -u root -it "$container" bash -c "php artisan view:clear"
    docker exec -u root -it "$container" bash -c "php artisan migrate --force"

    echo "✅ Dependencies installed and database initialized successfully in container '$container'."
}

deploy() {
    local active=$1
    local new=$2

    # Update .env before deploying
    update_env_file "$new"
    echo "🚀 Starting deployment. Current active environment: '$active'. Deploying to '$new'..."
    docker compose --profile "$new" up -d
    wait_for_health "$new"
    install_dependencies "$new"
    update_nginx_config "$new"
    echo "🗑️  Removing old environment: '$active'..."
    echo "🛑 Stopping '$active' containers..."
    docker compose stop $active >/dev/null 2>&1 || true
    echo "🗑️  Removing '$active' containers..."
    docker compose rm -f $active >/dev/null 2>&1 || true
    update_env_file "$new"
    echo "✅ Deployment to '$new' completed successfully."
}

get_active_container() {
    if [ -f "$ENV_FILE" ] && grep -q "CONTAINER_COLOR" "$ENV_FILE"; then
        grep "CONTAINER_COLOR" "$ENV_FILE" | cut -d '=' -f 2
    else
        echo ""
    fi
}

# Main script logic
prepare_nginx_config
build_containers

ACTIVE_COLOR=$(get_active_container)

if [ -z "$ACTIVE_COLOR" ]; then
    # if no active container found, deploy 'blue'
    echo "🟦 Initial setup. Bringing up 'blue' containers..."
    docker compose --profile blue up -d
    wait_for_health "blue"
    install_dependencies "blue"
    update_nginx_config "blue"
    update_env_file "blue"
elif [ "$ACTIVE_COLOR" == "green" ]; then
    # if the active is 'green', deploy 'blue'
    PREVIOUS_COLOR="green"
    deploy "green" "blue"
elif [ "$ACTIVE_COLOR" == "blue" ]; then
    # if the active is 'blue', deploy 'green'
    PREVIOUS_COLOR="blue"
    deploy "blue" "green"
else
    # if the active is neither 'green' nor 'blue', reset to 'blue'
    echo "🚧 Unexpected CONTAINER_COLOR value. Resetting to 'blue'..."
    PREVIOUS_COLOR=""
    docker compose --profile blue up -d
    wait_for_health "blue"
    install_dependencies "blue"
    update_nginx_config "blue"
    update_env_file "blue"
fi

echo "🎉 Deployment successful!"
  1. Configuración de Nginx: Maneja el enrutamiento de tráfico al entorno activo.

nginx/default.conf

server {
    listen 80;
    index index.php index.html;
    client_max_body_size 20M;
    root /var/www/html/public;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ .php$ {
        fastcgi_pass app_backend;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /.ht {
        deny all;
    }
}
  1. Configuración PHP-FPM: Gestiona los procesos PHP para Laravel.

php/www.conf

listen = 9000
user = www-data
group = www-data

[www]
pm = dynamic
pm.max_children = 20
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 15
  1. Dockerfile: Configura el entorno de Laravel con las dependencias necesarias.

Dockerfile

FROM php:8.2.0-fpm
WORKDIR /var/www/html
RUN apt-get update && apt-get install -y \
    curl \
    dos2unix \
    git \
    libonig-dev \
    libpng-dev \
    libxml2-dev \
    libzip-dev \
    unzip \
    zip \
    libfcgi0ldbl \
    && apt-get clean && rm -rf /var/lib/apt/lists/* \
    && docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip \
    && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
    && mkdir -p /var/www/html/storage /var/www/html/bootstrap/cache && \
    chown -R :www-data ./bootstrap/cache && \
    mkdir -p storage && \
    cd storage/ && \
    mkdir -p logs && \
    mkdir -p app && \
    mkdir -p framework/sessions && \
    mkdir -p framework/views && \
    mkdir -p framework/cache && \
    chmod -R 775 framework logs app && \
    chown -R :www-data ./framework ./logs ./app && \
    git config --global --add safe.directory '*'

COPY ./scripts/start.sh /usr/local/bin/start.sh
RUN chmod +x /usr/local/bin/start.sh
CMD ["/usr/local/bin/start.sh"]
  1. Docker Compose: Define los contenedores Blue-Green, Nginx y las configuraciones de comprobación de salud.

docker-compose.yml

services:
  blue:
    container_name: blue
    env_file:
      - .env
    profiles:
      - blue
    build:
      context: ./docker
      dockerfile: Dockerfile
    volumes:
      - ./:/var/www/html
      - ./docker/supervisor/supervisord.conf:/etc/supervisor/supervisord.conf
      - ./docker/php/www.conf:/usr/local/etc/php-fpm.d/www.conf
    healthcheck:
      test:
        [
          'CMD-SHELL',
          'SCRIPT_FILENAME=/var/www/html/public/index.php REQUEST_METHOD=GET cgi-fcgi -bind -connect 127.0.0.1:9000 || exit 1'
        ]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 5s

  green:
    container_name: green
    profiles:
      - green
    env_file:
      - .env
    build:
      context: ./docker
      dockerfile: Dockerfile
    volumes:
      - ./:/var/www/html
      - ./docker/supervisor/supervisord.conf:/etc/supervisor/supervisord.conf
      - ./docker/php/www.conf:/usr/local/etc/php-fpm.d/www.conf
    healthcheck:
      test:
        [
          'CMD-SHELL',
          'SCRIPT_FILENAME=/var/www/html/public/index.php REQUEST_METHOD=GET cgi-fcgi -bind -connect 127.0.0.1:9000 || exit 1'
        ]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 5s

  app:
    image: nginx:alpine
    container_name: app
    profiles:
      - blue
      - green
    ports:
      - '${PORT-80}:80'
    volumes:
      - ./:/var/www/html
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
    healthcheck:
      test: ['CMD', 'curl', '-f', 'http://localhost']
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 5s

Health-checks en acción

Cómo funciona

  1. Al desplegar actualizaciones:
    • Se inicia un nuevo entorno (Azul o Verde).
    • Los health-checks validan la preparación del contenedor.
  2. Si pasan:
    • El tráfico se dirige al nuevo entorno.
    • El antiguo entorno queda inactivo, listo para futuras actualizaciones.
  3. Si fallan:
    • Se desencadena un rollback, y el entorno activo permanece inalterado.

Ejemplo de reversión automatizada

El script de despliegue garantiza que se produzca una reversión cuando fallen las comprobaciones de estado. Por ejemplo:

Ejemplos:

Conclusión

Esta guía muestra cómo simular y comprender el despliegue Blue-Green con Docker en un entorno local. Al aprovechar las comprobaciones de estado, la conmutación de tráfico y las reversiones automatizadas, puede minimizar el tiempo de inactividad y garantizar la estabilidad durante los despliegues. Aunque este enfoque es simplificado, los conceptos pueden extenderse fácilmente a sistemas de producción.

Utilice esta configuración para probar y perfeccionar las estrategias de despliegue con confianza antes de escalar a producción.

Código completo en este repositorio