Home / Azure

AzureTroubleshootingAdvanced

App Service health check: instances flapping out of rotation, or a bad instance never replaced

How App Service health check removes and replaces instances, why it sometimes does nothing or makes things worse, and how to design and tune the endpoint for multi-instance plans.

Deepak Bhardwaj9 min read

Applies toAzure App Service (Windows and Linux, Basic and above), Azure Functions Premium and Dedicated plans, App Service Environment v3, Azure CLI 2.x

Two opposite complaints land on the same feature:

  • One instance is clearly broken (high error rate, stuck thread pool, full local disk) and keeps receiving traffic for an hour or more. Health check is enabled but nothing seems to happen.
  • Instances drop in and out of the load balancer during a dependency blip, capacity halves and the remaining instances fall over.

Both come from how health check decides what "unhealthy" means and the limits it applies before acting. Fix the endpoint design first, then tune the thresholds.

How health check actually behaves

Behaviour Detail
Probe Pings the configured path on every instance once a minute
Healthy HTTP 200-299. Anything else, or no response within one minute, is a failure
Removal from load balancer After WEBSITE_HEALTHCHECK_MAXPINGFAILURES consecutive failures (2-10, default 10)
Removal cap At most WEBSITE_HEALTHCHECK_MAXUNHEALTHYWORKERPERCENT of instances (1-100, default 50) are removed at once. If every instance is unhealthy, none are removed
Return to rotation Pinging continues; the instance comes back as soon as it returns 200-299
Replacement An instance unhealthy for one hour is replaced
Replacement rate At most one instance per hour and three per day per App Service plan, plus a non-configurable per-scale-unit limit that resets every 12 hours
Scale-out New instances are pinged before they receive traffic
Redirects 302 is not followed. 301 between the default and a custom domain marks the worker unhealthy. A 307 from an HTTPS redirect with HTTPS Only disabled shows as "Waiting for health check response"
Multiple apps per plan An instance is replaced only if every other app on it with health check enabled is also unhealthy
Logs Probe requests are internal and do not appear in the web server (HTTP) logs

These values come from the current documentation. Check the docs if your plan behaves differently, because the limits have changed before.

Warning

Enabling health check or changing its path restarts the app. The setting is not slot-specific, so a slot swap carries the configuration across. Change it in a staging slot or a maintenance window.

Symptom → cause → fix

Symptom Likely cause Fix
Broken instance keeps serving traffic Endpoint returns 200 because it only checks that the process is up Check what actually breaks on that instance: thread pool, local disk, a warmed cache
Broken instance is removed but never replaced Replacement rate limit reached, or another app on the same instance is healthy Restart the instance manually; review which apps share the plan
Single-instance app is never taken out By design, a lone instance is not removed; it is only replaced after an hour Run at least two instances
All instances unhealthy, none removed Endpoint checks a shared dependency (database, Redis, downstream API) Make the endpoint instance-local; alert on dependencies separately
Half the fleet drops during a dependency blip Deep check plus the default 50% cap Shallow endpoint, or lower MAXUNHEALTHYWORKERPERCENT
Status stuck at "Waiting for health check response" 307 redirect: HTTPS redirect in code, HTTPS Only off Enable HTTPS Only or exempt the health path from the redirect
Every instance unhealthy right after enabling Path requires authentication or is blocked by your own middleware Allow anonymous access to the path; secure it differently (below)
Plan scaled to 4, app still on 1 instance Per-app scaling caps the app's worker count Raise the app's worker count

Check the current configuration

RG=rg-web-prod
APP=app-orders-prod

# Health check path and the app's own worker count
az webapp config show -g $RG -n $APP \
  --query "{path:healthCheckPath, workers:numberOfWorkers}"

az webapp show -g $RG -n $APP --query "{httpsOnly:httpsOnly, plan:appServicePlanId}" -o json

# Threshold overrides, if any
az webapp config appsettings list -g $RG -n $APP \
  --query "[?starts_with(name, 'WEBSITE_HEALTHCHECK')]" -o table

# Instances currently running the app
az webapp list-instances -g $RG -n $APP --query "[].name" -o tsv

Find the health metric name on your resource rather than guessing it:

APP_ID=$(az webapp show -g $RG -n $APP --query id -o tsv)
az monitor metrics list-definitions --resource $APP_ID \
  --query "[?contains(name.value, 'Health')].name.value" -o tsv

Then chart it split by instance. A single instance sitting well below the others is the one being failed:

az monitor metrics list --resource $APP_ID --metric HealthCheckStatus \
  --interval PT5M --aggregation Average --filter "Instance eq '*'" -o table

If HealthCheckStatus is not in your list-definitions output, use the name it does return.

Design the endpoint for what you want it to do

The key decision is what a 500 from this path means. Health check acts per instance, so it should report on per-instance failures.

Check Include? Why
Process up, request pipeline responds Yes The minimum
App fully warmed up (caches loaded, JIT done, config read) Yes Stops new instances taking traffic too early on scale-out
Local resources: temp disk space, thread pool queue length, memory headroom Yes These fail on one instance at a time, which is what health check can fix
Shared database or cache reachable Usually no When it fails, it fails everywhere. Removing instances does not help and can tip the fleet over
Downstream APIs No Same problem, and they add latency to every probe

If you must include a shared dependency, only fail on errors that are local to the instance, such as a connection pool on that instance that has stopped recovering. Keep the endpoint well under the one-minute timeout. Give each dependency call a timeout of a few seconds.

Securing the endpoint

The path must allow anonymous access if you use your own authentication. App Service sends an x-ms-auth-internal-token header that you can validate: it is the Base64-encoded SHA-256 hash of the WEBSITE_AUTH_ENCRYPTION_KEY environment variable. Apply IP restrictions or a VNet first, and treat this header check as an extra layer on top.

# Flask example: answer probes from App Service only, and keep it instance-local
import base64, hashlib, hmac, os, shutil
from flask import Flask, request, abort

app = Flask(__name__)
EXPECTED = base64.b64encode(
    hashlib.sha256(os.environ["WEBSITE_AUTH_ENCRYPTION_KEY"].encode()).digest()
).decode()

@app.get("/healthz")
def healthz():
    token = request.headers.get("x-ms-auth-internal-token", "")
    if not hmac.compare_digest(token, EXPECTED):
        abort(403)
    free = shutil.disk_usage("/tmp").free
    if free < 500 * 1024 * 1024:          # local temp disk nearly full
        return "low disk", 503
    if not app.config.get("WARMED_UP"):    # set at the end of startup
        return "warming", 503
    return "ok", 200

Log failures from inside the endpoint. The probe requests are missing from HTTP logs, so your own log line is the only record of why a probe failed.

Configure and tune

# Set the path (generic configuration works across CLI versions)
az webapp config set -g $RG -n $APP \
  --generic-configurations '{"healthCheckPath": "/healthz"}'

# Remove a failing instance faster (5 minutes instead of 10)
# and never take out more than a third of the fleet
az webapp config appsettings set -g $RG -n $APP --settings \
  WEBSITE_HEALTHCHECK_MAXPINGFAILURES=5 \
  WEBSITE_HEALTHCHECK_MAXUNHEALTHYWORKERPERCENT=34

# HTTPS Only, so probes go over HTTPS and are not redirected
az webapp update -g $RG -n $APP --https-only true

Trade-offs to settle before changing the defaults:

  • Lower MAXPINGFAILURES removes bad instances faster but reacts to short GC pauses and cold dependencies. At 2 failures, a two-minute stall takes an instance out.
  • Lower MAXUNHEALTHYWORKERPERCENT protects capacity during wide failures but leaves more bad instances serving traffic when several fail for real.
  • Instance count matters more than either setting. With two instances and the default cap, one instance can be removed and the survivor takes all the load. Size the plan so N-1 instances can handle peak load.

Caution

Do not point health check at an endpoint that fails when the database is down. During a database incident every instance fails together. None are removed because of the all-unhealthy rule, and after an hour the platform starts replacing instances. You get cold restarts in the middle of the outage and no gain.

When an instance is removed but never replaced

Replacement is rate-limited per plan and per scale unit, so in a bad week a broken instance can stay out of rotation and you run with less capacity. Recover by hand:

  1. Identify the instance from the metric split above.
  2. Restart that specific instance with Diagnose and solve problems > Advanced Application Restart in the portal. That restarts one instance at a time instead of the whole app.
  3. If the same instance keeps failing, scale out by one (az appservice plan update --ids <plan-id> --number-of-workers <n+1>) to restore capacity while you investigate. Scaling back in does not let you choose which instance goes.

Prevent it next time

  • Treat the health endpoint as code with its own tests: instance-local checks, a warm-up gate, a hard internal timeout.
  • Run at least two instances (three for zone-redundant plans) and size for losing one.
  • Alert on the health metric split by instance, not on the app average, which hides a single bad worker.
  • Keep apps that share a plan on compatible health check designs; one healthy neighbour can block replacement.
  • Review WEBSITE_HEALTHCHECK_* overrides in code review, and roll out path changes through a staging slot.