VMs apagadas que siguen cobrando cómputo
Encuentra máquinas virtuales detenidas sin desasignar: se apagaron desde el sistema operativo, siguen reservando hardware y Azure las sigue cobrando como encendidas.
Solo lecturaPide 1 permisoProbado con respuestas simuladas. Sin ejecutar en un tenant real.
Ejemplo en México
Agencia aduanal en Nuevo Laredo, 80 personas
El equipo apaga los servidores de pruebas con "Apagar" desde Windows al salir y cree que con eso ya no se cobran.
.\get-vms-detenidas-sin-desasignar.ps1 -RutaCsv .\vms-detenidas.csvEncuentra seis VMs cobrando sin uso, las desasigna y corrige el procedimiento de cierre.
Cómo implementarlo
- Ejecuta el script y abre el resultado.
- Vuelve a ejecutarlo unos minutos después: una VM puede verse detenida un momento mientras arranca.
- Confirma con el dueño de cada VM que puede quedar apagada.
- Desasígnala con el botón Detener del portal o con set-apagado-vms-por-etiqueta, y explica al equipo que apagar desde Windows o Linux no deja de cobrar.
Ver el código completo de get-vms-detenidas-sin-desasignar.ps1
<#
.SYNOPSIS
¿Qué máquinas virtuales están apagadas pero siguen cobrando cómputo?
.DESCRIPTION
Busca las VMs en estado PowerState/stopped. Una VM en ese estado sigue cobrando cómputo; desasignada, no. Solo lectura: no apaga ni desasigna nada.
.PARAMETER Suscripciones
IDs o nombres de suscripción. Si se omite, usa todas a las que tienes acceso.
.PARAMETER RutaCsv
Ruta del CSV de salida. Si se omite, solo se muestra en pantalla.
.EXAMPLE
.\get-vms-detenidas-sin-desasignar.ps1 -RutaCsv .\vms-detenidas.csv
.NOTES
Permisos: Lector (Reader) en cada suscripción
Rol mínimo: Lector (Reader)
Licencia: Basta una suscripción de Azure; no requiere plan adicional.
Licencia del código: MIT. Se entrega TAL CUAL, sin garantía. Pruébalo primero en una suscripción de pruebas.
#>
#Requires -Version 7.0
[CmdletBinding()]
param(
[ValidateNotNullOrEmpty()][string[]]$Suscripciones,
[string]$RutaCsv
)
$ErrorActionPreference = 'Stop'
if (-not (Get-Command Connect-AzAccount -ErrorAction SilentlyContinue)) {
throw 'Falta el módulo. Instálalo con: Install-Module Az.Accounts -Scope CurrentUser'
}
if (-not (Get-AzContext)) { $null = Connect-AzAccount }
function InvocarAzure($Metodo, $Ruta, $Cuerpo) {
$parametros = @{ Method = $Metodo }
if ($Ruta -like 'http*') { $parametros.Uri = $Ruta } else { $parametros.Path = $Ruta }
if ($Cuerpo) { $parametros.Payload = $Cuerpo | ConvertTo-Json -Depth 10 }
# Invoke-AzRestMethod no lanza error con 4xx o 5xx. 429 es una limitación temporal: se reintenta.
for ($intento = 1; ; $intento++) {
$respuesta = Invoke-AzRestMethod @parametros
if ($respuesta.StatusCode -ne 429 -or $intento -ge 4) { break }
Start-Sleep -Seconds 30
}
if ($respuesta.StatusCode -ge 400) { throw "Azure respondió $($respuesta.StatusCode) en ${Ruta}: $($respuesta.Content)" }
if ($respuesta.Content) { $respuesta.Content | ConvertFrom-Json -AsHashtable }
}
function ListarPaginado($Ruta) {
while ($Ruta) {
$pagina = InvocarAzure GET $Ruta
$pagina.value
$Ruta = $pagina.nextLink
}
}
$activas = @(ListarPaginado '/subscriptions?api-version=2022-12-01' | Where-Object {
$_.state -notin 'Disabled', 'Deleted' -and
(-not $Suscripciones -or $_.subscriptionId -in $Suscripciones -or $_.displayName -in $Suscripciones)
})
if (-not $activas) { throw 'No hay suscripciones activas a las que tengas acceso con ese filtro.' }
$noEncontradas = @($Suscripciones | Where-Object { $_ -and $_ -notin $activas.subscriptionId -and $_ -notin $activas.displayName })
if ($noEncontradas) { throw "No se encontró o no está activa: $($noEncontradas -join ', '). Revisa -Suscripciones." }
$nombres = @{}
foreach ($s in $activas) { $nombres[$s.subscriptionId] = $s.displayName }
function ConsultarGrafo($Consulta) {
$ids = @($activas.subscriptionId)
# Resource Graph admite hasta 1,000 suscripciones por consulta.
for ($i = 0; $i -lt $ids.Count; $i += 1000) {
$lote = @($ids[$i..([Math]::Min($i + 999, $ids.Count - 1))])
$cuerpo = @{ query = $Consulta; subscriptions = $lote; options = @{ '$top' = 1000 } }
do {
$pagina = InvocarAzure POST '/providers/Microsoft.ResourceGraph/resources?api-version=2024-04-01' $cuerpo
$pagina.data
$cuerpo.options['$skipToken'] = $pagina.'$skipToken'
} while ($pagina.'$skipToken')
}
}
$consulta = 'resources | where type =~ "microsoft.compute/virtualmachines" | project id, name, resourceGroup, subscriptionId, location, tamano = tostring(properties.hardwareProfile.vmSize), estado = tostring(properties.extended.instanceView.powerState.code)'
$filas = @(foreach ($vm in ConsultarGrafo $consulta) {
if ($vm.estado -eq 'PowerState/stopped') {
[pscustomobject]@{
Suscripcion = $nombres[$vm.subscriptionId]
GrupoRecursos = $vm.resourceGroup
VM = $vm.name
Tamano = $vm.tamano
Region = $vm.location
Estado = $vm.estado
Id = $vm.id
}
}
})
Write-Host "VMs detenidas sin desasignar: $($filas.Count)"
if ($filas.Count -eq 0) {
Write-Host 'Sin resultados.'
# Sin esto, el CSV de una corrida anterior parecería el resultado de esta.
if ($RutaCsv) { Set-Content -Path $RutaCsv -Value '' -Encoding utf8BOM }
} elseif ($RutaCsv) {
# UTF-8 con BOM para que Excel en español muestre bien los acentos.
$filas | Export-Csv -Path $RutaCsv -NoTypeInformation -Encoding utf8BOM
Write-Host "CSV guardado en $RutaCsv"
} else {
$filas | Format-Table -AutoSize
}