Sacar de Heroku una API de ecommerce en Node.js sin reescribirla
El código de la aplicación no cambia. Lo que cambia es el modelo de despliegue: el buildpack y el dyno pasan a ser un Dockerfile, una imagen y una revisión.
En la práctica22 de julio de 20268 min de lectura
Una app de Heroku que lleva años corriendo no es realmente una aplicación Node.js. Es una aplicación Node.js más todo lo que la plataforma hacía en silencio por ella: elegir una versión de runtime, instalar dependencias, inyectar un puerto, guardar la configuración, y correr lo que alguien escribió en Scheduler en 2021. Mueve solo el código y te encuentras con todo a la vez, normalmente a las once de la noche.
Lo que sigue no cambia nada de la aplicación y lo cambia todo sobre cómo se construye, se configura y se ejecuta. Es la forma de la migración de Heroku a Cloud Run que hicimos sobre un conjunto de APIs de ecommerce en Node.js, y la restricción que la hace segura es la misma que la hace aburrida: nada de reescritura.
1. Inventaría primero el entorno en marcha
Toma esto de la plataforma y no del repositorio, porque el repositorio no sabe de la mitad.
- Cada tipo de proceso. El proceso web es obvio; los workers son los que se olvidan, y no se traducen uno a uno. Un worker que sondea una cola se convierte en un servicio de Cloud Run con instancia mínima, en un Cloud Run job programado, o en una suscripción push que convierte la cola en peticiones HTTP.
- Cada job programado. Las entradas de Scheduler son invisibles en el código. Cada una se convierte en un job de Cloud Scheduler que golpea un endpoint autenticado o dispara un Cloud Run job.
- Cada add-on. Decide por add-on si se mueve, se queda o se reemplaza — y ten en cuenta que se queda es una respuesta válida que quita una clase entera de riesgo.
- Cada variable de configuración, incluidas las que están puestas y no se usan, porque no vas a saber cuáles son cuáles hasta que algo falle.
- Cada llamante entrante. Qué storefronts, apps, webhooks y socios tienen tu hostname escrito a mano.
- Versión de runtime y dependencias nativas. Qué versión de Node corre el dyno de verdad, y si algo enlaza contra una librería del sistema que el buildpack traía gratis.
2. Contenerizar: escribe lo que el buildpack estaba haciendo
El buildpack hacía cuatro cosas: elegir una versión de Node, instalar dependencias de producción, correr tu script de build, y arrancar tu proceso con un puerto en el entorno. Un Dockerfile hace esas mismas cuatro, explícitamente.
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]
Fija la versión de Node a la que el dyno estaba corriendo de verdad, al menos para el primer despliegue. Una migración de plataforma y una actualización de runtime son dos cambios, y hacerlos juntos le da a cada fallo dos causas candidatas.
Escucha en todas las interfaces sobre el puerto inyectado. Cloud Run define PORT igual que Heroku, así que leerlo ya funciona — pero un servidor atado a 127.0.0.1 pasa en local y falla los health checks dentro de un contenedor.
Maneja SIGTERM. Ambas plataformas lo envían, y una API que no drena las peticiones en vuelo al apagarse pierde un pequeño porcentaje de ellas en cada despliegue. Es uno de los pocos lugares donde la aplicación sí necesita un cambio, y es una docena de líneas.
Y luego lo que los contenedores te obligan a mirar de frente: el sistema de archivos no es tuyo. En Cloud Run el sistema de archivos del contenedor vive en memoria, así que un archivo temporal cuenta contra el límite de memoria de la instancia y una subida grande buffereada a disco se vuelve un reinicio por falta de memoria. Las subidas y los artefactos generados van a object storage.
3. Un solo camino de un commit a una revisión
El camino de build es deliberadamente lineal, y hay exactamente uno: GitHub, a Cloud Build, a Artifact Registry, a Cloud Run.
Un push a la rama de despliegue dispara un trigger de Cloud Build. Cloud Build construye la imagen y la etiqueta con el SHA del commit — no con latest, que es como se pierde la capacidad de decir qué está sirviendo. La imagen va a Artifact Registry, y Cloud Build la despliega, creando una revisión nueva. Eso compra trazabilidad en los dos sentidos: desde una revisión en marcha puedes nombrar la imagen, desde la imagen el commit, desde el commit el diff.
Desliza la figura para verla completa
Las revisiones son además el mecanismo de rollback, y son mejores que el que estás dejando. Una revisión es inmutable — imagen, entorno, referencias a secretos, concurrencia, memoria y service account congeladas como una unidad — así que hacer rollback es apuntar el tráfico a la anterior, en segundos y sin reconstruir nada. También habilita repartir tráfico: cinco por ciento a la revisión nueva, mirar la tasa de error, mover el resto.
Define la concurrencia a propósito en lugar de aceptar el valor por defecto. Un dyno era un proceso sirviendo lo que el router le mandara; una instancia de Cloud Run sirve hasta un número configurado de peticiones simultáneas. Para una API que pasa el tiempo esperando a una base de datos, una concurrencia alta es correcta y barata; para una que hace trabajo de CPU por petición se convierte directamente en latencia.
4. Configuración e identidad
Las variables de configuración se vuelven secretos
La tentación es una traducción uno a uno: cada variable de configuración se vuelve una variable de entorno de Cloud Run. No lo hagas. Todo lo que sea una credencial — URLs de base de datos, API keys, secretos de firma y de webhook — va a Secret Manager y se monta en la revisión por referencia. Lo que se queda como variable de entorno plana es el resto no secreto: nivel de log, feature flags, una URL base pública.
La diferencia no es cosmética. Un secreto en Secret Manager tiene versiones, una política de acceso y un rastro de auditoría de quién lo leyó. Un valor en la pestaña de entorno de un panel no tiene nada de eso y termina pegado en un hilo de chat durante un incidente. Fija la versión explícitamente en lugar de seguir latest — una revisión que recogería en silencio un valor cambiado ya no es inmutable, y la inmutabilidad era el punto.
Una identidad de runtime por servicio
El resultado por defecto, si nadie decide, es que todo corre bajo una sola cuenta con permisos amplios — la misma postura que la credencial compartida que probablemente tenías antes. Crea una service account por servicio de Cloud Run y dale exactamente lo que ese servicio necesita: lectura sobre los dos secretos que usa, publicación sobre el único topic al que escribe.
Cuesta una hora. Contiene el radio de daño de un servicio comprometido, y documenta a qué puede tocar cada servicio en un lugar que no puede desviarse de la realidad — el mismo razonamiento que aplicamos a cualquier conjunto de APIs, esté en la nube o no.
5. Corre los dos entornos, y después mueve el DNS
Nada de lo anterior es lo riesgoso. Lo riesgoso es el momento en que el tráfico cambia, y la forma de hacerlo pequeño es haber demostrado que el entorno nuevo funciona mientras el viejo sigue sirviendo clientes.
Despliega cada servicio en Cloud Run con su propia URL, apunta un hostname de staging hacia él, y corre formas de tráfico reales por encima: los endpoints que tu storefront llama de verdad, los webhooks que tu plataforma envía de verdad y los jobs programados en sus horarios reales, detrás de una bandera de simulación.
Vigila tres números durante el solapamiento. La latencia de arranque en frío en los endpoints que importan: un contenedor que tarda ocho segundos en arrancar necesita una instancia mínima. El techo de memoria con tamaños de payload reales. Y el número de conexiones a la base de datos, que es el que muerde — una plataforma que escala horizontalmente abrirá más conexiones de las que Postgres acepta, y la respuesta es un pooler más un pool pequeño por instancia, decidido antes del corte.
Después el corte es un solo cambio. Baja el TTL del DNS a sesenta segundos un día o dos antes para que volver sea rápido. Mueve el registro. Vigila tasa de error, latencia y los logs de cada llamante aguas abajo.
Deja el entorno viejo corriendo, intacto, durante días — dejarlo arriba significa que el rollback es un cambio de DNS de vuelta, el rollback más barato que existe. Da de baja solo cuando haya pasado un ciclo completo de negocio por el entorno nuevo y cada job programado haya disparado al menos una vez del lado nuevo.
Desliza la figura para verla completa
Qué no arregla esto
Moverse a Cloud Run no vuelve rápido un endpoint lento, no convierte un monolito en servicios. La aplicación es la misma aplicación, que era el punto — la migración es segura precisamente porque no es también una reescritura.
Lo que cambia es lo que puedes hacer después: hacer rollback en segundos, repartir tráfico, rotar una credencial sin redesplegar todo, y decir exactamente qué commit está sirviendo una petición. Esas propiedades son las que hacen segura la siguiente modificación. Si hace falta una reescritura es un proyecto aparte, y es mucho más fácil correrla contra un entorno al que ya se le puede hacer rollback — que es la mayor parte de para qué sirve esta clase de migración, y donde empieza el resto del trabajo sobre Google Cloud.