Contents

From zero to Platform as a Service with Azure Web Apps

One command deploy with Bicep and Docker

Challenge

Abstract
My hypothesis is that great developer experience and costs are not mutually exclusive.

Our assumptions are that we will scale apps or APIs independently of each other, they can handle customer data of different confidentiality levels, and we target having at least one pre-production environment as similar as production.

Let’s start by having a look at how App Service plans are often designated for a single app. We compare the monthly running costs of different environment options, some of the development running on App Service Standard plan while the production always runs in Premium.

We also straight up rule out App Service plans Shared and Basic as they run in a shared environment, have a monthly hour limit and/or do not quarantee SLA, which is not for the paying customers’ best.

All the prices (2021-08) below are for App Service Linux plan, being cheaper of the plans as the Windows plan adds the cost of operating system license.

In Premium, we get double the performance and useful additional features such as option to hide App Service from public internet altogether by using Private Endpoints.

Note
No estimations of costs of work is included. As DevOps, we target making the operational work close to zero in all cases.

Let’s then have a look at the yearly App Service costs when having three apps (or a single-page app using three APIs):

Ten scale the above:

This roughly means 25K difference between the cheapest and the most expensive option.

Question
30 apps, 10 APIs each, how much Ks is the yearly difference between using either two or three Premium plans?

We have linear growth in all the presented cases so far.

Seems the expenses/savings in 30 apps are predictable? Hold on.

The above charts are the lowest tiers of the plans, S1 in Standard and P1V2 in Premium. Bumping the tier up of a single App Service plan doubles the VCPU and RAM the apps can use, effectively performance, doubling the plan’s costs.

Logical.

Similarly, the price is doubled for each additional SKU (App Service host) added inside the tier for a particular plan. SKUs are often doubled/tripled (from one) to tackle issues with exceptions occurring on one host, in which case customers are served from a healthy App Service host without interruption.

Second region? Multiply by two.

Disclaimer
Not to push that great inventions do not require great investments but to remind that successful businesses invest in what produces at least a magnitude greater outcome than what is spent.

As organizations are scaling up cloud computing at a pace never seen before, outcome of well placed optimizations will multiply the effect in overall costs, performance and reliability. This is especially prone to happen when the development culture has been grown to sharing practices between teams.

Resolution

With the above charts as a starting point, we decide to combine our non-production environments to a single App Service plan.

Moreover, we expect this to lead to greater efficiency in terms of QA, DevOps and security practices, purely by having 1/3 less Azure resources in question.

We decide to use Premium plan for both non-production and production to minimize the variance in the environments from the start.

Thus an exactly similar PaaS is deployed for both, including:

  • App Service with deployment slots, all environments behind Azure AD authentication
  • Traffic, application and audit logging to Log Analytics Workspace
  • Application Insights for real-time monitoring and alerts as push notifications to Azure mobile app
  • Storage Account with a private endpoint (and private DNS) for persistent Docker volumes and long-term log storage
  • Key Vault for secrets, App Service fetching them using a managed identity and over backbone network
  • Azure Container Registry for hosting Docker images, App Service pulling them with a service principal
Anssi Syrjäsalo
Azure Architecture. Greyed out parts are created later in the series.

Using slots over plans

We will deploy two resource groups, one for non-productions (stg) and one for production (prod). These will include our App Service plans and the back office with monitoring, container registry, etc.

The stg resource group’s App Service Plan will host, besides staging, an App Service deployment slot testing. The slot is used as any pre-staging environment usually is, that is integrating the upcoming features and fixes together in the mainline.

Note
The approach assumes (however, is not restricted to) us also striving to minimizing test data management which starts best by using a shared database for testing and staging.

We assume staging is usually what is used for internal demoing purposes and has the nearest production-like data. Regarding testing, we recognize that it does not hurt to develop against production-like data from day one.

Going from testing to staging is as simple as switching the slot testing as the current in the App Service. Note that after the swap, the current testing ought to be redeployed to testing where the previous staging is now held, unless there is a particular reason to keep the previous staging release.

Similarly, the production will include deployment slot rc which is dedicated for any possible final checks that ought to be done (especially with production data) before going live for customers.

Additionally, the rc slot can be used to implement “canary”, that is redirecting e.g 5% of the customers from the current production to the rc, allowing to pilot changes first with a small number of customers.

After swapping rc to production in AppService, the previous production is held in slot rc so that if errors start occurring in production, the previous production can be swapped back as fast as possible.

Note

For this to work, database migrations ought to be already run in rc.

Sometimes errors only occur with production data and for debugging them rc also happens to be (as a result) the most ideal.

Deploying to testing and rc, and swapping them to staging and prod, also ensures zero-downtime deployment in App Service. This is preferable not only in production, but also in staging as there is often internal demoing going on.

We will later introduce Azure DevOps pipeline to do the deployment and the swap, as well as implement an approval step before swapping the slots in production.

Tip
The approval can be as simple as using Azure DevOps web UI or by pressing Approve/Deny button on a Slack channel (or similarly in Microsoft Teams using Azure DevOps integration).

Creating Azure resources

Setup prerequisites

Clone the git repository:

git clone https://github.com/raas-dev/artery.git
cd artery/bicep

Azure CLI1, Bash and Docker2 are assumed present.

Tip
If you do not have them available in your OS, you can clone this repo in Azure Cloud Shell and run the commands there.

Install or upgrade Bicep3:

az bicep install
az bicep upgrade

One command deploy

If you want to create or upgrade the environment with one command, copy stg.env.example to stg.env, configure variables and run:

./deploy stg.env

Deploy steps clarified

The steps that deploy runs are explained below. You may run the script as part of the continuous deployment but note that some downtime can occur depending of the tier of the App Service and the number of SKUs.

Export the variables:

set -a; source stg.env; set +a

Create a target resource group for deployment:

az group create \
    --name "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-rg" \
    --location "$AZ_LOCATION" \
    --subscription "$AZ_SUBSCRIPTION_ID" \
    --tags app="$AZ_NAME" environment="$AZ_ENVIRONMENT" owner="$AZ_OWNER"

Create an AAD app to be used for authentication on the App Service and the slot:

AZ_AAD_APP_CLIENT_SECRET="$(openssl rand -base64 32)"

az ad app create \
    --display-name "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-appr" \
    --available-to-other-tenants false \
    --homepage "https://$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-app.azurewebsites.net" \
    --reply-urls "https://$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-app.azurewebsites.net/.auth/login/aad/callback" "https://$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-app-$AZ_SLOT_POSTFIX.azurewebsites.net/.auth/login/aad/callback" \
    --password "$AZ_AAD_APP_CLIENT_SECRET"

Fetch the AAD app’s client ID:

AZ_AAD_APP_CLIENT_ID="$(az ad app list \
    --display-name "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-appr" \
    --query "[].appId" --output tsv)"

Create a service principal for the above AAD app:

az ad sp create --id "$AZ_AAD_APP_CLIENT_ID"

Next, ensure you have Azure AD role Cloud Application Administrator in order to be able to grant User.Read permission to the App Service and the slot.

Permission User.Read is the minimum MS Graph API permission required for App Service to implement authentication so that only members in your Azure tenant will be able to access the app after AAD login.

If you do not have application administrator AAD role, you can either ask your Azure tenant Global Administrator to grant the role, or alternatively request consent the created AAD app on behalf of the organization.

Grant User.Read on AAD Graph API to the App Service’s AAD app:

az ad app permission add \
    --id "$AZ_AAD_APP_CLIENT_ID" \
    --api "00000002-0000-0000-c000-000000000000" \
    --api-permissions "311a71cc-e848-46a1-bdf8-97ff7156d8e6=Scope"

az ad app permission grant \
    --id "$AZ_AAD_APP_CLIENT_ID" \
    --api "00000002-0000-0000-c000-000000000000"

Next, we will create a dedicated service principal for App Service to be used for pulling Docker images from Azure Container Registry on deployment.

This is the second best option, as creating and using App Service’s managed identity unfortunately does not work4 for container registry operations.

Nevertheless, using a service principal is better option than leaving the ACR admin account enabled and using admin credentials just for pulling images from the registry on deploy.

Create a service principal for App Service (and slot) to pull images from ACR:

AZ_ACR_SP_PASSWORD="$(az ad sp create-for-rbac \
    --name "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-sp" \
    --skip-assignment \
    --only-show-errors \
    --query password --output tsv)"

AZ_ACR_SP_CLIENT_ID="$(az ad sp list \
    --display-name "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-sp" \
    --only-show-errors \
    --query "[].appId" --output tsv)"

AZ_ACR_SP_OBJECT_ID="$(az ad sp list \
    --display-name "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-sp" \
    --only-show-errors \
    --query "[].objectId" --output tsv)"

Finally, create a deployment in the resource group with Bicep:

az deployment group create \
    --resource-group "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-rg" \
    --subscription "$AZ_SUBSCRIPTION_ID" \
    --template-file main.bicep \
    -p prefix="$AZ_PREFIX" \
    -p name="$AZ_NAME" \
    -p environment="$AZ_ENVIRONMENT" \
    -p owner="$AZ_OWNER" \
    -p app_slot_postfix="$AZ_SLOT_POSTFIX" \
    -p acr_sp_client_id="$AZ_ACR_SP_CLIENT_ID" \
    -p acr_sp_object_id="$AZ_ACR_SP_OBJECT_ID" \
    -p acr_sp_password="$AZ_ACR_SP_PASSWORD" \
    -p aad_app_client_id="$AZ_AAD_APP_CLIENT_ID" \
    -p aad_app_client_secret="$AZ_AAD_APP_CLIENT_SECRET"

After the deployment finishes successfully, the rg content is as following:

Anssi Syrjäsalo
Azure resources created for stg

Now going to App Service or the testing slot’s URL, you will see the following:

Anssi Syrjäsalo
App Service hello world page

Next, we will deploy the app with Docker.

From Docker to App Service

We will use an example API implemented in TypeScript and Express.js, running on Node.js in Docker container.

The API implements endpoints for demonstarting private endpoint connectivity, an example of hosting interactive API documentation powered by RapiDoc5, Express middleware swagger-stats6 providing real-time statistics in web UI, and express-openapi-validator7 middleware for routing and validating the HTTP request parameters based on the OpenAPI/Swagger definition.

If you want to deploy a more sophisticated solution for verifying connectivity to the private endpoint fronted services, I recommend to take a look at the Janne Mattila’s webapp-network-tester8.

Deploy to the slot

Go back to the repository root:

cd ..

Build Docker image (and run a container from it, printing the Node.js version):

IMAGE_KIND="alpine" \
BUILD_ARGS="--pull --no-cache" \
    docker/build_and_test_image node --version

Login to your Azure Container Registry:

az acr login --name "${AZ_PREFIX//-/}${AZ_ENVIRONMENT//-/}${AZ_NAME//-/}acr"

Push the Docker image to the registry:

REGISTRY_FQDN="${AZ_PREFIX//-/}${AZ_ENVIRONMENT//-/}${AZ_NAME//-/}acr.azurecr.io" \
    docker/tag_and_push_image

Create a container from the image in the App Service slot:

az webapp config container set \
    --name "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-app" \
    --slot "$AZ_SLOT_POSTFIX" \
    --resource-group "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-rg" \
    --subscription "$AZ_SUBSCRIPTION_ID" \
    --docker-registry-server-url "https://${AZ_PREFIX//-/}${AZ_ENVIRONMENT//-/}${AZ_NAME//-/}acr.azurecr.io" \
    --docker-custom-image-name "${AZ_PREFIX//-/}${AZ_ENVIRONMENT//-/}${AZ_NAME//-/}acr.azurecr.io/$AZ_NAME:main"

Wait for the slot to restart or restart it immeadiately:

az webapp restart \
    --name "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-app" \
    --slot "$AZ_SLOT_POSTFIX" \
    --resource-group "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-rg" \
    --subscription "$AZ_SUBSCRIPTION_ID"

You can ignore the warning regarding the registry credentials, as username and password are read from the Key Vault by the App Service’s service principal.

Browse to https://APP_SERVICE_SLOT_URL/docs, authenticate with your Azure AD account (if not already logged in) and you will see the API docs:

Anssi Syrjäsalo
API docs

Swap the slots

After experimenting with the API, swap the testing slot in the App Service:

az webapp deployment slot swap \
    --name "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-app" \
    --slot "$AZ_SLOT_POSTFIX" \
    --resource-group "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-rg" \
    --subscription "$AZ_SUBSCRIPTION_ID"

Note that after the swap, the previous staging is now at the testing slot. You may redeploy to the testing slot to have the testing up-to-date for the team to continue work on:

az webapp config container set \
    --name "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-app" \
    --slot "$AZ_SLOT_POSTFIX" \
    --resource-group "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-rg" \
    --subscription "$AZ_SUBSCRIPTION_ID" \
    --docker-registry-server-url "https://${AZ_PREFIX//-/}${AZ_ENVIRONMENT//-/}${AZ_NAME//-/}acr.azurecr.io" \
    --docker-custom-image-name "${AZ_PREFIX//-/}${AZ_ENVIRONMENT//-/}${AZ_NAME//-/}acr.azurecr.io/$AZ_NAME:main"

az webapp restart \
    --name "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-app" \
    --slot "$AZ_SLOT_POSTFIX" \
    --resource-group "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-rg" \
    --subscription "$AZ_SUBSCRIPTION_ID"

Real-time endpoint stats

Set swagger-stats username and password in the App Service slot application settings:

az webapp config appsettings set \
    --name "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-app" \
    --slot "$AZ_SLOT_POSTFIX" \
    --resource-group "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-rg" \
    --subscription "$AZ_SUBSCRIPTION_ID" \
    --settings SWAGGER_STATS_USERNAME=main \
               SWAGGER_STATS_PASSWORD="$(git rev-parse HEAD)" \
               PRIVATE_BACKEND_URL="https://${AZ_PREFIX//-/}${AZ_ENVIRONMENT//-/}${AZ_NAME//-/}sa.blob.core.windows.net/public/openapi.yaml"

az webapp restart \
    --name "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-app" \
    --slot "$AZ_SLOT_POSTFIX" \
    --resource-group "$AZ_PREFIX-$AZ_ENVIRONMENT-$AZ_NAME-rg" \
    --subscription "$AZ_SUBSCRIPTION_ID"

Go to https://APP_SERVICE_SLOT_URL/stats and you will be presented with a login form.

Note that login form is not related to the Azure AD authentication, even though all the endpoints are behind AD authwall in any case.

Use main as the username and SHA of the last git commit as the password to log in.

Anssi Syrjäsalo
Real-time statistics for endpoints

Operational best practices

Monitoring

The back office was created as part of the PaaS deployment and is best experimented from the Azure Portal:

  • Application Insights metrics which are streamed by the Express.js middleware
  • Querying HTTP and audit logs in the Log Analytics Workspace
  • Alerts for suddenly increased response time and decreased availability (SLA)
  • Availability (web) tests ran for App Service from all the regions in the world

By default, the alerts are sent to Azure mobile app as push notifications, recipient specified by the email configured as $AZ_OWNER. If not using the mobile app, you may want to configure alternative ways of getting alerted in ai.bicep.

Service Endpoints

The public subnet is delegated for the App Service and all the possible service endpoints are already enabled in the subnet, allowing to restrict the inbound traffic in the subnet e.g. as done on the Key Vault side.

Traffic over service endpoints traverses in Azure backbone, although the target services continue to expose their public IPs. Regardless whether services are publicly or privately exposed, authentication is enforced except for blobs in “public” container of the Storage Account.

Azure Container Registry ought to be kept publicly reachable as it is required (for Azure Pipelines) to build and push Docker images to the registry. The ACR admin user is disabled and ought not be enabled, instead managed identities, or when they are not possible, service principals shall be used.

Private Endpoints

The general guideline advocated by the reference is to use private endpoints for all storage and databases App Service accesses underneath to fetch data from. The private endpoints ought to be deployed in private subnet of the virtual network, similarly as done for the Storage Account (sa.bicep) in main.bicep.

Browse to https://APP_SERVICE_SLOT_URL/to-backend to witness that the DNS query from App Service to the Storage Account resolves to a private IP.

The URL of the Storage Account is taken from App Service application setting PRIVATE_BACKEND_URL set earlier.

You can ignore the error message regarding the missing file, or alternatively download the OpenAPI definition from endpoint https://APP_SERVICE_SLOT_URL/spec and upload it to the Storage Account’s public container with name openapi.yaml to get rid of the message.

Note that you have to add your own IP as allowed in the Storage Account’s Networking to be able to upload files over the public network:

Anssi Syrjäsalo
Storage Account networking in Azure Portal

The definition file will be later used for importing the API in Azure API Management. Note that there is nothing secret in the in the OpenAPI definition itself (as the name hints :) as it is widely used for telling the client apps how to programmatically connect to the API.

Besides storage services and databases, private endpoints can also be created in front of both App Service and App Service slots. Note that this requires running on a Premium-tier App Service plan, which is usually the case in production, but might not hurt in non-prod either now due to fewer environments.

While this effectively hides the App Service from public Internet entirely, which is most likely welcome in production mission-critical systems (over purely restricting the inbound traffic with the App Service’s network rules), there are currently a couple of limitations to be aware of:

  • Azure availability monitoring stops working as there is no public endpoint anymore exposed by the App Service to run the tests for
  • Deploying to AppService from Azure DevOps will not work unless running a self-hosted agent in a virtual network, the virtual network then peered with the App Service’s virtual network.

While these are resolvable, especially the latter introduces maintenance of CI/CD agents not in the scope of this tutorial. The App Service specific private endpoints are left as a reference in main.bicep albeit commented out.

Exercise: Function Apps

As Function Apps are essentially backed by the App Service technology, this reference can be used to deploy Function Apps instead with minor changes to parameters for app.bicep.

Hint: In addition, you have to set App Service application setting FUNCTIONS_EXTENSION_VERSION to runtime version, e.g. ~3.

Creating production is done similarly, except copy prod.env.example to prod.env and after configuring variables, run:

./deploy prod.env

What’s next?

In the next part, we will walk through creating an Azure DevOps pipelines to update the Azure resources, deploy Docker images to Azure Container Registry, get App Service to pull them and swap the slots after approval.

We will also create a pipeline for importing/updating the API in existing Azure API Management based on openapi.yaml, similarly as we did with Azure Container Instances in the last post9, but this time we will add creating the Azure DevOps project and the pipelines programmatically.

Feel free to experiment with the template in GitHub10 including more goodies covered later in the series. I hope you link to this tutorial/series if you use the template so we can continue to educate more developers.

Until the next time.

Have fun. □