Los fundamentos de Ansible, sin los cuales tus playbooks son un lío de pasta pegada

Hago muchas revisiones del código de otras personas en Ansible y escribo bastante yo mismo. A través del análisis de errores (tanto ajenos como propios), y algunos entrevistas, comprendí el error principal que cometen los usuarios de Ansible: se adentran en lo complejo sin haber dominado lo básico.

Para corregir esta injusticia universal, decidí escribir una introducción a Ansible para aquellos que ya lo conocen. Advierto que no es un resumen de los manuales, es un texto extenso con muchas letras y sin imágenes.

El nivel esperado del lector es que ya ha escrito miles de líneas de YAML, ya tiene algo en producción, pero "todo parece torcido".

Nombres

El error principal del usuario de Ansible es no saber cómo se llaman las cosas. Si no conoces los nombres, no puedes entender lo que está escrito en la documentación. Un ejemplo real: en una entrevista, una persona que afirmaba haber escrito mucho en Ansible no pudo responder a la pregunta "¿de qué elementos se compone un playbook?". Y cuando le sugerí que "se esperaba que la respuesta fuera que un playbook está compuesto por plays", la respuesta fue un comentario devastador: "nosotros no utilizamos eso". La gente escribe en Ansible por dinero y no usa plays. En realidad, se utilizan, pero no saben lo que son.

Así que empecemos por lo simple: cómo se llaman las cosas. Tal vez ya lo sepas, o tal vez no, porque no prestaste atención al leer la documentación.

ansible-playbook ejecuta playbooks. Un playbook es un archivo con la extensión yml/yaml, dentro del cual hay algo como esto:

---
- hosts: group1
  roles:
    - role1

- hosts: group2,group3
  tasks:
    - debug:

Ya hemos comprendido que todo este archivo es un playbook. Podemos señalar dónde están los roles (roles), dónde están las tareas (tasks). Pero, ¿dónde está el play? ¿Y en qué se diferencia un play de un role o un playbook?

Todo esto está en la documentación. Y se pasa por alto. Los principiantes — porque hay demasiada información y no puedes memorizarlo todo de una vez. Los experimentados — porque son "cosas triviales". Si eres experimentado, vuelve a leer estas páginas al menos una vez cada seis meses, y tu código mejorará considerablemente.

Así que recuerda: un playbook es una lista que consta de plays y import_playbook.
Este es un play:

- hosts: group1
  roles:
    - role1

Y este también es otro play:

- hosts: group2,group3
  tasks:
    - debug:

¿Qué es exactamente un play? ¿Para qué sirve?

Un play es un elemento clave para el playbook, porque el play y solo el play conecta la lista de roles y/o tareas con la lista de hosts en los que deben ejecutarse. En las profundas entrañas de la documentación, se puede encontrar una mención a delegate_to, plugins de búsqueda locales, configuraciones específicas de network-cli, hosts de salto, etc. Permiten modificar ligeramente el lugar donde se ejecutan las tareas. Pero, olvídate de eso. Cada una de estas opciones ingeniosas tiene aplicaciones muy específicas y no son universales. Y estamos hablando de las cosas básicas que todos deben saber y utilizar.

Si quieres ejecutar "algo" "en algún lugar" — escribes un play. No un rol. No un rol con módulos y delegaciones. Simplemente escribes un play. En el cual, en el campo hosts enumeras dónde ejecutar, y en roles/tareas — qué ejecutar.

¿Es simple, verdad? ¿Y cómo podría ser diferente?

Uno de los momentos característicos en que las personas sienten la necesidad de hacer esto no a través de un play es con "un rol que lo configura todo". Se quiere tener un rol que configure y servidores del primer tipo y servidores del segundo tipo.

Un ejemplo arquetípico es la monitorización. Se desea tener un rol de monitorización que configure la monitorización. El rol de monitorización se asigna a los hosts de monitorización (en el correspondiente play). Pero resulta que para la monitorización necesitamos instalar paquetes en los hosts que estamos monitorizando. ¿Por qué no usar un delegate? Además, hay que configurar iptables. ¿delegate? Y también hay que escribir/corregir la configuración para la base de datos, para que la monitorización funcione. ¡delegate! Y si surge la creatividad, se puede hacer delegación include_role en un ciclo anidado con un filtro ingenioso en la lista de grupos, y dentro de include_role también se puede hacer delegate_to de nuevo. Y sigue adelante...

El buen deseo de tener un único rol de monitorización que "haga todo" nos lleva a un infierno del cual la mayoría de las veces solo hay una salida: reescribir todo desde cero.

¿Dónde ocurrió el error? En el momento en que descubriste que para realizar la tarea "x" en el host X tenías que ir al host Y y hacer allí "y", deberías haber realizado un ejercicio simple: ir y escribir un play que en el host Y realice y. No añadir algo a "x", sino escribir desde cero. Aunque sea con variables codificadas.

Parece que en los párrafos anteriores todo se dijo correctamente. ¡Pero ese no es tu caso! Porque quieres escribir código reutilizable que sea DRY y parezca una biblioteca, y necesitas buscar un método para hacerlo.

Aquí hay otro error grave oculto. Un error que ha convertido muchos proyectos de aceptablemente escritos (se puede mejorar, pero todo funciona y es fácil de agregar) en un completo desastre, en el que incluso el autor no puede entenderse. Funciona, pero Dios no permita que cambies algo.

Este error se describe así: un rol es una función de biblioteca. Esta analogía ha arruinado tantos buenos comienzos que es triste verlo. Un rol no es una función de biblioteca. No puede hacer cálculos y no puede tomar decisiones a nivel de play. Recuérdame, ¿qué decisiones toma play?

Gracias, tienes razón. Play toma decisiones (más bien, contiene información) sobre qué tareas y roles ejecutar en qué hosts.

Si delegas esa decisión a un rol, y además con cálculos, te condenas a ti mismo (y a quien intente desentrañar tu código) a una existencia miserable. Un rol no decide dónde ejecutarse. Esa decisión la toma play. Un rol hace lo que se le dice, allí donde se le dice.

Hablaremos de por qué programar en Ansible es peligroso y en qué COBOL es mejor que Ansible en el capítulo sobre variables y jinja. Por ahora, digamos una cosa: cada cálculo deja una huella imborrable de cambios en las variables globales, y no puedes hacer nada al respecto. Una vez que dos "huellas" se cruzan, todo está perdido.

Nota para los que son meticulosos: un rol, sin duda, puede influir en el control de flujo. Hay delegate_to y tiene aplicaciones razonables. Hay meta: end host/play. ¡Pero! Recuerda, ¿estamos enseñando lo básico? Olvidaste sobre delegate_to. Hablamos del código más simple y hermoso en Ansible. Que es fácil de leer, fácil de escribir, fácil de depurar, fácil de probar y fácil de agregar. Así que, de nuevo:

play y solo play decide en qué hosts se ejecuta qué.

En esta sección hemos abordado la confrontación entre play y rol. Ahora hablemos sobre la relación entre tasks y roles.

Tareas y Roles

Consideremos play:

- hosts: somegroup
  pre_tasks:
    - some_tasks1:
  roles:
     - role1
     - role2
  post_tasks:
     - some_task2:
     - some_task3:

Supongamos que necesitas hacer foo. Y se ve así foo: name=foobar state=present. ¿Dónde se escribe esto? ¿en pre? ¿post? ¿Crear un rol?

… ¿Y adónde fueron las tasks?

Volvemos a lo básico: la estructura de play. Si no tienes claro este aspecto, no puedes usar play como base para todo lo demás, y tu resultado es "inestable".

Dispositivo play: directiva hosts, configuraciones del play y secciones pre_tasks, tasks, roles, post_tasks. Otros parámetros para el play no son importantes ahora.

El orden de sus secciones con tareas y roles: pre_tasks, roles, tasks, post_tasks. Dado que semánticamente el orden de ejecución entre tasks y roles no es claro, las mejores prácticas indican que agregamos la sección tasks, solo si no hay roles. Si hay roles, entonces todas las tareas correspondientes se colocan en las secciones pre_tasks/post_tasks.

Solo queda lo que semánticamente está claro: primero pre_tasks, luego roles, luego post_tasks.

Pero aún no hemos respondido la pregunta: ¿dónde escribir la llamada al módulo foo ? ¿Es necesario escribir un rol entero para cada módulo? ¿O es mejor tener un rol general para todo? ¿Y si no es un rol, entonces dónde escribir: en pre o en post?

Si no hay respuesta argumentada a estas preguntas, entonces es un signo de falta de intuición, es decir, de esas "fundaciones inestables". Vamos a desentrañarlo. Primero una pregunta de control: Si el play tiene pre_tasks y post_tasks (y no hay ni tasks, ni roles), ¿puede romperse algo si llevo la primera tarea de post_tasks al final? pre_tasks?

Por supuesto, la formulación de la pregunta insinúa que se romperá. Pero, ¿qué exactamente?

… Handlers. Leer los fundamentos revela un hecho importante: todos los handlers se ejecutan automáticamente después de cada sección. Es decir, se ejecutan todas las tareas de pre_tasks, luego todos los handlers que fueron notificados. Después se ejecutan todos los roles y todos los handlers notificados en los roles. Luego post_tasks y sus handlers.

Por lo tanto, si arrastras una tarea de post_tasks en pre_tasks, potencialmente, la ejecutarás antes de ejecutar el handler. Por ejemplo, si en pre_tasks se instala y configura servidor web, mientras que en post_tasks se envía algo, entonces mover esta tarea a la sección pre_tasks resultará en que en el momento de "enviar", el servidor aún no se habrá iniciado y todo fallará.

Y ahora pensemos otra vez, ¿por qué necesitamos pre_tasks y post_tasks? Например, для того, чтобы выполнить всё нужное (включая хэндлеры) до выполнения роли. А post_tasks nos permitirá trabajar con los resultados de la ejecución de roles (incluidos los handlers).

Un conocedor meticuloso de Ansible nos dirá que hay meta: flush_handlers, pero ¿para qué necesitamos flush_handlers, si podemos confiar en el orden de ejecución de las secciones en el play? Más aún, el uso de meta: flush_handlers puede traernos problemas inesperados con handlers duplicados, haciéndonos recibir advertencias extrañas en caso de usar when sa-logic-subsets-canary-vs.yaml block etc. Cuanto mejor conozcas Ansible, más matices podrás nombrar para una solución "astuta". Y la solución simple — el uso de una división natural entre pre/roles/post — no plantea problemas.

Y volviendo a nuestro ‘foo’. ¿Dónde debemos colocarlo? ¿En pre, post o en roles? Obviamente, esto depende de si necesitamos los resultados del manejador para foo. Si no los hay, entonces no es necesario colocar foo ni en pre ni en post: estas secciones tienen un significado especial, que es la ejecución de tareas antes y después del array de código principal.

Ahora la respuesta a la pregunta "rol o tarea" se reduce a lo que ya existe en el play: si hay tasks, hay que añadirlas a tasks. Si hay roles, hay que crear un rol (aunque sea de una sola task). Recuerdo que tasks y roles no se utilizan simultáneamente.

Entender las bases de Ansible proporciona respuestas fundamentadas a lo que podrían parecer preguntas de gusto personal.

Tareas y roles (parte dos)

Ahora discutamos la situación en la que apenas estás comenzando a escribir un playbook. Necesitas hacer foo, bar y baz. ¿Son estas tres tareas, un rol o tres roles? Resumiendo la pregunta: ¿en qué momento deberíamos comenzar a escribir roles? ¿Cuál es el sentido de escribir roles cuando podemos escribir tareas?… ¿Qué es un rol?

Uno de los errores más groseros (ya he hablado de esto) es pensar que un rol es como una función en una biblioteca de programación. ¿Cómo se ve una descripción general de una función? Toma argumentos de entrada, interactúa con efectos colaterales y devuelve un valor.

Ahora, atención. ¿Qué de esto se puede hacer en un rol? ¿Llamar a efectos colaterales? Siempre es posible, esa es la esencia de Ansible: hacer efectos colaterales. ¿Tener causas colaterales? Elemental. Pero con "pasar un valor y devolverlo" — aquí es donde no se puede. En primer lugar, no puedes pasar un valor a un rol. Puedes establecer una variable global con un tiempo de vida correspondiente al play en la sección vars para el rol. Puedes establecer una variable global con un tiempo de vida en el play dentro del rol. O incluso con un tiempo de vida de playbooks (set_fact/register). Pero no puedes tener "variables locales". No puedes "recibir un valor" y "devolverlo".

De esto se deduce lo principal: no se puede escribir algo en Ansible sin provocar efectos colaterales. Cambiar variables globales es siempre un efecto colateral para una función. En Rust, por ejemplo, cambiar una variable global es unsafe. Y en Ansible es el único método para influir en los valores de un rol. Tengan en cuenta las palabras utilizadas: no "pasar un valor a un rol", sino "modificar los valores que utiliza el rol". No hay aislamiento entre roles. No hay aislamiento entre tareas y roles.

Total: un rol no es una función.

¿Qué hay de bueno en los roles? En primer lugar, los roles tienen valores predeterminados (/default/main.yaml), y en segundo lugar, los roles cuentan con catálogos adicionales para almacenar archivos.

¿Cuál es la ventaja de los valores predeterminados? En la tabla de prioridades de variables de la pirámide de Maslow bastante retorcida de Ansible, los valores predeterminados de los roles son los menos prioritarios (excepto por los parámetros de la línea de comandos de Ansible). Esto significa que si necesitas proporcionar valores predeterminados sin preocuparte de que interrumpan los valores de inventario o variables grupales, los valores predeterminados del rol son el lugar correcto para ti. (Estoy exagerando un poco, también existe |d(your_default_here), pero hablando de lugares estacionarios, solo son los valores predeterminados del rol).

¿Qué más es bueno en los roles? Que tienen sus propios directorios. Son directorios para variables, tanto permanentes (es decir, calculadas para el rol), como dinámicas (hay un patrón o antipatrones en juego — include_vars junto con {{ ansible_distribution }}-{{ ansible_distribution_major_version }}.yml). Son directorios para files/, templates/. Además, permiten a los roles tener sus propios módulos y complementos (library/). Sin embargo, en comparación con las tareas de los playbooks (cuyas características también pueden incluir todo esto), la ventaja aquí radica en que los archivos no están en un solo lugar, sino en varios grupos separados.Otro aspecto a mencionar: puedes intentar crear roles que se puedan reutilizar (a través de galaxy). Tras la aparición de colecciones, la difusión de roles casi se puede considerar olvidada.

Así, los roles poseen dos características importantes: tienen valores predeterminados (una característica única) y permiten estructurar el código.

Volviendo a la pregunta inicial: ¿cuándo hacer tareas y cuándo roles? Las tareas en el playbook se utilizan con mayor frecuencia como "pegamento" antes/después de los roles, o como elementos constructivos independientes (en cuyo caso no deberían haber roles en el código). Un montón de tareas normales mezcladas con roles es sin duda desordenado. Debe seguirse un estilo concreto: o tareas o roles. Los roles ofrecen separación de entidades y valores predeterminados, las tareas permiten leer el código más rápidamente. Generalmente, en los roles se lleva el código más "estacionario" (importante y complejo), mientras que en el estilo de tareas se escriben scripts auxiliares.

Volviendo a la pregunta original: ¿cuándo hacer tareas y cuándo roles? Las tareas en un playbook se utilizan con mayor frecuencia como "pegamento" antes/después de los roles, o como elementos constructivos independientes (en este caso, no debería haber roles en el código). Un montón de tareas normales mezcladas con roles es definitivamente desaliñado. Se debe seguir un estilo específico: o tareas o roles. Los roles proporcionan separación de entidades y valores predeterminados, mientras que las tareas permiten leer el código más rápido. Generalmente, en los roles se sacan códigos más "fijos" (importante y complejo), y en el estilo de tareas se escriben scripts auxiliares.

Existe la posibilidad de hacer un import_role como tarea, pero si escribes esto, prepárate para explicar por qué lo deseas hacer.

Un lector perspicaz podría decir que los roles pueden importar roles, que los roles pueden tener dependencias a través de galaxy.yml, y además existe algo horrible y aterrador include_role — les recuerdo, estamos mejorando nuestras habilidades en Ansible básico, no en gimnasia artística.

Manejadores y tareas

Hablemos de otra cosa obvia: los manejadores. Saber usarlos correctamente es casi un arte. ¿Cuál es la diferencia entre un manejador y una tarea?

Dado que estamos recordando lo básico, aquí hay un ejemplo:

- hosts: group1
  tasks:
    - foo:
      notify: handler1
  handlers:
     - name: handler1
       bar:

En un rol, los manejadores están en rolename/handlers/main.yaml. Los manejadores son compartidos entre todos los participantes del play: las pre/post_tasks pueden invocar los manejadores del rol, y un rol puede invocar los manejadores del play. Sin embargo, las invocaciones de manejadores "inter-roles" causan mucha más confusión que la repetición de un manejador trivial. (Otro elemento de las mejores prácticas es tratar de no repetir nombres de manejadores).

La diferencia principal es que una tarea se ejecuta (idempotente) siempre (más/menos etiquetas y when), mientras que un manejador se ejecuta por cambio de estado (notify se activa solo si hubo un cambio). ¿Qué implica esto? Por ejemplo, que al volver a ejecutar, si no hubo cambios, entonces no habrá manejador. ¿Y por qué puede ser que necesitemos ejecutar un manejador cuando no hubo cambios en la tarea que lo generó? Por ejemplo, porque algo se rompió y hubo un cambio, pero la ejecución no llegó al manejador. Por ejemplo, porque la red estuvo caída temporalmente. La configuración cambió, el servicio no se reinició. En la siguiente ejecución, la configuración ya no cambia y el servicio sigue con la versión anterior de la configuración.

La situación con la configuración no es resoluble (más bien, uno podría inventarse un protocolo especial de reinicio con banderas de archivos, pero eso ya no es ‘ansible básico’ de ninguna manera). Sin embargo, hay otra historia común: hemos instalado una aplicación, hemos registrado su .service-archivo, y ahora queremos daemon_reload y state=started. Y el lugar natural para esto parece ser un handler. Pero si lo hacemos no un handler, sino una tarea al final de la lista de tareas o de un rol, se ejecutará idempotentemente cada vez. Incluso si el playbook falla a la mitad. Esto no resuelve absolutamente el problema de restarted (no se puede hacer una tarea con el atributo restarted, ya que se pierde la idempotencia), pero definitivamente vale la pena hacerlo state=started, ya que la estabilidad general de los playbooks aumenta, a medida que se reduce la cantidad de conexiones y estado dinámico.

Otra propiedad positiva del handler es que no ensucia la salida. No hubo cambios, no hay skipped o ok de más en la salida, lo que facilita la lectura. Sin embargo, esto también es una desventaja: si encuentras un error tipográfico en una tarea ejecutada linealmente en la primera pasada, los handlers solo se ejecutarán si hay cambios, es decir, bajo ciertas condiciones, lo cual es muy raro. Por ejemplo, la primera vez en la vida después de cinco años. Y, por supuesto, habrá un error tipográfico en el nombre y todo fallará. La segunda vez no se podrá ejecutar, ya que no hay cambios.

Es necesario hablar por separado sobre la disponibilidad de las variables. Por ejemplo, si notificas para una tarea con un bucle, ¿qué habrá en las variables? Se puede deducir de manera analítica, pero no siempre es trivial, especialmente si las variables provienen de diferentes lugares.

Así que los handlers son mucho menos útiles y mucho más problemáticos de lo que parecen. Si hay algo que se puede escribir de manera bonita (sin trucos) sin handlers, es mejor hacerlo sin ellos. Si no se puede hacer bonito, es mejor hacerlo con ellos.

Un lector perspicaz señala correctamente que no hemos discutido listen, que un handler puede invocar notify para otro handler, que un handler puede incluir import_tasks (que puede hacer include_role con with_items), que el sistema de handlers en Ansible es turing completo, que los handlers de include_role se cruzan de manera curiosa con los handlers de play, etc. — todo esto claramente no son "fundamentos".

Aunque hay un cierto WTF que en realidad es una característica y que debe recordarse. Si una tarea se ejecuta con delegate_to y tiene notify, entonces el handler correspondiente se ejecuta sin delegate_to, es decir, en el host al que se asignó el play. (Aunque el handler, por supuesto, puede tener delegate_to también).

Quiero decir unas palabras sobre roles reutilizables. Antes de la aparición de las colecciones, había la idea de que se podían hacer roles universales, que se podían ansible-galaxy install y se fue. Funciona en todos los sistemas operativos en todas las variantes en todas las situaciones. Así que, mi opinión es: no funciona. Cualquier rol con soporte para 100500 casos está condenado a las profundidades de errores corner case. Se pueden solucionar con pruebas masivas, pero como con cualquier prueba, o tienes el producto cartesiano de las entradas y una función total, o tienes "cobiertos escenarios individuales". Mi opinión es que es mucho mejor si el rol es lineal (complejidad ciclomática 1). include_vars, cuanto menos ifs (explícitos o declarativos — en forma

o forma when por conjunto de variables), mejor es el rol. A veces es necesario hacer bifurcaciones, pero, repito, cuanto menos haya, mejor. Así que parece que un buen rol con galaxy (¡funciona!) con un montón include_vars puede ser menos preferible que "su" rol de cinco tareas. El momento en que el rol con galaxy es mejor — es cuando comienzas a escribir algo. El momento en que se vuelve peor — es cuando algo se rompe, y sospechas que es por "el rol con galaxy". Lo abres, y allí hay cinco inclusiones, ocho listas de tareas y una pila when de tareas... Y tienes que resolver eso. En lugar de 5 tareas en una lista lineal, donde no hay nada que pueda romperse. whenEn las siguientes partes

Un poco sobre inventarios, variables de grupo, plugin host_group_vars, hostvars. Cómo atar un nudo gordiano de espagueti. Alcance y precedencia de variables, modelo de memoria de Ansible. "¿Dónde se debe guardar el nombre de usuario para la base de datos?".

  • jinja: {{ jinja }}
  • — nosql notype nosense plastilina blanda. Está en todas partes, incluso donde no lo esperas. Un poco sobre !!unsafe y un delicioso yaml. ¿Cómo ayudan los gigantes de la tecnología a la educación? Parte 2: Microsoft

Fuente: habr.com

Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS 🔥 Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS | ProHoster