Ansible: Configurando Servidores de Forma Declarativa

[297] Ansible: Configurando Servidores de Forma Declarativa

Configuração declarativa de servidores por SSH, sem agente instalado: o arquivo de inventory, o primeiro playbook, templates com Jinja2, roles como unidade reutilizável, a precedência das variáveis, o Ansible Vault para segredos, e a idempotência como princípio que sustenta tudo.
DevOps

19 min de leitura

O Terraform é excelente em provisionar infraestrutura — criar servidores, configurar redes, provisionar bancos de dados. Mas depois que o servidor existe, o Terraform não é a ferramenta ideal para gerenciar o que acontece dentro dele. Instalar pacotes, copiar arquivos de configuração, criar usuários, iniciar serviços, aplicar patches de segurança — essas são responsabilidades de uma ferramenta de gerenciamento de configuração.

O Ansible é a ferramenta mais adotada para essa função. Ele conecta a servidores via SSH, sem necessidade de agente instalado, e executa uma série de tarefas declaradas em arquivos YAML chamados playbooks. O objetivo é garantir que o estado interno de cada servidor corresponda ao que foi declarado — independentemente do estado inicial da máquina.

A divisão de responsabilidades entre Terraform e Ansible é clara e complementar: o Terraform cria e gerencia a infraestrutura como um todo, o Ansible configura e mantém o estado interno dos servidores que o Terraform criou.

Instalação e Conceitos Fundamentais

# No Ubuntu/Debian
sudo apt update
sudo apt install -y software-properties-common
sudo add-apt-repository --yes --update ppa:ansible/ansible
sudo apt install -y ansible

# Via pip — recomendado para obter a versão mais recente
pip install ansible --break-system-packages

# Verifica a instalação
ansible --version

Antes de aprofundar, é importante estabelecer os quatro conceitos centrais do Ansible:

Inventory — o arquivo que lista os servidores que o Ansible vai gerenciar, organizados em grupos. É o mapa dos alvos de execução.

Playbook — o arquivo principal de automação. Descreve quais tarefas devem ser executadas em quais servidores. É onde a lógica de configuração vive.

Task — uma unidade de trabalho dentro de um playbook. Cada task usa um módulo do Ansible para executar uma ação específica — instalar um pacote, copiar um arquivo, reiniciar um serviço.

Role — uma estrutura de organização que agrupa tasks, variáveis, templates e handlers relacionados em uma unidade reutilizável. Roles são para o Ansible o que módulos são para o Terraform.

O Arquivo de Inventory

O inventory informa ao Ansible quais servidores existem e como se conectar a eles. Pode ser um arquivo estático ou gerado dinamicamente a partir de APIs de nuvem.

Inventory estático em formato INI:

# inventory/hosts.ini

# Grupo de servidores web
[webservers]
web01 ansible_host=54.123.45.10 ansible_user=ubuntu
web02 ansible_host=54.123.45.11 ansible_user=ubuntu

# Grupo de bancos de dados
[databases]
db01 ansible_host=10.0.1.50 ansible_user=ubuntu

# Grupo de servidores de cache
[cache]
redis01 ansible_host=10.0.1.60 ansible_user=ubuntu

# Grupo que agrega outros grupos
[producao:children]
webservers
databases
cache

# Variáveis aplicadas a todos os servidores do grupo webservers
[webservers:vars]
ansible_ssh_private_key_file=~/.ssh/id_ed25519
nginx_port=80
app_port=3000

# Variáveis aplicadas a todos os hosts
[all:vars]
ansible_python_interpreter=/usr/bin/python3

Inventory em YAML — mais legível para configurações complexas:

# inventory/hosts.yml
all:
  vars:
    ansible_python_interpreter: /usr/bin/python3
    ansible_ssh_private_key_file: ~/.ssh/id_ed25519

  children:
    webservers:
      hosts:
        web01:
          ansible_host: 54.123.45.10
          ansible_user: ubuntu
          nginx_port: 80
        web02:
          ansible_host: 54.123.45.11
          ansible_user: ubuntu
          nginx_port: 80
      vars:
        app_port: 3000

    databases:
      hosts:
        db01:
          ansible_host: 10.0.1.50
          ansible_user: ubuntu
          postgres_version: "16"

    cache:
      hosts:
        redis01:
          ansible_host: 10.0.1.60
          ansible_user: ubuntu

    producao:
      children:
        webservers:
        databases:
        cache:

Testando a conectividade com o inventory:

# Testa conexão com todos os hosts
ansible all -i inventory/hosts.yml -m ping

# Testa apenas os webservers
ansible webservers -i inventory/hosts.yml -m ping

# Executa um comando ad-hoc em todos os servidores
ansible all -i inventory/hosts.yml -m command -a "uptime"

# Executa com sudo
ansible webservers -i inventory/hosts.yml -m command -a "df -h" --become

O Primeiro Playbook

Um playbook é um arquivo YAML que define uma ou mais plays. Cada play associa um conjunto de tasks a um grupo de hosts:

# playbooks/configurar-servidor-web.yml
---
- name: Configura servidor web com Nginx e Docker
  hosts: webservers
  become: true  # equivalente ao sudo
  gather_facts: true  # coleta informações do sistema antes de executar

  vars:
    nginx_version: "1.24.*"
    app_user: "deploy"
    app_dir: "/opt/minha-api"
    docker_compose_version: "2.24.0"

  tasks:
    - name: Atualiza o cache do apt
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600  # não atualiza se cache tem menos de 1 hora

    - name: Instala pacotes essenciais
      ansible.builtin.apt:
        name:
          - curl
          - wget
          - git
          - unzip
          - htop
          - ufw
          - fail2ban
          - python3-pip
        state: present

    - name: Instala o Nginx
      ansible.builtin.apt:
        name: "nginx={{ nginx_version }}"
        state: present

    - name: Garante que o Nginx está iniciado e habilitado
      ansible.builtin.systemd:
        name: nginx
        state: started
        enabled: true

    - name: Cria o usuário de deploy
      ansible.builtin.user:
        name: "{{ app_user }}"
        shell: /bin/bash
        create_home: true
        groups: docker
        append: true

    - name: Cria o diretório da aplicação
      ansible.builtin.file:
        path: "{{ app_dir }}"
        state: directory
        owner: "{{ app_user }}"
        group: "{{ app_user }}"
        mode: "0755"

    - name: Copia o arquivo de configuração do Nginx
      ansible.builtin.template:
        src: templates/nginx.conf.j2
        dest: /etc/nginx/sites-available/minha-api
        owner: root
        group: root
        mode: "0644"
      notify: Recarrega Nginx

    - name: Habilita o site no Nginx
      ansible.builtin.file:
        src: /etc/nginx/sites-available/minha-api
        dest: /etc/nginx/sites-enabled/minha-api
        state: link
      notify: Recarrega Nginx

    - name: Remove a configuração padrão do Nginx
      ansible.builtin.file:
        path: /etc/nginx/sites-enabled/default
        state: absent
      notify: Recarrega Nginx

  handlers:
    - name: Recarrega Nginx
      ansible.builtin.systemd:
        name: nginx
        state: reloaded

Os handlers são tasks especiais que só são executados quando notificados por outras tasks e apenas uma vez ao final do playbook — mesmo que múltiplas tasks os notifiquem. São o mecanismo correto para reiniciar serviços após mudanças de configuração.

Templates com Jinja2

O Ansible usa o motor de templates Jinja2 para gerar arquivos de configuração dinâmicos. Variáveis do playbook, do inventory e de facts do sistema ficam disponíveis nos templates:

# templates/nginx.conf.j2
server {
    listen {{ nginx_port | default(80) }};
    server_name {{ ansible_hostname }};

    access_log /var/log/nginx/{{ app_user }}-access.log;
    error_log  /var/log/nginx/{{ app_user }}-error.log;

    location / {
        proxy_pass         http://127.0.0.1:{{ app_port }};
        proxy_http_version 1.1;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection 'upgrade';
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_cache_bypass $http_upgrade;

        proxy_connect_timeout 60s;
        proxy_send_timeout    60s;
        proxy_read_timeout    60s;
    }

    location /health {
        access_log off;
        proxy_pass http://127.0.0.1:{{ app_port }}/health;
    }

    # Configurações de segurança
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;

    {% if ssl_enabled | default(false) %}
    listen 443 ssl;
    ssl_certificate     /etc/ssl/certs/{{ ansible_hostname }}.crt;
    ssl_certificate_key /etc/ssl/private/{{ ansible_hostname }}.key;
    ssl_protocols       TLSv1.2 TLSv1.3;
    {% endif %}
}

Roles: Organizando Automações Reutilizáveis

Quando os playbooks crescem, roles são a solução para organizar e reutilizar código. A estrutura de uma role segue uma convenção rígida que o Ansible reconhece automaticamente:

roles/
└── docker/
    ├── tasks/
    │   ├── main.yml          # Ponto de entrada das tasks
    │   ├── install.yml       # Tasks de instalação
    │   └── configure.yml     # Tasks de configuração
    ├── handlers/
    │   └── main.yml          # Handlers da role
    ├── templates/
    │   └── daemon.json.j2    # Templates de configuração
    ├── files/
    │   └── docker-cleanup.sh # Arquivos estáticos
    ├── vars/
    │   └── main.yml          # Variáveis fixas da role
    ├── defaults/
    │   └── main.yml          # Variáveis com valores padrão
    └── meta/
        └── main.yml          # Metadados e dependências da role

Implementação completa da role de Docker:

# roles/docker/defaults/main.yml
---
docker_edition: "ce"
docker_version: "5:25.0.0-1~ubuntu.22.04~jammy"
docker_compose_version: "2.24.0"
docker_users: []
docker_daemon_options:
  log-driver: "json-file"
  log-opts:
    max-size: "100m"
    max-file: "3"
  storage-driver: "overlay2"
# roles/docker/tasks/main.yml
---
- name: Inclui tasks de instalação
  ansible.builtin.import_tasks: install.yml

- name: Inclui tasks de configuração
  ansible.builtin.import_tasks: configure.yml
# roles/docker/tasks/install.yml
---
- name: Remove versões antigas do Docker
  ansible.builtin.apt:
    name:
      - docker
      - docker-engine
      - docker.io
      - containerd
      - runc
    state: absent

- name: Instala dependências do repositório Docker
  ansible.builtin.apt:
    name:
      - ca-certificates
      - curl
      - gnupg
      - lsb-release
    state: present
    update_cache: true

- name: Cria diretório para keyrings
  ansible.builtin.file:
    path: /etc/apt/keyrings
    state: directory
    mode: "0755"

- name: Adiciona a chave GPG oficial do Docker
  ansible.builtin.apt_key:
    url: https://download.docker.com/linux/ubuntu/gpg
    keyring: /etc/apt/keyrings/docker.gpg
    state: present

- name: Adiciona o repositório do Docker
  ansible.builtin.apt_repository:
    repo: >
      deb [arch={{ ansible_architecture }}
      signed-by=/etc/apt/keyrings/docker.gpg]
      https://download.docker.com/linux/ubuntu
      {{ ansible_distribution_release }} stable
    state: present
    filename: docker

- name: Instala o Docker Engine
  ansible.builtin.apt:
    name:
      - "docker-{{ docker_edition }}={{ docker_version }}"
      - docker-ce-cli
      - containerd.io
      - docker-buildx-plugin
      - docker-compose-plugin
    state: present
    update_cache: true

- name: Garante que o Docker está iniciado e habilitado
  ansible.builtin.systemd:
    name: docker
    state: started
    enabled: true
# roles/docker/tasks/configure.yml
---
- name: Copia configuração do Docker daemon
  ansible.builtin.template:
    src: daemon.json.j2
    dest: /etc/docker/daemon.json
    owner: root
    group: root
    mode: "0644"
  notify: Reinicia Docker

- name: Adiciona usuários ao grupo docker
  ansible.builtin.user:
    name: "{{ item }}"
    groups: docker
    append: true
  loop: "{{ docker_users }}"
  when: docker_users | length > 0

- name: Instala o Docker Compose standalone
  ansible.builtin.get_url:
    url: "https://github.com/docker/compose/releases/download/v{{ docker_compose_version }}/docker-compose-linux-{{ ansible_architecture }}"
    dest: /usr/local/bin/docker-compose
    mode: "0755"
    owner: root
    group: root
# roles/docker/handlers/main.yml
---
- name: Reinicia Docker
  ansible.builtin.systemd:
    name: docker
    state: restarted
{# roles/docker/templates/daemon.json.j2 #}
{{ docker_daemon_options | to_nice_json }}

Um Playbook Completo Usando Roles

# playbooks/site.yml
---
- name: Configura servidores base
  hosts: all
  become: true
  gather_facts: true

  roles:
    - role: base
      vars:
        timezone: "America/Sao_Paulo"
        ntp_servers:
          - "0.br.pool.ntp.org"
          - "1.br.pool.ntp.org"

- name: Configura servidores web
  hosts: webservers
  become: true

  roles:
    - role: docker
      vars:
        docker_users:
          - ubuntu
          - deploy
    - role: nginx
      vars:
        nginx_port: 80
        app_port: 3000
    - role: aplicacao
      vars:
        app_image: "ghcr.io/minha-empresa/minha-api:{{ app_version }}"

- name: Configura servidores de banco de dados
  hosts: databases
  become: true

  roles:
    - role: postgres
      vars:
        postgres_version: "16"
        postgres_max_connections: 200
        postgres_shared_buffers: "256MB"

Variáveis e Precedência

O Ansible tem um sistema de precedência de variáveis com 22 níveis. Os mais relevantes na prática, do menor para o maior:

role defaults          → valores padrão das roles — facilmente sobrescritos
inventory vars         → variáveis definidas no inventory
playbook vars          → variáveis definidas no playbook
role vars              → variáveis fixas das roles
extra vars (-e)        → variáveis passadas na linha de comando — maior precedência

Usar a precedência corretamente torna as configurações flexíveis sem sacrificar clareza:

# Sobrescreve variáveis na linha de comando — útil para deploys
ansible-playbook \
  -i inventory/hosts.yml \
  playbooks/site.yml \
  -e "app_version=1.5.0" \
  -e "ambiente=producao"

Ansible Vault: Criptografando Segredos

Variáveis sensíveis — senhas, tokens, chaves — nunca devem ficar em texto puro nos arquivos do Ansible. O Ansible Vault criptografa arquivos ou strings individuais usando AES-256:

# Cria um arquivo de variáveis criptografado
ansible-vault create inventory/group_vars/all/vault.yml

# Edita um arquivo vault existente
ansible-vault edit inventory/group_vars/all/vault.yml

# Criptografa um arquivo existente
ansible-vault encrypt inventory/group_vars/databases/vars.yml

# Descriptografa para visualização
ansible-vault view inventory/group_vars/all/vault.yml

# Criptografa apenas uma string
ansible-vault encrypt_string 'senha_super_secreta' --name 'db_password'

Estrutura recomendada para variáveis com vault:

# inventory/group_vars/databases/vars.yml — não criptografado
postgres_user: app
postgres_db: producao
postgres_port: 5432

# inventory/group_vars/databases/vault.yml — criptografado com vault
vault_postgres_password: !vault |
  $ANSIBLE_VAULT;1.1;AES256
  66386439653236336462626566653061373932...
# inventory/group_vars/databases/vars.yml
# Referencia a variável do vault com um nome sem prefixo vault_
postgres_password: "{{ vault_postgres_password }}"

Executando playbooks com vault:

# Solicita a senha interativamente
ansible-playbook playbooks/site.yml --ask-vault-pass

# Lê a senha de um arquivo (útil em CI/CD)
echo "minha-senha-vault" > .vault-password
chmod 600 .vault-password
ansible-playbook playbooks/site.yml --vault-password-file .vault-password

# Variável de ambiente
export ANSIBLE_VAULT_PASSWORD_FILE=.vault-password
ansible-playbook playbooks/site.yml

Idempotência: O Princípio Central

A idempotência é o princípio que garante que executar o mesmo playbook múltiplas vezes produz sempre o mesmo resultado — sem efeitos colaterais cumulativos. Instalar o Nginx uma segunda vez não deve causar erro. Criar um usuário que já existe não deve falhar.

A maioria dos módulos do Ansible é idempotente por design — o módulo apt verifica se o pacote já está instalado antes de tentar instalá-lo, o módulo file verifica se o arquivo já existe com as permissões corretas antes de modificá-lo. É responsabilidade do autor do playbook garantir que tasks personalizadas com command ou shell também sejam idempotentes:

# Não idempotente — executa sempre, mesmo se já foi feito
- name: Inicializa o banco de dados
  ansible.builtin.command: npm run db:migrate

# Idempotente — cria um arquivo de flag após a primeira execução
- name: Verifica se o banco já foi inicializado
  ansible.builtin.stat:
    path: /opt/app/.db-initialized
  register: db_initialized

- name: Inicializa o banco de dados
  ansible.builtin.command: npm run db:migrate
  when: not db_initialized.stat.exists

- name: Marca banco como inicializado
  ansible.builtin.file:
    path: /opt/app/.db-initialized
    state: touch
  when: not db_initialized.stat.exists

O Que Vem a Seguir

O próximo artigo fecha o Módulo 5 combinando Terraform e Ansible em um fluxo de trabalho unificado — provisionar a infraestrutura com Terraform e em seguida configurá-la com Ansible, formando um pipeline completo de IaC do zero ao servidor pronto para receber a aplicação.

Referências para Aprofundamento

Documentação oficial

Boas práticas

Prática

Exercícios

Exercício 1

Qual é a divisão de responsabilidades entre Terraform e Ansible? Cite duas tarefas de cada lado e explique o que significa dizer que o Ansible é agentless.

Ver resposta

✓ Resposta: O Terraform provisiona a infraestrutura — cria servidores, redes, bancos de dados, tudo que existe "de fora" da máquina. O Ansible faz gerenciamento de configuração: cuida do que acontece dentro do servidor depois que ele existe — instalar pacotes, copiar arquivos de configuração, criar usuários, iniciar serviços, aplicar patches.

Do lado do Terraform: criar a VPC com suas subnets, subir uma instância EC2 ou um RDS. Do lado do Ansible: instalar e configurar o Nginx, criar o usuário deploy com o diretório da aplicação.

Agentless significa que nada precisa ser instalado previamente nos servidores gerenciados. O Ansible se conecta por SSH — que qualquer servidor Linux já oferece — e executa as tarefas por ali. Ferramentas com agente exigiriam instalar e manter um serviço em cada máquina, o que cria um problema circular: seria preciso configurar o servidor antes de poder usar a ferramenta que configura servidores.

Exercício 2

Três tasks do playbook notificam o handler Recarrega Nginx. Se as três causarem mudança na mesma execução, quantas vezes o Nginx é recarregado e em que momento? E se nenhuma delas mudar nada?

Ver resposta

✓ Resposta: Uma única vez, e ao final do playbook, depois que todas as tasks terminarem. Handlers são acumulados durante a execução e disparados uma só vez, por mais notificações que recebam.

Se nenhuma das três mudar nada, o handler não roda. A notificação só acontece quando a task reporta estado changed — se o template gerou conteúdo idêntico ao que já estava lá, o link simbólico já existia e o arquivo default já tinha sido removido, o Ansible reporta ok e o Nginx não é tocado.

É esse o motivo de usar handler em vez de simplesmente colocar um systemd: state=reloaded como última task: assim o serviço só é recarregado quando algo realmente mudou. Uma task comum recarregaria o Nginx a cada execução do playbook, mesmo sem motivo — e a execução deixaria de ser inócua, que é justamente o que se espera de um playbook idempotente.

Exercício 3

No template nginx.conf.j2, de onde vem cada um destes três valores: {{ nginx_port | default(80) }}, {{ app_port }} e {{ ansible_hostname }}? O que o filtro default(80) resolve?

Ver resposta

✓ Resposta: nginx_port vem do inventory, definido por host (web01 e web02 declaram nginx_port: 80) ou pelo grupo em [webservers:vars]. app_port vem das vars do grupo webservers no inventory — e também aparece nas vars do playbook. Já ansible_hostname não foi definido por ninguém: é um fact, coletado da própria máquina pelo gather_facts: true no início da play.

O filtro default(80) torna a variável opcional: se nginx_port não estiver definida para aquele host, o template usa 80 em vez de falhar. Sem ele, aplicar o playbook a um host novo que não declarou a porta abortaria com erro de variável indefinida — o filtro permite que o inventory só precise declarar o que foge do padrão.

Exercício 4

A primeira versão da task abaixo não é idempotente. Explique o problema concreto e descreva como as três tasks da segunda versão o resolvem.

- name: Inicializa o banco de dados
  ansible.builtin.command: npm run db:migrate
Ver resposta

✓ Resposta: Porque o módulo command apenas executa o comando — ele não tem como saber se aquilo já foi feito. A cada execução do playbook a migração roda de novo. Diferente do módulo apt, que consulta se o pacote está instalado antes de agir, ou do file, que verifica se o arquivo já existe com as permissões pedidas, o command e o shell são sempre reportados como changed e não têm noção de estado.

Na prática, rodar migrações repetidamente pode recriar dados de seed, disparar erros de coluna já existente ou reexecutar transformações destrutivas — e um playbook que se pretende seguro de reexecutar deixa de ser.

A segunda versão introduz estado explícito: o módulo stat verifica a existência de /opt/app/.db-initialized e guarda o resultado em db_initialized com register; a migração roda apenas when: not db_initialized.stat.exists; e a terceira task cria o arquivo de flag sob a mesma condição. Na primeira execução as três agem; da segunda em diante o stat encontra o arquivo e as outras duas são puladas. A responsabilidade de saber "isto já foi feito" passa a ser do autor do playbook — que é exatamente o que o artigo aponta ao usar command ou shell.

Exercício 5

Ordene as fontes de variáveis da menor para a maior precedência e diga qual vence um conflito entre role defaults e -e na linha de comando. No Ansible Vault, por que a senha é guardada como vault_postgres_password e referenciada por postgres_password em um arquivo separado?

Ver resposta

✓ Resposta: Da menor para a maior: role defaults → inventory vars → playbook vars → role vars → extra vars (-e). Num conflito entre role defaults e -e, vence o -e — ele tem a maior precedência de todas, o que faz de ansible-playbook ... -e "app_version=1.5.0" o mecanismo natural para parametrizar um deploy sem editar arquivo nenhum. Os role defaults, na outra ponta, existem justamente para serem sobrescritos: são o palpite razoável da role quando ninguém disse nada.

Quanto ao Vault, a separação em dois arquivos é uma questão de legibilidade. Todo o conteúdo de um arquivo criptografado vira um bloco ilegível — não dá para revisar em um Pull Request nem descobrir quais chaves existem ali sem descriptografar. Mantendo em vault.yml apenas os segredos, com o prefixo vault_, e deixando em vars.yml (em texto puro) a linha postgres_password: "{{ vault_postgres_password }}", o arquivo legível continua documentando quais variáveis existem e de onde vêm, enquanto só o valor sensível fica cifrado. O prefixo deixa evidente, ao ler qualquer playbook, quais valores são segredos.

Comentários

Mais em DevOps

Azure DevOps: Pipelines e Repositórios
Azure DevOps: Pipelines e Repositórios

A plataforma que muitas empresas já adotaram antes do GitHub: os cinco…

Azure para Quem Já Conhece AWS
Azure para Quem Já Conhece AWS

Um mapa de tradução para quem já domina a AWS: a hierarquia de tenant…

DevSecOps: Integrando Segurança em Todo o Pipeline
DevSecOps: Integrando Segurança em Todo o Pipeline

Segurança deslocada para a esquerda, em seis camadas: análise estática com…