Last time I talked about an easy problem of deploying .NET Core programs to Azure WebApps (or, well, any other app that can run there). As it turned out, it isn’t that easy, but let’s face the truth - there are far more complicated things in IT and we shouldn’t spend too much time talking about simple deployments. So let’s get started!
The plan of attack
As I stated in my previous post, everything boils down to ensuring that the WebApp is stopped and then sending the app packed as a ZIP to Kudu. It should be easy… Except it’s not. At least not THAT easy. The way you access WebApps (using Azure Resource Manager) and Kudu is rather different. ARM is quite complicated (especially when accessing it from unattended machines). And ensuring that the app is stopped is painful. Only Kudu endpoints are sort of easy to work with (albeit getting access credentials is not) and has fairly decent documentation.
To sum up, we have to:
- Build, publish and pack the app,
- Stop the WebApp,
- Upload the ZIP using Kudu,
- Restart the app.
Let’s tackle them one-by-one.
Step one - build & pack
This is the easy one - we have whole FAKE at our disposal. It has really friendly API with helpers for .NET Core CLI. I assume we have the basic building script working (if not - head to the tutorial) and a .NET Core project bootstrapped in the src
directory (dotnet new -t web
suffices ;) ). So, the only problem left is to create required Target
s (and maybe some configuration entries).
First, define some constants:
let baseDir = __SOURCE_DIRECTORY__
let sourceDir = baseDir @@ "src"
let projectDir = sourceDir @@ ProjectName |> FullName
let deployDir = baseDir @@ "deploy" |> FullName
let deployZip = baseDir @@ "deploy.zip" |> FullName
Next, we need to create our targets. What do we want them to do? Clean
, Restore
, Build
and Publish
the app. Easy, right? Of course it is! ;)
Target "Clean" (fun () ->
!! (projectDir @@ "bin")
++ (projectDir @@ "obj")
++ deployDir
|> CleanDirs
// Clean node_modules and bower files too?
DeleteFile deployZip
)
Target "Restore" (fun () ->
// If we have multiple projects, this command will restore all of them
DotNetCli.Restore (fun c -> { c with WorkingDir = baseDir })
)
Target "Build" (fun () ->
DotNetCli.Build (fun c -> { c with Configuration = currentConfig }) [projectDir]
)
Target "Publish" (fun () ->
// Unfortunately the DotNetCli helper does not wrap `publish` command - I need to change this!
DotNetCli.RunCommand id (sprintf "publish \"%s\" --configuration %s --output \"%s\"" projectDir currentConfig deployDir)
!! (deployDir @@ "**") |> Zip deployDir deployZip
)
Now we have the app freshly built, published (with the web.config
for IIS) and zipped!
Step two point one - sign in to ARM
OK, we have our app prepared, how do we stop the WebApp then? As I stated earlier, Azure Resource Manager allows us to manage the app state. To use it, we need to get the required credentials first. It’s not that complicated but really boring, so I’ll just link to the docs.
Having the credentials (tenant id, client id and client secret), we can issue the request for Access Token (ARM uses OAuth2 for authentication). It’s just a simple client_credentials
flow, so a single GET
request is enough. I’m using HTTP Utilities
and the magnificent JsonProvider
from FSharp.Data to simplify the process:
// Let's wrap the credentials in a record
type AzureWebAppSettings = {
TenantId : string
ClientId : string
ClientSecret : string
}
type AzureTokenResponse = JsonProvider<"""{"token_type":"","expires_in":"","ext_expires_in":"","expires_on":"","not_before":"","resource":"","access_token":""}""">
let acquireAccessToken settings =
let url = sprintf "https://login.microsoftonline.com/%s/oauth2/token" settings.TenantId
let response =
Http.RequestString (url, body = FormValues
[ "resource", "https://management.azure.com/";
"grant_type", "client_credentials";
"client_id", settings.ClientId;
"client_secret", settings.ClientSecret ])
let json = response |> AzureTokenResponse.Parse
json.AccessToken.JsonValue.AsString ()
Step two point two - stop the WebApp
Apart from the previously specified credentials, the ARM needs the subscription id, the resource group and the app name (for a total of 6 configuration parameters…). After providing them, we can finally tell the Azure to stop the app! But how to do that? Don’t try to find it on MSDN - it’s simply not there (or at least I couldn’t find it). You can read how to do it with Azure CLI and PowerShell, but the raw endpoint is not documented. Fortunately, it’s rather easy to get it from Google (or extract it from the source of Azure PowerShell). It’s just a POST
to stop
using the Microsoft.Web
provider:
// Extend the settings with the new ones
type AzureWebAppSettings = {
TenantId : string
ClientId : string
ClientSecret : string
SubscriptionId : string
ResourceGroup : string
WebAppName : string
}
let stopWebApp settings accessToken =
let url = sprintf "https://management.azure.com/subscriptions/%s/resourcegroups/%s/providers/Microsoft.Web/sites/%s/stop?api-version=2016-03-01" settings.SubscriptionId settings.ResourceGroup settings.WebAppName
Http.RequestString
(url,
httpMethod = HttpMethod.Post,
headers = [HttpRequestHeaders.Authorization ("Bearer " + accessToken)],
body = BinaryUpload [||] // This is needed, as Azure requires Content-Length header in POST requests
)
And voilà! The app will be stopped! That’s right, it WILL be stopped. Some time in the future. Maybe now. But maybe not. If you ask the ARM, it’ll tell you that it really IS stopped, but if you call the website, you’ll get 200 OK
. I know why this is the way it is, but Azure should provide some way of ensuring that the action has really taken place (please, tell me I am wrong and it’s just my unawareness).
I’ve tried a few distinct approaches on how to ensure the app is stopped (even tried Thread.Sleep
with a really long time-out), but none of them is as clean as I would want to. The easiest and most reliable one is probably requesting the website until 503 Service Unavailable
is returned (which IIS returns when the app pool is stopped and HEAD
method is used):
let rec ensureWebAppIsStopped settings =
let url = sprintf "https://%s.azurewebsites.net" settings.WebAppName
let response = Http.Request(url, httpMethod = HttpMethod.Head, silentHttpErrors = true)
if response.StatusCode <> 503 then
System.Threading.Thread.Sleep 1000 // Reduce the amount of calls
ensureWebAppIsStopped settings
Then it should really be dead, dotnet.exe
should be killed and w3wp.exe
that serves the requests should be gone.
Step three point one - get Kudu credentials
Kudu uses a totally different set of credentials. We could provide them together with the tenant id/client id/…, but 8 configuration entries is too much for me (hell, 6 is too much, but I have no idea how to reduce it). Fortunately, we can extract them from the publish settings of the WebApp. They are easily accessible using ARM - we just need to GET
publishxml
, parse it and extract FTP credentials:
type AzurePublishXmlResponse = XmlProvider<"""<publishData><publishProfile publishMethod="" userName="" userPWD="" /><publishProfile publishMethod="" userName="" userPWD="" /></publishData>"""> // We don't need more properties
let getKuduCredentials settings accessToken =
let url = sprintf "https://management.azure.com/subscriptions/%s/resourcegroups/%s/providers/Microsoft.Web/sites/%s/publishxml?api-version=2016-03-01" settings.SubscriptionId settings.ResourceGroup settings.WebAppName
let response =
Http.RequestString
(url,
httpMethod = HttpMethod.Get,
headers = [HttpRequestHeaders.Authorization ("Bearer " + accessToken)]
)
|> AzurePublishXmlResponse.Parse
let pubMethod = response.PublishProfiles |> Array.find (fun t -> t.PublishMethod = "FTP")
let idx = pubMethod.UserName.IndexOf '\\' // Kudu doesn't like the app specification
(pubMethod.UserName.Substring(idx + 1), pubMethod.UserPwd)
Rather easy, especially with XmlProvider
, isn’t it?
Step three point two - upload the ZIP
Okay, we now have everything that is required to upload the ZIP package we have prepared earlier. The app is surely stopped, we have Kudu’s credentials, we only need to call the ZIP Controller:
let makeBasicAuthHeader (username, password) =
sprintf "%s:%s" username password
|> Encoding.UTF8.GetBytes
|> Convert.ToBase64String
|> (+) "Basic "
|> HttpRequestHeaders.Authorization
let pushZipFile settings credentials =
let url = sprintf "https://%s.scm.azurewebsites.net/api/zip/site/wwwroot" settings.WebAppName
let content = File.ReadAllBytes deployZip
Http.Request
(url,
httpMethod = HttpMethod.Put,
headers = [makeBasicAuthHeader credentials],
body = BinaryUpload content) |> ignore
BAM! The app have just got deployed!
Step four - restart the app
Knowing how to stop the app, starting it again is really just a matter of changing stop
to start
and I’ll leave it as an exercise. ;)
Now we can wrap it in a target and configure dependencies:
Target "Upload" (fun () ->
let token = acquireAccessToken armSettings
let credentials = getKuduCredentials armSettings token
stopWebApp armSettings token
ensureWebAppIsStopped armSettings
pushZipFile armSettings credentials
startWebApp armSettings token
)
"Clean" ==> "Restore" ==> "Build" ==> "Publish" ==> "Upload"
RunTargetOrDefault "Build"
Summary
And this is it! Simple, reliable (well… probably - I need to test it on a living system) and rather clean deployments of .NET Core websites to Azure WebApps. No doubt there are better ways of doing this (Octopus Deploy?), but if we don’t have required infrastructure and the app is uncomplicated - why investing time and money? Simple scripts FTW!
The source code, cleaned and packed as a FAKE helper, is available on my GitHub and as the NuGet package. Hope you’ll like it!