Automatizando servidores Linux com Ansible

Mascote do LinuxPro aperta o botão do playbook e aponta para quatro racks atualizados um por vez sob o logo do Ansible, ao lado de um cofre trancado e do cachorro caramelo ciborgue

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: primeiro nginx -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: true esconde da saída o conteúdo da tarefa que grava a senha. Sem ele, um -v ou um --diff imprime 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

Diagrama do rolling update com Ansible: o nó de controle atualiza web1, reinicia se preciso e checa HTTP 200 antes de seguir para web2; se a checagem falha, a onda para e web2 não é tocado

  • serial: 1 processa um host por vez. Aceita também porcentagem (serial: "25%") ou uma lista que cresce, como [1, 5, "50%"].
  • max_fail_percentage: 0 interrompe 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.