Terraform: Estructura de Ficheros, Estado Remoto y Autenticación en Azure

Article available only in Spanish

1. Introducción

En este artículo se describen buenas prácticas relativas a aspectos comunes muy importantes a tener muy en cuenta, a resolver de forma eficiente y a poner en funcionamiento desde nuestros primeros pasos con Terraform. The sooner, the better. Los puntos que este artículo cubre son los siguientes:

2. Consideraciones Generales

Este artículo muestra estructuras mínimas recomendadas a criterio personal pero contrastadas mayoritariamente con otras fuentes de información. Por tanto, es un buen enfoque para empezar a trabajar con Terraform. Es evidente que conforme los proyectos se hacen más complejos y según la casuística particular, las soluciones concretas a adoptar serán diferentes. Incluso podría decir que no hay una solución óptima para todo, así que al final cada equipo de trabajo deberá decidir aquella que mejor se adapta a su entorno. En todo caso, existen buenas prácticas comunes que pueden ser aplicadas a cualquier proyecto y esas son precisamente las que este artículo pretende explicar.

En posteriores posts se tratará de complementar las ya disponibles soluciones a proyectos con diferentes entornos de trabajo (dev, QA, stage, prod, etc.) tanto desde el punto de vista de almacenamiento separado del estado vía uso de diferentes ficheros de configuración o vía diferentes workspaces haciendo uso de los mismos ficheros de configuración. En este post no se abordará ninguno de ellos ya que cae fuera de su alcance.

3. Estructura Básica de Ficheros y Módulos

La estructura mínima recomendada que debería contener cada uno de nuestros módulos raíz de Terraform para proyectos de tamaño pequeño o mediano debería ser similar a la mostrada a continuación:

README.MD: fichero que contiene la descripción del módulo y una serie de instrucciones acerca de para qué y cómo debería ser usado.

main.tf: fichero principal con la configuración HCL de la infraestructura a crear en la plataforma destino.

variables.tf: fichero que contiene la declaración de variables de entrada. Es buena práctica incluirlo pese a que pueda estar vacío. Todas las variables declaradas deberían tener contenido en el atributo description. Por ejemplo:

variable "azure_new_resource_group_tags" {
  type        = map(string)
  default     = {}
  description = "Tags to categorize new resource group and other resources within it"
}

 

outputs.tf: fichero que contiene la declaración de variables de salida. Es buena práctica incluirlo pese a que pueda estar vacío. Todas las variables declaradas deberían tener contenido en el atributo description. Por ejemplo:

output "publicips" {
  value       = azurerm_public_ip.publicip.*.ip_address
  description = "Returns public IPs associated to each VM in the cluster"
}

 

terraform.tfvars: fichero con la asignación de valores a variables declaradas en el fichero variables.tf. Terraform carga este fichero de forma automática cuando ejecuta los comandos plan o apply. Terraform también carga de igual modo ficheros cuyo nombre coincide con terraform.tfvars.json o que acaban en .auto.tfvars o .auto.tfvars.json. Ejemplo de asignación de variable:

azure_new_resource_group_tags = {
  "Topic"       = "Innovation"
  "Category"    = "Terraform"
  "Subcategory" = "Learning"
  "Subject"     = "Virtual Machines"
}
 
Otra opción recomendada sería declarar un fichero con extensión .tfvars pero con nombre personalizado. Si decidimos utilizar esta opción podemos referenciar este fichero mediante el uso de parámetros en el CLI de Terraform:
 
terraform apply -var-file="NOMBRE_FICHERO.tfvars"
 
Respecto a los ficheros .tfvars, también puedes no hacer uso de ellos y simplemente proporcionar valores por defecto en los ficheros variables.tf o crear las asignaciones en los ficheros *.tf. En los ejemplos disponibles de Terraform verás muchos proyectos con esta opción. Así pues, el uso de estos ficheros es una cuestión a decidir según cada equipo de trabajo.
 
En caso de utilizar varios módulos pertenecientes al mismo repositorio (módulos internos), la estructura mínima recomendada sería similar a la siguiente:
 
 
Los módulos hijos (module_A, module_B, etc.) deberían estar contenidos en un directorio con nombre modules o similar y cada uno de ellos debería contener a su vez la estructura mínima de ficheros recomendada tal y como se ve en la imagen anterior. En los módulos sería suficiente el uso de los ficheros variables.tf con asignaciones de valores por defecto sin especificar ficheros de tipo *.tfvars, si bien podrías utilizar este tipo de ficheros *.tfvars para asignar valores de variables que el módulo hijo espera no sean inyectadas por el módulo padre.
 
La asignación de variables de entrada de cada módulo hijo (variable1_in_module_A y variable1_in_module_B en la imagen de abajo) sería establecida en el código del módulo padre. Para llamar a un módulo hijo tan sólo hay que añadir en los ficheros .tf del módulo padre un código similar al mostrado a continuación:
 
module "module_A" {
  source = "./modules/module_A"
  ...
variable1_in_module_A = "XXXX" }

module "module_B" {
source = "./modules/module_B"
...
variable1_in_module_B = "YYYY"
}

Cuando el proyecto crece y debe contener muchos módulos se recomienda usar Module Composition, es decir, crear una estructura de módulos lo más plana posible con pocos níveles de jerarquía de tal modo que los valores de sus variables de entrada o dependencias puedan ser inyectados por el módulo raíz o módulos más altos en la jerarquía. De este modo, es posible combinar diferentes módulos para crear estructuras personalizadas más complejas. Esto es precisamente lo que hemos hecho en el ejemplo de más arriba.

Crear árboles de directorios con jerarquías de módulos muy profundas puede hacer dificil su mantenibilidad y reusabilidad, más teniendo en cuenta que cada módulo no recibe sus dependencias sino que las contiene en sí mismo.

Permíteme rescatar este párrafo de la documentación oficial de Terraform que explica perfectamente el concepto:

We call this flat style of module usage module composition, because it takes multiple composable building-block modules and assembles them together to produce a larger system. Instead of a module embedding its dependencies, creating and managing its own copy, the module receives its dependencies from the root module, which can therefore connect the same modules in different ways to produce different results.
Hasta aquí únicamente hemos hablado de módulos internos, es decir módulos que existen dentro de nuestro repositorio. No obstante, también podemos hablar y son tremendamente importantes los módulos externos, esto es, aquellos que referenciamos desde nuestros ficheros de Terraform para ser descargados desde Terraform Registry u otras fuentes externas. Para módulos externos es muy importante determinar si es preciso no solo referenciar la fuente sino también la versión del módulo. El versionado de módulos es un aspecto muy importante a considerar cuando se trabaja en proyectos con diferentes entornos (dev, QA, stage, prod, etc.) y es necesario modificar el contenido de módulos referenciados sin modificar el comportamiento en todos los entornos. Será habitual la necesidad de modificar el comportamiento de módulos en desarrollo (dev) pero que estos cambios no afecten a producción (prod) hasta estar completamente testeados en los entornos de QA, stage, etc. EL versionado de módulos resuelve este problema, cada entorno referencia a diferentes versiones del mismo módulo. Puedes echar un vistazo a Module Sources en la documentación oficial de Terraform para obtener detalles al respecto. Un ejemplo de código que referencia al módulo azure/network/azurerm con versión 3.1.1 de Terraform Registry sería:
 
module "network" {
  source              = "Azure/network/azurerm"
  version             = "3.1.1"
  resource_group_name = "jm-rg"
  vnet_name           = "jm-vnet"
}
 

Fichero .GitIgnore

La situación más normal cuando trabajamos con Terraform será la de usar alguna herramienta de control de código fuente. En Github Terraform .gitignore tienes un ejemplo de cómo puedes configurar tu fichero .gitignore para evitar que determinados ficheros innecesarios a nivel de código o con información sensible sean almacenados en nuestros repositorios:

# Exclude local .terraform directories holding plugins
**/.terraform/*

# Exclude .tfstate files
*.tfstate
*.tfstate.*

# Exclude Crash log files
crash.log

# Exclude all .tfvars files, which are likely to contain sentitive data, such as
# password, private keys, and other secrets. These should not be part of version
# control as they are data points which are potentially sensitive and subject
# to change depending on the environment.
#
*.tfvars

# Ignore override files as they are usually used to override resources locally and so
# are not checked in
override.tf
override.tf.json
*_override.tf
*_override.tf.json

# Include override files you do wish to add to version control using negated pattern
# !example_override.tf

# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
# example: *tfplan*

# Ignore CLI configuration files
.terraformrc
terraform.rc

Según la casuística particular de cada proyecto se puede personalizar el fichero anterior pero sin duda es un buen punto de partida. Ten en cuenta la siguiente información adicional:

- Respecto a la exclusión de ficheros *.tfvars, ten presente que si dichos ficheros no contienen información de claves (y así debería ser como opción recomendada) no deberías excluirlos en el control de código fuente.

- Respecto a la exclusión de información en el directorio local .terraform ten en cuenta que Terraform descarga y almacena en dicho directorio información acerca de plugins, módulos locales, módulos procedentes de Terraform Registry u otras fuentes externas, ficheros de estado local, etc. Puedes ver un ejemplo de la estructura interna del directorio .terraform en la siguiente imagen:

En mi proyecto cluster-with-hcl se está haciendo referencia a los módulos terraform-azurerm-network-3.1.1 y terraform-azurerm-compute-3.5.0 (módulos de Terraform Registry para crear infraestructura de red y máquinas virtuales respectivamente) y al plugin terraform-provider-azurerm_v2.23.0_x5 (provider de Azure) No tiene sentido almacenar esta información en los repositorios de código. Por último, como información adicional, tan sólo mencionar que estas descargas junto con la inicialización del backend son gestionadas por Terraform mediante el uso del comando Terraform init. Very cool.

4. Almacenamiento Remoto del Estado de Terraform

Terraform utiliza un Fichero de Configuración de Estado para almacenar información o metadatos sobre la infraestructura anteriormente desplegada en cloud de modo que la sincronización de cambios de infraestructura respecto a los cambios de código en los ficheros de Terraform con código HCL (HashiCorp Configuration Language) puedan efectuarse correctamente.

En caso de trabajar en modo colaborativo es buena práctica almacenar este fichero de configuración de estado de forma remota y centralizada, por ejemplo en un container de Azure creado desde una Storage Account:

  • La información de estado se guarda en un fichero plano de texto y puede contener datos sensibles de claves, secretos o passwords. Es muy importante utilizar un sistema que guarde esta información de forma cifrada y que limite el acceso a usuarios que puedan acceder al sistema. La solución elegida, blobs de Azure Containers en una Storage Account cumple perfectamente estas condiciones.
  • El hecho de guardar la información de estado de forma centralizada nos permitirá desplegar infraestructura desde múltiples equipos o desde diferentes interfaces de forma sincronizada, por ejemplo desde pipelines de Azure DevOpsAzure Cloud Shell o máquinas locales.
  • Otro aspecto importante es que Terraform con Azure Containers gestiona correctamente la concurrencia de múltiples usuarios intentando desplegar infraestructura al mismo tiempo. Cuando un usuario está desplegando infraestructura el blob donde se guarda el estado queda en estado bloqueado (leased) para otros usuarios y solo cuando el usuario inicial lo libera (available) el resto puede llevar a cabo sus cambios. Esto asegura la consistencia del proceso.

A continuación se muestran los pasos básicos para crear el Azure Container para almacenar la información de estado de Terraform:

1. Si no dispones todavía de una Storage Account en Azure estos son los Pasos a seguir para crear una Cuenta de Almacenamiento.

2. En el portal de Azure, tras seleccionar la Storage Account, acceder a la opción de menú Blob service -> Containers.

3. Pulsar en Container para añadir un nuevo container.

4. Completar los datos solicitados, nombre del container (referenciado como container_name en Terraform) y nivel de acceso:

5. Una vez creado el container, accedemos a la sección de access keys desde la cuenta de almacenamiento para obtener las claves de acceso:

Pulsando en el botón Show keys aparecen las claves de acceso key1 y key2. Ambas son válidas para acceder a la Storage Account. En la imagen posterior se muestra la key1 en modo hidden:

Esta información de claves será usada desde los ficheros de configuración de Terraform (sección backend) para gestionar de forma remota la información de estado de creación de recursos e infraestructura en Azure. El nombre del blob usado como ejemplo en este post es terraform-intro.tfstate (referenciado como key en Terraform).

6. El blob almacenado en el container de Azure será utilizado en modo colaborativo de manera que debe bloquear el acceso a otros usuarios cuando se está utilizando de forma previa. Este proceso es realizado automáticamente por Terraform en Azure por medio de la información de Lease State:

Si el fichero está disponible, el campo Lease State tendrá el valor Available. Si el fichero está siendo modificado mostrará el valor Leased. Si la liberación del fichero falla por algún motivo podemos liberar el lease de forma manual mediante la opción de menú Break Lease. Simplemente tener en cuenta esta información para saber de antemano los motivos de posibles errores cuando se ejecutan comandos de Terraform como plan o apply. Si el contenedor de estado no está disponible Terraform mostrará un error y abortará el comando a ejecutar.

Para referenciar el backend desde los ficheros main.tf se puede utilizar un código similar al mostrado a continuación:

# Reference to container holding the Terraform state
terraform {
  backend "azurerm" { 
    resource_group_name  = "jm-inn-core-rg"
    storage_account_name = "jminnstgacc"
    container_name       = "terraform"
    key                  = "terraform-intro.tfstate"
 
    # It is a best practice not include this key in plain text here.
    # It should be set via environment variable ARM_ACCESS_KEY
    # access_key = "YOUR_ACCESS_KEY" 
  }
}

resource_group_name: Obligatorio. Nombre del grupo de recursos que contiene la Storage Account.
 
storage_account_name: Obligatorio. Nombre de la Storage Account.
 
container_name: Obligatorio. Nombre del container en la Storage Account.
 
key: Obligatorio. Nombre del blob utilizado para almacenar el fichero de configuración de estado de Terraform en el container.
 
access_key: Opcional. La clave de acceso a la Storage Account. Puede ser asignada mediante la variable de entorno ARM_ACCESS_KEY. En este caso y con objeto de que el fichero no almacene esta información tan sensible, la asignación de la clave se hará vía variables de entorno. En el fichero puedes ver que este atributo está comentado. 

5. Autenticación en Azure vía Service Principal

Cuando se ejecuta Terraform, la creación de recursos en Azure (o cualquier otro provider) debe realizarse en el contexto de seguridad de una cuenta de usuario o "de sistema" con los permisos necesarios. Es por ello que antes de ejecutar los comandos plan o apply es necesario autenticarnos en Azure con una cuenta válida en Azure. Esta acción normalmente la hacemos con nuestra propia cuenta de usuario vía Azure CLI con el comando az login. Una vez autenticados, Terraform se ejecuta con los permisos de nuestra cuenta. Esto es perfectamente válido pero no es la mejor opción si nuestro objetivo es llegar a automatizar los despliegues de infraestructura mediante, por ejemplo, pipelines de Azure DevOps.

Cuando se utiliza Terraform es buena práctica crear un Service Principal específico en Azure con el rol contributor a nivel de suscripción como vía de autenticación y delegación de permisos. La asignación del rol contributor a nivel de suscripción permitirá crear toda la infraestructura necesaria en el ámbito de dicha suscripción. No son necesarios permisos adicionales. Una vez referenciado el nuevo Service Principal desde los ficheros de Terraform no será necesario autenticarse con nuestro usuario en Azure.

A continuación se explica cómo crear un Service Principal desde Azure Cloud Shell:

1. Abrir una sesión de Azure Cloud Shell y autenticarse con az login.

2. En caso de tener varias suscripciones de Azure activas, seleccionar la suscripción sobre la que vamos a crear nuestros nuevos recursos:

az account list --output table

az account set --subscription "YOUR_SUBSCRIPTION_ID"

3. Puedes verificar la suscripción seleccionada con:

az account show

4. Crear el Service Principal con permisos de contributor a nivel de suscripción:

az ad sp create-for-rbac --name "YOUR_SERVICE_PRINCIPAL_NAME" --role="Contributor" --scopes="/subscriptions/YOUR_SUBSCRIPTION_ID"

Si todo funciona de forma correcta se generará un service principal con el nombre asignado precedido de http://, un appId de tipo guid (será referenciado como client_id en Terraform) y un password por defecto (será referenciado como client_secret en Terraform) en el tenant o directorio (referenciado como tenant_id en Terraform) asociado a nuestra suscripción.

5. Si lo deseas, puedes modificar el password creado por defecto con el siguiente comando:

az ad sp credential reset --name "YOUR_SERVICE_PRINCIPAL_NAME" --password "YOUR_NEW_PASSWORD"

El password no queda almacenado en Azure en formato plano así que es importante recordarlo. No obstante, siempre se puede resetear en caso de olvidarlo con el comando que acabamos de utilizar.

6. Puedes verificar la creación del service principal junto con la asignación del rol contributor desde el portal de Azure accediendo a Subscription -> Access control (IAM) -> Role Assignments:

Ahora, sólo nos queda referenciar el service principal desde los ficheros de configuración de Terraform. Vayamos directos a cómo hacerlo con el siguiente fragmento de código de ejemplo de configuración del proveedor de Azure:

provider "azurerm" {  
  # Versiones no beta >= 2.5.0 y < 3.0.0
  version = "~>2.5"
 
  # Configura el Service Principal
  subscription_id = "YOUR_SUBSCRIPTION_ID"
  tenant_id       = "YOUR_TENANT_ID"
  client_id       = "YOUR_SERVICE_PRINCIPAL_CLIENT_ID"
  client_secret   = "YOUR_SERVICE_PRINCIPAL_CLIENT_SECRET" # No usar esta línea
 
  # Requerido. Configura propiedades de algunos recursos en Azure.
  # Dejarlo vacío, en caso contrario.
  features {}
}

 

subscription_id: Identificador de la suscripción de Azure utilizada para desplegar recursos. Si no es asignada se utilizará la suscripción por defecto configurada en Azure. Puede ser asignada de forma externa mediante la variable de entorno ARM_SUBSCRIPTION_ID. Terraform carga esta variable y la asigna de forma automática.
 
tenant_id: Tenant asociado a la suscripción. Se puede asignar mediante la variable de entorno ARM_TENANT_ID.
 
client_id: Identificador asociado al service principal (appId). Puede ser asignado de forma externa mediante la creación de la variable de entorno ARM_CLIENT_ID.
 
client_secret: Password asociado al service principal. En este caso, por motivos de seguridad, NO SE DEBE asignar de forma directa en el código de configuración de Terraform sino de forma externa mediante la variable de entorno ARM_CLIENT_SECRET. Así pues SE DEBE omitir la línea de código client_secret   = "YOUR_SERVICE_PRINCIPAL_CLIENT_SECRET". Terraform asignará de forma automática el valor de la variable de entorno ARM_CLIENT_SECRET al atributo client_secret a pesar de no estar presente en el fichero de configuración.
 

6. Uso de la Documentación

Otro factor muy importante a la hora de configurar de forma óptima los proyectos de Terraform, sobre todo cuando empezamos a trabajar con la plataforma, es el buen uso de la documentación. Sólo adquiriendo el suficiente conocimiento seremos capaces de crear estructuras de código de configuración HCL óptimas y esto basicamente se consigue por medio de un buen uso de la documentación y de la propia experiencia. Saber donde buscar la documentación adecuada y cómo hacer uso de ella lo considero básico en el arranque del aprendizaje de cualquier nueva plataforma y Terraform no es ninguna excepción. A continuación se presenta una lista con puntos interesantes a tener en cuenta:

- Documentación oficial de Terraform.

- Terraform ofrece una serie de enlaces para aprender lo básico de forma práctica acerca de la plataforma en Learn Terraform, muy útil para empezar a trabajar con cada proveedor o aplicar buenas prácticas desde el inicio.

- Cada proveedor y módulo integrado de Terraform tiene su propia documentación que podemos visualizar en Terraform Registry. Es muy importante filtrar inicialmente por la versión del proveedor o módulo con el que estamos trabajando.

Incluida en el registro puedes acceder a la documentación del proveedor o módulos integrados disponibles para Azure (azurerm).

- El uso de módulos acelera la creación de infraestructura común en la plataforma destino. No obstante, recomiendo leer muy bien los valores asignados por defecto a la infraestructura a crear más alla de aquellos que podemos parametrizar mediante los parámetros de entrada. Una vez revisada la documentación y creada la infraestructura en la plataforma destino, revisar los recursos generados y ser consciente de todo lo que cada módulo está creando "por debajo" para ser capaces de personalizarlo si fuera necesario y por supuesto probado/verificado en entornos de desarrollo o testing previos a los entornos de producción.

7. Referencias

 

Add comment