Posts Monitoring Azure Subnet IP Availability
Post
Cancel

Monitoring Azure Subnet IP Availability

Azure Monitor Azure Subnet VNET IP Availability

Azure networking is one of those areas that usually just works—until it suddenly doesn’t. One of the most common and least visible problems I keep seeing in real-world environments is subnet IP exhaustion. Everything looks healthy, deployments are automated, pipelines are green… and then a new VM, private endpoint, or scale-out operation fails because there are no available IP addresses left in the subnet.

What makes this especially tricky is that Azure does not alert you when a subnet is running out of IPs. There is no built-in Azure Monitor signal, no metric you can threshold, and no default alert that warns you before things break. Unless you manually check subnet usage, or discover the problem during a failed deployment, you’re flying blind.

In this article, we’ll fix that.

We’ll build a proactive monitoring solution for Azure subnet IP availability, using a PowerShell script that runs on a schedule inside Azure Automation, collects subnet capacity data across your environment, and stores it in a custom table in Azure Log Analytics. Once the data is there, we’ll use KQL and Azure Monitor alerts to notify us when a subnet drops below a safe threshold, 20% of available IP addresses, giving us time to react before deployments start failing.

By the end of this article, you will have an end-to-end solution that does the following:

  • Enumerates Azure virtual networks and subnets
  • Calculates total, used, and available IP addresses per subnet
  • Runs automatically on a schedule using Azure Automation
  • Ingests the data into a custom Log Analytics table
  • Triggers an Azure Monitor alert when available IPs fall below 20%

I already wrote about how to Ingest custom data to Azure Log Analytics. If you already followed that guide, you’ll recognize the same patterns here: Data Collection Rules, custom tables, and scheduled ingestion from automation. We’ll simply reuse and adapt them for subnet monitoring.

Collecting subnet IP availability data with a PowerShell script

Before we can alert on “low available IPs”, we need a reliable way to measure subnet consumption before we can store it somewhere queryable.

The following script does exactly that across all subscriptions. At a high level it:

  • Iterates through every subscription (Get-AzSubscription + Select-AzSubscription).
  • Pulls all NICs in the subscription once (Get-AzNetworkInterface) and all VNets/subnets (Get-AzVirtualNetwork).
  • For each subnet, calculates:
  • Total IPs in the subnet from CIDR math (2^(32 - prefix))

    • Reserved IPs (Azure reserves 5 per subnet) 
    • Allocated IPs by counting NIC IP configurations that reference that subnet ID
    • Free IPs and AvailablePercent (free/theoretical available) 
  • Outputs a clean object per subnet with fields like SubscriptionId, VirtualNetwork, SubnetName, AddressPrefix, FreeAddresses, and AvailablePercent. 

That output is ready for Log Analytics ingestion (one row per subnet per run).


Running the script manually is useful for troubleshooting, but for monitoring you want it to be:

  • Scheduled (e.g., every 120 minutes)
  • Centralized (one place to run across subscriptions)
  • Identity-based (managed identity + RBAC, no secrets)
  • Integrated with Log Analytics (so we can query/alert)

So the next step is to take this script and run it as an Azure Automation PowerShell runbook, then post the results into a custom table in Log Analytics using the Logs Ingestion API + DCE/DCR pattern (the ingestion mechanics are covered in your existing article, so we’ll reference it and reuse the same approach).

Deploying Automation Account and Log Analytics Workspace

I already covered all the components in the previous article, so let’s quickly go through creating each of them with PowerShell:

Define parameters

1
2
3
4
5
6
7
8
9
$SubscriptionId = "00000000-0000-0000-0000-000000000000"
$Location = "westus"
$ResourceGroupName = "rg-networktest"
$LogAnalyticsWorkspaceName = "law-networktest"
$AutomationAccountName = "aa-networktest"
$ManagementGroupId = "MG-Test"
$CustomLogName = "SubnetIPAllocation_CL"

$ErrorActionPreference = "Stop"

Step 1: Set Azure Context

1
2
3
4
5
6
7
# ---------------------------
# 1) Set context
# ---------------------------
Select-AzSubscription -SubscriptionId $SubscriptionId

# Optional: ensure provider registered for DCE/DCR resources
Register-AzResourceProvider -ProviderNamespace "Microsoft.Insights" | Out-Null

Step 2: Create Resource Group

1
2
3
4
5
6
7
# ---------------------------
# 2) Create/ensure Resource Group
# ---------------------------
$rg = Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction SilentlyContinue
if (-not $rg) {
  $rg = New-AzResourceGroup -Name $ResourceGroupName -Location $Location
}

Step 3: Create Log Analytics Workspace

1
2
3
4
5
6
7
8
9
10
11
# ---------------------------
# 3) Create Log Analytics Workspace
# ---------------------------
$law = Get-AzOperationalInsightsWorkspace -ResourceGroupName $ResourceGroupName -Name $LogAnalyticsWorkspaceName -ErrorAction SilentlyContinue
if (-not $law) {
  $law = New-AzOperationalInsightsWorkspace `
    -ResourceGroupName $ResourceGroupName `
    -Name $LogAnalyticsWorkspaceName `
    -Location $Location `
    -Sku "PerGB2018"
}

Step 4: Create Automation Account with System-assigned MI

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# ---------------------------
# 4) Create Automation Account with System-assigned MI
# ---------------------------
$aa = Get-AzAutomationAccount -ResourceGroupName $ResourceGroupName -Name $AutomationAccountName -ErrorAction SilentlyContinue
if (-not $aa) {
  # Create the Automation Account with a system-assigned identity enabled up front
  $aa = New-AzAutomationAccount -ResourceGroupName $ResourceGroupName -Name $AutomationAccountName -Location $Location -Plan "Basic" -AssignSystemIdentity
}

# Ensure system-assigned identity is enabled (covers pre-existing accounts created without identity)
if (-not $aa.Identity -or -not $aa.Identity.PrincipalId) {
  Set-AzAutomationAccount -ResourceGroupName $ResourceGroupName -Name $AutomationAccountName -AssignSystemIdentity | Out-Null
  $aa = Get-AzAutomationAccount -ResourceGroupName $ResourceGroupName -Name $AutomationAccountName
}

$principalId = $aa.Identity.PrincipalId
if (-not $principalId) { throw "Automation Account Managed Identity principalId not found." }

Step 5: RBAC Assignment

1
2
3
4
5
6
7
8
9
10
11
12
13
# ---------------------------
# 5) RBAC assignments
# ---------------------------

# 5a) Reader on Management Group scope (allows enumerating resources across subs, assuming MG structure)
$mgScope = "/providers/Microsoft.Management/managementGroups/$ManagementGroupId"
New-AzRoleAssignment -ObjectId $principalId -RoleDefinitionName "Reader" -Scope $mgScope -ErrorAction SilentlyContinue | Out-Null

# 5b) Log Analytics Contributor on Resource Group
New-AzRoleAssignment -ObjectId $principalId -RoleDefinitionName "Log Analytics Contributor" -Scope $rg.ResourceId -ErrorAction SilentlyContinue | Out-Null

# 5c) Monitoring Metrics Publisher on Resource Group
New-AzRoleAssignment -ObjectId $principalId -RoleDefinitionName "Monitoring Metrics Publisher" -Scope $rg.ResourceId -ErrorAction SilentlyContinue | Out-Null

Step 6: Create a custom table in Log Analytics Workspace

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# ---------------------------
# 6) Prepare custom table in Log Analytics
# ---------------------------
$tableTemplate = @'
{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "workspaceName": { "type": "string" },
    "tableName": { "type": "string" }
  },
  "resources": [
    {
      "type": "Microsoft.OperationalInsights/workspaces/tables",
      "apiVersion": "2022-10-01",
      "name": "[format('{0}/{1}', parameters('workspaceName'), parameters('tableName'))]",
      "properties": {
        "schema": {
          "name": "[parameters('tableName')]",
          "columns": [
            { "name": "TimeGenerated", "type": "DateTime" },
            { "name": "Subscription", "type": "String" },
            { "name": "SubscriptionId", "type": "String" },
            { "name": "VirtualNetwork", "type": "String" },
            { "name": "SubnetName", "type": "String" },
            { "name": "AddressPrefix", "type": "String" },
            { "name": "TotalAddresses", "type": "Int" },
            { "name": "ReservedAddresses", "type": "Int" },
            { "name": "TheoreticalAvailable", "type": "Int" },
            { "name": "Allocated", "type": "Int" },
            { "name": "FreeAddresses", "type": "Int" },
            { "name": "AvailablePercent", "type": "Real" }
          ]
        },
        "retentionInDays": 30,
        "totalRetentionInDays": 30
      }
    }
  ]
}
'@

$tableTemplateObject = ConvertFrom-Json -InputObject $tableTemplate -AsHashtable
New-AzResourceGroupDeployment `
  -ResourceGroupName $ResourceGroupName `
  -Name "deploy-law-table" `
  -TemplateParameterObject @{ workspaceName = $LogAnalyticsWorkspaceName; tableName = $CustomLogName } `
  -TemplateObject $tableTemplateObject | Out-Null

Step 7: Create Data Collection Endpoint

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# ---------------------------
# 7) Deploy Data Collection Endpoint (DCE)
# ---------------------------
$dceName = "dce-$($AutomationAccountName)"
$dceTemplate = @'
{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "dceName": { "type": "string" },
    "location": { "type": "string" }
  },
  "resources": [
    {
      "type": "Microsoft.Insights/dataCollectionEndpoints",
      "apiVersion": "2022-06-01",
      "name": "[parameters('dceName')]",
      "location": "[parameters('location')]",
      "properties": {
        "networkAcls": {
          "publicNetworkAccess": "Enabled"
        }
      }
    }
  ],
  "outputs": {
    "dceResourceId": {
      "type": "string",
      "value": "[resourceId('Microsoft.Insights/dataCollectionEndpoints', parameters('dceName'))]"
    }
  }
}
'@

$dceTemplateObject = ConvertFrom-Json -InputObject $dceTemplate -AsHashtable
$dceDeployment = New-AzResourceGroupDeployment `
  -ResourceGroupName $ResourceGroupName `
  -Name "deploy-dce" `
  -TemplateParameterObject @{ dceName = $dceName; location = $Location } `
  -TemplateObject $dceTemplateObject

$dceResourceId = $dceDeployment.Outputs.dceResourceId.value
$dce = Get-AzResource -ResourceId $dceResourceId -ExpandProperties

# DCE ingestion endpoint URI is under properties.logsIngestion.endpoint in most API versions
$dceUri = $dce.Properties.logsIngestion.endpoint
if (-not $dceUri) {
  # fallback: dump properties if schema differs
  Write-Warning "Could not auto-resolve DCE logsIngestion endpoint. Inspect the returned object properties."
}

Step 8: Create Data Collection Rule

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# ---------------------------
# 8) Deploy Data Collection Rule (DCR)
# ---------------------------
$dcrName = "dcr-$($AutomationAccountName)"
$dcrTemplate = @'
{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "dcrName": { "type": "string" },
    "location": { "type": "string" },
    "dceResourceId": { "type": "string" },
    "workspaceResourceId": { "type": "string" },
    "customLogName": { "type": "string" }
  },
  "resources": [
    {
      "type": "Microsoft.Insights/dataCollectionRules",
      "apiVersion": "2022-06-01",
      "name": "[parameters('dcrName')]",
      "location": "[parameters('location')]",
      "properties": {
        "dataCollectionEndpointId": "[parameters('dceResourceId')]",
        "destinations": {
          "logAnalytics": [
            {
              "name": "laDest",
              "workspaceResourceId": "[parameters('workspaceResourceId')]"
            }
          ]
        },
        "dataFlows": [
          {
            "streams": [
              "[concat('Custom-', parameters('customLogName'))]"
            ],
            "destinations": [
              "laDest"
            ]
          }
        ],
        "streamDeclarations": {
          "[concat('Custom-', parameters('customLogName'))]": {
            "columns": [
              { "name": "TimeGenerated", "type": "datetime" },
              { "name": "Subscription", "type": "string" },
              { "name": "SubscriptionId", "type": "string" },
              { "name": "VirtualNetwork", "type": "string" },
              { "name": "SubnetName", "type": "string" },
              { "name": "AddressPrefix", "type": "string" },
              { "name": "TotalAddresses", "type": "int" },
              { "name": "ReservedAddresses", "type": "int" },
              { "name": "TheoreticalAvailable", "type": "int" },
              { "name": "Allocated", "type": "int" },
              { "name": "FreeAddresses", "type": "int" },
              { "name": "AvailablePercent", "type": "real" }
            ]
          }
        }
      }
    }
  ],
  "outputs": {
    "dcrResourceId": {
      "type": "string",
      "value": "[resourceId('Microsoft.Insights/dataCollectionRules', parameters('dcrName'))]"
    }
  }
}
'@

$dcrTemplateObject = ConvertFrom-Json -InputObject $dcrTemplate -AsHashtable
$dcrDeployment = New-AzResourceGroupDeployment `
  -ResourceGroupName $ResourceGroupName `
  -Name "deploy-dcr" `
  -TemplateParameterObject @{
    dcrName = $dcrName
    location = $Location
    dceResourceId = $dceResourceId
    workspaceResourceId = $law.ResourceId
    customLogName = $CustomLogName
  } `
  -TemplateObject $dcrTemplateObject

$dcrResourceId = $dcrDeployment.Outputs.dcrResourceId.value
$dcr = Get-AzResource -ResourceId $dcrResourceId -ExpandProperties
$dcrImmutableId = $dcr.Properties.immutableId

Step 9: Create Automation Account variables

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# ---------------------------
# 9) Create Automation Account variables
# ---------------------------
New-AzAutomationVariable `
  -ResourceGroupName $ResourceGroupName `
  -AutomationAccountName $AutomationAccountName `
  -Name "SubnetIP_DceUri" `
  -Value $dceUri `
  -Encrypted $false | Out-Null

New-AzAutomationVariable `
  -ResourceGroupName $ResourceGroupName `
  -AutomationAccountName $AutomationAccountName `
  -Name "SubnetIP_DcrImmutableId" `
  -Value $dcrImmutableId `
  -Encrypted $false | Out-Null

New-AzAutomationVariable `
  -ResourceGroupName $ResourceGroupName `
  -AutomationAccountName $AutomationAccountName `
  -Name "SubnetIP_StreamName" `
  -Value "Custom-$CustomLogName" `
  -Encrypted $false | Out-Null

Step 10: Output important values

Now that we have everything created, we can simply output all important values that we will need for our next steps:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# ---------------------------
# 10) Output the important values
# ---------------------------
[PSCustomObject]@{
  ResourceGroup      = $ResourceGroupName
  LogAnalytics       = $law.Name
  LogAnalyticsID     = $law.id
  AutomationAccount  = $AutomationAccountName
  ManagedIdentityId  = $principalId
  DCE_Name           = $dceName
  DCE_Uri            = $dceUri
  DCR_Name           = $dcrName
  DCR_ImmutableId    = $dcrImmutableId
  StreamName         = "Custom-$CustomLogName"
}

Instead of copying step by step, you can also download this full script from my PowerShell GitHub Repository.

Creating Automation Runbook

We can now transform our initial PowerShell script to full Azure Automation Runbook, and make it deliver information that collects to our custom Log Analytics workspace table that we just created.

Go to the Automation Account, click Runbooks, and create a new PowerShell 7.2 Runbook.

Add this to the top of the Runbook. This part is going to connect using the managed identity, and also read variables from the Automation Account, that we created previously. You can also replace that with parameters or fixed values.

1
2
3
4
5
6
7
8
9
10
$ErrorActionPreference = "Stop"

$dceUri       = Get-AutomationVariable -Name "SubnetIP_DceUri"
$dcrImmutable = Get-AutomationVariable -Name "SubnetIP_DcrImmutableId"
$streamName   = Get-AutomationVariable -Name "SubnetIP_StreamName"

$apiVersion = "2023-01-01"
$maxBatchSize = 500

Connect-AzAccount -Identity

Then, include the entire script from above that we used to collect information about subnets. If you are using your own script, make sure that table we created previously has the same fields, and that exported object is called @results (or adjust the name bellow).

After that, add this to the end of the Runbook:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
if (-not $results) {
    throw "Subnet IP script did not return any data."
}

#cleanup AddressPrefix and change it to the correct format for input:
$results = $results | ForEach-Object {
    if ($null -ne $_.AddressPrefix) {
        if ($_.AddressPrefix -is [System.Collections.IEnumerable] -and
            $_.AddressPrefix -isnot [string]) {

            $_.AddressPrefix = ($_.AddressPrefix -join ",")
        }
        else {
            $_.AddressPrefix = [string]$_.AddressPrefix
        }
    }

    $_
}

Write-Output ("Collected {0} subnet records." -f $results.Count)

#Prepare ingestion request
$ingestUri = "{0}/dataCollectionRules/{1}/streams/{2}?api-version={3}" -f `
    $dceUri.TrimEnd("/"), $dcrImmutable, $streamName, $apiVersion


Write-Output "Getting the authentication token..."
try {
    $token = (Get-AzAccessToken -ResourceUrl "https://monitor.azure.com//.default").Token
}
catch {
    Write-Error -Message $_.Exception
    throw $_.Exception
}

$headers = @{
    "Authorization" = "Bearer $token"
    "Content-Type" = "application/json"
}

#Helper: batch splitter
function Split-Batch {
    param(
        [array]$Items,
        [int]$Size
    )

    for ($i = 0; $i -lt $Items.Count; $i += $Size) {
        $end = [Math]::Min($i + $Size - 1, $Items.Count - 1)
        ,$Items[$i..$end]
    }
}

$batches = Split-Batch -Items $results -Size $maxBatchSize
$batchIndex = 0

foreach ($batch in $batches) {
    Write-Output "Processing batch $batchIndex..."
    $batchIndex++
    $body = $batch | ConvertTo-Json

    try {
        Invoke-RestMethod -Method "POST" -Uri $ingestUri -Headers $headers -Body $body -StatusCodeVariable "responseStatusCode" -ResponseHeadersVariable "responseHeaders"
    }
    catch {
        Write-Error -Message $_.Exception
        throw $_.Exception
    }
    
    # Outputting the request results for troubleshooting purposes
    Write-Output "Responses are:"
    $responseStatusCode
    $responseHeaders

    Write-Output ("Batch {0}/{1} ingested ({2} records)." -f $batchIndex, $batches.Count, $batch.Count)
}

Write-Output "Subnet IP availability ingestion completed successfully."

This will result collecting and sending data to your Log Analytics Workspace.

You can validate that from within the Log Analytics Workspace, buy going to Logs and checking listing your custom table.

Custom Log Analytics Table

Or pull that same information via PowerShell:

1
(Invoke-AzOperationalInsightQuery -WorkspaceID $law.id -Query $CustomLogName).Results | Format-Table

Set the schedule

There is one last thing left to do to make this Runbook trully useful. We need to set it to run on schedule. Once or twice a day is probably enough, but you can increase that frequency depending on your needs.

Creating Azure Monitor Alert

Now that we are collecting data to Log Analytics Workspace, we can simply use Azure Monitor for additional visibility and alerts.

We are going to create an alert to let us know when there is less than 20% of available IPs left in the subnet.

Note: It is important to realize that sometimes we can’t increase the size of the Subnet or remove assigned IPs. Therefore this alert will stay active.

Create Alert Rule

  1. Go to Azure Monitor, click Alerts and Create
  2. As a Scope, select your Log Analytics Workspace
  3. Under Condition, select Signal name Custom Log Search
  4. Use the following Query to retreive all records where AvailablePercent is less than 20
1
2
3
4
5
6
7
8
9
10
11
12
13
14
SubnetIPAllocation_CL
| summarize arg_max(TimeGenerated, *) 
    by SubscriptionId, VirtualNetwork, SubnetName
| where AvailablePercent < 20
| project
    TimeGenerated,
    Subscription,
    SubscriptionId,
    VirtualNetwork,
    SubnetName,
    AddressPrefix,
    AvailablePercent,
    FreeAddresses,
    TheoreticalAvailable

You can test that with higher value to validate that it is working. You can also switch it to FreeAddresses to use the fixed number instead of percentage.

  1. Set Measure -> Table rows
  2. Set Aggregation type -> Count (this represents number of returned results)
  3. And finally set the Alert logic to alert when Operator is Greater than, with threshold value 0.

In simple words, this means that if our query returns more than zero results, that means that there is more than zero subnets with less than 20% of IPs available. And we will get alert in that case.

  1. Set the Frequency of evaluation to correspond on how often your script runs and collects the data. There is no point of evaluating the alert rule every 5 minutes when your script imports new data once a day.

Azure Monitor Configuration

  1. Finish completing your alert. Assign Action, Tags, Name, Severity, etc.

Conclusion

Subnet IP exhaustion is one of those Azure problems that rarely gets attention, right up until it breaks something critical. By the time deployments start failing or private endpoints can’t be created, the damage is already done and the fix is usually urgent, disruptive, and stressful.

In this article, we turned that silent risk into a first-class monitoring signal.

By combining a PowerShell discovery script, Azure Automation, custom Log Analytics ingestion, and Azure Monitor alerts, you now have a solution that:

  • Continuously measures subnet IP availability across your Azure estate
  • Stores the data centrally and historically in Log Analytics
  • Surfaces early warnings when subnets drop below safe capacity thresholds
  • Integrates cleanly with existing Azure Monitor alerting and action groups

Just as importantly, this approach scales. Whether you manage a handful of VNets or hundreds of subscriptions, the same pattern applies—identity-based access, scheduled execution, structured telemetry, and KQL-driven insights.

The real value here isn’t just the alert at 20% available IPs. It’s the visibility. Once subnet capacity lives in Log Analytics, you can trend growth, identify risky network designs, separate production from non-production, and make informed architectural decisions before Azure enforces them for you.

If you’re already ingesting custom data into Log Analytics, this solution fits naturally into that model. If you’re not, this is a practical, real-world example of why extending Azure Monitor beyond built-in metrics is often necessary in enterprise environments.

Subnet IP exhaustion shouldn’t be a surprise anymore. With the approach you’ve built here, it won’t be.

Vukasin Terzic

This post is licensed under CC BY 4.0