DevOps

Terraform + Ansible: Do Provisionamento à Configuração Já leu

14 min de leitura

Terraform + Ansible: Do Provisionamento à Configuração
Os artigos anteriores 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

Os artigos anteriores 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 - Terraform Output — developer.hashicorp.com — Documentação completa de outputs do Terraform, essencial para passar informações entre Terraform e Ansible. - Ansible Dynamic Inventory — docs.ansible.com — Guia completo de inventories dinâmicos, incluindo o plugin AWS EC2 usado neste artigo.

Coleções Ansible para AWS e Docker - amazon.aws Collection — docs.ansible.com — Documentação da coleção oficial Amazon AWS para Ansible, com referência de todos os módulos disponíveis. - community.docker Collection — docs.ansible.com — Documentação da coleção Docker para Ansible, cobrindo gerenciamento de containers, imagens e redes.

Segurança e hardening - dev-sec/ansible-collection-hardening — GitHub — Coleção de roles de hardening mantidas pela comunidade Dev-Sec, amplamente usada em ambientes de produção. Cobre hardening de SSH, OS e nginx seguindo benchmarks CIS.

Comentários

Mais em DevOps

Grafana: Dashboards e Alertas que Fazem Sentido
Grafana: Dashboards e Alertas que Fazem Sentido

Um dashboard mal projetado é quase tão ruim quanto não ter dashboard. Quando...

Performance e FinOps: Otimizando Custo e Velocidade na Cloud
Performance e FinOps: Otimizando Custo e Velocidade na Cloud

Existe uma correlação quase universal entre o crescimento de um sistema em pr...

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

Este guia cobre a instalação do MySQL 9.7.0 puro (sem MariaDB, sem pacotes de...