Hace unos días pregunté en r/devsarg cómo encuentra cada uno los discos sin adjuntar y las IPs huérfanas antes de que aparezcan en la factura (https://www.reddit.com/r/devsarg/comments/1xqhlcb/). Manejo cuentas de OCI, Azure y AWS, y cada tanto aparece un disco de 200 GB que quedó de una VM borrada hace meses. Hubo 48 comentarios y casi todos contaban la misma historia con otro nombre: el disco que alguien descubre un año después, el recurso de diez años que existe desde antes de que hubiera política de tags, los volúmenes de Kubernetes que el driver CSI desadjuntó y nadie volvió a mirar. En r/googlecloud, la misma semana, alguien había hecho la limpieza de VMs de dev y la factura casi no se movió: lo que crecía mes a mes era storage, incluidos snapshots de discos que ya no existían.

Esta nota es la respuesta larga a esas dos preguntas. Qué recursos nacen con la VM y no mueren con ella en cada nube, un listado de sólo lectura por proveedor para verlos, y lo que ese listado no puede decirte.

Por qué pasa: la consola lista VMs, la factura lista recursos

La página de instancias muestra instancias. El disco, la IP pública y el snapshot son recursos aparte, con su propio ID y su propia línea en la factura. Cuando borrás la VM, cada uno de ellos sigue la regla que tenía configurada, y esa regla casi nunca la eligió nadie a conciencia.

  • AWS. Cada volumen EBS tiene un atributo DeleteOnTermination. Según la guía de EC2, el volumen raíz creado al lanzar la instancia se borra con ella; un volumen de datos agregado en el lanzamiento desde la consola se conserva (desde la CLI se borra), y cualquier volumen adjuntado después del lanzamiento se conserva. Los volúmenes conservados se siguen cobrando. La Elastic IP es un recurso independiente: desde febrero de 2024 AWS cobra USD 0,005 por hora por cada IPv4 pública, y la tarifa es la misma para una IP en uso que para una ociosa. Los snapshots nunca dependen del volumen: sobreviven al volumen y a la instancia.
  • GCP. Cada disco adjunto tiene autoDelete. Para el disco de arranque creado con la instancia el valor por defecto es sí; para un disco existente que adjuntás con --disk, el valor por defecto es no, y la referencia de gcloud aclara que si el disco se desadjunta después, la opción deja de aplicar. Una dirección externa estática reservada queda en estado RESERVED cuando no está asignada a nada, y la página de precios de red dice que una IP estática reservada sin asignar se cobra a una tarifa más alta que una en uso. Los snapshots guardan el nombre del disco de origen en sourceDisk, pero no se borran con él.
  • Azure. La IP pública, la NIC y el disco del sistema operativo son recursos separados de la VM. Al crearla, cada uno tiene un deleteOption que puede ser Delete o Detach; el portal marca por defecto "Delete with VM" para el disco del SO, pero el valor por defecto cuando creás la VM desde la CLI o PowerShell es Detach, y la IP pública tiene su propia casilla. Un disco que quedó suelto aparece con diskState en Unattached.
  • OCI. Según la documentación de Compute, al terminar una instancia el boot volume se conserva por defecto; borrarlo con la instancia es una opción que hay que marcar. Los block volumes se desadjuntan y quedan en lifecycleState AVAILABLE, que es el mismo estado que tienen cuando están adjuntos, así que el estado solo no alcanza. Una IP pública reservada que no está asignada a nada tiene assignedEntityId en null.

Los cuatro listados, de sólo lectura

Ninguno de estos comandos borra nada. Todos salen de la referencia oficial de cada CLI. Dejo los campos que después sirven para decidir: tamaño, fecha de creación y tags.

# AWS: volúmenes sin instancia, Elastic IPs sin asociación y snapshots propios con su volumen de origen aws ec2 describe-volumes --filters Name=status,Values=available --query "Volumes[].{id:VolumeId,gb:Size,created:CreateTime,tags:Tags}" aws ec2 describe-addresses --query "Addresses[?AssociationId==null].[PublicIp,AllocationId]" aws ec2 describe-snapshots --owner-ids self --query "Snapshots[].[SnapshotId,VolumeId,VolumeSize,StartTime]" # cruzar el VolumeId de cada snapshot contra describe-volumes: si no está, el disco ya no existe # GCP: discos sin usuarios, direcciones estáticas reservadas sin asignar y snapshots con su disco de origen gcloud compute disks list --filter="-users:*" --format="table(name,zone.basename(),sizeGb,creationTimestamp)" gcloud compute addresses list --filter="status=RESERVED" gcloud compute snapshots list --format="table(name,sourceDisk.basename(),diskSizeGb,creationTimestamp)" # Azure: discos Unattached, IPs públicas sin ipConfiguration y snapshots con su recurso de origen az disk list --query "[?diskState=='Unattached'].{name:name,rg:resourceGroup,gb:diskSizeGb,created:timeCreated}" -o table az network public-ip list --query "[?ipConfiguration==null].{name:name,rg:resourceGroup,ip:ipAddress}" -o table az snapshot list --query "[].{name:name,source:creationData.sourceResourceId,created:timeCreated}" -o table # OCI: AVAILABLE no significa suelto; el cruce es contra los attachments oci bv volume list --compartment-id "$C" --lifecycle-state AVAILABLE --all --query 'data[].{id:id,name:"display-name",gb:"size-in-gbs",created:"time-created"}' oci compute volume-attachment list --compartment-id "$C" --all --query 'data[?"lifecycle-state"==`ATTACHED`]."volume-id"' # boot volumes que sobrevivieron a un terminate: mismo cruce, por availability domain oci bv boot-volume list --compartment-id "$C" --availability-domain "$AD" --all oci compute boot-volume-attachment list --compartment-id "$C" --availability-domain "$AD" --all # IPs públicas reservadas sin asignar oci network public-ip list --compartment-id "$C" --scope REGION --lifetime RESERVED --all --query 'data[?"assigned-entity-id"==null].{ip:"ip-address",state:"lifecycle-state"}' 

Dos detalles de los listados. En OCI el estado del volumen no distingue adjunto de suelto, por eso van dos comandos y una resta. Y en las cuatro nubes las IPs merecen su propia pasada, porque son el único de estos recursos que cuesta lo mismo en uso que sin usar; el detalle por proveedor está en IPs públicas que cobran.

Lo que el listado no te dice

El listado dice qué disco está suelto. No dice si es basura. Eso fue lo que más se discutió en el hilo, y creo que es la parte difícil de verdad:

  • Desde cuándo está suelto. describe-volumes devuelve CreateTime, no la fecha del detach. Ningún proveedor guarda el momento en que el disco se quedó sin VM; si querés un período de gracia de 14 o 30 días, el reloj lo ponés vos: un tag con la fecha de la primera vez que lo viste, o una tabla que lleve el registro entre corridas.
  • Quién lo creó. Sale del audit log del proveedor, no del recurso. Y el recurso de hace diez años, anterior a la política de tags, no tiene owner ni centro de costos: a ese no lo resuelve ningún filtro, lo resuelve alguien que lo reconoce.
  • Los volúmenes de Kubernetes. Un PersistentVolume con persistentVolumeReclaimPolicy: Retain sobrevive al borrado del PVC: el PV queda en Released y el disco de la nube sigue existiendo, suelto, con el PV todavía apuntándole. Para el listado es un disco sin adjuntar más; para el equipo que lo dejó en Retain, es un backup.
  • El disco con los mdf y ldf de un SQL Server de 2018 que alguien dejó a propósito. Un comentario del hilo lo describía tal cual, y es el motivo por el que el borrado automático sin revisión humana casi nadie lo tiene en producción.

Un script lista; una persona decide. El script que salió del hilo está en GitHub como cloud-orphans: lista discos sin adjuntar e IPs sin asociar en las cuatro nubes, con la edad de cada uno, y no borra nada. Hoy la salida contra OCI es real y el resto está con datos de prueba, y todavía no guarda la primera vez que vio cada recurso, que es justo lo que hace falta para el período de gracia.

Verlo sin correr los cuatro listados

Control Cloud lee la cuenta por la API del proveedor con una credencial de sólo lectura y dibuja el mapa de lo que hay: la VM, su subred, sus discos, sus IPs. Un disco o una IP que quedó sin VM aparece en el mapa sin nada adjunto, así que se ve sin correr los cuatro listados ni cruzar salidas a mano. Lo que no hace es decidir qué borrar: eso sigue siendo tuyo. Las guías por proveedor explican cómo conectar cada cuenta de AWS, GCP, Azure u OCI con permisos de lectura, y si querés probarlo con la tuya, pedí acceso.