Private/ConvertTo-AACResourceUtilization.ps1
|
function ConvertTo-AACResourceUtilization { <# .SYNOPSIS Rates how much of each resource is used - from its Azure Monitor metrics, hour by hour - and says which are idle, under-used, right sized or running hot, which way they're trending, and what to resize them to, with the money it would save. .DESCRIPTION From each resource's hourly metrics: CPU (or its equivalent: RU consumption for Cosmos DB) average, 95th percentile and maximum; memory 95th percentile (a VM's from its free memory and size); its activity summed over the window (transactions, connections, network). Idle CPU 95th percentile under -IdlePercent (5) - or, with no CPU metric, no activity at all Under-used CPU 95th percentile under -LowPercent (30), and memory (when known) under 60% Hot CPU or memory 95th percentile over -HighPercent (80) Right sized otherwise; No data when the metrics are empty Trend: the second half of the window against the first - Growing (over +20%), Shrinking (under -20%) or Stable. A VM under-used is offered the size of its family with half the vCPUs (if it has the memory used, with 20% to spare), priced from the Azure retail prices for its region and OS: the saving a month. With -Cost (each resource's monthly cost), every row has its cost, the share of it that pays for unused capacity (cost x (1 - average use)) - the chargeback - and an estimated saving for the idle (all of it) and the under-used (half). Returns @{ Rows (AAC.ResourceUtilization); Stats }. #> [CmdletBinding()] [OutputType([hashtable])] param( # Resource Graph rows: id, name, type, resourceGroup, subscriptionId, location, size, os, sku. [AllowEmptyCollection()] [object[]] $Resource = @(), # Resource ID (lower case) -> the metrics response's 'value' (hashtables). [hashtable] $Metric = @{}, # 'location|size' (lower case) -> @{ Cores; MemoryMB }. [hashtable] $Size = @{}, # 'location|size|os' (lower case) -> the price an hour. [hashtable] $Price = @{}, # Resource ID (lower case) -> @{ Cost (a month); Currency }. [hashtable] $Cost = @{}, [System.Collections.IDictionary] $SubscriptionName = @{}, [int] $IdlePercent = 5, [int] $LowPercent = 30, [int] $HighPercent = 80 ) $get = { param($Row, [string] $Name) if ($Row -is [System.Collections.IDictionary]) { $Row[$Name] } elseif ($null -ne $Row) { $p = $Row.PSObject.Properties[$Name]; if ($p) { $p.Value } } } $lower = { param($Text) ([string]$Text).ToLowerInvariant() } $round = { param($N) if ($null -eq $N) { $null } else { [Math]::Round([double]$N, 1) } } $percentile = { param([double[]] $Values, [double] $P) if (-not $Values.Count) { return $null } $sorted = [double[]]($Values | Sort-Object); $sorted[[Math]::Max(0, [int][Math]::Ceiling($P * $sorted.Count) - 1)] } $mean = { param([double[]] $Values) if (-not $Values.Count) { return $null } $t = 0.0; foreach ($v in $Values) { $t += $v }; $t / $Values.Count } $subOf = { param($Id) $s = & $lower $Id; if ($s -match '^/subscriptions/([^/]+)') { $s = $Matches[1]; if ($SubscriptionName.Contains($s)) { [string]$SubscriptionName[$s] } else { $s } } else { '' } } $definitions = Get-AACUtilizationMetric $series = { # One metric's hourly points: @{ Average; Maximum; Total } arrays, oldest first. param($Values, [string] $Name) $m = @($Values | Where-Object { [string](& $get (& $get $_ 'name') 'value') -eq $Name }) | Select-Object -First 1 $points = @(if ($m) { foreach ($ts in @(& $get $m 'timeseries')) { @(& $get $ts 'data') } }) @{ Average = [double[]]@($points | Where-Object { $null -ne (& $get $_ 'average') } | ForEach-Object { [double](& $get $_ 'average') }) Maximum = [double[]]@($points | Where-Object { $null -ne (& $get $_ 'maximum') } | ForEach-Object { [double](& $get $_ 'maximum') }) Minimum = [double[]]@($points | Where-Object { $null -ne (& $get $_ 'minimum') } | ForEach-Object { [double](& $get $_ 'minimum') }) Total = [double[]]@($points | Where-Object { $null -ne (& $get $_ 'total') } | ForEach-Object { [double](& $get $_ 'total') }) } } # The same family with half the vCPUs: Standard_D8s_v5 -> Standard_D4s_v5. $smaller = { param([string] $SizeName, [string] $Location, $UsedMemoryMB) if ($SizeName -notmatch '^(?<prefix>[A-Za-z]+_[A-Za-z]+)(?<cores>\d+)(?<rest>.*)$') { return $null } $cores = [int]$Matches['cores'] if ($cores -lt 2) { return $null } $candidate = "$($Matches['prefix'])$([int]($cores / 2))$($Matches['rest'])" $spec = $Size["$(& $lower $Location)|$(& $lower $candidate)"] if (-not $spec) { return $null } if ($null -ne $UsedMemoryMB -and [double]$spec.MemoryMB -lt [double]$UsedMemoryMB * 1.2) { return $null } $candidate } $rows = [System.Collections.Generic.List[object]]::new() foreach ($r in $Resource) { $id = & $lower (& $get $r 'id'); $type = & $lower (& $get $r 'type') $def = $definitions[$type] if (-not $def) { continue } $values = if ($Metric.Contains($id)) { $Metric[$id] } else { $null } $cpu = if ($def.Cpu -and $values) { & $series $values $def.Cpu } else { $null } $cpuAvg = if ($cpu) { & $mean $cpu.Average } else { $null } $cpuP95 = if ($cpu) { & $percentile $cpu.Average 0.95 } else { $null } $cpuMax = if ($cpu -and $cpu.Maximum.Count) { [double]($cpu.Maximum | Measure-Object -Maximum).Maximum } else { $null } $location = & $lower (& $get $r 'location'); $sizeName = [string](& $get $r 'size') $memoryMB = $null; $memoryP95 = $null if ($def.Memory -and $values) { $mem = & $series $values $def.Memory if ($def.MemoryKind -eq 'Percent') { $memoryP95 = & $percentile $mem.Average 0.95 } elseif ($mem.Average.Count -or $mem.Minimum.Count) { $spec = $Size["$location|$(& $lower $sizeName)"] if ($spec) { $total = [double]$spec.MemoryMB * 1MB $freeLow = & $percentile $(if ($mem.Minimum.Count) { $mem.Minimum } else { $mem.Average }) 0.05 if ($null -ne $freeLow -and $total) { $memoryP95 = [Math]::Max(0, [Math]::Min(100, (1 - $freeLow / $total) * 100)); $memoryMB = $spec.MemoryMB * $memoryP95 / 100 } } } } $activity = if ($def.Activity -and $values) { $a = & $series $values $def.Activity; if ($a.Total.Count) { [double]($a.Total | Measure-Object -Sum).Sum } elseif ($a.Average.Count) { [double]($a.Average | Measure-Object -Sum).Sum } else { $null } } else { $null } # Trend: the window's second half against its first. $trend = 'Stable'; $growth = $null $basis = if ($cpu -and $cpu.Average.Count -ge 4) { $cpu.Average } elseif ($def.Activity -and $values) { (& $series $values $def.Activity).Total } else { @() } if (@($basis).Count -ge 4) { $half = [int][Math]::Floor($basis.Count / 2) $first = & $mean ([double[]]$basis[0..($half - 1)]); $second = & $mean ([double[]]$basis[$half..($basis.Count - 1)]) if ($first -gt 0) { $growth = ($second - $first) / $first * 100; $trend = if ($growth -gt 20) { 'Growing' } elseif ($growth -lt -20) { 'Shrinking' } else { 'Stable' } } } $status = if (-not $values -or ($null -eq $cpuP95 -and $null -eq $activity)) { 'No data' } elseif ($null -ne $cpuP95) { if ($cpuP95 -gt $HighPercent -or ($null -ne $memoryP95 -and $memoryP95 -gt $HighPercent)) { 'Hot' } elseif ($cpuP95 -lt $IdlePercent -and ($null -eq $activity -or $type -ne 'microsoft.compute/virtualmachines' -or $activity -lt 100MB)) { 'Idle' } elseif ($cpuP95 -lt $LowPercent -and ($null -eq $memoryP95 -or $memoryP95 -lt 60)) { 'Under-used' } else { 'Right sized' } } elseif ($activity -eq 0) { 'Idle' } else { 'Right sized' } # Right-sizing and savings. $suggested = $null; $saving = $null; $currency = '' $monthly = $null if ($Cost.Contains($id)) { $monthly = [double]$Cost[$id].Cost; $currency = [string]$Cost[$id].Currency } if ($type -eq 'microsoft.compute/virtualmachines' -and $status -eq 'Under-used' -and $sizeName) { $suggested = & $smaller $sizeName $location $memoryMB if ($suggested) { $os = & $lower (& $get $r 'os') $now = $Price["$location|$(& $lower $sizeName)|$os"]; $then = $Price["$location|$(& $lower $suggested)|$os"] if ($null -ne $now -and $null -ne $then) { $saving = [Math]::Round(([double]$now - [double]$then) * 730, 2); $currency = 'USD' } } } if ($null -eq $saving -and $null -ne $monthly) { $saving = switch ($status) { 'Idle' { [Math]::Round($monthly, 2) } 'Under-used' { [Math]::Round($monthly / 2, 2) } default { $null } } } $unused = if ($null -ne $monthly -and $null -ne $cpuAvg) { [Math]::Round($monthly * (1 - [Math]::Min([double]100, [double]$cpuAvg) / 100), 2) } else { $null } $label = $def.Label $recommend = switch ($status) { 'Idle' { "Nothing uses it: delete it, or stop or scale it to zero while it's not needed$(if ($type -eq 'microsoft.compute/virtualmachines') { ' (deallocate it: a VM only stopped is still billed)' })." } 'Under-used' { "Scale down: $(if ($suggested) { "resize to $suggested" } else { $def.Down })." } 'Hot' { "Scale up or out before it becomes a bottleneck$(if ($trend -eq 'Growing') { ' - and it''s growing' }): look at what drives the load first." } 'Right sized' { $(if ($trend -eq 'Growing') { 'Right sized now, but growing: plan for more capacity.' } else { 'Keep: it''s sized for what it does.' }) } default { 'No metrics: check it is running, and that its platform metrics are available.' } } $severity = switch ($status) { 'Idle' { 'Medium' } 'Under-used' { 'Low' } 'Hot' { if ($trend -eq 'Growing') { 'High' } else { 'Medium' } } default { 'Info' } } $rows.Add((New-AACFinding -TypeName 'AAC.ResourceUtilization' -Severity $severity -Category $status -Finding "$($label) $(& $get $r 'name'): $($status.ToLowerInvariant())" -ResourceId $id -Resource ([string](& $get $r 'name')) -ResourceType $type ` -Subscription (& $subOf $id) -Detail ((@($(if ($null -ne $cpuP95) { 'CPU {0:N0}% (average {1:N0}%, max {2:N0}%)' -f $cpuP95, $cpuAvg, $cpuMax }), $(if ($null -ne $memoryP95) { 'memory {0:N0}%' -f $memoryP95 }), $(if ($null -ne $activity) { "activity $('{0:N0}' -f $activity)" })) | Where-Object { $_ }) -join '; ') ` -Impact $(if ($null -ne $saving -and $saving -gt 0) { '{0:N2} {1} a month' -f $saving, $currency } else { '' }) -Remediation $recommend -Effort $(if ($status -in 'Idle', 'Under-used') { 'Low' } elseif ($status -eq 'Hot') { 'Medium' } else { '' }) ` -Link 'https://learn.microsoft.com/azure/advisor/advisor-cost-recommendations' -Property ([ordered]@{ Kind = $label; Size = $(if ($sizeName) { $sizeName } else { [string](& $get $r 'sku') }); CpuAverage = & $round $cpuAvg; CpuP95 = & $round $cpuP95; CpuMax = & $round $cpuMax; MemoryP95 = & $round $memoryP95 Activity = $activity; Trend = $trend; GrowthPercent = & $round $growth; SuggestedSize = $suggested; MonthlyCost = $monthly; UnusedCost = $unused; EstimatedSaving = $saving; Currency = $currency }))) } $order = @{ Idle = 0; 'Under-used' = 1; Hot = 2; 'Right sized' = 3; 'No data' = 4 } $sorted = @($rows | Sort-Object -Property @{ Expression = { $order[$_.Category] } }, @{ Expression = { if ($null -ne $_.EstimatedSaving) { [double]$_.EstimatedSaving } else { 0 } }; Descending = $true }, Resource) @{ Rows = $sorted Stats = @{ Resources = $sorted.Count Idle = @($sorted | Where-Object Category -EQ 'Idle').Count Underused = @($sorted | Where-Object Category -EQ 'Under-used').Count Hot = @($sorted | Where-Object Category -EQ 'Hot').Count Right = @($sorted | Where-Object Category -EQ 'Right sized').Count NoData = @($sorted | Where-Object Category -EQ 'No data').Count Growing = @($sorted | Where-Object Trend -EQ 'Growing').Count Saving = [Math]::Round([double](@($sorted | ForEach-Object { if ($null -ne $_.EstimatedSaving) { [double]$_.EstimatedSaving } else { 0 } }) | Measure-Object -Sum).Sum, 2) UnusedCost = [Math]::Round([double](@($sorted | ForEach-Object { if ($null -ne $_.UnusedCost) { [double]$_.UnusedCost } else { 0 } }) | Measure-Object -Sum).Sum, 2) Currencies = @($sorted | ForEach-Object Currency | Where-Object { $_ } | Select-Object -Unique) # Savings by currency (retail prices are in USD, billed cost in the billing currency): '146 USD; 100 EUR'. SavingText = (@($sorted | Where-Object { $_.EstimatedSaving -gt 0 } | Group-Object Currency | Sort-Object Name | ForEach-Object { '{0:N0} {1}' -f ([double](@($_.Group | ForEach-Object { [double]$_.EstimatedSaving }) | Measure-Object -Sum).Sum), $_.Name }) -join '; ') } } } |