
🌐 También en: English · Deutsch · Français
Todo empezó con una pregunta un poco vanidosa. Mi repositorio de Ansible gestiona este blog y dos servidores Nextcloud: 34 roles, cuatro playbooks, unas 9.000 líneas de YAML, casi 200 commits. La mayoría de esas líneas no las escribí yo, sino mi coadministrador de IA (Claude); yo decidí, probé y ejecuté los playbooks. Así que le pregunté: si entiendo la mayor parte de este código, ¿soy un usuario avanzado de Ansible?
La respuesta fue honesta y un poco incómoda: avanzado para leerlo y operarlo, sí. Avanzado para escribirlo desde cero, aún no demostrado. Luego vinieron diez propuestas para mejorar el propio código. Mi respuesta: haz las diez. Pruébalas a fondo. Después escribe sobre ello. Este es ese artículo.
Las reglas de siempre: todo ocurre en una rama de Git aparte, nada toca un servidor de producción hasta que yo lo lance, y cada afirmación se prueba. «Probado» resultó ser la parte interesante.
📋 Los diez puntos
| # | Mejora | Por qué |
|---|---|---|
| 1 | Objetos de Kubernetes con kubernetes.core.k8s en lugar de kubectl apply vía shell | idempotente, diffs reales, funciona en modo check |
| 2 | Hacer que --check sea realmente útil | un ensayo antes de cada ejecución en producción |
| 3 | ansible-lint, yamllint y un escáner de secretos como hook de pre-commit y GitHub Action | detectar errores y secretos filtrados antes de que lleguen |
| 4 | Prueba de idempotencia automatizada | la segunda ejecución debe mostrar changed=0 |
| 5 | Rutas rescue en los playbooks de actualización | una reacción definida cuando una actualización falla a mitad |
| 6 | Dividir los archivos de tareas grandes | un archivo de 912 líneas no es amigo de nadie |
| 7 | meta/argument_specs.yml para los roles | una variable que falta falla al principio, no a mitad |
| 8 | Fusionar roles duplicados | un solo rol de ClamAV en lugar de dos casi idénticos |
| 9 | Fijar exactamente las versiones de las colecciones | >= deja pasar sin avisar una versión mayor incompatible |
| 10 | Renovate para las versiones fijadas | propuestas de actualización como pull requests en lugar de buscarlas a mano |
Por el camino añadí un undécimo deseo: todas las versiones fijadas en un solo lugar, para ver de un vistazo qué corre dónde. Más sobre eso en el punto 10.
🧪 Primer problema: ¿cómo pruebas código de Kubernetes sin tocar producción?
Antes había un servidor de pruebas en el inventario. Cuando Claude intentó conectarse, SSH lo rechazó: host key verification failed. Probablemente la IP pertenece ahora a otra persona, así que no se intentó ninguna conexión, a propósito. Y yo no doy mi contraseña del vault en un chat, así que tampoco podía ejecutarse ningún playbook contra los servidores reales.
La solución fue elegante: un K3s desechable en podman rootless en mi jump host. Un K3s completo falló enseguida (failed to find cpuset cgroup: los contenedores rootless no reciben ese controlador delegado). Pero para comprobar si los manifiestos se aplican de forma idempotente no hacen falta pods en marcha, basta con el servidor de API. K3s tiene una opción justo para eso:
podman run -d --name k3s-test --privileged -p 127.0.0.1:16443:6443 \
docker.io/rancher/k3s:v1.36.5-k3s1 server --disable-agent \
--disable traefik --disable servicelb --disable metrics-serverUn pequeño script sustituto hace de k3s kubectl: lo reenvía todo al contenedor y responde «OK» a los comandos que necesitarían pods en marcha (exec, rollout status, wait). La prueba hace entonces lo importante: aplicar todo con el código antiguo de main (ese es el estado de los servidores) y luego ejecutar encima el código nuevo: ensayo, ejecución real, otra ejecución real, otro ensayo.
1️⃣ kubernetes.core en lugar de kubectl apply
El patrón antiguo aparecía 29 veces: renderizar una plantilla en /tmp, kubectl apply -f, borrar el archivo y adivinar «changed» buscando la palabra configured en la salida. El patrón nuevo renderiza en memoria y habla directamente con la API:
- name: Redis | Apply Deployment + Service
kubernetes.core.k8s:
kubeconfig: "{{ k3s_kubeconfig }}"
state: present
apply: true
definition: "{{ item }}"
loop: "{{ lookup('ansible.builtin.template', 'redis-deployment.yml.j2') | ansible.builtin.from_yaml_all | select | list }}"
loop_control:
label: "{{ item.kind }}/{{ item.metadata.name }}"Tres cosas solo salieron a la luz gracias a la prueba:
- El primer ensayo mostraba «changed» en casi todo, sin diff. Resulta que el módulo no muestra ningún diff cuando se le pasa una lista de objetos. Una iteración del bucle por objeto lo arregló, y ahora el diff dice exactamente
image: redis:7.4.11-alpine → redis:7.4.12-alpine. - La migración única solo reescribe la anotación
last-applied-configuration(kubectl escribe ahí un"annotations":{}vacío, el módulo no). La spec y la plantilla del pod quedan idénticas, así que ningún pod se reinicia; comprobado con las revisiones de los ReplicaSets, todas siguen en 1. - Helm:
helm upgrade --installse ejecutaba en cada play y apilaba una nueva revisión cada vez.kubernetes.core.helmcompara la versión del chart y los valores, y no hace nada si coinciden. Trampa: Helm 4 solo está soportado desde la versión 6.5.0 de la colección; el paquete de la distribución traía la 6.1.0.
Un efecto secundario que me gusta mucho: los Secrets de Kubernetes ya no pasan por un archivo en /tmp en el servidor.
2️⃣ Un ensayo que significa algo
En modo check, Ansible se salta todas las tareas shell/command, incluidas las inofensivas que solo leen. Las tareas siguientes que evalúan esos resultados fallan o deciden sobre nada. 94 consultas puras (comprobaciones de versión, occ config:system:get, systemctl is-active …) llevan ahora check_mode: false. Todo lo que escribe se sigue saltando, y donde eso ocultaría un cambio pendiente, una pequeña tarea adicional lo avisa.
3️⃣ Lint, escaneo de secretos, hooks
La primera ejecución de ansible-lint encontró 811 hallazgos. Unos 600 eran reglas de estilo que contradicen decisiones deliberadas (columnas alineadas, nombres de variables compartidos sin prefijo de rol); están desactivadas con una justificación escrita. El resto se corrigió. Dos hallazgos reales:
ansible.builtin.ini_fileno existe. Solo funcionaba por una antigua redirección acommunity.general, una colección que ni siquiera estaba enrequirements.yml. En un controlador con solo ansible-core, el playbook de Nextcloud se habría parado justo ahí.- 27 pipes de shell sin
pipefail. Casi siempre inofensivos, pero en la comprobación de versión,k3s --version | awk … || echo 'not installed'nunca podía mostrar «not installed»: sinpipefailsolo cuenta el código de salida deawk.
gitleaks revisó los 191 commits del historial: un único resultado, un marcador PASTE_CONTENT_OF_id_rsa_HERE entre dos marcadores de clave en un viejo archivo de ejemplo. Aceptado a propósito y documentado. Los hooks se ejecutan ahora en cada commit, y el primer commit que bloquearon, unas horas después, fue uno de Claude (sangría YAML incorrecta en archivos generados). Exactamente para eso están.
4️⃣ Idempotencia, probada
La prueba con podman forma ahora parte del repositorio (tests/k8s/run.sh). Falla si la segunda ejecución todavía cambia algo, si un pod se volvería a desplegar, si una release de Helm recibe una revisión nueva, o si un tag de imagen cambiado a propósito no se detecta. Esto último importa: una prueba que nunca puede fallar no demuestra nada.
Para los servidores reales existe scripts/check-drift.sh <playbook> <host>: un ensayo que solo muestra lo que cambiaría, con un diff legible. Justo después de una ejecución real, el resultado debe ser NO DRIFT.
De paso se corrigieron algunas tareas que mostraban «changed» en cada ejecución (descarga de firmas, un usuario de base de datos, un módulo dnf). El hallazgo más traicionero era al revés: los nueve ajustes SMTP de Nextcloud se reescribían en cada ejecución y siempre mostraban «ok». Una contraseña de correo cambiada nunca habría aparecido como cambio. Ahora primero se leen, se comparan, y solo se escriben los valores distintos, pasados por argv para que unas comillas en una contraseña no puedan romper el comando.
5️⃣ Cuando una actualización falla a mitad
La primera propuesta de Claude fue: «salir limpiamente del modo mantenimiento en el rescue». Pensándolo mejor, se corrigió a sí mismo: tras un occ upgrade interrumpido, desactivar el modo mantenimiento deja a los usuarios ante una instancia a medio actualizar. La lógica ahora:
- Una comprobación previa fuera del block: instalado, no en modo mantenimiento, ninguna actualización del núcleo pendiente; si no, el playbook se niega a empezar. Fuera del block a propósito: el rescue solo debe tratar estados causados por la propia ejecución.
- El rescue recoge el estado de occ, los pods, los eventos y las últimas líneas de
nextcloud.log, y solo desactiva el modo mantenimiento sineedsDbUpgradees false. Después se detiene, para que no se toque un segundo servidor. - Extra: los playbooks de actualización reiniciaban Nextcloud y Collabora en cada ejecución. Un pequeño rol nuevo compara el digest de cada imagen recién descargada con lo que ejecutan los pods, y solo reinicia lo que realmente se reconstruyó, exporters y notify_push incluidos.
Probado con sustitutos de kubectl/crictl en 14 escenarios. Luego el paso importante: romper el rescue a propósito (desactivar siempre el modo mantenimiento) y volver a lanzar la prueba. Se puso en rojo exactamente en el escenario correcto. Antes, la misma prueba había mostrado una vez «todo verde» por un motivo equivocado: dos escenarios con el mismo nombre compartían directorio y mezclaban sus logs. El verde significa poco hasta que has visto fallar la prueba.
6️⃣ a 9️⃣ Los más discretos
- 6 – dividir: el rol de WordPress (912 líneas) tiene ahora siete archivos temáticos, Grafana tres, los roles de verificación dos cada uno. Prueba de que nada cambió:
--list-tasks --list-tagsantes y después, idénticos. - 7 – argument specs: 23 roles declaran sus entradas. «Obligatorio» se calcula por tipo de host: los roles comunes leen
blog_hostnameen un servidor ynextcloud_hostnameen el otro, así que ninguno puede ser obligatorio. Comprobado contra las variables reales de los tres servidores. Los valores del vault van conno_log; comprobado que no aparecen ni con-vvvni en un error de validación. - 8 – fusionar: un solo rol de ClamAV y uno de SELinux con un perfil por stack, más 16 comprobaciones comunes. Trampa evitada: Ansible identifica las entradas de cron por su nombre, así que los nombres tenían que quedar exactamente iguales; si no, el trabajo antiguo habría seguido corriendo junto al nuevo.
- 9 – fijar:
>=3.0.0pasó a ser3.15.0. El ensayo de Renovate mostró por qué importa: las versiones mayores 5.x y 13.x ya estaban esperando.
🔟 Renovate, y todas las versiones en un solo archivo
Todas las versiones fijadas están ahora en inventory/group_vars/all/versions.yml: los valores de forma compacta arriba, los motivos y el historial debajo. Renovate recibe regex managers para exactamente esas líneas y propone actualizaciones como pull requests, cada semana, nunca fusionadas automáticamente. Un ensayo local con el contenedor de Renovate encontró las 21 versiones rastreadas y propuso enseguida nginx 1.31. Esa es la rama mainline; yo uso la rama stable (minor par). De ahí las barreras:
- nginx se queda en versiones minor pares.
- Las versiones mayores (Nextcloud 35, MariaDB, Redis 8) y los saltos minor de K3s/Calico solo tras aprobarlos en el dependency dashboard, con una nota sobre qué hacer.
- Las PR de WP-CLI me recuerdan actualizar también la suma SHA-512: Renovate no puede calcularla, y sin ella el contenedor de inicialización del blog rechazaría la descarga.
📚 Y la documentación
Nuevo en el repositorio: «Cómo funciona el código de Ansible» en alemán, inglés y francés: las variables y su precedencia, qué pasa en una ejecución, cómo funcionan los módulos de Kubernetes, los patrones de idempotencia, el manejo de errores, las pruebas y una ruta de aprendizaje. Actualizar las guías existentes sacó a la luz de paso algunos datos desfasados: una versión de imagen antigua, una descripción de Calico que aún decía «solo Nextcloud», una tabla de variables que apuntaba al archivo equivocado.
🚀 El despliegue
Las pruebas locales estaban en verde. Luego creé yo mismo el archivo de la contraseña del vault en la terminal (nunca en el chat), y Claude lanzó la comprobación de deriva contra los servidores reales. La primerísima ejecución contra el blog se cayó: el propio script de Claude tropezó con el módulo dnf, que devuelve sus resultados como una lista de cadenas en lugar de objetos. Corregido, relanzado, y llegaron los hallazgos reales. La mayoría estaban en código que llevaba meses funcionando.
Lo que solo mostraron los servidores reales
- El rol de Grafana no podía hacer ningún ensayo. Pregunta a la API «¿existe la fuente de datos?» con el módulo
uri, que Ansible se salta en modo check, y la tarea siguiente evaluaba entonces un resultado inexistente. Inofensivo en una ejecución real, fatal para--check. - Mi propia corrección del punto 4 estaba incompleta. Añadí
creates:para quefreshclamsolo se ejecutara una vez, pero dejéchanged_when: true, que sobrescribe el resultado «saltado, el archivo existe». Seguía mostrando «changed» sin hacer nada. - Un ping-pong de propietario: el playbook ponía
publickey.asca root, otra cosa lo devolvía a www-data, en cada ejecución, durante meses sin que nadie lo notara. - Los binarios de monitorización de Nextcloud se volvían a descargar tras cada reinicio. Los archivos comprimidos están en
/tmp, que un reinicio vacía, y la tarea de descarga no tenía ninguna condición. Ahora primero comprueba la versión instalada. - Un privilegio renombrado: el usuario del exporter de MariaDB recibía sus permisos de nuevo en cada ejecución. El código pedía
REPLICATION CLIENT, MariaDB 10.11 muestra el mismo privilegio comoBINLOG MONITOR, y el módulo veía una diferencia que no lo era. - 46 avisos de obsolescencia por facts usados como variables de primer nivel (
ansible_kernelen lugar deansible_facts['kernel']): sin problema hoy, roto en una futura versión de Ansible.
Una pregunta necesitaba más que un log: ¿cambiaría el Deployment de Nextcloud? Su tarea va con no_log porque contiene secretos, así que el diff está oculto. Un reinicio significa una breve interrupción (estrategia Recreate). Claude comparó el objeto en marcha con un ensayo del apply en el servidor y mostró solo los nombres de los campos distintos, nunca los valores: idéntico salvo la anotación last-applied. Sin reinicio.
Más rápido, ya puestos
Pregunté si algunos roles podían ejecutarse en paralelo para ahorrar tiempo. Respuesta honesta: no en un solo host. Ansible procesa las tareas de un host una tras otra, y las condiciones de carrera reales serían los bloqueos de dnf y AIDE/rkhunter, que deben ver el estado final. Las ganancias seguras estaban en otra parte: AIDE ahora compara y escribe su base de datos en un solo escaneo en lugar de dos (de 30 segundos a 3 minutos por ejecución en el blog), y los objetos de Kubernetes de una plantilla van a la API en una sola llamada en las ejecuciones normales; por objeto solo con --diff, donde importa el diff legible. Solo Calico pasó de unos 30 segundos a menos de 2.
Las ejecuciones reales
- Blog: MariaDB y Redis intactos, WordPress reiniciado una vez; comprobé por qué: la única diferencia entre la plantilla de pod antigua y la nueva era la marca de tiempo
restartedAtdel reinicio intencionado tras actualizar plugins. Las releases de Helm se quedaron en la revisión 87 (cert-manager) y 72 (ingress-nginx). Esas cifras lo dicen todo: el código antiguo añadía una revisión por ejecución. Comprobación de deriva después: NO DRIFT. - sofie: ningún pod reiniciado, revisiones de Helm sin cambios, autotest de notify_push en verde, NO DRIFT.
- cvjm (el Nextcloud grande): el mismo panorama; ningún pod reiniciado, revisiones de Helm sin cambios, el usuario del exporter tiene ahora
BINLOG MONITORy ya no recibe sus permisos cada vez, NO DRIFT.
🎯 Conclusión
En cifras: 16 commits, 165 archivos cambiados, 0 errores de lint, dos nuevas suites de pruebas, tres servidores convertidos, cada uno terminando en NO DRIFT. Pero lo importante no está en las cifras. La mayoría de los hallazgos reales vinieron de pruebas a las que se les permitía fallar: la dependencia oculta de una colección, la contraseña de correo que nunca aparecía como cambio, el diff que faltaba, la prueba en verde por un motivo equivocado y, en cuanto entraron en juego servidores reales, media docena de pequeñas mentiras en código que llevaba meses funcionando tranquilamente.
¿Y mi pregunta inicial? El repositorio tiene ahora una ruta de aprendizaje. El paso tres dice: escribir un rol tú mismo, sin plantilla. Ese es para mí.





