Lambda vs Fargate: passare dall'uno all'altro senza riscrivere tutto

Nell’articolo precedente abbiamo visto come scegliere consapevolmente tra Lambda e Fargate.
Durante il ciclo di vita di un workload però, soprattutto se viene applicata una metodologia agile, i requisiti possono cambiare rapidamente.
Parti con una Lambda perché hai poco traffico e vuoi scalare a zero. Poi il servizio ha successo, i costi salgono, o magari il timeout di 15 minuti inizia a starti stretto… capisci che devi passare a Fargate.
Se in fase di prototipo hai scritto il tuo Infrastructure as Code (IaC) in modo rigido, questa migrazione è un bagno di sangue.
L’approccio migliore è sempre decidere il modello computazionale a monte.
Ma se il profilo del traffico non è ancora chiaro, puoi progettare il codice Terraform del MVP per rendere questa transizione un dettaglio implementativo, non una riscrittura.
In questa guida scopriremo come ridurre drasticamente il costo distruttivo generato dal passaggio a un tipo di compute diverso.
Fermi tutti, disclaimer
Quanto andremo a scoprire è una strategia utile per le startup in fase di discovery o per workload ancora embrionali.
Se il tuo workload è stabile e ben definito, introdurre questo livello di astrazione aggiunge complessità inutile.
(Rischio overengineering, continuare a leggere responsabilmente!)
L’idea: Astrarre la “Compute”
Non pensare a “Lambda” o “ECS”, pensa alla “Capacità di Calcolo”. Questo non rende Lambda ed ECS identici (hanno comportamenti e limiti diversi!), ma ci permette di riutilizzare lo stesso artefatto applicativo (Container Image) e la stessa logica di business.
L’obiettivo è un modulo Terraform guidato da una variabile:
- Oggi:
compute_type = "lambda"(Traffico basso / Picchi di traffico irregolari). - Domani:
compute_type = "fargate"(Traffico alto / Processi di lunga durata).
Cambiamo la variabile, lanciamo terraform apply e l’infrastruttura sottostante cambia motore, mantenendo lo stesso identico codice applicativo.
Step 1a. Un solo ingresso che vale per entrambi
Il modulo di cui parleremo gestisce la logica di compute, ma lascia aperto il problema dell’Ingress.
In molti scenari di produzione si inserisce un ingresso dedicato (ALB o API Gateway) per una miriade di possibili motivi (disaccopiamento, sicurezza, osservabilità, routing…)
Nel nostro caso per evitare di cambiare URL, implementiamo un ALB wrapper.
Un possibile pattern quando vuoi un entrypoint HTTP unico ALB-based:
- Creiamo un solo Application Load Balancer con un unico DNS.
- Definiamo due Target Group: uno di tipo LAMBDA e uno di tipo IP (per Fargate).
- Il nostro modulo compute espone l’ARN o l’IP necessari (tramite outputs).
- Una regola sull’ALB decide a quale Target Group instradare il traffico.
In questo modo, l’ALB diventa l’interruttore universale. Il DNS non cambia mai, cambia solo la destinazione “dietro le quinte”. Questo modulo è il primo, fondamentale passo per implementare questo pattern.
# Application Load Balancer
resource "aws_lb" "this" {
name = "${var.service_name}-alb"
internal = false # Deve essere pubblico per essere raggiunto
load_balancer_type = "application"
security_groups = var.vpc_config.security_group_ids
subnets = var.public_subnet_ids
tags = { Name = "${var.service_name}-alb" }
}
# Target Group per Lambda
resource "aws_lb_target_group" "lambda" {
name = "${var.service_name}-tg-lambda"
target_type = "lambda"
# Non serve port configuration per Lambda
}
# Target Group per Fargate (Porta 8080)
resource "aws_lb_target_group" "fargate" {
name = "${var.service_name}-tg-fargate"
port = 8080
protocol = "HTTP"
target_type = "ip" # Best practice per Fargate
vpc_id = var.vpc_config.subnet_ids[0] # Terraform estrarrà il VPC ID dalla subnet
health_check {
enabled = true
path = "/"
interval = 30
timeout = 3
healthy_threshold = 2
unhealthy_threshold = 2
matcher = "200"
}
}
# Listener (Porta 80)
resource "aws_lb_listener" "this" {
load_balancer_arn = aws_lb.this.arn
port = 80
protocol = "HTTP"
# Di default: rispondi 404 se nessuna regola corrisponde (per sicurezza)
default_action {
type = "fixed-response"
fixed_response {
content_type = "text/plain"
message_body = "Service not found or switching mode..."
status_code = 404
}
}
}
# Listener Rule per LAMBDA (Attiva solo se compute_type == lambda)
resource "aws_lb_listener_rule" "lambda" {
count = var.compute_type == "lambda" ? 1 : 0
listener_arn = aws_lb_listener.this.arn
priority = 1
action {
type = "forward"
target_group_arn = aws_lb_target_group.lambda.arn
}
condition {
path_pattern {
values = ["/*"] # Cattura tutto il traffico
}
}
}
# Listener Rule per FARGATE (Attiva solo se compute_type == fargate)
resource "aws_lb_listener_rule" "fargate" {
count = var.compute_type == "fargate" ? 1 : 0
listener_arn = aws_lb_listener.this.arn
priority = 1
action {
type = "forward"
target_group_arn = aws_lb_target_group.fargate.arn
}
condition {
path_pattern {
values = ["/*"]
}
}
}
Step 1b. L’Artefatto Unificato: Dockerfile e Web Adapter
Il primo ostacolo è che Lambda non “ascolta” su porte HTTP TCP standard come un container Docker normale.
Per usare la stessa immagine ovunque senza modificare il codice dell’app (Express, Flask, Spring, Go, etc.), usiamo AWS Lambda Web Adapter.
Questo pattern funziona quando i due runtime condividono lo stesso modello di esposizione (HTTP request/response), per cui non è adatto a job batch, stream processor o workload fortemente event-driven (in questi casi, non dovrebbero esserci dubbi sulla scelta)
Questo tool si comporta da proxy HTTP tra il runtime Lambda e la tua applicazione web dentro il container, che può leggermente aumentare la latenza e il cold start a favore di una migliore portabilità.
In pratica, agisce come un’estensione che traduce gli eventi JSON di Lambda in chiamate HTTP verso la tua app.
Una volta che la tua WebApp (API HTTP) è impacchettata in un Dockerfile, basta copiare il binario di Lambda Web Adapter in/opt/extensions.
Se usi un Load Balancer (come suggerito sopra), l’ALB deve sapere se il container è vivo. Lambda lo gestisce da sola, Fargate no.
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production --prefer-offline
RUN apk add --no-cache curl
COPY ../../../../../../Scaricati .
# Copiamo il binario di Lambda Web Adapter in /opt/extensions
# Lambda avvierà automaticamente qualsiasi eseguibile trovato in quella cartella
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.7.1 /lambda-adapter /opt/extensions/lambda-adapter
# Configurazione opzionale dell'adapter (default è già 8080)
# Se la tua app ascolta su una porta diversa, cambiala qui.
ENV PORT=8080
# IMPORTANTE: non serve sovrascrivere l'ENTRYPOINT!
# Su Fargate: il container ignora /opt/extensions ed esegue il CMD.
# Su Lambda: l'estensione parte, intercetta la chiamata e la gira al processo in CMD.
CMD ["node", "server.js"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/ || exit 1
Il Lambda Web Adapter non è obbligatorio in assoluto, ma diventa essenziale quando l’obiettivo è avere un unico artefatto containerizzato eseguibile sia su Lambda sia su Fargate senza modificare il codice applicativo. Se stai scrivendo Lambda “native” (handler puri, non HTTP), puoi evitare questo pattern.
Step 2. Per variare ci servono le variabili
Di seguito un esempio NON esaustivo di file variable.tf: bisogna considerare a monte sia le variabili obbligatorie che quelle opzionali, in modo da avere il terreno già pronto per la futura migrazione.
variable "service_name" {
type = string
}
variable "image_uri" {
description = "URI dell'immagine Docker su ECR"
type = string
}
variable "compute_type" {
description = "Motore di calcolo: 'lambda' o 'fargate'"
type = string
validation {
condition = contains(["lambda", "fargate"], var.compute_type)
error_message = "compute_type deve essere 'lambda' o 'fargate'."
}
}
variable "vpc_config" {
description = "Configurazione di rete (Subnets e Security Groups)"
type = object({
subnet_ids = list(string)
security_group_ids = list(string)
})
}
# Variabili specifiche per Fargate (opzionali se usiamo Lambda)
variable "ecs_cluster_id" {
type = string
default = null
}
# Variabili per l'ALB
variable "public_subnet_ids" {
description = "Subnet PUBBLICHE per l'Application Load Balancer (deve permettere connessioni da internet)"
type = list(string)
}
Step 3a. Implementazione: Lambda (lambda.tf)
Creare la Lambda non basta perché senza un trigger è irraggiungibile.
Per parità con Fargate (che è un servizio web), dobbiamo esporla.
resource "aws_lambda_function" "this" {
count = var.compute_type == "lambda" ? 1 : 0
function_name = var.service_name
package_type = "Image"
image_uri = var.image_uri
role = aws_iam_role.lambda_exec[0].arn
timeout = 30
memory_size = 1024 # L'adapter beneficia di più CPU (per Lambda, è legata alla RAM)
vpc_config {
subnet_ids = var.vpc_config.subnet_ids
security_group_ids = var.vpc_config.security_group_ids
}
environment {
variables = {
RUST_LOG = "info" # Utile per debuggare l'adapter
}
}
}
# Permette all'ALB di invocare la Lambda
resource "aws_lambda_permission" "allow_alb" {
count = var.compute_type == "lambda" ? 1 : 0
statement_id = "AllowExecutionFromALB"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.this[0].function_name
principal = "elasticloadbalancing.amazonaws.com"
source_arn = aws_lb_target_group.lambda.arn
}
# Collega la Lambda al Target Group
resource "aws_lb_target_group_attachment" "lambda" {
count = var.compute_type == "lambda" ? 1 : 0
target_group_arn = aws_lb_target_group.lambda.arn
target_id = aws_lambda_function.this[0].arn
depends_on = [aws_lambda_permission.allow_alb]
}
Step 3b. Implementazione: Fargate (fargate.tf)
Passando a Fargate, dobbiamo definire Task Definition e Service.
Se le tue subnet sono private (come da best practice), devi avere un NAT Gateway configurato nella VPC (che costa!), altrimenti il task fallirà a pullare l’immagine da ECR e resterà in PENDING eterno.
resource "aws_ecs_task_definition" "this" {
count = var.compute_type == "fargate" ? 1 : 0
family = var.service_name
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = 256
memory = 512
execution_role_arn = aws_iam_role.ecs_exec[0].arn # Ruolo per pull immagini/log
task_role_arn = aws_iam_role.ecs_task[0].arn # Ruolo dell'app
container_definitions = jsonencode([
{
name = "app"
image = var.image_uri
essential = true
portMappings = [{ containerPort = 8080 }]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = "/ecs/${var.service_name}"
awslogs-region = "eu-south-1" # Assicurati sia dinamico in prod usando il data block
awslogs-stream-prefix = "ecs"
awslogs-create-group = "true"
}
}
}
])
}
resource "aws_ecs_service" "this" {
count = var.compute_type == "fargate" ? 1 : 0
name = var.service_name
cluster = var.ecs_cluster_id
task_definition = aws_ecs_task_definition.this[0].arn
launch_type = "FARGATE"
desired_count = 1
network_configuration {
subnets = var.vpc_config.subnet_ids
security_groups = var.vpc_config.security_group_ids
assign_public_ip = false # True SOLO se sei in subnet pubbliche
}
load_balancer {
target_group_arn = aws_lb_target_group.fargate.arn
container_name = "app"
container_port = 8080
}
}
Step 4. IAM: qui cadono tutti
Non si può usare lo stesso IAM Role per Lambda ed ECS. Hanno Trust Relationships (chi può assumere il ruolo) diverse:
- Lambda:
lambda.amazonaws.com - ECS Task:
ecs-tasks.amazonaws.com
Una soluzione elegante è scrivere una Policy condivisa, ma due Ruoli distinti.
# Policy per entrambi, ad esempio aggiunta e rimozione oggetti da uno specifico bucket s3
resource "aws_iam_policy" "app_policy" {
name = "${var.service_name}-policy"
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject"]
Resource = "arn:aws:s3:::my-bucket/*"
}]
})
}
# Ruolo di Esecuzione ECS (obbligatorio per Fargate)
resource "aws_iam_role" "ecs_exec" {
count = var.compute_type == "fargate" ? 1 : 0
name = "${var.service_name}-ecs-exec-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "ecs-tasks.amazonaws.com" }
}]
})
}
# Ruolo per ECS Task
resource "aws_iam_role" "ecs_task" {
count = var.compute_type == "fargate" ? 1 : 0
name = "${var.service_name}-ecs-task-role"
assume_role_policy = jsonencode({
Version = "2012-10-17",
Statement = [{
Action = "sts:AssumeRole",
Effect = "Allow",
Principal = { Service = "ecs-tasks.amazonaws.com" }
}]
})
}
# Ruolo per Lambda
resource "aws_iam_role" "lambda_exec" {
count = var.compute_type == "lambda" ? 1 : 0
name = "${var.service_name}-lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17",
Statement = [{
Action = "sts:AssumeRole",
Effect = "Allow",
Principal = { Service = "lambda.amazonaws.com" }
}]
})
}
# Attach condizionale per ogni ruolo
# Come un "if", in base al valore della variabile compute_type, aggancia il ruolo corrispondente
resource "aws_iam_role_policy_attachment" "lambda_attach" {
count = var.compute_type == "lambda" ? 1 : 0
role = aws_iam_role.lambda_exec[0].name
policy_arn = aws_iam_policy.app_policy.arn
}
resource "aws_iam_role_policy_attachment" "ecs_attach" {
count = var.compute_type == "fargate" ? 1 : 0
role = aws_iam_role.ecs_task[0].name
policy_arn = aws_iam_policy.app_policy.arn
}
resource "aws_iam_role_policy_attachment" "ecs_exec_policy" {
count = var.compute_type == "fargate" ? 1 : 0
role = aws_iam_role.ecs_exec[0].name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
Prima di salutarci…
Con questo modulo abbiamo creato un “Compute Toggle”:
- Usi
compute_type = "lambda"e paghi a consumo. - Il traffico esplode? Cambi in
compute_type = "fargate".
Terraform distrugge la Lambda e dopo un downtime minimo l’app è su ECS, tutto perché il Dockerfile è ibrido.
Il runtime precedente verrà sempre distrutto prima di crearne uno nuovo.
Questo comportamento è intenzionale poiché la migrazione è di natura distruttiva, ma adesso è pulita e ordinata.
Attenzione però: La portabilità del codice non implica la portabilità dello stato.
Su Lambda lo spazio disco /tmp è effimero e sparisce dopo l’invocazione. Su Fargate può persistere finché il task è vivo.
Questo pattern standardizza il provisioning, non il comportamento runtime.
Lambda ed ECS restano modelli profondamente diversi per scaling, latenza, costi e gestione degli errori.
Cambiare compute_type non evita il lavoro architetturale: evita solo di riscrivere l’infrastruttura di base.
In ambienti production-critical è preferibile adottare strategie blue/green o canary deploy. Il toggle distruttivo mostrato qui è pensato per ambienti di discovery o non mission-critical.
NON seguire questo approccio per:
workload stabili e prevedibili
sistemi mission-critical
requisiti di latenza molto stretti
architetture già fortemente caratterizzate (ad esempio event-driven puro)
Conclusioni
Insomma, se sei alle prime armi con Terraform analizza nel dettaglio quali sono le condizioni ideali per lavorare con ciò che hai visto oggi, senza copiarlo e incollarlo in produzione. Piuttosto, usalo per ragionare sulle scelte architetturali.
Se leggendolo hai compreso quanto sia importante scegliere la strategia giusta dal giorno 0 (se possibile), che per quanto se ne possa ridurre il rumore una migrazione introduce inevitabilmente complessità, allora sei sulla strada giusta.










