Periodically restart an Azure App Service using a WebJob

Periodically restart an Azure App Service using a WebJob

Introduction

We have an ASP.NET web application, running in an Azure App Service. After each deployment, the webapp is nice and fast. Then, its performance slowly degrades. After 4 weeks, the page load times are unbearably slow.

We’ve investigated this, but weren’t able to find the root cause. Digging deeper is simply too costly at this point. So as mitigation, we’d like to just restart the webbapp every now and then, so that at least our users aren’t bothered by the slowness.

I did some googling on how to do this. A WebJob seems to be the go-to answer.

The WebJob file format

I wanted to check out what WebJobs look like, but I wasn’t allowed to just add a WebJob to our existing App Service. Apparently DevOps-deployments interfere with WebJobs created via the portal.

webjob - not via portal

❌ WebJob cannot be added from portal if deployment from source control is configured.

So I created some WebJobs on an App Service that’s not hooked up to git. To get past the upload validation, I created an empty file, named it foo.py, and zipped it. I then uploaded the .zip twice, once as continuous and once as triggered. Here’s the result.

webjob - via portal

WebJob dir structure

📦 wwwroot
 ┣ 📂App_Data
 ┃ ┗ 📂jobs
 ┃   ┣ 📂continuous
 ┃   ┃ ┗ 📂cont
 ┃   ┃   ┗ 📜foo.py
 ┃   ┗ 📂triggered
 ┃     ┗ 📂trig
 ┃       ┣ 📜foo.py
 ┃       ┗ 📜settings.job
 ┗ (… application code …)

⤷ inspired by file-tree-generator

Apparently WebJobs get stored under App_Data/jobs, with the NCRONTAB expression for triggered jobs stored in settings.job.
(‘cont’ and ‘trig’ are the job names I chose.)

// App_Data/jobs/triggered/<job-name>/settings.job
{
    // Your cron expression goes here.
    // https://docs.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer?tabs=csharp#ncrontab-expressions
    "schedule": "0 */2 * * * *"
}

If we get DevOps to upload the WebJob using this format, then the job should get picked up.

The supported languages / executables are: (source)

  • .cmd, .bat, .exe (using Windows cmd)
  • .ps1 (using PowerShell)
  • .sh (using Bash)
  • .php (using PHP)
  • .py (using Python)
  • .js (using Node.js)
  • .jar (using Java)

Exploring the WebJob runtime environment

Let’s use Powershell: the HAVIT-blog and Stack Overflow both state that the restart-command itself is as simple as follows. We can use Kudu as a debugging tool.

Stop-AzureRmWebApp -Name 'my-app' -ResourceGroupName 'my-rg'
Start-AzureRmWebApp -Name 'my-app' -ResourceGroupName 'my-rg'

link to Kudu from App Service
kudu: available PS modules

Start-AzureRmWebApp/Stop-AzureRmWebApp are both part of AzureRM PowerShell, which has been marked as outdated. New scripts should prefer Azure PowerShell (Restart-AzWebApp) or the Azure CLI (az webapp restart).

It looks like all we’ve got is AzureRM, and the ancient AzureRM.Websites-1.1.0 at that. Oh well, let’s just make this work.

PS C:\home> Restart-AzureRmWebApp -ResourceGroupName webjobtest-rg -Name webjobtest-app
Restart-AzureRmWebApp : Run Login-AzureRmAccount to login.

All right. So we need to authenticate.

Getting authorization

Azure App Services support Managed Identities. That means that we’ll be spared the hassle of dealing with credentials.

Side note: the terms below all seem to refer to the same concept.
“Managed Identity” seems to be the go-to term, so I’ll use that.

  • service principal / SPN
  • managed identity
  • Enterprise Application (in AAD)

system assigned managed identity - app service
app service managed identity in AAD

The docs “How to use managed identities for App Service” say that you should use the managed identity to obtain a token, and then use that token to access resources. It even includes some example code.

$resourceURI = "https://management.azure.com/"
$tokenAuthURI = $env:IDENTITY_ENDPOINT + "?resource=$resourceURI&api-version=2019-08-01"
$tokenResponse = Invoke-RestMethod -Method Get -Headers @{"X-IDENTITY-HEADER"="$env:IDENTITY_HEADER"} -Uri $tokenAuthURI
$accessToken = $tokenResponse.access_token
$accessToken

Login-AzureRmAccount (AKA Add-AzureRmAccount) has a parameterset that takes an -AccessToken. Unfortunately, I wasn’t able to figure out how to use it.

PS C:\home> Get-Help Login-AzureRmAccount
NAME
    Add-AzureRmAccount
(…)
SYNTAX
    Add-AzureRmAccount [-EnvironmentName <String>] [-SubscriptionId <String>]
    [-SubscriptionName <String>] [-Tenant <String>] -AccessToken <String>
    [<CommonParameters>]
(…)

Let’s try the REST API instead - the Stack Overflow post mentions that it needs a token. The docs for Web Apps - Restart even include a “Try it” tool that demonstrates how to use this token. Using this “Try it” tool, restarting my webapp actually works! 🎉

POST https://management.azure.com/subscriptions/{guid}/resourceGroups/{rg-name}/providers/Microsoft.Web/sites/{webapp-name}/restart?api-version=2019-08-01
Authorization: Bearer {token}
Content-type: application/json

Let’s add this to the WebJob.

$uri = "https://management.azure.com/subscriptions/c972f514-0123-4567-89ab-51d47a550082/resourceGroups/webjobtest-rg/providers/Microsoft.Web/sites/webjobtest-app/restart?api-version=2019-08-01"
$response = Invoke-RestMethod -Method Post -Headers @{"Authorization"="Bearer $accessToken"} -Uri $uri
$response

Invoke-RestMethod : {"error":{"code":"AuthorizationFailed","message":"The client '47081595-0495-4104-9030-26920b268312' with object id '47081595-0495-4104-9030-26920b268312' does not have authorization to perform action 'Microsoft.Web/sites/restart/action' over scope '/subscriptions/c972f514-0123-4567-89ab-51d47a550082/resourceGroups/webjobtest-rg/providers/Microsoft.Web/sites/webjobtest-app' or the scope is invalid. If access was recently granted, please refresh your credentials."}}

Right, so we still need to authorize the Managed Identity. Let’s do that.
I gave the Managed Identity the role Website Contributor on the App Service. This is smallest pre-defined role that includes the restart-permission. You could create a custom role that only grants restart permissions. I didn’t bother.

azure portal: authorize the managed identity

Retried the Invoke-RestMethod. It now no longer gives an error, and the webapp actually restarts! 🎉🎉

Time for some clean-up: I’d like the restart-command to report that it worked, rather than simply not giving an error. We should be able to see the HTTP 200 if we use Invoke-WebRequest instead.

Invoke-WebRequest -Method Post -Uri $uri -Headers @{"Authorization"="Bearer $accessToken"}
Invoke-WebRequest : The response content cannot be parsed because the Internet Explorer engine is not available, or Internet Explorer's first-launch configuration is not complete. Specify the UseBasicParsing parameter and try again. 

Okay. Can add -UseBasicParsing.

Invoke-WebRequest -Method Post -Uri $uri -Headers @{"Authorization"="Bearer $accessToken"} -UseBasicParsing
Invoke-WebRequest : Win32 internal error "The handle is invalid" 0x6 occurred while reading the console output buffer. Contact Microsoft Customer Support Services.

Err what? That’s quite the cryptic error message.
This post explains that Invoke-WebRequest tries to display a progress bar, which is not supported, and that the solution is to suppress the progress bar. The HAVIT-blog does this as well, just without explaining why.

$ProgressPreference="SilentlyContinue"

Trying again:

PS C:\home> $ProgressPreference="SilentlyContinue"
PS C:\home> Invoke-WebRequest -Method Post -Uri $uri -Headers @{"Authorization"="Bearer $accessToken"} -UseBasicParsing

StatusCode        : 200
StatusDescription : OK
Content           : {}
RawContent        : HTTP/1.1 200 OK
                    Pragma: no-cache
                    Strict-Transport-Security: max-age=31536000; 
                    includeSubDomains
                    x-ms-request-id: c0c2436e-374d-4750-bf0a-989ac1c99f88
                    x-ms-ratelimit-remaining-subscription-writes: ...
Headers           : {[Pragma, no-cache], [Strict-Transport-Security, 
                    max-age=31536000; includeSubDomains], [x-ms-request-id, 
                    c0c2436e-374d-4750-bf0a-989ac1c99f88], 
                    [x-ms-ratelimit-remaining-subscription-writes, 1199]...}
RawContentLength  : 0

Excellent! It works, and it confirms that it does.

Let’s extract the subscription-guid and resource names from the environment variables, instead of hard-coding them.

app service env vars azure resource

The final WebJob

#
# This is a WebJob that restarts the current app service.
#
# Prerequisites:
# 
# 1. The system assigned managed identity (MSI) must be enabled.
#    To enable: Azure Portal → this App Service → Identity → Status → On.
#
# 2. The MSI must be authorized to restart this app service.
#    To authorize: Azure Portal → this App Service → Access control (IAM) → Add role assignment → 
#    → Role = "Website Contributor" (or another that allows restarts) → User = [same name as app service] → Save

$ErrorActionPreference = "Stop"

if (-not (Test-Path env:IDENTITY_ENDPOINT)) {
    throw "IDENTITY_ENDPOINT is not set. Please enable the system assigned managed identity for this app service."
}

### Fetch an access token, based on the MSI.

$tokenAuthURI = $env:IDENTITY_ENDPOINT + "?resource=https://management.azure.com/&api-version=2019-08-01"
$tokenResponse = Invoke-RestMethod -Method Get -Uri $tokenAuthURI -Headers @{"X-IDENTITY-HEADER"="$env:IDENTITY_HEADER"}
$accessToken = $tokenResponse.access_token

### Send the restart command

$subscriptionGuid = $env:WEBSITE_OWNER_NAME.Split('+')[0]
$resourceGroupName = $env:WEBSITE_RESOURCE_GROUP
$appServiceName = $env:WEBSITE_SITE_NAME
$restartApp = "https://management.azure.com/subscriptions/$subscriptionGuid/resourceGroups/$resourceGroupName/providers/Microsoft.Web/sites/$appServiceName/restart?api-version=2019-08-01"

# Turning off the ProgressPreference prevents an error where Invoke-WebRequest tries to display a progress bar, which isn't supported in WebJobs.
# > Win32 internal error "The handle is invalid" 0x6 occurred while reading the console output buffer.
$ProgressPreference = "SilentlyContinue"

# -UseBasicParsing prevents the following error:
# > The response content cannot be parsed because the Internet Explorer engine is not available, or Internet Explorer's first-launch configuration is not complete.
Invoke-WebRequest -Method Post -Uri $restartApp -Headers @{"Authorization"="Bearer $accessToken"} -UseBasicParsing

Note that we ended up not using the AzureRM-module at all - we just do two HTTP calls.

Alternative solution: a Logic App

After implementing the above, someone pointed out a solution that uses an Azure Logic App:
https://dzone.com/articles/restart-azure-web-app-using-azure-logic-app
I haven’t tried it myself, but it looks like quite an elegant solution.

References

  • Blog post from Microsoft (2017) Explains how to set up a Service Principal and then use a PowerShell-WebJob to restart the app. The code is missing though.
  • Blog post from HAVIT (2018) Fixed version of the above. Includes source code.
  • Stack Overflow question (2017) Recommends the same strategy as the MS blog post. Mentions some alternative strategies.
Oscar de Groot
Oscar de Groot

software developer (.NET and otherwise) / Azure engineer