Lab práctico · Semana 5: Redes en AWS (I): VPC, conectividad híbrida y costes de red
VPC desde cero con subredes públicas y privadas en dos AZ
Qué vas a construir
Una VPC completa, comando a comando con la AWS CLI desde CloudShell, para que entiendas qué hace cada pieza (en el trabajo lo harás con CloudFormation o Terraform, pero primero hay que saber qué se despliega):
- VPC
10.0.0.0/16con dos subredes públicas y dos privadas en dos AZ de eu-south-2. - Internet gateway y tablas de rutas pública y privada.
- Un servidor web (nginx) en una subred pública.
- Una instancia privada sin IP pública a la que entrarás con EC2 Instance Connect Endpoint (gratis, sin bastión ni claves SSH).
- Un NAT gateway para que la instancia privada salga a internet, y la comprobación de que antes no podía.
- Un experimento con network ACL (stateless, reglas deny) frente a security groups (stateful).
- VPC Flow Logs del tráfico rechazado, enviados a S3.
flowchart TB
inet(("Internet"))
cs["CloudShell (tu terminal)"]
subgraph vpc["lab09-vpc 10.0.0.0/16"]
igw["Internet gateway"]
subgraph a["AZ a"]
pub1["Pública 10.0.0.0/24<br/>NAT gateway"]
priv1["Privada 10.0.10.0/24<br/>instancia privada + EC2 Instance Connect Endpoint"]
end
subgraph b["AZ b"]
pub2["Pública 10.0.1.0/24<br/>servidor web nginx"]
priv2["Privada 10.0.11.0/24"]
end
end
inet --- igw
igw --- pub1
igw --- pub2
priv1 -- "0.0.0.0/0" --> pub1
priv2 -- "0.0.0.0/0" --> pub1
cs -- "HTTP" --> pub2
cs -- "SSH por el endpoint" --> priv1
Antes de empezar
- Entra con tu usuario de IAM Identity Center (o usuario IAM con MFA), nunca con root, en la región Europa (España) eu-south-2.
- Abre AWS CloudShell (icono
>_de la barra superior). - Este lab usa muchas variables de shell. CloudShell puede cerrar la sesión por inactividad, así que guardaremos cada ID en el fichero
~/lab09.env. Si se corta la sesión, recupéralas consource ~/lab09.env.
export AWS_REGION=eu-south-2
echo "export AWS_REGION=eu-south-2" > ~/lab09.env
TAGS='{Key=lab,Value=lab09}'
AZ1=$(aws ec2 describe-availability-zones --query 'AvailabilityZones[0].ZoneName' --output text)
AZ2=$(aws ec2 describe-availability-zones --query 'AvailabilityZones[1].ZoneName' --output text)
echo "export AZ1=$AZ1 AZ2=$AZ2" >> ~/lab09.env
echo "AZ1=$AZ1 AZ2=$AZ2"
Paso 1: crear la VPC
Una VPC con un /16 da margen para crecer. Activamos los nombres DNS para que las instancias reciban nombre de host (lo necesitan, entre otros, los interface endpoints del lab 10).
VPC_ID=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 \
--tag-specifications "ResourceType=vpc,Tags=[{Key=Name,Value=lab09-vpc},$TAGS]" \
--query Vpc.VpcId --output text)
aws ec2 modify-vpc-attribute --vpc-id $VPC_ID --enable-dns-hostnames '{"Value":true}'
echo "export VPC_ID=$VPC_ID" >> ~/lab09.env
echo $VPC_ID
Fíjate en la tabla de rutas principal, la network ACL por defecto y el security group por defecto que AWS crea con la VPC:
aws ec2 describe-route-tables --filters Name=vpc-id,Values=$VPC_ID --query 'RouteTables[].Routes'
aws ec2 describe-network-acls --filters Name=vpc-id,Values=$VPC_ID --query 'NetworkAcls[].Entries'
La tabla solo tiene la ruta local (10.0.0.0/16), y la NACL por defecto permite todo (regla 100 allow) antes de la regla * deny.
Paso 2: crear las cuatro subredes
crear_subred () {
aws ec2 create-subnet --vpc-id $VPC_ID --cidr-block $1 --availability-zone $2 \
--tag-specifications "ResourceType=subnet,Tags=[{Key=Name,Value=$3},$TAGS]" \
--query Subnet.SubnetId --output text
}
PUB1=$(crear_subred 10.0.0.0/24 $AZ1 lab09-publica-a)
PUB2=$(crear_subred 10.0.1.0/24 $AZ2 lab09-publica-b)
PRIV1=$(crear_subred 10.0.10.0/24 $AZ1 lab09-privada-a)
PRIV2=$(crear_subred 10.0.11.0/24 $AZ2 lab09-privada-b)
echo "export PUB1=$PUB1 PUB2=$PUB2 PRIV1=$PRIV1 PRIV2=$PRIV2" >> ~/lab09.env
aws ec2 describe-subnets --subnet-ids $PUB1 $PUB2 $PRIV1 $PRIV2 \
--query 'Subnets[].[Tags[?Key==`Name`]|[0].Value,CidrBlock,AvailabilityZone,AvailableIpAddressCount]' --output table
Observa AvailableIpAddressCount: 251 en cada /24, porque AWS reserva 5 direcciones por subred.
Paso 3: internet gateway y tabla de rutas pública
IGW_ID=$(aws ec2 create-internet-gateway \
--tag-specifications "ResourceType=internet-gateway,Tags=[{Key=Name,Value=lab09-igw},$TAGS]" \
--query InternetGateway.InternetGatewayId --output text)
aws ec2 attach-internet-gateway --internet-gateway-id $IGW_ID --vpc-id $VPC_ID
RT_PUB=$(aws ec2 create-route-table --vpc-id $VPC_ID \
--tag-specifications "ResourceType=route-table,Tags=[{Key=Name,Value=lab09-rt-publica},$TAGS]" \
--query RouteTable.RouteTableId --output text)
aws ec2 create-route --route-table-id $RT_PUB --destination-cidr-block 0.0.0.0/0 --gateway-id $IGW_ID
aws ec2 associate-route-table --route-table-id $RT_PUB --subnet-id $PUB1
aws ec2 associate-route-table --route-table-id $RT_PUB --subnet-id $PUB2
echo "export IGW_ID=$IGW_ID RT_PUB=$RT_PUB" >> ~/lab09.env
Y una tabla privada (de momento, solo con la ruta local) para las dos subredes privadas:
RT_PRIV=$(aws ec2 create-route-table --vpc-id $VPC_ID \
--tag-specifications "ResourceType=route-table,Tags=[{Key=Name,Value=lab09-rt-privada},$TAGS]" \
--query RouteTable.RouteTableId --output text)
aws ec2 associate-route-table --route-table-id $RT_PRIV --subnet-id $PRIV1
aws ec2 associate-route-table --route-table-id $RT_PRIV --subnet-id $PRIV2
echo "export RT_PRIV=$RT_PRIV" >> ~/lab09.env
Paso 4: security groups
Tres security groups: el del servidor web, el del EC2 Instance Connect Endpoint y el de la instancia privada (que solo acepta SSH desde el SG del endpoint, no desde un rango IP).
SG_WEB=$(aws ec2 create-security-group --group-name lab09-web --description "HTTP desde internet" \
--vpc-id $VPC_ID --query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id $SG_WEB --protocol tcp --port 80 --cidr 0.0.0.0/0
SG_EICE=$(aws ec2 create-security-group --group-name lab09-eice --description "EC2 Instance Connect Endpoint" \
--vpc-id $VPC_ID --query GroupId --output text)
SG_PRIV=$(aws ec2 create-security-group --group-name lab09-privada --description "SSH solo desde el endpoint" \
--vpc-id $VPC_ID --query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id $SG_PRIV --protocol tcp --port 22 --source-group $SG_EICE
echo "export SG_WEB=$SG_WEB SG_EICE=$SG_EICE SG_PRIV=$SG_PRIV" >> ~/lab09.env
Fíjate en que no creamos ninguna regla de salida: los security groups nuevos permiten toda la salida y, como son stateful, las respuestas a las conexiones permitidas vuelven solas.
Paso 5: lanzar el servidor web (subred pública b)
Usamos la AMI más reciente de Amazon Linux 2023 a través del parámetro público de SSM y un user data que instala nginx.
AMI=$(aws ssm get-parameter --name /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 \
--query Parameter.Value --output text)
cat > web.sh <<'EOF'
#!/bin/bash
dnf install -y nginx
echo "<h1>Hola desde la subred publica de lab09</h1>" > /usr/share/nginx/html/index.html
systemctl enable --now nginx
EOF
WEB_ID=$(aws ec2 run-instances --image-id $AMI --instance-type t3.micro \
--subnet-id $PUB2 --security-group-ids $SG_WEB --associate-public-ip-address \
--user-data file://web.sh \
--tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=lab09-web},$TAGS]" \
--query 'Instances[0].InstanceId' --output text)
echo "export WEB_ID=$WEB_ID AMI=$AMI" >> ~/lab09.env
aws ec2 wait instance-running --instance-ids $WEB_ID
WEB_IP=$(aws ec2 describe-instances --instance-ids $WEB_ID --query 'Reservations[0].Instances[0].PublicIpAddress' --output text)
echo "export WEB_IP=$WEB_IP" >> ~/lab09.env
echo $WEB_IP
Espera un par de minutos a que termine el user data y prueba desde CloudShell:
curl -s --max-time 5 http://$WEB_IP
Deberías ver el <h1>. Funciona porque se cumplen las cuatro condiciones: ruta al IGW, IP pública, security group que permite el puerto 80 y NACL por defecto que permite todo.
Paso 6: instancia privada y EC2 Instance Connect Endpoint
La instancia privada no tiene IP pública. Para entrar sin bastión usamos un EC2 Instance Connect Endpoint (EICE): un endpoint gestionado en la subred privada que abre un túnel SSH autenticado con IAM. Es gratuito (solo se cobraría transferencia entre AZ si la instancia estuviera en otra AZ; por eso lo ponemos en la misma).
PRIV_ID=$(aws ec2 run-instances --image-id $AMI --instance-type t3.micro \
--subnet-id $PRIV1 --security-group-ids $SG_PRIV --no-associate-public-ip-address \
--tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=lab09-privada},$TAGS]" \
--query 'Instances[0].InstanceId' --output text)
EICE_ID=$(aws ec2 create-instance-connect-endpoint --subnet-id $PRIV1 --security-group-ids $SG_EICE \
--tag-specifications "ResourceType=instance-connect-endpoint,Tags=[{Key=Name,Value=lab09-eice},$TAGS]" \
--query InstanceConnectEndpoint.InstanceConnectEndpointId --output text)
echo "export PRIV_ID=$PRIV_ID EICE_ID=$EICE_ID" >> ~/lab09.env
El endpoint tarda unos minutos en quedar en estado create-complete:
aws ec2 describe-instance-connect-endpoints --instance-connect-endpoint-ids $EICE_ID \
--query 'InstanceConnectEndpoints[0].State' --output text
Cuando esté listo, conéctate:
aws ec2-instance-connect ssh --instance-id $PRIV_ID --os-user ec2-user --connection-type eice
Ya dentro de la instancia privada, intenta salir a internet:
curl -sI --max-time 5 https://aws.amazon.com || echo "SIN SALIDA A INTERNET"
exit
Falla: la subred privada no tiene ninguna ruta 0.0.0.0/0.
Paso 7: NAT gateway (aquí empieza el contador de coste)
Creamos un NAT gateway zonal en la subred pública de la AZ a, con una Elastic IP, y añadimos la ruta por defecto en la tabla privada.
EIP_ALLOC=$(aws ec2 allocate-address --domain vpc \
--tag-specifications "ResourceType=elastic-ip,Tags=[{Key=Name,Value=lab09-eip-nat},$TAGS]" \
--query AllocationId --output text)
NAT_ID=$(aws ec2 create-nat-gateway --subnet-id $PUB1 --allocation-id $EIP_ALLOC \
--tag-specifications "ResourceType=natgateway,Tags=[{Key=Name,Value=lab09-nat},$TAGS]" \
--query NatGateway.NatGatewayId --output text)
echo "export EIP_ALLOC=$EIP_ALLOC NAT_ID=$NAT_ID" >> ~/lab09.env
date
aws ec2 wait nat-gateway-available --nat-gateway-ids $NAT_ID
aws ec2 create-route --route-table-id $RT_PRIV --destination-cidr-block 0.0.0.0/0 --nat-gateway-id $NAT_ID
Vuelve a entrar en la instancia privada y repite la prueba:
aws ec2-instance-connect ssh --instance-id $PRIV_ID --os-user ec2-user --connection-type eice
curl -sI --max-time 5 https://aws.amazon.com | head -1
curl -s --max-time 5 https://checkip.amazonaws.com
exit
Ahora sale, y checkip devuelve la Elastic IP del NAT, no una IP de la instancia. Compruébalo:
aws ec2 describe-addresses --allocation-ids $EIP_ALLOC --query 'Addresses[0].PublicIp' --output text
Paso 8: network ACL frente a security group
Vamos a demostrar que una NACL es stateless, que deniega todo por defecto si es personalizada y que sus reglas se evalúan en orden. Lo haremos en la subred pública b (la del servidor web), para no afectar al NAT gateway que vive en la subred pública a.
Crea una NACL personalizada y asóciala a PUB2:
NACL_ID=$(aws ec2 create-network-acl --vpc-id $VPC_ID \
--tag-specifications "ResourceType=network-acl,Tags=[{Key=Name,Value=lab09-nacl},$TAGS]" \
--query NetworkAcl.NetworkAclId --output text)
ASSOC_ID=$(aws ec2 describe-network-acls --filters Name=association.subnet-id,Values=$PUB2 \
--query "NetworkAcls[0].Associations[?SubnetId=='$PUB2'].NetworkAclAssociationId" --output text)
NEW_ASSOC=$(aws ec2 replace-network-acl-association --association-id $ASSOC_ID --network-acl-id $NACL_ID \
--query NewAssociationId --output text)
echo "export NACL_ID=$NACL_ID NEW_ASSOC=$NEW_ASSOC" >> ~/lab09.env
curl -s --max-time 5 http://$WEB_IP || echo "BLOQUEADO"
Bloqueado: una NACL personalizada nueva solo tiene la regla * deny. El security group sigue permitiendo el 80, pero la NACL se evalúa antes, en la frontera de la subred.
Permite la entrada al puerto 80:
aws ec2 create-network-acl-entry --network-acl-id $NACL_ID --ingress --rule-number 100 \
--protocol tcp --port-range From=80,To=80 --cidr-block 0.0.0.0/0 --rule-action allow
curl -s --max-time 5 http://$WEB_IP || echo "SIGUE BLOQUEADO"
Sigue bloqueado: la petición entra, pero la respuesta (del puerto 80 del servidor a un puerto efímero de tu CloudShell) no tiene regla de salida. Eso es stateless. Añade la salida a los puertos efímeros:
aws ec2 create-network-acl-entry --network-acl-id $NACL_ID --egress --rule-number 100 \
--protocol tcp --port-range From=1024,To=65535 --cidr-block 0.0.0.0/0 --rule-action allow
curl -s --max-time 5 http://$WEB_IP
Ahora funciona. Por último, deniega solo tu IP con una regla de número menor (se evalúa antes que la 100):
MI_IP=$(curl -s https://checkip.amazonaws.com)
aws ec2 create-network-acl-entry --network-acl-id $NACL_ID --ingress --rule-number 90 \
--protocol tcp --port-range From=80,To=80 --cidr-block $MI_IP/32 --rule-action deny
curl -s --max-time 5 http://$WEB_IP || echo "BLOQUEADA MI IP"
Eso es algo que un security group no puede hacer (no tiene reglas deny). Borra la regla 90 para seguir:
aws ec2 delete-network-acl-entry --network-acl-id $NACL_ID --ingress --rule-number 90
Paso 9: VPC Flow Logs del tráfico rechazado
Enviamos a S3 los flujos rechazados de toda la VPC (a S3 no hace falta crear un rol IAM; AWS añade la política necesaria al bucket).
CUENTA=$(aws sts get-caller-identity --query Account --output text)
BUCKET=lab09-flowlogs-$CUENTA-$RANDOM
aws s3 mb s3://$BUCKET --region eu-south-2
FL_ID=$(aws ec2 create-flow-logs --resource-type VPC --resource-ids $VPC_ID --traffic-type REJECT \
--log-destination-type s3 --log-destination arn:aws:s3:::$BUCKET/ --max-aggregation-interval 60 \
--query 'FlowLogIds[0]' --output text)
echo "export BUCKET=$BUCKET FL_ID=$FL_ID" >> ~/lab09.env
Genera tráfico rechazado: por ejemplo, intenta conectar a un puerto que el SG del servidor web no permite:
curl -s --max-time 5 http://$WEB_IP:8080 || echo "rechazado por el security group"
Espera unos 10 minutos (los Flow Logs no son en tiempo real) y busca los ficheros:
aws s3 ls s3://$BUCKET --recursive | head
Descarga uno y busca tu IP con acción REJECT y puerto de destino 8080:
F=$(aws s3 ls s3://$BUCKET --recursive | awk '{print $4}' | head -1)
aws s3 cp s3://$BUCKET/$F - | gunzip | grep 8080 | head
Comprueba que funciona
-
curl http://$WEB_IPdevuelve la página desde CloudShell con la NACL con reglas 100 de entrada y salida. - La instancia privada no salía a internet antes del NAT y sí después, con la IP pública del NAT.
- Has visto cómo la NACL bloquea sin la regla de salida de puertos efímeros (stateless) y cómo una regla deny de número menor gana.
- Hay registros
REJECTen el bucket de Flow Logs.
Limpieza
Obligatoria y en este orden (hay dependencias). Si tu sesión de CloudShell se ha reiniciado, empieza con source ~/lab09.env.
- Borra el NAT gateway (lo más caro) y libera su Elastic IP:
source ~/lab09.env
aws ec2 delete-nat-gateway --nat-gateway-id $NAT_ID
aws ec2 wait nat-gateway-deleted --nat-gateway-ids $NAT_ID
aws ec2 release-address --allocation-id $EIP_ALLOC
date
- Termina las instancias y borra el EC2 Instance Connect Endpoint:
aws ec2 terminate-instances --instance-ids $WEB_ID $PRIV_ID
aws ec2 delete-instance-connect-endpoint --instance-connect-endpoint-id $EICE_ID
aws ec2 wait instance-terminated --instance-ids $WEB_ID $PRIV_ID
- Borra el flow log y el bucket:
aws ec2 delete-flow-logs --flow-log-ids $FL_ID
aws s3 rb s3://$BUCKET --force
- Borra los security groups (el del endpoint puede tardar un poco en liberarse; si da
DependencyViolation, espera un minuto y repite):
aws ec2 delete-security-group --group-id $SG_WEB
aws ec2 delete-security-group --group-id $SG_PRIV
aws ec2 delete-security-group --group-id $SG_EICE
- Borra las subredes (eso elimina sus asociaciones con NACL y tablas), la NACL, las tablas de rutas, el internet gateway y la VPC:
for s in $PUB1 $PUB2 $PRIV1 $PRIV2; do aws ec2 delete-subnet --subnet-id $s; done
aws ec2 delete-network-acl --network-acl-id $NACL_ID
aws ec2 delete-route-table --route-table-id $RT_PUB
aws ec2 delete-route-table --route-table-id $RT_PRIV
aws ec2 detach-internet-gateway --internet-gateway-id $IGW_ID --vpc-id $VPC_ID
aws ec2 delete-internet-gateway --internet-gateway-id $IGW_ID
aws ec2 delete-vpc --vpc-id $VPC_ID
rm -f ~/lab09.env web.sh
- Comprueba que no queda nada que cobre:
aws ec2 describe-nat-gateways --filter Name=state,Values=available,pending --query 'NatGateways[].NatGatewayId'
aws ec2 describe-addresses --query 'Addresses[].[PublicIp,AllocationId]'
aws ec2 describe-instances --filters Name=tag:lab,Values=lab09 Name=instance-state-name,Values=running,pending --query 'Reservations[].Instances[].InstanceId'
Las tres listas deben salir vacías. Mañana, revisa en Billing and Cost Management → Bills el cargo de «NAT Gateway» en eu-south-2: te ayudará a interiorizar cuánto cuesta.
Preguntas para pensar como arquitecto
1. En este lab ambas subredes privadas salían por un único NAT en la AZ a. ¿Qué pasa si cae la AZ a y cómo lo arreglarías?
La subred privada de la AZ b perdería la salida a internet, aunque sus instancias sigan vivas: el NAT compartido es un punto único de fallo. En producción: un NAT gateway zonal por AZ con una tabla de rutas privada por AZ apuntando a su NAT local (además evitas pagar transferencia entre AZ hacia el NAT), o bien un NAT gateway regional, que se extiende solo por las AZ con cargas y usa un único ID en todas las rutas.
2. La instancia privada descarga 2 TB al mes de un bucket S3 de la misma región a través del NAT. ¿Cómo reducirías el coste?
Con un gateway endpoint de S3 asociado a la tabla de rutas privada: es gratis y la ruta hacia la prefix list de S3 gana a 0.0.0.0/0. Te ahorras el procesamiento del NAT (0,048 USD/GB en eu-south-2, unos 98 USD al mes por 2 TB). Lo harás en el lab 10.
3. ¿Por qué el acceso SSH a la instancia privada se permite «desde el security group del endpoint» y no desde un CIDR?
Porque así la regla sigue siendo válida aunque cambie la IP del endpoint y solo acepta tráfico que venga de recursos con ese SG. Referenciar security groups es la forma recomendada de encadenar niveles (ALB → app → base de datos) sin depender de IP.
4. Te piden bloquear una red concreta que está escaneando tu web. ¿NACL o security group? ¿Hay algo mejor?
Network ACL con una regla deny de número bajo en las subredes públicas: el security group no puede denegar. Si el tráfico es HTTP/HTTPS y pasa por un ALB o CloudFront, AWS WAF (con conjuntos de IP o reglas rate-based) es más flexible y no tiene el límite de 20 reglas de la NACL (módulo 6).
5. Tu organización va a tener 30 VPC como esta. ¿Qué cambiarías del diseño de direccionamiento?
Planificar los CIDR sin solapamientos entre VPC y con la red local (por ejemplo con Amazon VPC IPAM), reservar bloques por entorno y región, y conectar las VPC con Transit Gateway en lugar de peerings. Podrías centralizar la salida a internet en una VPC de egress con NAT (y Network Firewall), accesible por Transit Gateway, para no pagar un NAT por AZ en cada VPC.