PowerShell script to achieve zero downtime deployments using IIS ARR module. It uses the Green / Blue deployment strategy: two sites and reverse proxy in front of them (ARR in this case). Only one site is running at a time. The script deploys to the stopped site, warms it up and the swaps the reverse proxy to route requests to the new site. Finally it stops the old site.
# -----------------------------------------------------------------------------
# IIS-ARR-Zero-Downtime
# =====================
#
# Powershell script to achieve zero downtime deployments using IIS ARR module.
# It uses the Green / Blue deployment strategy: two sites and reverse proxy in
# front of them (ARR in this case). Only one site is running at a time. The
# script deploys to the stopped site, warms it up and the swaps the reverse
# proxy to route requests to the new site. Finally it stops the old site.
#
# ## Invoking the Deploy method assumes:
#
# 1. There are 2 stopped sites called $projectName-Green and $projectName-Blue.
# Green bound only to $deploymentGreenNodeAddress and Blue only bound to
# deploymentBlueNodeAddress, both using port $deploymentNodesPort
# 2. There is a farm called $projectName-Farm
# 3. The farm has 2 nodes, each pointing to one of the sites (Blue and Green)
# 4. There is a rewrite rule to redirect requests to the farm when the port
# ({SERVER_PORT}) does not match $deploymentNodesPort
# 5. There is a healthcheck in the farm pointing to the main url of the farm
#
# ## File Structure
#
# - C:\PATH_TO_YOUR_CODE\$projectName (This folder holds the files to be deployed)
# - C:\PATH_TO_YOUR_CODE\$projectName-Green (Can be empty to start with - deployment files will be copied here when activating this node)
# - C:\PATH_TO_YOUR_CODE\$projectName-Blue (Can be empty to start with - deployment files will be copied here when activating this node)
#
# ## IIS Sites
#
# - "$projectName" (ARR Site) Running
# - "$projectName-Green" (Balanced Site Green) Stopped
# - "$projectName-Blue" (Balanced Site Blue) Stopped
#
# ## Web Farms
#
# - "$projectName-Farm"
# - "$deploymentBlueNodeAddress" Unavailable
# - "$deploymentGreenNodeAddress" Unavailable
#
# This script provides some utility functions to automate the creating of the
# sites, farms and nodes but you don't need to use them. By calling "Deploy" the
# script assumes all the above has been setup.
#
# XML Schema for ARR: %windir%\system32\inetsrv\config\schema\arr_schema.xml
#
# ## Usage
#
# A bat file is all is required:
#
# ```powershell
# powershell -ExecutionPolicy unrestricted -file "FULL PATH TO PS1 FILE"
# pause
# ```
#
# The default behavior of the script is just to call the Deploy function, which
# assumes all sites, farms, nodes and IPs are setup. This functions just deploys
# to the inactive node, swaps ARR to use it, and deactivates the active node.
#
# If you are using Windows 2012 / Windows 8 you can also use the utility
# functions to script most of the setup process. Create a Farm, the nodes and
# add the IPs to your local interface.
#
# ## Related article
#
# How to Deploy Anything in IIS with Zero Downtime on a Single Server
# https://kevinareed.com/2015/11/07/how-to-deploy-anything-in-iis-with-zero-downtime-on-a-single-server/
# -----------------------------------------------------------------------------
#[System.Reflection.Assembly]::LoadWithPartialName("Microsoft.Web.Administration")
[System.Reflection.Assembly]::LoadFrom( ${env:windir} + "\system32\inetsrv\Microsoft.Web.Administration.dll" ) > $null
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
Import-Module WebAdministration -ErrorAction Stop
Add-PSSnapin WebFarmSnapin
$deploymentBlueNodeAddress = "127.0.0.1" #The ip of the node representing the blue site
$deploymentGreenNodeAddress = "127.0.0.2" #The ip of the node representing the blue site
$deploymentBlueNodePort = 22001 #The port used by both sites and nodes
$deploymentGreenNodePort = 22002 #The port used by both sites and node2
$path = "C:\inetpub\wwwroot\" #The path where the deployment, blue and green sites are. Script will look for the deployment files in $path\$projectName and copy them to $path\$projectName-Green and $path\$projectName-Blue
$projectName = "YOUR SITE NAME" #The base name for the IIS balanced sites (Blue and Green) and the folders holding their application files
$networkInterfaceAlias = "Ethernet" #The name of the network adapter where to create the IPs bound to the balanced sites
Set-Location $path
function HasIpAddress($ip)
{
$r = Get-NetIPAddress |
Where-Object { $_.InterfaceAlias -eq $networkInterfaceAlias } |
Where-Object { $_.IPAddress -eq $ip }
return $r -ne $null
}
function CreateIpAddress($ip)
{
Write-Host "Adding IP $ip to interface $networkInterfaceAlias"
$r = New-NetIPAddress -InterfaceAlias $networkInterfaceAlias -IPAddress $ip -PrefixLength 24
}
function CreateBalancedSite($sufix, $ip, $port)
{
$hasIp = HasIpAddress $ip
if ($hasIp -eq $false)
{
$r = CreateIpAddress($ip)
}
$foundSite = Get-Website $projectName-$sufix
if ($foundSite -eq $null)
{
$r = CreateWebSite $sufix $port $ip "$path\$projectName-$sufix"
}
}
function CreateWebSite($sufix, $port, $ip, $path)
{
Write-Host "Creating site $projectName-$sufix..."
mkdir -Path $path
$r = New-Website -Name "$projectName-$sufix" -Port $port -IPAddress $ip -PhysicalPath $path
}
function ChangeNodeStatus($farm, $nodeAddress, $status)
{
#status can be "Start", "Drain", "ForcefulStop", "GracefulStop"
#Find the node mapped to the site we are going to the deploy the files to
$nodes = $farm.GetCollection()
$deploymentNode = $nodes | Where-Object { $_.GetAttributeValue("address") -eq $nodeAddress }
if ($deploymentNode -eq $null)
{
Write-Error "Could not find deploymentNode with address $nodeAddress"
exit 1
}
$arrObject = $deploymentNode.GetChildElement("applicationRequestRouting")
#Change the node status
Write-Host "Changing status for node $nodeAddress to $status"
$setStateMethod = $arrObject.Methods["SetState"]
$setStateMethodInst = $setStateMethod.CreateInstance()
$newStateProp = $setStateMethodInst.Input.Attributes[0].Value = $status
$setStateMethodInst.Execute()
}
function ChangeNodeToHealthy($farm, $nodeAddress)
{
#Find the node mapped to the site we are going to the deploy the files to
$nodes = $farm.GetCollection()
$deploymentNode = $nodes | Where-Object { $_.GetAttributeValue("address") -eq $nodeAddress }
if ($deploymentNode -eq $null)
{
Write-Error "Could not find deploymentNode with address $nodeAddress"
exit 1
}
$arrObject = $deploymentNode.GetChildElement("applicationRequestRouting")
#Change the node status
Write-Host "Changing health status for node $nodeAddress to healthy..."
$setStateMethod = $arrObject.Methods["SetHealthy"]
$setStateMethodInst = $setStateMethod.CreateInstance()
$setStateMethodInst.Execute()
}
function SetNodeHttpPort($farmName, $ip, $port)
{
$computer = Get-Content env:computername
$iis = [Microsoft.Web.Administration.ServerManager]::OpenRemote($computer.ToLower())
#Get app host configuration file and the webfarms section within it
$conf = $iis.GetApplicationHostConfiguration()
$webFarmsSection = $conf.GetSection("webFarms")
$webFarms = $webFarmsSection.GetCollection()
$farm = $webFarms | Where-Object { $_.GetAttributeValue("name") -eq $farmName }
$nodes = $farm.GetCollection()
$deploymentNode = $nodes | Where-Object { $_.GetAttributeValue("address") -eq $ip }
$httpPort = $deploymentNode.ChildElements.Attributes | Where-Object { $_.Name -eq "httpPort" }
$httpPort.Value = $port
$iis.CommitChanges()
}
function CreateFarmNode($nodeAddress)
{
$computer = Get-Content env:computername
$iis = [Microsoft.Web.Administration.ServerManager]::OpenRemote($computer.ToLower())
$sites = $iis.sites |
Select-Object Id, Name, State |
Format-Table -AutoSize
#Get app host configuration file and the webfarms section within it
$conf = $iis.GetApplicationHostConfiguration()
$webFarmsSection = $conf.GetSection("webFarms")
$webFarms = $webFarmsSection.GetCollection()
#Find the farm by its name
$farmName = "$projectName-Farm"
$farm = $webFarms | Where-Object { $_.GetAttributeValue("name") -eq $farmName }
if ($farm -eq $null)
{
New-WebFarm $farmName -Enabled
New-Server -WebFarm $farmName -Address $deploymentBlueNodeAddress
New-Server -WebFarm $farmName -Address $deploymentGreenNodeAddress
SetNodeHttpPort "$projectName-Farm" $deploymentBlueNodeAddress $deploymentBlueNodePort -Enabled
SetNodeHttpPort "$projectName-Farm" $deploymentGreenNodeAddress $deploymentGreenNodePort
}
else
{
#Find the node mapped to the site we are going to the deploy the files to
$nodes = $farm.GetCollection()
}
}
function CreateRewriteRule($port)
{
$rule = Get-WebConfigurationProperty "/system.webserver/rewrite/globalRules/rule[@name='$projectName']" -Name "name"
if ($rule -eq $null)
{
$serverManager = New-Object Microsoft.Web.Administration.ServerManager
$config = $serverManager.GetApplicationHostConfiguration();
$rulesSection = $config.GetEffectiveSectionGroup().SectionGroups["system.webServer"].Sections["rewrite"]
$rulesSection
}
}
function Deploy()
{
$computer = Get-Content env:computername
$iis = [Microsoft.Web.Administration.ServerManager]::OpenRemote($computer.ToLower())
$blueSiteName = "$projectName-Blue"
$greenSiteName = "$projectName-Green"
$blueSite = Get-Website $blueSiteName
$greenSite = Get-Website $greenSiteName
if ($blueSite -eq $null)
{
Write-Error "Could not find IIS website $blueSiteName"
exit 1
}
if ($greenSite -eq $null)
{
Write-Error "Could not find IIS website $greenSiteName"
exit 1
}
$blueStatus = Get-WebsiteState $blueSiteName
$greenStatus = Get-WebsiteState $greenSiteName
$farmName = "$projectName-Farm"
#decide which node to deploy
if ($blueStatus.Value -eq "Stopped")
{
$stoppedNodeAddress = $deploymentBlueNodeAddress
$stoppedSiteName = "$projectName-Blue"
$runningNodeAddress = $deploymentGreenNodeAddress
$runningSiteName = "$projectName-Green"
$wakeUpPort = $deploymentBlueNodePort
}
else
{
$stoppedNodeAddress = $deploymentGreenNodeAddress
$stoppedSiteName = "$projectName-Green"
$runningNodeAddress = $deploymentBlueNodeAddress
$runningSiteName = "$projectName-Blue"
$wakeUpPort = $deploymentGreenNodePort
}
#Get app host configuration file and the webfarms section within it
$conf = $iis.GetApplicationHostConfiguration()
$webFarmsSection = $conf.GetSection("webFarms")
$webFarms = $webFarmsSection.GetCollection()
#Find the farm by its name
$farm = $webFarms | Where-Object { $_.GetAttributeValue("name") -eq $farmName }
if ($farm -eq $null)
{
Write-Error "Could not find farm $farmName"
exit 1
}
$deploymentSite = Get-Item IIS:\Sites\$stoppedSiteName
$deploymentSitePath = $deploymentSite.PhysicalPath
$deploymentFilesPath = $path + "\" + $projectName + "\"
$archivePath = $deploymentFilesPath + $projectName + ".zip"
#Deploy files
$shell = New-Object -com shell.application
$destination = $shell.namespace($deploymentSitePath)
# If there is a zip file, use it to deploy
if (Test-Path $archivePath)
{
Write-Host "Extracting archive '$archivePath' to $deploymentFilesPath..."
$zipFile = $shell.namespace($archivePath)
$destination.Copyhere($zipFile.items(), [System.Int32]1556)
}
else
{
Write-Host "Copying raw files to $deploymentSitePath..."
$destination.CopyHere($deploymentFilesPath + "\*", [System.Int32]1556)
}
Write-Host "Starting deployment website..."
Start-Website $stoppedSiteName
#wake up deployment site
$url = "http://" + $stoppedNodeAddress + ":$wakeUpPort/"
Write-Host "Starting deployment website $url..."
$page = (New-Object System.Net.WebClient).DownloadString($url)
#Swap farm nodes
ChangeNodeToHealthy $farm $stoppedNodeAddress
ChangeNodeStatus $farm $stoppedNodeAddress "Start"
ChangeNodeStatus $farm $runningNodeAddress "Drain"
#Wait a minute for connections to drain
Write-Host "Waiting 60 secs for connections to drain..."
Start-Sleep -s 60
#Stop old code website
Stop-Website $runningSiteName
}
Deploy
Write-Host "Done"