1. Introduction
Managing the costs associated with running virtual machines (VMs) in Azure can be challenging, especially when VMs are left running during non-business hours. One effective solution is to schedule automatic shutdowns using Azure tags. In this blog, we'll walk you through a PowerShell script that uses an Azure tag to define and enforce VM shutdown schedules.
2. Prerequisites
Before you get started, ensure you have the following:
- An active Azure subscription.
- Appropriate permissions to manage VMs and read tag information.
- Basic knowledge of PowerShell scripting.
3. Script Overview
The script manages the power state of Azure virtual machines based on a specified tag and its value, which defines the schedule. For example, a tag named "AutoShutdownSchedule" with a value of "10PM -> 6AM" will shut down the VM at 10 PM and start it at 6 AM.
4. Detailed Script Breakdown
Parameters and Initial Setup
The script accepts three parameters:
TagName
: The name of the tag to look for on virtual machines.ManagementGroupId
: The ID of the Azure management group to operate on.Simulate
: If set to$true
, the script will only simulate the actions without making any changes.
param (
[parameter(Mandatory = $true)]
[string]$TagName,
[parameter(Mandatory = $true)]
[string]$ManagementGroupId,
[parameter(Mandatory = $false)]
[bool]$Simulate = $false
)
Function: Get-SubscriptionsUnderManagementGroup
This function retrieves all subscription IDs under a specified Azure management group.
function Get-SubscriptionsUnderManagementGroup {
param (
[Parameter(Mandatory = $true)]
[string]$ManagementGroupId
)
# Array to store subscription IDs
$subscriptionIds = @()
# Get the management group hierarchy
$managementGroup = Get-AzManagementGroup -GroupId $ManagementGroupId -Expand
if ($managementGroup -and $managementGroup.Children) {
# Loop through each child in the management group
foreach ($child in $managementGroup.Children) {
if ($child.Type -eq "Microsoft.Management/managementGroups") {
# Recursively get subscriptions from child management groups
$childManagementGroupId = $child.Name
$subscriptionIds += Get-SubscriptionsUnderManagementGroup -ManagementGroupId $childManagementGroupId
} elseif ($child.Type -match "/subscriptions") {
# Extract subscription ID
$subscriptionId = [regex]::Match($child.Name, "([a-f0-9-]{36})").Value
if ($subscriptionId) {
$subscriptionIds += $subscriptionId
}
}
}
}
return $subscriptionIds
}
Function: CheckScheduleEntry
This function checks if the current time falls within a specified time range.
function CheckScheduleEntry ([string]$TimeRange) {
$rangeStart, $rangeEnd, $parsedDay = $null
$currentTime = (Get-Date).ToUniversalTime().AddHours(2)
$midnight = $currentTime.AddDays(1).Date
try {
if ($TimeRange -like "*->*") {
$timeRangeComponents = $TimeRange -split "->" | ForEach-Object { $_.Trim() }
if ($timeRangeComponents.Count -eq 2) {
$rangeStart = Get-Date $timeRangeComponents[0]
$rangeEnd = Get-Date $timeRangeComponents[1]
if ($rangeStart -gt $rangeEnd) {
if ($currentTime -ge $rangeStart -and $currentTime -lt $midnight) {
$rangeEnd = $rangeEnd.AddDays(1)
}
else {
$rangeStart = $rangeStart.AddDays(-1)
}
}
}
else {
Write-Output "`WARNING: Invalid time range format. Expects valid .Net DateTime-formatted start time and end time separated by '->'"
}
}
else {
if ([System.DayOfWeek].GetEnumValues() -contains $TimeRange) {
if ($TimeRange -eq (Get-Date).DayOfWeek) {
$parsedDay = Get-Date "00:00"
}
}
else {
$parsedDay = Get-Date $TimeRange
}
if ($parsedDay -ne $null) {
$rangeStart = $parsedDay
$rangeEnd = $parsedDay.AddHours(23).AddMinutes(59).AddSeconds(59)
}
}
}
catch {
Write-Output "`WARNING: Exception encountered while parsing time range. Details: $($_.Exception.Message). Check the syntax of entry, e.g. '<StartTime> -> <EndTime>', or days/dates like 'Sunday' and 'December 25'"
return $false
}
if ($currentTime -ge $rangeStart -and $currentTime -le $rangeEnd) {
return $true
}
else {
return $false
}
}
Function: AssertVirtualMachinePowerState
This function ensures that a VM is in the desired power state (running or stopped) based on the schedule.
function AssertVirtualMachinePowerState {
param (
[Object]$VirtualMachine,
[string]$DesiredState,
[bool]$Simulate
)
$resourceManagerVM = Get-AzVM -ResourceGroupName $VirtualMachine.ResourceGroupName -Name $VirtualMachine.Name -Status
$currentStatus = $resourceManagerVM.Statuses | Where-Object { $_.Code -like "PowerState*" }
$currentStatus = $currentStatus.Code -replace "PowerState/", ""
if ($DesiredState -eq "Started" -and $currentStatus -notmatch "running") {
if ($Simulate) {
Write-Output "[$($VirtualMachine.Name)]: SIMULATION -- Would have started VM. (No action taken)"
}
else {
Write-Output "[$($VirtualMachine.Name)]: Starting VM"
$resourceManagerVM | Start-AzVM
}
}
elseif ($DesiredState -eq "StoppedDeallocated" -and $currentStatus -ne "deallocated") {
if ($Simulate) {
Write-Output "[$($VirtualMachine.Name)]: SIMULATION -- Would have stopped VM. (No action taken)"
}
else {
Write-Output "[$($VirtualMachine.Name)]: Stopping VM"
$resourceManagerVM | Stop-AzVM -Force
}
}
else {
Write-Output "[$($VirtualMachine.Name)]: Current power state [$currentStatus] is correct."
}
}
The script iterates through all VMs in the specified subscriptions, checks their tags, and enforces the power state according to the schedule. Below is the outline of what it does
try {
# Main script logic
}
catch {
$errorMessage = $_.Exception.Message
throw "Unexpected exception: $errorMessage"
}
finally {
Write-Output "Script finished (Duration: $(("{0:hh\:mm\:ss}" -f ((Get-Date).ToUniversalTime() - $currentTime))))"
}
5. Usage Example
To run the script, use the following command:
.\StartStopVMsBasedOnTag.ps1 -TagName "AutoShutdownSchedule" -ManagementGroupId "MngEnv" -Simulate $true
I will be running the example in Local PowerShell, but the PowerShell could be run from anywhere including Automation Accounts.
5.1 We tag our virtual machine accordingly
5.2 We look at our PowerShell code after being signed in to Azure and we run our command
5.3 We used the "-Simulate $true" flag which shows us what would have happened. If we want to run this in production, we can simulate first and when we are happy with our testing we can stop simulating by switching the "-Simulate $false"
6. Conclusion
Automating VM shutdown schedules using Azure tags helps optimize resource usage and reduce costs. By following this guide, you can implement a similar solution in your Azure environment. If you have any questions or feedback, feel free to leave a comment below. You can find a copy of this code in my GitHub Repo --> RallTheory/StartStopVMs/StartStopVMsBasedOnTag.ps1 at main ยท WernerRall147/RallTheory (github.com)
Disclaimer
The sample scripts are not supported under any Microsoft standard support program or service. The sample scripts or Power BI Dashboards are provided AS IS without warranty of any kind. Microsoft further disclaims all implied warranties including, without limitation, any implied warranties of merchantability or of fitness for a particular purpose. The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. In no event shall Microsoft, its authors, or anyone else involved in the creation, production, or delivery of the scripts or Power BI Dashboards be liable for any damages whatsoever (including, without limitation, damages for loss of business profits, business interruption, loss of business information, or other pecuniary loss) arising out of the use of or inability to use the sample scripts or documentation, even if Microsoft has been advised of the possibility of such damages. This blog post was written with the help of generative AI.