Migrar de DigitalOcean a Hetzner
(isayeter.com)- Al mover una infraestructura de producción de $1,432 al mes a un servidor dedicado de $233 al mes, se mantuvo la continuidad del servicio sin tiempo de inactividad, incluso cambiando el sistema operativo
- Se reprodujo la misma configuración de 30 bases de datos MySQL y 34 hosts virtuales de Nginx, además de GitLab EE, Neo4J, Supervisor y Gearman, y la migración se completó con replicación en tiempo real y sincronización incremental final
- La clave de la migración de bases de datos fue la combinación de procesamiento paralelo con mydumper·myloader y replicación de MySQL; también se corrigieron problemas de esquema
sysy permisos surgidos al pasar de MySQL 5.7 a 8.0 - El cutover se realizó en este orden: reducción del TTL de DNS, cambio del Nginx del servidor antiguo a proxy inverso y modificación masiva de los registros A, de modo que las solicitudes al IP anterior durante la propagación de DNS se reenviaban al servidor nuevo
- Como resultado, se logró un ahorro mensual de $1,199, un ahorro anual de $14,388, mejoras en CPU, memoria y almacenamiento, y 0 minutos de inactividad
Contexto de la migración
- Al operar una empresa de software en Turquía, la inflación acelerada y la debilidad de la lira turca incrementaron fuertemente la carga de los costos de infraestructura en dólares
- El costo del servidor existente en DigitalOcean era de $1,432 al mes, con una configuración de 192 GB de RAM, 32 vCPU, 600 GB SSD, 2 volúmenes de bloque de 1 TB y respaldos incluidos
- El nuevo destino fue un servidor dedicado Hetzner AX162-R, con AMD EPYC 9454P de 48 núcleos y 96 hilos, 256 GB DDR5 y 1.92 TB NVMe Gen4 en RAID1
- El costo mensual bajó a $233, con un ahorro mensual de $1,199 y un ahorro anual de $14,388
- No había quejas sobre la confiabilidad del servidor anterior ni sobre la experiencia de desarrollo, pero para cargas steady-state la relación precio-rendimiento ya no resultaba razonable
Entorno operativo anterior
- El stack operativo no era un entorno de prueba simple, sino una configuración real de producción
- 30 bases de datos MySQL, con un total de 248 GB de datos
- 34 hosts virtuales de Nginx distribuidos en varios dominios
- GitLab EE con 42 GB de respaldos incluidos
- Neo4J Graph DB operando con un volumen de 30 GB
- Supervisor administrando decenas de workers en segundo plano
- Uso de la cola de trabajos Gearman
- Operación de apps móviles en vivo para cientos de miles de usuarios
- El sistema operativo del servidor anterior era CentOS 7, ya fuera de soporte
- El nuevo sistema operativo del servidor es AlmaLinux 9.7, una distribución compatible con RHEL 9 y una opción sucesora natural de CentOS
- Esta migración no solo redujo costos, sino que también permitió salir de un sistema operativo que llevaba años sin actualizaciones de seguridad
Estrategia sin tiempo de inactividad
- No se aceptó un método simple de cambiar DNS y reiniciar servicios; la migración sin downtime se realizó con un procedimiento de 6 etapas
-
Etapa 1: instalación de todo el stack en el servidor nuevo
- Instalación de Nginx compilado desde código fuente con las mismas flags que en el servidor anterior
- PHP se instaló mediante el repo de Remi y se aplicaron los mismos archivos de configuración
.inidel servidor anterior - Instalación de MySQL 8.0, Neo4J Graph DB, GitLab EE, Node.js, Supervisor y Gearman, configurados para comportarse igual que antes
- Antes de tocar los registros DNS, todos los servicios se dejaron funcionando igual que en el servidor anterior
- Los certificados SSL se manejaron copiando por rsync todo el directorio
/etc/letsencrypt/del servidor anterior - Una vez que todo el tráfico se trasladó al nuevo servidor, se ejecutó
certbot renew --force-renewalpara renovar en lote los certificados de forma forzada
-
Etapa 2: replicación de archivos web con rsync
- Se replicó por SSH todo el directorio
/var/www/html, unos 65 GB y 1.5 millones de archivos, usandorsync - Se realizó verificación de integridad con la opción
--checksum - Justo antes del cutover también se hizo una sincronización incremental final para reflejar los archivos modificados
- Se replicó por SSH todo el directorio
-
Etapa 3: replicación maestro-esclavo de MySQL
- En lugar de bajar las bases de datos para exportar y restaurar, se configuró replicación en tiempo real
- El servidor anterior se dejó como maestro y el nuevo como esclavo de solo lectura
- La carga inicial masiva se hizo con
mydumper, y luego la replicación comenzó desde la posición exacta del binlog registrada en los metadatos del dump - Hasta el momento del cutover, ambas bases de datos se mantuvieron sincronizadas en tiempo real
-
Etapa 4: reducción del TTL de DNS
- Se llamó por script a la API de DNS de DigitalOcean para reducir el TTL de todos los registros A/AAAA de 3600 segundos a 300 segundos
- Los registros MX y TXT no se modificaron
- Se excluyeron porque cambiar el TTL de registros de correo podía provocar problemas de entregabilidad
- Después de esperar 1 hora para que el TTL anterior expirara globalmente, quedó todo listo para hacer el cutover dentro de 5 minutos
-
Etapa 5: convertir el Nginx del servidor anterior en proxy inverso
- Un script en Python analizó los bloques
server {}en las 34 configuraciones de sitios Nginx - La configuración anterior se respaldó y se reemplazó por configuración de proxy apuntando al servidor nuevo
- Así, incluso durante la propagación de DNS, las solicitudes que llegaban al IP anterior se reenviaban silenciosamente al servidor nuevo
- Desde la perspectiva del usuario, no hubo interrupción visible
- Un script en Python analizó los bloques
-
Etapa 6: cutover de DNS y apagado del servidor anterior
- Un script en Python llamó a la API de DigitalOcean para cambiar todos los registros A al nuevo IP en cuestión de segundos
- El servidor anterior se mantuvo durante 1 semana como cold standby antes de apagarse
- Durante todo el proceso, el servicio siguió respondiendo de forma directa o a través del proxy, por lo que no hubo un periodo sin disponibilidad
Migración de MySQL
- La parte más compleja de todo el trabajo fue la migración de MySQL
-
Volcado de datos
- Se usó mydumper en lugar de
mysqldump - Aprovechando los 48 núcleos de CPU del nuevo servidor para exportación/importación paralela, un trabajo que con
mysqldumpde un solo hilo habría tomado días se redujo a unas horas - Entre las principales opciones utilizadas estaban
--threads 32,--compress,--trx-consistency-only,--skip-definer,--chunk-filesize 256 - El archivo
metadatadel dump principal registró la posición del binlog en el momento del snapshotFile: mysql-bin.000004Position: 21834307
- Esos valores se usaron después como punto inicial de la replicación
- Se usó mydumper en lugar de
-
Transferencia del dump
- Tras completar el dump, se transfirió al nuevo servidor mediante rsync sobre SSH
- Se enviaron en total 248 GB de chunks comprimidos
- La opción
--compressdemydumperayudó a mejorar la velocidad de transferencia por red
-
Carga de datos
- Se usó
myloader - Las opciones principales fueron
--threads 32,--overwrite-tables,--ignore-errors 1062,--skip-definer
- Se usó
-
Problemas en el cambio de MySQL 5.7 a 8.0
- Debido al entorno con CentOS 7, el servidor anterior seguía en MySQL 5.7
- Antes de migrar, se verificó con
mysqlcheck --check-upgradeque los datos fueran compatibles con MySQL 8.0, y el resultado fue sin problemas - En el nuevo servidor se instaló la versión más reciente de MySQL 8.0 Community
- Los tiempos de ejecución de consultas se redujeron de forma significativa en todo el proyecto; en el texto original esto se atribuye al optimizador mejorado y las mejoras en InnoDB de MySQL 8.0
- Aun así, surgieron problemas por el salto de versión
- Después del import, la estructura de columnas de la tabla
mysql.usertenía 45 columnas en vez de las 51 esperadas - Como resultado, faltaba
mysql.infoschemay hubo fallas en la autenticación de usuarios
- Después del import, la estructura de columnas de la tabla
- El primer intento de corrección usó los siguientes comandos
systemctl stop mysqldmysqld --upgrade=FORCE --user=mysql &
- El primer intento falló con el error
ERROR: 'sys.innodb_buffer_stats_by_schema' is not VIEW - La causa fue que el esquema sys se había importado como tabla normal en lugar de vista
- La solución fue ejecutar
DROP DATABASE sys;y luego repetir la actualización - Después de eso, se completó con normalidad
Configuración de replicación de MySQL
- Una vez terminada la carga del dump en ambos servidores, el nuevo servidor se configuró como réplica del anterior
- En la sentencia
CHANGE MASTER TOse especificaron el IP del servidor anterior, el usuario de replicación, el puerto 3306,MASTER_LOG_FILE='mysql-bin.000004'yMASTER_LOG_POS=21834307 - Después se ejecutó
START SLAVE; - Casi de inmediato la replicación se detuvo por error 1062 Duplicate Key
- La causa fue que el dump se hizo en dos partes y, entre una y otra, hubo escrituras en algunas tablas; así, el dump importado y la reproducción del binlog intentaban insertar duplicadamente la misma fila
- Para resolverlo, se aplicó esta configuración
SET GLOBAL slave_exec_mode = 'IDEMPOTENT';START SLAVE;
- El modo IDEMPOTENT omite silenciosamente errores de clave duplicada y de fila faltante
- Todas las bases de datos críticas se sincronizaron sin errores, y en pocos minutos
Seconds_Behind_Masterbajó a 0
Verificación previa al cutover
- Antes de tocar los registros DNS, era necesario verificar que todos los servicios funcionaran correctamente en el nuevo servidor
- El método de validación consistió en modificar temporalmente el archivo
/etc/hostsde la máquina local para mapear los dominios al IP del nuevo servidor - Así, el navegador y Postman enviaban solicitudes al servidor nuevo, mientras que los usuarios externos seguían conectándose al servidor anterior
- Se revisaron los endpoints de API, el panel de administración y el estado de respuesta de cada servicio
- Tras confirmar todo, se procedió al cutover real
Problema con el privilegio SUPER
- Después de que la replicación maestro-esclavo quedó completamente sincronizada, se confirmó que en el nuevo servidor las sentencias INSERT tenían éxito aunque
read_only = 1 - La causa fue que todos los usuarios PHP de las aplicaciones tenían asignado el privilegio SUPER
- En MySQL, el privilegio SUPER omite
read_only - En el resultado de
SHOW GRANTS FOR 'some_db_user'@'localhost';se confirmó que el privilegioSUPERestaba incluido - Se ejecutó repetidamente
REVOKE SUPER ON *.* FROM 'some_db_user'@'localhost';sobre un total de 24 usuarios de aplicación - Después se ejecutó
FLUSH PRIVILEGES; - A partir de ese momento,
read_only = 1bloqueó correctamente las escrituras de los usuarios de aplicación mientras la replicación siguió funcionando
Preparación de DNS
- Todos los dominios se administraban con DigitalOcean DNS, con los nameservers conectados desde GoDaddy
- La reducción del TTL se automatizó con scripts contra la API de DigitalOcean
- El cambio se limitó solo a los registros A y AAAA
- Los registros MX y TXT no se tocaron
- Se excluyó el cambio de TTL de registros relacionados con correo por posibles problemas de entregabilidad con Google Workspace
- Tras esperar 1 hora para la expiración del TTL anterior, quedó todo listo para el cutover
Conversión del Nginx del servidor anterior a proxy inverso
- En lugar de editar manualmente 34 archivos de configuración, se hizo una conversión automática con un script en Python
- El script analizó los bloques
server {}de todos los archivos de configuración y reemplazó el bloque principal de contenido por configuración de proxy - Las configuraciones originales se respaldaron como archivos
.backup - En el ejemplo de configuración se aplicaron
proxy_pass https://NEW_SERVER_IP;,proxy_set_header Host $host;,proxy_set_header X-Real-IP $remote_addr;,proxy_read_timeout 150; - La opción clave fue
proxy_ssl_verify off- Porque el certificado SSL del servidor nuevo era válido para el dominio, pero no para la dirección IP
- Como ambos extremos estaban bajo control, aquí se consideró aceptable desactivar la verificación
Procedimiento de cutover
- Justo antes del cutover, la condición era tener el retraso de replicación en
Seconds_Behind_Master: 0y el proxy inverso listo - La secuencia de ejecución fue la siguiente
- En el servidor nuevo:
STOP SLAVE; - En el servidor nuevo:
SET GLOBAL read_only = 0; - En el servidor nuevo:
RESET SLAVE ALL; - En el servidor nuevo:
supervisorctl start all - En el servidor anterior:
nginx -t && systemctl reload nginxpara activar el proxy - En el servidor anterior:
supervisorctl stop all - En la Mac local:
python3 do_cutover.pypara cambiar todos los registros A del DNS al IP del nuevo servidor - Espera de propagación de aproximadamente 5 minutos
- En el servidor anterior: comentar todas las entradas de crontab
- En el servidor nuevo:
- El script de cutover de DNS llamaba a la API de DigitalOcean para cambiar todos los registros A en unos 10 segundos
Trabajo adicional después del cutover
- Tras completar la migración, se detectó que varios webhooks de proyectos de GitLab seguían apuntando al IP del servidor anterior
- Se escribió y aplicó un script que recorría todos los proyectos mediante la API de GitLab y actualizaba en lote los webhooks
Resultado final
- El costo mensual bajó de $1,432 a $233
- El ahorro anual fue de $14,388
- También se consiguió un servidor más potente en rendimiento
- La CPU pasó de 32 vCPU a 96 CPU lógicas
- La RAM pasó de 192 GB a 256 GB DDR5
- El almacenamiento cambió de una configuración mixta de ~2.6 TB a 2 TB NVMe RAID1
- El tiempo de inactividad fue de 0 minutos
- El tiempo total de toda la migración fue de aproximadamente 24 horas
- No hubo impacto para los usuarios
Lecciones clave
- La replicación de MySQL es la herramienta clave para migraciones sin downtime
- La idea es configurarla temprano, dejar que alcance el ritmo y luego hacer el cutover
- Es indispensable revisar los permisos de usuarios MySQL antes de migrar
- Si existe el privilegio SUPER, se puede omitir
read_onlyy el esclavo deja de ser realmente de solo lectura
- Si existe el privilegio SUPER, se puede omitir
- Es importante automatizar con scripts las actualizaciones de DNS, cambios de configuración de Nginx y correcciones de webhooks
- Manejar manualmente más de 34 sitios toma mucho tiempo y aumenta la posibilidad de errores
- La combinación mydumper + myloader es mucho más rápida que
mysqldumpen datasets grandes- Con dump y restauración paralelos de 32 hilos, un trabajo de varios días se redujo a unas horas
- En cargas steady-state, un proveedor cloud puede resultar caro, y un servidor dedicado puede ofrecer mayor rendimiento a menor costo
Scripts en GitHub
- Todos los scripts en Python usados en la migración se publicaron en GitHub
- Lista de scripts incluidos
do_list_domains_ttl.py- Consulta los registros A, el IP y el TTL de todos los dominios en DigitalOcean
do_ttl_update.py- Reduce en lote el TTL de todos los registros A/AAAA a 300 segundos
do_to_hetzner_bulk_dns_records_import.py- Migra todas las zonas DNS de DigitalOcean a Hetzner DNS
do_cutover_to_new_ip.py- Cambia todos los registros A del IP del servidor anterior al del nuevo servidor
nginx_reverse_proxy_update.py- Convierte toda la configuración de sitios nginx a configuración de proxy inverso
mysql_compare.py- Compara el conteo de filas de todas las tablas entre dos servidores MySQL
final_gitlab_webhook_update.py- Actualiza todos los webhooks de proyectos GitLab al IP del nuevo servidor
mydumper- Biblioteca de mydumper
- Todos los scripts admiten el modo
DRY_RUN = Truepara previsualizar con seguridad antes de aplicar cambios reales
1 comentarios
Comentarios en Hacker News
Hace unos meses moví dos servidores de Linode y DO a Hetzner, y logré una gran reducción de costos sin cambiar demasiado. Lo más impresionante fue que era un stack caótico con decenas de sitios, distintos lenguajes, librerías antiguas, MySQL y Redis todo mezclado. Pero Claude Code migró todo, y cuando faltaban librerías incluso reescribió parte del código para resolverlo. Ahora este tipo de migraciones complejas son mucho más fáciles, así que creo que en el futuro habrá mucha más movilidad entre proveedores
Estoy planeando una migración de AWS a Hetzner. Amazon a veces cobra 20 veces más que la competencia, te empuja a compromisos a largo plazo para conseguir precios medio decentes, y además hace que sacar datos sea carísimo, lo cual me parece muy hostil hacia el cliente. Uno pensaría que las tarifas de egress están para encerrarte, pero en realidad terminan presionando para que, si mueves una parte a la competencia, acabes moviendo todo. Aun así, en mi caso la migración es un poco más fácil porque no construí la plataforma encima de servicios exclusivos de Amazon
Cada vez que veo publicaciones así, me llama la atención que casi nadie hable de redundancia o balanceadores de carga. Si se cae un solo servidor, pueden caerse varios servicios al mismo tiempo, y me pregunto si de verdad les parece aceptable. Puede que ahorren dinero, pero quizá terminen gastando más en tiempo de mantenimiento y futuros dolores de cabeza
En lithus.eu hemos migrado clientes con frecuencia desde varias nubes hacia Hetzner. Normalmente armamos configuraciones multiservidor, a veces multi-AZ, y distribuimos cargas con Kubernetes para dar HA. En un solo nodo Kubernetes puede ser demasiado, pero con varios nodos ya tiene mucho más sentido. Para backups usamos Velero junto con respaldos a nivel aplicación; por ejemplo, en Postgres hacemos backup de WAL para tener PITR. Los datos con estado se dejan al menos en dos nodos para garantizar HA. En rendimiento también suele ir mejor bare metal, y muchas veces vimos latencias a la mitad frente a AWS. Creo que la causa no es tanto la virtualización en sí, sino factores alrededor como NVMe, menor latencia de red y menos cache contention. Dejé más detalles en un post anterior de HN
Este artículo fue bastante duro de leer. Se sentía como si Claude hubiera hecho la migración y luego yo estuviera leyendo un reporte escrito por Claude. Si realmente ahorraron así gracias a un LLM, genial, pero si lo vas a publicar, mínimo debería estar editado para quitar repeticiones y esa forma de escribir de LLM
Creo que hay que tener cuidado con Hetzner. Antes me gustaba mucho, pero hace poco me fui. Nos apagaron unas 30 VM que usábamos en nuestro pipeline de CI/CD por una sola disputa de cobro de 36 dólares. Les mandamos comprobantes de pago completo, incluso registros del banco, y ni así quisieron revisarlo. Mientras tratábamos de contactarlos con urgencia, igual terminaron bloqueando todo acceso. Ahora nos movimos a Scaleway
Hace unos meses, cuando buscaba una alternativa a AWS para un pequeño SaaS side project, al principio consideré seriamente Hetzner por el ahorro de costos y por apoyar cloud europeo. Estaba dispuesto a aceptar tener que hacer más cosas manualmente, pero al final lo que me frenó fue la reputación de IP. Una de las reglas del firewall administrado de AWS que usamos en la empresa bloqueaba muchas IP de Hetzner, quizá incluso todas, y en mi laptop de trabajo tampoco podía abrir sitios alojados en IP de Hetzner por políticas de TI. Puede que usando algo como Cloudflare eso pese menos, pero también vi comentarios de que la protección DDoS es floja. Al final elegí DO App Platform en región de la UE, y la opción de base de datos administrada también fue una gran ventaja
Compartir este tipo de experiencia de migración me pareció bastante útil, y se agradece. Yo veo la comparación entre DO y Hetzner como el trade-off entre abrir DoorDash o UberEats y cocinar la cena tú mismo. Incluso la proporción de costos se siente parecida. Yo trabajo con las tres grandes nubes y con on-prem, pero para tareas pequeñas o pruebas de PoC todavía sigo yendo a la consola de DigitalOcean. Esa comodidad de tener un servidor o bucket listo con unos cuantos clics, defaults sensatos y backups con una sola casilla sí tiene valor cuando consideras el costo del tiempo
Me quedé con la duda de cómo hacen los backups de la DB. Quería saber si tienen replica o standby, o si solo hacen backups por hora. En una configuración de servidor único como esa, una falla de hardware como un SSD puede parar la app de inmediato, y especialmente si muere el SSD, imagino que podrían pasar horas o días antes de volver a levantar todo
La imagen meme del encabezado la había hecho yo. La había puesto en este artículo, y me dio gusto verla usada dos veces