
Administrar um servidor na mão é tranquilo. Administrar dez, entrando por SSH em um por um, é pedir para errar. O Ansible resolve isso: você descreve o estado desejado em YAML e ele aplica em todos os servidores, sem agente, só com SSH e Python do outro lado. Este guia é o passo seguinte para quem já sabe instalar o Ansible e rodar o primeiro playbook. Se você está começando do zero, leia antes os nossos primeiros passos com Ansible.
Aqui vamos montar um projeto de verdade: inventário com grupos, firewall, Nginx com template e handler que testa a configuração antes de recarregar, senha guardada no vault e uma atualização em ondas que para sozinha se um servidor não voltar. Todos os comandos e saídas foram testados em três máquinas virtuais Ubuntu 24.04 com o ansible-core 2.21.
Estrutura do projeto
projeto/
├── ansible.cfg
├── inventario.ini
├── requirements.yml
├── group_vars/
│ ├── all/main.yml
│ └── web/
│ ├── main.yml
│ └── vault.yml # criptografado
├── templates/
│ ├── site.conf.j2
│ ├── index.html.j2
│ └── app.env.j2
├── site.yml # estado desejado de todos os servidores
└── atualiza.yml # atualização em ondas
O ansible.cfg na raiz evita repetir -i inventario.ini em todo comando:
[defaults]
inventory = inventario.ini
Cuidado com comentário na mesma linha dentro do ansible.cfg: em host_key_checking = True # comentário o valor inteiro vira uma string que não é booleano válido e o Ansible passa a ler False. Coloque comentários em linha própria, começando com # ou ;.
Inventário com grupos
Os servidores se dividem em grupos, e um grupo pode conter outros (:children). Assim uma variável definida em producao vale para web e banco ao mesmo tempo:
[web]
web1 ansible_host=10.0.10.11
web2 ansible_host=10.0.10.12
[db]
db1 ansible_host=10.0.10.21
[producao:children]
web
db
[producao:vars]
ansible_user=deploy
ansible_python_interpreter=/usr/bin/python3
Confira a árvore antes de rodar qualquer coisa:
ansible-inventory --graph
@all:
|--@ungrouped:
|--@producao:
| |--@web:
| | |--web1
| | |--web2
| |--@db:
| | |--db1
As variáveis de cada grupo ficam em group_vars/, com o nome do grupo:
# group_vars/all/main.yml
---
pacotes_base:
- htop
- vim
- unattended-upgrades
# group_vars/web/main.yml
---
dominio: exemplo.com.br
app_env: producao
app_db_senha: "{{ vault_app_db_senha }}"
Coleções: o ufw não vem no ansible-core
O módulo community.general.ufw faz parte da coleção community.general. O pacote completo ansible já traz essa coleção, mas quem instalou só o ansible-core precisa instalá-la. Declare as dependências em requirements.yml:
---
collections:
- name: community.general
ansible-galaxy collection install -r requirements.yml
ansible-galaxy collection list community.general
O playbook completo
O site.yml tem duas plays. A primeira vale para todos os servidores: pacotes básicos e firewall. A segunda só para o grupo web: Nginx, site e arquivo de configuração da aplicação.
---
- name: Base de todos os servidores
hosts: producao
become: true
tags: [base]
tasks:
- name: Instalar pacotes básicos
ansible.builtin.apt:
name: "{{ pacotes_base }}"
state: present
update_cache: true
cache_valid_time: 3600
- name: Liberar SSH no ufw antes de ativar o firewall
community.general.ufw:
rule: allow
name: OpenSSH
- name: Ativar o ufw bloqueando o resto por padrão
community.general.ufw:
state: enabled
policy: deny
direction: incoming
- name: Servidores web
hosts: web
become: true
tags: [web]
tasks:
- name: Instalar o Nginx
ansible.builtin.apt:
name: nginx
state: present
- name: Liberar HTTP e HTTPS no ufw
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop: ["80", "443"]
- name: Criar a raiz do site
ansible.builtin.file:
path: /var/www/{{ dominio }}
state: directory
mode: "0755"
- name: Publicar a página inicial
ansible.builtin.template:
src: templates/index.html.j2
dest: /var/www/{{ dominio }}/index.html
mode: "0644"
- name: Configurar o site
ansible.builtin.template:
src: templates/site.conf.j2
dest: /etc/nginx/sites-available/{{ dominio }}.conf
mode: "0644"
notify: Nginx alterado
- name: Ativar o site
ansible.builtin.file:
src: /etc/nginx/sites-available/{{ dominio }}.conf
dest: /etc/nginx/sites-enabled/{{ dominio }}.conf
state: link
notify: Nginx alterado
- name: Remover o site padrão
ansible.builtin.file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: Nginx alterado
- name: Garantir o Nginx ativo no boot
ansible.builtin.service:
name: nginx
state: started
enabled: true
- name: Criar o diretório da aplicação
ansible.builtin.file:
path: /etc/meuapp
state: directory
mode: "0750"
- name: Gravar as variáveis da aplicação, com a senha vinda do vault
ansible.builtin.template:
src: templates/app.env.j2
dest: /etc/meuapp/app.env
owner: root
group: root
mode: "0600"
no_log: true
handlers:
- name: Testar a configuração do Nginx
ansible.builtin.command: nginx -t
changed_when: false
listen: Nginx alterado
- name: Recarregar o Nginx
ansible.builtin.service:
name: nginx
state: reloaded
listen: Nginx alterado
Três detalhes merecem atenção:
- A ordem do firewall importa. Primeiro libera o OpenSSH, depois ativa o ufw com política
deny. Na ordem inversa, o Ansible derruba a própria conexão e você perde o acesso ao servidor. - Handlers com
listen. As três tarefas que mexem na configuração notificam o mesmo evento,Nginx alterado. No fim da play, os dois handlers rodam uma única vez, na ordem em que foram declarados: primeironginx -t, depois o reload. Se a configuração estiver quebrada, o teste falha e o reload não acontece, e o Nginx segue servindo a versão anterior. no_log: trueesconde da saída o conteúdo da tarefa que grava a senha. Sem ele, um-vou um--diffimprime o segredo no terminal e no log do CI.
Templates Jinja2
Um template é um arquivo com variáveis. O mesmo site.conf.j2 serve para qualquer domínio:
# {{ ansible_managed }}
server {
listen 80;
server_name {{ dominio }} www.{{ dominio }};
root /var/www/{{ dominio }};
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
<!-- templates/index.html.j2 -->
<h1>{{ dominio }}</h1>
<p>Servido por {{ inventory_hostname }}</p>
# templates/app.env.j2
# {{ ansible_managed }}
APP_ENV={{ app_env }}
DB_PASSWORD={{ app_db_senha }}
A linha {{ ansible_managed }} vira um comentário avisando que o arquivo é gerenciado pelo Ansible, o que ajuda quem abrir o arquivo no servidor e pensar em editá-lo à mão. Se você precisa de várias versões de PHP atrás desse Nginx, o template é o lugar certo para os blocos location. Veja como montar isso em Nginx e várias versões de PHP no Debian e Ubuntu.
Segredos com ansible-vault
A senha do banco não pode ficar em YAML aberto no Git. Guarde-a em group_vars/web/vault.yml e criptografe o arquivo:
cat > group_vars/web/vault.yml <<'EOF'
---
vault_app_db_senha: "troque-esta-senha"
EOF
ansible-vault encrypt group_vars/web/vault.yml
head -1 group_vars/web/vault.yml # $ANSIBLE_VAULT;1.1;AES256
ansible-vault view group_vars/web/vault.yml
ansible-vault edit group_vars/web/vault.yml
A convenção de prefixar com vault_ e referenciar a variável em main.yml (app_db_senha: "{{ vault_app_db_senha }}") deixa visível, sem abrir o cofre, quais variáveis existem e de onde vêm.
Para um valor avulso, sem criar arquivo, use encrypt_string e cole o resultado direto no YAML:
ansible-vault encrypt_string 'meu-token' --name api_token
Uma pegadinha que aparece no primeiro uso: o Ansible carrega o vault.yml para qualquer play que toque o grupo, mesmo que nenhuma tarefa use a senha. Rodar o playbook sem informar a senha falha antes da primeira tarefa:
[ERROR]: Attempting to decrypt but no vault secrets found.
Informe a senha com --ask-vault-pass ou aponte para um arquivo fora do repositório, com permissão 600. A variável de ambiente evita repetir a opção em todo comando e não quebra o projeto de quem não tem o arquivo:
export ANSIBLE_VAULT_PASSWORD_FILE=~/.config/ansible/vault-pass
Colocar vault_password_file no ansible.cfg também funciona, mas aí o playbook falha em qualquer máquina onde esse arquivo não exista.
Primeira execução e idempotência
Antes de aplicar, valide a sintaxe e liste o que vai rodar:
ansible-playbook site.yml --syntax-check
ansible-playbook site.yml --list-tasks
ansible-playbook site.yml
O resumo da primeira execução no laboratório:
RUNNING HANDLER [Testar a configuração do Nginx] ****
ok: [web1]
ok: [web2]
RUNNING HANDLER [Recarregar o Nginx] ****
changed: [web1]
changed: [web2]
PLAY RECAP ****
db1 : ok=4 changed=3 unreachable=0 failed=0
web1 : ok=17 changed=13 unreachable=0 failed=0
web2 : ok=17 changed=13 unreachable=0 failed=0
Agora rode de novo. Na segunda vez nada muda, e os handlers nem aparecem, porque nenhuma tarefa notificou:
db1 : ok=4 changed=0 unreachable=0 failed=0
web1 : ok=15 changed=0 unreachable=0 failed=0
web2 : ok=15 changed=0 unreachable=0 failed=0
Isso é idempotência: o Ansible só age quando o estado real difere do declarado. Por isso dá para rodar o mesmo playbook toda noite, e o changed diferente de zero passa a ser um alerta de que alguém mexeu no servidor fora do Ansible.
No servidor, o resultado é o esperado: ufw status verbose mostra Default: deny (incoming) com 22, 80 e 443 liberados nos servidores web e só o OpenSSH no banco, e o /etc/meuapp/app.env está com -rw------- root root.
Simular antes de aplicar: --check --diff
Mudou o template? Veja o efeito antes, em um único servidor e só na parte web:
ansible-playbook site.yml --tags web --limit web1 --check --diff
TASK [Configurar o site] ****
--- before: /etc/nginx/sites-available/exemplo.com.br.conf
+++ after: .../site.conf.j2
@@ -3,7 +3,7 @@
listen 80;
server_name exemplo.com.br www.exemplo.com.br;
root /var/www/exemplo.com.br;
- index index.html;
+ index index.html index.htm;
O modo check não altera nada no servidor. As tags que colocamos em cada play (base e web) permitem rodar só uma parte do projeto, e o --limit restringe a um host ou grupo. Juntos, servem para testar uma mudança em uma máquina antes das outras.
Atualização em ondas (rolling update)
Com servidores atrás de um balanceador, ninguém quer atualizar e reiniciar todos ao mesmo tempo. O atualiza.yml atualiza um servidor por vez, reinicia só se o sistema pedir e confere se o site voltou antes de passar ao próximo:
---
- name: Atualizar a frota web sem derrubar tudo
hosts: web
become: true
serial: 1
max_fail_percentage: 0
tasks:
- name: Atualizar os pacotes
ansible.builtin.apt:
upgrade: dist
update_cache: true
- name: Verificar se o sistema pede reinício
ansible.builtin.stat:
path: /var/run/reboot-required
register: reinicio
- name: Reiniciar se for preciso
ansible.builtin.reboot:
reboot_timeout: 600
when: reinicio.stat.exists
- name: Conferir se o site responde antes de seguir para o próximo
ansible.builtin.uri:
url: "http://{{ ansible_host }}/"
status_code: 200
delegate_to: localhost
become: false
register: saude
retries: 5
delay: 3
until: saude.status == 200

serial: 1processa um host por vez. Aceita também porcentagem (serial: "25%") ou uma lista que cresce, como[1, 5, "50%"].max_fail_percentage: 0interrompe tudo na primeira falha.- A checagem roda no nó de controle (
delegate_to: localhost), do mesmo ponto de vista de quem acessa o site, e tenta cinco vezes antes de desistir.
Lembre do vault: como atualiza.yml também toca o grupo web, ele precisa da senha mesmo sem usar nenhum segredo.
No laboratório, web1 e web2 foram atualizados e reiniciados um depois do outro. Para ver a proteção funcionar, paramos o Nginx de web1 e rodamos de novo:
FAILED - RETRYING: [web1 -> localhost]: Conferir se o site responde antes de seguir para o próximo (4 retries left).
...
fatal: [web1 -> localhost]: FAILED! => {"attempts": 5, "changed": false, "msg": "Status code was -1 and not [200]: Connection failure: [Errno 104] Connection reset by peer", "status": -1, ...}
NO MORE HOSTS LEFT ****
O web2 nem foi tocado e seguiu no ar. Uma nova execução do site.yml religou o Nginx em web1, porque a tarefa state: started corrige o desvio.
Comandos rápidos (ad-hoc)
Nem tudo precisa de playbook. Para uma ação pontual, use o comando ansible com um módulo:
# quanto tempo cada servidor está no ar
ansible producao -a "uptime -p"
# reiniciar o Nginx nos servidores web
ansible web -b -m ansible.builtin.systemd_service -a "name=nginx state=restarted"
# espaço em disco no banco
ansible db -a "df -h /"
Se a ação virar rotina, transforme-a em tarefa de playbook: aí ela ganha histórico no Git e idempotência.
Qualidade: ansible-lint
O ansible-lint aponta módulos sem o nome completo (FQCN), permissões de arquivo implícitas, tarefas sem nome e outros problemas que viram bug em produção. Os dois playbooks deste post passam no perfil production, o mais rigoroso:
pipx install ansible-lint
ansible-lint site.yml atualiza.yml
Coloque o lint no CI. Assim, o erro aparece no pull request, antes de chegar a um servidor.
Próximos passos
Quando o site.yml crescer, quebre-o em roles: uma para a base, outra para o Nginx, outra para a aplicação. Um exemplo completo de role, com testes e CI, é o ansible-linux-users no GitHub, explicado passo a passo em usuários no Linux com Ansible: chaves SSH, sudo e histórico no Git. Para ir além dos módulos usados aqui, a referência é a documentação oficial do Ansible. E se automação é o seu rumo de carreira, o Ansible é peça central da certificação LPI DevOps Tools Engineer.
Comece automatizando uma tarefa que você repete toda semana. Em um mês, você não vai lembrar como vivia sem.