Skip to main content

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"