— 9 min de lectura
Migrar una aplicación en producción rara vez consiste solo en cambiar el sitio donde se ejecuta un contenedor. Partíamos de una aplicación Symfony desplegada en Google App Engine, con base de datos relacional en Cloud SQL, sesiones y caché en Redis, procesos programados, assets estáticos, tareas de mantenimiento, integraciones externas y varios entornos persistentes.
El objetivo era mover el runtime principal a Cloud Run sin convertir la migración en un salto de fe. Queríamos mantener la operación diaria, desplegar por entornos, ejecutar migraciones de base de datos de forma controlada, proteger los entornos no públicos con IAP y poder probar en desarrollo con datos realistas pero anonimizados.
Punto de partida
Nos movimos por dos razones que se reforzaban entre sí: necesitábamos más control sobre el despliegue y Google lleva tiempo empujando en esa dirección.
Hoy Google recomienda Cloud Run como opción preferente frente a App Engine para los proyectos nuevos, y lo presenta como la evolución de su serverless, construido sobre más de una década operando App Engine y sobre buena parte de su misma infraestructura. App Engine sigue plenamente soportado y nadie ha anunciado su retirada. Pero la señal es difícil de ignorar: cuando el dueño de la plataforma te dice por defecto que empieces en Cloud Run, mantener ahí un proyecto a largo plazo es apostar contra su hoja de ruta. No migramos por seguir una moda; migramos porque ese es el terreno en el que Google invierte y hacia el que dirige a los equipos nuevos.
App Engine nos había dado una plataforma sencilla para servir la aplicación, pero el proyecto ya necesitaba más control sobre el despliegue:
- separar claramente los servicios web principales
- tener un balanceador HTTPS global con rutas explícitas por dominio
- activar o desactivar una página de mantenimiento desde infraestructura
- ejecutar comandos Symfony como jobs efímeros
- gestionar secretos desde Secret Manager
- conectar de forma controlada con Cloud SQL y Redis
- mantener un flujo de release basado en imágenes versionadas
Cloud Run encajaba mejor con ese modelo. Nos permite desplegar contenedores inmutables, ajustar concurrencia y recursos por servicio, conectar a Cloud SQL, usar Serverless VPC Access para recursos privados y apoyarnos en el balanceador global de Google Cloud para certificados, CDN, Cloud Armor e IAP.
La arquitectura de destino
La aplicación quedó dividida en 3 servicios Cloud Run: la tienda pública, el panel de administración y la API.
Los 3 servicios usan la misma imagen de aplicación, pero reciben variables de entorno distintas: dominio canónico, prefijos de caché, configuración de runtime y secretos específicos cuando hace falta. Esta separación evita mezclar tráfico y permite ajustar recursos por perfil de uso. El servicio público puede necesitar más concurrencia y un mínimo de instancias, mientras que el panel de administración puede arrancar bajo demanda.
Añadimos un cuarto servicio Cloud Run para mantenimiento. El balanceador puede enviar el tráfico de uno, varios o todos los servicios a esa página de mantenimiento sin tocar la aplicación ni publicar una imagen nueva.
Delante de Cloud Run colocamos un balanceador HTTPS global, que se encarga de:
- redirigir HTTP a HTTPS
- servir certificados gestionados
- enrutar cada dominio al backend correcto
- aplicar Cloud Armor
- activar CDN cuando procede
- proteger entornos internos mediante IAP
La base de datos siguió en Cloud SQL y Redis siguió en Memorystore. Cloud Run accede a Cloud SQL mediante su integración nativa y a Redis mediante un conector Serverless VPC Access con egress limitado a rangos privados.
Terraform por capas
Uno de los cambios más importantes fue partir la infraestructura en recetas pequeñas. En vez de una única receta grande que lo hace todo, organizamos Terraform por responsabilidad:
- configuración global de builds, buckets, Artifact Registry y triggers
- infraestructura compartida del proyecto: Cloud SQL, Redis y conector VPC
- recursos de entorno: IPs globales y salidas de DNS
- pipelines de datos
- despliegue de aplicación en Cloud Run
- despliegue de mantenimiento
- balanceador HTTPS
- monitorización
- importación de datos en desarrollo
- generación de dumps de producción
Cada receta tiene su workspace de Terraform Cloud. Las recetas globales usan workspaces fijos y las de entorno se seleccionan con TF_WORKSPACE. Con eso, el orden de aplicación queda claro:
- infraestructura compartida
- infraestructura del entorno
- pipelines de datos
- aplicación
- mantenimiento
- balanceador
- monitorización
Esta división tiene una ventaja práctica: si queremos cambiar solo una regla del balanceador o un dashboard, no tenemos que tocar el despliegue de la aplicación. Y si un entorno reutiliza una instancia existente de Cloud SQL, Redis o conector VPC, la receta puede leer esos recursos en vez de intentar recrearlos.
Imágenes inmutables y release por tag
El build de aplicación se movió a Cloud Build. El flujo de release parte de un tag de Git:
- GitHub Actions arranca los triggers de Cloud Build.
- Cloud Build construye la imagen PHP base.
- Se ejecuta
composer installpara producción. - Se compilan los assets de frontend.
- Se validan los assets para evitar errores de bundling.
- Se suben los assets versionados a Cloud Storage.
- Se construye la imagen final con PHP-FPM y Nginx.
- Se publica la imagen en Artifact Registry con el tag de release.
También se construye una imagen independiente para mantenimiento. Separarla de la imagen de aplicación reduce riesgo: la página de mantenimiento debe poder desplegarse aunque la aplicación principal tenga un problema.
En el despliegue, Terraform recibe deploy_version y actualiza los servicios Cloud Run con la imagen exacta de esa versión. No desplegamos latest, sino tags concretos.
Migraciones como jobs efímeros
En App Engine era habitual mezclar despliegue y comandos operativos de formas poco explícitas. En Cloud Run preferimos convertir los comandos Symfony en jobs efímeros creados desde Terraform.
Antes de actualizar los servicios, el despliegue ejecuta la sincronización de la metadata de Doctrine Migrations y doctrine:migrations:migrate -n.
Los jobs usan la misma imagen, variables y secretos que el servicio principal, además de la misma conexión a Cloud SQL y el mismo conector VPC. Cuando terminan, se eliminan. Esto deja el despliegue trazable: si una migración falla, el apply falla antes de mover tráfico a una versión que espera otro esquema de base de datos.
Cron jobs fuera del runtime
Los cron antiguos se trasladaron a Cloud Scheduler. Cada tarea programada llama a una ruta interna de administración con una cabecera específica, y queda declarada en Terraform con nombre, descripción, ruta, horario y zona horaria.
Esto tiene 2 ventajas: los horarios se revisan como código y las tareas dejan de depender de una característica concreta del runtime anterior.
Secretos y configuración
La configuración quedó separada en 2 grupos: las variables no secretas, gestionadas desde Terraform Cloud, y los secretos, almacenados en Secret Manager.
Terraform no guarda valores sensibles en el repositorio. Solo recibe mapas que relacionan nombres de variables de entorno con identificadores de secretos, y después concede al service account de runtime el permiso secretAccessor sobre todos los secretos necesarios.
Un detalle importante fue conceder permisos no solo a los secretos globales, sino también a los específicos de cada servicio. Si administración o la API añaden un secreto propio, el runtime debe poder leerlo sin permisos manuales adicionales.
También dejamos valores por defecto explícitos para las integraciones externas. En una migración, las variables ausentes producen fallos difíciles de diagnosticar; preferimos que la aplicación arranque con valores vacíos o seguros y que cada entorno sobrescriba lo necesario.
IAP, Cloud Armor y CDN
Los entornos internos se protegieron con IAP. En desarrollo y staging, todos los servicios pueden quedar detrás de IAP. En producción el patrón cambia: tienda y API siguen siendo públicas, mientras que administración mantiene IAP.
La receta de Cloud Run concede al service agent de IAP el rol run.invoker sobre los servicios protegidos. Sin este paso, el balanceador puede tener IAP configurado y aun así Cloud Run rechaza la invocación.
Cloud Armor y CDN se gestionan desde el backend del balanceador, lo que permite aplicar políticas de seguridad y caché sin acoplarlas al código Symfony.
Mantenimiento con rutas de balanceador
La página de mantenimiento es otro servicio Cloud Run. El balanceador decide si cada entrada apunta al backend real o al de mantenimiento, y el cambio es puramente de infraestructura:
- mantenimiento solo para la tienda
- mantenimiento solo para administración
- mantenimiento solo para la API
- mantenimiento global
Así evitamos desplegar una versión especial de la aplicación y tenemos un interruptor operativo claro.
Datos realistas para desarrollo
Una parte crítica de la migración fue poder probar el entorno de desarrollo con datos parecidos a producción. Para eso creamos un flujo de dumps:
- Cloud Build clona temporalmente la instancia de producción.
- Exporta un dump completo.
- Aplica scripts SQL de anonimización.
- Exporta un dump anonimizado.
- Aplica una reducción de tamaño para entornos de desarrollo.
- Exporta un dump reducido.
- Elimina la instancia temporal.
Los dumps se limpian para eliminar las sentencias CREATE DATABASE y USE, de forma que puedan importarse en bases de datos con otro nombre.
La anonimización reemplaza datos personales de usuarios, pedidos, contactos, campañas, notas, historiales e integraciones externas. Añadimos una excepción práctica: conservar los usuarios internos del equipo para que puedan entrar en desarrollo después de importar un dump. Es una decisión pequeña, pero evita reconstruir accesos cada vez que se refresca la base de datos.
La importación a desarrollo también quedó declarada como infraestructura. Una receta específica, limitada a dev, copia el dump seleccionado a un bucket temporal, concede acceso al service account de Cloud SQL, borra y recrea la base de datos de desarrollo, importa el dump y elimina el bucket temporal al terminar.
Monitorización
La migración no terminó cuando los servicios respondieron 200. Añadimos una receta de monitorización por entorno con:
- uptime checks HTTPS
- alertas de 5xx en el balanceador
- alertas de latencia p95
- presión de instancias en Cloud Run
- CPU de Cloud SQL
- memoria de Redis
- dashboard de entorno
La señal principal está en el balanceador, no solo en Cloud Run. Importa porque el usuario real atraviesa DNS, certificado, IAP, Cloud Armor, backend service y, por último, Cloud Run.
Problemas que aparecieron
Hubo varios ajustes que merece la pena anotar.
El formato del timeout de Cloud Run tenía que coincidir con lo que espera la API: pasar un número donde se espera una duración con sufijo rompe el despliegue sin dar muchas pistas. Lo corregimos generando valores como 60s.
Los jobs de importación de datos no debían esperar a operaciones intermedias de forma frágil. Simplificamos el flujo y dejamos que los comandos de gcloud bloqueen hasta terminar, reduciendo lógica propia.
Los secretos específicos por servicio no siempre se concedían al runtime. Esto no aparece en un plan de Terraform como un fallo obvio, pero salta en ejecución cuando un servicio no puede leer una variable sensible. La solución fue construir un mapa único de todos los secretos que usan todos los servicios y conceder permisos desde ahí.
También tuvimos que cuidar los permisos de Cloud Build para el flujo de dumps. Clonar una instancia de Cloud SQL, exportar, importar scripts SQL y borrar la réplica temporal requiere permisos más amplios que un build normal de contenedores.
Validación
La validación se hizo por capas:
terraform fmt,terraform inityterraform validatepara los módulos modificados- builds de imágenes desde Cloud Build
- despliegue del entorno de desarrollo
- comprobación de los servicios Cloud Run en estado
Readyy con el 100% del tráfico - comprobación de los certificados gestionados activos
- prueba de respuesta HTTPS con redirección a IAP en los dominios internos
- generación de un dump reciente
- importación del dump en desarrollo
- consulta directa a la tabla de usuarios para verificar que los usuarios internos se conservaban
Esta validación probó el ciclo completo: imagen, infraestructura, balanceador, seguridad, base de datos y datos de prueba.
Qué ganamos
El resultado no fue solo que la aplicación corriera en Cloud Run. Ganamos una forma más explícita de operar:
- releases reproducibles por tag
- servicios separados por responsabilidad
- migraciones trazables antes de actualizar servicios
- mantenimiento controlado desde el balanceador
- secretos fuera del repositorio
- entornos internos protegidos con IAP
- datos de desarrollo refrescables y anonimizados
- monitorización alineada con el tráfico real
La migración funcionó porque no la tratamos como un cambio aislado de runtime, sino como una reconstrucción ordenada de todo el camino que sigue una release: build, imagen, Terraform, migraciones, servicios, balanceador, datos y observabilidad.
Si estás pensando en el mismo salto, cuenta con una cosa: Cloud Run simplifica la ejecución de contenedores, pero la arquitectura operativa no viene incluida. Ese trabajo sigue siendo tuyo.
