Plantillas y handlers de Ansible con un ejemplo
Genera una configuracion de nginx con Jinja2 y recarga el servicio solo si cambia. Ejecuta el playbook dos veces para comprobar la idempotencia.
Qué aportan las plantillas y los handlers de Ansible a su primer playbook
Las plantillas y los handlers de Ansible son los dos componentes que convierten un playbook estático en uno útil. Una plantilla genera un archivo de configuración a partir de sus variables, por lo que un solo archivo sirve para todos los hosts. Un handler se ejecuta sólo cuando una tarea realmente ha cambiado algo. Así, el servicio se recarga cuando cambia la configuración y permanece sin cambios el resto del tiempo.
Esta guía continúa exactamente donde termina su primer playbook de Ansible en un VPS. Ya tiene un play que instala un paquete e inicia un servicio. Todo lo siguiente se ejecuta en una máquina, porque el play apunta a localhost mediante una conexión local. No necesita un segundo servidor para seguir la guía. El mismo play se ejecuta en hosts de un inventario real sin modificar las tareas. La última sección explica qué cambia.
Configurar el directorio de trabajo
sudo apt update
sudo apt install -y ansible nginx
ansible --version
mkdir -p ~/ansible-templates/templates
cd ~/ansible-templatesnginx sólo aparece aquí porque es un servicio real con un archivo de configuración y un comando de recarga. Eso es todo lo que necesita el ejemplo. ansible --version muestra la versión de ansible-core y el intérprete de Python que utilizará. Anote ambos. El playbook siguiente usa nombres de módulo totalmente cualificados, como ansible.builtin.template, que requieren Ansible 2.10 o posterior. Cualquier paquete actual de una distribución supera ampliamente ese requisito.
Cree inventory.ini:
[local]
localhost ansible_connection=local ansible_python_interpreter="{{ ansible_playbook_python }}"ansible_connection=local indica a Ansible que ejecute cada tarea como un proceso local en lugar de abrir una sesión SSH consigo mismo. La segunda configuración no es decorativa. Cuando escribe localhost en un archivo de inventario, se convierte en un host normal y pierde el intérprete que Ansible proporciona automáticamente a localhost implícito. Por tanto, vuelve a detectar el intérprete y puede elegir un Python distinto del que ejecuta el play. ansible_playbook_python es el intérprete que ejecuta ansible-playbook en este momento, por lo que ambos quedan alineados.
Cree ansible.cfg:
[defaults]
inventory = inventory.iniSin ese archivo, debe pasar -i inventory.ini en cada comando. Si no existe ningún inventario, Ansible muestra [WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all' y un play con hosts: all no coincide con ningún host. Hay otro aspecto importante sobre ansible.cfg: Ansible lo ignora cuando se encuentra en un directorio con permisos de escritura para cualquier usuario. Por eso, mantenga el proyecto dentro de su directorio personal. Un archivo de inventario contiene más que una lista de hosts, y este es el archivo mínimo que cumple la función.
template frente a copy: cuándo corresponde usar cada uno
ansible.builtin.copy transfiere un archivo sin modificarlo. ansible.builtin.template procesa primero el archivo con Jinja2 y transfiere el resultado. La documentación del módulo describe template como «un módulo virtual implementado por completo como un complemento de acción y ejecutado en el controlador». Esto tiene una consecuencia importante: el renderizado se realiza en el equipo donde se ejecutó ansible-playbook. El host de destino nunca recibe las variables y no necesita tener Jinja2 instalado.
Use copy cuando el archivo sea idéntico en todos los hosts. Use template en cuanto un valor difiera según el host o necesite un bucle {% for %} o un bloque {% if %}. copy sí tiene un parámetro content:, y las variables incluidas en él se sustituyen como en cualquier otro argumento de tarea, pero no admite bucles ni condicionales. Por tanto, todo lo que tenga estructura debe ir en una plantilla. Ambos módulos aceptan las mismas opciones de archivo porque incorporan los mismos fragmentos de documentación. Por eso, owner, group, mode, backup y validate funcionan del mismo modo en ambos.
Escriba la plantilla: una variable, un bucle
Guarde esto como templates/app.conf.j2:
# {{ ansible_managed }}
upstream {{ app_name }}_backend {
{% for backend in app_backends %}
server {{ backend.host }}:{{ backend.port }} weight={{ backend.weight }};
{% endfor %}
}
server {
listen {{ app_listen_port }};
server_name {{ app_server_name }};
location / {
proxy_pass http://{{ app_name }}_backend;
proxy_set_header Host $host;
}
}Aquí intervienen dos tipos de etiquetas de Jinja2. {{ ... }} es una expresión e imprime su valor. {% ... %} es una instrucción y no imprime nada por sí misma. app_backends es una lista de diccionarios, por lo que backend.host lee una clave de cada entrada y el bucle escribe una línea server por entrada, tantas como defina.
Hay un detalle sobre los espacios en blanco que suele sorprender a quienes conocen Jinja2 de otros entornos. Ansible establece trim_blocks en yes de forma predeterminada, mientras que Jinja2 no lo hace. Por eso se elimina el salto de línea inmediatamente posterior a una etiqueta {% ... %} y el bucle no deja una línea en blanco detrás. Ansible deja lstrip_blocks en no, por lo que conserva los espacios que coloque delante de una etiqueta {% y estos aparecen en el archivo generado. Si el resultado contiene sangría no deseada, establezca lstrip_blocks: true en la tarea de plantilla.
{{ ansible_managed }} se genera como el texto literal Ansible managed de forma predeterminada. Déjelo así. A menudo se redefine ansible_managed en ansible.cfg para incluir una fecha. En ese momento, el archivo generado cambia en cada ejecución, la tarea informa de un cambio en cada ejecución y el servicio se vuelve a cargar en cada ejecución. Ese ajuste destruye la propiedad en la que se basa el resto de esta guía. La extensión .j2 es una convención y Ansible no la comprueba.
El playbook
Guárdelo como site.yml:
- name: Render an nginx site from a template
hosts: local
become: true
vars:
app_name: learn
app_listen_port: 8080
app_server_name: learn.example.com
app_backends:
- host: 127.0.0.1
port: 9001
weight: 3
- host: 127.0.0.1
port: 9002
weight: 1
tasks:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
cache_valid_time: 3600
- name: Render the site configuration
ansible.builtin.template:
src: templates/app.conf.j2
dest: "/etc/nginx/conf.d/{{ app_name }}.conf"
owner: root
group: root
mode: '0644'
backup: true
notify: nginx config changed
- name: Make sure nginx is enabled and running
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers:
- name: Test the nginx configuration
ansible.builtin.command:
cmd: /usr/sbin/nginx -t
changed_when: false
listen: nginx config changed
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
listen: nginx config changedmode: '0644' aparece entre comillas de forma deliberada. La documentación de opciones de archivos indica que los números octales deben escribirse entre comillas «para que Ansible reciba una cadena y pueda convertirla por sí mismo de cadena a número». Sin comillas, el analizador YAML interpreta 0644 como un número simple y puede terminar aplicando permisos distintos de los solicitados.
notify: nginx config changed nombra un tema, no un handler. Ambos handlers contienen listen: nginx config changed, por lo que una notificación llega a los dos. Puede añadir más adelante un tercer handler con la misma línea listen y la tarea de plantilla no necesita cambios. cache_valid_time: 3600 evita que una segunda ejecución dentro de la misma hora vuelva a conectarse a los repositorios de paquetes.
Ejecutarlo una vez y leer la salida
ansible-playbook site.ymlSi sudo solicita una contraseña, añada -K y Ansible se la solicitará.
Lea primero las líneas de cada tarea y después el PLAY RECAP del final. Cada tarea muestra changed: cuando Ansible tuvo que realizar alguna acción, o ok: cuando el host ya se encontraba en el estado deseado. El resumen suma esos contadores por host. Después de que terminen todas las tareas del play, y no antes, aparece RUNNING HANDLER [Test the nginx configuration] seguido de RUNNING HANDLER [Reload nginx].
Ahora compruebe la propia máquina en lugar de confiar en la salida:
sudo cat /etc/nginx/conf.d/learn.conf
sudo /usr/sbin/nginx -t
curl -sI http://127.0.0.1:8080/nginx -t muestra nginx: configuration file /etc/nginx/nginx.conf test is successful cuando la configuración ensamblada se analiza correctamente. curl devuelve una línea de estado de nginx, y 502 Bad Gateway es la respuesta correcta en este caso, porque el bloque de servidor está activo y no hay nada escuchando en los puertos 9001 o 9002. sudo tail /var/log/nginx/error.log indica el motivo con palabras claras: connect() failed (111: Connection refused) while connecting to upstream.
Ejecutarlo una segunda vez para demostrar la idempotencia
ansible-playbook site.ymlEsta es la ejecución importante, así que compare su salida con la primera línea por línea. La tarea de plantilla ahora debería mostrar ok: donde antes mostraba changed:, y ninguno de los handlers debería aparecer en la salida.
El mecanismo es sencillo y conviene conocerlo, porque es la base para depurarlo. template renderiza el archivo en el controller y compara la suma de comprobación del resultado con la suma de comprobación del archivo que ya existe en dest. Si el contenido, el propietario y el modo coinciden, no hay nada que hacer. Por eso la tarea informa ok, notify nunca se activa y el handler nunca se ejecuta. Los handlers se activan cuando hay changed y por ningún otro motivo.
Demuestre también la dirección contraria. Cambie weight: 3 por weight: 1 en vars, vuelva a ejecutar el play y la tarea de plantilla informará changed, ambos handlers se ejecutarán y sudo cat /etc/nginx/conf.d/learn.conf mostrará el nuevo valor.
Si una segunda ejecución idéntica sigue informando de un cambio, el renderizado no es estable. Busque primero algún valor basado en el tiempo en la salida, porque es la causa más habitual y un ansible_managed personalizado suele ser el responsable. Después, compruebe que mode y owner de la tarea coincidan con lo que existe realmente en el disco, porque una discrepancia ahí cuenta como un cambio aunque los bytes sean idénticos.
Consulta un cambio antes de aplicarlo
ansible-playbook site.yml --check --diff--check ejecuta el play sin modificar el host. --diff muestra qué habría modificado cada tarea. En el caso de template, muestra una diferencia línea por línea entre el resultado renderizado y el archivo del disco. Juntos responden a la pregunta «qué haría esta ejecución» sin realizar cambios. El modo de comprobación también tiene limitaciones, sobre todo en las tareas cuyo resultado depende de una tarea anterior que el modo de comprobación no llegó a ejecutar.
Por qué los handlers esperan hasta el final
La documentación de handlers lo indica claramente: «De forma predeterminada, los handlers se ejecutan después de completar todas las tareas de un play. Los handlers notificados se ejecutan automáticamente después de cada una de las siguientes secciones, en este orden: pre_tasks, roles/tasks y post_tasks».
La razón es el procesamiento por lotes. Un play que genera cuatro archivos de configuración para un servicio debe reiniciar ese servicio una sola vez, al final, cuando los cuatro archivos ya estén instalados. Reiniciarlo después de cada archivo lo reiniciaría cuatro veces, y tres de esos reinicios cargarían una configuración incompleta. La misma página establece claramente la garantía: «Notificar el mismo handler varias veces hace que el handler se ejecute una sola vez, independientemente del número de tareas que lo notifiquen».
El orden también es fijo: «Los handlers se ejecutan en el orden en que están definidos en la sección handlers, no en el orden en que aparecen en la instrucción notify». Por eso Test the nginx configuration aparece encima de Reload nginx en el playbook. La prueba se ejecuta primero porque está escrita primero, y nada de la línea notify modifica este comportamiento.
Cómo ejecutar los handlers antes y cómo ejecutarlos después de un fallo
A veces una tarea posterior del mismo play necesita que el servicio ya esté ejecutando la nueva configuración. Ejecute en ese punto los handlers notificados con el módulo meta. La documentación lo describe como la acción que hace que «Ansible ejecute las tareas de cualquier handler que haya sido notificado hasta ese momento».
- name: Run the notified handlers now instead of at the end of the play
ansible.builtin.meta: flush_handlers
- name: Wait for the new listener to accept connections
ansible.builtin.wait_for:
host: 127.0.0.1
port: 8080
timeout: 10Si elimina la línea meta, la tarea wait_for se ejecuta mientras nginx todavía sirve la configuración antigua. En la primera ejecución todavía no hay ningún proceso escuchando en el puerto 8080, por lo que la tarea espera los diez segundos completos y después falla.
El segundo caso es un fallo. «Si una tarea notifica un handler, pero otra tarea falla después en el play, de forma predeterminada el handler no se ejecuta en ese host. Esto puede dejar el host en un estado inesperado». Por tanto, un play que genera una configuración y después falla por una tarea no relacionada deja el archivo nuevo en el disco, pero el servicio sigue ejecutando la configuración antigua. Puede cambiar este comportamiento con --force-handlers en la línea de comandos o con force_handlers: true en el play. La misma opción está disponible como force_handlers = True en [defaults] dentro de ansible.cfg y como la variable de entorno ANSIBLE_FORCE_HANDLERS. El valor predeterminado es False.
Los nombres de los handlers colisionan y el que pierde queda en silencio
La documentación establece esta regla: «Cada handler debe tener un nombre único globalmente. Si se definen varios handlers con el mismo nombre, sólo se puede notificar y ejecutar el último que se haya cargado en el play». Los handlers definidos dentro de un role tampoco están limitados a ese role. Se insertan en una única lista global de handlers para todo el play. Por tanto, si dos roles definen cada uno Restart nginx, el nombre sólo resolverá a uno de ellos. El orden de carga decide cuál, no el role desde el que se envió la notificación.
Compruebe esa regla antes de basarse en ella. Guarde lo siguiente como handlers-dup.yml:
- name: Two handlers, one name
hosts: local
gather_facts: false
tasks:
- name: Notify the duplicated name
ansible.builtin.command:
cmd: /bin/true
changed_when: true
notify: Duplicated handler
handlers:
- name: Duplicated handler
ansible.builtin.file:
path: /tmp/dup-first
state: touch
mode: '0644'
- name: Duplicated handler
ansible.builtin.file:
path: /tmp/dup-second
state: touch
mode: '0644'rm -f /tmp/dup-first /tmp/dup-second
ansible-playbook handlers-dup.yml
ls -l /tmp/dup-first /tmp/dup-secondEl play termina correctamente, RUNNING HANDLER [Duplicated handler] aparece una vez y ls muestra una línea para /tmp/dup-first y ls: cannot access '/tmp/dup-second': No such file or directory para el otro. El handler que se ejecutó es el escrito en primer lugar, no el último cargado. Esto contradice lo que predice esa frase.
Conviene entender la diferencia, porque la regla documentada se refiere a bloques de handlers, no a líneas de un archivo. Los handlers que llegan desde lugares separados, primero un role y después otro, forman bloques independientes, y un bloque posterior oculta a uno anterior. Una lista simple de handlers: en un play es un único bloque. La búsqueda dentro de un bloque se realiza de arriba abajo y se detiene en el primer nombre coincidente. Por tanto, dentro de un archivo responde la primera definición y la segunda queda inaccesible. Entre roles, en cambio, el ocultamiento funciona como describe la documentación. En ambos casos no se pueden alcanzar los dos handlers, y no conviene basarse en ninguno de los dos comportamientos.
Hay dos soluciones claras. Dé a cada handler un nombre con un prefijo específico de su role, o envíe la notificación usando la forma cualificada role_name : handler_name. La documentación indica que esta forma sirve «para garantizar que se notifica un handler de un role y no otro externo con el mismo nombre». Los espacios alrededor de los dos puntos forman parte de esta sintaxis. El problema aparece en cuanto empieza a incorporar roles que no ha escrito usted.
La misma página establece otra regla: «Evite colocar variables en el nombre del handler. Como los nombres de los handlers se procesan mediante plantillas al principio, es posible que Ansible no tenga disponible un valor para un nombre de handler como este». Un handler llamado Restart {{ service_name }} hace que falle todo el play si esa variable no está definida cuando se procesa el nombre mediante una plantilla. Mantener los nombres de los handlers como cadenas fijas y agruparlos con listen evita esta situación.
validar: rechazar una configuración renderizada incorrecta
validate ejecuta un comando sobre el archivo renderizado antes de que Ansible lo coloque en su ubicación final. La documentación indica: «El comando de validación que se ejecutará antes de copiar el archivo actualizado en su destino final. Se usa una ruta de archivo temporal para la validación, que se proporciona mediante %s, y debe aparecer como en los ejemplos siguientes. Además, el comando se transmite de forma segura, por lo que las funciones del shell, como la expansión y las tuberías, no funcionarán».
De ese texto se desprenden dos reglas. %s es obligatorio, y una cadena de validación que no lo incluya hace que la tarea falle con validate must contain %s. Además, no se usa ningún shell, por lo que las tuberías, las redirecciones, los patrones glob y && no funcionan. Un comando y un argumento de archivo.
Los ejemplos oficiales del módulo muestran los dos casos en los que esto funciona correctamente:
- name: Copy a new sudoers file into place, after passing validation with visudo
ansible.builtin.template:
src: /mine/sudoers
dest: /etc/sudoers
validate: /usr/sbin/visudo -cf %s
- name: Update sshd configuration safely, avoid locking yourself out
ansible.builtin.template:
src: etc/ssh/sshd_config.j2
dest: /etc/ssh/sshd_config
owner: root
group: root
mode: '0600'
validate: /usr/sbin/sshd -t -f %s
backup: yesAmbos funcionan porque cada comprobador acepta un archivo y lo evalúa por separado. visudo -cf lee un archivo de sudoers. sshd -t -f lee un sshd_config completo.
Por qué validate no puede comprobar el archivo de nginx en esta guía
Añada validate: /usr/sbin/nginx -t -c %s a la tarea de plantilla anterior y la tarea fallará. El mensaje indica la causa:
nginx: [emerg] "upstream" directive is not allowed here in <ansible temporary path>:2nginx -t -c espera una configuración completa que empiece en el nivel superior con los bloques events y http. El archivo que renderiza este play es un fragmento que se incluye en el bloque http mediante include /etc/nginx/conf.d/*.conf; dentro de /etc/nginx/nginx.conf. Por sí solo, fuera de ese contexto, upstream es realmente una directiva en una ubicación incorrecta. Por eso nginx rechaza un archivo que es completamente válido donde se utiliza. Se entregó un fragmento al comprobador y se le pidió que lo tratara como una configuración completa.
La solución aplicable es la que ya aparece en el playbook. Instale el fragmento y compruebe después la configuración ensamblada en un handler definido antes del handler de recarga. Como los handlers se ejecutan en el orden en que se definen, nginx -t ve el /etc/nginx/nginx.conf real con el fragmento incluido. Si esa comprobación falla, el play falla antes de llegar a ejecutar systemctl reload. Tenga claro el coste: el archivo incorrecto queda en disco cuando falla la comprobación, y nginx sigue sirviendo la última configuración que cargó hasta que alguien lo reinicia.
Para eso sirve backup: true. Escribe una copia del archivo anterior junto al original antes de sobrescribirlo. La copia recibe el nombre basename.PID.YYYY-MM-DD@HH:MM:SS~, por lo que el directorio termina conteniendo entradas como learn.conf.4127.2026-08-20@11:42:09~. Ejecute sudo ls -l /etc/nginx/conf.d/ después de un cambio y encontrará una.
Este detalle del nombre es más importante de lo que parece. La copia de seguridad no causa problemas en /etc/nginx/conf.d/ porque la configuración principal sólo incluye conf.d/*.conf y el nombre de la copia termina en una tilde. No ocurre lo mismo en un directorio incluido mediante un * sin patrón. En Debian y Ubuntu, /etc/nginx/nginx.conf incluye /etc/nginx/sites-enabled/* exactamente de esa forma. Si crea la plantilla en sites-enabled con backup: true, nginx carga la copia de seguridad como un segundo bloque de servidor activo. Por eso este play escribe en conf.d en su lugar.
Ejecución del mismo play contra hosts reales del inventario
Cambie hosts: local por el nombre del grupo que use. No es necesario modificar nada más del play. La plantilla se procesa una vez por host, por lo que app_listen_port y app_backends pueden obtenerse de group_vars y host_vars mientras el archivo de plantilla sigue siendo único. Esa es la ventaja de definir los valores en variables en lugar de escribirlos en el archivo.
Hay dos cambios. become: true necesita ahora una contraseña de sudo en cada destino, salvo que allí tenga sudo sin contraseña. Por tanto, añada -K. Además, cualquier secreto de esa plantilla, como una contraseña de base de datos o un token de API, no debe almacenarse en texto plano en vars: dentro de un archivo que confirme en el repositorio. Cifre esos valores con Ansible Vault y haga referencia a ellos por nombre exactamente como lo hace ahora, porque la plantilla no depende del origen de una variable.
Cuando el play incluya más de un servicio, vars:, templates/ y handlers: ya tendrán un lugar estándar donde definirse. Trasladarlos allí es precisamente el objetivo de separar un playbook de un role.
FAQ
¿Por qué no se ejecutó mi handler de Ansible?
Casi siempre se debe a que la tarea que lo notifica informó ok en lugar de changed. Los handlers se ejecutan cuando hay cambios y en ningún otro caso, por lo que una tarea template cuyo resultado coincide con el archivo que ya existe en el disco no notifica nada. Después, compruebe cuatro cosas. La cadena de notify debe coincidir exactamente con el name del handler o con un tema listen, incluida la distinción entre mayúsculas y minúsculas y los espacios. Una tarea posterior que falle en ese host suprime los handlers notificados, salvo que pase --force-handlers. Un handler definido en otro play no está disponible en este. Además, una tarea que notifica y que se omite debido a una condición when nunca notifica nada.
¿Por qué mi playbook informa de cambios en cada ejecución?
El texto generado no es estable entre ejecuciones. La causa más habitual es una marca de tiempo en la salida, y una cadena ansible_managed personalizada que incluye una fecha produce exactamente ese problema. Lo siguiente que debe comprobar es mode y owner en la tarea: si no coinciden con el archivo que ya existe en el disco, Ansible los corrige e informa de un cambio aunque el contenido sea idéntico. Ejecute ansible-playbook site.yml --check --diff para determinar cuál de los dos es la causa, porque --diff muestra la diferencia que la tarea pretende aplicar.
¿Cuál es la diferencia entre template y copy en Ansible?
ansible.builtin.copy envía un archivo sin modificarlo. ansible.builtin.template lo procesa primero mediante Jinja2 en el controlador y después envía el resultado, por lo que las variables y los bucles se resuelven antes de que el archivo llegue al host de destino. Use copy para un archivo que sea idéntico byte a byte en todos los hosts. Use template para cualquier archivo cuyo contenido varíe según el host. Ambos comparten las mismas opciones de archivo, por lo que mode, owner, backup y validate funcionan de la misma forma en los dos.
¿Cómo puedo hacer que un handler se ejecute en mitad de un play?
Añada ansible.builtin.meta: flush_handlers como una tarea en el punto en el que quiera que se ejecuten. Activa todos los handlers notificados hasta ese momento y, después, el play continúa con normalidad. Úselo cuando una tarea posterior del mismo play dependa de que el servicio ya esté ejecutando la nueva configuración, por ejemplo un wait_for en un puerto que sólo existe después de la recarga. Es la forma admitida de ejecutar un handler antes del final del play.
¿Puedo usar validate con un fragmento de configuración de nginx?
No con nginx -t -c %s. Ese comando espera una configuración completa que comience con los bloques de nivel superior events y http, por lo que rechaza un fragmento conf.d con un mensaje como "upstream" directive is not allowed here. El fragmento es válido dentro del bloque http, pero no por sí solo. Instale el archivo y después ejecute nginx -t contra la configuración ensamblada en un handler definido antes del handler de recarga. Los handlers se ejecutan en el orden en que están definidos, por lo que una configuración incorrecta detiene el play antes de intentar la recarga. Establezca backup: true en la tarea template para que el archivo anterior siga disponible y pueda restaurarse.