Poniendo Rails en AWS Lambda, de extremo a extremo
Ejecutando Rails en Lambda (sin magia negra, por favor)
Tengo una aplicación Rails que quiero ejecutar en AWS Lambda. ¿Por qué? Porque el tráfico es intermitente, la aplicación pasa la mayor parte del día sin hacer nada, y prefiero pagar $0 por tiempo inactivo que $20/mes por una instancia EC2 que está ahí sin hacer nada. Pero tampoco quiero una herramienta de despliegue mágica que oculte lo que realmente está sucediendo — cuando algo se rompe a las 2 am, la “magia” se convierte en “no tengo idea de dónde buscar”.
Esta es la configuración en la que terminé. Dos funciones Lambda, una imagen Docker, una base de datos Postgres que también escala a cero. Así encajan las piezas y qué hace cada una.
La forma de la cosa
flowchart LR
U[Usuario] -->|HTTPS| F[URL de Función Lambda]
F --> R[Lambda: servidor Rails]
D[Yo / CI] -.->|Invocación manual| M[Lambda: migraciones DB]
R --> N[(Neon Postgres<br/>scale-to-zero)]
M --> N
E[ECR: una imagen Docker] -.-> R
E -.-> M
Dos Lambdas, misma imagen, diferentes puntos de entrada:
- La Lambda del servidor Rails maneja el tráfico HTTP a través de una URL de Función.
- La Lambda de migraciones ejecuta
rake db:migrate(y opcionalmentedb:drop / db:create / db:seed) cuando la invoco manualmente.
Misma imagen Docker porque las dependencias son idénticas — solo cambia el punto de entrada. No hay razón para mantener dos builds.
Las piezas
Lamby
Lamby es una pequeña gema que traduce los eventos de invocación de Lambda en peticiones Rack que Rails puede manejar. Agrégala a tu Gemfile:
gem 'lamby'
El runtime Ruby de Lambda
Cuando envías Lambda como una imagen de contenedor, necesitas el cliente de la interfaz de runtime (RIC) de Lambda instalado. En tu Dockerfile:
RUN gem install aws_lambda_ric
Este es el binario que Lambda invoca realmente. Arranca, llama a tu aplicación Rails a través del adaptador de Lamby y espera el siguiente evento.
Un nuevo entorno Rails: lambda
Lambda tiene restricciones que los entornos Rails habituales no esperan: un sistema de archivos mayormente de solo lectura (sin archivos de log, sin volcado de schema.rb después de migraciones), sin memoria persistente entre invocaciones (el almacenamiento de sesión en memoria no sirve), y un hostname que es la fea URL que Lambda te da (que la autorización de hosts de Rails rechaza por defecto).
Crea config/environments/lambda.rb:
# Basado en los defaults de producción
require Rails.root.join("config/environments/production")
Rails.application.configure do
# Configuraciones aquí sobrescriben config/environments/production.rb
# Lista blanca la URL de la Función Lambda — Rails nunca la ha visto
config.hosts << ENV.fetch("LAMBDA_FUNCTION_URL")
# Lambda no puede escribir a archivos; los logs van a STDOUT y CloudWatch los recoge
config.logger = ActiveSupport::Logger.new(STDOUT)
# No volcar schema.rb después de migraciones — sistema de archivos de solo lectura
config.active_record.dump_schema_after_migration = false
# Las sesiones en memoria desaparecen cuando la lambda se congela; las cookies persisten
config.session_store :cookie_store, key: ENV.fetch("SECRET_KEY_BASE")
end
Cada una de esas configuraciones soluciona un problema real que se rompe en Lambda. Elimina cualquiera de ellas y descubrirás cuál.
database.yml
Refleja el nuevo entorno en config/database.yml para que las conexiones funcionen:
default: &default
adapter: postgresql
encoding: unicode
# Para detalles sobre el pool de conexiones, ver la guía de configuración de Rails
# https://guides.rubyonrails.org/configuring.html#database-pooling
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
host: <%= ENV.fetch("DB_HOST") %>
password: <%= ENV.fetch("DB_PASS") %>
user: <%= ENV.fetch("DB_USER") %>
development:
<<: *default
database: <%= ENV['DB_NAME'] %>_development
test:
<<: *default
database: <%= ENV['DB_NAME'] %>_test
production:
<<: *default
database: <%= ENV['DB_NAME'] %>_production
lambda:
<<: *default
database: <%= ENV['DB_NAME'] %>_lambda
Migraciones: dos formas
Opción 1 — una Lambda dedicada a migraciones
Agrega lambda-entrypoint-migrate.sh a tu imagen y hazla ejecutable con chmod +x:
#!/bin/bash
if [[ "$RESET_DATABASE" == "true" ]]; then
rake db:drop db:create db:migrate db:seed
else
rake db:migrate
fi
echo "Migración completada"
# Ceder el control al runtime de Lamby para que el contenedor salga limpiamente con 404
# en lugar de un código de salida distinto de cero (que Lambda interpreta como fallo).
exec "/usr/local/bundle/bin/aws_lambda_ric" "config/environment.Lamby.cmd"
Compila, sube, despliega como una función Lambda separada (más abajo explico), e invócala manualmente cada vez que envíes una migración.
Opción 2 — conectar directamente desde tu laptop
Si tu DB está en Neon (o cualquier otra base accesible desde fuera de AWS), apunta un docker compose local a ella con las credenciales de producción y ejecuta rake db:migrate desde tu máquina. El mismo truco sirve para rails console — útil para depuración puntual.
Uso la Opción 2 para cosas pequeñas y la Opción 1 para cualquier cosa impulsada por CI.
Desplegando a AWS
1. ECR
Crea un repositorio privado. Compila, etiqueta y sube tu imagen Docker. No se necesita configuración especial.
2. Lambda para el servidor Rails
Crea una nueva Lambda a partir de una imagen de contenedor, apuntando al repositorio que acabas de subir.
[imagen: página de AWS “Create function” con “Container image” seleccionado y el nombre de la función rellenado]
[imagen: diálogo selector de imagen de ECR con la etiqueta latest seleccionada]
Luego expande Container image overrides y establece:
ENTRYPOINT:/usr/local/bundle/bin/aws_lambda_ricCMD:config/environment.Lamby.cmd
[imagen: sección “Container image overrides” con ENTRYPOINT y CMD rellenados — ojo, esta captura también muestra el URI de la imagen del contenedor que contiene tu ID de cuenta AWS; recórtalo o difumínalo antes de publicar]
3. Memoria y timeout
1024 MB de RAM y un timeout de 60 segundos es un buen punto de partida. Ajusta según el uso real — Lambda te da CPU proporcional a la memoria, así que provisionar poca RAM también ralentiza el arranque.
[imagen: página “Basic settings” de Lambda con Memory: 1024 MB y Timeout: 1 min]
4. URL de Función
En Configuration → Function URL → Create function URL:
[imagen: pestaña Configuration de Lambda con “Function URL” resaltado en la barra lateral y el botón “Create function URL” visible]
Establece Auth type a NONE (Rails maneja su propia autenticación) y créala. Lambda te devuelve una URL como xxxxxxxx.lambda-url.us-east-1.on.aws.
[imagen: página de configuración de Function URL con Auth type en NONE — esta captura también muestra el ARN del recurso, que contiene tu ID de cuenta AWS; recórtalo antes de publicar]
5. Variables de entorno
Configura todo lo que tu app necesita — credenciales DB, secret key base, claves de APIs externas. Dos son obligatorias para que el entorno lambda arranque:
RAILS_ENV=lambdaLAMBDA_FUNCTION_URL— pon el valor que Lambda te dio en el paso anterior (sin elhttps://)
[imagen: página de variables de entorno mostrando las claves configuradas — nota: el valor de LAMBDA_FUNCTION_URL es tu endpoint público real; redacta antes de publicar]
6. Lambda de migraciones
Misma imagen ECR, nueva función Lambda, con una diferencia: sobrescribe ENTRYPOINT a /app/lambda-entrypoint-migrate.sh y deja CMD vacío — el script hará exec al runtime de Lamby cuando termine de migrar.
[imagen: página Create function para la Lambda de migraciones, ENTRYPOINT sobrescrito al script de migración — también contiene el ID de cuenta en el URI de la imagen; recórtalo]
Misma RAM, mismo timeout, mismas variables de entorno. Para el primer despliegue, pon RESET_DATABASE=true para crear el esquema desde cero — luego quítalo, de lo contrario cada invocación borrará tus datos.
La DB: Neon para escalar a cero
Para mantener la propiedad “todo escala a cero” de extremo a extremo, uso Neon para Postgres. Pausa la base de datos tras cierto tiempo inactivo y la reanuda en la siguiente conexión. Combinado con Lambda, el costo de ejecución cuando nadie usa la app es prácticamente cero.
La penalización de cold start cuando tanto Lambda como Neon están fríos es real — la primera petición tras mucho idle puede tardar unos segundos. Para herramientas internas y proyectos laterales, está bien. Para cualquier cosa de cara al usuario en producción querrías mantener la DB caliente.
Cuándo encaja (y cuándo no)
Encaja bien: herramientas internas, paneles de administración, apps de bajo tráfico, cargas de trabajo intermitentes (webhooks, fan‑outs programados), cualquier cosa donde prefieras pagar $0 por tiempo inactivo que $20/mes por una pequeña EC2.
No encaja: apps públicas de alto tráfico (a gran escala Lambda cuesta más que EC2/Fargate, y los cold starts dañan la UX), peticiones de larga duración (Lambda tiene límite de 15 min y pagas cada segundo), apps que necesiten workers persistentes como Sidekiq.
Para el caso de uso que construí — una herramienta de administración Rails usada unas pocas horas al día — es un ajuste perfecto. Todo el stack cuesta casi nada cuando está inactivo, que es la mayor parte del tiempo.