🎨 Sysprovider Code
Sysprovider LogoWiki
🇪🇸Hosting español para ecommerce

Migración sin downtime de servidores físicos a contenedores Docker

Actualizado el 30 de abril de 2026

Introducción

La migración de cargas de trabajo desde servidores físicos (bare-metal) a contenedores Docker representa un salto cualitativo en eficiencia operativa, escalabilidad y gestión de recursos. Sin embargo, el principal desafío técnico reside en garantizar una disponibilidad continua durante el proceso: cero ventanas de mantenimiento, cero pérdida de conexiones activas y cero impacto para los usuarios finales.

Este artículo detalla una metodología probada para realizar esta migración con downtime cero, utilizando técnicas de blue-green deployment, balanceo de carga progresivo y replicación de estado. Asumimos un escenario típico: un servidor físico ejecutando una aplicación web monolítica (PHP/MySQL o Node.js/PostgreSQL) y un balanceador Nginx como frontend.

Arquitectura Objetivo y Principios de Diseño

Antes de ejecutar cualquier comando, es crucial definir el modelo de transición. No se trata de "mover" el servidor, sino de orquestar una convivencia temporal entre el entorno legacy y el nuevo ecosistema Docker.

Principios Clave

  1. Inmutabilidad: Los contenedores serán imágenes inmutables. Cualquier cambio de configuración implicará una nueva imagen y un despliegue, no una modificación ad-hoc.
  2. Estado Externo: Bases de datos, sesiones y archivos subidos residirán fuera del contenedor (volúmenes Docker o servicios externos).
  3. Proxy Reverso como Pivote: Nginx (o HAProxy) será el punto de conmutación. Su configuración dinámica permite redirigir tráfico sin reiniciar el servicio.

Tabla Comparativa: Estado Inicial vs. Final

ComponenteServidor Físico (Estado Inicial)Contenedores Docker (Estado Final)
AplicaciónCódigo en /var/www/htmlImagen Docker en Registry privado
Base de DatosMySQL 8.0 nativoContenedor MySQL con volumen persistente
SesionesArchivos en /tmpRedis en contenedor (o servicio externo)
ArchivosDirectorio /data/uploadsVolumen Docker bind-mount a NFS/GlusterFS
ProxyNginx 1.24 en hostNginx en contenedor (o en host como punto fijo)
LogsArchivos locales en /var/logjournald + docker logs + agregador central

Nota Crítica: En esta guía, asumimos que el balanceador Nginx permanece en el servidor físico o se migra a un contenedor antes de la migración de la app, pero siempre como punto de control. Migrar el proxy después de la app añade complejidad innecesaria.

Fase 1: Preparación del Entorno Docker y Refactorización

No podemos lanzar contenedores sobre un sistema legacy sin antes contenerizar la aplicación. Esto implica crear un Dockerfile reproducible y externalizar el estado.

Paso 1.1: Análisis de Dependencias y Creación del Dockerfile

Primero, audite el servidor físico para listar todas las dependencias de tiempo de ejecución.

# En el servidor físico
php -v
php -m | grep -iE 'mysql|pdo|redis|curl'  # Módulos PHP críticos
ldd /usr/sbin/nginx | grep "=> /" | wc -l  # Librerías compartidas de Nginx

Con esa información, construya un Dockerfile multi-etapa para minimizar el tamaño final.

# Dockerfile - Aplicación PHP
# Etapa 1: Compilación (si aplica, ej: assets)

FROM composer:2.7 AS vendor

WORKDIR /app

COPY composer.json composer.lock ./

RUN composer install --no-dev --optimize-autoloader --no-interaction

# Etapa 2: Runtime

FROM php:8.2-fpm-alpine3.19

# Argumentos de build para evitar hardcodear rutas

ARG APP_ENV=production

ARG APP_DIR=/var/www/html

# Instalar extensiones necesarias (basado en el audit)

RUN docker-php-ext-install pdo_mysql mysqli opcache && \
    pecl install redis && docker-php-ext-enable redis

# Configuración de PHP para producción

COPY php.ini-production /usr/local/etc/php/php.ini

RUN echo "opcache.enable=1" >> /usr/local/etc/php/conf.d/opcache.ini && \
    echo "opcache.memory_consumption=128" >> /usr/local/etc/php/conf.d/opcache.ini

# Copiar código de aplicación (excluyendo vendor)

COPY --from=vendor /app/vendor /var/www/html/vendor

COPY src/ /var/www/html/

COPY config/ /var/www/html/config

# Volumen para uploads (bind mount externo)

VOLUME ["/var/www/html/uploads"]

WORKDIR /var/www/html

EXPOSE 9000

CMD ["php-fpm"]

¿Por qué Alpine? Reduce la superficie de ataque y el tamaño de la imagen (de ~800MB a ~150MB). La huella de memoria también es menor.

Paso 1.2: Externalización del Estado (Base de Datos y Sesiones)

No migrará la base de datos dentro del contenedor. Utilizará un contenedor MySQL separado con un volumen persistente.

# docker-compose.yml (entorno final)
version: '3.8'

services:
  db:
    image: mysql:8.0
    container_name: app_db
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS}
      MYSQL_DATABASE: ${DB_NAME}
      MYSQL_USER: ${DB_USER}
      MYSQL_PASSWORD: ${DB_PASS}
    volumes:
      - db_data:/var/lib/mysql
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql  # Script inicial
    networks:
      - app_net
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    container_name: app_session
    volumes:
      - redis_data:/data
    networks:
      - app_net

  app:
    build: .
    container_name: app_php
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    environment:
      DB_HOST: db
      DB_NAME: ${DB_NAME}
      REDIS_HOST: redis
    volumes:
      - uploads_data:/var/www/html/uploads  # Volumen compartido
    networks:
      - app_net

volumes:
  db_data:
  redis_data:
  uploads_data:

networks:
  app_net:
    driver: bridge

¿Por qué usar volúmenes nombrados en lugar de bind mounts para la DB? Los volúmenes Docker son gestionados por el demonio, ofrecen mejor rendimiento en I/O y son portables entre hosts. Para archivos subidos por usuarios (uploads), un bind mount a un sistema de archivos compartido (NFS) puede ser más adecuado si se planea escalar horizontalmente.

Fase 2: Migración de Datos en Caliente (Live Replication)

El punto más crítico. No podemos detener MySQL para hacer un dump masivo. Usaremos replicación nativa de MySQL para sincronizar la base de datos física con el contenedor.

Paso 2.1: Configurar Replicación MySQL

En el servidor físico (maestro), asegúrese de que log_bin esté activo y cree un usuario de replicación.

-- En el servidor físico (maestro)

CREATE USER 'replicator'@'%' IDENTIFIED BY 'StrongP@ssw0rd';

GRANT REPLICATION SLAVE ON *.* TO 'replicator'@'%';

FLUSH PRIVILEGES;

SHOW MASTER STATUS;  -- Anote File y Position

En el contenedor MySQL (esclavo), configure la replicación. Primero, cargue un snapshot inicial para evitar un lag enorme.

# En el servidor físico
mysqldump --all-databases --source-data=2 --single-transaction --routines --triggers --events > /tmp/initial_dump.sql

# Copie el dump al directorio init.sql en el host Docker
scp /tmp/initial_dump.sql user@docker-host:/opt/docker/init.sql

Luego, inicie el esclavo dentro del contenedor.

-- Conectarse al contenedor MySQL

CHANGE MASTER TO
  MASTER_HOST='IP_DEL_SERVIDOR_FISICO',
  MASTER_USER='replicator',
  MASTER_PASSWORD='StrongP@ssw0rd',
  MASTER_LOG_FILE='mysql-bin.000001',  -- Del SHOW MASTER STATUS
  MASTER_LOG_POS=123456789;            -- Del SHOW MASTER STATUS

START SLAVE;

SHOW SLAVE STATUS\G;

Verifique que Slave_IO_Running: Yes y Slave_SQL_Running: Yes. El lag (Seconds_Behind_Master) debe ser cercano a 0.

Paso 2.2: Sincronización de Archivos (Uploads)

Para los archivos subidos, usaremos rsync en modo continuo hasta que el delta sea mínimo.

# En el servidor físico, ejecute rsync hacia el volumen Docker
# Primero una sincronización completa
rsync -avz --delete /data/uploads/ user@docker-host:/var/lib/docker/volumes/app_uploads_data/_data/

# Luego, cada 5 segundos, sincronice cambios incrementales
watch -n 5 rsync -avz --delete /data/uploads/ user@docker-host:/var/lib/docker/volumes/app_uploads_data/_data/

Nota sobre --delete: Es seguro aquí porque el contenedor aún no está sirviendo tráfico. Una vez que el contenedor esté activo, deberá cambiar a un mecanismo bidireccional o usar un sistema de archivos compartido (como GlusterFS o NFS) para evitar inconsistencias.

Fase 3: Despliegue Progresivo con Balanceo de Carga

Aquí es donde Nginx juega su papel fundamental. Configuraremos un upstream que apunte tanto al servidor físico como al nuevo contenedor, y luego ajustaremos los pesos progresivamente.

Paso 3.1: Configuración Inicial de Nginx (Punto Fijo)

En el servidor físico, Nginx ya está configurado. Lo modificaremos para incluir dos upstreams.

# /etc/nginx/conf.d/app.conf
upstream backend_legacy {
    server 127.0.0.1:9000;  # PHP-FPM en servidor físico
}

upstream backend_docker {
    server 172.17.0.3:9000;  # IP del contenedor PHP-FPM en red bridge de Docker
}

server {
    listen 80;
    server_name app.sysprovider.com;

    # Mantener logs y config existente...
    access_log /var/log/nginx/app_access.log;
    error_log /var/log/nginx/app_error.log;

    root /var/www/html/public;
    index index.php;

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

    location ~ \.php$ {
        # Inicialmente, todo al legacy
        # set $upstream_name backend_legacy;
        # Luego cambiaremos a:
        # set $upstream_name backend_docker;

        # Usaremos una variable para controlar la conmutación
        include fastcgi_params;
        fastcgi_pass $upstream_name;
    }

    location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
}

Paso 3.2: Conmutación Gradual (Canary Release)

No haremos un cambio brusco. Usaremos un script para modificar la variable $upstream_name en caliente, o mejor aún, usaremos el módulo ngx_http_upstream_conf de Nginx Plus. Si usamos la versión Open Source, recargaremos la configuración sin downtime.

#!/bin/bash
# Script: switch_traffic.sh
# Uso: ./switch_traffic.sh [legacy|docker|50-50]

ACTION=$1

CONFIG_FILE="/etc/nginx/conf.d/app.conf"

case $ACTION in
  legacy)
    sed -i 's/set \$upstream_name.*/set $upstream_name backend_legacy;/' $CONFIG_FILE
    ;;
  docker)
    sed -i 's/set \$upstream_name.*/set $upstream_name backend_docker;/' $CONFIG_FILE
    ;;
  50-50)
    # Dividir tráfico usando split_clients (necesita módulo http_split_clients)
    # Alternativa: balanceo round-robin con pesos iguales
    cat > /etc/nginx/conf.d/upstream_weights.conf << EOF
upstream backend_mixed {
    server 127.0.0.1:9000 weight=50;
    server 172.17.0.3:9000 weight=50;
}

EOF
    sed -i 's/set \$upstream_name.*/set $upstream_name backend_mixed;/' $CONFIG_FILE
    ;;
  *)
    echo "Uso: $0 {legacy|docker|50-50}"
    exit 1
    ;;
esac

# Recarga sin downtime (no interrumpe conexiones activas)
nginx -s reload
echo "Tráfico conmutado a: $ACTION"

¿Por qué nginx -s reload es seguro? El proceso maestro lee la nueva configuración, inicia nuevos procesos workers y luego envía una señal a los workers antiguos para que terminen de procesar las peticiones actuales y mueran. No hay corte de servicio.

Paso 3.3: Monitoreo de la Transición

Durante la fase 50-50, supervise métricas clave para detectar anomalías.

# Monitorear logs de error en tiempo real
tail -f /var/log/nginx/error.log | grep -E 'upstream|timeout|error'

# Comparar rendimiento con curl
while true; do
  echo "=== $(date) ==="
  curl -o /dev/null -s -w "Legacy: %{time_total}s\n" http://127.0.0.1:9000/health.php
  curl -o /dev/null -s -w "Docker: %{time_total}s\n" http://172.17.0.3:9000/health.php
  sleep 2
done

Si todo es estable durante 15-30 minutos, puede conmutar el 100% del tráfico al contenedor Docker.

Fase 4: Validación y Corte Definitivo

Una vez que el tráfico fluye completamente hacia el contenedor, debemos asegurarnos de que el servidor físico pueda ser retirado sin afectar el servicio.

Paso 4.1: Verificar Persistencia de Sesiones

Las sesiones ahora deben vivir en Redis. Verifique que los usuarios no pierdan su sesión al ser redirigidos.

// health.php (dentro del contenedor)
<?php
$redis = new Redis();
$redis->connect('redis', 6379);
$redis->set('test_key', 'Docker is serving traffic');
echo "Redis OK: " . $redis->get('test_key');
?>

También verifique que las transacciones de base de datos se escriban correctamente.

# Desde el contenedor, insertar un registro de prueba
docker exec -it app_db mysql -u root -p -e "INSERT INTO app.logs (message) VALUES ('Migration test at $(date)');"

Paso 4.2: Detener Replicación y Promover Esclavo

Cuando esté seguro de que el contenedor es el nuevo maestro, detenga la replicación y promuévalo.

-- En el contenedor MySQL (esclavo)

STOP SLAVE;

RESET SLAVE ALL;  -- Elimina configuración de replicación

-- Ahora es un maestro independiente

SHOW MASTER STATUS;

Luego, en el servidor físico, puede detener MySQL de forma segura.

systemctl stop mysql
systemctl disable mysql

Paso 4.3: Pruebas de Rollback

Antes de retirar el servidor físico, prepare un plan de rollback rápido. Si algo falla, el script switch_traffic.sh legacy debe restaurar el tráfico al servidor físico en segundos.

# Simular un fallo en el contenedor
docker stop app_php

# Verificar que el health check de Nginx falla
curl -I http://app.sysprovider.com/health.php  # Debe dar 502

# Ejecutar rollback
./switch_traffic.sh legacy
curl -I http://app.sysprovider.com/health.php

¿Necesitas ayuda?Son dos de nuestros técnicos, Agustín y Mikel, y están disponibles para resolver cualquier problema.

Hablar con ellos ahora
Agustín y Mikel