6. Despliegue y operación¶
En esta unidad pasarás de un despliegue funcional pero manual a un despliegue reproducible y operable mediante Infraestructura como Código (IaC) con Terraform y Gestión de Configuración con Ansible.
Trabajarás sobre los entornos reales que ya tienes desplegados en el Tema 5 (Docker o AWS) y los convertirás en infraestructura versionada, playbooks idempotentes y operación automatizada, siguiendo prácticas profesionales de DevOps.
Hay dos retos de igual alcance (30p cada uno); cada grupo sigue solo el que corresponda a su línea de proyecto:
- RG601 (Ansible sobre Docker/Linux): automatización del aprovisionamiento y configuración de servicios Linux (web, BBDD, backups, monitorización) sobre los hosts que sostienen el stack del Tema 5 (línea Docker).
- RG602 (Terraform + Ansible sobre AWS): despliegue automático de la infraestructura AWS (VPC, EC2, SG, DNS Bind9, MySQL en EC2) con Terraform y configuración posterior con Ansible, sustituyendo el clic-clic en consola por código versionado.
Esta unidad de trabajo corresponde principalmente a los Resultados de Aprendizaje RA3 y RA4.
Idea clave del tema
Si en el Tema 5 conseguiste que todo funcione, en el Tema 6 debes conseguir que todo se pueda volver a montar desde cero, sin tocar la consola, en pocos minutos y de forma idéntica para cualquier integrante del equipo.
Resultado de aprendizaje¶
Al finalizar esta unidad, serás capaz de:
- RA3: Validar, mediante automatización, que la solución es reproducible y operable, comprobando que un despliegue desde cero produce un entorno equivalente al ya validado en el Tema 5.
- RA4: Documentar, versionar y desplegar los manifiestos IaC (Terraform) y los playbooks (Ansible) en el repositorio del proyecto, con evidencias del flujo
init → plan → applyyansible-playbook.
1. De despliegue manual a despliegue como código¶
En el Tema 4 montaste el stack a mano y en el Tema 5 lo integraste y aseguraste. En el Tema 6 pasamos a un modelo DevOps: cada cambio en la infraestructura o en la configuración pasa por un fichero versionado en el repositorio.
flowchart LR
A[Tema 4<br/>Despliegue manual] --> B[Tema 5<br/>Integración + seguridad]
B --> C[Tema 6<br/>IaC + Configuración como código]
C --> D[Entorno reproducible<br/>en minutos]
C --> E[Operación automatizada<br/>y mantenible]
1.1. ¿Por qué IaC y Configuration Management?¶
| Sin automatización | Con Terraform + Ansible |
|---|---|
| Pasos manuales documentados en una wiki | Pasos en *.tf y *.yml versionados en Git |
| Dependencia de quien lo montó | Cualquier integrante puede ejecutar apply |
| "En mi máquina funciona" | Idempotencia: mismo resultado en cualquier host |
| Difícil reproducir errores | El error está en el código, se corrige y se vuelve a aplicar |
| Rollback complicado | git revert + nuevo apply |
| Auditoría imposible | Historial completo en Git |
1.2. Reparto de responsabilidades¶
| Herramienta | Para qué la usamos | Qué no hace |
|---|---|---|
| Terraform (HashiCorp) | Crear y destruir infraestructura (VPC, EC2, SG, Elastic IP, EBS, registros DNS) | Configurar el dentro de la máquina |
| Ansible (Red Hat) | Configurar lo que vive dentro de las máquinas: paquetes, ficheros, servicios, usuarios, firewall a nivel SO, despliegue de la app | Crear infraestructura cloud (aunque tiene módulos, lo dejamos a Terraform) |
| Git + GitHub | Versionar todo el código IaC, revisar cambios en Pull Request | Aplicar nada (lo aplica el operador o un runner CI/CD) |
Regla práctica para ASIR
- Terraform para todo lo que se crea/destruye (recursos AWS, redes, hosts).
- Ansible para todo lo que se instala/configura (paquetes, ficheros, servicios systemd, despliegue del código del proyecto).
- Docker Compose del Tema 4/5 sigue siendo válido para describir la app, pero Ansible lanza Compose en lugar de tener que hacerlo a mano.
2. Conceptos básicos¶
2.1. Infraestructura como Código (IaC)¶
Infraestructura como Código consiste en describir la infraestructura mediante archivos de texto legibles por humanos y por máquinas, gestionados como código fuente (Git), aplicables de forma automática y idempotente.
- Declarativo: describes el estado deseado (Terraform, Ansible en modo declarativo). La herramienta calcula los pasos.
- Imperativo: describes paso a paso lo que hay que hacer (un script Bash o un shell module de Ansible).
- Idempotencia: aplicar la misma configuración N veces produce el mismo resultado, sin duplicar recursos.
2.2. Terraform en una pantalla¶
flowchart LR
subgraph Código
A[main.tf<br/>variables.tf<br/>outputs.tf]
end
subgraph Estado
S[(terraform.tfstate)]
end
subgraph Nube
AWS[(AWS API)]
end
A -- terraform init --> P[Providers descargados]
A -- terraform plan --> Diff[Diff esperado]
Diff -- terraform apply --> AWS
AWS --> S
S -- comparar --> A
Comandos esenciales:
| Comando | Para qué sirve |
|---|---|
terraform init |
Descarga providers y prepara el backend. Primera vez o tras cambios. |
terraform fmt |
Formatea el código (estilo HCL coherente). |
terraform validate |
Verifica sintaxis y referencias antes de tocar nada. |
terraform plan |
Muestra los cambios que aplicaría, sin tocar la infraestructura real. |
terraform apply |
Aplica los cambios al provider (pide confirmación). |
terraform destroy |
Elimina todos los recursos gestionados por ese estado. |
terraform output |
Muestra los valores de salida (IP pública, DNS, etc.). |
2.3. Ansible en una pantalla¶
flowchart LR
C[Controlador<br/>Ansible] -- SSH --> N1[node1]
C -- SSH --> N2[node2]
C -- SSH --> N3[node3]
C --- Inv[inventory.ini]
C --- PB[playbook.yml]
C --- Roles[roles/]
C --- Vars[group_vars/<br/>host_vars/]
Elementos clave:
| Elemento | Definición |
|---|---|
| Controlador | La máquina donde se ejecuta ansible y ansible-playbook (tu portátil o un bastion). |
| Inventario | Lista de hosts gestionados, agrupados ([webservers], [dbservers]). Estático (.ini o .yml) o dinámico (script que consulta AWS, etc.). |
| Playbook | Fichero YAML con la secuencia de plays y tasks a aplicar. |
| Módulo | "Programa" de Ansible que ejecuta una acción concreta (apt, copy, service, mysql_user...). Hay cientos. |
| Rol | Estructura de carpetas reutilizable (tasks/, handlers/, templates/, defaults/, vars/, files/, meta/). Permite empaquetar lógica. |
| Handler | Tarea que se ejecuta solo si otra la notifica (típico para reiniciar servicios tras cambiar configuración). |
| Variable | Valor parametrizable (en group_vars/, host_vars/, defaults del rol, línea de comandos con -e...). |
| Template | Fichero Jinja2 (.j2) que se renderiza con variables antes de copiarse al host destino. |
| Hecho (fact) | Información que Ansible descubre del host (SO, IPs, RAM...) usable como variable. |
2.4. Idempotencia y --check¶
Una task idempotente devuelve changed=0 cuando el estado del host ya coincide con el deseado. Para validarlo:
ansible-playbook -i inventario.ini site.yml --check --diff
--check: simulación, no aplica cambios.--diff: muestra las diferencias línea a línea en los ficheros que se modificarían.
Regla de oro
Si tras ejecutar dos veces seguidas el mismo playbook obtienes changed > 0 en la segunda ejecución, tu playbook no es idempotente y debe revisarse.
3. Retos de despliegue y operación¶
Cada grupo sigue un reto (el de su línea de proyecto). Ambos tienen guía detallada y 30 puntos; el profesorado valora el reto que os corresponda.
| Aspecto | RG601 — Ansible sobre Docker/Linux | RG602 — Terraform + Ansible en AWS |
|---|---|---|
| Punto de partida | Stack Compose del Tema 5 (Nginx, WordPress, Laravel, MySQL) ejecutándose en uno o varios hosts Linux | Infraestructura AWS del Tema 5 (VPC, EC2 web, EC2 app, EC2 MySQL, EC2 DNS Bind9) creada a mano |
| Aprovisionamiento | Ansible aprovisiona el host (paquetes, Docker, usuarios, firewall) y lanza Compose | Terraform crea VPC + subredes + SG + EC2 + EIP; Ansible las configura |
| Configuración | Roles Ansible: common, docker_host, nginx_proxy, backups, monitoring |
Roles Ansible: common, web, app, db, dns, hardening |
| Operación | ansible-playbook site.yml reproduce el host completo desde cero |
terraform apply + ansible-playbook site.yml reproducen infraestructura y configuración |
| Seguridad | SSH con claves, ansible-vault para secretos, UFW/firewalld, hardening básico |
IAM mínimo, state protegido, ansible-vault, SG restrictivos, SSH endurecido |
| Monitorización | Node Exporter / Prometheus desplegado vía rol Ansible | CloudWatch Agent instalado por Ansible; alarmas básicas opcionalmente con Terraform |
| Proyecto de referencia | Proyecto Intermodular (Docker) | Proyecto Intermodular (AWS) |
4. RG601 — Aprovisionamiento y operación con Ansible (línea Docker)¶
Objetivo: que cualquier integrante del grupo, partiendo de una máquina Linux recién instalada (Ubuntu Server 22.04 o Debian 12), pueda ejecutar un solo comando y obtener el stack del Tema 5 en marcha, idéntico al validado, con backups y monitorización básica.
4.1. Arquitectura objetivo¶
flowchart LR
Dev[Controlador<br/>portátil del alumno] -- ansible-playbook --> Host[Host Linux<br/>Ubuntu/Debian]
Host -- docker compose --> Nginx
Host --> WP[WordPress]
Host --> Laravel
Host --> MySQL[(MySQL)]
Host --> NE[Node Exporter]
Host --> Backups[Volumen<br/>backups]
4.2. Estructura del repositorio Ansible¶
ansible/
├── ansible.cfg
├── inventory/
│ ├── hosts.ini # inventario estático
│ └── group_vars/
│ ├── all.yml # variables comunes
│ └── webservers.yml # variables específicas
├── site.yml # playbook principal
├── requirements.yml # roles/colecciones externas
├── roles/
│ ├── common/
│ │ ├── tasks/main.yml
│ │ ├── handlers/main.yml
│ │ └── defaults/main.yml
│ ├── docker_host/
│ │ ├── tasks/main.yml
│ │ └── templates/daemon.json.j2
│ ├── stack_compose/
│ │ ├── tasks/main.yml
│ │ ├── templates/docker-compose.yml.j2
│ │ ├── templates/nginx.conf.j2
│ │ └── files/
│ ├── backups/
│ │ ├── tasks/main.yml
│ │ └── templates/backup.sh.j2
│ └── monitoring/
│ ├── tasks/main.yml
│ └── templates/prometheus.yml.j2
└── vault/
└── secrets.yml # cifrado con ansible-vault
4.3. Ficheros base¶
ansible.cfg
[defaults]
inventory = inventory/hosts.ini
roles_path = roles
host_key_checking = False
retry_files_enabled = False
stdout_callback = yaml
forks = 10
interpreter_python = auto_silent
[ssh_connection]
pipelining = True
inventory/hosts.ini
[webservers]
web01 ansible_host=192.168.1.50 ansible_user=ubuntu
[dbservers]
db01 ansible_host=192.168.1.51 ansible_user=ubuntu
[monitoring]
mon01 ansible_host=192.168.1.52 ansible_user=ubuntu
[all:vars]
ansible_ssh_private_key_file=~/.ssh/asir_pi
inventory/group_vars/all.yml
---
proyecto: "intermodular-asir"
zona_horaria: "Europe/Madrid"
usuarios_admin:
- { nombre: "alumno1", clave_publica: "ssh-ed25519 AAAA... alumno1@asir" }
- { nombre: "alumno2", clave_publica: "ssh-ed25519 AAAA... alumno2@asir" }
paquetes_base:
- curl
- git
- ufw
- htop
- vim
- python3-pip
site.yml
---
- name: Aprovisionamiento del host del proyecto (RG601)
hosts: webservers
become: true
roles:
- role: common
- role: docker_host
- role: stack_compose
- role: backups
- role: monitoring
4.4. Rol common (paquetes, usuarios, firewall)¶
roles/common/tasks/main.yml
---
- name: Configurar zona horaria
community.general.timezone:
name: "{{ zona_horaria }}"
- name: Actualizar caché de paquetes (Debian/Ubuntu)
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
- name: Instalar paquetes base
ansible.builtin.apt:
name: "{{ paquetes_base }}"
state: present
- name: Crear usuarios de administración
ansible.builtin.user:
name: "{{ item.nombre }}"
groups: sudo
shell: /bin/bash
state: present
loop: "{{ usuarios_admin }}"
- name: Instalar claves SSH
ansible.posix.authorized_key:
user: "{{ item.nombre }}"
key: "{{ item.clave_publica }}"
state: present
loop: "{{ usuarios_admin }}"
- name: Permitir SSH en UFW
community.general.ufw:
rule: allow
port: "22"
proto: tcp
- name: Habilitar UFW por defecto deny
community.general.ufw:
state: enabled
policy: deny
4.5. Rol docker_host¶
roles/docker_host/tasks/main.yml
---
- name: Instalar dependencias para repositorio Docker
ansible.builtin.apt:
name:
- ca-certificates
- gnupg
- lsb-release
state: present
- name: Añadir clave GPG oficial de Docker
ansible.builtin.apt_key:
url: https://download.docker.com/linux/ubuntu/gpg
state: present
- name: Añadir repositorio Docker
ansible.builtin.apt_repository:
repo: "deb [arch=amd64] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
state: present
filename: docker
- name: Instalar Docker Engine y Compose plugin
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-compose-plugin
state: present
update_cache: true
- name: Añadir usuarios admin al grupo docker
ansible.builtin.user:
name: "{{ item.nombre }}"
groups: docker
append: true
loop: "{{ usuarios_admin }}"
- name: Asegurar servicio Docker
ansible.builtin.service:
name: docker
state: started
enabled: true
4.6. Rol stack_compose con plantilla Jinja2¶
roles/stack_compose/defaults/main.yml
---
stack_dir: /opt/proyecto
mysql_db: wordpress
mysql_user: wp_user
puertos:
http: 80
https: 443
roles/stack_compose/tasks/main.yml
---
- name: Crear directorio del stack
ansible.builtin.file:
path: "{{ stack_dir }}"
state: directory
mode: "0750"
- name: Desplegar docker-compose.yml desde plantilla
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ stack_dir }}/docker-compose.yml"
mode: "0640"
notify: Recrear stack
- name: Desplegar configuración Nginx
ansible.builtin.template:
src: nginx.conf.j2
dest: "{{ stack_dir }}/nginx.conf"
mode: "0644"
notify: Recrear stack
- name: Arrancar el stack
community.docker.docker_compose_v2:
project_src: "{{ stack_dir }}"
state: present
roles/stack_compose/handlers/main.yml
---
- name: Recrear stack
community.docker.docker_compose_v2:
project_src: "{{ stack_dir }}"
state: present
recreate: always
roles/stack_compose/templates/docker-compose.yml.j2
services:
nginx:
image: nginx:1.27
ports:
- "{{ puertos.http }}:80"
- "{{ puertos.https }}:443"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- wordpress
wordpress:
image: wordpress:php8.2-fpm
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: "{{ mysql_user }}"
WORDPRESS_DB_PASSWORD: "{{ vault_mysql_password }}"
WORDPRESS_DB_NAME: "{{ mysql_db }}"
db:
image: mysql:8.0
environment:
MYSQL_DATABASE: "{{ mysql_db }}"
MYSQL_USER: "{{ mysql_user }}"
MYSQL_PASSWORD: "{{ vault_mysql_password }}"
MYSQL_ROOT_PASSWORD: "{{ vault_mysql_root_password }}"
volumes:
- dbdata:/var/lib/mysql
volumes:
dbdata:
4.7. Secretos con ansible-vault¶
Nunca se suben contraseñas en claro a Git. Crea un fichero cifrado:
ansible-vault create vault/secrets.yml
Contenido (se edita en claro, se guarda cifrado):
vault_mysql_password: "P4ssw0rd_segura_para_wp"
vault_mysql_root_password: "Otra_clave_muy_robusta"
Ejecución pidiendo la vault password:
ansible-playbook -i inventory/hosts.ini site.yml \
--extra-vars "@vault/secrets.yml" --ask-vault-pass
No commitees claves en claro
Añade a .gitignore cualquier fichero *.pem, *.key, .env real y nunca subas la vault password. En GitHub puedes guardarla como secret del repositorio si usas GitHub Actions.
4.8. Rol backups¶
roles/backups/tasks/main.yml
---
- name: Crear directorio de backups
ansible.builtin.file:
path: /var/backups/proyecto
state: directory
mode: "0750"
- name: Desplegar script de backup
ansible.builtin.template:
src: backup.sh.j2
dest: /usr/local/bin/backup-proyecto.sh
mode: "0750"
- name: Programar cron diario a las 03:00
ansible.builtin.cron:
name: "Backup MySQL proyecto"
minute: "0"
hour: "3"
job: "/usr/local/bin/backup-proyecto.sh >> /var/log/backup-proyecto.log 2>&1"
roles/backups/templates/backup.sh.j2
#!/usr/bin/env bash
set -euo pipefail
FECHA=$(date +%Y%m%d_%H%M)
DEST=/var/backups/proyecto
cd {{ stack_dir }}
docker compose exec -T db \
mysqldump -u root -p"{{ vault_mysql_root_password }}" \
--single-transaction --routines --triggers \
{{ mysql_db }} | gzip > "$DEST/${FECHA}_{{ mysql_db }}.sql.gz"
find "$DEST" -name '*.sql.gz' -mtime +7 -delete
4.9. Rol monitoring (Node Exporter + Prometheus mínimo)¶
roles/monitoring/tasks/main.yml
---
- name: Desplegar Node Exporter como contenedor
community.docker.docker_container:
name: node-exporter
image: prom/node-exporter:latest
restart_policy: unless-stopped
network_mode: host
pid_mode: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.sysfs=/host/sys'
- '--path.rootfs=/rootfs'
- name: Permitir Prometheus scrape en UFW
community.general.ufw:
rule: allow
port: "9100"
proto: tcp
src: "{{ prometheus_cidr | default('192.168.1.0/24') }}"
4.10. Ejecución y validación¶
cd ansible/
ansible-playbook -i inventory/hosts.ini site.yml --check --diff # simulación
ansible-playbook -i inventory/hosts.ini site.yml --ask-vault-pass # aplicar
ansible-playbook -i inventory/hosts.ini site.yml --ask-vault-pass # 2.ª vez: changed=0
Criterios de aceptación de RG601
- El primer
applydeja el stack funcional (HTTP 200 en/). - El segundo
applyno realiza cambios (idempotencia verificada). - El backup se ejecuta y deja
.sql.gzen/var/backups/proyecto. - Existen al menos dos roles propios distintos a los de ejemplo.
- Los secretos están cifrados con
ansible-vault. -
node_exporterresponde en el puerto9100solo desde la red autorizada.
5. RG602 — Terraform + Ansible en AWS (línea cloud)¶
Objetivo: sustituir el clic-clic en la consola AWS por código Terraform que crea VPC, subredes, SG y EC2; y un playbook Ansible que configura las EC2 (web, app, MySQL, DNS) con la misma topología validada en el Tema 5.
5.1. Arquitectura objetivo¶
flowchart LR
subgraph VPC["VPC 10.0.0.0/16"]
subgraph PUB["Subred pública 10.0.1.0/24"]
WEB[EC2 web<br/>WordPress]
APP[EC2 app<br/>Laravel]
DNS[EC2 DNS<br/>Bind9]
end
subgraph PRIV["Subred privada 10.0.2.0/24"]
DB[(EC2 MySQL)]
end
end
IGW[Internet Gateway] --- PUB
USER[Usuario] -->|HTTP/HTTPS| WEB
USER -->|API| APP
WEB --> DB
APP --> DB
5.2. Estructura del repositorio¶
deploy/
├── terraform/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ ├── providers.tf
│ ├── network.tf
│ ├── compute.tf
│ ├── security.tf
│ └── terraform.tfvars.example
├── ansible/
│ ├── ansible.cfg
│ ├── inventory_aws_ec2.yml # inventario dinámico
│ ├── site.yml
│ ├── group_vars/
│ │ └── all.yml
│ └── roles/
│ ├── common/
│ ├── web/
│ ├── app/
│ ├── db/
│ ├── dns/
│ └── hardening/
└── README.md
5.3. Terraform: providers y red¶
terraform/providers.tf
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.region
}
terraform/variables.tf
variable "region" { type = string default = "us-east-1" }
variable "project" { type = string default = "pi-asir" }
variable "key_name" { type = string }
variable "admin_ip_cidr" { type = string description = "IP del aula con /32" }
variable "ami_ubuntu" { type = string default = "ami-0a0e5d9c7acc336f1" }
variable "instance_type" { type = string default = "t3.micro" }
terraform/network.tf
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = { Name = "${var.project}-vpc" }
}
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.project}-igw" }
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
map_public_ip_on_launch = true
availability_zone = "${var.region}a"
tags = { Name = "${var.project}-public" }
}
resource "aws_subnet" "private" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = "${var.region}b"
tags = { Name = "${var.project}-private" }
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
tags = { Name = "${var.project}-rt-public" }
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
5.4. Terraform: Security Groups y EC2¶
terraform/security.tf
resource "aws_security_group" "web" {
name = "${var.project}-sg-web"
description = "HTTP/HTTPS público + SSH del aula"
vpc_id = aws_vpc.main.id
ingress { from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] }
ingress { from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] }
ingress { from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = [var.admin_ip_cidr] }
egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] }
}
resource "aws_security_group" "db" {
name = "${var.project}-sg-db"
vpc_id = aws_vpc.main.id
ingress {
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.web.id] # solo desde web
}
egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] }
}
resource "aws_security_group" "dns" {
name = "${var.project}-sg-dns"
vpc_id = aws_vpc.main.id
ingress { from_port = 53 to_port = 53 protocol = "udp" cidr_blocks = ["0.0.0.0/0"] }
ingress { from_port = 53 to_port = 53 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] }
ingress { from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = [var.admin_ip_cidr] }
egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] }
}
terraform/compute.tf
locals {
hosts = {
web = { subnet = aws_subnet.public.id, sg = aws_security_group.web.id }
app = { subnet = aws_subnet.public.id, sg = aws_security_group.web.id }
db = { subnet = aws_subnet.private.id, sg = aws_security_group.db.id }
dns = { subnet = aws_subnet.public.id, sg = aws_security_group.dns.id }
}
}
resource "aws_instance" "node" {
for_each = local.hosts
ami = var.ami_ubuntu
instance_type = var.instance_type
subnet_id = each.value.subnet
vpc_security_group_ids = [each.value.sg]
key_name = var.key_name
associate_public_ip_address = each.key != "db"
tags = {
Name = "${var.project}-${each.key}"
Role = each.key
Project = var.project
}
}
resource "aws_eip" "web" {
instance = aws_instance.node["web"].id
domain = "vpc"
tags = { Name = "${var.project}-eip-web" }
}
terraform/outputs.tf
output "ips_publicas" {
value = { for k, v in aws_instance.node : k => v.public_ip if v.public_ip != "" }
}
output "ip_privada_db" {
value = aws_instance.node["db"].private_ip
}
output "eip_web" { value = aws_eip.web.public_ip }
5.5. Flujo Terraform¶
cd deploy/terraform
cp terraform.tfvars.example terraform.tfvars # edita con tus valores
terraform init
terraform fmt -recursive
terraform validate
terraform plan -out tfplan
terraform apply tfplan
terraform output -json > ../ansible/tf_outputs.json
No subas el tfstate
Añade a .gitignore:
.terraform/
*.tfstate
*.tfstate.*
terraform.tfvars
crash.log
En entornos colaborativos reales, configura un backend remoto (S3 + DynamoDB para locking). En el aula podemos trabajar con estado local siempre que solo aplique una persona a la vez.
5.6. Inventario dinámico de Ansible para AWS¶
ansible/inventory_aws_ec2.yml
plugin: amazon.aws.aws_ec2
regions:
- us-east-1
filters:
tag:Project: pi-asir
keyed_groups:
- key: tags.Role
prefix: role
hostnames:
- tag:Name
compose:
ansible_host: public_ip_address | default(private_ip_address)
ansible_user: "'ubuntu'"
Instalar la colección:
ansible-galaxy collection install amazon.aws community.general community.mysql ansible.posix
Probarlo:
ansible-inventory -i inventory_aws_ec2.yml --graph
Debería mostrarte grupos role_web, role_app, role_db, role_dns.
5.7. Playbook principal site.yml¶
---
- name: Configuración base de todos los nodos
hosts: all
become: true
roles:
- common
- hardening
- name: Servidor DNS Bind9
hosts: role_dns
become: true
roles:
- dns
- name: Base de datos MySQL
hosts: role_db
become: true
roles:
- db
- name: Servidor web WordPress
hosts: role_web
become: true
roles:
- web
- name: Aplicación Laravel
hosts: role_app
become: true
roles:
- app
5.8. Rol dns (Bind9)¶
roles/dns/tasks/main.yml
---
- name: Instalar Bind9
ansible.builtin.apt:
name: [bind9, bind9utils, dnsutils]
state: present
update_cache: true
- name: Configurar zona directa
ansible.builtin.template:
src: db.proyecto.local.j2
dest: /etc/bind/db.proyecto.local
owner: bind
group: bind
mode: "0644"
notify: Reiniciar bind9
- name: Declarar zona en named.conf.local
ansible.builtin.template:
src: named.conf.local.j2
dest: /etc/bind/named.conf.local
mode: "0644"
notify: Reiniciar bind9
- name: Habilitar y arrancar bind9
ansible.builtin.service:
name: bind9
state: started
enabled: true
roles/dns/templates/db.proyecto.local.j2
$TTL 604800
@ IN SOA ns.proyecto.local. admin.proyecto.local. (
{{ ansible_date_time.epoch }} ; Serial
604800 ; Refresh
86400 ; Retry
2419200 ; Expire
604800 ) ; Negative Cache TTL
;
@ IN NS ns.proyecto.local.
ns IN A {{ hostvars[groups['role_dns'][0]]['ansible_host'] }}
web IN A {{ hostvars[groups['role_web'][0]]['ansible_host'] }}
app IN A {{ hostvars[groups['role_app'][0]]['ansible_host'] }}
db IN A {{ hostvars[groups['role_db'][0]]['private_ip_address'] }}
5.9. Rol db (MySQL en EC2)¶
roles/db/tasks/main.yml
---
- name: Instalar MySQL Server
ansible.builtin.apt:
name: [mysql-server, python3-pymysql]
state: present
update_cache: true
- name: Vincular MySQL a la IP privada
ansible.builtin.lineinfile:
path: /etc/mysql/mysql.conf.d/mysqld.cnf
regexp: "^bind-address"
line: "bind-address = {{ ansible_default_ipv4.address }}"
notify: Reiniciar mysql
- name: Crear base de datos del proyecto
community.mysql.mysql_db:
name: "{{ db_nombre }}"
state: present
login_unix_socket: /var/run/mysqld/mysqld.sock
- name: Crear usuario aplicación
community.mysql.mysql_user:
name: "{{ db_usuario }}"
password: "{{ vault_db_password }}"
priv: "{{ db_nombre }}.*:ALL"
host: "%"
state: present
login_unix_socket: /var/run/mysqld/mysqld.sock
5.10. Rol hardening (mínimos profesionales)¶
roles/hardening/tasks/main.yml
---
- name: Deshabilitar acceso root por SSH
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: "^#?PermitRootLogin"
line: "PermitRootLogin no"
notify: Reiniciar sshd
- name: Deshabilitar contraseñas en SSH (solo clave pública)
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: "^#?PasswordAuthentication"
line: "PasswordAuthentication no"
notify: Reiniciar sshd
- name: Instalar fail2ban
ansible.builtin.apt:
name: fail2ban
state: present
- name: Activar fail2ban
ansible.builtin.service:
name: fail2ban
state: started
enabled: true
- name: Aplicar actualizaciones automáticas de seguridad
ansible.builtin.apt:
name: unattended-upgrades
state: present
5.11. Despliegue completo en un único comando¶
# 1. Infraestructura
cd deploy/terraform && terraform apply -auto-approve
# 2. Esperar a que SSH esté disponible (~60 s)
sleep 60
# 3. Configuración
cd ../ansible
ansible-playbook -i inventory_aws_ec2.yml site.yml --ask-vault-pass
Criterios de aceptación de RG602
-
terraform planno muestra cambios trasapplyexitoso. - El inventario dinámico detecta automáticamente las 4 EC2.
-
nslookup web.proyecto.local @<IP_DNS>responde. - Web y app conectan con MySQL en la subred privada.
- La regla SG bloquea
3306desde cualquier IP que no seasg-web(prueba negativa). - El playbook es idempotente (segundo
applyconchanged=0). - No hay claves ni
tfstateen el repositorio Git.
6. Integración Terraform + Ansible¶
Hay dos formas habituales de unirlos:
6.1. Patrón "Terraform primero, Ansible después" (recomendado en el aula)¶
sequenceDiagram
participant Dev as Operador
participant TF as Terraform
participant AWS as AWS API
participant ANS as Ansible
participant EC2 as EC2 nodes
Dev->>TF: terraform apply
TF->>AWS: Crea VPC, SG, EC2
AWS-->>TF: IDs e IPs
TF-->>Dev: outputs (IPs, DNS)
Dev->>ANS: ansible-playbook (inventario dinámico)
ANS->>EC2: SSH + configuración
EC2-->>ANS: changed/ok
Ventajas: límites claros entre IaC e CM, fácil depuración, scope reducido.
6.2. Patrón "Terraform invoca Ansible"¶
Con el provisioner local-exec:
resource "null_resource" "configure" {
depends_on = [aws_instance.node]
provisioner "local-exec" {
command = "ansible-playbook -i inventory_aws_ec2.yml site.yml"
}
}
Útil para pipelines CI/CD, pero acopla las herramientas. Se recomienda solo cuando el equipo ya tiene experiencia.
6.3. Patrón "user_data" para arranque mínimo¶
Terraform pasa un script Bash a la EC2 en su primer arranque para que se "auto-registre" o instale el agente Ansible/python:
resource "aws_instance" "node" {
# ...
user_data = <<-EOF
#!/bin/bash
apt-get update
apt-get install -y python3
EOF
}
Buena práctica
Deja user_data con lo estrictamente imprescindible para que Ansible pueda conectar (python, claves). Todo lo demás, en playbooks; así mantienes la separación de responsabilidades.
7. Operación y mantenimiento¶
Una vez automatizado el despliegue, la operación es el día a día del sistema.
7.1. Tareas operativas típicas¶
| Tarea | Herramienta | Frecuencia |
|---|---|---|
| Aplicar parches de seguridad | unattended-upgrades + playbook update.yml |
Semanal |
| Renovar certificados TLS | certbot desde Ansible |
Mensual / automático |
| Comprobar backups | Script verify-backup.sh + cron |
Diario |
| Rotación de logs | logrotate (configurado por Ansible) |
Diario |
| Restauración en staging | mysql < backup.sql (probado periódicamente) |
Mensual |
| Revisión de alarmas | CloudWatch / Prometheus | Continua |
| Inventario actualizado | ansible-inventory --graph |
Antes de cada cambio |
7.2. Playbook de actualización segura¶
---
- name: Actualizar paquetes con ventana de mantenimiento
hosts: all
become: true
serial: 1 # uno por uno, evita caídas simultáneas
max_fail_percentage: 0
tasks:
- name: Actualizar caché
ansible.builtin.apt:
update_cache: true
- name: Aplicar upgrades de seguridad
ansible.builtin.apt:
upgrade: dist
autoremove: true
register: upgrade_result
- name: Reiniciar si lo pide el kernel
ansible.builtin.reboot:
msg: "Reinicio tras actualización del kernel"
when: upgrade_result.changed
and ansible_facts['kernel'] is defined
and lookup('file', '/var/run/reboot-required', errors='ignore') is not none
7.3. Monitorización básica¶
| Stack | Componentes mínimos | Despliegue |
|---|---|---|
| Prometheus | Node Exporter + Prometheus Server + Alertmanager | Compose + rol Ansible |
| CloudWatch (AWS) | CloudWatch Agent + alarmas (CPUUtilization, StatusCheckFailed) |
Instalado por Ansible; alarmas opcionalmente con Terraform |
| Pandora FMS | Servidor + agentes en cada nodo | Rol Ansible que despliega el agente |
Ejemplo de alarma CloudWatch en Terraform:
resource "aws_cloudwatch_metric_alarm" "cpu_web" {
alarm_name = "${var.project}-cpu-web"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "CPUUtilization"
namespace = "AWS/EC2"
period = 300
statistic = "Average"
threshold = 80
dimensions = { InstanceId = aws_instance.node["web"].id }
alarm_description = "CPU > 80% en el servidor web"
}
8. Buenas prácticas profesionales DevOps¶
Decálogo aplicado a vuestro proyecto
- Una fuente de verdad: Git. Si no está en Git, no existe.
- Pull Request siempre: ningún cambio se aplica sin revisión de otro miembro del equipo.
planantes deapply: Terraform;--check --diffen Ansible.- Idempotencia obligatoria: segundo
applyconchanged=0. - Secretos cifrados:
ansible-vaultyterraform.tfvarsfuera de Git. - Tags y nombres consistentes:
Project,Role,Environmenten todos los recursos. - Roles pequeños y reutilizables: un rol = una responsabilidad.
- Documentación viva:
README.mden cada carpeta con cómo usar y cómo probar. - Pruebas: al menos un playbook de validación que verifique que los servicios responden.
- Destruir lo que no se usa: en el aula,
terraform destroyal acabar la sesión para no consumir saldo.
8.1. Seguridad básica en automatización¶
| Riesgo | Mitigación |
|---|---|
| Secretos en claro en repositorio | ansible-vault, .gitignore, SOPS, AWS Secrets Manager |
| Estado Terraform expuesto | Backend remoto S3 con cifrado + DynamoDB para locking |
| SSH abierto al mundo | SG restringido al CIDR del aula / VPN |
Usuario root por SSH |
PermitRootLogin no + claves obligatorias |
| Credenciales hardcoded en playbook | Variables + Vault + no_log: true en tareas sensibles |
| Acción destructiva sin confirmación | terraform plan revisado + --check en Ansible |
Ejemplo de tarea sensible:
- name: Crear usuario con contraseña
ansible.builtin.user:
name: appuser
password: "{{ vault_appuser_hash }}"
no_log: true
9. Troubleshooting y errores comunes¶
9.1. Terraform¶
| Error | Causa habitual | Solución |
|---|---|---|
Error: error loading state |
Estado corrupto o mal ubicado | Restaurar terraform.tfstate.backup o usar backend remoto versionado |
InvalidKeyPair.NotFound |
El key_name no existe en esa región |
Crear el key pair en la consola o cambiar región |
UnauthorizedOperation |
Credenciales con permisos insuficientes | Revisar IAM o usar AWS Academy Learner Lab |
terraform apply cuelga |
user_data con script bloqueante |
Ejecutar el script con & o moverlo a Ansible |
Cycle: ... depends on ... |
Referencias circulares entre recursos | Romper la dependencia con un recurso null_resource o reordenar |
9.2. Ansible¶
| Error | Causa habitual | Solución |
|---|---|---|
UNREACHABLE! Permission denied |
Clave SSH incorrecta o usuario erróneo | Verificar ansible_user, ansible_ssh_private_key_file |
sudo: a password is required |
Falta become: true o become_pass |
Añadir --ask-become-pass o NOPASSWD en sudoers |
Failed to lock apt |
Otro proceso apt corriendo |
Esperar / wait_for: path=/var/lib/dpkg/lock-frontend state=absent |
changed siempre que ejecutas |
Tarea no idempotente (típico con command) |
Usar módulo apropiado o creates: / changed_when: |
Failed to import the required Python library (PyMySQL) |
Falta python3-pymysql en el host destino |
Instalarlo en el rol antes de usar módulos mysql_* |
| Inventario dinámico vacío | Filtros mal puestos o región incorrecta | Probar con ansible-inventory --graph y revisar regions: y filters: |
9.3. Integración¶
| Error | Causa habitual | Solución |
|---|---|---|
| Ansible no encuentra las IPs | Inventario dinámico desactualizado | Ejecutar terraform apply antes y refrescar cache (--flush-cache) |
SSH falla justo tras apply |
EC2 todavía arrancando | wait_for_connection al inicio del play o sleep |
| Variables Terraform → Ansible perdidas | No se exportó terraform output -json |
Generar tf_outputs.json y leerlo con lookup('file', ...) |
10. Checklists¶
10.1. Checklist de despliegue (pre-vuelo)¶
- Repositorio Git con
terraform/,ansible/,README.md. -
.gitignorecon.terraform/,*.tfstate*,*.tfvars,vault_pass,*.pem. -
terraform.tfvars.exampleactualizado con todas las variables. -
terraform fmtyterraform validatesin errores. -
terraform planrevisado por un compañero. - Variables sensibles en
ansible-vault. -
ansible-lint site.ymlsin errores críticos. - Ejecución en
--checkrevisada.
10.2. Checklist de operación (día a día)¶
- Backups verificados en las últimas 24 h.
- Alarmas CloudWatch / Prometheus en estado OK.
-
unattended-upgradesactivo y log revisado semanalmente. -
last,journalctl -p err,fail2ban-client statussin novedades preocupantes. - Espacio en disco < 80 %.
- Estado de Terraform sin drift (
terraform planno propone cambios). - Documentación de operación al día en
docs/operacion.md.
10.3. Checklist de cierre de sesión (aula)¶
-
terraform destroyejecutado si no se va a continuar. - No quedan EIP "huérfanas" (cobran aunque no estén asociadas).
- Commits pendientes hechos y pusheados.
- Diario de aprendizaje del día actualizado.
11. Estructura de la sección "Despliegue y operación" en tu memoria¶
La memoria del proyecto debe incluir un capítulo Despliegue y operación con:
- Descripción de la automatización: qué hace Terraform, qué hace Ansible y por qué.
- Estructura del repositorio IaC/CM: árbol de carpetas comentado.
- Manual de despliegue paso a paso: cualquier persona del equipo (o el profesor) debe poder ejecutarlo.
- Variables y secretos: dónde se definen, cómo se cifran y cómo se piden en ejecución.
- Evidencias: capturas de
terraform plan,terraform apply,ansible-playbook(con resumenok / changed / failed). - Pruebas de reproducibilidad: doble ejecución con
changed=0documentada. - Operación: tareas programadas, monitorización y procedimiento de recuperación ante caída.
- Lecciones aprendidas: problemas reales y cómo se resolvieron.
12. Actividades y entregables¶
- RG601. (RA3+RA4 // 30p). Aprovisionamiento, configuración y operación con Ansible del stack del proyecto Docker (línea PR402): roles
common,docker_host,stack_compose,backupsymonitoring, secretos conansible-vault, idempotencia verificada y manual de operación.- Proyecto de referencia: Proyecto Intermodular Docker (IJJ) — Repositorio.
- RG602. (RA3+RA4 // 30p). Despliegue automatizado con Terraform + Ansible del entorno AWS (línea PR403 + continuidad AWS): VPC, subredes, SG, EC2 (
web,app,db,dns), Bind9, MySQL en EC2, hardening, monitorización CloudWatch básica y operación.- Proyecto de referencia: Proyecto Intermodular AWS (Alejandro Mariño) — Repositorio.
12.1. Actividades complementarias¶
| Código | Tipo | Descripción | Puntos |
|---|---|---|---|
| AC601 | Crear un rol Ansible mínimo (common) y aplicarlo a una VM Vagrant o EC2 de pruebas; comprobar idempotencia. |
1-3p | |
| AC602 | Convertir un docker-compose.yml existente en una plantilla Jinja2 con al menos 3 variables. |
1-3p | |
| AC603 | Escribir un módulo Terraform reutilizable para crear EC2 + SG + EIP. | 1-3p | |
| AP601 | Configurar un backend remoto Terraform con S3 + DynamoDB. | Extra | |
| AP602 | Pipeline GitHub Actions que ejecute terraform plan automático en cada Pull Request. |
Extra | |
| AP603 | Desplegar Prometheus + Grafana como rol Ansible y construir un dashboard con métricas del proyecto. | Extra |
13. Recursos y referencias¶
Documentación oficial¶
- Terraform — Documentación
- Ansible — Documentación
- Ansible Galaxy — Roles y colecciones
- Terraform AWS Provider
- Amazon EC2 dynamic inventory plugin
Proyectos reales del alumnado ASIR¶
- Docker: Proyecto Intermodular 2 ASIR IJJ (GitHub).
- AWS: Proyecto Intermodular — Alejandro Mariño (GitHub).
Repositorios y guías de apoyo¶
- terraform-aws-examples (santos-pardos)
- tf-multi-tier-architecture (santos-pardos)
- Ansible Best Practices (Red Hat)
- Terraform — Style Guide
Material relacionado del ciclo¶
- ASO — Automatización y monitorización: IaC, Terraform, CloudWatch, CloudTrail y EventBridge aplicado a TechCorp.
- ASO — Configuración integrada: introducción a Ansible, inventarios, playbooks y aprovisionamiento.
- PI — Tema 5: Integración, seguridad y validación — punto de partida para RG601/RG602.
Glosario de términos y acrónimos¶
- IaC: Infrastructure as Code (Infraestructura como Código)
- CM: Configuration Management (Gestión de Configuración)
- HCL: HashiCorp Configuration Language (lenguaje de Terraform)
- Playbook: fichero YAML con la secuencia de plays y tasks en Ansible
- Rol: unidad reutilizable de Ansible con estructura estandarizada
- Inventario: lista de hosts gestionados, estática o dinámica
- Handler: tarea que se ejecuta solo si otra la notifica (típico para reiniciar servicios)
- Idempotencia: propiedad por la que aplicar N veces la misma configuración produce el mismo resultado
- Vault: mecanismo de Ansible para cifrar secretos en YAML
- State / tfstate: fichero JSON donde Terraform registra el estado real de los recursos
- Drift: desviación entre el estado real y el deseado
- Provisioner: mecanismo de Terraform para ejecutar comandos durante la creación de un recurso
- DevOps: cultura y prácticas que integran desarrollo y operaciones para entregas rápidas y fiables
- RA3 / RA4: Resultados de Aprendizaje 3 (Desarrollo) y 4 (Documentación, versionado y despliegue)
Conclusión y siguientes pasos¶
Al cerrar este tema tu proyecto debe poder destruirse y volver a montarse desde cero en una sesión de prácticas: terraform apply + ansible-playbook + tiempo de café. Esa es la prueba definitiva de que pasaste de una configuración manual a una infraestructura operable como código.
En el Tema 7 recogerás toda esta automatización en una memoria técnica profesional con MkDocs, GitHub y GitHub Pages, lista para ser leída, evaluada y reutilizada.