Migración sin downtime de servidores físicos a contenedores Docker
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
- 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.
- Estado Externo: Bases de datos, sesiones y archivos subidos residirán fuera del contenedor (volúmenes Docker o servicios externos).
- 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
| Componente | Servidor Físico (Estado Inicial) | Contenedores Docker (Estado Final) |
|---|---|---|
| Aplicación | Código en /var/www/html | Imagen Docker en Registry privado |
| Base de Datos | MySQL 8.0 nativo | Contenedor MySQL con volumen persistente |
| Sesiones | Archivos en /tmp | Redis en contenedor (o servicio externo) |
| Archivos | Directorio /data/uploads | Volumen Docker bind-mount a NFS/GlusterFS |
| Proxy | Nginx 1.24 en host | Nginx en contenedor (o en host como punto fijo) |
| Logs | Archivos locales en /var/log | journald + 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
