Azure waste

Oversized virtual machines in Azure

A server bigger than the work it does
The agent fixes this
Reversible
Why it happens

Machines get sized on a guess before launch and nobody goes back. One size down usually costs about half.

Machines are sized from a launch estimate and almost never revisited. The workload settles at a fraction of the capacity and the bill never follows it down.

Paste this into Resource Graph Explorer in the Azure portal.

resources
| where type =~ 'microsoft.compute/virtualmachines'
// Scope below matches CloudFinOpsPro policy DEFAULTS: exclusions.protectTag, environment.tagKeys,
// environment.allowedTagValues, and allowUntagged false so an untagged resource counts as production.
// If your policy.yaml differs, edit the three lines below or this will disagree with your own agent.
| where tolower(tostring(coalesce(tags['cfop:protect'], ''))) !in ('true', 'yes', '1')
| extend cfopEnv = tolower(trim(' ', tostring(coalesce(tags['Environment'], tags['environment'], tags['env'], ''))))
| where cfopEnv in ('dev', 'development', 'test', 'qa', 'sandbox', 'nonprod', 'non-prod', 'staging')
| extend size = tostring(properties.hardwareProfile.vmSize)
// The sizes the agent can actually step down, paired with the size it would propose.
// smallerSize() returns nothing for the bottom of each ladder or for a size the ladder
// does not carry, and the check drops those, so listing them here would be a list the
// agent will never act on.
| join kind=inner (
    datatable(size: string, proposedSize: string) [
    'Standard_B1ms', 'Standard_B1s',
    'Standard_B1s', 'Standard_B1ls',
    'Standard_B2ms', 'Standard_B2s',
    'Standard_B2s', 'Standard_B1ms',
    'Standard_B4ms', 'Standard_B2ms',
    'Standard_B8ms', 'Standard_B4ms',
    'Standard_D16as_v5', 'Standard_D8as_v5',
    'Standard_D16s_v3', 'Standard_D8s_v3',
    'Standard_D16s_v4', 'Standard_D8s_v4',
    'Standard_D16s_v5', 'Standard_D8s_v5',
    'Standard_D4as_v5', 'Standard_D2as_v5',
    'Standard_D4s_v3', 'Standard_D2s_v3',
    'Standard_D4s_v4', 'Standard_D2s_v4',
    'Standard_D4s_v5', 'Standard_D2s_v5',
    'Standard_D8as_v5', 'Standard_D4as_v5',
    'Standard_D8s_v3', 'Standard_D4s_v3',
    'Standard_D8s_v4', 'Standard_D4s_v4',
    'Standard_D8s_v5', 'Standard_D4s_v5',
    'Standard_E16s_v3', 'Standard_E8s_v3',
    'Standard_E16s_v5', 'Standard_E8s_v5',
    'Standard_E4s_v3', 'Standard_E2s_v3',
    'Standard_E4s_v5', 'Standard_E2s_v5',
    'Standard_E8s_v3', 'Standard_E4s_v3',
    'Standard_E8s_v5', 'Standard_E4s_v5',
    'Standard_F16s_v2', 'Standard_F8s_v2',
    'Standard_F4s_v2', 'Standard_F2s_v2',
    'Standard_F8s_v2', 'Standard_F4s_v2'
    ]
  ) on size
| project name, resourceGroup, location, size, proposedSize
| order by size asc, name asc

Each row is a machine, its current size, and the exact smaller size the agent would propose; an earlier version of this query returned a count per size, which named no machine and could not be checked against a finding. The list is the agent's ladder rather than Azure's catalogue: a size with no smaller sibling, and any size the ladder does not carry such as Standard_D4_v3 or Standard_E8as_v4, is absent here because the check would drop it. Resource Graph holds no metrics, so this cannot apply the part that decides: the check additionally requires the AVERAGE CPU below its threshold AND the PEAK below a separate one, so a machine listed here can still be missing from the findings because of a single busy hour. Confirm both in Azure Monitor before resizing.

Applies to microsoft.compute/virtualmachines
What the agent does about it

A running VM whose sustained peak fits comfortably inside the next size down.

And what stops it

It has to see the same thing on three consecutive runs before it will act, it stays away from anything a person changed recently, and it will not touch a resource unless you tagged it non-production. Untagged counts as production. Every refusal is written down with the rule that caused it.