Terraform + Ansible: Do Provisionamento à Configuração

[298] Terraform + Ansible: Do Provisionamento à Configuração

A fronteira entre provisionar e configurar, e como cruzá-la sem trabalho manual: a estrutura do projeto integrado, a geração do inventory a partir dos outputs do Terraform, o inventory dinâmico pelo plugin AWS EC2, a role de hardening, o playbook de deploy e a amarração no GitHub Actions.
DevOps

21 min de leitura

Os artigos anteriores (Módulos no Terraform: Reusabilidade e Organização, State do Terraform: Entendendo o Arquivo Mais Crítico do Projeto, Ansible: Configurando Servidores de Forma Declarativa) apresentaram o Terraform e o Ansible como ferramentas distintas. Este artigo demonstra como combiná-las em um fluxo de trabalho coeso — o que muitos times chamam de pipeline de IaC completo.

A divisão de responsabilidades é clara: o Terraform cria a infraestrutura e o Ansible a configura. Mas a fronteira entre os dois não é sempre óbvia na prática. Algumas perguntas comuns:

Devo instalar o Docker com Terraform user_data ou com Ansible? — Com Ansible. O user_data do Terraform é executado apenas uma vez, no momento da criação da instância. O Ansible pode ser reexecutado quantas vezes for necessário, garantindo que o estado do servidor converge para o desejado mesmo após mudanças manuais ou falhas parciais.

Devo criar usuários IAM com Ansible ou Terraform? — Com Terraform. Usuários IAM são recursos de infraestrutura gerenciados pela API da AWS. O Ansible não é a ferramenta natural para esse tipo de recurso.

Como o Ansible descobre os IPs dos servidores que o Terraform criou? — Essa é a integração central que este artigo demonstra.

Estrutura do Projeto Integrado

infraestrutura-completa/
├── terraform/
│   ├── modules/
│   │   ├── vpc/
│   │   └── rds/
│   ├── environments/
│   │   ├── staging/
│   │   │   ├── main.tf
│   │   │   ├── outputs.tf
│   │   │   └── terraform.tfvars
│   │   └── production/
│   │       ├── main.tf
│   │       ├── outputs.tf
│   │       └── terraform.tfvars
│   └── scripts/
│       └── gerar-inventory.sh
│
├── ansible/
│   ├── inventory/
│   │   ├── staging/
│   │   │   ├── hosts.yml      # Gerado pelo Terraform
│   │   │   └── group_vars/
│   │   └── production/
│   │       ├── hosts.yml      # Gerado pelo Terraform
│   │       └── group_vars/
│   ├── roles/
│   │   ├── base/
│   │   ├── docker/
│   │   ├── nginx/
│   │   └── aplicacao/
│   ├── playbooks/
│   │   ├── site.yml
│   │   ├── deploy.yml
│   │   └── hardening.yml
│   └── ansible.cfg
│
└── scripts/
    ├── provisionar.sh         # Terraform apply
    ├── configurar.sh          # Ansible playbook
    └── pipeline-completo.sh   # Os dois em sequência

Gerando o Inventory Ansible a Partir dos Outputs do Terraform

A integração mais direta entre Terraform e Ansible é usar os outputs do Terraform para gerar o inventory do Ansible. O Terraform conhece os IPs dos servidores que acabou de criar — basta exportar esse conhecimento em um formato que o Ansible entenda.

Outputs do Terraform preparados para o Ansible:

# terraform/environments/staging/outputs.tf

output "webservers" {
  description = "Informações dos servidores web para o inventory Ansible"
  value = {
    hosts = {
      for idx, instance in aws_instance.web :
      "web${format("%02d", idx + 1)}" => {
        ansible_host = instance.public_ip
        ansible_user = "ubuntu"
        instance_id  = instance.id
      }
    }
    vars = {
      app_port    = 3000
      nginx_port  = 80
      environment = "staging"
    }
  }
}

output "databases" {
  description = "Informações dos servidores de banco de dados"
  sensitive   = true
  value = {
    vars = {
      postgres_host     = aws_db_instance.principal.address
      postgres_port     = aws_db_instance.principal.port
      postgres_db       = aws_db_instance.principal.db_name
      postgres_user     = var.db_username
      postgres_password = var.db_password
    }
  }
}

output "bastion_host" {
  description = "IP do bastion host para acesso SSH aos servidores privados"
  value       = aws_eip.bastion.public_ip
}

Script que converte os outputs do Terraform em inventory YAML:

#!/bin/bash
# terraform/scripts/gerar-inventory.sh

set -euo pipefail

AMBIENTE=${1:-staging}
TERRAFORM_DIR="terraform/environments/$AMBIENTE"
INVENTORY_DIR="ansible/inventory/$AMBIENTE"

echo "Gerando inventory Ansible para o ambiente: $AMBIENTE"

# Garante que o diretório do inventory existe
mkdir -p "$INVENTORY_DIR"

# Lê os outputs do Terraform em JSON
cd "$TERRAFORM_DIR"
OUTPUTS=$(terraform output -json)
cd - > /dev/null

# Extrai o IP do bastion host
BASTION_IP=$(echo "$OUTPUTS" | jq -r '.bastion_host.value')

# Extrai informações dos webservers
WEBSERVERS=$(echo "$OUTPUTS" | jq -r '.webservers.value')

# Extrai variáveis do banco de dados
DB_HOST=$(echo "$OUTPUTS" | jq -r '.databases.value.vars.postgres_host')
DB_PORT=$(echo "$OUTPUTS" | jq -r '.databases.value.vars.postgres_port')

# Gera o arquivo hosts.yml
cat > "$INVENTORY_DIR/hosts.yml" << EOF
---
all:
  vars:
    ansible_python_interpreter: /usr/bin/python3
    ansible_ssh_private_key_file: ~/.ssh/id_ed25519
    ansible_ssh_common_args: >-
      -o StrictHostKeyChecking=no
      -o UserKnownHostsFile=/dev/null
      -o ProxyJump=ubuntu@${BASTION_IP}
    ambiente: ${AMBIENTE}

  children:
    webservers:
      hosts:
$(echo "$WEBSERVERS" | jq -r '
  .hosts | to_entries[] |
  "        \(.key):\n          ansible_host: \(.value.ansible_host)\n          ansible_user: \(.value.ansible_user)\n          instance_id: \(.value.instance_id)"
')
      vars:
$(echo "$WEBSERVERS" | jq -r '
  .vars | to_entries[] |
  "        \(.key): \(.value)"
')

    databases:
      hosts:
        db01:
          ansible_host: ${DB_HOST}
          ansible_user: ubuntu
          ansible_port: 22
      vars:
        postgres_host: ${DB_HOST}
        postgres_port: ${DB_PORT}
EOF

echo "Inventory gerado em: $INVENTORY_DIR/hosts.yml"
echo ""
echo "Hosts configurados:"
cat "$INVENTORY_DIR/hosts.yml"

Inventory Dinâmico com o Plugin AWS EC2

Para ambientes que mudam frequentemente — com Auto Scaling Groups — o inventory estático gerado pelo Terraform rapidamente fica desatualizado. O Ansible oferece um inventory dinâmico que consulta a AWS diretamente no momento da execução:

# ansible/inventory/aws_ec2.yml
plugin: amazon.aws.aws_ec2

regions:
  - us-east-1

# Filtra apenas instâncias em execução
filters:
  instance-state-name: running
  tag:Environment: staging

# Define os grupos com base em tags das instâncias
keyed_groups:
  - prefix: tag
    key: tags.Role
  - prefix: tag
    key: tags.Environment

# Usa o IP privado como hostname (acesso via bastion)
hostnames:
  - private-ip-address

# Variáveis disponíveis em cada host
compose:
  ansible_host: private_ip_address
  ansible_user: "'ubuntu'"
  instance_id: instance_id
  instance_type: instance_type

# Configura o acesso via bastion host
vars:
  ansible_ssh_common_args: >-
    -o StrictHostKeyChecking=no
    -o ProxyJump=ubuntu@{{ bastion_ip }}

Para usar o inventory dinâmico, é necessário instalar a coleção AWS:

# Instala a coleção oficial da AWS para Ansible
ansible-galaxy collection install amazon.aws

# Testa o inventory dinâmico
ansible-inventory \
  -i ansible/inventory/aws_ec2.yml \
  --list

# Executa um playbook usando o inventory dinâmico
ansible-playbook \
  -i ansible/inventory/aws_ec2.yml \
  ansible/playbooks/site.yml

O Playbook Completo de Configuração Inicial

Com o inventory gerado, o playbook de configuração inicial prepara os servidores do zero:

# ansible/playbooks/site.yml
---
# ── Play 1: Configurações base em todos os servidores ──
- name: Configuração base
  hosts: all
  become: true
  gather_facts: true

  pre_tasks:
    - name: Aguarda o SSH estar disponível
      ansible.builtin.wait_for_connection:
        delay: 10
        timeout: 120

    - name: Atualiza o cache do apt
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600

  roles:
    - role: base
    - role: hardening
    - role: monitoramento_agente

  post_tasks:
    - name: Registra data da configuração inicial
      ansible.builtin.copy:
        content: "{{ ansible_date_time.iso8601 }}"
        dest: /etc/ansible-configured-at
        owner: root
        group: root
        mode: "0444"

# ── Play 2: Servidores web ────────────────────────────
- name: Configura servidores web
  hosts: tag_Role_webserver
  become: true
  gather_facts: true

  vars_files:
    - "{{ playbook_dir }}/../inventory/{{ ambiente }}/group_vars/all/vault.yml"

  vars:
    docker_users:
      - ubuntu
      - deploy

  roles:
    - role: docker
    - role: nginx
    - role: certbot
    - role: aplicacao

# ── Play 3: Verifica saúde dos servidores ─────────────
- name: Verifica saúde da configuração
  hosts: all
  become: false
  gather_facts: false

  tasks:
    - name: Verifica conectividade
      ansible.builtin.ping:

    - name: Verifica que o Docker está rodando
      ansible.builtin.command: docker info
      register: docker_info
      changed_when: false
      when: "'tag_Role_webserver' in group_names"

    - name: Verifica que o Nginx está respondendo
      ansible.builtin.uri:
        url: "http://{{ ansible_host }}/health"
        status_code: 200
      delegate_to: localhost
      when: "'tag_Role_webserver' in group_names"

Role de Hardening de Segurança

Uma role essencial que todo servidor de produção deve ter — configura as principais proteções de segurança:

# ansible/roles/hardening/tasks/main.yml
---
- name: Configura o firewall UFW
  block:
    - name: Instala o UFW
      ansible.builtin.apt:
        name: ufw
        state: present

    - name: Define política padrão — bloqueia entrada
      community.general.ufw:
        policy: deny
        direction: incoming

    - name: Define política padrão — permite saída
      community.general.ufw:
        policy: allow
        direction: outgoing

    - name: Permite SSH
      community.general.ufw:
        rule: allow
        port: "22"
        proto: tcp

    - name: Permite HTTP
      community.general.ufw:
        rule: allow
        port: "80"
        proto: tcp
      when: "'tag_Role_webserver' in group_names"

    - name: Permite HTTPS
      community.general.ufw:
        rule: allow
        port: "443"
        proto: tcp
      when: "'tag_Role_webserver' in group_names"

    - name: Habilita o UFW
      community.general.ufw:
        state: enabled

- name: Configura o SSH hardening
  block:
    - name: Aplica configurações seguras do SSH
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: "{{ item.regexp }}"
        line: "{{ item.line }}"
        validate: sshd -t -f %s
      loop:
        - regexp: "^#?PasswordAuthentication"
          line: "PasswordAuthentication no"
        - regexp: "^#?PermitRootLogin"
          line: "PermitRootLogin no"
        - regexp: "^#?X11Forwarding"
          line: "X11Forwarding no"
        - regexp: "^#?MaxAuthTries"
          line: "MaxAuthTries 3"
        - regexp: "^#?ClientAliveInterval"
          line: "ClientAliveInterval 300"
        - regexp: "^#?ClientAliveCountMax"
          line: "ClientAliveCountMax 2"
      notify: Reinicia SSH

- name: Configura o Fail2ban
  block:
    - name: Instala o Fail2ban
      ansible.builtin.apt:
        name: fail2ban
        state: present

    - name: Copia configuração personalizada do Fail2ban
      ansible.builtin.template:
        src: jail.local.j2
        dest: /etc/fail2ban/jail.local
        owner: root
        group: root
        mode: "0644"
      notify: Reinicia Fail2ban

    - name: Garante que o Fail2ban está habilitado
      ansible.builtin.systemd:
        name: fail2ban
        state: started
        enabled: true

- name: Configura atualizações automáticas de segurança
  block:
    - name: Instala unattended-upgrades
      ansible.builtin.apt:
        name:
          - unattended-upgrades
          - apt-listchanges
        state: present

    - name: Habilita atualizações automáticas de segurança
      ansible.builtin.template:
        src: 20auto-upgrades.j2
        dest: /etc/apt/apt.conf.d/20auto-upgrades
        owner: root
        group: root
        mode: "0644"

Playbook de Deploy da Aplicação

Separado do playbook de configuração inicial, o playbook de deploy é executado a cada nova versão da aplicação:

# ansible/playbooks/deploy.yml
---
- name: Deploy da aplicação
  hosts: tag_Role_webserver
  become: true
  gather_facts: false
  serial: 1  # um servidor por vez — zero downtime em clusters

  vars:
    app_image: "{{ image | mandatory }}"
    container_name: "minha-api"
    app_port: 3000
    health_check_url: "http://localhost:{{ app_port }}/health"
    health_check_retries: 30
    health_check_delay: 2

  tasks:
    - name: Remove o servidor do load balancer
      community.aws.elb_instance:
        instance_id: "{{ instance_id }}"
        ec2_elbs: "{{ load_balancer_name }}"
        state: absent
        region: "{{ aws_region }}"
      delegate_to: localhost
      when: load_balancer_name is defined

    - name: Autentica no GHCR
      ansible.builtin.shell: |
        echo "{{ ghcr_token }}" | \
          docker login ghcr.io -u "{{ ghcr_user }}" --password-stdin
      no_log: true

    - name: Baixa a nova imagem
      community.docker.docker_image:
        name: "{{ app_image }}"
        source: pull
        force_source: true

    - name: Para e remove o container atual
      community.docker.docker_container:
        name: "{{ container_name }}"
        state: absent

    - name: Inicia o novo container
      community.docker.docker_container:
        name: "{{ container_name }}"
        image: "{{ app_image }}"
        state: started
        restart_policy: unless-stopped
        ports:
          - "{{ app_port }}:3000"
        env_file: /opt/minha-api/.env
        log_driver: json-file
        log_options:
          max-size: "100m"
          max-file: "3"

    - name: Aguarda a aplicação ficar saudável
      ansible.builtin.uri:
        url: "{{ health_check_url }}"
        status_code: 200
      register: health_check
      until: health_check.status == 200
      retries: "{{ health_check_retries }}"
      delay: "{{ health_check_delay }}"

    - name: Recoloca o servidor no load balancer
      community.aws.elb_instance:
        instance_id: "{{ instance_id }}"
        ec2_elbs: "{{ load_balancer_name }}"
        state: present
        region: "{{ aws_region }}"
      delegate_to: localhost
      when: load_balancer_name is defined

    - name: Remove imagens antigas
      community.docker.docker_prune:
        images: true
        images_filters:
          dangling: false
          until: "24h"

Executando o deploy via pipeline:

ansible-playbook \
  -i ansible/inventory/aws_ec2.yml \
  ansible/playbooks/deploy.yml \
  -e "image=ghcr.io/minha-empresa/minha-api:abc1234" \
  -e "load_balancer_name=minha-api-alb" \
  -e "aws_region=us-east-1" \
  --vault-password-file .vault-password

O Script de Pipeline Completo

Unindo tudo em um único script que o pipeline de CI/CD pode invocar:

#!/bin/bash
# scripts/pipeline-completo.sh
# Provisiona infraestrutura com Terraform e configura com Ansible

set -euo pipefail

AMBIENTE=${1:?Uso: $0 <ambiente> <versao-app>}
VERSAO_APP=${2:?Uso: $0 <ambiente> <versao-app>}

log() { echo "[$(date -u +%H:%M:%S)] $*"; }
erro() { echo "[$(date -u +%H:%M:%S)] ERRO: $*" >&2; exit 1; }

log "=== Pipeline IaC Completo ==="
log "Ambiente: $AMBIENTE"
log "Versão da aplicação: $VERSAO_APP"

# ── Etapa 1: Terraform ────────────────────────────────
log "--- Etapa 1: Provisionamento com Terraform ---"

cd "terraform/environments/$AMBIENTE"

terraform init \
  -backend-config="key=projetos/minha-api/$AMBIENTE/terraform.tfstate" \
  -reconfigure

terraform validate || erro "Validação do Terraform falhou"
terraform fmt -check || erro "Arquivos Terraform não formatados"

log "Executando terraform plan..."
terraform plan -out=tfplan -no-color

log "Aplicando infraestrutura..."
terraform apply -auto-approve tfplan

log "Terraform concluído. Aguardando inicialização dos servidores..."
sleep 30

cd - > /dev/null

# ── Etapa 2: Geração do inventory ────────────────────
log "--- Etapa 2: Gerando inventory Ansible ---"

bash terraform/scripts/gerar-inventory.sh "$AMBIENTE"

# ── Etapa 3: Verificação de conectividade ─────────────
log "--- Etapa 3: Verificando conectividade SSH ---"

ansible all \
  -i "ansible/inventory/$AMBIENTE/hosts.yml" \
  -m wait_for_connection \
  --timeout=120

# ── Etapa 4: Configuração base com Ansible ────────────
log "--- Etapa 4: Configuração base dos servidores ---"

ansible-playbook \
  -i "ansible/inventory/$AMBIENTE/hosts.yml" \
  ansible/playbooks/site.yml \
  --vault-password-file .vault-password \
  -e "ambiente=$AMBIENTE"

# ── Etapa 5: Deploy da aplicação ──────────────────────
log "--- Etapa 5: Deploy da aplicação ---"

ansible-playbook \
  -i "ansible/inventory/aws_ec2.yml" \
  ansible/playbooks/deploy.yml \
  --vault-password-file .vault-password \
  -e "image=ghcr.io/minha-empresa/minha-api:$VERSAO_APP" \
  -e "ambiente=$AMBIENTE"

# ── Etapa 6: Verificação final ────────────────────────
log "--- Etapa 6: Verificação final ---"

BASE_URL="https://$AMBIENTE.minha-api.com"

verificar_endpoint() {
  local endpoint=$1
  local esperado=$2
  local status
  status=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$endpoint")

  if [ "$status" != "$esperado" ]; then
    erro "$endpoint retornou $status, esperado $esperado"
  fi
  log "OK: $endpoint ($status)"
}

verificar_endpoint "/health" "200"
verificar_endpoint "/api/v1/status" "200"

log "=== Pipeline concluído com sucesso ==="
log "Aplicação versão $VERSAO_APP em execução em $AMBIENTE"

Integrando ao GitHub Actions

O script de pipeline é invocado pelo workflow de CI/CD:

# .github/workflows/iac-pipeline.yml
name: Pipeline IaC

on:
  workflow_dispatch:
    inputs:
      ambiente:
        description: 'Ambiente de destino'
        required: true
        type: choice
        options: [staging, production]
      versao_app:
        description: 'Versão da aplicação (SHA do commit)'
        required: true

jobs:
  pipeline-iac:
    runs-on: ubuntu-latest
    environment: ${{ inputs.ambiente }}

    steps:
      - uses: actions/checkout@v4

      - name: Configura credenciais AWS
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
          aws-region: us-east-1

      - name: Instala Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.7.0"

      - name: Instala Ansible e dependências
        run: |
          pip install ansible boto3 botocore --break-system-packages
          ansible-galaxy collection install \
            amazon.aws \
            community.docker \
            community.general

      - name: Configura chave SSH para os servidores
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519

      - name: Configura Ansible Vault
        run: |
          echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > .vault-password
          chmod 600 .vault-password

      - name: Executa pipeline completo
        run: |
          bash scripts/pipeline-completo.sh \
            ${{ inputs.ambiente }} \
            ${{ inputs.versao_app }}
        env:
          TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}
          ANSIBLE_HOST_KEY_CHECKING: "false"

      - name: Limpeza de arquivos sensíveis
        if: always()
        run: |
          rm -f ~/.ssh/id_ed25519
          rm -f .vault-password

Encerrando o Módulo 5

Com este artigo conclui-se mais uma etapa — Infraestrutura como Código. Foram cobertos os fundamentos do Terraform, a criação de recursos AWS, organização em módulos, gerenciamento de state e a configuração de servidores com Ansible. O encerramento natural foi a integração das duas ferramentas em um pipeline unificado, que vai do zero — nenhuma infraestrutura existente — até servidores configurados e a aplicação em execução.

Na sequência, entramos no território do monitoramento e observabilidade — como saber que a aplicação está saudável, como identificar problemas antes que os usuários os reportem e como diagnosticar incidentes com rapidez.

Referências para Aprofundamento

Integração Terraform e Ansible

Coleções Ansible para AWS e Docker

Segurança e hardening

Exercícios

Exercício 1

O artigo responde a duas perguntas de fronteira: instalar o Docker via user_data do Terraform ou via Ansible? Criar usuários IAM com Ansible ou Terraform? Dê as duas respostas com a justificativa — em especial, o que há de limitante no user_data.

Ver resposta

✓ Resposta: Docker com Ansible; usuários IAM com Terraform.

A limitação do user_data é que ele roda uma única vez, na criação da instância. Se a instalação falhar pela metade, se alguém alterar algo manualmente depois, ou se a versão do Docker precisar mudar, não há como reexecutá-lo — a única saída seria destruir e recriar a máquina. O Ansible, por ser idempotente, pode ser reexecutado quantas vezes for preciso, e a cada execução o servidor converge para o estado declarado, independentemente de onde estava antes.

Usuários IAM ficam com o Terraform porque são recursos de infraestrutura, criados pela API da AWS, e não configuração interna de um servidor. Terraform já os rastreia no state, sabe compará-los e removê-los quando saem do código. O Ansible não é a ferramenta natural para isso — a regra que organiza as duas respostas é a mesma: o que a nuvem expõe como recurso é do Terraform, o que vive dentro do sistema operacional é do Ansible.

Exercício 2

Descreva a cadeia completa que faz o Ansible descobrir os IPs criados pelo Terraform, nomeando o comando e a ferramenta usados em cada passo. Por que o output databases é marcado como sensitive e o webservers não?

Ver resposta

✓ Resposta: A cadeia é: o Terraform declara um output estruturado (webservers) montado com um for sobre aws_instance.web, já no formato que o inventory espera — chaves ansible_host, ansible_user. O script gerar-inventory.sh roda terraform output -json para obter tudo como JSON, usa jq para extrair e reformatar os campos, e escreve o resultado em ansible/inventory/$AMBIENTE/hosts.yml através de um heredoc. O Ansible então lê esse arquivo como um inventory estático comum.

O databases é sensitive porque suas vars incluem postgres_password — sem a marcação, a senha apareceria em texto puro na saída do apply e do terraform output, e no log do pipeline. O webservers carrega apenas IPs públicos, nome de usuário e IDs de instância: informação não secreta, e que precisa estar visível justamente para ser lida pelo script.

Vale notar que sensitive não impede o terraform output -json de retornar o valor — ele oculta na exibição legível, mas o JSON traz o dado, que é o que permite ao script funcionar. Por isso o próprio arquivo gerado passa a conter segredo e não deve ser versionado.

Exercício 3

Em que situação o inventory estático gerado pelo Terraform fica inadequado, e como o plugin amazon.aws.aws_ec2 resolve? Explique que grupos são formados por esta configuração e como uma instância entra em cada um.

keyed_groups:
  - prefix: tag
    key: tags.Role
  - prefix: tag
    key: tags.Environment
Ver resposta

✓ Resposta: Ele fica inadequado quando as instâncias mudam com frequência — o caso típico é um Auto Scaling Group, que cria e destrói máquinas conforme a demanda. O arquivo foi gerado no momento do apply e, minutos depois, já pode listar servidores que não existem mais e ignorar outros que subiram. Regerá-lo a cada execução resolveria só em parte, porque a defasagem volta a existir logo em seguida.

O plugin resolve consultando a API da AWS no momento em que o playbook roda: a fonte de verdade passa a ser a nuvem, não um arquivo. Os filters restringem o resultado a instâncias running com a tag Environment: staging.

Os keyed_groups criam grupos a partir dos valores das tags, com o prefixo tag. Uma instância marcada com Role=webserver e Environment=staging entra automaticamente nos grupos tag_Role_webserver e tag_Environment_staging. É exatamente por isso que a segunda play do site.yml tem hosts: tag_Role_webserver: a associação entre máquina e papel deixa de ser mantida à mão no inventory e passa a ser consequência da tag aplicada pelo Terraform.

Exercício 4

O inventory dinâmico usa hostnames: private-ip-address e um ProxyJump para o bastion. Explique como um servidor sem IP público é alcançado por esse arranjo. Que risco existe em StrictHostKeyChecking=no e por que ele acaba sendo tolerado aqui?

Ver resposta

✓ Resposta: Os servidores de aplicação ficam em subnets privadas, sem IP público — não há como o SSH chegar até eles pela internet. O ProxyJump=ubuntu@{{ bastion_ip }} faz a conexão passar pelo bastion host, que está em subnet pública: o SSH conecta primeiro ao bastion e, de dentro da VPC, abre a conexão para o IP privado do alvo. Por isso hostnames usa private-ip-address — é esse o endereço válido no salto final. O modelo mantém uma única porta de entrada auditável, em vez de expor cada servidor.

O StrictHostKeyChecking=no desativa a verificação da chave do host, aceitando qualquer servidor que atenda naquele endereço. Isso abre espaço para um ataque man-in-the-middle: quem conseguisse responder no IP esperado receberia a conexão como se fosse o servidor legítimo.

É tolerado porque as instâncias são efêmeras: cada máquina nova tem uma chave de host nova, e um IP privado reciclado pelo Auto Scaling passa a apresentar uma chave diferente da registrada. Com a verificação estrita, praticamente toda execução falharia com aviso de chave alterada — daí o par com UserKnownHostsFile=/dev/null, que nem sequer guarda o registro. O risco é mitigado pelo fato de todo o tráfego ocorrer dentro da VPC, através do bastion, e não pela internet aberta. Ainda assim é uma concessão consciente, não uma boa prática universal.

Exercício 5

Por que o playbook começa com a task abaixo em pre_tasks, e o que aconteceria sem ela no fluxo "Terraform apply seguido de Ansible"? Qual a diferença entre pre_tasks, roles e post_tasks?

- name: Aguarda o SSH estar disponível
  ansible.builtin.wait_for_connection:
    delay: 10
    timeout: 120
Ver resposta

✓ Resposta: Porque a AWS reporta a instância como criada bem antes de ela estar utilizável. O terraform apply retorna assim que a API confirma o recurso, mas o sistema operacional ainda está inicializando e o sshd pode levar dezenas de segundos para aceitar conexões. Sem a espera, o Ansible tentaria conectar no primeiro instante e falharia com erro de conexão recusada — o pipeline quebraria por uma questão de tempo, não de configuração, e de forma intermitente, o que é pior para diagnosticar.

A task espera delay: 10 segundos antes da primeira tentativa e insiste até timeout: 120, seguindo assim que a conexão responder. É a mesma lógica de aguardar a condição em vez do relógio que aparece nos healthchecks de deploy.

Quanto à ordem: pre_tasks roda antes de todas as roles, roles em seguida, e post_tasks ao final. A separação importa porque as roles pressupõem um servidor alcançável e com cache do apt atualizado — condições que as pre_tasks estabelecem. E o registro em /etc/ansible-configured-at fica em post_tasks porque só faz sentido gravar a data quando toda a configuração já foi aplicada com sucesso. Handlers notificados pelas pre_tasks, vale lembrar, são executados antes das roles começarem, e não no fim do playbook.

Comentários

Mais em DevOps

Seus Primeiros Recursos na AWS com Terraform
Seus Primeiros Recursos na AWS com Terraform

Uma infraestrutura AWS completa provisionada inteiramente por código: a…

Instalando e Rodando Seus Primeiros Containers
Instalando e Rodando Seus Primeiros Containers

Docker na prática: instalação pelo repositório oficial, uso sem sudo…

Instalação Manual do MySQL no Debian, Arch, Fedora e openSUSE
Instalação Manual do MySQL no Debian, Arch, Fedora e openSUSE

Instalação do MySQL pelo tarball genérico da Oracle em Debian, Arch, Fedora e…