M04·08Projeto guiado: subindo uma API real na AWS

CAPÍTULO 08

Projeto guiado: subindo uma API real na AWS

Suba uma API REST em Go com RDS Multi-AZ, bucket S3, Auto Scaling e CloudWatch do zero — passo a passo.

Por Thiago Souza20 min de leituraAtualizado em 2026-05

Chega de teoria. Vamos subir uma aplicação de verdade. Você vai criar uma API REST de "lista de tarefas" em Go, com banco PostgreSQL no RDS e upload de anexos no S3, rodando atrás de um Load Balancer com 2 instâncias EC2 em AZs diferentes.

O que vamos construir

Arquitetura final:

  • VPC com 2 subnets públicas (para o ALB) e 2 subnets privadas (para EC2 e RDS)
  • ALB recebendo HTTP na porta 80
  • Auto Scaling Group com 2 EC2 (t3.micro) rodando a API Go
  • RDS PostgreSQL Multi-AZ
  • Bucket S3 para uploads
  • IAM Role na EC2 dando acesso ao S3 e Secrets Manager
  • Senha do banco no Secrets Manager
  • Logs e métricas no CloudWatch

Pré-requisitos

  • Conta AWS (Free Tier resolve)
  • AWS CLI instalado e configurado (aws configure)
  • Go 1.26+ na sua máquina (para compilar o binário localmente)
  • Um par de chaves SSH gerado (.pem)

Passo a passo

Passo 1 — Criar a VPC e subnets

Vamos usar a VPC default que já existe na sua conta. Ela já tem subnets públicas em todas as AZs. Para um projeto sério você criaria uma VPC dedicada, mas para o aprendizado simplifica.

bash
# Listar VPCs existentes
aws ec2 describe-vpcs --filters "Name=is-default,Values=true"
 
# Listar subnets da VPC default
aws ec2 describe-subnets --filters "Name=vpc-id,Values=<VPC_ID>"

Passo 2 — Criar Security Groups

Vamos criar 3 SGs:

  • sg-alb: aceita 80 da internet (0.0.0.0/0)
  • sg-app: aceita 8080 apenas vindo de sg-alb
  • sg-db: aceita 5432 apenas vindo de sg-app
bash
# Criar SG do ALB
aws ec2 create-security-group --group-name sg-alb \
  --description "ALB SG" --vpc-id <VPC_ID>
 
aws ec2 authorize-security-group-ingress --group-id <SG_ALB_ID> \
  --protocol tcp --port 80 --cidr 0.0.0.0/0
 
# Criar SG da aplicação
aws ec2 create-security-group --group-name sg-app \
  --description "App SG" --vpc-id <VPC_ID>
 
aws ec2 authorize-security-group-ingress --group-id <SG_APP_ID> \
  --protocol tcp --port 8080 --source-group <SG_ALB_ID>
 
# Criar SG do banco
aws ec2 create-security-group --group-name sg-db \
  --description "DB SG" --vpc-id <VPC_ID>
 
aws ec2 authorize-security-group-ingress --group-id <SG_DB_ID> \
  --protocol tcp --port 5432 --source-group <SG_APP_ID>

Passo 3 — Criar o RDS PostgreSQL

Primeiro, guarde a senha no Secrets Manager:

bash
aws secretsmanager create-secret \
  --name prod/tasks/db \
  --secret-string '{"username":"appuser","password":"StrongPassword123!"}'

Agora crie o RDS:

bash
aws rds create-db-instance \
  --db-instance-identifier tasks-db \
  --db-instance-class db.t3.micro \
  --engine postgres --engine-version 16.1 \
  --master-username appuser \
  --master-user-password 'StrongPassword123!' \
  --allocated-storage 20 --multi-az \
  --vpc-security-group-ids <SG_DB_ID> \
  --backup-retention-period 7 \
  --no-publicly-accessible \
  --storage-encrypted
 
# Aguarde alguns minutos. Status final = 'available'
aws rds describe-db-instances --db-instance-identifier tasks-db

Passo 4 — Criar o bucket S3

bash
# Nome global e único, escolha algo único
aws s3api create-bucket \
  --bucket tasks-uploads-2026 \
  --region sa-east-1 \
  --create-bucket-configuration LocationConstraint=sa-east-1
 
# Habilitar criptografia no bucket
aws s3api put-bucket-encryption \
  --bucket tasks-uploads-2026 \
  --server-side-encryption-configuration \
  '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
 
# Bloquear acesso público
aws s3api put-public-access-block \
  --bucket tasks-uploads-2026 \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

Passo 5 — Criar a IAM Role para a EC2

A role permite que a EC2 acesse S3 e Secrets Manager sem precisar de access key.

bash
# Trust policy (quem pode assumir a role)
cat > trust.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {"Service": "ec2.amazonaws.com"},
    "Action": "sts:AssumeRole"
  }]
}
EOF
 
aws iam create-role --role-name TasksAppRole \
  --assume-role-policy-document file://trust.json
 
# Permission policy
cat > policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject","s3:GetObject"],
      "Resource": "arn:aws:s3:::tasks-uploads-2026/*"
    },
    {
      "Effect": "Allow",
      "Action": "secretsmanager:GetSecretValue",
      "Resource": "arn:aws:secretsmanager:*:*:secret:prod/tasks/db-*"
    }
  ]
}
EOF
 
aws iam put-role-policy --role-name TasksAppRole \
  --policy-name TasksAppPolicy --policy-document file://policy.json
 
# Instance profile (necessário para anexar à EC2)
aws iam create-instance-profile --instance-profile-name TasksAppProfile
aws iam add-role-to-instance-profile \
  --instance-profile-name TasksAppProfile \
  --role-name TasksAppRole

Passo 6 — Código da API em Go

A API usa só a biblioteca padrão (net/http, database/sql, encoding/json) mais pgx para PostgreSQL e o AWS SDK v2 para acessar o Secrets Manager com a IAM Role — sem access key no código.

go
package main
 
import (
    "context"
    "encoding/json"
    "fmt"
    "log/slog"
    "net/http"
    "os"
    "time"
 
    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
    "github.com/jackc/pgx/v5/pgxpool"
)
 
type Task struct {
    ID        int64     `json:"id"`
    Title     string    `json:"title"`
    Done      bool      `json:"done"`
    CreatedAt time.Time `json:"created_at"`
}
 
type dbSecret struct {
    Username string `json:"username"`
    Password string `json:"password"`
}
 
// loadDBSecret busca credenciais no Secrets Manager usando a IAM Role da EC2.
// Nenhuma access key no código — a role cuida da autenticação.
func loadDBSecret(ctx context.Context) (dbSecret, error) {
    cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("sa-east-1"))
    if err != nil {
        return dbSecret{}, fmt.Errorf("aws config: %w", err)
    }
 
    sm := secretsmanager.NewFromConfig(cfg)
    out, err := sm.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
        SecretId: aws.String("prod/tasks/db"),
    })
    if err != nil {
        return dbSecret{}, fmt.Errorf("get secret: %w", err)
    }
 
    var s dbSecret
    if err := json.Unmarshal([]byte(*out.SecretString), &s); err != nil {
        return dbSecret{}, fmt.Errorf("parse secret: %w", err)
    }
    return s, nil
}
 
func main() {
    ctx := context.Background()
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    slog.SetDefault(logger)
 
    // Credenciais via Secrets Manager (IAM Role — sem access key!)
    secret, err := loadDBSecret(ctx)
    if err != nil {
        slog.Error("falha ao carregar secret", "err", err)
        os.Exit(1)
    }
 
    dsn := fmt.Sprintf(
        "host=%s user=%s password=%s dbname=postgres sslmode=require",
        os.Getenv("DB_HOST"), secret.Username, secret.Password,
    )
 
    pool, err := pgxpool.New(ctx, dsn)
    if err != nil {
        slog.Error("falha ao conectar no banco", "err", err)
        os.Exit(1)
    }
    defer pool.Close()
 
    // Cria a tabela se não existir
    _, err = pool.Exec(ctx, `
        CREATE TABLE IF NOT EXISTS tasks (
            id         BIGSERIAL PRIMARY KEY,
            title      TEXT NOT NULL,
            done       BOOLEAN NOT NULL DEFAULT false,
            created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
        )
    `)
    if err != nil {
        slog.Error("falha ao criar tabela", "err", err)
        os.Exit(1)
    }
 
    mux := http.NewServeMux()
 
    // GET /health — usado pelo ALB para saber se a instância está saudável
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        if err := pool.Ping(r.Context()); err != nil {
            http.Error(w, "db unreachable", http.StatusServiceUnavailable)
            return
        }
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
    })
 
    // POST /tasks — cria uma nova tarefa
    mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
        var body struct {
            Title string `json:"title"`
        }
        if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Title == "" {
            http.Error(w, "title required", http.StatusBadRequest)
            return
        }
 
        var t Task
        err := pool.QueryRow(r.Context(),
            "INSERT INTO tasks(title) VALUES($1) RETURNING id, title, done, created_at",
            body.Title,
        ).Scan(&t.ID, &t.Title, &t.Done, &t.CreatedAt)
        if err != nil {
            slog.Error("insert task", "err", err)
            http.Error(w, "internal error", http.StatusInternalServerError)
            return
        }
 
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusCreated)
        json.NewEncoder(w).Encode(t)
    })
 
    // GET /tasks — lista todas as tarefas
    mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
        rows, err := pool.Query(r.Context(),
            "SELECT id, title, done, created_at FROM tasks ORDER BY id DESC LIMIT 100",
        )
        if err != nil {
            http.Error(w, "internal error", http.StatusInternalServerError)
            return
        }
        defer rows.Close()
 
        tasks := []Task{}
        for rows.Next() {
            var t Task
            if err := rows.Scan(&t.ID, &t.Title, &t.Done, &t.CreatedAt); err != nil {
                continue
            }
            tasks = append(tasks, t)
        }
 
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(tasks)
    })
 
    addr := ":8080"
    slog.Info("servidor iniciado", "addr", addr)
    if err := http.ListenAndServe(addr, mux); err != nil {
        slog.Error("servidor encerrado", "err", err)
    }
}
Dependências do go.mod: github.com/aws/aws-sdk-go-v2/config, github.com/aws/aws-sdk-go-v2/service/secretsmanager e github.com/jackc/pgx/v5. Execute go mod tidy após criar o arquivo.

Passo 7 — Compilar e subir o binário para o S3

Go compila para um binário estático — não precisa de runtime na EC2. A estratégia é compilar localmente (ou em CI), subir para S3, e a EC2 baixa na inicialização.

bash
# Compila para Linux x86-64 (target da EC2)
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o tasks-api .
 
# Sobe o binário para S3
aws s3 cp tasks-api s3://tasks-uploads-2026/releases/tasks-api

Passo 8 — Criar Launch Template

O User Data baixa o binário do S3 e sobe o serviço via systemd:

bash
#!/bin/bash
# user-data.sh — roda na primeira inicialização da EC2
 
# Baixa o binário do S3 (a IAM Role autentica automaticamente)
aws s3 cp s3://tasks-uploads-2026/releases/tasks-api /opt/tasks-api
chmod +x /opt/tasks-api
 
# Pega o endpoint do RDS
DB_HOST=$(aws rds describe-db-instances \
  --db-instance-identifier tasks-db \
  --query 'DBInstances[0].Endpoint.Address' --output text)
 
# Cria unit do systemd para manter o processo vivo e reiniciar em falhas
cat > /etc/systemd/system/tasks-api.service <<EOF
[Unit]
Description=Tasks API
After=network.target
 
[Service]
ExecStart=/opt/tasks-api
Restart=always
RestartSec=5s
Environment=DB_HOST=${DB_HOST}
 
[Install]
WantedBy=multi-user.target
EOF
 
systemctl daemon-reload
systemctl enable --now tasks-api
Por que systemd em vez de pm2? Go compila para um binário autossuficiente — não precisa de runtime como Node.js. O systemd é nativo do Linux e mais robusto para reinícios automáticos e gerenciamento de logs (journalctl).

Passo 9 — Criar o Application Load Balancer

bash
# Criar ALB nas subnets públicas (2 AZs diferentes)
aws elbv2 create-load-balancer \
  --name tasks-alb \
  --subnets <SUBNET_PUB_A> <SUBNET_PUB_B> \
  --security-groups <SG_ALB_ID>
 
# Criar Target Group apontando para a porta 8080 da API Go
aws elbv2 create-target-group \
  --name tasks-tg \
  --protocol HTTP --port 8080 \
  --vpc-id <VPC_ID> \
  --health-check-path /health \
  --target-type instance
 
# Criar listener (porta 80 -> Target Group)
aws elbv2 create-listener \
  --load-balancer-arn <ALB_ARN> \
  --protocol HTTP --port 80 \
  --default-actions Type=forward,TargetGroupArn=<TG_ARN>

Passo 10 — Criar Auto Scaling Group

bash
aws autoscaling create-auto-scaling-group \
  --auto-scaling-group-name tasks-asg \
  --launch-template LaunchTemplateName=tasks-lt,Version=1 \
  --min-size 2 --max-size 4 --desired-capacity 2 \
  --target-group-arns <TG_ARN> \
  --vpc-zone-identifier "<SUBNET_PRIV_A>,<SUBNET_PRIV_B>" \
  --health-check-type ELB --health-check-grace-period 60
 
# Política de scaling: CPU > 70%
aws autoscaling put-scaling-policy \
  --auto-scaling-group-name tasks-asg \
  --policy-name cpu-target \
  --policy-type TargetTrackingScaling \
  --target-tracking-configuration '{
    "PredefinedMetricSpecification": {"PredefinedMetricType":"ASGAverageCPUUtilization"},
    "TargetValue": 70.0
  }'
O health-check-grace-period é 60s aqui porque o binário Go sobe em menos de 1 segundo — bem mais rápido do que um processo Node.js com npm install.

Passo 11 — Testar

bash
# Pegar DNS do ALB
aws elbv2 describe-load-balancers --names tasks-alb \
  --query 'LoadBalancers[0].DNSName' --output text
 
# Testar o health check
curl http://<ALB_DNS>/health
# {"status":"ok"}
 
# Criar uma tarefa
curl -X POST http://<ALB_DNS>/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Estudar AWS"}'
# {"id":1,"title":"Estudar AWS","done":false,"created_at":"..."}
 
# Listar tarefas
curl http://<ALB_DNS>/tasks

Passo 12 — Configurar alarme no CloudWatch

bash
# Alarme: 5xx do ALB > 10 em 5 minutos
aws cloudwatch put-metric-alarm \
  --alarm-name api-5xx-high \
  --metric-name HTTPCode_Target_5XX_Count \
  --namespace AWS/ApplicationELB \
  --statistic Sum --period 300 \
  --threshold 10 --comparison-operator GreaterThanThreshold \
  --evaluation-periods 1 \
  --alarm-actions arn:aws:sns:sa-east-1:123:api-alerts
Após terminar o exercício, destrua todos os recursos para não gerar custo: (1) Deletar o Auto Scaling Group, (2) Deletar o ALB e Target Group, (3) Deletar o RDS (snapshot opcional), (4) Esvaziar e deletar o bucket S3, (5) Deletar Security Groups, (6) Deletar IAM Role e Instance Profile, (7) Deletar segredo do Secrets Manager.

O que você aprendeu nesse projeto

  • Como criar uma rede segura (Security Groups encadeados)
  • Como provisionar um banco gerenciado com Multi-AZ
  • Como não colocar credenciais no código (IAM Role + Secrets Manager)
  • Como compilar e distribuir um binário Go para EC2 via S3
  • Como ter alta disponibilidade real (ALB + 2 instâncias em AZs diferentes)
  • Como escalar automaticamente (ASG com target tracking)
  • Como observar a aplicação (alarmes do CloudWatch)

Esses são os mesmos blocos usados por aplicações em produção da Netflix, Itaú, Nubank e Magalu. A vantagem do Go aqui é concreta: um binário de ~10MB, sem runtime, que sobe em milissegundos e não precisa de gerenciador de processos externo.