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.

lunes, 1 de julio de 2024

Kubernetes - Redes y PODs I

Uno de los aspectos que, al menos a mi, me resulta más confuso cuando hablamos de Kubernetes es como funciona la red. Hay diferentes conceptos y abstracciones involucradas que se relacionan entre si, para permitir que los PODs se comuniquen entre ellos y con el exterior del cluster.

Empezando por lo básico, recordemos que un POD es una abstracción de uno o más contenedores que se ejecutan en el mismo host, compartiendo recursos como volúmenes y, para el caso que nos ocupa, la pila de red. Este punto es muy importante porque, si todos los contenedores que forman parte de un POD comparten la pila de red, pueden comunicarse entre ellos accediendo a localhost.

Comencemos por lo más simple, que consiste en que un POD solo encapsule un contenedor. En este caso, vamos a intentar explicarlo a partir de la imagen siguiente:

Un contenedor encapsulado en un POD.

El host donde se ejecuta el motor de contenedores tiene un interfaz de red físico eth0, al cual se conecta el bridge que crea dicho motor de contenedores. Este bridge, dependiendo de nuestra configuración, tendrá una dirección IP la cual, y es esto es muy importante, será el gateway de cualquier contenedor que creemos. A este bridge, se conectará el interfaz de red virtual veth del contenedor en el momento de su creación, siendo la dirección IP asignada a dicho interfaz veth, del mismo rango de red que el del bridge.

Podemos ver lo anterior con solo ejecutar el comando ip addr show en nuestro host físico:

Red en el host.

Como vemos en la imagen anterior, además del interfaz físico y de loopback, aparece el bridge cni0 creado por el motor de contenedores.

Ahora, podemos crear manualmente un POD y asignar un contenedor a dicho POD, para lo cual podemos hacer algo como lo siguiente:

Creación de un contenedor dentro de un POD.

En el momento de crear el POD, un interfaz veth aparece en el sistema y al inspeccionar el POD creado con los comandos anteriores, podemos ver la dirección IP asignada al mismo:

Dirección IP asignada al POD.

Con los comandos anteriores, hemos creado un POD y a continuación, hemos creado y arrancado un contenedor empleando una imagen simple de busybox. Al ejecutar el comando /bin/sh en dicho contenedor, podemos comprobar la dirección IP asignada al mismo:

Interfaz de red en el contendor.

Como vemos, la dirección IP es la misma, es decir, la dirección IP asignada al POD es la que se ha asignado al contenedor encapsulado por dicho POD.

En el caso de crear un segundo contenedor, la situación que tendríamos sería muy similar a la siguiente:

Dos contenedores en PODs separados.

De forma similar a como ocurría en el primer caso, el nuevo contenedor tiene un interfaz de red virtual al que se asigna una dirección IP del mismo rango y, si los contenedores tienen alguna manera de descubrimiento, podrán comunicarse entre ellos a través del bridge.

En este segundo caso, en el momento de crear el nuevo POD, podemos comprobar que un nuevo interfaz virtual veth aparece en nuestro host físico:

 

Creación de un segundo POD.

Repitiendo el proceso anterior y creando otro contenedor, encapsulado por este nuevo POD, podemos comprobar que, de nuevo, la dirreción IP del segundo contenedor es la misma que la del POD que lo encapsula:

Configuración de red del segundo contenedor.

Si ambos contenedores necesitan comunicarse entre ellos, podemos ver que esta comunicación funciona sin problema y se realiza a través del bridge cni0.

Los interfaces virtuales veth correspondientes a cada POD, desaparecerán del host en el momento en el que destruyamos los PODs.

Por tanto, hasta aquí tenemos el caso más sencillo usando contenedores pero, como hemos comentado, en Kubernetes un POD puede contener más de un contenedor y es en ese caso cuando todos comparten la pila de red. Por tanto, la imagen anterior, quedaría del siguiente modo:

Dos contenedores encapsulados por el mismo POD.

Para crear un POD y arrancar dos contenedores dentro del mismo, haríamos algo como lo siguiente:

Creación de dos contenedores encapsulados en el mismo POD.

Podemos observar en la salida anterior que ambos contenedores forman parte del mismo POD, ya que la columna POD ID muestra el mismo identificador para ambos. Además, como esperábamos, ahora solo hay un interfaz veth presente en el host:

Interfaces de red en el host.

Al comprobar la red en cada uno de los contenedores, podemos observar que ambos contenedores comparten la misma dirección IP:

Dirección IP en un contendor.....

.... y en el otro.

Es importante recordar que, como indica la documentación de Kubernetes, lo normal es que un POD solo encapsule un contenedor, siendo el caso de varios contenedores "dentro" de un mismo POD un caso de uso avanzado y solo recomendado cuando estos tienen una fuerte dependencia entre ellos.

Pensemos que, al fin y al cabo, lo que está haciendo Kubernetes es controlar al motor de contenedores para indicarle que, al crear el nuevo contenedor dentro del POD, no se cree un nuevo interfaz virtual, sino que utilice el ya existente. Por tanto, en este caso, ambos contenedores tienen un único interfaz de red común compartiendo la pila de red. Como es lógico, esto tiene las siguientes implicaciones:

  • Con la configuración adecuada, ambos contenedores son accesibles desde el exterior.
  • Al compartir la dirección IP, no pueden abrir el mismo puerto a la vez.

Ahora, podemos comprobar que efectivamente ambos contenedores pueden comunicarse entre si usando localhost como dirección haciendo algo como lo siguiente:

Arrancamos el servidor telnet en uno de los contenedores.

Ahora, podemos conectarnos al otro contenedor, usando crictl exec y ejecutar el comando telnet 127.0.0.1 para establecer una conexión contra el otro contenedor:

Conexión telnet con localhost.

Como podemos ver, nos podemos conectar de un contenedor a otro usando directamente localhost ya que, al compartir la pila de red, las redes de ambos contenedores son la misma.

¿Como implementa esto Kubernetes? mediante la ejecución de un contenedor adicional especial cuyo único objetivo es proporcionar la red al resto de contenedores del POD. Este contenedor, que es una instancia de la imagen pause, es el corazón del POD permitiendo la comunicación por red del contenedor o contenedores que estén encapsulados por el POD, ya sea entre ellos o con el exterior del cluster.

Hasta aquí tenemos un resumen breve de como funciona la red entre PODs o contenedores que se encuentran en el mismo host, pero como sabemos, un cluster de Kubernetes estará compuesto por varios nodos y los diferentes PODs deben comunicarse entre ellos estén o no en el mismo nodo del cluster.

En la siguiente entrada, veremos como funciona la red entre PODs cuando hablamos de un cluster y, por tanto, de PODs en diferentes nodos del cluster.


viernes, 24 de mayo de 2024

Ansible - Estructura de proyectos II

Hoy vamos a profundizar más en la estructura recomendada para un proyecto de Ansible. Como ya vimos, una estructura mínima que nos permite aprovechar ciertas características, como la encriptación mediante el uso de ansible-vault, era la siguiente:

Estructura mínima de proyecto.

Resumiendo lo expuesto anteriormente y por tanto, en el caso más simple:

  • En la carpeta raíz del proyecto, tendremos al menos el fichero de configuración ansible.cfg así como el fichero de inventario.
  • En la carpeta host_vars existirá un fichero que identificará, ya sea por nombre o dirección IP, a aquellos hosts que necesiten variables específicas.
  • En la carpeta group_vars existirán ficheros identificando a los grupos de hosts definidos en el fichero de inventario, y que contendrán variables comunes a dichos grupos de hosts.

Con una estructura así, podemos lanzar comandos ad-hoc simples teniendo todas las variables necesarias controladas y localizadas facilmente. De esta manera, podemos añadir o modificar las variables necesarias de forma simple y localizada, evitando así posibles duplicidades y errores. En resumen, con esta estructura:

  • Las variables comunes a grupos de hosts deben estar presentes en el fichero correspondiente al grupo, que debe encontrarse en la carpeta group_vars.
  • Es muy importante tener en cuenta que, al poder definir variables en múltiples sitios, es necesario que las variables tengan diferentes nombres. Ansible asigna un orden a las variables en función del ámbito de las mismas, lo cual puede llevar a situaciones en las que, variables con el mismo nombre, sean aplicadas según el orden de preferencia dado por Ansible llevando a resultados incorrectos en la ejecución del comando ad-hoc o playbook. En el siguiente enlace puedes consultar el orden que sigue Ansible para el uso de variables.

Como es lógico, todo esto son solo recomendaciones y dependerá del entorno en el que trabajemos, nuestra infraestructura y necesidades que tengamos.

Por tanto, con esta estructura podemos trabajar de forma cómoda con comandos ad-hoc, pero la potencia de Ansible es la posibilidad de crear playbooks con múltiples tareas. En ese caso, la estructura cambiará ligeramente ya que incluiremos el playbook en la carpeta raíz del proyecto, y nos quedaría algo como lo siguiente:

Proyecto con un playbook.

De forma muy simple, supongamos que tenemos que realizar ciertas tareas sobre un grupo de hosts, como por ejemplo:

  • Crear un nuevo usuario en el sistema.
  • Instalar un paquete.
  • Crear un directorio y aplicarle unos permisos determinados.
  • Copiar un fichero con cierta información.

Cada una de estas tareas, podemos realizarlas empleando comandos ad-hoc, pero, para que sea más sencillo y rapido realizar todas las tareas con una sola ejecución, podemos usar un playbook simple como el siguiente:

Playbook simple.

El playbook es un documento en formato YAML en el cual, si lo analizamos detenidamente, observamos lo siguiente:

  • Al ser un documento YAML, debe empezar siempre con tres guiones y terminar con tres puntos.
  • El playbook comienza con la especificación de su nombre, si queremos obtener los facts de todos los hosts, con la opción gather_facts, así como a que hosts queremos que se aplique, especificado con la opción hosts que, en este ejemplo, esta fijada en all.
  • Usando la cabecera tasks, definimos la lista de tareas que queremos que se apliquen a aquellos hosts sobre los que queramos lanzar el playbook.
  • Cada tarea está definida a continuación, especificando el nombre, módulo necesario y los parámetros del mismo.

Es importante darse cuenta del indentado o sangrado necesario, para asegurar que la sintaxis del playbook es correcta o Ansible devolverá un error al procesarlo.

Al usar un playbook, podemos hacer uso de características adicionales de Ansible como las siguientes:

  • Podemos usar el subdirectorio files, dentro de la carpeta del proyecto, para guardar aquellos ficheros estáticos que deben copiarse a los hosts. Al usar el módulo ansible.builtin.copy, Ansible buscará por defecto los ficheros a copiar dentro de dicha carpeta, lo que nos permite tener localizados de forma simple los ficheros cuyo contenido no cambia y que necesitamos copiar. En nuestro ejemplo tenemos lo siguiente:

Copia de fichero estático.

Como podemos ver, no especificamos la ruta donde se encuentra el fichero que queremos copiar, especificado por el parámetro src. Al usar un playbook, Ansible buscará la carpeta files y, si esta existe, buscará el fichero a copiar a las máquinas de destino. Para este ejemplo simple, dicho fichero está presente en la carpeta files y se copia a todos los hosts al ejecutar esta tarea.

  • Como vimos en entradas anteriores, usaremos variables, las cuales estarán presentes en el fichero de grupo correspondiente, dentro del directorio group_vars. En este caso, tenemos fijada la password del usuario que queremos crear con la variable secadmin_pass:

Creación de una cuenta de usuario.

Contenido del fichero group_vars/all.

De esta manera, en cada ejecución de la tarea para cada host, se creará el usuario con la password dada en el fichero. Adicionalmente, por simplicidad, he definido la password de SSH así como la de sudo en el fichero, para que no sea necesario especificarlas por línea de comandos.
  • De nuevo, por simplicidad, no he utilizado ansible-vault para encriptar las contraseñas, pero una de las ventajas de emplear esta estructura de directorios con un playbook es que podemos encriptar dicha información para protegerla, como ya vimos en una entrada anterior.

Por tanto, como vemos, emplear la estructura de directorios recomendada, simplifica un poco la creación de proyectos de Ansible y nos permite utilizar características como la encriptación mediante ansible-vault, copia de ficheros sin necesidad de especificar la ruta completa al fichero origen, etc...

En el caso de emplear templates, ficheros cuyo contenido cambia en función de una variable, o del resultado de la ejecución de una tarea anterior, añadiriamos la carpeta templates al directorio de proyecto, con lo que la estructura del mismo quedaría del siguiente modo:

Proyecto incluyendo la carpeta templates.

Mediante el uso de templates, Ansible puede copiar un fichero y al mismo tiempo modificar el contenido del mismo, mediante el uso de una o más variables. Veamoslo con un ejemplo práctico:

Uso de templates.

Como podemos ver, empleando el módulo ansible.builtin.setup, recogemos los facts de los hosts, los cuales quedan registrados en variables identificadas como ansible_ y posteriormente, en la siguiente tarea, usando el módulo ansible.builtin.template, copiamos el fichero especificado con el parámetro src. Este parámetro, que identifica el fichero que queremos que se copie, no incluye la ruta completa del mismo ya que se encuentra dentro de la carpeta templates del proyecto. El contenido de este fichero de plantilla puede ser algo como lo siguiente:
 
Contenido de un fichero de plantilla.

Cuando usamos el módulo template, Ansible copia el fichero a la ruta indicada por el parámetro dest y modifica el contenido del mismo, sustituyendo aquellas cadenas delimitadas con {{ }} por las variables que coincidan durante la ejecución del playbook con el nombre de las mismas. Por tanto, en este caso, al ejecutar el playbook, Ansible obtendrá los facts y registrará las variables correspondientes y, al ejecutar la tarea de copia usando el módulo template, realizará todas las sustituciones por las variables correspondientes.

Al ejecutar el playbook sobre un conjunto de contenedores, tenemos lo siguiente:

Ejecución de playbook simple.

Como podemos comprobar, cada una de las tareas se ha ejecutado en cada uno de los hosts gestionados y todas se han ejecutado correctamente. Analizando la salida vemos que, salvo la tarea Registrar facts de red, el estado del resto de tareas es changed. Cuando el resultado de una tarea es changed, Ansible nos indica que el sistema se ha cambiado, según las instrucciones descritas por la tarea. Sin embargo, cuando el resultado es ok, Ansible indica que el sistema ya se encontraba en el estado descrito por la tarea o, como en este caso, que se han recogido los datos correctamente.
 
Podemos comprobar que, efectivamente, el fichero que hemos utilizado como plantilla con el módulo template, se ha copiado y su contenido se ha modificado correctamente:
 
Resultado del uso de templates.

Para cada uno de los contenedores, se ha sustituido la cadena {{ ansible_hostname }} por el nombre del contenedor y la cadena {{ ansible_kernel }} por la versión de kernel, ambas obtenidas en la tarea anterior, en la que recogimos los facts de todos los hosts.

Como veremos más adelante, podríamos utilizar este proyecto de Ansible en un proyecto mayor encapsulándolo en lo que se denomina un rol. De forma muy simple, podemos entender un rol como el conjunto de tareas, junto con las variables, ficheros y plantillas necesarias, que describen el estado de un grupo de hosts. Esta idea, nos permitirá definir proyectos complejos que apliquen a muchos hosts, agrupados de tal manera que cada rol aplique solamente a los grupos de hosts que definamos, como servidores de aplicaciones, servidores web, firewalls, máquinas virtuales, etc. En esos casos, al ejecutar el playbook, podremos aplicar los roles correspondientes a cada grupo de forma eficiente en cada ejecución.

En próximas entradas analizaremos más detenidamente el concepto de rol para, más adelante, realizar un proyecto de configuración de hosts y emplear todo lo que hemos aprendido hasta ahora.

 

sábado, 2 de marzo de 2024

Ansible - Uso de vault y encriptación de ficheros

Como en cualquier infraestructura informática, la seguridad es fundamental. Pero si además hablamos de herramientas tan potentes como Ansible, es importante que protejamos los datos que estamos usando todo lo posible. 

Ya vimos como utilizar claves SSH para la comunicación entre nuestro nodo central de Ansible y los hosts bajo su control. Además de ser la opción recomendada por Ansible, nos permite evitar el uso de contraseñas para establecer la conexión.

Hoy vamos a estudiar la herramienta de la que dispone Ansible para realizar tareas de encriptación de nuestros datos.

En una entrda anterior vimos como definir la password utilizada para el escalado de privilegios, es decir para el comando sudo en los hosts remotos. Vimos que la password la definimos en el fichero group_vars/all del siguinte modo:

Password de sudo como variable.

Como es lógico esta password está en claro en dicho fichero, lo cual requiere que nos aseguremos que tiene los permisos correctos y que solo las personas autorizadas tengan acceso al mismo.

Para conseguir más seguridad, podemos usar vault para encriptar dicha password. El uso de vault para encriptar una cadena de caracteres es tan sencillo como lo siguiente:

Encriptación de una cadena de caracteres con ansible-vault.

Con esta cadena de caracteres, podemos editar el fichero group_vars/all y sustituir la password en claro por la salida del comando ansible-vault:

Password encriptada en fichero group_vars/all.

Al encriptar la cadena, Ansible nos ha solicitado una password de encriptación con lo que, a la hora de lanzar un playbook o un comando ad-hoc será necesario que proporcionemos dicha contraseña para desencriptar la variable correspondiente. Para esto usaremos la opción --ask-vault-pass como podemos ver en el siguiente comando:

Uso de variable encriptada.

Para evitar tener que introducir la password manualmente, en caso de ser necesario automatizar alguna tarea, podemos guardar la password empleada para encriptar la password en un fichero y lanzar el comando ad-hoc anterior del siguiente modo:

Comando ad-hoc especificando fichero con password de Vault.

Especificando la opción --vault-password-file y la ruta al fichero que contiene la password, ansible es capaz de desencriptar la password empleada parael escalado de privilegios. Como es lógico, este fichero deberá tener los permisos correctos para proteger la contraseña usada para el desencriptado.

El comando ansible-vault también nos permite encriptar ficheros completos, con lo que podemos, por ejemplo, hacer lo siguiente:

Encriptación de un fichero completo.

Ahora, suponiendo que necesitamos instalar el paquete de base de datos en ciertos hosts, podremos lanzar el comando ad-hoc siguiente, especificando el fichero que contiene la password empleada para encriptar el fichero:

Instalación de paquete. Especificamos la variable y el fichero con la password de encriptación.

Podemos desencriptar el fichero con el subcomando decrypt del siguiente modo:

Desencriptación de fichero.

Por sencillez, lo lógico sería utilizar la misma password de encriptación en todos los casos, pero si nos encontramos en un entorno con unos requisitos de seguridad muy estrictos, o si somos muy paranoicos, podemos usar diferentes passwords para diferentes vaults. Para esto asignamos lo que se denomina vault IDs en el momento de realizar la encriptación. Por ejemplo, si al encriptar el fichero anterior, hacemos lo siguiente:

Usando un identificador al encriptar un fichero.

Con el comando anterior, encriptamos el fichero dbservers de group_vars, y lo identificamos con el vault ID dbserver. Al emplear la opción dbserver@prompt, estamos indicando que se solicite la password para la encriptación. Una vez guardada esta password, que es diferente a la que hemos empleado para encriptar la password de sudo, podemos desinstalar el paquete especificado por la variable db_package, usando un comando ad-hoc como el siguiente:

Usando varias vaults con diferentes passwords de encriptación.

En este caso, tenemos encriptada la password de sudo y el fichero group_vars/dbservers así que, para poder usar la información encriptada correctamente:

  • Mediante la opción --vault-password-file vault_pass.info, especifico el fichero que contiene la password para desencriptar la variable que contiene la contraseña necesaria para sudo.
  • Mediante la opción --vault-id dbserver@another_vault_pass.info, especifico que, para el vault identificado con la cadena dbserver, la password debe buscarse en el fichero another_vault_pass_info.

El identificador se almacena con la cadena o fichero encriptado en el momento de crearlo, en la cabecera del mismo:

Cabecera de vault con ID.

En general, sino especificamos un ID en el momento de realizar una encriptación con ansible-vault, la cadena o fichero encriptado no tendrá ningún identificador asociado pero, si necesitamos emplear diferentes contraseñas, entonces lo mejor es emplear IDs para cada una y tenerlas bien registradas.

Con todo esto, tenemos una idea de como proteger la información sensible, como por ejemplo passwords, en los ficheros de proyecto de Ansible.