Split-horizon DNS per un ALB internet-facing: come e perché

Il problema

Un Application Load Balancer internet-facing su AWS pubblica nel DNS solo i propri indirizzi pubblici. Un servizio in una subnet privata che risolve il nome dell'ALB ottiene quindi IP pubblici, e il suo traffico esce dal VPC attraverso un NAT gateway per poi rientrare dall'interfaccia pubblica del load balancer.

Questo significa pagare NAT data processing e data transfer per ogni richiesta tra due workload che si trovano a pochi metri di distanza nello stesso VPC. Su volumi interni significativi la spesa è nell'ordine di decine o centinaia di euro al mese, e cresce linearmente col traffico.

Come si risolve tradizionalmente

Un secondo ALB interno

La soluzione più diretta è creare un secondo Application Load Balancer di tipo interno, davanti agli stessi target. L'ALB interno risolve direttamente ai propri IP privati, quindi il traffico in-VPC non esce mai.

Il problema: un ALB parte da circa $16/mese prima degli LCU, e richiede di creare listener, target group e health check separati per il secondo ALB. I target possono essere gli stessi, ma ogni target group è legato a un singolo load balancer, quindi vanno registrati di nuovo. Due configurazioni da tenere sincronizzate, doppi health check sui target, e un costo fisso che non scala a zero.

Un alias record privato

Si potrebbe pensare di creare un alias A record in una hosted zone privata che punti all'ALB. Ma un alias a un ALB internet-facing risolve agli stessi IP pubblici: Route 53 segue la catena e restituisce le stesse risposte della zona pubblica. Non serve a nulla.

La nostra soluzione: uno stack CloudFormation standalone

Without split-horizon DNS
Internal
Clients
NAT
Gateway
AWS Public
Network
ALB
(public IP)
Targets
Traffic exits and re-enters the VPC — you pay NAT + data transfer
With split-horizon DNS
Internal
Clients
ALB
(private IP)
Targets
Traffic stays on the VPC private network — $0 data transfer

Abbiamo realizzato uno stack CloudFormation che risolve il problema con una Lambda schedulata, una hosted zone privata e un record A mantenuto automaticamente.

Il principio è semplice: i nodi dell'ALB hanno già indirizzi privati sulle proprie ENI. Non sono pubblicati nel DNS e cambiano quando l'ALB scala, ma sono raggiungibili direttamente dall'interno del VPC. Lo stack tiene un record A privato allineato a quegli indirizzi.

Non è un pattern nuovo: AWS stessa lo implementa nativamente per altri servizi. Un'istanza RDS con accesso pubblico abilitato risolve automaticamente al proprio IP privato quando la query DNS arriva dall'interno del VPC, e all'IP pubblico quando arriva da fuori. Per gli Application Load Balancer questa funzionalità non esiste: il DNS restituisce sempre gli IP pubblici indipendentemente da dove si risolve. Il nostro stack aggiunge esattamente quello che manca.

Come funziona

  1. Ogni minuto, EventBridge Scheduler invoca una Lambda.
  2. La Lambda risolve il nome DNS pubblico dell'ALB per ottenere i suoi IP pubblici correnti.
  3. Chiama ec2:DescribeNetworkInterfaces filtrando per association.public-ip con quegli IP. Questo restituisce le ENI dell'ALB: ogni ENI ha sia un IP pubblico (l'association) sia un IP privato sulla subnet del VPC. La Lambda legge gli IP privati.
  4. Se il record A nella hosted zone privata è diverso dagli IP privati appena ottenuti, lo aggiorna con un UPSERT.

Il passaggio chiave è il terzo: gli IP privati delle ENI dell'ALB non sono pubblicati da nessuna parte, ma sono raggiungibili direttamente dall'interno del VPC. La Lambda li scopre partendo dagli IP pubblici (che sono nel DNS) e risalendo alle ENI sottostanti.

I chiamanti interni al VPC risolvono il nome privato e raggiungono l'ALB direttamente sulla rete privata. Il TLS continua a funzionare perché il certificato è sul listener dell'ALB, non sull'IP. Nulla dell'ALB cambia.

Perché seguire il DNS e non gli eventi ENI

Si potrebbe pensare di reagire agli eventi di creazione/eliminazione delle ENI (via EventBridge) anziché risolvere il DNS ogni minuto. In realtà il DNS dell'ALB è l'unica fonte affidabile dello stato "pronto a ricevere traffico". Durante lo scale-out, la ENI viene creata prima che il nodo sia pronto: aggiungerla al record privato instraderebbe traffico verso un nodo che non risponde ancora. Durante lo scale-in, l'evento di eliminazione arriva dopo che il nodo è già stato spento. Seguire il DNS significa operare nella stessa finestra temporale in cui AWS considera il nodo attivo.

Il caching

La Lambda mantiene in memoria gli ultimi IP pubblici dell'ALB nell'execution environment di Lambda. Finché l'execution environment resta "caldo" e gli IP pubblici non cambiano, la funzione esce immediatamente dopo la risoluzione DNS senza chiamare EC2 né Route 53.

La cache viene scritta solo dopo che Route 53 ha accettato l'aggiornamento, o dopo aver verificato con una read che il record contiene già gli IP privati attesi. Una run fallita non sporca la cache: al tentativo successivo la Lambda riparte dal confronto DNS e riesegue il flusso completo.

Il rovescio della medaglia: finché gli IP pubblici dell'ALB restano invariati, un execution environment caldo assume che il record privato sia ancora corretto. Modifiche manuali al record non vengono necessariamente riparate fino a un cold start o a un cambio degli IP pubblici dell'ALB. Il record privato va quindi trattato come gestito esclusivamente da questo stack.

Vantaggi rispetto alle alternative

Split-horizon DNS ALB interno
Costo mensile ~$0.50 (hosted zone) ~$16-25 (ALB + LCU)
Risorse da gestire 1 stack, 0 configurazione duplicata 2 ALB, listener, target group, health check, DNS
Traffico NAT eliminato
Convergenza dopo scaling ~2 minuti (senza errori) immediata

La convergenza di circa due minuti (un minuto di intervallo schedule + 60 secondi di TTL) è l'unico compromesso operativo. In pratica, però, durante lo scaling non si verificano errori: quando l'ALB rimuove un nodo, lo toglie prima dal DNS e lo mantiene attivo in un grace period per completare le connessioni esistenti. Anche se il record privato punta ancora al vecchio indirizzo, il traffico viene comunque servito fino allo shutdown effettivo del nodo. Per la stragrande maggioranza dei workload interni non è un problema.

Il costo nel dettaglio

Lo stack costa circa $0.50 al mese: il prezzo di una hosted zone privata Route 53. Tutto il resto rientra nei free tier perpetui di AWS:

Voce Utilizzo Free tier Addebitato
Route 53 hosted zone privata 1 zona nessuno $0.50/mese
Route 53 query (zona privata) qualsiasi volume n/a — mai addebitato $0
Lambda invocazioni 43.200 1.000.000/mese $0
Lambda durata ≤10.800 GB-s 400.000 GB-s/mese $0
EventBridge Scheduler 43.200 14.000.000/mese $0
CloudWatch Logs ~10–25 MB 5 GB/mese $0
CloudWatch Alarms 2 primi 10 $0

La durata della Lambda potrebbe sembrare rilevante, ma anche assumendo un secondo intero per run a 256 MB, diverse volte il valore reale visto che la maggior parte delle esecuzioni esce subito sul cache check, si arriva a 10.800 GB-s contro un'allocazione gratuita di 400.000 GB-s.

Questi free tier sono perpetui, non limitati ai primi 12 mesi dell'account. Sono però per account, non per stack: su un account che usa già Lambda, Logs, o più di otto allarmi, il costo marginale sale fino a circa $0.70/mese.

Confronto con un ALB interno

L'alternativa più ovvia è un secondo ALB di tipo interno, davanti agli stessi target. Il costo fisso parte da $0.0225/ora, quindi circa $16.43/mese.

Oltre al costo, un ALB interno richiede di mantenere sincronizzate due configurazioni: listener, target group (ogni target group è legato a un singolo load balancer), health check, record DNS in una hosted zone privata, e certificati TLS da attaccare a entrambi. I target ricevono il doppio degli health check.

Il risparmio reale

Senza il nostro stack, chi ha traffico interno verso un ALB internet-facing ha due scelte:

  1. Opzione raccomandata da AWS: un ALB interno. ~$16/mese fissi + doppia configurazione da gestire (listener, target group, health check, record DNS, certificati). Funziona, ma ha un costo organizzativo oltre che economico.
  2. Non fare niente. Il traffico esce dal VPC attraverso il NAT gateway e rientra dall'interfaccia pubblica dell'ALB, pagando NAT data processing ($0.045/GB) e data transfer regionale per l'uso di IP pubblici ($0.01/GB per direzione). Su volumi interni significativi (decine o centinaia di GB al mese tra servizi) questo supera facilmente i $16 di un ALB interno.

Il nostro stack elimina entrambi i problemi a $0.50/mese: il traffico resta sulla rete privata del VPC, senza un secondo ALB da gestire e senza passare dal NAT. Come beneficio aggiuntivo, il traffico che non esce dal VPC ha anche una latenza inferiore rispetto al percorso NAT → internet → ALB.

Tutti i prezzi indicati si riferiscono a US East (N. Virginia) e variano per regione.

Come è fatto lato tecnico

Architettura

VPC
EventBridge
Scheduler
Lambda
Route 53
Private Zone
CloudWatch
Logs & Alarms
invoke
upsert
logs

Lo stack crea 13 risorse, tutte in un singolo template CloudFormation senza dipendenze esterne:

  • Una hosted zone privata Route 53 associata al VPC
  • Un record A placeholder, che la Lambda mantiene aggiornato
  • La Lambda di riconciliazione con il suo ruolo IAM, log group e scheduler
  • Una Lambda custom resource per il teardown pulito
  • Due allarmi CloudWatch opzionali (errori e assenza di invocazioni)

Il teardown e l'update del DNS name: perché servono e come funzionano

Un aspetto da gestire è la cancellazione dello stack e il cambio del nome DNS privato. Route 53 richiede che un DELETE di un record corrisponda esattamente ai valori attuali. Ma CloudFormation conosce solo i valori placeholder che ha creato, mentre il record contiene gli IP reali scritti dalla Lambda.

Senza un intervento, il delete del record fallisce con InvalidChangeBatch e la hosted zone non può essere svuotata.

La soluzione è un custom resource (RestoreSeedRecordInvoke) che rimette i valori placeholder in due scenari:

  • Cancellazione dello stack: in fase di Delete, ripristina i seed prima che CloudFormation provi a eliminare il record.
  • Cambio di pCustomAlbPrivateDnsName: la hosted zone e il record vengono sostituiti (update-replace). Il custom resource riceve un evento Update con le OldResourceProperties contenenti l'ID della vecchia zona e il vecchio nome DNS, e vi scrive i seed IP prima che CloudFormation elimini le vecchie risorse.

L'ordine di creazione

Route 53 viene creato per primo perché la IAM policy della Lambda principale referenzia l'ID della hosted zone:

PrivateHostedZone ├── PrivateRecordA └── SplitHorizonLambdaRole RestoreSeedRecordFunctionRole + RestoreSeedRecordLambdaLogGroup └── RestoreSeedRecordFunction └── RestoreSeedRecordInvoke └── SplitHorizonLambda ├── SplitHorizonLambdaErrorAlarm └── SchedulerInvokeRole └── SplitHorizonSchedule └── SplitHorizonLambdaNoInvocationAlarm

La schedule viene creata per ultima di proposito: nulla invoca la Lambda finché il record e l'helper di teardown non sono entrambi attivi. SplitHorizonLambda viene creata dopo RestoreSeedRecordInvoke così che la cancellazione avvenga nell'ordine opposto.

L'ordine di cancellazione

CloudFormation cancella in ordine inverso rispetto alle dipendenze:

  1. SplitHorizonLambdaNoInvocationAlarm → cancellato prima che la schedule si fermi, così la perdita di invocazioni non genera falsi allarmi
  2. SplitHorizonSchedule → le invocazioni si fermano
  3. SplitHorizonLambda → la funzione è cancellata, nessuna nuova invocazione può partire
  4. RestoreSeedRecordInvoke → fa UPSERT dei valori placeholder immediatamente
  5. PrivateRecordA → il DELETE di CloudFormation ora corrisponde ai valori attesi
  6. PrivateHostedZone → la zona è vuota, si cancella senza problemi

L'ordinamento è garantito perché SplitHorizonLambda dichiara DependsOn: RestoreSeedRecordInvoke: CloudFormation crea la funzione dopo il custom resource e la cancella prima. Una volta che la funzione non esiste più, nessuna nuova invocazione può partire né dalla schedule interna (già cancellata al passo 2) né da un invoker esterno, che riceve ResourceNotFoundException.

La race condition residua

Cancellare una funzione Lambda non termina un'invocazione già in corso. Un'invocazione partita poco prima del passo 2, e per qualche motivo ancora in esecuzione al passo 4, potrebbe fare UPSERT dei veri indirizzi dopo che il restore ha rimesso i placeholder. Questo renderebbe il DELETE al passo 5 fallimentare con InvalidChangeBatch.

Il gap tra il passo 2 e il passo 4 è circa 20 secondi, mentre una run normale finisce in uno o due secondi. Serve quindi una run bloccata in retry contro EC2 o Route 53 per innescare la race; alzare pLambdaTimeoutSeconds allarga la finestra.

Recovery in caso di delete fallito

Ritentare il delete dello stack da solo non basta: il custom resource è già stato cancellato e il restore non viene rieseguito. Per risolvere:

  1. Impostare manualmente il record A ai valori placeholder (10.1.2.3, 10.4.5.6)
  2. Ritentare la cancellazione dello stack

L'update del nome DNS

Quando si cambia pCustomAlbPrivateDnsName, CloudFormation sostituisce la hosted zone e il record (update-replace). Il custom resource riceve un evento Update con OldResourceProperties contenenti l'ID della vecchia zona e il vecchio nome DNS. La Lambda scrive i seed IP sulla vecchia zona, poi CloudFormation procede a eliminarla senza errori.

La sequenza durante l'update è:

  1. Nuova PrivateHostedZone e PrivateRecordA creati con il nuovo nome
  2. RestoreSeedRecordInvoke riceve Update → ripristina i seed sulla vecchia zona
  3. SplitHorizonLambda aggiornata (le env var ora puntano alla nuova zona)
  4. CloudFormation entra nella fase di cleanup: elimina la vecchia custom resource (Delete sulla vecchia physical resource) → la Lambda ripristina di nuovo i seed sulla vecchia zona
  5. PrivateRecordA vecchio eliminato (il DELETE corrisponde ai valori attesi)
  6. PrivateHostedZone vecchia eliminata

C'è una doppia sicurezza: poiché il custom resource stesso subisce un update-replace, CloudFormation genera sia un Update (passo 2) sia un Delete (passo 4) sulla vecchia risorsa. Anche se la Lambda split-horizon riuscisse a riscrivere gli IP reali tra i due passi, il Delete al passo 4 li riporta ai seed prima della cancellazione effettiva del record.

Una volta che la Lambda è aggiornata (passo 3), anche se lo scheduler la invoca, scrive sulla nuova zona e la vecchia non viene più toccata.

Scheduler esterno

Lo stack supporta anche l'uso di uno scheduler esterno. Un parametro (pEnableSchedule=false) disabilita lo schedule interno senza rompere le dipendenze CloudFormation. Chi ha già un orchestratore (cron, Step Functions, o altro) può invocare la Lambda direttamente, con un intervallo a piacere.

Il timeout della Lambda è parametrizzato: chi invoca ogni 30 secondi può abbassarlo a 25 per evitare sovrapposizioni.

Una nota sugli allarmi: se si configura pAlarmSnsTopicArn insieme a pEnableSchedule=false, l'allarme di assenza invocazioni (TreatMissingData: breaching) scatterà fino a quando lo scheduler esterno non inizia a invocare la Lambda. È un comportamento atteso: l'allarme rileva correttamente che la funzione non viene invocata. Se si usa un invoker esterno, assicurarsi che sia operativo entro 5 minuti dal deploy dello stack, oppure aspettarsi una notifica transitoria che rientra da sola appena le invocazioni iniziano.

Conclusioni

Il progetto è open source, disponibile su GitHub come singolo template CloudFormation deployabile in qualsiasi account e regione senza dipendenze esterne. L'obiettivo è fornire una soluzione operativa a un problema concreto che molte architetture AWS affrontano, a un costo irrisorio rispetto alla soluzione tradizionale.

Il codice sorgente, la documentazione completa e le istruzioni di deploy sono disponibili nella repository: github.com/polarity-dev/aws-split-horizon-alb.