DevOps, CI/CD y contenedores

Infraestructura como código: Terraform, Ansible e infraestructura inmutable

Avanzado13 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

La infraestructura como código convierte servidores, redes y cuentas cloud en archivos que se revisan, se prueban y se revierten igual que el código de una aplicación. Esta guía separa el aprovisionamiento de la gestión de configuración, recorre el modelo de state de Terraform y el diseño de módulos, muestra la parte de Ansible y cubre las realidades operativas: drift, bloqueo, secretos y la ejecución de plan y apply desde un pipeline en lugar de desde un portátil.

Lo que aprenderás

  • Distinguir el aprovisionamiento declarativo de la gestión de configuración imperativa y explicar la idempotencia
  • Escribir configuración de Terraform con providers, resources, variables, outputs y módulos
  • Explicar qué es el state, por qué se bloquea y cómo lo protegen los backends remotos
  • Escribir un playbook de Ansible idempotente y saber cuándo sigue teniendo sentido la gestión de configuración
  • Detectar y gestionar el drift, y mantener los secretos fuera del código y del state
  • Ejecutar plan en cada pull request y apply al hacer merge, con comprobaciones de políticas en el pipeline

Toda infraestructura acaba llegando al punto en que nadie sabe decir con exactitud qué está en marcha. Alguien añadió una regla de firewall durante un incidente, alguien parcheó un servidor a mano, alguien subió el timeout de un balanceador para una demo hace tres años. Reconstruir el entorno se convierte en un proyecto de investigación, y staging se separó de producción hace tanto tiempo que probar allí demuestra muy poco.

La infraestructura como código es la disciplina que lo evita: la infraestructura se describe en archivos, los archivos viven en control de versiones y los cambios llegan al entorno aplicando esos archivos, no haciendo clic ni entrando en las máquinas. Lo que ganas no es solo automatización. Ganas revisión, historial, la posibilidad de comparar dos entornos y la de reconstruirlo todo desde cero, que es lo que convierte «se nos ha quemado el centro de datos» de una amenaza existencial en un día largo.

Dos trabajos, dos tipos de herramienta

El término abarca dos actividades relacionadas que conviene no confundir, porque sale caro.

El aprovisionamiento crea y destruye recursos: redes virtuales, subredes, security groups, máquinas virtuales, bases de datos gestionadas, clústeres de Kubernetes, registros DNS, roles de IAM. Los recursos no existen hasta que la herramienta los crea. Aquí viven Terraform y su fork OpenTofu, junto con herramientas nativas de cada cloud como CloudFormation, Bicep y Pulumi.

La gestión de configuración parte de una máquina existente y hace que su contenido coincida con una descripción: paquetes instalados, archivos escritos, servicios habilitados, usuarios creados. Aquí viven Ansible, Chef, Puppet y Salt.

También cambia la forma de trabajar. Las herramientas de aprovisionamiento son declarativas y guardan un registro persistente de lo que les pertenece, así que pueden calcular la diferencia entre tu descripción y la realidad, y destruir lo que hayas quitado del archivo. Los gestores de configuración suelen ser procedimentales pero idempotentes: un playbook es una lista ordenada de tareas, cada una escrita para que ejecutarla sobre una máquina que ya está bien no cambie nada.

La idempotencia es la propiedad que hace que todo esto sea seguro. Aplicar la misma configuración dos veces produce el mismo resultado que aplicarla una, y por eso una ejecución fallida se puede simplemente repetir y la herramienta puede ejecutarse de forma programada para corregir el drift. Los scripts de shell son el contraejemplo: useradd deploy funciona la primera vez y falla a partir de ahí, así que cada script va acumulando condicionales hasta convertirse en una reimplementación poco fiable de un gestor de configuración.

Terraform: el modelo

Terraform lee archivos .tf escritos en HCL, se comunica con providers (plugins para AWS, Azure, Google Cloud, Kubernetes, GitHub, PostgreSQL y cientos más) y mantiene un state que asocia los recursos de tu configuración con los objetos reales que ha creado.

terraform {
  required_version = "~> 1.9"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.60"
    }
  }
}

provider "aws" {
  region = var.region
  default_tags {
    tags = {
      Environment = var.environment
      ManagedBy   = "terraform"
      Repository  = "acme/infrastructure"
    }
  }
}

variable "environment" {
  description = "Deployment environment name"
  type        = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be dev, staging or prod."
  }
}

variable "region" {
  type    = string
  default = "eu-west-3"
}

resource "aws_vpc" "main" {
  cidr_block           = "10.20.0.0/16"
  enable_dns_hostnames = true
  tags = { Name = "${var.environment}-vpc" }
}

resource "aws_subnet" "private" {
  for_each          = { for i, az in local.azs : az => cidrsubnet(aws_vpc.main.cidr_block, 4, i) }
  vpc_id            = aws_vpc.main.id
  availability_zone = each.key
  cidr_block        = each.value
  tags              = { Name = "${var.environment}-private-${each.key}" }
}

locals {
  azs = ["eu-west-3a", "eu-west-3b", "eu-west-3c"]
}

output "vpc_id" {
  value = aws_vpc.main.id
}

Aquí hay dos hábitos que conviene adoptar pronto. default_tags en el provider etiqueta todo lo que crea Terraform, lo que más adelante hace posible imputar costes y localizar recursos huérfanos. Y for_each sobre un map, en lugar de count sobre un número, da a cada subred una dirección estable indexada por zona de disponibilidad; con count, quitar el elemento del medio renumera todos los siguientes y Terraform propone destruir recursos que no deberían haberse tocado. Fija también las versiones de Terraform y de los providers, y haz commit de .terraform.lock.hcl para que todas las máquinas resuelvan los mismos builds de los providers.

El flujo de trabajo

terraform init          # download providers and configure the backend
terraform fmt -recursive
terraform validate      # syntax and internal consistency
terraform plan -out=tfplan
terraform apply tfplan
terraform destroy       # tear the environment down

El paso importante es el plan, y la habilidad importante es saber leerlo. Un plan enumera cada acción que Terraform pretende realizar: crear, actualizar in situ, destruir o el temido «must be replaced». Reemplazar significa destruir y luego crear, y en una base de datos eso es una pérdida de datos anunciada en una línea de salida que pasa muy deprisa.

  # aws_db_instance.main must be replaced
-/+ resource "aws_db_instance" "main" {
      ~ engine_version = "16.3" -> "17.2" # forces replacement
      ~ id             = "shop-prod-db" -> (known after apply)
    }

Aplica siempre un archivo de plan guardado en lugar de ejecutar terraform apply a secas. Sin plan, apply vuelve a planificar y pide confirmación, y lo que aplica puede no coincidir con lo que revisaste si el mundo cambió entretanto.

Un plan sin cambios debería mostrar «No changes». Si cada ejecución propone en cambio actualizar el mismo recurso, tienes un bug en el provider o un valor que el cloud normaliza de forma distinta a como lo escribiste. Persíguelo: si el plan nunca sale limpio, un plan limpio deja de servirte como señal.

El state, y por qué necesita un backend

El state es un documento JSON que asocia cada dirección de recurso de tu configuración (aws_vpc.main) con el identificador y los atributos del recurso real. Terraform lo necesita para saber qué ha creado, detectar el drift y calcular dependencias.

Un archivo de state local vale para un tutorial y es inviable en un equipo: no se comparte, no se puede bloquear, contiene secretos y, si se pierde, Terraform olvida todo lo que le pertenece. Un backend remoto resuelve los cuatro problemas.

terraform {
  backend "s3" {
    bucket       = "acme-terraform-state"
    key          = "prod/network/terraform.tfstate"
    region       = "eu-west-3"
    encrypt      = true
    use_lockfile = true          # native S3 locking, Terraform 1.10+
  }
}

Las configuraciones más antiguas usan una tabla de DynamoDB para el bloqueo; los backends de Azure y Google Cloud bloquean mediante sus propias primitivas de almacenamiento, y Terraform Cloud, Scalr y Spacelift ofrecen state gestionado con bloqueo e historial de ejecuciones. Uses lo que uses, activa el versionado del bucket para poder revertir un state corrupto.

De aquí se derivan dos consecuencias. La primera: el state es sensible. Las contraseñas, claves y certificados que generan los recursos se guardan en él en texto plano. Cifra el backend, restringe quién puede leer el bucket y no subas nunca el state a Git. La segunda: el state hay que dividirlo. Un único state gigantesco para toda una organización implica que cada cambio se planifica contra todo, que un apply tarda veinte minutos y que un solo recurso roto bloquea trabajo que no tiene nada que ver. Divide por ciclo de vida y radio de impacto (red, almacenes de datos, clúster, aplicaciones) y lee a través de esas fronteras con data sources o con los outputs del remote state.

data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket = "acme-terraform-state"
    key    = "prod/network/terraform.tfstate"
    region = "eu-west-3"
  }
}

resource "aws_instance" "app" {
  subnet_id = data.terraform_remote_state.network.outputs.private_subnet_ids[0]
  # ...
}

Cuando tengas que intervenir en el state (un recurso renombrado, un recurso creado a mano, un recurso que ya no debe gestionarse), usa los comandos específicos en lugar de editar el archivo: terraform state mv, terraform state rm e import. Desde Terraform 1.5, los bloques moved e import permiten expresar esas operaciones de forma declarativa en la configuración, de modo que pasan por revisión como todo lo demás:

moved {
  from = aws_instance.web
  to   = aws_instance.frontend
}

import {
  to = aws_s3_bucket.legacy_assets
  id = "acme-legacy-assets"
}

Módulos

Un módulo es un directorio de archivos de Terraform con entradas y salidas. La configuración raíz es en sí misma un módulo; todo lo demás es un módulo reutilizable.

module "service" {
  source  = "git::https://git.example.com/acme/tf-modules.git//service?ref=v2.3.0"

  name        = "shop-api"
  environment = var.environment
  vpc_id      = data.terraform_remote_state.network.outputs.vpc_id
  subnet_ids  = data.terraform_remote_state.network.outputs.private_subnet_ids
  image_tag   = var.image_tag
  min_size    = 3
  max_size    = 12
}

Los buenos módulos se parecen a las buenas funciones: un propósito claro, entradas tipadas y bien nombradas con valores por defecto razonables, y salidas que exponen lo que necesitan quienes los llaman, no todo. Fija la versión con ?ref= apuntando a un tag: un módulo referenciado por rama cambia sin que te enteres, y el día que lo descubres es en mitad de un incidente.

Resiste dos tentaciones. No envuelvas un único recurso en un módulo que solo añade indirección. Y no construyas un módulo gigante con cuarenta flags booleanos: la composición gana a la configuración, y un módulo cuyo comportamiento depende de count = var.enabled ? 1 : 0 en veinte sitios es ilegible.

Para los entornos, prefiere directorios separados, cada uno con su clave de backend y sus archivos .tfvars, a los workspaces de Terraform. Los workspaces comparten una misma configuración y un mismo prefijo de clave de backend, lo que hace demasiado fácil lanzar un apply de producción con el workspace equivocado seleccionado. Con directorios separados, el destino queda explícito.

Ansible: configurar lo que ya existe

Terraform se detiene en la frontera de la máquina. Si trabajas con máquinas virtuales en lugar de contenedores, algo tiene que instalar paquetes, escribir archivos de configuración y arrancar servicios. Ansible lo hace por SSH, sin agente que instalar.

Un inventario enumera hosts y grupos; un playbook asigna roles y tareas a los grupos; los módulos realizan operaciones idempotentes.

- name: Configure application servers
  hosts: app_servers
  become: true
  vars:
    java_package: openjdk-21-jre-headless
    app_user: shop
  tasks:
    - name: Install runtime packages
      ansible.builtin.apt:
        name:
          - "{{ java_package }}"
          - ca-certificates
        state: present
        update_cache: true
        cache_valid_time: 3600

    - name: Create the service account
      ansible.builtin.user:
        name: "{{ app_user }}"
        system: true
        shell: /usr/sbin/nologin

    - name: Deploy the systemd unit
      ansible.builtin.template:
        src: templates/shop-api.service.j2
        dest: /etc/systemd/system/shop-api.service
        owner: root
        group: root
        mode: "0644"
      notify: Restart shop-api

    - name: Enable and start the service
      ansible.builtin.systemd_service:
        name: shop-api
        enabled: true
        state: started
        daemon_reload: true

  handlers:
    - name: Restart shop-api
      ansible.builtin.systemd_service:
        name: shop-api
        state: restarted

Cada módulo es idempotente: apt con state: present solo instala si falta el paquete, template solo reescribe el archivo cuando el contenido renderizado cambia, y el handler solo reinicia el servicio cuando alguna tarea lo ha notificado. Ejecútalo primero con --check --diff para ver qué cambiaría sin cambiar nada: el equivalente a un plan de Terraform.

ansible-playbook -i inventories/prod site.yml --check --diff --limit app_servers
ansible-playbook -i inventories/prod site.yml --limit app_servers

Agrupa las tareas en roles (roles/app-server/{tasks,templates,handlers,defaults}) para que los playbooks sigan siendo legibles, y somételos al mismo proceso de revisión que todo lo demás. Los nombres de las tareas aparecen en la salida, así que redáctalos como descripciones que un compañero pueda seguir.

Infraestructura inmutable

La gestión de configuración da por hecho que hay servidores de larga vida que vas corrigiendo. La alternativa es dejar de corregir y empezar a reemplazar: construir una imagen de máquina o de contenedor con el sistema completamente configurado, desplegar instancias nuevas a partir de ella y no modificar nunca una que ya esté en marcha.

El pipeline pasa a ser: construir una imagen (Packer para imágenes de VM, un Dockerfile para contenedores), probarla y dejar que la herramienta de aprovisionamiento sustituya las instancias por otras basadas en la nueva imagen. No se parchea nada in situ, así que no hay drift que detectar ni cambios aplicados a medias, y hacer rollback consiste en desplegar la imagen anterior.

source "amazon-ebs" "app" {
  region        = "eu-west-3"
  source_ami_filter {
    filters     = { name = "ubuntu/images/*24.04-amd64-server-*" }
    most_recent = true
    owners      = ["099720109477"]
  }
  instance_type = "t3.small"
  ssh_username  = "ubuntu"
  ami_name      = "shop-api-{{timestamp}}"
}

build {
  sources = ["source.amazon-ebs.app"]
  provisioner "ansible" {
    playbook_file = "./ansible/site.yml"
  }
}

Fíjate en que Ansible no ha desaparecido: ahora se ejecuta una sola vez, al construir la imagen, en lugar de hacerlo de forma continua contra producción. Es el término medio pragmático al que llega la mayoría de los equipos, y es exactamente el mismo argumento que defienden los contenedores: el pipeline de entrega construye un artefacto inmutable una sola vez y lo promociona sin cambios.

La inmutabilidad tiene límites: las bases de datos guardan un estado que no se puede reemplazar a la ligera. Aplica el patrón donde encaje y mantén la gestión de configuración para el resto.

El drift y qué hacer con él

El drift es cualquier diferencia entre el código y la realidad: un cambio en la consola durante un incidente, un autoscaler que ajusta un valor, un proveedor cloud que cambia un valor por defecto, la automatización de otro equipo que toca un recurso compartido.

Detéctalo de forma programada, no durante un despliegue:

terraform plan -detailed-exitcode -lock=false
# exit 0 = no changes, 1 = error, 2 = changes pending

Un job nocturno del pipeline que ejecute ese comando y alerte ante el código de salida 2 convierte el drift en una notificación en vez de en una sorpresa. El modo --check de Ansible ofrece la señal equivalente para los hosts configurados.

Gestionar el drift es cuestión de criterio. Lo habitual es reconciliar volviendo a aplicar el código, lo que deshace el cambio manual: seguro si el cambio fue accidental, peligroso si era un arreglo urgente del que nadie te ha avisado. A veces incorporas el cambio al código porque era correcto. Y de vez en cuando aceptas que un campo se gestiona en otro sitio y le dices a Terraform que deje de vigilarlo:

lifecycle {
  ignore_changes  = [tags["LastScanned"], desired_count]
  prevent_destroy = true
}

prevent_destroy en bases de datos y en los buckets de state es un seguro barato frente a un plan que nadie leyó con atención.

Secretos

No pongas nunca una credencial en un archivo .tf o .yml. Las opciones realistas, de menor a mayor madurez: variables de entorno que proporciona el pipeline (TF_VAR_db_password); un gestor de secretos referenciado mediante una data source en el momento del plan; y recursos que nunca llegan a contener el secreto, como una base de datos cuya contraseña se genera directamente en el gestor de secretos del proveedor cloud.

Ansible cuenta con Vault para cifrar in situ los archivos de variables, lo que permite guardar los secretos en Git de forma cifrada:

ansible-vault encrypt group_vars/prod/secrets.yml
ansible-playbook site.yml --vault-password-file ~/.ansible-vault-pass

Y recuerda la restricción de antes: un secreto que Terraform crea o lee acaba en el state en texto plano. Marcar una variable con sensitive = true solo la oculta en la salida de consola. Si un valor no debe estar nunca en el state, el recurso al que pertenece no puede gestionarlo Terraform.

Ejecutarlo desde un pipeline

Aplicar desde portátiles es el origen de la mayoría de los incidentes de IaC: versiones de herramientas distintas, providers desactualizados, sin lock, sin rastro de auditoría y credenciales guardadas en el perfil de shell de alguien. Llévalo a la CI.

La forma estándar es plan en la pull request y apply al hacer merge:

  1. En cada pull request

    Ejecuta fmt -check, validate, un análisis de seguridad (tfsec, Checkov o el modo IaC de Trivy) y plan -out=tfplan. Publica el plan como comentario para que los revisores vean el diff de infraestructura junto al diff de código.
  2. Confía en políticas, no en la vigilancia

    Añade policy-as-code (OPA/Conftest, Sentinel o las reglas del escáner) para los invariantes que deben cumplirse siempre: ningún bucket de almacenamiento público, cifrado activado, tags obligatorios, ningún security group abierto a todo el mundo en el puerto 22.
  3. Aplica el plan revisado al hacer merge

    Aplica el artefacto de plan guardado en la ejecución de la pull request, no uno recién calculado, para que lo que se despliega sea lo que se aprobó. Usa credenciales de corta duración obtenidas mediante federación OIDC en lugar de claves de acceso de larga duración.
  4. Promociona entre entornos

    Las mismas versiones de los módulos se aplican en dev, luego en staging y luego en producción, con distintos archivos de variables. Las diferencias entre entornos deben ser valores, nunca código distinto.
  5. Detecta el drift cada noche

    Un plan -detailed-exitcode programado por cada state, que alerte cuando la realidad se haya alejado del código.

La extensión natural hacia el autoservicio es una plataforma: los mismos módulos expuestos a los equipos de producto detrás de una pull request, de modo que un equipo obtiene una cola, un bucket o una base de datos añadiendo diez líneas a un repositorio en lugar de abrir un ticket. Es el modelo de equipo de plataforma que se describe en la introducción a DevOps.

Dónde se tuerce

  • El state en Git. Contiene secretos, genera conflictos en cada merge y acabará perdiéndose. Usa un backend remoto desde el primer día.
  • Un único state para todo. Planes lentos, un radio de impacto enorme y contención constante por el lock. Divide por ciclo de vida.
  • Módulos referenciados por rama. Un commit en el repositorio de origen cambia el plan de todos los consumidores sin previo aviso. Fija tags.
  • count donde corresponde for_each. El direccionamiento por índice convierte el borrado de un elemento en mitad de una lista en una cascada de reemplazos.
  • Ignorar el plan. «Must be replaced» sobre una base de datos no es un trámite. Lee cada línea de destrucción antes de escribir yes.
  • Infraestructura migrada a medias. Unos recursos en código, otros creados a golpe de clic en la consola, y nadie sabe cuáles son cuáles. Importa los recursos sueltos o elimínalos, y establece una política de tags para que los recursos no gestionados salten a la vista.
  • Tratar la IaC como algo que solo se escribe. Un código que nadie ha vuelto a aplicar en un año es una hipótesis. Reconstruye con regularidad un entorno efímero desde cero para demostrar que sigue funcionando.

Ideas clave

  • Las herramientas de aprovisionamiento crean recursos y los registran en el state; los gestores de configuración hacen que máquinas existentes coincidan con una descripción. La mayoría de las infraestructuras necesitan ambas cosas, o una construcción de imágenes que integre lo segundo en lo primero.
  • La idempotencia es lo que hace seguro volver a ejecutar, y eso es lo que hace posibles la corrección automática y los pipelines desatendidos.
  • El plan de Terraform es el artefacto que se revisa: léelo, guárdalo, aplica el archivo guardado y no dejes nunca que un «must be replaced» pase de largo sin examinarlo.
  • El state es compartido, sensible y bloqueable: ponlo en un backend remoto cifrado y con versionado, divídelo según el radio de impacto y modifícalo con moved, import y los comandos de state, nunca a mano.
  • Escribe los módulos como funciones, fíjalos por tag, prefiere for_each a count y usa directorios separados en lugar de workspaces para los entornos.
  • Las imágenes inmutables eliminan el drift sustituyendo servidores en lugar de corregirlos; reserva la gestión de configuración para lo que no se puede reconstruir.
  • Detecta el drift de forma programada con plan -detailed-exitcode y decide de forma deliberada si reconcilias, adoptas o ignoras cada diferencia.
  • Ejecuta plan en las pull requests con comprobaciones de políticas, aplica el plan aprobado al hacer merge con credenciales de corta duración y promociona los mismos módulos entre entornos cambiando solo los valores.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Terraform o Ansible? ¿Cuál necesito?
Resuelven problemas distintos y es habitual usarlos juntos. Terraform es una herramienta de aprovisionamiento: crea y destruye recursos cloud como redes, bases de datos, clústeres y registros DNS, y lleva la cuenta de lo que es suyo en el state. Ansible es un gestor de configuración: hace que una máquina existente coincida con una configuración descrita, instalando paquetes y escribiendo archivos por SSH. Aprovisiona con uno y configura con el otro, o integra la configuración en una imagen y sáltate el segundo paso.
¿Puedo modificar la infraestructura en la consola cloud después de un apply?
Puedes, y el siguiente plan propondrá deshacer el cambio. Eso es drift, y es el mecanismo funcionando tal como está diseñado. A veces un cambio de emergencia en la consola es inevitable; la regla es reproducirlo en el código ese mismo día. Si un recurso tiene que gestionarse fuera de Terraform, exclúyelo de forma explícita en lugar de dejar que dos dueños se lo disputen en cada apply.
¿Qué pasa si dos personas ejecutan apply a la vez?
Con un backend que soporte bloqueo, la segunda ejecución falla de inmediato con un mensaje que indica quién tiene el lock. Sin bloqueo, ambas ejecuciones leen el mismo state, ambas lo escriben y se pierde uno de los dos conjuntos de cambios, dejando recursos que existen en el cloud pero no en el state. Es la razón de más peso para usar un backend remoto y lanzar apply desde un pipeline en lugar de desde portátiles.
¿Están seguros los secretos en el state de Terraform?
No. Valores como contraseñas generadas y claves de acceso se escriben en el state en texto plano aunque la variable esté marcada como sensitive, lo que solo los oculta en la salida de consola. Trata el state como un secreto: guárdalo en un backend cifrado con un control de acceso estricto, no lo subas nunca al repositorio y prefiere recursos que referencien un gestor de secretos a recursos que creen y guarden el secreto ellos mismos.