Toda la evidencia
AWSMigrationHigh AvailabilityAuroraCloudFrontTerraform

Hecho para seguir funcionando: alta disponibilidad para un WordPress Multisite de 15.000 usuarios

Trasladamos un WordPress Multisite de 15.000 usuarios y con mucha escritura desde un servicio de WP gestionado a un stack de alta disponibilidad en AWS que operamos de principio a fin. El servicio gestionado era más barato, y todavía lo es, pero ejecutaba la plataforma como un único entorno: cuando una pieza fallaba, el sitio se caía. En AWS cada capa se ejecuta en al menos dos zonas de disponibilidad, de modo que perder un servidor, la instancia principal de la base de datos o una zona entera ya no deja la plataforma fuera de línea.

Servicio de WP gestionadoAlta disponibilidad en AWS
TopologíaEntorno únicoCada capa en dos o más zonas de disponibilidad
Fallo del servidor webCaída hasta la recuperaciónSustituido automáticamente, sin downtime
Fallo de la base de datosCaída hasta la restauraciónSe promueve una réplica, breve pausa
Fallo de una zonaCaídaLa otra zona sigue sirviendo
ParchesVentanas de mantenimientoProgresivos, sin ventana de mantenimiento
Picos de tráficoLimitados por el planAuto Scaling y réplicas de lectura
Coste mensualMás bajoMás alto

Contexto

La plataforma es un WordPress Multisite en subdirectorios con unos 15.000 usuarios repartidos en unos 40 subsitios. Tiene mucha escritura: la mayor parte del tráfico son personas autenticadas que publican, editan y suben archivos, así que la caché de página completa ayuda poco y casi todas las peticiones de administración llegan a PHP y a la base de datos.

El servicio de WP gestionado la hacía funcionar bien en el día a día, y por el precio era difícil de superar. El problema era la forma de la configuración: un solo entorno, sin failover automático. Un servidor caído, un problema en la base de datos o la caída de una zona significaban que el sitio quedaba fuera de servicio hasta que el proveedor lo restauraba, y aplicar parches significaba ventanas de mantenimiento.

Para una plataforma en la que miles de personas publican cada día, cada hora de caída es trabajo perdido, confianza perdida y una cola de soporte llena. Decidimos que la resiliencia merecía pagarse, y que queríamos tenerla en nuestras manos: un único stack de alta disponibilidad, definido en Terraform, que operamos y podemos explicar de principio a fin.

Restricciones

  • Ningún punto único de fallo. Cada capa debe sobrevivir a la pérdida de una instancia o de una zona de disponibilidad sin que tenga que intervenir nadie.
  • Ninguna parada planificada después de la migración. Los despliegues, los parches del sistema operativo y el mantenimiento de la base de datos se hacen mientras el sitio está sirviendo.
  • Ninguna pérdida de datos. Cada entrada, usuario y archivo subido de cada subsitio tenía que llegar intacto, incluido un historial de medios de hace años.
  • Una migración corta y planificada. Una sola ventana de mantenimiento de cuatro horas un sábado por la noche, con una vía de rollback al servicio de WP gestionado hasta que el DNS se estabilizara.
  • Sin SSH ni claves de larga duración en los hosts. Todo el acceso por shell pasa por SSM Session Manager y queda registrado.
  • Todo en Terraform. Si no está en el código, no se puede reconstruir en otra zona.
  • Cambios mínimos en el propio WordPress. La aplicación solo incorporó lo que la infraestructura necesitaba: WP Offload Media, un drop-in de caché de objetos para Redis y un drop-in de base de datos para separar lecturas y escrituras.

Qué significa aquí alta disponibilidad

La alta disponibilidad no es una sola funcionalidad. Es la respuesta a una lista de fallos concretos, así que primero escribimos esa lista y diseñamos cada capa en función de ella.

Qué pasa cuando algo falla: entorno único frente a alta disponibilidad en AWS, en cinco escenarios de fallo

En un entorno único, cualquier fallo de un servidor o de la base de datos se convierte en una caída, por bueno que sea el proveedor. El fallo de una zona es poco habitual pero real, y tira de golpe todo lo que hay en esa zona. Todo lo que hay en la arquitectura siguiente existe para convertir esas filas en «sin downtime».

Arquitectura

Unos servidores web sin estado se sitúan entre un edge compartido y un estado compartido, y cada capa con estado mantiene una segunda copia en otra zona de disponibilidad. Cualquier instancia puede morir, y la siguiente arranca desde el mismo launch template, monta el mismo código y habla con los mismos datos.

Arquitectura: CloudFront con orígenes S3 y ALB, un Auto Scaling group multi-AZ sobre EFS, RDS Proxy delante de Aurora y ElastiCache
  • Edge: CloudFront, que no tiene ninguna zona única que perder, con dos orígenes: el ALB para las páginas dinámicas y wp-admin, y S3 para los medios descargados. Una CloudFront Function reescribe las rutas del Multisite a la entrada.
  • Balanceador de carga: un ALB que abarca varias zonas de disponibilidad, comprueba la salud de cada instancia y está protegido por AWS WAF.
  • Capa web: un Auto Scaling group de instancias Apache + PHP repartidas entre zonas. Una instancia que no supera la comprobación de salud se sustituye automáticamente. Sin key pair ni puerto 22: el acceso por shell es solo por SSM Session Manager.
  • Código: el núcleo de WordPress, los temas y los plugins están en EFS, que almacena los datos entre zonas y se monta a través de un access point, de modo que todas las instancias sirven la misma versión.
  • Archivos subidos: WP Offload Media escribe los medios nuevos directamente en S3, así que ningún archivo subido vive en una instancia que pueda desaparecer.
  • Base de datos: Aurora MySQL con el escritor y los lectores en zonas distintas, detrás de RDS Proxy. Si el escritor falla, se promueve un lector.
  • Caché de objetos: ElastiCache for Redis con una réplica en una segunda zona y failover automático.
  • Todo lo anterior: Terraform, desde la VPC hasta el código de la CloudFront Function.

Decisiones clave y compromisos

Cada decisión siguiente o bien elimina un punto único de fallo o bien mantiene la plataforma utilizable mientras se sustituye una pieza que ha fallado.

Instancias sin estado: el código en EFS, los medios en S3

Una instancia que guarda algo único es un punto único de fallo, por muchas copias que se ejecuten. Por eso en las instancias no vive nada único: el código viene de EFS, los archivos subidos van a S3, la caché de objetos está en Redis y los datos en Aurora. Una instancia es solo un launch template más un montaje, y eso es lo que permite al Auto Scaling group sustituir una en minutos sin que nadie tenga que decidir si importaba.

  • Compromiso: el código se lee por la red. Que PHP lea miles de archivos desde EFS es más lento que leerlos del disco local. OPcache absorbe la mayor parte, con las comprobaciones de timestamp relajadas para que PHP no pregunte a EFS en cada petición si cada archivo ha cambiado; los despliegues vacían la caché.

Separación de lecturas y escrituras con RDS Proxy y Aurora

WordPress asume una sola base de datos. Mantuvimos el escritor para las escrituras y movimos las lecturas a las réplicas de Aurora, usando el drop-in LudicrousDB para enrutar cada consulta: las escrituras al endpoint por defecto de RDS Proxy y las lecturas a su endpoint de solo lectura, que las reparte entre los lectores.

Separación de lecturas y escrituras: LudicrousDB envía las escrituras y las lecturas posteriores a una escritura al endpoint por defecto del proxy, y el resto de lecturas al endpoint de solo lectura

El proxy se gana su sitio por partida doble, y la primera razón es la disponibilidad. Durante un failover del escritor mantiene las conexiones de los clientes mientras se promueve un lector, de modo que PHP ve una pausa corta en lugar de una ráfaga de errores de base de datos. En el día a día, además, agrupa conexiones: PHP abre una por petición, así que cada escalado horizontal multiplicaba las conexiones en Aurora, y el proxy lo absorbe.

  • Compromiso: retraso de las réplicas. Un usuario que guarda una entrada y recarga inmediatamente no puede leer una réplica desactualizada. Cuando una petición ya ha escrito, el drop-in envía el resto de lecturas de esa petición al escritor.
  • Compromiso: coste y pinning. RDS Proxy se factura por vCPU de la base de datos, y algunas sentencias a nivel de sesión fijan un cliente a una sola conexión, lo que reduce el agrupamiento. Por eso la métrica de pinning está en el dashboard.

Las tres decisiones siguientes actúan sobre la misma petición, una tras otra, mientras atraviesa el edge.

Una petición a través del edge: reescritura con la CloudFront Function, comprobación de WAF en el ALB y mod_remoteip restaurando la IP del cliente

Reescritura de URI con una CloudFront Function para el Multisite

En un Multisite en subdirectorios, cada subsitio sirve los mismos archivos del núcleo bajo su propio prefijo: /site-a/wp-includes/js/jquery.js y /site-b/wp-includes/js/jquery.js son el mismo archivo. Si no se hace nada, CloudFront guarda en caché una copia por subsitio y sus cache behaviours basados en rutas no pueden hacerlas coincidir.

Una CloudFront Function en la viewer request elimina el prefijo del subsitio de las rutas de los recursos del núcleo antes de consultar la caché. Ahora un solo objeto en caché sirve a todos los subsitios, y los behaviours estáticos coinciden sin problemas. Elegimos una CloudFront Function en lugar de Lambda@Edge porque esto es una reescritura de cadenas: se ejecuta en menos de un milisegundo en el edge, cuesta una fracción y no necesita ningún despliegue regional.

También ayuda a la disponibilidad. Cada petición servida desde la caché del edge no llega nunca al origen, así que un pico de tráfico sobre recursos estáticos recae en CloudFront y no en PHP.

Lista de acceso al ALB con WAF

El ALB tiene que ser público para que CloudFront pueda llegar a él, lo que significa que cualquiera podría saltarse la CDN y atacarlo directamente. Un web ACL de WAF en el ALB bloquea por defecto y solo permite las peticiones que llevan la cabecera secreta de origen de CloudFront, más un pequeño IP set para hacer pruebas directas desde la oficina.

La alternativa era un security group que hiciera referencia a la managed prefix list de CloudFront. Esa lista cuenta como decenas de reglas contra la cuota del security group, y no puede expresar «esta cabecera o esta IP». WAF sí puede, y registra cada bloqueo, de modo que una petición rechazada es fácil de explicar.

mod_remoteip para obtener la IP real del cliente

Detrás de CloudFront y el ALB, Apache ve la dirección privada del ALB como cliente. Eso rompe los logs de acceso, la limitación de intentos de inicio de sesión, las comprobaciones de spam en los comentarios y cualquier otra cosa que dependa de la IP.

mod_remoteip lee X-Forwarded-For y lo recorre hacia atrás a través de los proxies en los que confiamos: el rango de la VPC para el ALB y los rangos publicados de CloudFront. La primera dirección que no es de confianza es el cliente real. Es importante confiar solo en proxies conocidos; confiar ciegamente en la cabecera permitiría a cualquiera falsificar su IP.

Qué cuesta la alta disponibilidad

La alta disponibilidad en AWS cuesta más cada mes que el servicio de WP gestionado, y lo sabíamos desde el principio. El precio del servicio gestionado cubría un solo entorno; el nuestro también paga una redundancia que la mayoría de días está inactiva, que es exactamente su trabajo.

El gasto adicional viene de cuatro sitios:

  • Segundas copias de todo. Capacidad web, un lector de Aurora y una réplica de Redis en una segunda zona, dimensionados para que una sola zona pueda soportar toda la carga.
  • Piezas gestionadas. RDS Proxy, EFS, WAF y el balanceador de carga añaden cada uno una línea que un solo servidor no tiene.
  • Red entre zonas. El tráfico entre zonas se factura, y también un NAT gateway en cada zona.
  • Nuestro tiempo. Los parches, las actualizaciones y las guardias ahora son cosa nuestra y no del proveedor.

Lo justificamos comparando el sobrecoste mensual con el coste de una sola caída grave. Para una plataforma que miles de personas usan para publicar cada día, una caída de varias horas cuesta más en trabajo perdido, carga de soporte y confianza que la diferencia de alojamiento, y la alta disponibilidad convierte esas caídas en no-incidentes. El Auto Scaling mantiene el sobrecoste a raya ejecutando menos instancias fuera de las horas punta.

Qué falló

Ninguno de estos problemas tiró el sitio. Todos fueron casos en los que la nueva plataforma hizo exactamente lo que se le había dicho, y lo que se le había dicho estaba incompleto.

Un cron que se paró sin hacer ruido

El wp-config.php llegó con DISABLE_WP_CRON a true. En el servicio de WP gestionado eso era correcto, porque el proveedor ejecutaba el cron de WordPress desde un planificador del sistema. Ese planificador no vino con nosotros, así que después de la migración nada ejecutaba los eventos programados: ni entradas programadas, ni correos en cola, ni limpieza. No se registraba ningún error, porque nada fallaba; simplemente no ocurría.

Solución: una única tarea programada que ejecuta con WP-CLI los eventos pendientes de cada subsitio. Una tarea, no un crontab por instancia, o un Auto Scaling group de N instancias ejecuta el cron N veces. Lección: hay que migrar el comportamiento, no solo la configuración. Un flag que desactiva algo da por hecho que otra cosa lo sustituye.

Los access points de EFS pasan por encima de root

Un access point de EFS impone su propio usuario POSIX a cada operación que pasa por él, también a las de root. Nuestros pasos de despliegue se ejecutaban como root y esperaban tener sus poderes; en el access point, root se convertía en el usuario del servidor web. chown fallaba con errores de permisos y los archivos nuevos no tenían los propietarios que pedían los scripts.

Solución: tratar el access point como el modelo de propiedad. Su usuario y grupo son los del servidor web, su directorio raíz se crea con esos propietarios y el despliegue ya no intenta cambiar la propiedad en EFS. Lección: un access point es una sustitución de identidad, no solo una ruta de montaje.

require_secure_transport de Aurora y MYSQLI_CLIENT_SSL

Impusimos TLS en Aurora con require_secure_transport = ON. El cliente mysql desde un shell se conectaba sin problemas, porque por defecto prefiere TLS. WordPress no: mysqli no pide TLS si no se le indica, así que Aurora rechazaba todas las conexiones por transporte inseguro.

Solución: define( 'MYSQL_CLIENT_FLAGS', MYSQLI_CLIENT_SSL ); en el wp-config.php. Lección: una prueba correcta desde el shell demuestra que el shell funciona. Esa idea vuelve a aparecer más abajo.

El .htaccess de sitio único y el bucle de redirecciones

El .htaccess que vino con el código era el predeterminado de WordPress para un sitio único. Un Multisite en subdirectorios necesita reglas de reescritura adicionales que vuelvan a apuntar /site-a/wp-admin/ y compañía a los archivos reales del núcleo. Sin ellas, las URL de administración de los subsitios rebotaban entre WordPress y Apache hasta que el navegador se rendía.

Solución: el bloque de reescritura del Multisite en subdirectorios, que ahora se guarda en el repositorio y se despliega con el código. Lección: hay que comparar cada archivo de configuración con el tipo de instalación, no solo con el servidor antiguo.

El shell no es PHP: el fallo de subidas del site 16

Las subidas fallaban en un subsitio, el site 16. Todas las comprobaciones desde un shell de SSM pasaban: las rutas existían, el bucket aceptaba un objeto de prueba y las credenciales se resolvían. La subida seguía fallando, porque el shell comprobaba las rutas que esperábamos, y PHP usaba la ruta que le daba WordPress. El site 16 todavía tenía una opción upload_path de hacía años, con una ruta absoluta en el servidor del servicio de WP gestionado. WP Offload Media escribe cada archivo localmente antes de copiarlo a S3, así que PHP intentaba escribir en un directorio que no existía en las nuevas instancias.

La misma comprobación desde un shell de SSM y desde PHP bajo Apache da respuestas distintas

Solución: eliminar las opciones obsoletas upload_path y upload_url_path con WP-CLI, y después revisar la tabla de opciones de cada subsitio en busca de rutas absolutas del antiguo alojamiento. Otros dos subsitios tenían el mismo resto y se arreglaron antes de que nadie se diera cuenta. Lección: hay que reproducir el problema dentro del runtime. Ejecuta la prueba como el usuario del servidor web, a través de PHP y con la configuración de PHP, antes de fiarte del resultado. Después de los problemas con el cron, TLS y las subidas, «¿funciona desde PHP?» se convirtió en la primera pregunta de cada investigación.

Resultados

Desde la migración, en marzo de 2026, la plataforma ha superado todos los fallos que hemos visto o provocado sin ninguna caída de infraestructura.

  • Disponibilidad: 99,98 % de uptime desde la migración. El único incidente que llegó a los usuarios fue corto y no vino de un componente que fallara.
  • Fallos de instancias: el Auto Scaling group ha sustituido varias veces instancias no saludables, sin errores visibles para los usuarios y sin avisar a nadie de guardia.
  • Simulacros de failover: terminamos instancias web, forzamos failovers de Aurora y sacamos una zona del ALB en momentos de poco tráfico. Los failovers de Aurora pausaron las escrituras unos 20 o 30 segundos, algo que RDS Proxy convirtió en peticiones más lentas en lugar de errores.
  • Mantenimiento: ninguna parada planificada desde la migración. Los parches del sistema operativo se despliegan con instance refresh, y las actualizaciones menores de Aurora pasan por un failover.
  • Rendimiento de la administración: el p95 de wp-admin bajó de unos 2,4 s a unos 650 ms en los cinco subsitios con más actividad, un efecto secundario de mover las lecturas a las réplicas y los objetos a Redis.
  • Coste: más alto que el del servicio de WP gestionado, tal como estaba previsto y como se explica más arriba.

Las mejoras que no caben en un número importan igual. Un fallo a las 3 de la madrugada ahora es una línea de log por la mañana en lugar de una llamada. Cada cambio llega como un plan de Terraform revisado, nadie tiene una clave SSH y, cuando algo se degrada, podemos ver qué capa es la responsable.

Qué haría de otra manera

  • Programar simulacros de failover desde el primer día. Probamos el failover antes de la migración y después solo de vez en cuando. Un simulacro mensual que mate una instancia y haga failover de la base de datos mantendría la confianza al día y detectaría el drift pronto.
  • Reducir antes la diferencia de coste. Un Compute Savings Plan y capacidad reservada de Aurora para la base siempre activa, más instancias Graviton para la capa web, habrían reducido el sobrecoste de la alta disponibilidad desde el primer mes.
  • Hacer inventario de lo que el antiguo alojamiento hacía gratis. El fallo del cron vino de un comportamiento que el servicio de WP gestionado ofrecía sin que apareciera nunca en nuestro código. Una lista escrita de «qué hace el alojamiento por nosotros» (planificadores, reglas de reescritura, límites de PHP) lo habría hecho aflorar en staging.
  • Hacer privado el ALB con los VPC origins de CloudFront. Ahora CloudFront puede llegar directamente a un ALB interno. Eso elimina el endpoint público y, con él, la mayor parte del motivo de la lista de acceso de WAF.
  • Smoke tests desde dentro de PHP, por subsitio. Una comprobación posterior al despliegue que suba un archivo y programe un evento en cada subsitio habría detectado los problemas del cron y del site 16 en minutos.