Subredes, routing y alta disponibilidad en VPC
Diseñar una VPC que “funcione” es fácil. Diseñar una VPC que resista fallos reales es otra historia. Aquí es donde muchas arquitecturas cloud se rompen: no por falta de servicios, sino por una red mal pensada.
En Amazon Web Services, la alta disponibilidad no viene por defecto. Se construye. Y la base está en cómo distribuyes subredes, rutas y salidas a red entre Availability Zones.
He visto sistemas con réplicas de base de datos, autoscaling y health checks impecables… que se caían completamente cuando fallaba una AZ. ¿Por qué? Porque la red estaba centralizada en un único punto.
Este artículo va de eso: de pasar de una VPC que funciona en condiciones normales a una VPC que resiste cuando todo va mal.
Availability Zones: usarlas bien o no usarlas
Una Availability Zone no es un concepto abstracto. Es un datacenter físicamente separado, con alimentación eléctrica independiente, refrigeración propia y conectividad redundante. Si todo tu sistema vive en una sola AZ, no tienes alta disponibilidad, aunque estés “en la nube”.
El primer principio es simple:
Cada AZ debe ser autosuficiente.
Eso implica:
- Subred pública por AZ
- Subred privada por AZ
- Rutas coherentes en cada una
- Acceso a salida (NAT) desde cada AZ
El error del “NAT único”
Un error clásico que veo constantemente: crear subredes privadas en varias AZ, pero una sola subnet pública con un único NAT Gateway.
¿Qué pasa cuando esa AZ cae?
AZ-a (🔴 CAÍDA)
├── Subnet pública → NAT Gateway (muerto)
└── Internet Gateway (inaccesible)
AZ-b (🟢 viva)
├── Subnet privada → intenta usar NAT de AZ-a
└── ❌ Sin salida a Internet
AZ-c (🟢 viva)
├── Subnet privada → intenta usar NAT de AZ-a
└── ❌ Sin salida a Internet
Resultado: Toda tu infraestructura privada pierde capacidad de actualizar paquetes, llamar APIs externas o acceder a servicios AWS. Arquitectura frágil.
Subredes duplicadas: patrón obligatorio en producción
El patrón correcto no es “tengo tres subredes”, sino:
VPC: 10.0.0.0/16
AZ-a (us-east-1a)
├── Public Subnet: 10.0.1.0/24
│ ├── NAT Gateway nat-a
│ └── Application Load Balancer
└── Private Subnet: 10.0.10.0/24
├── EC2 instances
├── EKS nodes
└── RDS (primary)
AZ-b (us-east-1b)
├── Public Subnet: 10.0.2.0/24
│ ├── NAT Gateway nat-b
│ └── Application Load Balancer
└── Private Subnet: 10.0.11.0/24
├── EC2 instances
├── EKS nodes
└── RDS (standby)
AZ-c (us-east-1c)
├── Public Subnet: 10.0.3.0/24
│ ├── NAT Gateway nat-c
│ └── Application Load Balancer
└── Private Subnet: 10.0.12.0/24
├── EC2 instances
└── EKS nodes
Esto parece redundante, pero es intencional. Cada capa se replica para eliminar puntos únicos de fallo.
Beneficios inmediatos
En este modelo:
- ✅ Los ALB viven en subredes públicas de varias AZ → distribución automática del tráfico
- ✅ Los servicios backend se reparten en subredes privadas → si una AZ cae, el resto sigue vivo
- ✅ Cada AZ tiene su propio NAT → no hay dependencias cruzadas
- ✅ El tráfico se balancea automáticamente entre zonas
- ✅ La red deja de ser un cuello de botella y pasa a ser transparente
Caso real: e-commerce con 50K peticiones/minuto
Una empresa para la que trabajé tenía este setup. Durante un Black Friday, us-east-1a se cayó durante 25 minutos.
¿Impacto en usuarios? Cero.
El ALB detectó automáticamente que los targets en AZ-a no respondían, dejó de enviar tráfico allí y redistribuyó todo a AZ-b y AZ-c. Los usuarios ni se enteraron.
Si hubiesen tenido todo centralizado o con NAT único, habrían perdido cientos de miles de euros.
Route Tables: el detalle que define la disponibilidad
Las route tables suelen configurarse una vez… y olvidarse. Grave error.
Cada tipo de subnet debería tener su propia tabla de rutas, clara y específica:
- Tabla pública: salida directa al Internet Gateway
- Tabla privada: salida vía NAT Gateway
- Tablas especiales: endpoints, VPN, peering, Transit Gateway
Ejemplo práctico de Route Tables
Route Table para subredes públicas (compartida entre AZ):
Destination Target Note
10.0.0.0/16 local Tráfico interno VPC
0.0.0.0/0 igw-abc123 Salida a Internet
Route Table para subnet privada AZ-a:
Destination Target Note
10.0.0.0/16 local Tráfico interno VPC
0.0.0.0/0 nat-a Salida vía NAT de AZ-a
Route Table para subnet privada AZ-b:
Destination Target Note
10.0.0.0/16 local Tráfico interno VPC
0.0.0.0/0 nat-b Salida vía NAT de AZ-b
¿Ves el patrón? Cada subnet privada apunta al NAT de su propia AZ. Nada de rutas cruzadas. Nada de “optimizaciones” que te dejan sin salida cuando una zona cae.
El peligro de reutilizar tablas
Cuando reutilizas tablas “por comodidad”, pierdes control. Y cuando pierdes control, no sabes por qué algo deja de funcionar a las 3 de la mañana.
He debuggeado incidentes donde el problema era simplemente que alguien cambió una route table compartida y afectó a 15 subredes sin darse cuenta.
Routing explícito es arquitectura mantenible.
NAT Gateway: caro, sí. Imprescindible, también.
El NAT Gateway es uno de los recursos que más debate genera por coste. Un NAT Gateway cuesta aproximadamente:
- $0.045/hora (~$32/mes por estar activo)
- $0.045/GB de datos procesados
Si tienes 3 AZs, son ~$100/mes solo por tenerlos ahí. Parece mucho, ¿verdad?
¿Vale la pena?
Sí, absolutamente.
Eliminarlo o centralizarlo suele salir mucho más caro a medio plazo:
Opción mala: 1 NAT para todas las AZs
- Ahorras $64/mes
- Pierdes resiliencia total
- Pagas transferencia inter-AZ ($0.01/GB adicional)
- Un fallo te deja sin servicio
- Costo de un incidente: $$$$$
Opción buena: 1 NAT por AZ
- Gastas $96/mes
- Resiliencia real
- Sin transferencias inter-AZ innecesarias
- Arquitectura predecible
- Duermes tranquilo
Buenas prácticas claras
# Terraform: NAT Gateway por AZ
resource "aws_nat_gateway" "nat_az_a" {
allocation_id = aws_eip.nat_az_a.id
subnet_id = aws_subnet.public_az_a.id
tags = {
Name = "NAT Gateway AZ-A"
AZ = "us-east-1a"
}
}
resource "aws_nat_gateway" "nat_az_b" {
allocation_id = aws_eip.nat_az_b.id
subnet_id = aws_subnet.public_az_b.id
tags = {
Name = "NAT Gateway AZ-B"
AZ = "us-east-1b"
}
}
# Route Table para subnet privada AZ-A
resource "aws_route" "private_az_a_nat" {
route_table_id = aws_route_table.private_az_a.id
destination_cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.nat_az_a.id
}
¿Por qué esta configuración?
Porque si AZ-a cae:
- ✅ Las subredes privadas de AZ-b y AZ-c siguen teniendo salida
- ✅ No dependes de rutas inter-AZ que podrían fallar
- ✅ Evitas latencia innecesaria (cada AZ usa su NAT local)
- ✅ Los logs y el troubleshooting son más claros
Esto no es sobre gastar más. Es sobre no diseñar puntos únicos de fallo.
Alternativa para entornos de desarrollo
Si realmente necesitas ahorrar en dev/staging:
# NAT Instance (solo para dev/test)
# No recomendado para producción
resource "aws_instance" "nat_instance" {
ami = "ami-nat-instance"
instance_type = "t4g.nano" # ~$3/mes
source_dest_check = false
associate_public_ip_address = true
tags = {
Name = "NAT Instance (DEV ONLY)"
}
}
Pero en producción, usa NAT Gateway gestionado. Punto.
Load Balancers y subredes: relación crítica
Los Application Load Balancer (ALB) solo pueden operar correctamente si están asociados a subredes públicas en múltiples AZ. No es opcional si buscas alta disponibilidad.
AWS requiere mínimo 2 AZs para crear un ALB. Y con razón.
Un ALB bien configurado
resource "aws_lb" "main" {
name = "production-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
# Crítico: múltiples subredes públicas
subnets = [
aws_subnet.public_az_a.id,
aws_subnet.public_az_b.id,
aws_subnet.public_az_c.id,
]
enable_deletion_protection = true
enable_http2 = true
}
resource "aws_lb_target_group" "backend" {
name = "backend-targets"
port = 8080
protocol = "HTTP"
vpc_id = aws_vpc.main.id
health_check {
enabled = true
path = "/health"
interval = 30
timeout = 5
healthy_threshold = 2
unhealthy_threshold = 3
}
}
Flujo de tráfico:
- ✅ Usuario → DNS → ALB (distribuido en 3 AZs)
- ✅ ALB recibe tráfico en subnet pública
- ✅ ALB distribuye hacia targets privados en múltiples AZs
- ✅ Health checks detectan fallos automáticamente
- ✅ Si una AZ cae, ALB deja de enviarle tráfico
El problema de los targets mal distribuidos
Imagina este escenario:
ALB configurado en:
- AZ-a ✅
- AZ-b ✅
- AZ-c ✅
Pero tus instancias EC2 están SOLO en:
- AZ-a (10 instancias)
¿Qué pasa? El ALB está distribuido, pero los targets no. Si AZ-a cae, tu servicio se cae. El balanceo es solo teórico.
La solución correcta:
# Auto Scaling Group distribuido
resource "aws_autoscaling_group" "backend" {
name = "backend-asg"
vpc_zone_identifier = [
aws_subnet.private_az_a.id,
aws_subnet.private_az_b.id,
aws_subnet.private_az_c.id,
]
target_group_arns = [aws_lb_target_group.backend.arn]
min_size = 3
max_size = 30
desired_capacity = 9
# Distribuir uniformemente entre AZs
capacity_rebalance = true
}
Con capacity_rebalance = true, AWS intenta mantener el mismo número de instancias en cada AZ.
La red y el balanceador no son capas independientes. Se diseñan juntas.
VPC Endpoints: menos Internet, más resiliencia
Uno de los errores más comunes es hacer que todo el tráfico hacia servicios AWS salga a Internet.
Escenario típico:
EC2 en subnet privada
↓
NAT Gateway ($$$)
↓
Internet
↓
API de S3 (que está en la misma región AWS)
Estás pagando por NAT y por transferencia de datos para hablar con un servicio que está a 2ms de distancia en la red privada de AWS.
La solución: VPC Endpoints
# Gateway Endpoint para S3 (GRATIS)
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.us-east-1.s3"
route_table_ids = [
aws_route_table.private_az_a.id,
aws_route_table.private_az_b.id,
aws_route_table.private_az_c.id,
]
tags = {
Name = "S3 Gateway Endpoint"
}
}
# Interface Endpoint para ECR (de pago pero vale la pena)
resource "aws_vpc_endpoint" "ecr_api" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.us-east-1.ecr.api"
vpc_endpoint_type = "Interface"
private_dns_enabled = true
subnet_ids = [
aws_subnet.private_az_a.id,
aws_subnet.private_az_b.id,
aws_subnet.private_az_c.id,
]
security_group_ids = [aws_security_group.vpc_endpoints.id]
}
Ventajas medibles
- ⚡ Menos latencia: Tráfico directo sin salir a Internet
- 💰 Menos coste: Sin cargos de NAT para tráfico a S3/DynamoDB
- 🔒 Menos superficie de ataque: El tráfico nunca sale de la red AWS
- 🎯 Arquitectura más predecible: Menos dependencia de Internet Gateway
Caso real: Cluster Kubernetes con ECR
Antes de VPC Endpoints:
- Pull de imágenes → NAT → Internet → ECR
- Coste NAT: ~$500/mes
- Latencia: 100-200ms
- Dependencia de Internet
Después de VPC Endpoints:
- Pull de imágenes → VPC Endpoint → ECR
- Coste endpoint: ~$22/mes
- Latencia: 10-20ms
- Sin dependencia de Internet
Ahorro: $478/mes + arquitectura más robusta.
Si tu VPC depende menos de Internet, falla menos.
Pensar en fallos desde el diseño
La diferencia entre una VPC amateur y una profesional no está en los servicios, sino en las suposiciones.
Una VPC madura asume que:
- ❌ Una AZ puede caer (y caerá)
- ❌ Una ruta puede romperse
- ❌ Un NAT puede saturarse
- ❌ Un despliegue puede fallar
- ❌ Internet puede ser inestable
Y aun así, el sistema sigue funcionando.
Eso no se arregla con más monitoring después. Se diseña antes.
Checklist de alta disponibilidad en VPC
Usa esto antes de llevar una VPC a producción:
Subredes:
✅ Al menos 2 AZs (idealmente 3)
✅ Subnet pública por cada AZ
✅ Subnet privada por cada AZ
✅ Rangos CIDR bien distribuidos
Routing:
✅ Route table específica para cada tipo de subnet
✅ Cada subnet privada apunta al NAT de su misma AZ
✅ Rutas documentadas y versionadas (Terraform/IaC)
NAT:
✅ Un NAT Gateway por AZ (no compartido)
✅ Elastic IPs asignadas y documentadas
✅ Monitoring de costes configurado
Load Balancers:
✅ ALB/NLB configurado en múltiples AZs
✅ Health checks activos
✅ Targets distribuidos uniformemente
Endpoints:
✅ Gateway Endpoint para S3 (gratis)
✅ Gateway Endpoint para DynamoDB (gratis)
✅ Interface Endpoints para servicios críticos (ECR, SSM, Secrets)
Security:
✅ Security Groups por capa (ALB, backend, DB)
✅ NACLs solo si son necesarios
✅ Flow Logs activados para debugging
Observabilidad:
✅ VPC Flow Logs → CloudWatch / S3
✅ Alarmas en CloudWatch para NAT Gateway
✅ Dashboards de métricas de red
Errores comunes que matan la alta disponibilidad
1. “Tenemos multi-AZ en RDS, ya estamos cubiertos”
RDS Multi-AZ no te sirve de nada si tu red depende de un único NAT en la AZ primaria.
2. “Autoscaling arregla todo”
Autoscaling no arregla arquitectura de red frágil. Si tus subredes están mal, escalar solo distribuye el problema.
3. “VPC Peering puede esperar”
Conectar VPCs después de tener todo desplegado es un infierno. Diseña las conexiones desde el principio.
4. “Los costes de NAT son exagerados”
El coste de un incidente de producción por no tener NAT redundante supera años de gastos en NAT Gateways.
5. “La VPC es responsabilidad de DevOps”
La VPC es responsabilidad de todos. Desarrolladores, SRE, arquitectos, seguridad. Todos deben entenderla.
Herramientas para validar tu VPC
# AWS CLI: Verificar distribución de subredes
aws ec2 describe-subnets \
--filters "Name=vpc-id,Values=vpc-xxx" \
--query 'Subnets[*].[SubnetId,AvailabilityZone,CidrBlock,Tags[?Key==`Name`].Value]' \
--output table
# Verificar NAT Gateways por AZ
aws ec2 describe-nat-gateways \
--filter "Name=vpc-id,Values=vpc-xxx" \
--query 'NatGateways[*].[NatGatewayId,SubnetId,State]' \
--output table
# Verificar Route Tables
aws ec2 describe-route-tables \
--filters "Name=vpc-id,Values=vpc-xxx" \
--query 'RouteTables[*].{ID:RouteTableId,Routes:Routes}' \
--output json
Conclusión: diseña para el caos
Alta disponibilidad no es marcar una casilla. Es el resultado de decisiones coherentes en:
- ✅ Distribución de subredes
- ✅ Routing independiente por AZ
- ✅ Eliminación de puntos únicos de fallo
- ✅ Reducción de dependencias externas
- ✅ Observabilidad desde el diseño
Una VPC bien diseñada no se nota cuando todo va bien. Se nota cuando algo va mal… y no pasa nada.
He estado en incidentes donde una AZ entera se caía y el único impacto era un mensaje en Slack: “AZ-a degradada, tráfico redirigido automáticamente”. Sin alertas. Sin escaladas. Sin pánico.
Eso es alta disponibilidad real.
No es suerte. Es arquitectura.