
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: primeronginx -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: trueesconde de la salida el contenido de la tarea que escribe la contraseña. Sin él, un-vo un--diffimprime 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

serial: 1procesa un host por vez. Acepta también porcentaje (serial: "25%") o una lista que crece, como[1, 5, "50%"].max_fail_percentage: 0interrumpe 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.