sábado, 28 de septiembre de 2024

Ansible - Estructura de proyectos III

En las anteriores entradas de esta serie, hemos analizado como crear un proyecto de Ansible siguiendo la estructura de directorios recomendada. Esto nos llevaba a la creación de playbooks mediante los cuales, podíamos ejecutar múltiples tareas de forma sencilla aprovechando la estructura de directorios recomendada, ya que Ansible es capaz de utilizarla para simplificar todo el proceso.

Como vimos, esta estructura nos permite organizar la información necesaria para la correcta ejecución del playbook, ficheros estáticos, plantillas, definición de variables, etc... de forma simple y repetible. Recordemos que la estructura que definimos era algo similar a lo siguiente:

Estructura básica de proyecto.

Siguiendo esta estructura, por tanto, podemos hacer playbooks complejos para poder realizar tareas en diferentes tipos de hosts, por ejemplo servidores web, servidores de aplicaciones y servidores de bases de datos. Además podemos aplicar configuraciones, que por ejemplo sean comunes a los tres grupos de hosts, y posteriormente aplicar las configuraciones correspondientes a cada grupo de hosts. Evidentemente podemos repetir esto tantas veces como tipos o grupos de hosts tengamos que configurar o bien podemos aprovechar otra característica de Ansible denominada roles.

Básicamente, podríamos decir que un rol es un proyecto de Ansible que aplica a un tipo o grupo de hosts. Siguiendo la estructura presentada, un rol contendrá todos los ficheros, templates, tareas, etc.. que solo aplican a dicho grupo de hosts, dada por una estructura de directorios similar a la ya expuesta. Esta estructura se repetirá por cada uno de los roles existentes.

Podemos verlo, de forma simplificada, del siguiente modo:

Proyecto de Ansible y roles.

Como podemos ver, añadimos una carpeta roles a nuestro proyecto y dentro de la misma, una carpeta por cada uno de los roles. Cada uno de estos roles se corresponderán con cualquier clasificación que pueda existir en nuestro entorno, ya sean tipos de servidores, acciones o tareas a realizar, etc...

La estructura de cada una de las carpetas correspondientes a un rol es, en su forma más simple, similar a la siguiente:

Estructura básica de un rol.

Teniendo en cuenta lo expuesto en anteriores entradas de esta serie de posts, es fácil imaginar cual es el objetivo de cada una de las carpetas que forman un rol:

  • En la carpeta files se almacenarán los ficheros estáticos que es necesario copiar a los hosts de destino.
  • La carpeta handlers contendrá un fichero main.yaml, donde podremos definir cada uno de los handlers que se utilizarán a lo largo del playbook.
  • La carpeta tasks es la carpeta principal del rol y contiene, al menos, el fichero main.yaml con todas las tareas que se aplicarán al rol.
  • En la carpeta templates podremos almacenar todas las plantillas Jinja correspondientes a ficheros cuyo contenido varía, las cuales copiaremos a los hosts de destino mediante el módulo ansible.builtin.template.
  • La carpeta vars contendrá un fichero main.yaml, donde podremos especificar variables que son exclusivas del rol.

Con estas carpetas básicas, podemos definir un rol el cual, por tanto, podemos ver como una plantilla que siempre aplicará de igual manera a nuestros objetivos, sean estos hosts a configurar, acciones de un proceso batch, etc...

Al usar roles, el fichero principal del proyecto cambia para indicar que roles deben ejecutarse y sobre que hosts. Por ejemplo, en el caso de estar configurando un grupo de hosts para construir un cluster de kubernetes, podríamos tener un playbook general similar al siguiente:

Uso de roles.
 
Al ejecutar este playbook, del mismo modo que ejecutamos un playbook simple, Ansible ejecutará el playbook correspondiente al rol common en todos los hosts y tras su finalización, pasará a ejecutar el rol masters solamente en los hosts miembros del grupo masters registrado en el inventario.
 
Como ya hemos dicho, cada rol es un playbook estructurado de tal manera que, de forma simple, contiene todos los elementos requeridos así como el fichero playbook con todas las tareas necesarias, para fijar la configuración deseada sobre los hosts indicados.  

Teniendo en cuenta esto, es sencillo poder reutilizar los roles en otros proyectos con solo replicar el contenido de los mismos, así, en el ejmplo indicado:

  • El rol common puede encargarse de configurar la red, nombres de hosts, instalación de paquetes, hardening, en todos los hosts que formarán el cluster.
  • El rol masters se encargará de crear el control plane con los hosts miembros del grupo masters, realizando el bootstrapping del cluster y añadiendo el resto de nodos master al cluster.

Si fijamos la lista de paquetes a instalar en el rol common como una variable propia del rol, que almacenaríamos en la subcarpeta vars de dicho rol, podríamos reutilizar el rol completo para otro proyecto. 

En resumen, el uso de roles es la manera más potente y flexible que proporciona Ansible para realizar tareas complejas que implican muchos hosts, permitiendo además reutilizar el código que desarrollemos para otros proyectos.

Ansible - La variable hostvars

Uno de los puntos más importantes que tenemos que entender cuando trabajamos con Ansible, es el uso y asignación de variables que se generan de forma dinámica durante la ejecución de un playbook.

En general, una de las maneras más habituales de trabajar con las variables consiste en registrarlas, usando el módulo ansible.builtin.register, para usarlas posteriormente en tareas posteriores. Por ejemplo, con el siguiente playbook, registramos el nombre de cada uno de los hosts e imprimimos el valor registrado:

Playbook simple para registrar una variable.

Salida del playbook de registro de variable.

El resultado, como podemos comprobar, es el esperado y la salida que obtenemos se corresponde con la salida del comando hostname en cada uno de los hosts remotos.

Ahora bien ¿que sucede si limito el comando a un solo host? Por ejemplo, si usamos Ansible para configurar un cluster de Kubernetes, podemos construir el comando join utilizado para unir nodos al cluster, a partir de la salida que podemos obtener de uno de los nodos master o al crear un hash de un fichero en uno de los hosts, necesitamos registrar dicho valor para luego emplearlo en el resto de hosts del playbook. De una forma similar, si modificamos nuestro playbook de la siguiente manera:

Playbook modificado limitando la primera tarea a un solo host.

De esta manera limitamos la ejecución de la primera tarea al host cuyo hostname coincida con el indicado. Al hacer esto, el resultado de la ejecución del playbook será el siguiente:

Resultado de la ejecución del playbook limitado.

Como era de esperar, la variable se ha registrado pero únicamente para el host en el cual se ha ejecutado la primera tarea, lo que provoca que dicha variable no esté disponible en el conjunto de variables del resto de hosts del playbook.

Este comportamiento se debe a que, durante la ejecución de un playbook, cada host tiene asociado una serie de variables, que se almacenan en un diccionario llamado hostvars. Este conjunto de variables contienen valores que son exclusivos del host, lo que incluye aquellas variables que registremos durante la ejecución del playbook. Cada host tiene asociada un diccionario hostvars que contiene todas las variables del host, incluyendo las registradas durante la ejecución del playbook. Para acceder al diccionario hostvars de un host, es necesario que utilicemos el nombre de dicho host como está registrado en el inventario.

Así, modificando el playbook de nuevo para mostrar la variable registrada nombre_host en el conjunto de variables hostvars de cada uno de los hosts, podemos comprobar que solo existe para uno de ellos:

Mostrando la variable registrada en hostvars.

Salida resumida de ejecución del playbook anterior.

Como podemos ver, solamente uno de los hosts tiene registrada dicha variable en su conjunto de variables hostvars. Este comportamiento se mantiene incluso aunque usemos set_fact, ya que aunque dicho módulo permite convertir variables en facts, esto solamente es así para el host que se está procesando en ese momento en el playbook. Si registramos como un fact la variable y ejecutamos el playbook:

Registramos la variable con un fact.
 
Salida del playbook.

Por tanto y en resumen, si necesito utilizar una variable registrada para un único host, lo mejor es que hagamos referencia a la misma accediendo al conjunto de variables de dicho host, mediante el empleo de hostvars:

Referenciando variables en hostvars.

Salida del playbook anterior.

Como referencia adicional, podemos ver que Ansible construye diccionarios para almacenar las variables. Para acceder a las variables de estos diccionarios, al igual que en Python, solo tenemos que hacer referencia a la clave cuyo valor queremos obtener. Lo recomendado por Ansible, es referenciar mediante el uso de corchetes [] para indicar los nombres de las claves cuyos valores necesitamos.


sábado, 21 de septiembre de 2024

Kubernetes - Redes y PODs II

En la anterior entrada de esta serie, repasamos como funciona la red entre contenedores que están encapsulados en el mismo POD o que están en PODs diferentes, pero todos se encuentran en el mismo nodo del cluster.

Como también vimos, en un sistema real tendremos un cluster de Kubernetes formado por diferentes nodos y por tanto, es importante que comprendamos perfectamente como se comunican los PODs entre si cuando estos se encuentran en hosts diferentes. Teniendo esto en cuenta, de forma muy simple, un cluster de Kubernetes de N nodos será algo como lo siguiente:

Comunicaciones entre nodos de un clsuter.

Con este diagrama de red podemos decir que, como es lógico, cada host o nodo miembro del cluster puede comunicarse con el resto usando el interfaz eth0 ya que todos se encuentran en la misma red. Pero, si recordamos lo que vimos en el post anterior de esta serie, lo que realmente tenemos es algo como lo siguiente:

Comunicaciones entre todos los elementos de un cluster.

Esta imagen nos permite darnos cuenta de la complejidad que tienen las comunicaciones entre todos los miembros de un cluster de Kubernetes ya que, por defecto, puede suceder que las direcciones IP internas de todos los componentes, como el bridge virtual cni0 o las direcciones IP asignadas a los interfaces veth, coincidan en dos o más de los nodos que forman parte del cluster. Aunque es el peor caso que se puede dar, incluso aunque todas las direcciones IP fuesen diferentes, un nodo que forma parte del cluster no tendría forma de saber, a priori, que direcciones IP tienen el resto de bridges o interfaces veth del resto de nodos del cluster.

Esto podemos comprobarlo con un cluster muy simple, formado por un nodo master y dos nodos worker. Partiendo de este cluster, en el cual empleamos cri-o como motor de contenedores, podemos ver lo siguiente si lanzamos un deployment simple para crear una réplica de un contenedor en cada uno de los nodos worker:

Deployment en un cluster de kubernetes recién creado.

Como se puede comprobar en la imagen anterior, ambos contenedores tienen la misma dirección IP, la cual se corresponde al rango de direcciones IP proporcionadas por el motor de contenedores que está instalado en cada nodo del cluster, en este caso cri-o.

Además, como es lógico, las direcciones IP de los interfaces de red bridge a los que se conectan los contenedores es la misma en todos los nodos:

Direccionamiento IP de interfaz bridge cni0 en los nodos del cluster.

Es importante que recordemos que, en todos los nodos, tenemos instalado el mismo motor de contenedores. Este motor se encarga de asignar direcciones IP a los PODs, en función de la configuración por defecto del mismo y Kubernetes es la capa de orquestación que controla el motor, pero no controla la asignación de direcciones IP. En resumen, este comportamiento es el esperado en un cluster de Kubernetes recien instalado. 

Evidentemente, las comunicaciones entres los PODs que se encuentran en diferentes nodos, es totalmente imposible ya que las direcciones IP son iguales y no hay manera de enrutar los paquetes correctamente entre ellos.

Para poder solventar este problema y asegurar la correcta comunicación entre todos los PODs del cluster, es necesario que instalemos un plugin de red CNI. Este plugin de red, basándose en la configuración que apliquemos, hará dos cosas, primero asignará un rango de direcciones IP global a los bridges en cada nodo y dentro de ese rango de direcciones, asignará una dirección IP al interfaz bridge en función del nodo donde se construya dicho interfaz. Adicionalmente añadirá reglas de enrutamiento en el nodo, indicando que interfaz físico es el primer salto o gateway para alcanzar las direcciones de red de cada uno de los bridges correspondientes al resto de nodos. De esta manera, aseguramos la correcta comunicación entre todos los PODs que puedan ejecutarse en el cluster.

Por tanto el plugin o add-on de red, el cual es totalmente necesario para permitir las correctas comunicaciones entre todos los elementos del cluster, nos permite establecer la comunicación entre todos los PODs del cluster.

Podemos simular esto si modificamos la configuración del rango de red proporcionado por el motor de contenedores y añadimos rutas de red en los nodos worker. Por ejemplo, podemos hacer lo siguiente en los nodos worker:

1.- Editamos el fichero /etc/cni/net.d/11-crio-ipv4-bridge.conflist y modificamos el rango de la dirección de subred de los nodos worker. En uno de los nodos worker fijamos como subred para PODs la dirección de red 10.85.1.0/24, mientras que en el otro usaremos la red 10.85.2.0/24. A continuación reiniciamos el servicio crio de ambos workers y arrancamos un par de PODs. Para esta prueba usaré netshoot, una imagen que contiene herramientas de red, pensada para analizar problemas de comunicaciones en entornos de contenedores.
 
Al comprobar el estado del cluster podemos ver que, ahora, las direcciones IP de cada POD son de redes diferentes:

PODs arrancados con direcciones IP de subredes diferentes.

2.- A continuación añadimos rutas de red en cada nodo worker, de tal modo que indicamos como acceder a la subred asignada a los PODs remotos.
 
3.-  Desde la consola de cada contenedor comprobamos que hay comunicación entre los PODs, aunque se encuentren en diferentes nodos worker:

Comunicación entre PODs.

Esto que hemos realizado a mano, es lo que básicamente implementa el plugin de red CNI que tenemos que instalar en nuestros clusters de Kubernetes para permitir las comunicaciones entre los PODs que se encuentren en diferentes nodos. En general, un plugin de red, se encargará de la asignación de direcciones IP a los interfaces bridge de los nodos, asignación de direcciones IP a los diferentes PODs que corren en cada uno de ellos y creación de todas las rutas necesarias para permitir estas comunicaciones. Adicionalmente, algunos plugins ofrecen además características como el control de red mediante políticas, permitiendo controlar las comunicaciones entre los diferentes PODs, así como entre estos y el exterior del cluster.

En resumen, para permitir las comunicaciones entre todos los PODs de un cluster de Kubernetes, es necesario que instalemos un plugin de red CNI, el cual controlará todos los aspectos de red necesarios para implementar el modelo de red de Kubernetes, así como funcionalidades adicionales dependiendo del plugin que utilicemos.