Las variables, los bucles y los condicionales son lo que convierte una lista fija de tareas en un playbook que se adapta a cada servidor. Las variables guardan los valores que cambian entre hosts y entornos, los bucles repiten una tarea sobre una lista sin copiarla y los condicionales deciden si una tarea debe ejecutarse. En este tutorial verás cada concepto con playbooks pequeños que puedes ejecutar contra servidores Ubuntu 24.04, y terminarás con un playbook que combina los tres para gestionar usuarios y parámetros del kernel.

Requisitos previos

Para seguir esta guía necesitas:

  • Un nodo de control con Ansible instalado y acceso SSH con clave a tus servidores, como se explica en la guía Cómo instalar y usar Ansible en Ubuntu 24.04.
  • Uno o dos servidores gestionados con Ubuntu 24.04, por ejemplo VPS de CubePath. Esta guía usa web1 (web1_server_ip) y web2 (web2_server_ip).
  • Un usuario no root con privilegios sudo en los servidores (your_user).
  • Nociones básicas de YAML y de cómo ejecutar ansible-playbook.

La mayoría de los ejemplos solo imprimen valores con el módulo debug, así que puedes ejecutarlos en cualquier servidor sin riesgo. El paso 8 sí hace cambios reales.

Paso 1: Preparar el proyecto de prácticas

Crea un directorio de proyecto con las carpetas de las que Ansible lee variables:

mkdir -p ~/ansible-vars/{group_vars,host_vars}
cd ~/ansible-vars

Crea el inventario:

nano inventory.ini
[webservers]
web1 ansible_host=web1_server_ip
web2 ansible_host=web2_server_ip

[all:vars]
ansible_user=your_user

Y un ansible.cfg mínimo para no tener que pasar el inventario cada vez:

nano ansible.cfg
[defaults]
inventory = inventory.ini
interpreter_python = auto_silent

Comprueba que los dos hosts responden:

ansible all -m ping

Paso 2: Definir variables en playbooks y archivos de inventario

Los nombres de variable pueden contener letras, números y guiones bajos, y deben empezar por una letra o un guion bajo. app_port y nginx_worker_connections son válidos; app-port y 2fa_enabled no lo son.

El lugar más sencillo para definir una variable es la sección vars de un play. Crea un playbook que imprima unas cuantas:

nano vars-basics.yml
---
- name: Conceptos básicos de variables
  hosts: webservers
  gather_facts: false

  vars:
    app_name: shop
    app_port: 8080

  tasks:
    - name: Mostrar la configuración de la aplicación
      ansible.builtin.debug:
        msg: "{{ app_name }} escucha en el puerto {{ app_port }} en {{ inventory_hostname }}"

Los valores se insertan con expresiones Jinja2 {{ }}. Cuando un valor empieza por {{, la cadena completa debe ir entre comillas; si no, YAML intentará interpretarla como un diccionario.

Ejecútalo:

ansible-playbook vars-basics.yml
TASK [Mostrar la configuración de la aplicación] *******************************
ok: [web1] => {
    "msg": "shop escucha en el puerto 8080 en web1"
}
ok: [web2] => {
    "msg": "shop escucha en el puerto 8080 en web2"
}

Los valores que pertenecen a la infraestructura, y no a un playbook concreto, van en archivos de variables del inventario. Ansible carga automáticamente group_vars/<grupo>.yml para cada host del grupo y host_vars/<host>.yml para un host concreto.

Mueve los valores a variables de grupo:

nano group_vars/webservers.yml
---
app_name: shop
app_port: 8080

Después, cambia el puerto solo para web2:

nano host_vars/web2.yml
---
app_port: 9090

Elimina el bloque vars: (la línea vars: y sus dos valores) de vars-basics.yml, porque las variables del play tienen prioridad sobre los archivos del inventario. Vuelve a ejecutar el playbook:

ansible-playbook vars-basics.yml
ok: [web1] => {
    "msg": "shop escucha en el puerto 8080 en web1"
}
ok: [web2] => {
    "msg": "shop escucha en el puerto 9090 en web2"
}

La variable de host se impone a la de grupo, que es justo lo que quieres para las excepciones de un servidor concreto.

Paso 3: Entender la precedencia de variables

Cuando la misma variable se define en varios sitios, Ansible usa la de mayor precedencia. La lista completa tiene más de 20 niveles, pero estos son los que te encontrarás con más frecuencia, de menor a mayor:

PrecedenciaOrigen
MínimaValores por defecto del rol (roles/x/defaults/main.yml)
group_vars/all
group_vars/<grupo>
host_vars/<host>
Facts recogidos
vars y vars_files del play
Variables del rol (roles/x/vars/main.yml)
vars de la tarea
register y set_fact
MáximaVariables extra (-e en la línea de comandos)

Las variables extra siempre ganan, lo que las hace útiles para cambios puntuales. Pruébalo:

ansible-playbook vars-basics.yml -e app_port=7000
ok: [web1] => {
    "msg": "shop escucha en el puerto 7000 en web1"
}
ok: [web2] => {
    "msg": "shop escucha en el puerto 7000 en web2"
}

Una regla práctica: pon los valores por defecto razonables abajo (defaults del rol o group_vars/all), las diferencias de entorno y de grupo en group_vars, las excepciones en host_vars, y evita definir la misma variable también en el playbook.

Los valores pasados con -e clave=valor son cadenas. Si pasas un booleano como -e debug_mode=false, conviértelo en tus condiciones con el filtro bool (debug_mode | bool); si no, la cadena no vacía "false" cuenta como verdadera.

Paso 4: Trabajar con listas y diccionarios

Las variables pueden contener listas y diccionarios, y son precisamente lo que recorren los bucles. Añade datos estructurados a las variables de grupo:

nano group_vars/webservers.yml
---
app_name: shop
app_port: 8080

app_packages:
  - nginx
  - git

app_database:
  host: 10.0.0.10
  name: shop
  port: 3306

Accede a los elementos con corchetes o con puntos. Los corchetes funcionan con cualquier clave, incluidas las que llevan guiones o coinciden con nombres de métodos de Python, así que es mejor usarlos:

nano vars-structures.yml
---
- name: Listas y diccionarios
  hosts: web1
  gather_facts: false

  tasks:
    - name: Mostrar el primer paquete y el host de la base de datos
      ansible.builtin.debug:
        msg: "Primer paquete: {{ app_packages[0] }}, base de datos: {{ app_database['host'] }}:{{ app_database['port'] }}"

    - name: Usar un valor por defecto para una variable no definida
      ansible.builtin.debug:
        msg: "El nivel de log es {{ app_log_level | default('info') }}"

    - name: Unir una lista en una cadena
      ansible.builtin.debug:
        msg: "Paquetes: {{ app_packages | join(', ') }}"
ansible-playbook vars-structures.yml
ok: [web1] => {
    "msg": "Primer paquete: nginx, base de datos: 10.0.0.10:3306"
}
ok: [web1] => {
    "msg": "El nivel de log es info"
}
ok: [web1] => {
    "msg": "Paquetes: nginx, git"
}

El filtro default evita el error undefined variable cuando un valor es opcional.

Paso 5: Capturar resultados con register y set_fact

Cada tarea devuelve datos. register guarda esos datos en una variable para que las tareas siguientes puedan usarlos. set_fact crea una variable nueva a partir de una expresión en tiempo de ejecución, por ejemplo a partir de los facts recogidos.

nano vars-register.yml
---
- name: Register y set_fact
  hosts: webservers

  tasks:
    - name: Comprobar si existe la configuración de Nginx
      ansible.builtin.stat:
        path: /etc/nginx/nginx.conf
      register: nginx_conf

    - name: Mostrar el resultado registrado
      ansible.builtin.debug:
        msg: "nginx.conf existe: {{ nginx_conf.stat.exists }}"

    - name: Calcular las conexiones por worker según el número de CPU
      ansible.builtin.set_fact:
        nginx_worker_connections: "{{ ansible_facts['processor_vcpus'] * 1024 }}"

    - name: Mostrar el valor calculado
      ansible.builtin.debug:
        var: nginx_worker_connections

Este play recoge facts (el comportamiento por defecto), así que ansible_facts contiene el hardware y el sistema operativo de cada host. debug con var: imprime el valor de una variable sin escribir un mensaje.

ansible-playbook vars-register.yml
TASK [Mostrar el resultado registrado] *****************************************
ok: [web1] => {
    "msg": "nginx.conf existe: False"
}
...
TASK [Mostrar el valor calculado] **********************************************
ok: [web1] => {
    "nginx_worker_connections": 2048
}

Para ver todo lo que devuelve un módulo, imprime la variable registrada completa con debug: var: nginx_conf. Es la forma más rápida de encontrar la clave que necesitas para una condición.

Paso 6: Repetir tareas con bucles

loop ejecuta una tarea una vez por elemento, y el elemento actual está disponible como item. Úsalo siempre que, de otro modo, copiarías una tarea varias veces.

Muchos módulos aceptan una lista directamente, lo que es más rápido que un bucle porque se ejecuta en una sola llamada. Por ejemplo, ansible.builtin.apt acepta una lista en name, así que instala los paquetes así en lugar de con un bucle:

- name: Instalar los paquetes de la aplicación
  ansible.builtin.apt:
    name: "{{ app_packages }}"
    state: present

Los bucles son la herramienta adecuada cuando cada elemento necesita parámetros distintos. Este playbook recorre una lista de diccionarios, un diccionario y reintenta una tarea hasta que funciona:

nano loops.yml
---
- name: Ejemplos de bucles
  hosts: web1
  gather_facts: false

  vars:
    app_users:
      - name: deploy
        shell: /bin/bash
      - name: backup
        shell: /usr/sbin/nologin

    sysctl_settings:
      vm.swappiness: 10
      net.core.somaxconn: 1024

  tasks:
    - name: Recorrer una lista de diccionarios
      ansible.builtin.debug:
        msg: "El usuario {{ item['name'] }} usa {{ item['shell'] }}"
      loop: "{{ app_users }}"
      loop_control:
        label: "{{ item['name'] }}"

    - name: Recorrer un diccionario
      ansible.builtin.debug:
        msg: "{{ item.key }} = {{ item.value }}"
      loop: "{{ sysctl_settings | dict2items }}"

    - name: Numerar los elementos
      ansible.builtin.debug:
        msg: "{{ idx + 1 }}. {{ item }}"
      loop:
        - primero
        - segundo
      loop_control:
        index_var: idx

    - name: Reintentar hasta que una URL responda
      ansible.builtin.uri:
        url: https://ubuntu.com/
      register: result
      until: result.status == 200
      retries: 5
      delay: 5

Qué muestra cada bucle:

  • loop_control.label controla lo que Ansible imprime para cada elemento. Sin él, se imprime el diccionario completo en cada iteración.
  • dict2items convierte un diccionario en una lista de pares {key, value} para poder recorrerlo.
  • index_var expone la posición del elemento (empezando por 0).
  • until, retries y delay repiten una sola tarea, no una lista, hasta que la condición se cumple. Así se espera a que un servicio arranque.
ansible-playbook loops.yml
TASK [Recorrer una lista de diccionarios] **************************************
ok: [web1] => (item=deploy) => {
    "msg": "El usuario deploy usa /bin/bash"
}
ok: [web1] => (item=backup) => {
    "msg": "El usuario backup usa /usr/sbin/nologin"
}

TASK [Recorrer un diccionario] *************************************************
ok: [web1] => (item={'key': 'vm.swappiness', 'value': 10}) => {
    "msg": "vm.swappiness = 10"
}
...

Paso 7: Ejecutar tareas de forma condicional con when

when recibe una expresión Jinja2 sin {{ }} y ejecuta la tarea solo si es verdadera. Las tareas omitidas aparecen como skipping.

nano conditionals.yml
---
- name: Ejemplos de condicionales
  hosts: webservers

  tasks:
    - name: Ejecutar solo en Ubuntu 24.04 o posterior
      ansible.builtin.debug:
        msg: "Esto es {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}"
      when:
        - ansible_facts['distribution'] == 'Ubuntu'
        - ansible_facts['distribution_major_version'] | int >= 24

    - name: Ejecutar solo en hosts con al menos 2 GB de RAM
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} tiene {{ ansible_facts['memtotal_mb'] }} MB de RAM"
      when: ansible_facts['memtotal_mb'] >= 2048

    - name: Comprobar si Nginx está instalado
      ansible.builtin.stat:
        path: /usr/sbin/nginx
      register: nginx_binary

    - name: Ejecutar solo donde falta Nginx
      ansible.builtin.debug:
        msg: "Nginx no está instalado en {{ inventory_hostname }}"
      when: not nginx_binary.stat.exists

    - name: Ejecutar solo si una variable está definida
      ansible.builtin.debug:
        msg: "Ventana de mantenimiento: {{ maintenance_window }}"
      when: maintenance_window is defined
  • Una lista bajo when significa que deben cumplirse todas las condiciones (AND lógico). Usa or dentro de una única expresión para alternativas.
  • Los facts son cadenas o números según el fact. distribution_major_version es una cadena, así que se convierte con | int antes de comparar.
  • is defined e is not defined comprueban si una variable existe, lo que evita errores con valores opcionales.
  • Los facts se leen del diccionario ansible_facts, que es la forma recomendada de acceder a ellos.
ansible-playbook conditionals.yml -e maintenance_window="domingo 03:00"
TASK [Ejecutar solo en hosts con al menos 2 GB de RAM] *************************
ok: [web1] => {
    "msg": "web1 tiene 3915 MB de RAM"
}
skipping: [web2]

El resultado exacto depende de tus servidores. Cuando when se combina con loop, la condición se evalúa por separado para cada elemento, como verás en el siguiente paso.

Paso 8: Combinar variables, bucles y condicionales

Este último playbook aplica lo aprendido a una tarea real: crea o elimina usuarios del sistema definidos en una variable y aplica parámetros del kernel desde un diccionario, con una excepción para un host.

Añade los datos a las variables de grupo:

nano group_vars/webservers.yml
---
app_name: shop
app_port: 8080

managed_users:
  - name: deploy
    groups: [www-data]
    state: present
  - name: olduser
    state: absent

sysctl_settings:
  vm.swappiness: 10
  net.core.somaxconn: 1024

enable_sysctl_tuning: true

Desactiva el ajuste en web2 añadiendo una línea a sus variables de host:

nano host_vars/web2.yml
---
app_port: 9090
enable_sysctl_tuning: false

Crea el playbook:

nano manage.yml
---
- name: Gestionar usuarios y parámetros del kernel
  hosts: webservers
  become: true

  tasks:
    - name: Crear los usuarios que deben existir
      ansible.builtin.user:
        name: "{{ item['name'] }}"
        groups: "{{ item['groups'] | default([]) }}"
        append: true
        shell: /bin/bash
        state: present
      loop: "{{ managed_users }}"
      loop_control:
        label: "{{ item['name'] }}"
      when: item['state'] == 'present'

    - name: Eliminar los usuarios que no deben existir
      ansible.builtin.user:
        name: "{{ item['name'] }}"
        state: absent
        remove: true
      loop: "{{ managed_users }}"
      loop_control:
        label: "{{ item['name'] }}"
      when: item['state'] == 'absent'

    - name: Aplicar los parámetros del kernel
      ansible.posix.sysctl:
        name: "{{ item.key }}"
        value: "{{ item.value }}"
        sysctl_file: /etc/sysctl.d/90-ansible.conf
        reload: true
      loop: "{{ sysctl_settings | dict2items }}"
      when: enable_sysctl_tuning | bool

Ejecútalo e introduce tu contraseña de sudo cuando te la pida:

ansible-playbook manage.yml -K
TASK [Crear los usuarios que deben existir] ************************************
skipping: [web1] => (item=olduser)
changed: [web1] => (item=deploy)
skipping: [web2] => (item=olduser)
changed: [web2] => (item=deploy)

TASK [Eliminar los usuarios que no deben existir] ******************************
skipping: [web1] => (item=deploy)
ok: [web1] => (item=olduser)
...
TASK [Aplicar los parámetros del kernel] ***************************************
changed: [web1] => (item={'key': 'vm.swappiness', 'value': 10})
changed: [web1] => (item={'key': 'net.core.somaxconn', 'value': 1024})
skipping: [web2] => (item={'key': 'vm.swappiness', 'value': 10})
skipping: [web2] => (item={'key': 'net.core.somaxconn', 'value': 1024})

Comprueba el resultado en web1:

ansible web1 -m ansible.builtin.command -a "id deploy"
ansible web1 -m ansible.builtin.command -a "sysctl vm.swappiness"
web1 | CHANGED | rc=0 >>
uid=1001(deploy) gid=1001(deploy) groups=1001(deploy),33(www-data)
web1 | CHANGED | rc=0 >>
vm.swappiness = 10

En web2, sysctl vm.swappiness sigue mostrando el valor por defecto de Ubuntu, 60. Para añadir más adelante un usuario o un parámetro, solo editas las variables; el playbook no cambia.

Solución de problemas

'app_port' is undefined: la variable no está definida para ese host. Revisa qué archivos se aplican con ansible-inventory --host web1 y usa el filtro default para los valores opcionales.

Errores de sintaxis YAML en una línea que usa {{ }}: un valor que empieza por {{ debe ir entre comillas, por ejemplo msg: "{{ app_name }}".

Una condición siempre es verdadera: probablemente la variable es una cadena. "false" pasado con -e o "no" en un inventario INI son cadenas no vacías; usa my_var | bool en la condición.

Los elementos del bucle se imprimen como diccionarios largos: añade loop_control con un label para mostrar solo el campo que te interesa.

Para inspeccionar valores mientras desarrollas, añade -v para ver los resultados de las tareas, o usa ansible -m ansible.builtin.debug -a "var=hostvars[inventory_hostname]" web1 para imprimir todas las variables que Ansible conoce de un host.

Conclusión

Has definido variables en plays, group_vars y host_vars, has visto cómo la precedencia decide qué valor gana, has capturado resultados de tareas con register y set_fact, has repetido tareas con loop y las has omitido con when. Combinar los tres permite que un solo playbook gestione muchos servidores mientras todas las diferencias viven en archivos de variables.

Como siguientes pasos, mueve estas tareas a un rol con sus valores por defecto en defaults/main.yml, guarda los secretos en archivos cifrados con ansible-vault y usa plantillas para generar archivos de configuración a partir de las mismas variables.