Lab 1: Sitio Web Primario

Lab 1: Sitio Web Primario (EC2 + ALB)

En este laboratorio desplegaremos el sitio web principal compuesto por una instancia EC2 ejecutando Amazon Linux 2023 con servidor Nginx, frente a un Application Load Balancer (ALB) público.

Usuario → Application Load Balancer (puerto 80) → EC2 Amazon Linux 2023 + Nginx

Cargar variables de entorno

source ~/global.env

Opción A: CloudFormation (Base)

Elegí esta opción si preferís usar plantillas YAML declarativas desplegadas con AWS CLI.

1. Crear el template CloudFormation

cat > /tmp/primary-site.yaml << 'TEMPLATE_EOF'
AWSTemplateFormatVersion: '2010-09-09'
Description: 'roLearning - Route53 ARC Workshop - Sitio Primario (EC2 + ALB)'

Parameters:
  UseDefaultVPC:
    Type: String
    Default: "true"
    AllowedValues: ["true", "false"]
    Description: "true = usar VPC default existente | false = crear VPC nueva"

  VpcId:
    Type: String
    Default: ""
    Description: "VPC ID de la VPC default (requerido si UseDefaultVPC=true)"

  SubnetId1:
    Type: String
    Default: ""
    Description: "Subnet ID pública 1 (requerido si UseDefaultVPC=true)"

  SubnetId2:
    Type: String
    Default: ""
    Description: "Subnet ID pública 2 (requerido si UseDefaultVPC=true)"

  InstanceType:
    Type: String
    Default: t4g.micro
    AllowedValues: [t4g.micro, t3.micro]
    Description: "t4g.micro (ARM/Graviton, recomendado) o t3.micro (x86)"

Conditions:
  CreateNewVPC: !Equals [!Ref UseDefaultVPC, "false"]
  UseExistingVPC: !Equals [!Ref UseDefaultVPC, "true"]
  IsARM:        !Equals [!Ref InstanceType, "t4g.micro"]

Resources:
  # ── VPC Nueva (si UseDefaultVPC = false) ──
  WorkshopVPC:
    Type: AWS::EC2::VPC
    Condition: CreateNewVPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags: [{ Key: Name, Value: route53-arc-vpc }]

  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Condition: CreateNewVPC
    Properties:
      Tags: [{ Key: Name, Value: route53-arc-igw }]

  AttachGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Condition: CreateNewVPC
    Properties:
      VpcId: !Ref WorkshopVPC
      InternetGatewayId: !Ref InternetGateway

  PublicSubnet1:
    Type: AWS::EC2::Subnet
    Condition: CreateNewVPC
    Properties:
      VpcId: !Ref WorkshopVPC
      CidrBlock: 10.0.1.0/24
      AvailabilityZone: !Select [0, !GetAZs '']
      MapPublicIpOnLaunch: true

  PublicSubnet2:
    Type: AWS::EC2::Subnet
    Condition: CreateNewVPC
    Properties:
      VpcId: !Ref WorkshopVPC
      CidrBlock: 10.0.2.0/24
      AvailabilityZone: !Select [1, !GetAZs '']
      MapPublicIpOnLaunch: true

  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Condition: CreateNewVPC
    Properties:
      VpcId: !Ref WorkshopVPC

  PublicRoute:
    Type: AWS::EC2::Route
    Condition: CreateNewVPC
    DependsOn: AttachGateway
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway

  Subnet1RouteAssoc:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Condition: CreateNewVPC
    Properties:
      SubnetId: !Ref PublicSubnet1
      RouteTableId: !Ref PublicRouteTable

  Subnet2RouteAssoc:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Condition: CreateNewVPC
    Properties:
      SubnetId: !Ref PublicSubnet2
      RouteTableId: !Ref PublicRouteTable

  # ── Security Groups ──
  ALBSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: "SG ALB - HTTP publico"
      VpcId: !If [CreateNewVPC, !Ref WorkshopVPC, !Ref VpcId]
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          CidrIp: 0.0.0.0/0
      Tags: [{ Key: Name, Value: route53-arc-alb-sg }]

  EC2SecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: "SG EC2 - Acepta trafico solo del ALB"
      VpcId: !If [CreateNewVPC, !Ref WorkshopVPC, !Ref VpcId]
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          SourceSecurityGroupId: !Ref ALBSecurityGroup
      Tags: [{ Key: Name, Value: route53-arc-ec2-sg }]

  # ── IAM Role para SSM ──
  EC2SSMRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: route53-arc-ec2-ssm-role-cfn
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal: { Service: ec2.amazonaws.com }
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore

  EC2InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      InstanceProfileName: route53-arc-ec2-profile-cfn
      Roles: [!Ref EC2SSMRole]

  # ── Instancia EC2 ──
  PrimaryWebServer:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: !Ref InstanceType
      ImageId: !If
        - IsARM
        - !Sub "{{resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64}}"
        - !Sub "{{resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64}}"
      SubnetId: !If [CreateNewVPC, !Ref PublicSubnet1, !Ref SubnetId1]
      SecurityGroupIds: [!Ref EC2SecurityGroup]
      IamInstanceProfile: !Ref EC2InstanceProfile
      UserData:
        Fn::Base64: !Sub |
          #!/bin/bash
          set -e
          dnf install -y nginx
          systemctl enable nginx
          systemctl start nginx

          cat > /usr/share/nginx/html/index.html << 'HTML'
          <!DOCTYPE html>
          <html lang="es">
          <head>
            <meta charset="UTF-8">
            <title>Sitio Primario – roLearning</title>
            <style>
              * { margin: 0; padding: 0; box-sizing: border-box; }
              body { font-family: 'Segoe UI', Arial, sans-serif; background: #0f172a; color: #e2e8f0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
              .card { background: #1e293b; border-radius: 12px; padding: 48px 64px; text-align: center; box-shadow: 0 4px 24px rgba(0,0,0,0.4); }
              h1 { color: #22c55e; font-size: 2.5em; margin-bottom: 8px; }
              h2 { color: #94a3b8; font-size: 1.1em; font-weight: normal; margin-bottom: 24px; }
              .badge { display: inline-block; background: #22c55e; color: #0f172a; padding: 6px 20px; border-radius: 20px; font-weight: bold; }
              .meta { color: #475569; margin-top: 24px; font-size: 0.85em; line-height: 1.8; }
            </style>
          </head>
          <body>
            <div class="card">
              <h1>✅ Sitio Primario</h1>
              <h2>roLearning – AWS Route 53 ARC Workshop</h2>
              <div class="badge">ONLINE</div>
              <p class="meta">Desplegado con <strong>CloudFormation</strong></p>
            </div>
          </body>
          </html>
          HTML

          echo "OK" > /usr/share/nginx/html/health
          systemctl restart nginx
      Tags: [{ Key: Name, Value: route53-arc-primary-web }]

  # ── ALB y Target Group ──
  PrimaryALB:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: route53-arc-primary-alb
      Scheme: internet-facing
      Type: application
      Subnets: !If
        - CreateNewVPC
        - [!Ref PublicSubnet1, !Ref PublicSubnet2]
        - [!Ref SubnetId1, !Ref SubnetId2]
      SecurityGroups: [!Ref ALBSecurityGroup]

  PrimaryTargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Name: route53-arc-primary-tg
      Port: 80
      Protocol: HTTP
      VpcId: !If [CreateNewVPC, !Ref WorkshopVPC, !Ref VpcId]
      Targets: [{ Id: !Ref PrimaryWebServer, Port: 80 }]
      HealthCheckPath: /health
      HealthCheckIntervalSeconds: 30
      HealthCheckTimeoutSeconds: 5
      HealthyThresholdCount: 2
      UnhealthyThresholdCount: 3

  PrimaryListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref PrimaryALB
      Port: 80
      Protocol: HTTP
      DefaultActions: [{ Type: forward, TargetGroupArn: !Ref PrimaryTargetGroup }]

Outputs:
  ALBDNSName:
    Value: !GetAtt PrimaryALB.DNSName
    Export: { Name: route53-arc-alb-dns }
  ALBHostedZoneID:
    Value: !GetAtt PrimaryALB.CanonicalHostedZoneID
    Export: { Name: route53-arc-alb-hz-id }
  EC2InstanceID:
    Value: !Ref PrimaryWebServer
    Export: { Name: route53-arc-ec2-id }
TEMPLATE_EOF

2. Desplegar el Stack

El script resuelve el VPC ID y las subnets reales con aws ec2 describe-* antes de llamar a CloudFormation, evitando dependencias de parámetros SSM que no existen para la VPC default.

Información

⏱️ El deploy de CloudFormation tarda 3-5 minutos. El cursor queda quieto durante ese tiempo — es normal. El comando imprimirá Successfully created/updated stack al finalizar.

# Resolver VPC y subnets para la VPC default (si aplica)
if [ "$USE_DEFAULT_VPC" = "true" ]; then
  DEFAULT_VPC_ID=$(aws ec2 describe-vpcs \
    --filters "Name=is-default,Values=true" \
    --query "Vpcs[0].VpcId" --output text --region $AWS_REGION)

  # Obtener las primeras 2 subnets de la VPC default
  SUBNET_IDS=$(aws ec2 describe-subnets \
    --filters "Name=vpc-id,Values=${DEFAULT_VPC_ID}" "Name=default-for-az,Values=true" \
    --query "Subnets[0:2].SubnetId" --output text --region $AWS_REGION)

  SUBNET_1=$(echo $SUBNET_IDS | awk '{print $1}')
  SUBNET_2=$(echo $SUBNET_IDS | awk '{print $2}')

  echo "✅ VPC Default: $DEFAULT_VPC_ID"
  echo "✅ Subnets: $SUBNET_1 | $SUBNET_2"

  EXTRA_PARAMS="VpcId=$DEFAULT_VPC_ID SubnetId1=$SUBNET_1 SubnetId2=$SUBNET_2"
else
  EXTRA_PARAMS=""
fi

# Validar el template antes de desplegarlo
aws cloudformation validate-template \
  --template-body file:///tmp/primary-site.yaml \
  --region $AWS_REGION > /dev/null \
  && echo "✅ Template válido" \
  || { echo "❌ Error en el template — revisá el bloque de código anterior"; exit 1; }

aws cloudformation deploy \
  --template-file /tmp/primary-site.yaml \
  --stack-name route53-arc-primary \
  --capabilities CAPABILITY_NAMED_IAM \
  --region $AWS_REGION \
  --parameter-overrides UseDefaultVPC=$USE_DEFAULT_VPC InstanceType=t4g.micro $EXTRA_PARAMS

export PRIMARY_ALB_DNS=$(aws cloudformation describe-stacks \
  --stack-name route53-arc-primary --region $AWS_REGION \
  --query 'Stacks[0].Outputs[?OutputKey==`ALBDNSName`].OutputValue' --output text)

echo "🌐 ALB DNS (CloudFormation): http://$PRIMARY_ALB_DNS"

Opción B: Terraform (Segunda Opción)

Detalles

🚫 Opción B requiere terminal local — no uses CloudShell con Terraform. Ver Lab 0 para más detalles.

1. Crear los archivos del módulo

cd ~/route53-arc-tf/01_primary

# variables.tf
cat > variables.tf << 'EOF'
variable "aws_region"    { type = string; default = "us-east-1" }
variable "vpc_mode"      { type = string; default = "default" }
variable "instance_type" { type = string; default = "t4g.micro" }
variable "project_tag"   { type = string; default = "route53-arc" }
EOF

# userdata.sh
cat > userdata.sh << 'EOF'
#!/bin/bash
set -e
dnf install -y nginx
systemctl enable nginx
systemctl start nginx

cat > /usr/share/nginx/html/index.html << 'HTML'
<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <title>Sitio Primario – roLearning</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: 'Segoe UI', Arial, sans-serif; background: #0f172a; color: #e2e8f0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
    .card { background: #1e293b; border-radius: 12px; padding: 48px 64px; text-align: center; box-shadow: 0 4px 24px rgba(0,0,0,0.4); }
    h1 { color: #22c55e; font-size: 2.5em; margin-bottom: 8px; }
    h2 { color: #94a3b8; font-size: 1.1em; font-weight: normal; margin-bottom: 24px; }
    .badge { display: inline-block; background: #22c55e; color: #0f172a; padding: 6px 20px; border-radius: 20px; font-weight: bold; }
    .meta { color: #475569; margin-top: 24px; font-size: 0.85em; line-height: 1.8; }
  </style>
</head>
<body>
  <div class="card">
    <h1>✅ Sitio Primario</h1>
    <h2>roLearning – AWS Route 53 ARC Workshop</h2>
    <div class="badge">ONLINE</div>
    <p class="meta">Desplegado con <strong>Terraform</strong></p>
  </div>
</body>
</html>
HTML

echo "OK" > /usr/share/nginx/html/health
systemctl restart nginx
EOF

# main.tf
cat > main.tf << 'EOF'
terraform {
  required_version = ">= 1.5"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
}

provider "aws" {
  region = var.aws_region
  default_tags { tags = { Workshop = var.project_tag, ManagedBy = "Terraform" } }
}

locals {
  is_arm   = startswith(var.instance_type, "t4g")
  ami_arch = local.is_arm ? "arm64" : "x86_64"
}

data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]
  filter { name = "name", values = ["al2023-ami-*-kernel-*-${local.ami_arch}"] }
}

data "aws_vpc" "default" {
  count   = var.vpc_mode == "default" ? 1 : 0
  default = true
}

data "aws_subnets" "default_public" {
  count = var.vpc_mode == "default" ? 1 : 0
  filter { name = "vpc-id", values = [data.aws_vpc.default[0].id] }
  filter { name = "default-for-az", values = ["true"] }
}

data "aws_availability_zones" "available" { state = "available" }

resource "aws_vpc" "workshop" {
  count                = var.vpc_mode == "new" ? 1 : 0
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true
}

resource "aws_internet_gateway" "workshop" {
  count  = var.vpc_mode == "new" ? 1 : 0
  vpc_id = aws_vpc.workshop[0].id
}

resource "aws_subnet" "public" {
  count                   = var.vpc_mode == "new" ? 2 : 0
  vpc_id                  = aws_vpc.workshop[0].id
  cidr_block              = "10.0.${count.index + 1}.0/24"
  availability_zone       = data.aws_availability_zones.available.names[count.index]
  map_public_ip_on_launch = true
}

resource "aws_route_table" "public" {
  count  = var.vpc_mode == "new" ? 1 : 0
  vpc_id = aws_vpc.workshop[0].id
  route { cidr_block = "0.0.0.0/0", gateway_id = aws_internet_gateway.workshop[0].id }
}

resource "aws_route_table_association" "public" {
  count          = var.vpc_mode == "new" ? 2 : 0
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public[0].id
}

locals {
  vpc_id     = var.vpc_mode == "new" ? aws_vpc.workshop[0].id : data.aws_vpc.default[0].id
  subnet_ids = var.vpc_mode == "new" ? aws_subnet.public[*].id : slice(data.aws_subnets.default_public[0].ids, 0, 2)
}

resource "aws_security_group" "alb" {
  name        = "route53-arc-alb-sg"
  description = "SG ALB HTTP"
  vpc_id      = local.vpc_id
  ingress { from_port = 80, to_port = 80, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] }
  egress  { from_port = 0, to_port = 0, protocol = "-1", cidr_blocks = ["0.0.0.0/0"] }
}

resource "aws_security_group" "ec2" {
  name        = "route53-arc-ec2-sg"
  description = "SG EC2 de ALB"
  vpc_id      = local.vpc_id
  ingress { from_port = 80, to_port = 80, protocol = "tcp", security_groups = [aws_security_group.alb.id] }
  egress  { from_port = 0, to_port = 0, protocol = "-1", cidr_blocks = ["0.0.0.0/0"] }
}

resource "aws_iam_role" "ec2_ssm" {
  name = "route53-arc-ec2-ssm-role-tf"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{ Effect = "Allow", Principal = { Service = "ec2.amazonaws.com" }, Action = "sts:AssumeRole" }]
  })
}

resource "aws_iam_role_policy_attachment" "ssm" {
  role       = aws_iam_role.ec2_ssm.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "ec2" {
  name = "route53-arc-ec2-profile-tf"
  role = aws_iam_role.ec2_ssm.name
}

resource "aws_instance" "primary" {
  ami                    = data.aws_ami.al2023.id
  instance_type          = var.instance_type
  subnet_id              = local.subnet_ids[0]
  vpc_security_group_ids = [aws_security_group.ec2.id]
  iam_instance_profile   = aws_iam_instance_profile.ec2.name
  user_data              = filebase64("${path.module}/userdata.sh")
  tags                   = { Name = "route53-arc-primary-web" }
}

resource "aws_lb" "primary" {
  name               = "route53-arc-primary-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = local.subnet_ids
}

resource "aws_lb_target_group" "primary" {
  name     = "route53-arc-primary-tg"
  port     = 80
  protocol = "HTTP"
  vpc_id   = local.vpc_id
  health_check { path = "/health", interval = 30, timeout = 5, healthy_threshold = 2, unhealthy_threshold = 3 }
}

resource "aws_lb_target_group_attachment" "primary" {
  target_group_arn = aws_lb_target_group.primary.arn
  target_id        = aws_instance.primary.id
  port             = 80
}

resource "aws_lb_listener" "primary" {
  load_balancer_arn = aws_lb.primary.arn
  port              = 80
  protocol          = "HTTP"
  default_action { type = "forward", target_group_arn = aws_lb_target_group.primary.arn }
}
EOF

# outputs.tf
cat > outputs.tf << 'EOF'
output "alb_dns_name"    { value = aws_lb.primary.dns_name }
output "alb_zone_id"     { value = aws_lb.primary.zone_id }
output "ec2_instance_id" { value = aws_instance.primary.id }
output "vpc_id"          { value = local.vpc_id }
EOF

2. Aplicar los cambios en Terraform

terraform init

terraform apply \
  -var="vpc_mode=${VPC_MODE}" \
  -var="instance_type=t4g.micro" \
  -auto-approve

export PRIMARY_ALB_DNS=$(terraform output -raw alb_dns_name)
echo "🌐 ALB DNS (Terraform): http://$PRIMARY_ALB_DNS"

✅ Verificación del Lab 1

Información

⏱️ El ALB tarda 2-5 minutos en registrar la instancia EC2 como saludable. El script siguiente reintenta automáticamente hasta que el sitio responda. También notá que el SSM Agent puede tardar 2-3 minutos adicionales en registrarse después del boot — esto importa en el Lab 5 cuando uses SSM para detener Nginx.

echo "=== Verificación Lab 1: Sitio Primario ==="
echo "ALB DNS: $PRIMARY_ALB_DNS"
echo "⏳ Esperando que el ALB y nginx estén listos (puede tardar hasta 5 minutos)..."

for i in {1..20}; do
  HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://$PRIMARY_ALB_DNS/ 2>/dev/null)
  HEALTH=$(curl -s http://$PRIMARY_ALB_DNS/health 2>/dev/null | tr -d '[:space:]')
  echo "  [$i/20] HTTP: $HTTP_CODE | /health: ${HEALTH:-vacío}"
  if [ "$HTTP_CODE" = "200" ] && [ "$HEALTH" = "OK" ]; then
    echo "✅ Sitio primario listo: HTTP $HTTP_CODE | /health: OK"
    break
  fi
  [ "$i" = "20" ] && echo "⚠️  El sitio no respondió en el tiempo esperado. Verificá el estado del stack en la consola de CloudFormation."
  sleep 15
done

Siguiente paso → Lab 2: Sitio Secundario