🖥️ Despliegue de máquinas virtuales¶
1. Anatomía de una VM en Azure¶
Grupo de recursos
├── Red virtual 10.0.0.0/16
│ └── Subred snet-vm 10.0.1.0/24 ─────────────┐
├── NSG nsg-vm (regla inline: SSH desde mi IP) ─┤ asociación
├── IP pública pip-vm (Standard, estática) │
├── NIC nic-vm ◄─── subred + NSG + IP pública ──┘
│ ip privada 10.0.1.10
└── VM vm-lab ◄─── NIC + disco SO + imagen Ubuntu 22.04 + clave SSH
| Recurso Terraform | Resource provider | Función | En Topaz |
|---|---|---|---|
azurerm_virtual_network |
Microsoft.Network | Espacio de direcciones privado | ✅ (no devuelve private_endpoint_vnet_policies) |
azurerm_subnet |
Microsoft.Network | Rango donde la NIC toma su IP privada | ✅ |
azurerm_network_security_group con bloque security_rule |
Microsoft.Network | Cortafuegos de capa 4: qué puertos, desde dónde | ✅ solo reglas inline; azurerm_network_security_rule ❌ |
azurerm_public_ip |
Microsoft.Network | Dirección alcanzable desde Internet | ✅ recurso; la dirección asignada es simulada |
azurerm_network_interface + _security_group_association |
Microsoft.Network | Tarjeta de red: une subred, IP pública y NSG | ✅ (no devuelve public_ip_address_id) |
azurerm_linux_virtual_machine |
Microsoft.Compute | Cómputo, disco e imagen | ❌ solo Azure real |
La cadena de referencias (subnet_id = azurerm_subnet.vm.id, network_interface_ids = [azurerm_network_interface.vm.id]…) es lo que permite a Terraform ordenar la creación sin que escribas ningún depends_on.
2. Preparar el directorio y las variables¶
mkdir -p ~/tf-vm && cd ~/tf-vm
cp ~/tf-intro/providers.tf . # imprescindible: fija azurerm ~> 4.0 y apunta el provider a Topaz
ls # providers.tf ← y nada más, de momento
⚠️ Copia solo
providers.tf. Si copias también elmain.tfde la página 1, tendrás el grupo de recursos y la red virtual declarados dos veces (allí y enred.tf) yvalidatefallará con Duplicate resource. En este laboratorio cada recurso vive en su archivo:red.tf,nic.tf,vm.tf,variables.tfyoutputs.tf. Y si al hacerinitves Finding latest version of hashicorp/azurerm e instala una 5.x, es que faltaproviders.tf: sin la versión fijada, elplanfallará.
# variables.tf
variable "desplegar_vm" {
type = bool
description = "Crear la máquina virtual (requiere Microsoft.Compute: false en Topaz, true en Azure real)"
default = false
}
variable "ip_admin" {
type = string
description = "IP pública desde la que se permite SSH, en formato CIDR (p. ej. 203.0.113.7/32)"
validation {
condition = can(cidrhost(var.ip_admin, 0))
error_message = "Debe ser un CIDR válido, como 203.0.113.7/32."
}
}
variable "admin_username" {
type = string
description = "Usuario administrador de la VM"
default = "azureuser"
}
variable "ruta_clave_publica" {
type = string
description = "Ruta a la clave pública SSH"
default = "~/.ssh/tf-lab.pub"
}
variable "tamano_vm" {
type = string
description = "SKU de la VM"
default = "Standard_B2ats_v2" # serie B v2: barata, apta para laboratorio
}
3. Plano de red: red, subred y NSG¶
# red.tf
resource "azurerm_resource_group" "lab" {
name = "rg-vm-001"
location = "eastus"
tags = { entorno = "lab", gestion = "terraform" }
lifecycle { ignore_changes = [tags] } # Topaz no devuelve las tags del grupo
}
resource "azurerm_virtual_network" "lab" {
name = "vnet-vm"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
address_space = ["10.0.0.0/16"]
lifecycle {
ignore_changes = [private_endpoint_vnet_policies] # Topaz no devuelve este atributo
}
}
resource "azurerm_subnet" "vm" {
name = "snet-vm"
resource_group_name = azurerm_resource_group.lab.name
virtual_network_name = azurerm_virtual_network.lab.name
address_prefixes = ["10.0.1.0/24"]
}
resource "azurerm_network_security_group" "vm" {
name = "nsg-vm"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
# Regla inline: viaja en el mismo PUT que el NSG (Topaz no implementa securityRules/<nombre>)
security_rule {
name = "Allow-SSH-Admin"
priority = 1001 # 100-4096; menor = se evalúa antes
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.ip_admin # NUNCA "*": eso es SSH abierto al mundo
destination_address_prefix = "*"
}
}
Las reglas de un NSG se pueden declarar de dos formas, y el resultado en Azure es idéntico:
| Forma | Cuándo conviene | En Topaz |
|---|---|---|
Bloque security_rule { } dentro del NSG (inline) |
Pocas reglas, gestionadas desde el mismo módulo que el NSG. No lleva resource_group_name ni network_security_group_name: los hereda |
✅ |
Recurso independiente azurerm_network_security_rule |
Muchas reglas, o añadidas desde módulos distintos al que crea el NSG. Recomendado en Azure real para equipos grandes | ❌ EndpointNotFound en PUT .../securityRules/<nombre> |
No mezcles las dos formas sobre el mismo NSG: Terraform eliminaría en cada apply las reglas independientes que no aparezcan en el bloque inline, y viceversa. Para referencia, la misma regla como recurso independiente (válida en Azure real):
# Alternativa para Azure real (NO en Topaz): regla como recurso independiente
resource "azurerm_network_security_rule" "ssh" {
name = "Allow-SSH-Admin"
priority = 1001
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = var.ip_admin
destination_address_prefix = "*"
resource_group_name = azurerm_resource_group.lab.name
network_security_group_name = azurerm_network_security_group.vm.name
}
⚠️ Sobre la regla SSH. El original usaba
source_address_prefix = "*". Una VM con el 22 abierto a Internet recibe miles de intentos de acceso por hora desde el primer minuto; Microsoft Defender lo marca como alerta de severidad alta. Restringe siempre a tu IP, y en producción elimina la IP pública y entra por Azure Bastion o una VPN.🔷 En Topaz: el patrón
ignore_changes. El emulador acepta todos los argumentos que le envías, pero al leer el recurso no devuelve algunos (tagsdel grupo,private_endpoint_vnet_policiesde la vnet,public_ip_address_idde la NIC). Terraform interpreta la ausencia como una desviación y propone "corregirla" en cadaapply: verás Modifying… sobre recursos que no has tocado y elplannunca llegará a No changes.lifecycle { ignore_changes = [...] }le dice que no compare ese atributo. Ninguno de los tres es necesario en Azure real.
4. IP pública e interfaz de red¶
# nic.tf
resource "azurerm_public_ip" "vm" {
name = "pip-vm"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
sku = "Standard" # Basic está retirado; Standard exige Static
allocation_method = "Static"
}
resource "azurerm_network_interface" "vm" {
name = "nic-vm"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
ip_configuration {
name = "internal"
subnet_id = azurerm_subnet.vm.id
private_ip_address_allocation = "Static"
private_ip_address = "10.0.1.10" # las 4 primeras y la última IP de cada subred están reservadas
public_ip_address_id = azurerm_public_ip.vm.id
}
lifecycle {
ignore_changes = [ip_configuration[0].public_ip_address_id] # Topaz no devuelve este atributo
}
}
resource "azurerm_network_interface_security_group_association" "vm" {
network_interface_id = azurerm_network_interface.vm.id
network_security_group_id = azurerm_network_security_group.vm.id
}
Dos cambios respecto al original. La IP pública Dynamic con SKU Basic dejó de poder crearse en 2025; con Standard la dirección es estática y se conoce en el apply. Y la IP privada se fija a 10.0.1.10: en una VM de laboratorio da igual, pero en cuanto algo (un DNS, una regla de cortafuegos) apunte a esa dirección, querrás que sobreviva a un reemplazo de la máquina.
🔷 En Topaz. Los tres recursos de este archivo se crean en el emulador, incluida la IP pública con
sku = "Standard". La dirección asignada es simulada (no enrutable) y la NIC muestra la privada que fijaste. La asociación con la IP pública se guarda pero no se devuelve al leer la NIC: de ahí elignore_changes. Sin él, cadaapplymostraría+ public_ip_address_iden la NIC aunque nada haya cambiado.
5. La máquina virtual (activable)¶
# vm.tf
resource "azurerm_linux_virtual_machine" "lab" {
count = var.desplegar_vm ? 1 : 0 # 0 en Topaz, 1 en Azure real
name = "vm-lab"
resource_group_name = azurerm_resource_group.lab.name
location = azurerm_resource_group.lab.location
size = var.tamano_vm
admin_username = var.admin_username
network_interface_ids = [azurerm_network_interface.vm.id]
disable_password_authentication = true # solo clave SSH
admin_ssh_key {
username = var.admin_username
public_key = file(pathexpand(var.ruta_clave_publica))
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference { # Ubuntu 22.04 LTS Gen2 (el offer "UbuntuServer" ya no existe)
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
tags = azurerm_resource_group.lab.tags
}
| Argumento | Qué decide |
|---|---|
size |
CPU/RAM y precio. Consulta disponibilidad: az vm list-skus -l eastus --size Standard_B -o table |
source_image_reference |
Imagen del marketplace. Lista actual: az vm image list -p Canonical --all -o table. Para 24.04: offer ubuntu-24_04-lts, sku server |
os_disk.storage_account_type |
Standard_LRS (HDD) para laboratorio; Premium_LRS en producción |
pathexpand() |
Resuelve ~; file() a secas no lo hace en todos los sistemas |
Con count, la VM pasa a ser una lista: se referencia como azurerm_linux_virtual_machine.lab[0], y los outputs que dependen de ella deben tolerar que la lista esté vacía (siguiente sección).
6. Outputs¶
# outputs.tf
output "ip_publica" {
description = "IP pública asociada a la NIC"
value = azurerm_public_ip.vm.ip_address
}
output "ip_privada" {
description = "IP privada de la NIC"
value = azurerm_network_interface.vm.private_ip_address
}
output "vm_id" {
description = "ID de la VM, o null si no se ha desplegado"
value = one(azurerm_linux_virtual_machine.lab[*].id)
}
output "comando_ssh" {
description = "Comando de conexión (solo útil con la VM desplegada)"
value = var.desplegar_vm ? "ssh -i ${trimsuffix(var.ruta_clave_publica, ".pub")} ${var.admin_username}@${azurerm_public_ip.vm.ip_address}" : "VM no desplegada (desplegar_vm = false)"
}
one() devuelve el único elemento de una lista, o null si está vacía: es la forma idiomática de exponer un recurso con count condicional sin que el output falle. El original construía el comando SSH sin la IP; aquí sale completo, con la clave privada deducida de la pública.
7. Despliegue en Topaz¶
ls # nic.tf outputs.tf providers.tf red.tf terraform.tfvars variables.tf vm.tf
terraform init
# - Installing hashicorp/azurerm v4.x.x... ← si dice "Finding latest version" o instala 5.x, falta providers.tf
terraform validate
terraform plan
# Plan: 7 to add, 0 to change, 0 to destroy. ← grupo, vnet, subred, NSG (con su regla), pip, nic, asociación; sin VM
terraform apply -auto-approve
terraform output
# comando_ssh = "VM no desplegada (desplegar_vm = false)"
# ip_privada = "10.0.1.10"
# ip_publica = "<dirección simulada>"
# vm_id = null
# Verificar la cadena con la CLI
az network nsg show -g rg-vm-001 -n nsg-vm \
--query "securityRules[].{regla:name, prio:priority, puerto:destinationPortRange, origen:sourceAddressPrefix}" -o table
az network nic show -g rg-vm-001 -n nic-vm \
--query "{privada:ipConfigurations[0].privateIPAddress, subred:ipConfigurations[0].subnet.id, nsg:networkSecurityGroup.id}" -o json
terraform plan # No changes. ← gracias a los tres ignore_changes
Si ese último plan sigue proponiendo algún cambio, mira qué atributo marca con ~ o +: es un atributo más que tu versión del emulador no devuelve. Añádelo al ignore_changes del recurso y anótalo como diferencia.
| Limitación de Topaz en este laboratorio | Síntoma | Solución aplicada |
|---|---|---|
No implementa el endpoint securityRules/<nombre> |
EndpointNotFound (404) al crear azurerm_network_security_rule |
Bloque security_rule inline en el NSG |
No devuelve tags del grupo de recursos |
~ tags en cada plan |
ignore_changes = [tags] |
No devuelve private_endpoint_vnet_policies de la vnet |
Modifying… sobre la vnet en cada apply |
ignore_changes = [private_endpoint_vnet_policies] |
No devuelve public_ip_address_id de la NIC |
Modifying… sobre la NIC en cada apply |
ignore_changes = [ip_configuration[0].public_ip_address_id] |
No incluye Microsoft.Compute |
NoRegisteredProviderFound al aplicar la VM | count = var.desplegar_vm ? 1 : 0 |
Prueba ahora la validación: cambia ip_admin a "203.0.113.7" (sin /32) y ejecuta plan. Debe fallar antes de tocar el emulador con el mensaje que escribiste. Restaura el valor.
# Ver qué pasaría con la VM activada, sin aplicarlo
terraform plan -var desplegar_vm=true
# + azurerm_linux_virtual_machine.lab[0]
# Plan: 1 to add, 0 to change, 0 to destroy.
terraform destroy -auto-approve # limpieza
az group list -o table # vacío
🔷 En Topaz. El
plancondesplegar_vm=truefunciona porque planificar no llama aMicrosoft.Compute. Unapplycon ese valor fallaría en el emulador con un error de resource provider no encontrado: es la señal de que has llegado al límite de Topaz, no un error tuyo.
8. Despliegue y conexión en Azure real¶
Mismo directorio, con estos ajustes:
- En
providers.tf: quitarmetadata_hostyresource_provider_registrations, poner tusubscription_id. - Quitar los tres
lifecycle { ignore_changes }de Topaz (grupo, vnet y NIC). En Azure real esos atributos sí se devuelven y conviene que Terraform los vigile. - La regla inline del NSG se queda tal cual: funciona igual en Azure real.
az login && az account set -s "<tu suscripción>"
terraform init -reconfigure
terraform apply -var desplegar_vm=true # Plan: 8 to add (7 de red + VM); la VM tarda 1-2 minutos
$(terraform output -raw comando_ssh) # ejecuta el comando SSH generado
# azureuser@vm-lab:~$ uname -a
# Linux vm-lab 6.x ... Ubuntu
# Comprobaciones y coste
az vm show -g rg-vm-001 -n vm-lab -d --query "{estado:powerState, ip:publicIps, tamano:hardwareProfile.vmSize}" -o table
az vm deallocate -g rg-vm-001 -n vm-lab # parada sin coste de cómputo (el disco sigue cobrándose)
terraform destroy -var desplegar_vm=true -auto-approve # cuando termines: una B2ats_v2 cuesta ~8 €/mes encendida
⚠️
az vm stopno es gratis. Deja la VM en estado Stopped, que sigue facturando cómputo. Solodeallocate(odestroy) detiene el cargo. Y si hacesdeallocatecon IP pública Basic dinámica, la dirección cambia; con Standard estática, como aquí, se conserva.
9. Errores comunes¶
⚠️ Solución de problemas
Mensaje o síntoma Causa y solución Duplicate resource "azurerm_resource_group" configuration Has copiado main.tfde~/tf-introy el grupo o la vnet están declarados dos veces. Borramain.tf: en este laboratorio ambos viven enred.tfReference to undeclared resource azurerm_subnet.vmennic.tfAl resolver los duplicados has borrado red.tfen lugar demain.tf. Recréalo completo (sección 2.3): contiene también la subred y el NSGFinding latest version of hashicorp/azurerm e instala v5.xFalta providers.tfconversion = "~> 4.0". Cópialo de~/tf-intro, borra.terraform/y.terraform.lock.hcl, y repiteinitEndpointNotFound (404) en PUT .../securityRules/Allow-SSH-AdminTopaz no implementa azurerm_network_security_rule. Define la regla como bloquesecurity_ruledentro del NSG y elimina el recurso independiente (no llegó al estado, no hace faltastate rm)Modifying… en vnet o NIC en cada apply; elplannunca dice No changesEl emulador no devuelve private_endpoint_vnet_policiesnipublic_ip_address_id. Añade losignore_changesde las secciones 2.3 y 2.4NoRegisteredProviderFound: Microsoft.Compute en Topaz Has aplicado con desplegar_vm=trueen el emulador: soloplan; elapplyde la VM es para Azure realNo value for required variable "ip_admin" Falta terraform.tfvarso la clave está mal escrita:echo 'ip_admin = "203.0.113.7/32"' > terraform.tfvarsPublicIPAllocationMethodMustBeStatic / SKU Basic no disponible Usa sku = "Standard"+allocation_method = "Static"PlatformImageNotFound / offer UbuntuServer Imagen retirada: 0001-com-ubuntu-server-jammy/22_04-lts-gen2SkuNotAvailable / QuotaExceeded Tamaño no disponible en la región o cuota de suscripción gratuita: cambia tamano_vmo regiónPrivateIPAddressInReservedRange Las IPs .0-.3 y la última de cada subred son de Azure: usa .4 en adelante Invalid function argument: no file exists at ~/.ssh/... Genera la clave ( ssh-keygen) o corrigeruta_clave_publicaConnection timed out al hacer SSH Tu IP pública cambió respecto a ip_admin:curl -s ifconfig.me, actualiza yapplyPermission denied (publickey) Clave privada equivocada o usuario distinto de admin_usernameUnsupported attribute en azurerm_linux_virtual_machine.lab.idCon countes una lista:lab[0].idoone(lab[*].id)
10. Autoevaluación¶
- ¿Cuántos recursos de red necesita una VM con acceso SSH desde Internet y en qué orden se crean? Red virtual, subred, NSG con su regla, IP pública, NIC y asociación NIC-NSG (siete con el grupo de recursos); el orden lo deduce Terraform de las referencias.
- ¿Por qué el NSG se asocia a la NIC y no a la VM? El filtrado ocurre en la tarjeta de red (o en la subred); la VM no tiene reglas propias.
- ¿Qué diferencia hay entre una regla
security_ruleinline y un recursoazurerm_network_security_rule? El resultado en Azure es el mismo. La inline viaja en el mismo PUT que el NSG (la única que Topaz acepta); la independiente permite añadir reglas desde otros módulos. No se deben mezclar sobre el mismo NSG. - ¿Qué problema tiene
source_address_prefix = "*"en la regla SSH? Expone el puerto 22 a todo Internet; se restringe a la IP del administrador o se elimina la IP pública y se usa Bastion. - ¿Por qué el
planproponía cambios en la vnet y la NIC en cada ejecución, y cómo se resuelve? Topaz no devuelve algunos atributos al leer el recurso y Terraform lo interpreta como desviación.lifecycle { ignore_changes = [...] }excluye ese atributo de la comparación; en Azure real no hace falta. - ¿Para qué sirve
count = var.desplegar_vm ? 1 : 0? Hace la VM opcional: el mismo código despliega solo red en Topaz y red + VM en Azure real. - ¿Por qué
plan -var desplegar_vm=truefunciona en Topaz yapplyno? Planificar no llama a Microsoft.Compute; crear sí, y el emulador no lo implementa. - ¿Qué diferencia hay entre
az vm stopyaz vm deallocate? Stop apaga el SO pero sigue facturando cómputo; deallocate libera el hardware y solo se paga el disco.
11. Referencias¶
azurerm_linux_virtual_machine,azurerm_network_interface,azurerm_public_ip,azurerm_network_security_group(bloquesecurity_rule),azurerm_network_security_rule- Meta-argumento
lifecycle:ignore_changes - Grupos de seguridad de red y Azure Bastion
- IP públicas: SKU Standard y retirada de Basic
- Buscar imágenes de VM con Azure CLI
- Estados de una VM y facturación
- Función
one()y meta-argumentocount - Azure Local Emulator (Topaz)