Tutorial Ansible: primer playbook en VPS
Instala Ansible con pipx en Ubuntu 24.04. Crea un inventario y un playbook para hardening de VPS. Soluciones para errores de Permission denied y sudo.
Lo que va a construir
Una máquina de control con Ansible instalado y uno o más VPS con Ubuntu 24.04 recién instalados con la imagen estándar. Al finalizar, tendrá un archivo de inventario con los nombres de sus servidores, un ping ad-hoc que confirma que la autenticación funciona correctamente y un playbook que ejecuta toda la lista de tareas para nuevos VPS como código: un usuario de despliegue con su clave SSH, sshd endurecido, fail2ban, unattended upgrades y un firewall que permite OpenSSH antes de denegar todo lo demás. Puede usarlo para un servidor o para veinte. Ejecútelo dos veces y la segunda ejecución no cambiará nada; ese es el objetivo.
Después de quince años aprovisionando VPS, puedo decirle el patrón real: todo el mundo configura los primeros cinco servidores manualmente, y luego pierde un fin de semana con el sexto porque nadie recuerda qué hizo en los primeros cinco. Esta guía profundiza en la gestión de múltiples servidores Linux — consígala el día que se encuentre escribiendo el mismo apt install en tres terminales.
Qué es Ansible en un párrafo
Ansible no utiliza agentes. No es necesario instalar ningún daemon en los servidores gestionados: la máquina de control se conecta mediante SSH estándar, copia un módulo pequeño de Python al destino, lo ejecuta, lee el JSON resultante y lo elimina. El único requisito del destino es python3, el cual ya está presente en cualquier imagen estándar de Ubuntu. El concepto clave es la idempotencia, que significa algo simple: una tarea describe un estado, no una acción. state: present para un paquete significa "asegurar que esté instalado", no "ejecutar el instalador". Si el estado ya se cumple, Ansible no realiza cambios y lo reporta como ok en lugar de changed. Esa propiedad es la esencia del producto: es lo que permite ejecutar un playbook de forma segura, y las ejecuciones seguras son lo que convierte un script de shell en infraestructura.
Requisitos previos y advertencias iniciales
- Una máquina de control: su laptop o un VPS pequeño. Asumo Ubuntu 24.04; macOS funciona de la misma forma tras instalar pipx mediante Homebrew.
- Uno o más VPS de destino con Ubuntu 24.04 sobre KVM, accesibles como root. No se instalará nada en ellos.
- Autenticación mediante llave SSH en cada destino. Ansible utiliza la misma autenticación que su comando
ssh; sissh root@hostsolicita una contraseña, Ansible fallará. - En Ubuntu 24.04,
pip install ansiblefalla conerror: externally-managed-environment. Es una política deliberada de la distribución, no un error. Use pipx. - El espacio en blanco en YAML es sintaxis. Una indentación incorrecta produce
mapping values are not allowed in this context, y un carácter de tabulación en cualquier lugar causará un error fatal. - Mantenga una sesión SSH abierta en cada destino mientras el playbook endurece sshd. Cada bloqueo del que he ayudado a recuperar a un cliente ocurrió al cerrar la última sesión "para probar desde cero".
Paso 1: instalar Ansible en la máquina de control con pipx, no con pip
El instinto clásico es usar pip3 install ansible. En una imagen 24.04 totalmente limpia que falla un paso antes — Command 'pip3' not found, but can be installed with: sudo apt install python3-pip — instalar pip solo lleva al error real:
pip3 install ansibleerror: externally-managed-environment
× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
python3-xyz, where xyz is the package you are trying to
install.Ubuntu 24.04 marca el Python del sistema como gestionado externamente (PEP 668) para que pip no entre en conflicto con apt por los mismos archivos. No uses --break-system-packages; el flag es descriptivo. La solución limpia es pipx, que crea un virtualenv aislado para Ansible y añade los binarios al PATH:
sudo apt update && sudo apt install -y pipx
pipx ensurepath
pipx install --include-deps ansibleAbre una nueva shell después de pipx ensurepath para que se aplique el cambio en el PATH. --include-deps no es opcional: el paquete ansible no incluye scripts de consola propios — ansible, ansible-playbook y el resto son puntos de entrada de su dependencia ansible-core — por lo que sin el flag pipx rechaza la instalación con No apps associated with package ansible or its dependencies. Instala el paquete ansible en lugar de ansible-core; el paquete completo incluye las colecciones de la comunidad, y este playbook utiliza módulos de dos de ellas (ansible.posix y community.general).
ansible --versionEl resultado correcto comienza con una línea como ansible [core 2.19.x] e indica el Python que utiliza; cualquier versión actual del núcleo es válida para este proceso. ansible: command not found significa que ~/.local/bin aún no está en tu PATH; abre una nueva shell o usa source ~/.bashrc.
Este es el proceso de instalación completo. Los nodos destino no reciben nada.
Paso 2: Acceso mediante clave SSH a cada objetivo
ssh-keygen -t ed25519 -C "ansible control"
ssh-copy-id root@10.0.0.10
ssh-copy-id root@10.0.0.20Luego verifíquelo, una vez por host:
ssh root@10.0.0.10 true && echo okEsa línea realiza dos funciones: confirma que la autenticación por clave funciona sin contraseña y registra la clave del host en known_hosts. Realice este paso ahora, ya que Ansible presenta una clave de host no registrada como un prompt interactivo durante la ejecución, lo cual parece un bloqueo del sistema.
Paso 3: el inventario — INI primero, YAML cuando crezca
El inventario es un archivo de texto que contiene la lista de las máquinas que Ansible puede gestionar. Cree inventory.ini en un directorio de proyecto nuevo:
[vps]
web1 ansible_host=10.0.0.10
web2 ansible_host=10.0.0.20
[vps:vars]
ansible_user=rootweb1 es un alias que usted elija; es lo que aparece en la salida y lo que se usa como objetivo con --limit web1. ansible_host es la dirección real. [vps] es un grupo, y [vps:vars] define las variables para cada host en dicho grupo; ansible_user es el usuario con el que Ansible inicia sesión. Junto a esto, un ansible.cfg para no tener que escribir -i nunca más:
[defaults]
inventory = inventory.iniAnsible lee ansible.cfg desde el directorio actual. El mismo inventario en formato YAML —guárdelo como inventory.yml y apunte ansible.cfg a ese nombre en su lugar— es lo que preferirá cuando cada host tenga varias variables:
vps:
hosts:
web1:
ansible_host: 10.0.0.10
web2:
ansible_host: 10.0.0.20
vars:
ansible_user: rootSon equivalentes. El formato INI es más fácil de revisar visualmente con dos servidores; YAML escala mejor con veinte. Elija uno y no piense más en ello.
Paso 4: comandos ad-hoc — el ping verde que lo confirma todo
ansible all -m pingEsto no es ICMP. El módulo ping es un ensayo general completo: inicio de sesión SSH, copia del módulo, ejecución de Python en el destino y limpieza. El resultado correcto es verde, con un bloque por host:
web1 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}El SUCCESS verde significa que la autenticación, el intérprete de Python y el transporte funcionan; el playbook también funcionará. El UNREACHABLE! rojo significa que el transporte falló antes de ejecutar cualquier módulo; la cadena exacta y la solución se encuentran en la sección de modos de error a continuación. Otros dos comandos ad-hoc importantes:
ansible all -a "uptime"
ansible all -m apt -a "update_cache=true upgrade=dist" --becomeEl modo ad-hoc es para ejecuciones únicas y comprobaciones. Cualquier comando que deba ejecutarse dos veces debe incluirse en un playbook.
Paso 5: el primer playbook — la lista de verificación de un nuevo VPS como código
Esto contiene todas las acciones que realizarías manualmente durante los primeros diez minutos en un servidor nuevo. Guárdalo como site.yml:
---
- name: Baseline a fresh Ubuntu VPS
hosts: vps
become: true
vars:
deploy_user: deploy
deploy_pubkey: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
baseline_packages:
- fail2ban
- unattended-upgrades
- ufw
baseline_services:
- fail2ban
- unattended-upgrades
tasks:
- name: Create the deploy user
ansible.builtin.user:
name: "{{ deploy_user }}"
groups: sudo
append: true
shell: /bin/bash
- name: Install the deploy user's SSH key
ansible.posix.authorized_key:
user: "{{ deploy_user }}"
key: "{{ deploy_pubkey }}"
- name: Passwordless sudo for the deploy user
ansible.builtin.copy:
dest: /etc/sudoers.d/deploy
content: "{{ deploy_user }} ALL=(ALL) NOPASSWD:ALL\n"
mode: "0440"
validate: /usr/sbin/visudo -cf %s
- name: Install baseline packages
ansible.builtin.apt:
name: "{{ baseline_packages }}"
state: present
update_cache: true
- name: Enable and start baseline services
ansible.builtin.service:
name: "{{ item }}"
state: started
enabled: true
loop: "{{ baseline_services }}"
- name: Harden sshd with a drop-in
ansible.builtin.copy:
dest: /etc/ssh/sshd_config.d/00-hardening.conf
content: |
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
X11Forwarding no
mode: "0644"
validate: /usr/sbin/sshd -t -f %s
notify: Restart ssh
- name: Allow OpenSSH through ufw
community.general.ufw:
rule: allow
name: OpenSSH
- name: Enable ufw with default deny
community.general.ufw:
state: enabled
policy: deny
handlers:
- name: Restart ssh
ansible.builtin.service:
name: ssh
state: restartedLíneas clave para entender en lugar de solo copiar:
Las variables se encuentran bajo vars: y se referencian con "{{ deploy_user }}" — usa comillas en toda la expresión si el valor comienza con una llave, de lo contrario el parser de YAML lo leerá incorrectamente. El lookup('file', ...) lee tu clave pública desde la máquina de control en tiempo de ejecución, por lo que el playbook no contiene material de claves.
El bucle. loop: "{{ baseline_services }}" ejecuta la tarea del servicio una vez por cada elemento, y la salida muestra cada elemento en su propia línea. Nota que la tarea de apt procesa toda la lista de paquetes de una sola vez; una transacción de apt es más rápida y es el patrón preferido para paquetes; los bucles se usan para módulos que actúan sobre un solo elemento a la vez.
El handler es el concepto que debes asimilar. notify: Restart ssh no significa "reiniciar ssh ahora". Pone el handler en cola para que se ejecute una vez al final del play, y solo si la tarea que lo notifica reporta changed. Ejecuta el playbook mañana: el archivo drop-in ya es correcto, la tarea de copia reporta ok y sshd nunca se reinicia. La línea validate: es el seguro del disparador: sshd verifica el archivo antes de reemplazar el anterior, por lo que un error de sintaxis falla la tarea en lugar de romper el daemon.
PermitRootLogin prohibit-password, no no — deliberadamente. Este playbook inicia sesión como root con una clave. prohibit-password desactiva los inicios de sesión de root por contraseña mientras mantiene la tuya activa. Una vez que el usuario de despliegue esté verificado (ssh deploy@10.0.0.10 sudo true — la dirección simple, ya que web1 es solo un alias que Ansible conoce), cambia ansible_user=deploy en el inventory y restríngelo a no en una ejecución posterior. Realiza el endurecimiento (hardening) en un orden que no te deje bloqueado.
El prefijo 00- es importante. Para la mayoría de las palabras clave, sshd respeta la primera ocurrencia que procesa, y el sshd_config de Ubuntu incluye sshd_config.d/*.conf en orden léxico antes de su propio cuerpo. Las imágenes cloud de Ubuntu 24.04 ya incluyen un 60-cloudimg-settings.conf en ese directorio, y los proveedores que habilitan inicios de sesión por contraseña mediante cloud-init añaden un 50-cloud-init.conf con PasswordAuthentication yes; nombrar el nuestro como 00-hardening.conf hace que se ordene primero y prevalezca sobre ambos.
El orden de las tareas es la seguridad del firewall. Allow OpenSSH se ejecuta antes que Enable ufw con una política de denegación — Ansible ejecuta las tareas estrictamente en el orden listado, por lo que el puerto queda abierto antes de levantar el muro. fail2ban no requiere configuración para ser útil aquí; sus valores predeterminados en Ubuntu monitorean sshd de fábrica. Lo que las jails hacen realmente —y qué ajustar— se trata en la guía de fail2ban en Ubuntu 24.04.
Paso 6: ejecución de prueba con --check, luego ejecución real
ansible-playbook site.yml --checkEl modo check se conecta, calcula las acciones que realizaría y no cambia nada. Revise el recuento de changed= en el PLAY RECAP al final; ese es el número de tareas que modificarían cada host. Una advertencia importante: el modo check tiene un límite estructural cuando una tarea posterior depende de los cambios de una tarea anterior. La imagen estándar de servidor de Ubuntu incluye ufw, por lo que este playbook se ejecuta sin errores en modo de prueba; sin embargo, en una imagen mínima sin ufw, las tareas de ufw fallan en modo check, porque el modo check nunca instala el paquete y el módulo no tiene nada que ejecutar. Esto es una limitación de las ejecuciones de prueba, no un error en su playbook. Cuando el plan sea correcto:
ansible-playbook site.ymlCada tarea imprime una línea por host — changed en amarillo, ok en verde — y el resumen debe mostrar:
PLAY RECAP *********************************************************************
web1 : ok=10 changed=9 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=10 changed=9 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0Diez ok corresponden a la recopilación de datos más ocho tareas más el handler. Su changed puede diferir del mío por uno o dos: la imagen estándar de Ubuntu incluye ufw y unattended-upgrades, y fail2ban se inicia en cuanto apt lo instala, por lo que una tarea puede reportar legítimamente ok en su primera ejecución, indicando que el estado ya se mantiene. Los valores que deben ser cero son unreachable y failed. Una nota sobre become: true: es un trámite mientras se conecta como root, pero en cuanto cambie ansible_user a deploy, sudo será efectivo; el archivo sudoers NOPASSWD que instala este playbook es lo que evita que aparezca -K en su línea de comandos. Sin esto, obtendrá Missing sudo password, que se explica a continuación.
Paso 7: ejecutarlo dos veces — qué es la idempotencia
Ejecute el mismo comando de nuevo inmediatamente:
web1 : ok=9 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0changed=0 y ok disminuyeron en uno porque el handler no notificado nunca se ejecutó. No se reinstaló nada, no se reinició sshd y no se modificó ufw. Esto hace que el playbook sea tanto una herramienta de auditoría como de aprovisionamiento: añada web3 al inventario el próximo mes y ejecútelo de nuevo; el nuevo servidor se configurará y los servidores antiguos se verificarán. Un valor distinto de cero en changed en un servidor que no ha tocado indica desviación (drift), lo que significa que alguien editó manualmente lo que debería haberse editado en el playbook.
A partir de aquí, el patrón se acumula. El siguiente playbook que valga la pena escribir establece una VPN WireGuard en el mismo VPS y restringe la regla de ufw para que SSH responda solo a través del túnel; después de eso, uno que instale Docker y Compose en cada servidor de aplicaciones. Cuando site.yml ocupe más de tres pantallas, divídalo en roles, pero no antes.
Modos de fallo y los mensajes que verá
UNREACHABLE with Permission denied.
web1 | UNREACHABLE! => {
"changed": false,
"msg": "Failed to connect to the host via ssh: root@10.0.0.10: Permission denied (publickey).",
"unreachable": true
}El transporte SSH falló antes de ejecutar cualquier módulo: ansible_user es incorrecto, la clave no se copió al host o se está ofreciendo la clave errónea. Reproduzca el error con ssh root@10.0.0.10 simple, y luego con ssh -v para ver qué claves se ofrecieron. Si SSH con contraseña funciona pero Ansible no, omitió ssh-copy-id.
Missing sudo password.
web1 | FAILED! => {
"msg": "Missing sudo password"
}Configuró become: true, se conectó como un usuario sin privilegios de root y ese usuario requiere contraseña para sudo. Añada -K (--ask-become-pass) a la línea de comandos o asigne al usuario una entrada NOPASSWD en sudoers; por esta razón el playbook instala una para deploy antes de que usted cambie a ese usuario.
error: externally-managed-environment. Ejecutó pip contra el Python del sistema en Ubuntu 24.04. Cubierto en el paso 1: use pipx, no pip, y no --break-system-packages.
mapping values are not allowed in this context.
ERROR! Syntax Error while loading YAML.
mapping values are not allowed in this contextCasi siempre es un error de indentación: una clave con la profundidad incorrecta o un espacio faltante tras los dos puntos. El número de línea reportado indica el área cerca del error, no el error exacto; verifique también la línea superior. Su variante found character '\t' that cannot start any token significa que se insertó un tabulador; YAML los prohíbe. Haga de ansible-playbook site.yml --syntax-check un hábito antes de cada ejecución y configure su editor con indentación de dos espacios para YAML.
/usr/bin/python3: not found. Poco común en imágenes estándar de Ubuntu 24.04, común en imágenes minimalistas o netboot: la ejecución del módulo falla porque el destino no tiene Python. Instálelo mediante el módulo raw, el único módulo que no requiere nada en el destino: ansible all -m raw -a "apt-get update && apt-get install -y python3" --become, y luego vuelva a ejecutar el playbook.
FAQ
¿Es necesario instalar Ansible en los servidores gestionados?
No. Ansible es agentless: la máquina de control envía módulos pequeños de Python mediante SSH, los ejecuta y los elimina. El objetivo solo requiere python3 y acceso SSH, elementos que las imágenes estándar de Ubuntu ya incluyen. La única instalación en esta guía se realiza en su máquina de control.
¿Por qué Ansible muestra el error "Permission denied (publickey)"?
El bloque UNREACHABLE! con Permission denied (publickey) indica que la autenticación SSH falló antes de que Ansible ejecutara cualquier comando. Verifique que ansible_user en el inventory coincida con la cuenta configurada, que haya ejecutado ssh-copy-id hacia ese host y que el comando ssh user@host inicie sesión sin contraseña. Cualquier solución para el comando ssh estándar funcionará para Ansible, ya que utilizan el mismo protocolo de transporte.
¿Qué significa la idempotencia en Ansible?
Una tarea declara un estado deseado —"este paquete está instalado", "esta línea está en este archivo"— en lugar de una acción a realizar. Si el estado ya se cumple, Ansible no realiza cambios y reporta ok en lugar de changed. Por esto, ejecutar un playbook dos veces muestra changed=0 la segunda vez, y una reejecución es una auditoría segura en lugar de una reinstalación riesgosa.
¿Debo usar pip o pipx para instalar Ansible en Ubuntu 24.04?
pipx. Ubuntu 24.04 marca el Python del sistema como gestionado externamente, por lo que pip install ansible falla con error: externally-managed-environment por diseño. pipx install --include-deps ansible instala Ansible en un virtualenv aislado y expone ansible, ansible-playbook y el resto en su PATH de forma limpia.
¿Cuál es la diferencia entre los paquetes ansible y ansible-core?
ansible-core es el motor junto con únicamente los módulos ansible.builtin. El paquete ansible incluye el núcleo junto con colecciones de la comunidad seleccionadas, incluyendo ansible.posix (el módulo authorized_key) y community.general (el módulo ufw), ambos utilizados en esta guía. Comience con el paquete completo; reduzca a core más colecciones específicas solo si es necesario.