Automatizando servidores Linux con 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 un servidor a mano es tranquilo. Administrar diez, entrando por SSH uno a uno, es pedir para equivocarse. Ansible Ansible lo soluciona: describes el estado deseado en YAML y lo aplica en todos los servidores, sin agente, solo con SSH y Python al otro lado. Esta guía es el siguiente paso para quien ya sabe instalar Ansible y ejecutar el primer playbook. Si estás empezando desde cero, lee antes nuestros primeros pasos con Ansible.

Aquí vamos a montar un proyecto de verdad: inventario con grupos, firewall, Nginx con plantilla y handler que prueba la configuración antes de recargar, contraseña guardada en el vault y una actualización por oleadas que se detiene sola si un servidor no vuelve. Todos los comandos y salidas se han probado en tres máquinas virtuales Ubuntu 24.04 con ansible-core 2.21.

Estructura del proyecto

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 en la raíz evita repetir -i inventario.ini en cada comando:

[defaults]
inventory = inventario.ini

Cuidado con comentarios en la misma línea dentro del ansible.cfg: en host_key_checking = True # comentário el valor entero se convierte en una cadena que no es un booleano válido y Ansible pasa a leer False. los servidores se dividen en grupos, y un grupo puede contener otros ( # o ;.

Inventario con grupos

. Así una variable definida en:children. Coloque los comentarios en una línea propia, empezando con producao vale para web y banco al mismo tiempo:

[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

Consulte el árbol antes de ejecutar cualquier cosa:

ansible-inventory --graph
@all:
  |--@ungrouped:
  |--@producao:
  |  |--@web:
  |  |  |--web1
  |  |  |--web2
  |  |--@db:
  |  |  |--db1

Las variables de cada grupo están en group_vars/, con el nombre del 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 }}"

Colecciones: ufw no viene en ansible-core

El módulo community.general.ufw forma parte de la colección community.general. El paquete completo ansible ya trae esta colección, pero quien instaló solo el ansible-core necesita instalarla. Declara las dependencias en requirements.yml:

---
collections:
  - name: community.general
ansible-galaxy collection install -r requirements.yml
ansible-galaxy collection list community.general

El playbook completo

O site.yml tiene dos plays. La primera vale para todos los servidores: paquetes básicos y firewall. La segunda solo para el grupo web: Nginx, sitio y archivo de configuración de la aplicación.

---
- 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

Tres detalles merecen atención:

  • El orden del firewall importa. Primero se libera OpenSSH, luego se activa ufw con la política deny. En el orden inverso, Ansible tira abajo la propia conexión y pierdes el acceso al servidor.
  • Handlers con listen. Las tres tareas que tocan la configuración notifican el mismo evento, Nginx alterado. Al final del play, los dos handlers se ejecutan una sola vez, en el orden en que se declararon: primero nginx -t, luego el reload. Si la configuración está rota, la prueba falla y el reload no ocurre, y Nginx sigue sirviendo la versión anterior.
  • no_log: true esconde de la salida el contenido de la tarea que escribe la contraseña. Sin él, un -v o un --diff imprime el secreto en el terminal y en el log del CI.

Plantillas Jinja2

Una plantilla es un archivo con variables. La misma site.conf.j2 sirve para cualquier dominio:

# {{ 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 }}

La línea {{ ansible_managed }} se convierte en un comentario avisando que el archivo es gestionado por Ansible, lo que ayuda a quien abra el archivo en el servidor y piense en editarlo a mano. Si necesitas varias versiones de PHP detrás de ese Nginx, la plantilla es el lugar adecuado para los bloques location. Mira cómo montarlo en Nginx y varias versiones de PHP en Debian y Ubuntu.

Secretos con ansible-vault

La contraseña de la base de datos no puede quedar en YAML abierto en Git. Guárdala en group_vars/web/vault.yml y cifra el archivo:

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

La convención de prefijar con vault_ y referenciar la variable en main.yml (app_db_senha: "{{ vault_app_db_senha }}") muestra, sin abrir la caja fuerte, qué variables existen y de dónde proceden.

Para un valor puntual, sin crear un archivo, usa encrypt_string y pega el resultado directamente en el YAML:

ansible-vault encrypt_string 'meu-token' --name api_token

Un truco que aparece en el primer uso: Ansible carga el vault.yml para cualquier play que toque el grupo, aunque ninguna tarea use la contraseña. Ejecutar el playbook sin proporcionar la contraseña falla antes de la primera tarea:

[ERROR]: Attempting to decrypt but no vault secrets found.

Proporciona la contraseña con --ask-vault-pass o apunta a un archivo fuera del repositorio, con permisos 600. La variable de entorno evita repetir la opción en cada comando y no rompe el proyecto de quien no tiene el archivo:

export ANSIBLE_VAULT_PASSWORD_FILE=~/.config/ansible/vault-pass

Colocar vault_password_file no ansible.cfg también funciona, pero entonces el playbook falla en cualquier máquina donde ese archivo no exista.

Primera ejecución e idempotencia

Antes de aplicar, valide la sintaxis y liste lo que se va a ejecutar:

ansible-playbook site.yml --syntax-check
ansible-playbook site.yml --list-tasks
ansible-playbook site.yml

El resumen de la primera ejecución en el laboratorio:

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

Ahora ejecútelo de nuevo. La segunda vez no cambia nada, y los handlers ni siquiera aparecen, porque ninguna tarea notificó:

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

Esto es idempotencia: Ansible solo actúa cuando el estado real difiere del declarado. Por eso se puede ejecutar el mismo playbook cada noche, y el changed distinto de cero pasa a ser una alerta de que alguien tocó el servidor fuera de Ansible.

En el servidor, el resultado es el esperado: ufw status verbose muestra Default: deny (incoming) con 22, 80 y 443 habilitados en los servidores web y solo OpenSSH en el de base de datos, y el /etc/meuapp/app.env está con -rw------- root root.

Simular antes de aplicar: --check --diff

¿Cambió la plantilla? Vea el efecto antes, en un único servidor y solo en la 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;

El modo check no altera nada en el servidor. Las tags que colocamos en cada play (base e web) permiten ejecutar solo una parte del proyecto, y el --limit restringe a un host o grupo. Juntos, sirven para probar un cambio en una máquina antes que en las demás.

Actualización por oleadas (rolling update)

Con servidores detrás de un balanceador, nadie quiere actualizar y reiniciar todos a la vez. El atualiza.yml actualiza un servidor por vez, reinicia solo si el sistema lo pide y comprueba si el sitio ha vuelto antes de pasar al siguiente:

---
- 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 procesa un host por vez. Acepta también porcentaje (serial: "25%") o una lista que crece, como [1, 5, "50%"].
  • max_fail_percentage: 0 interrumpe todo en el primer fallo.
  • La comprobación se ejecuta en el nodo de control (delegate_to: localhost), desde el mismo punto de vista de quien accede al sitio, e intenta cinco veces antes de rendirse.

Recuerda el vault: como atualiza.yml también toca el grupo web, lo necesita la contraseña aunque no use ningún secreto.

En el laboratorio, web1 y web2 se actualizaron y reiniciaron una tras otra. Para ver la protección en acción, paramos Nginx de web1 y lo ejecutamos de nuevo:

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 ****

Web2 ni se tocó y siguió funcionando. Una nueva ejecución de site.yml volvió a levantar Nginx en web1, porque la tarea state: started corrige el desvío.

Comandos rápidos (ad-hoc)

No todo necesita un playbook. Para una acción puntual, usa el comando ansible con un 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 /"

Si la acción se vuelve rutinaria, conviértela en una tarea de playbook: entonces tendrá historial en Git e idempotencia.

Calidad: ansible-lint

O ansible-lint señala módulos sin el nombre completo (FQCN), permisos de archivo implícitos, tareas sin nombre y otros problemas que se convierten en errores en producción. Los dos playbooks de este post pasan el perfil production, el más riguroso:

pipx install ansible-lint
ansible-lint site.yml atualiza.yml

Pon el lint en el CI. Así, el error aparece en el pull request, antes de llegar a un servidor.

Próximos pasos

Cuando el site.yml crecimiento, rómpelo en roles: uno para la base, otro para Nginx, otro para la aplicación. Un ejemplo completo de role, con pruebas e CI, es el ansible-linux-users en GitHub, explicado paso a paso en usuarios en Linux con Ansible: claves SSH, sudo e historial en Git. Para ir más allá de los módulos utilizados aquí, la referencia es la documentación oficial de Ansible. Y si la automatización es tu camino profesional, Ansible es una pieza central de certificación LPI DevOps Tools Engineer.

Empieza automatizando una tarea que repitas cada semana. En un mes, no recordarás cómo vivías sin ella.