The big picture

First of all, you shouldn't normally need to take your web role offline. You only need to deploy to staging slot and when it's ready, swap the slots.

But you might need to do some database maintenance which needs preventing database changes in order to ensure data integrity. In that case you have two options: either stop the role and let your users face the cruelty of 503 page, or use a cool ASP.NET feature to display a friendly message to your users and assure them you’ll be back soon.

Displaying that site-under-maintenance page is easy, the only thing you need to do is to put a file named app_offline.htm into your site root and IIS will take care of the rest. (A little secret: the name should be app_offline.htm exactly and it should be a self-sufficient file with no linked styles or images)

Easy! Right? Well, it wasn’t for me. I had to use remote-desktop to connect to every instance on our cloud service and copy the magical file to its root. Is there an easy way to do that? Please let me know.

What if I use Remote PowerShell to connect to each instance and copy my app_offline to the site root? There is a little problem: you cannot access instances using their IP address. Don’t worry, there is another way: InstanceInputEndpoint. It basically defines a range of ports on your cloud service and maps them to a spesific port on each intance. For example if you have

  <InstanceInputEndpoint name="WinrmAccess" protocol="tcp" localPort="5986">
    <AllocatePublicPortFrom>
      <FixedPortRange max="60670" min="60666" />
    </AllocatePublicPortFrom>
  </InstanceInputEndpoint>

in your service definition, and your cloud service domain is https://mycoolwebapp.cloudapp.net, you can access port 5986 of the first instance using https://mycoolwebapp.cloudapp.net:60666

The last piece of puzzle is enabling WinRM on instances. This can be done using Azure Startup Tasks. You can read more about them here and here, and make sure you don’t miss this one; it saves you lots of time.

It’s time to put all the pieces together

Let’s say we have a cool ASP.NET project, named MyCoolWebApplication and its companion Azure project.

Solution Explorer

The first step is to add the offline page content somewhere in the project. You can either put it in the content folder, or the project root with a different name like offline.htm.

Next thing to do is to create startup tasks. Here I have two files under StartupScripts folder. Again, it’s up to you where to put your tasks. They are not part of your project, but they need to be deployed with it.

Here I have a cmd file which is only there to run the PowerShell script. Read more about using a PowerShell Script as startup task here.

StartupTask.cmd

@echo off 
PowerShell -ExecutionPolicy Unrestricted -File .\StartupScripts\Enable-PowershellRemoting.ps1  >> "%TEMP%\StartupLog.txt
EXIT /B 0

Enable-PowershellRemoting.ps1

Function Write-Log($message) 
{
    $date = get-date -Format "yyyy-MM-dd HH:mm:ss"
    $content = "`n$date - $message"
    Add-Content $Script:LogFile $content
}

function Find-Certificate($hostname) {
    foreach ($cert in Get-ChildItem cert:\LocalMachine\My) { 
        if ($cert.Subject -like ("*" + $hostname + "*")) {
            return $cert
        }
    }
}

Function Add-WinrmFirewallRule()
{
    netsh advfirewall firewall add rule name="PowerShell Remoting" dir=in action=allow service=any enable=yes profile=any localport=5986 protocol=tcp
}

Function Start-WinrmRemotingListner()
{
    $certId = "MyCoolWebApplication"
    $cert = Find-Certificate $certId
    winrm create winrm/config/listener?Address=*+Transport=HTTPS `@`{Hostname=`"($certId)`"`;CertificateThumbprint=`"($cert.Thumbprint)`"`}
    Set-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB 2000
}

Function Create-AdminAccount ([string]$AccountName, [string]$AccountPassword) 
{
    if ((Get-WmiObject -Class Win32_UserAccount | where {$_.Name -eq $AccountName}) -eq $null)
    {
        $hostname = hostname
        $comp = [adsi]"WinNT://$hostname"
        $user = $comp.Create("User", $AccountName)
        $user.SetPassword($AccountPassword)
        $user.SetInfo()
        $user.description = "WinRM Administrator Account"
        $user.SetInfo()
        $ADS_UF_DONT_EXPIRE_PASSWD = 65536 # 0x10000
        $User.UserFlags[0] = $User.UserFlags[0] -bor $ADS_UF_DONT_EXPIRE_PASSWD 
        $user.SetInfo()

        $objOU = [ADSI]"WinNT://$hostname/Administrators,group"
        $objOU.add("WinNT://$hostname/$accountName")

        $objOU = [ADSI]"WinNT://$hostname/Remote Desktop Users,group"
        $objOU.add("WinNT://$hostname/$accountName")
    }
}


$Script:LogFile = ".\StartupScriptLog.txt"
if (-not (Test-Path $Script:LogFile))
{
    Write-Log "Configuring WinRM ..."

    Create-AdminAccount -AccountName $env.WinrmUsername -AccountPassword $env.WinrmPassword
    Write-Log "Admin account created."

    Add-WinrmFirewallRule
    Write-Log "Winrm firewall rule is added."

    Start-WinrmRemotingListner
    Write-Log "WinRM listener started."
}

Notes

  • Most of the code is taken from here and here.
  • This code assumed that a valid certificate is already installed on your instance. If it's a testing environment you can use the code form here to install a local certificate (You'll need to ignore certificate error later on)
  • If you only use a specific range of IP addresses add remoteip = <your range> to your firewall command.

It's time to configure Azure Cloud Service; add the following lines to your ServiceDefinition.csdef

<InstanceInputEndpoint name="WinrmAccess" protocol="tcp" localPort="5986">
      <AllocatePublicPortFrom>
        <FixedPortRange max="60670" min="60666" />
      </AllocatePublicPortFrom>
    </InstanceInputEndpoint>

to <Endpoints> section and

    <Task commandLine=".\StartupScripts\StartupTask.cmd" executionContext="elevated" taskType="background">
      <Environment>
        <Variable name="WinrmUsername" value="MyWinrmUsername"/>
        <Variable name="WinrmPassword" value="MyVerySecurePassword"/>
      </Environment>
    </Task>

to <Startup> section.

Save everything and deploy your project.

Using remote Powershell to change the site mode to offline and online

Next time you need to take everything offline, use this script:

Function Set-SiteMode
(
    [Parameter(Mandatory=$True)]
    [ValidateNotNullOrEmpty()] 
    [String]$ServiceName,

    [Parameter(Mandatory=$True)]
    [ValidateNotNullOrEmpty()] 
    [String]$SiteUrl,

    [Parameter(Mandatory=$True)]
    [ValidateNotNullOrEmpty()] 
    [Int32]$FirstPort,

    [Parameter(Mandatory=$True)]
    [ValidateSet('Offline','Online', ignorecase=$True)]
    [String]$Mode
)
{

    Function Run-ScriptOnAllInstances($ServiceName, $SiteUrl, $FirstPort, $ScriptBlock)
    {
        $DeploymentRoleInstanceCount = (Get-AzureDeployment -Slot Production -ServiceName $ServiceName).RoleInstanceList.Count

        $RemoteWinrmCredential = Get-Credential

        for ($i=0; $i -lt $DeploymentRoleInstanceCount; $i++)
        {
            $PortNumber = $FirstPort + $i
            $InstanceConnectionUri = ("{0}:{1}" -f $SiteUrl, $PortNumber)
            Write-Verbose ("Updating {0}" -f $InstanceConnectionUri)
            $InstanceSession = New-PSSession -ConnectionUri $InstanceConnectionUri -Authentication Negotiate -Credential $RemoteWinrmCredential 
            Invoke-Command -ScriptBlock $ScriptBlock -Session $InstanceSession
            Remove-PSSession $InstanceSession
        }
    }

    Function Set-InstanceOffline()
    {
        Import-Module WebAdministration
        $SiteRootLocation = (Get-ChildItem IIS:\Sites | where {$_.Name -like "mycoolsite*" }).physicalPath
        Set-Location $SiteRootLocation
        Copy-Item -Path .\Content\App_Offline\app_offline.htm -Destination .\app_offline.htm -Force
    }

    Function Set-InstanceOnline()
    {
        Import-Module WebAdministration
        Set-Location (Get-ChildItem IIS:\Sites | where {$_.Name -like "mycoolsite*" }).physicalPath

        if(Test-Path -Path app_offline.htm)
        {
            Remove-Item -Path app_offline.htm -Force
        }
    }

    Write-Verbose ("Setting {0} site mode to {1} ..." -f $SiteUrl, $Mode)

    if ($Mode -eq "Offline")
    {
        Run-ScriptOnAllInstances -ServiceName $ServiceName -SiteUrl $SiteUrl -FirstPort $FirstPort -ScriptBlock ${function:Set-InstanceOffline}
    }
    else
    {
        Run-ScriptOnAllInstances -ServiceName $ServiceName -SiteUrl $SiteUrl -FirstPort $FirstPort -ScriptBlock ${function:Set-InstanceOnline}
    }

    Write-Verbose "Completed"
}

Set-SiteMode -ServiceName "MyCoolWebApplication" -SiteUrl "https:\\www.mycoolsite.com.au" -FirstPort 60666 -Mode Offline -Verbose 

Notes

  • Before running the script make sure you have the correct subscription, find out more about Azure Subscription here
  • If you get any errors regarding remote host use this link for troubleshooting.
  • You might need to change your PowerShell execution policy to be able to run the scripts.
  • If you’re not using a valid certificate on your remote host, use -SessionOption (New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck), at your own risk.

<br/>


DISCLAIMER: The scripts are provided as is and while I tested the original ones I had to make some changes to make them suitable for this post and I haven’t done much testing on the ones presented here.