CPU Throttling Lab¶
Reproducible lab demonstrating that per-replica CPU allocation is the dominant bottleneck for a CPU-bound HTTP workload on Azure Container Apps, and that raising the CPU allocation from 0.25 vCPU to 1.0 vCPU removes the bottleneck under the same byte-identical concurrent load. The lab uses a deterministic CPU-bound workload (python:3.12-slim running an inline HTTP server that computes 80 SHA-256 hashes over a 200 KiB buffer per GET) so the latency difference between the baseline and the post-fix run is attributable to per-replica CPU pressure and not to cold-start, image-pull, or scale-out effects.
Lab Metadata¶
| Field | Value |
|---|---|
| Difficulty | Intermediate |
| Duration | 10-15 minutes |
| Tier | Full evidence pack (IaC + scripts + raw CLI evidence) |
| Category | Scaling and Runtime |
flowchart TD
A[Deploy Container App with cpu=0.25, memory=0.5Gi, single replica via Bicep] --> B[Capture initial config + revision list]
B --> C[Warm up replica with 5 discarded GETs]
C --> D[Load test 100 req / 20 concurrent at cpu=0.25, capture p50/p95/p99/max/avg]
D --> E[az containerapp update --cpu 1.0 --memory 2.0Gi -> new revision]
E --> F[Wait for new revision to reach runningState=Running]
F --> G[Warm up new replica with 5 discarded GETs]
G --> H[Load test 100 req / 20 concurrent at cpu=1.0, byte-identical client code]
H --> I[Evaluate H1: cpu=0.25 p95 > 100ms AND H2: cpu=1.0 p95 < 50% of cpu=0.25 p95] Evidence depth
This lab is fully reproducible with dedicated infrastructure-as-code, helper scripts, and raw evidence committed under labs/cpu-throttling/:
infra/main.bicepprovisions a Log Analytics workspace, a Container Apps Environment (Consumption), and one Container App runningpython:3.12-slimwith an inline CPU-burn HTTP server (80 SHA-256 hashes over a 200 KiB buffer per GET) atcpu: '0.25',memory: '0.5Gi',minReplicas: 1,maxReplicas: 1, andactiveRevisionsMode: 'Single'. Gate 17 proves that on the shared-fields surface captured by the cohort the only observed diff is{cpu, memory}withminReplicasandmaxReplicasunchanged. The unchanged image, command, and inline Python script are design intent inferred from the Phase Aaz containerapp update --cpu --memorycall (no--image,--command, or--argsflags), not directly evidenced by image digests in this cohort.load_test.pyis a 93-line standalone load generator usingThreadPoolExecutorwith nearest-rank percentile aggregation. The same file is used by bothtrigger.shandfix-and-capture.shso the cpu=0.25 baseline and cpu=1.0 post-fix runs are measured by byte-identical client code.trigger.shruns Phases 1-5: capture initial config (fails fast ifcpu != 0.25), capture initial revision list, warm up the replica with 5 discarded GETs, run a 100-request / 20-concurrent load test against the FQDN, and capture theUsageNanoCoresmetric for the baseline window.fix-and-capture.sh(formerlyverify.shbefore the 2026-06-26 Phase B overlay) runs Phases 6-12: apply the fix (az containerapp update --cpu 1.0 --memory 2.0Gi), poll until the new revision reachesrunningState=Running(max 5 min), capture post-fix config and revision list, warm up the new replica, re-run the byte-identical 100/20 load test, capture the post-fixUsageNanoCoresmetric, and evaluate H1+H2 with explicit exit codes (0=supported, 1=invalid, 2=falsified). The captured log file00-verify-run.txtkeeps its name for schema stability across the rename.- The new Phase B
verify.shis a pure file processor (no Azure calls) that reads only the 15 canonical Phase A files underevidence/and emits four falsifiable gate JSONs (14-cohort-integrity-gate.jsonthrough17-single-variable-falsification-gate.json). See §4b below. evidence/carries 20 files: 15 canonical Phase A captures from the 2026-06-22 reproduction inkoreacentral(full script execution logs00-trigger-run.txtand00-verify-run.txt, per-phase JSON captures of the app configuration, revision list, load-test latency, andUsageNanoCoresmetric01-09, and supporting environment captures10-13for CLI version,containerappextension version, region, Bicep deployment outputs), plus 4 Phase B gate JSONs emitted byverify.sh(14-17), plusevidence/README.md(evidence tour with provenance, capture timeline, claim ceiling, per-file integrity table, and honest disclosure).
Azure Portal screenshots (Container App Overview, Metrics blade with UsageNanoCores, Revisions blade with cpu=0.25 and cpu=1.0 revisions side-by-side) are pending in a follow-up PR. The follow-up will re-deploy the same Bicep template in a short-lived environment purely to capture the Portal blades, then close out.
1) Background¶
On Azure Container Apps, is per-replica CPU allocation the dominant bottleneck for a CPU-bound HTTP workload — and does raising the configured CPU/memory envelope via az containerapp update --cpu --memory cleanly remove that bottleneck under the same concurrent load?
The lab uses a dedicated resource group and Bicep template (infra/main.bicep) that provisions exactly three resources: a Log Analytics workspace, a Container Apps Environment, and one Container App at cpu: '0.25', memory: '0.5Gi', minReplicas: 1, maxReplicas: 1. No ACR (the image is python:3.12-slim from public Docker Hub), no Application Insights, no private endpoint, no public IP. The Container App runs an inline Python HTTP server that performs a deterministic 80 SHA-256 hashes over a 200 KiB buffer per GET — measured at ~40 ms of CPU work on a full vCPU at the time of this reproduction. Under Linux CFS throttling at 0.25 vCPU, that same work takes ~160 ms minimum per request plus queueing time when concurrent requests share the throttled CPU budget.
The lab deliberately avoids mcr.microsoft.com/azuredocs/containerapps-helloworld:latest because helloworld returns immediately without measurable CPU work, so any latency difference between cpu=0.25 and cpu=1.0 on helloworld is dominated by cold-start (~6 s spike on first request), image-pull (~1-2 s), and TLS/TCP setup (~50-100 ms) rather than steady-state CPU throttling. The inline python:3.12-slim server gives each GET enough deterministic CPU work to dwarf network jitter, and the lab's Phase-3 and Phase-10 warm-up steps remove cold-start as a confounding variable before each load test.
The lab also pins minReplicas: 1, maxReplicas: 1 so a scale-out cannot mask per-replica CPU pressure. The hypothesis under test is specifically about CPU throttling at the replica level. A separate lab (Replica Load Imbalance) covers scale-out behavior.
Per-replica CPU/memory allocation is documented in Microsoft Learn → Containers in Azure Container Apps. The UsageNanoCores metric used by this lab to corroborate the load-test latency evidence is documented in Microsoft Learn → Metrics in Azure Container Apps.
Set the base inputs before running the runbook. APP_NAME and APP_FQDN are derived from Bicep outputs in the next section because infra/main.bicep appends a deterministic but resource-group-scoped suffix to every resource name:
export AZ_SUBSCRIPTION="<subscription-id>"
export RG="rg-aca-lab-cputhrottle"
export LOCATION="koreacentral"
2) Hypothesis¶
On the same Container App, same python:3.12-slim image, same inline CPU-burn HTTP server, and same 100-request / 20-concurrent load pattern, raising the per-replica CPU allocation from 0.25 vCPU to 1.0 vCPU (and the memory from 0.5Gi to 2.0Gi, the smallest valid pair for 1.0 vCPU) materially reduces tail latency. Specifically: the p95 latency at cpu=1.0 will be strictly less than 50% of the p95 latency at cpu=0.25.
The alternative hypothesis being tested is that CPU allocation is NOT the dominant bottleneck for this workload — meaning per-request work is bounded by some other resource (concurrency limits, network egress, dependency latency, memory pressure) and raising CPU alone has no material effect on tail latency.
Prediction (IF / THEN):
- IF the CPU-throttling hypothesis holds, THEN at the same 100/20 load with byte-identical client code:
- H1 — cpu=0.25 baseline p95 will be strictly greater than 100 ms (CPU pressure is observable). If this fails, the workload was too light to demonstrate the throttling effect and the run is invalid.
- H2 — cpu=1.0 post-fix p95 will be strictly less than 50% of the cpu=0.25 baseline p95 (raising CPU removes the bottleneck).
- IF the alternative hypothesis is correct, THEN H1 may still pass (something else creates the latency) but H2 will fail: the cpu=1.0 p95 will be ≥ 50% of the cpu=0.25 p95 because raising CPU alone does not address the real bottleneck.
3) Runbook¶
Deploy infrastructure¶
All az, ./trigger.sh, ./fix-and-capture.sh, ./verify.sh, and ./cleanup.sh invocations below assume the working directory is the lab folder. Switch into it from the repository root before running anything:
-
Create the resource group and deploy the Bicep template. The
--parameters baseName="cputhrottle"value is required (the Bicep template declaresparam baseName stringwith no default).--name maingives the deployment a stable, queryable name so the next step can read its outputs:az group create \ --subscription "$AZ_SUBSCRIPTION" \ --name "$RG" \ --location "$LOCATION" az deployment group create \ --subscription "$AZ_SUBSCRIPTION" \ --resource-group "$RG" \ --name main \ --template-file ./infra/main.bicep \ --parameters baseName="cputhrottle"Command Why it is used az group createCreates the lab resource group at $LOCATION(koreacentral) before any child resources are deployed.az deployment group createDeploys ./infra/main.bicepwith the requiredbaseName="cputhrottle"parameter.--name maingives the deployment a stable, queryable name so step 2 can read its outputs.This creates the Log Analytics workspace, Container Apps Environment, and one Container App running the inline CPU-burn HTTP server at
cpu=0.25, memory=0.5Gi, minReplicas=1, maxReplicas=1. -
Read the deployment outputs the scripts need:
export APP_NAME=$(az deployment group show \ --subscription "$AZ_SUBSCRIPTION" \ --resource-group "$RG" \ --name main \ --query "properties.outputs.containerAppName.value" \ --output tsv) export APP_FQDN=$(az deployment group show \ --subscription "$AZ_SUBSCRIPTION" \ --resource-group "$RG" \ --name main \ --query "properties.outputs.containerAppFqdn.value" \ --output tsv)Command Purpose export APP_NAME=$(az deployment group show --subscription "$AZ_SUBSCRIPTION" --resource-group "$RG" --name main --query "properties.outputs.containerAppName.value" --output tsv)Captures APP_NAMEfrom the live Azure lookup so later commands reuse the exact current value instead of guessing it. Reads the group deployment result directly so you can verify whether the top-level deployment failed before any healthy revision was created.export APP_FQDN=$(az deployment group show --subscription "$AZ_SUBSCRIPTION" --resource-group "$RG" --name main --query "properties.outputs.containerAppFqdn.value" --output tsv)Captures APP_FQDNfrom the live Azure lookup so later commands reuse the exact current value instead of guessing it. Reads the group deployment result directly so you can verify whether the top-level deployment failed before any healthy revision was created.
Trigger the baseline (run trigger.sh)¶
Run trigger.sh, which:
- Captures
01-app-config-before.json(readscpu,memory,minReplicas,maxReplicas,activeRevisionsMode; aborts with exit code 1 ifcpu != 0.25). - Captures
02-revisions-before.json(revision list before the load test — expects 1 revision from the Bicep deploy). - Warms up the replica with 5 discarded GETs so cold-start latency does not contaminate the baseline.
- Runs a 100-request / 20-concurrent load test via
load_test.pyagainsthttps://${APP_FQDN}/, captures the JSON summary to03-loadtest-cpu025.json(url, started/finished UTC, wall_clock_seconds, requests_total/ok/err, latency_ms {p50, p95, p99, max, avg}, errors_sample). - Captures the
UsageNanoCoresmetric for the same window to04-metrics-cpu025.json. - Exits 0 if requests_ok ≥ 95 AND p95 > 100 ms (clean baseline for the cpu=1.0 comparison); exits 1 if either gate fails (INVALID RUN — re-run with a heavier workload or investigate network errors).
All scripts pass --subscription "$AZ_SUBSCRIPTION" on every az invocation to immunize the run against the Azure CLI's default-subscription drift, which has been observed in long-running shells where unrelated commands silently switch back to a different subscription.
Apply the fix and re-measure (run fix-and-capture.sh)¶
Run fix-and-capture.sh (formerly named verify.sh before the 2026-06-26 Phase B overlay; the captured log file 00-verify-run.txt keeps its name for schema stability across the rename), which:
- Reads the cpu=0.25 baseline p95 from
03-loadtest-cpu025.json(the filetrigger.shproduced). - Applies the fix:
az containerapp update --cpu 1.0 --memory 2.0Gi. The platform creates a new revision underactiveRevisionsMode: 'Single'; the update result (including the new revision name) is captured to05-update-result.json. - Polls
az containerapp revision showevery 10 seconds until the new revision reachesrunningState=RunningorrunningState=RunningAtMaxScale, with a 5-minute deadline. Exits 1 (INVALID RUN) on timeout. - Captures
06-app-config-after.json(post-fix config readback to provecpu=1.0) and07-revisions-after.json(revision list showing the old revision inDeprovisioningstate and the new revision active). - Warms up the new replica with 5 discarded GETs.
- Re-runs the byte-identical 100-request / 20-concurrent load test via
load_test.py, captures the JSON summary to08-loadtest-cpu1.json. - Captures the post-fix
UsageNanoCoresmetric to09-metrics-cpu1.json. - Evaluates H1 and H2 and exits with one of three codes:
- Exit 0 — H1 PASS (cpu=0.25 baseline p95 > 100 ms, established in
trigger.sh) AND H2 PASS (cpu=1.0 post-fix p95 < 50% of cpu=0.25 baseline p95). CPU-throttling hypothesis SUPPORTED. - Exit 1 — INVALID RUN (post-fix request success count < 95/100; investigate before re-running).
- Exit 2 — H2 FALSIFIED (cpu=1.0 p95 NOT < 50% of cpu=0.25 p95). CPU is not the dominant bottleneck; investigate concurrency, network, dependency latency, or memory pressure before raising CPU further.
- Exit 0 — H1 PASS (cpu=0.25 baseline p95 > 100 ms, established in
Apply the fix manually (the canonical operator response)¶
The fix that fix-and-capture.sh applies is also the canonical operator response for a real CPU-throttling incident. When the lab proves CPU is the bottleneck for a given workload, the operator action is:
az containerapp update \
--subscription "$AZ_SUBSCRIPTION" \
--resource-group "$RG" \
--name "$APP_NAME" \
--cpu 1.0 \
--memory 2.0Gi
| Command | Purpose |
|---|---|
az containerapp update --subscription "$AZ_SUBSCRIPTION" --resource-group "$RG" --name "$APP_NAME" --cpu 1.0 --memory 2.0Gi | Changes per-replica resource allocation, which is the corrective action this step is validating or applying. |
| Command | Why it is used |
|---|---|
az containerapp update --cpu --memory | Updates the per-replica CPU and memory envelope. Under activeRevisionsMode: 'Single', the platform creates a new revision with the new resource settings and shifts 100% traffic to it; the old revision moves to Deprovisioning. Cold-start during the swap adds a few seconds for the new replica to warm up. |
The platform requires that (cpu, memory) be a valid pair from the documented CPU/memory matrix. For cpu=1.0, the minimum valid memory is 2.0Gi. Attempting cpu=1.0 with memory=0.5Gi returns a validation error.
Prevention guidance¶
- Treat per-replica CPU as a configurable knob that depends on your per-request work, not as an environmental constant. The Container Apps Consumption profile defaults to
cpu=0.25, memory=0.5Gi, which is intentionally low for cost — it is NOT the right setting for CPU-bound workloads. - Before changing CPU in production, run
az monitor metrics list --metric UsageNanoCores --aggregation Average,Maximumagainst the live app under representative load. If the average usage is pinned near the configured limit (e.g.,Average ≈ 0.25 vCPU * 1e9 nanoCores = 2.5e8 nanoCoresat cpu=0.25), CPU is at least one constraint — but raising CPU alone is only effective when each request is the unit of work that needs more compute. - Choose between scaling UP and scaling OUT based on the workload shape:
- Scale UP (raise per-replica CPU/memory) when each request needs more compute and concurrency is naturally bounded by upstream rate limits or a small concurrent-user count.
- Scale OUT (raise
maxReplicasand tune scale rules) when total throughput is the constraint and the workload is many small independent requests competing for a shared per-replica budget. See Microsoft Learn → Scaling in Azure Container Apps.
- Document the CPU/memory pair in your IaC (Bicep, Terraform, ARM) so the value is visible in pull request reviews rather than hidden behind a platform default that could change. Bicep schema for
Microsoft.App/containerApps@2023-05-01carries CPU asproperties.template.containers[N].resources.cpu(typed as JSON number, e.g.,json('0.25')) and memory asproperties.template.containers[N].resources.memory(typed as string, e.g.,'0.5Gi').
4) Experiment Log¶
Initial-state evidence (immediately after Bicep deploy)¶
[Observed]evidence/01-app-config-before.json:{"activeRevisionsMode": "Single", "cpu": 0.25, "memory": "0.5Gi", "minReplicas": 1, "maxReplicas": 1}— Bicep values persisted to the live resource exactly as declared.[Observed]evidence/02-revisions-before.json: 1 revision visible (ca-cputhrottle-65svxr--8pz2nir,active=true,provisioningState=Provisioned,runningState=RunningAtMaxScale,trafficWeight=100).
Baseline evidence (cpu=0.25, 100 req / 20 concurrent)¶
[Measured]evidence/03-loadtest-cpu025.json: 100/100 successful requests, 0 errors, wall_clock=8.65s, latency_ms{p50: 1554.5, p95: 2574.8, p99: 2703.4, max: 2800.8, avg: 1625.6}. The p95 of 2575 ms clears the 100 ms H1 minimum by more than 25×, confirming CPU pressure is the dominant component of tail latency at this configuration.[Observed]evidence/04-metrics-cpu025.json:UsageNanoCoresquery for the baseline window (07:49-08:48 UTC) returned 60 per-minute timestamps with no Average or Maximum samples populated. This reflects Azure Monitor metric materialization lag for newly-deployed Container Apps as observed in this reproduction; the snapshot was taken immediately aftertrigger.shfinished its load test. The latency evidence in03-loadtest-cpu025.jsonis the lab's primary signal; the metric capture is documented as an empty baseline so that the script behavior is reproducible and the timing limitation is visible. For a production diagnosis, wait several minutes after the load event before queryingUsageNanoCores.
Post-fix evidence (cpu=1.0 after az containerapp update)¶
[Observed]evidence/05-update-result.json:{"latestRevisionName": "ca-cputhrottle-65svxr--0000001", "name": "ca-cputhrottle-65svxr", "provisioningState": "Succeeded"}. The platform created a new revision (--0000001) and shifted traffic to it underactiveRevisionsMode: 'Single'.[Observed]evidence/00-verify-run.txtPhase 7 timeline: the new revision reachedrunningState=Activatingat 08:50:45Z andrunningState=RunningAtMaxScaleat 08:51:00Z — a 15-second activation window between the two observed poll samples.[Observed]evidence/06-app-config-after.json:{"cpu": 1.0, "memory": "2Gi", "minReplicas": 1, "maxReplicas": 1, "latestRevisionName": "ca-cputhrottle-65svxr--0000001"}— config readback proves the fix landed on the new revision with the expected resource envelope.[Observed]evidence/07-revisions-after.json: 2 revisions visible — the oldca-cputhrottle-65svxr--8pz2nirinrunningState=DeprovisioningwithtrafficWeight=0, and the newca-cputhrottle-65svxr--0000001inrunningState=RunningAtMaxScalewithtrafficWeight=100.[Measured]evidence/08-loadtest-cpu1.json: 100/100 successful requests, 0 errors, wall_clock=2.85s, latency_ms{p50: 473.6, p95: 773.2, p99: 1092.8, max: 1302.2, avg: 531.0}. The p95 of 773 ms is 30.0% of the cpu=0.25 p95 (2575 ms), well below the 50% H2 threshold.[Observed]evidence/09-metrics-cpu1.json:UsageNanoCoresquery for the post-fix window (07:51-08:50 UTC) returned 60 per-minute timestamps; only the trailing minute at08:50:00Zhas materialized samples (average=820660.5 nanoCores,maximum=852758.0 nanoCores). This is the same Azure Monitor materialization lag as the baseline capture and is further compounded by per-minute averaging: the post-fix load test ran for ~2.85 s inside a 60 s aggregation window, so the per-minute Average reads as a small fraction (≈ 2.85/60 ≈ 4.7%) of the peak instantaneous usage. The latency evidence in08-loadtest-cpu1.jsonis the primary signal; the metric capture documents that snapshot timing matters and that short-duration load events are systematically under-sampled byPT1Maggregation.
Analysis¶
The before/after comparison is bounded to the shared-fields surface captured by the cohort. Gate 17 shows the only observed diff on that surface is {cpu, memory} (0.25 → 1.0 and 0.5Gi → 2Gi, with memory=2.0Gi required for cpu=1.0 per the platform's CPU/memory matrix), with minReplicas and maxReplicas unchanged at 1. The unchanged image, command, and inline Python script are design intent inferred from the Phase A az containerapp update --cpu --memory call (no --image, --command, or --args flag), not directly evidenced by image digests in this cohort. Both load tests use the same load_test.py with the same total=100, concurrency=20, same warm-up sequence (5 discarded GETs), and target the same FQDN. Network conditions are held constant by running both tests from the same client within ~2 minutes of each other.
The 70.0% reduction in p95 (2575 ms → 773 ms) plus the 3.0× improvement in wall-clock throughput (8.65s → 2.85s for 100 requests at concurrency 20) directly demonstrates that the per-replica CPU budget at 0.25 vCPU was the controlling bottleneck. The load-test latency in 03-loadtest-cpu025.json and 08-loadtest-cpu1.json is the lab's primary evidence. The UsageNanoCores metric captures (04-metrics-cpu025.json, 09-metrics-cpu1.json) document a separate operational lesson: a snapshot taken immediately after a load event under-samples by design — Azure Monitor metric aggregation typically materializes 1-3 minutes after the event, and the PT1M aggregation window further averages a short load test across a full minute. A production diagnostic should wait 3-5 minutes after the load event before querying UsageNanoCores and ideally use a Maximum aggregation rather than Average for short-duration events.
The supporting environment captures (evidence/10-cli-versions.json, evidence/11-cli-containerapp-ext.json, evidence/12-region.json, evidence/13-deployment-outputs.json) record the exact CLI version (2.79.0), containerapp extension version (1.3.0b4, marked preview), Azure region (koreacentral), and Bicep deployment outputs (Container App name, FQDN, environment name, Log Analytics workspace name) used in this reproduction so that any second observer can compare apples to apples.
Conclusion¶
The CPU-throttling hypothesis is SUPPORTED in this reproduction. Per-replica CPU allocation at 0.25 vCPU is the dominant tail-latency bottleneck for the CPU-bound workload, and raising the allocation to 1.0 vCPU eliminates the bottleneck — H1 holds (cpu=0.25 baseline p95 = 2575 ms ≫ 100 ms) AND H2 holds (cpu=1.0 post-fix p95 = 773 ms < 1287 ms, which is 50% of the baseline). The corrective operator action created a new revision, and this reproduction captured one 15-second revision-state transition (08:50:45Z Activating → 08:51:00Z RunningAtMaxScale).
Falsification¶
The alternative hypothesis ("CPU is NOT the dominant bottleneck; raising CPU alone has no material effect on tail latency") is falsified by the directly evidenced before/after comparison:
[Measured]evidence/03-loadtest-cpu025.json(cpu=0.25): p95 = 2575 ms with 100/100 successful requests.[Measured]evidence/08-loadtest-cpu1.json(cpu=1.0): p95 = 773 ms with 100/100 successful requests.[Measured]ratio: 773 / 2575 = 0.300, well below the 50% H2 threshold.[Observed]Both runs used byte-identical client code (load_test.py), same FQDN, same warm-up sequence, sametotal=100, concurrency=20. On the server side, Gate 17 shows the only observed diff on the shared-fields surface captured by the cohort is the per-replica CPU/memory envelope.
If the alternative hypothesis were correct, the p95 at cpu=1.0 would be ≥ 50% of the p95 at cpu=0.25 because the bottleneck would be something CPU does not address. The observed ratio of 30.0% rules that out.
To re-falsify in a future re-reproduction: modify the workload (for example add an artificial time.sleep(0.5) in the inline server's do_GET), re-run trigger.sh and fix-and-capture.sh to capture a new cohort, then run the Phase B verify.sh against that newly captured evidence. In that scenario the new cohort's H2 ratio would land near 1.0, and the Phase B verifier would exit 1 because at least one gate would fail.
Operator takeaway¶
Per-replica CPU is a configurable knob, not an environmental constant. When latency rises under load:
- Confirm CPU is the bottleneck with
az monitor metrics list --metric UsageNanoCores --aggregation Average,Maximumagainst the live app under representative load. If average usage is pinned near the configured nanocore budget, CPU is at least one constraint. - Reproduce the bottleneck deterministically (this lab's pattern) before changing production. A latency spike that disappears after a CPU bump but reappears on the next deploy means CPU was a contributing factor, not the root cause.
- Choose between scaling UP and scaling OUT based on the workload shape (per-request compute need vs total throughput).
- Apply the change via
az containerapp update --cpu <new> --memory <new>(or Bicep with the same property names). The platform creates a new revision and swaps traffic underactiveRevisionsMode: 'Single'; cold-start adds a few seconds during the swap.
Support takeaway¶
When escalating a "latency is bad under load" case on Azure Container Apps, run this sequence in order before assuming a platform issue:
-
Confirm the per-replica CPU/memory envelope and that the workload is actually CPU-bound (not memory-pressure-bound, not network-bound, not dependency-latency-bound):
az containerapp show \ --subscription "$AZ_SUBSCRIPTION" \ --resource-group "$RG" \ --name "$APP_NAME" \ --query "{cpu: properties.template.containers[0].resources.cpu, memory: properties.template.containers[0].resources.memory, minReplicas: properties.template.scale.minReplicas, maxReplicas: properties.template.scale.maxReplicas}"Command Purpose az containerapp show --subscription "$AZ_SUBSCRIPTION" --resource-group "$RG" --name "$APP_NAME" --query "{cpu: properties.template.containers[0].resources.cpu, memory: properties.template.containers[0].resources.memory, minReplicas: properties.template.scale.minReplicas, maxReplicas: properties.template.scale.maxReplicas}"Reads the Container App resource and extracts the per-container CPU and memory allocation, which is the specific surface this troubleshooting step needs to confirm. -
Capture
UsageNanoCoresfor a window that covers the reported latency event:az monitor metrics list \ --subscription "$AZ_SUBSCRIPTION" \ --resource "/subscriptions/$AZ_SUBSCRIPTION/resourceGroups/$RG/providers/Microsoft.App/containerApps/$APP_NAME" \ --metric UsageNanoCores \ --aggregation Average Maximum \ --interval PT1MCommand Purpose az monitor metrics list --subscription "$AZ_SUBSCRIPTION" --resource "/subscriptions/$AZ_SUBSCRIPTION/resourceGroups/$RG/providers/Microsoft.App/containerApps/$APP_NAME" --metric UsageNanoCores --aggregation Average Maximum --interval PT1MQueries one-minute CPU usage for the exact Container App resource so you can verify that the latency window aligns with sustained CPU saturation instead of another bottleneck. -
Recommend the controlled before/after experiment in this lab (
trigger.sh+fix-and-capture.shpattern) on a non-production replica before raising CPU in production. The lab's H2 ratio (post-fix p95 / pre-fix p95) is the falsifiable signal that distinguishes "CPU was the bottleneck" from "raising CPU masked the real bottleneck temporarily."
4b) Phase B Falsification Gates¶
The 2026-06-26 evidence-pack overlay adds a Phase B verifier under labs/cpu-throttling/. Unlike the live-Azure Phase A workflow (trigger.sh + fix-and-capture.sh), the new labs/cpu-throttling/verify.sh is a pure file processor: it reads only the committed canonical cohort under labs/cpu-throttling/evidence/ (15 canonical files — 2 script logs + 2 before-fix captures + 1 baseline load-test result + 1 baseline metric snapshot + 1 update result + 2 after-fix captures + 1 post-fix load-test result + 1 post-fix metric snapshot + 4 environment captures, anchored on the 2026-06-22T08:49:38Z → 2026-06-22T08:51:13Z koreacentral traffic window of 95.0 s end-to-end) and emits four derived gate JSONs. The four gates encode cohort integrity (all required files present, no foreign artifacts, temporal coherence anchored directly on 03.started_utc and 08.finished_utc), baseline CPU pressure attribution (cpu=0.25 envelope + p95 > 100 ms floor + transport health + wall-clock > 5 s floor), recovery materialization (cpu=1.0 envelope + recovery ratio < 0.5 strict + post-fix transport health + revision-state with runningState ∈ {Running, RunningAtMaxScale} AND trafficWeight==100), and single-variable falsification (shared-keys diff equals exactly {cpu, memory} + revision lineage shows new revision did not exist before and is now the traffic-100 holder + Container App identity preserved by literal prefix match). All 15 sub-gates pass on the 2026-06-22 cohort.
| Gate | Claim | Sub-gates | Predicate inputs | PASS / FAIL | Rationale |
|---|---|---|---|---|---|
14-cohort-integrity-gate.json | evidence_cohort_is_internally_consistent_and_temporally_coherent | 4 | All 15 canonical Phase A files + cohort directory listing + 4 emitted Phase B gate JSONs + evidence/README.md | PASS | Cohort integrity gate. Confirms (a) all 15 canonical Phase A files exist on disk — Strong path requires all 15; Fallback path requires >= 13 present AND all five hypothesis-gate inputs (01-app-config-before.json, 03-loadtest-cpu025.json, 06-app-config-after.json, 07-revisions-after.json, 08-loadtest-cpu1.json) present; (b) temporal coherence — the baseline started_utc field of 03-loadtest-cpu025.json (2026-06-22T08:49:38Z) and the post-fix finished_utc field of 08-loadtest-cpu1.json (2026-06-22T08:51:13Z) both parse as strict ISO-8601 UTC, are monotonic, AND span 95.0 s, well within both the 30-minute Strong window and the 60-minute Fallback window. Unlike Lab 22 which used dedicated traffic-completed timestamp files, this lab reads the temporal anchors directly from the load-test JSONs because the load-test runner records both started_utc and finished_utc natively; (c) no unexpected non-junk extras — Strong path requires exact match of the 15 canonical + 4 Phase B gate JSONs + README.md; Fallback path tolerates editor/OS junk (.swp, .bak, .tmp, .swo, .orig, .DS_Store, Thumbs.db) but still requires zero missing canonical files (the Lab 19 P0 unexpected_non_junk_extras predicate + Lab 20 PR #279 fallback-integrity directive); (d) evidence/README.md cross-references all 4 Phase B gate JSON filenames (Strong) or just exists (Fallback) so a reviewer can locate every emitted output. |
15-baseline-cpu-pressure-gate.json | at_cpu_0.25_memory_0.5gi_single_replica_the_deterministic_cpu-bound_endpoint_produced_observable_cpu_pressure (H1) | 4 | 01-app-config-before.json + 03-loadtest-cpu025.json | PASS | Baseline CPU-pressure gate (the H1 anchor for Gate 16's recovery ratio). Confirms (a) 01-app-config-before.json records cpu==0.25 AND memory=="0.5Gi" AND minReplicas==1 AND maxReplicas==1 AND activeRevisionsMode=="Single" (BOTH-not-OR-across-five, single-path, record-scoped parsed JSON field reads — the baseline envelope is the exact platform configuration under which the H1 measurement was taken); (b) 03-loadtest-cpu025.json.latency_ms.p95 > 100 ms (the trivial-load floor — without strictly above 100 ms p95 the workload was too light to reproduce throttling and the run is invalid; observed 2574.8 ms); (c) 03-loadtest-cpu025.json.requests_ok >= 95 AND requests_err == 0 (BOTH-not-OR, single-path — the measurement is not corrupted by transport failures; observed 100/0); (d) 03-loadtest-cpu025.json.wall_clock_seconds > 5 (the load test ran long enough to actually exercise CPU rather than completing trivially; observed 8.65 s). All four sub-gates are single-path because each checks the structured shape of a canonical capture file, not a tolerance-bounded observation. Without H1 passing, the recovery ratio in Gate 16 is meaningless because there was no pressure to recover from. |
16-recovery-materialization-gate.json | raising_the_per-replica_resource_envelope_from_cpu_0.25_memory_0.5gi_to_cpu_1.0_memory_2.0gi_materialized_recovery (H2) | 4 | 03-loadtest-cpu025.json + 05-update-result.json + 06-app-config-after.json + 07-revisions-after.json + 08-loadtest-cpu1.json | PASS | Recovery materialization gate (the H2 anchor). Confirms (a) 06-app-config-after.json records cpu==1.0 AND memory in {"2Gi", "2.0Gi"} AND minReplicas==1 AND maxReplicas==1 AND latestRevisionName == 05-update-result.json.latestRevisionName (BOTH-not-OR-across-five, single-path; the cross-check pins the Phase 8 config-after read to the same revision that the Phase 6 update response named; memory normalization is platform-driven — the Bicep template requests 2.0Gi, the Microsoft.App API normalizes it to 2Gi on read, so the gate accepts both variants); (b) 08-loadtest-cpu1.json.latency_ms.p95 < 0.5 * 03-loadtest-cpu025.json.latency_ms.p95 (strict less-than: equal-to-50% is borderline noise, not recovery; the observed ratio is 0.3003, a 70.0% reduction, well below the 50% strict threshold of 1287.4 ms); (c) 08-loadtest-cpu1.json.requests_ok >= 95 AND requests_err == 0 (BOTH-not-OR, single-path; the post-fix measurement is not corrupted by transport failures; observed 100/0); (d) 07-revisions-after.json (record-scoped iteration): exactly 2 revision records AND exactly 1 record has trafficWeight==100 AND that record's runningState ∈ {"Running", "RunningAtMaxScale"} AND that record's name equals 06-app-config-after.json.latestRevisionName (BOTH-not-OR-across-four, single-path). The runningState predicate explicitly excludes "Deprovisioning" so the cohort cannot count a tearing-down old revision as the active traffic target — the cohort's old revision --8pz2nir is in exactly this state (active==true, runningState=="Deprovisioning", trafficWeight==0), so active==true alone would be unsafe. |
17-single-variable-falsification-gate.json | the_cohort_cannot_attribute_recovery_to_anything_other_than_the_cpu_memory_delta (H3) | 3 | 01-app-config-before.json + 02-revisions-before.json + 06-app-config-after.json + 07-revisions-after.json + 13-deployment-outputs.json | PASS | Single-variable falsification gate. Confirms (a) on the shared-keys surface between 01-app-config-before.json and 06-app-config-after.json (shared_keys = {cpu, memory, minReplicas, maxReplicas}; activeRevisionsMode appears only in 01, latestRevisionName appears only in 06 — a documented schema asymmetry of az containerapp show query projections), the set of keys whose values differ must equal exactly {cpu, memory}; the observed diff is exactly {cpu: 0.25→1.0, memory: 0.5Gi→2Gi} with minReplicas==1 and maxReplicas==1 unchanged (BOTH-not-OR, single-path, parsed JSON dictionary equality NOT greppy substring per Lab 15 lesson 34); (b) revision lineage (record-scoped over 02-revisions-before.json and 07-revisions-after.json): the before-revision name ca-cputhrottle-65svxr--8pz2nir appears in 07 (any state, any traffic) AND 06.latestRevisionName == ca-cputhrottle-65svxr--0000001 appears in 07 with trafficWeight==100 AND that new name is NOT in 02 (the platform created a new revision during the update, did not edit the old one in place — BOTH-not-OR-across-three, single-path); (c) every revision name in both 02-revisions-before.json[*].name and 07-revisions-after.json[*].name starts with 13-deployment-outputs.json.containerAppName + "--" (ca-cputhrottle-65svxr--), proving the cohort did not silently switch to a different Container App resource between before and after. The gate JSON carries an explicit cohort_binding_note field stating that the single-variable claim is bounded to the (cpu, memory) delta on the shared-fields surface of 01 and 06, and explicitly DOES NOT prove: (1) Kubernetes pod reuse — sub-gate (b) explicitly shows a NEW revision was created and the old revision is in runningState=="Deprovisioning"; (2) container image byte-identity — this is inferred from the fact that the Phase A fix-and-capture.sh issued az containerapp update --cpu --memory with no --image, --command, or --args flag, but the cohort does NOT capture image digests so this inference is a documented gap in the claim ceiling. This explicit binding enforces the Lab 21 Q1 "Bound every absence predicate to the failed-deployment cohort" directive in the resource-envelope falsification context. |
The four gates together block three classes of overclaim AND narrow a fourth: evidence-pack-was-contaminated-with-stale-or-foreign-artifacts is blocked by Gate 14's canonical-file presence check + temporal coherence window + non-junk-extras predicate; the-baseline-p95-of-2574.8-ms-could-have-been-network-jitter-or-transport-corruption is blocked by Gate 15's BOTH-not-OR composition of envelope-shape (a) + p95-floor (b) + zero-error transport health (c) + wall-clock floor (d) — all four must independently pass, so a noisy short run with transport failures or a trivial-load misread cannot satisfy the gate; recovery-was-partial-or-the-post-fix-p95-was-incidental is blocked by Gate 16's strict less-than ratio (< 0.5 * baseline_p95 rather than <=, ruling out borderline-noise observations) AND the revision-state predicate that excludes "Deprovisioning" from being counted as the active traffic target (the cohort's old revision sits in exactly this state with active==true, so an active==true-only check would be unsafe); and Gate 17 narrows the claim to the (cpu, memory) delta on the shared-keys surface of 01 and 06 — it does NOT prove pod reuse or image byte-identity (timestamp / no-recreation / image-digest claims are dropped per Lab 21 Q1 as not soundly evidenced by this cohort; the lab carries no image-lineage captures because the workload is python:3.12-slim pulled inline from public Docker Hub with no ACR). The gates do NOT prove the platform always activates a new revision within 15 seconds of az containerapp update — the cohort captured one 15-second activation window (08:50:45Z runningState=Activating → 08:51:00Z runningState=RunningAtMaxScale), and Azure Container Apps revision-activation latency can vary with image size, replica count, and regional load; a longer activation-window lab would be needed to bound that claim. The full per-file provenance and honest-disclosure notes are in labs/cpu-throttling/evidence/README.md.
Expected Evidence¶
Reproduced end-to-end in koreacentral on 2026-06-22. All raw evidence is committed under labs/cpu-throttling/evidence/:
| File | Content |
|---|---|
00-trigger-run.txt | Full trigger.sh execution log (config check + warm-up + load test at cpu=0.25 + metric capture, exit 0) |
00-verify-run.txt | Full fix-and-capture.sh execution log (apply fix + revision swap + load test at cpu=1.0 + metric capture + H1+H2 evaluation, exit 0 / SUPPORTED). The script was named verify.sh at capture time and renamed to fix-and-capture.sh during the 2026-06-26 Phase B overlay; the log filename is preserved for schema stability across the rename. |
01-app-config-before.json | Initial config readback: {"cpu": 0.25, "memory": "0.5Gi", "minReplicas": 1, "maxReplicas": 1, "activeRevisionsMode": "Single"} |
02-revisions-before.json | Initial revision list: 1 revision (Bicep-deployed baseline at cpu=0.25) |
03-loadtest-cpu025.json | Baseline load test summary: 100/100 ok, p50=1555ms, p95=2575ms, p99=2703ms, max=2801ms, avg=1626ms, wall=8.65s |
04-metrics-cpu025.json | UsageNanoCores query attempt for the baseline window (07:49-08:48 UTC); 60 per-minute timestamps with no Average/Maximum samples populated — documents Azure Monitor metric materialization lag for snapshots taken immediately after a load event |
05-update-result.json | az containerapp update --cpu 1.0 --memory 2.0Gi result: new revision name ca-cputhrottle-65svxr--0000001, provisioningState=Succeeded |
06-app-config-after.json | Post-fix config readback: {"cpu": 1.0, "memory": "2Gi", "minReplicas": 1, "maxReplicas": 1, "latestRevisionName": "ca-cputhrottle-65svxr--0000001"} |
07-revisions-after.json | Post-fix revision list: 2 revisions — old in Deprovisioning, new RunningAtMaxScale with 100% traffic |
08-loadtest-cpu1.json | Post-fix load test summary: 100/100 ok, p50=474ms, p95=773ms, p99=1093ms, max=1302ms, avg=531ms, wall=2.85s |
09-metrics-cpu1.json | UsageNanoCores query for the post-fix window (07:51-08:50 UTC); 60 per-minute timestamps, only the trailing minute at 08:50:00Z has materialized samples (average=820660.5 nC, maximum=852758.0 nC) due to materialization lag + PT1M averaging of a ~2.85 s load event |
10-cli-versions.json | Azure CLI version (2.79.0) and installed extensions at the time of the run |
11-cli-containerapp-ext.json | containerapp extension version (1.3.0b4, marked preview) |
12-region.json | Azure region (koreacentral) used for the reproduction |
13-deployment-outputs.json | Bicep deployment outputs (Container App name, FQDN, environment name, Log Analytics workspace name) |
14-cohort-integrity-gate.json | Phase B Gate 14 (4 sub-gates, all PASS Strong path): (a) all 15 canonical files present; (b) temporal coherence — 03.started_utc (2026-06-22T08:49:38Z) and 08.finished_utc (2026-06-22T08:51:13Z) both strict ISO-8601 UTC + monotonic + 95.0 s span within the 30-min Strong window; (c) no unexpected non-junk extras; (d) evidence/README.md cross-references all 4 Phase B gate filenames literally |
15-baseline-cpu-pressure-gate.json | Phase B Gate 15 H1 (4 sub-gates, all PASS): (a) baseline envelope cpu=0.25 + memory=0.5Gi + minReplicas=1 + maxReplicas=1 + activeRevisionsMode=Single; (b) baseline p95 > 100 ms floor (observed 2574.8 ms); (c) requests_ok ≥ 95 + requests_err == 0 (observed 100/0); (d) wall_clock > 5 s floor (observed 8.65 s) |
16-recovery-materialization-gate.json | Phase B Gate 16 H2 (4 sub-gates, all PASS): (a) recovery envelope cpu=1.0 + memory ∈ {2Gi, 2.0Gi} + replicas=1 + latestRevisionName cross-check with 05; (b) post-fix p95 < 0.5 × baseline p95 strict (observed ratio 0.3003, computed threshold 1287.4 ms); (c) post-fix requests_ok ≥ 95 + requests_err == 0 (observed 100/0); (d) revision state — 2 records, exactly 1 trafficWeight==100 holder, name matches 06.latestRevisionName, runningState ∈ {Running, RunningAtMaxScale} (excludes Deprovisioning) |
17-single-variable-falsification-gate.json | Phase B Gate 17 H3 (3 sub-gates, all PASS): (a) shared-keys diff between 01 and 06 = exactly {cpu, memory} (activeRevisionsMode-only-in-01 / latestRevisionName-only-in-06 documented as schema asymmetry); (b) revision lineage — old --8pz2nir in 07, new --0000001 in 07 with trafficWeight==100, new not in 02; (c) Container App identity — every revision name starts with containerAppName + "--". Carries cohort_binding_note documenting image-byte-identity is inferred (not directly evidenced) |
README.md | Phase B evidence tour: provenance + capture timeline + four-gate claim ceiling + per-file integrity table + honest disclosure (Azure Monitor materialization lag, schema asymmetry between 01/06, memory normalization, Deprovisioning revision state, image-byte-identity inference gap, no-pod-reuse claim) |
// Excerpt from evidence/03-loadtest-cpu025.json — cpu=0.25 baseline
{
"requests_ok": 100,
"latency_ms": { "p50": 1554.5, "p95": 2574.8, "p99": 2703.4, "max": 2800.8, "avg": 1625.6 }
}
// Excerpt from evidence/08-loadtest-cpu1.json — cpu=1.0 post-fix
{
"requests_ok": 100,
"latency_ms": { "p50": 473.6, "p95": 773.2, "p99": 1092.8, "max": 1302.2, "avg": 531.0 }
}
The 70% reduction in p95 (2575 ms → 773 ms) for the same 100 req / 20 concurrent load against byte-identical client code is the lab's central evidence.
Clean Up¶
Or, if you want to keep the environment and only roll the app back to the cheap baseline:
az containerapp update \
--subscription "$AZ_SUBSCRIPTION" \
--resource-group "$RG" \
--name "$APP_NAME" \
--cpu 0.25 \
--memory 0.5Gi
| Command | Why it is used |
|---|---|
./cleanup.sh | Runs az group delete --subscription "$AZ_SUBSCRIPTION" --name "$RG" --yes --no-wait so all lab resources (Container App, environment, Log Analytics workspace) are removed in one call. Recommended after evidence has been captured. |
az containerapp update --cpu 0.25 --memory 0.5Gi | Rolls the Container App back to the cheap baseline allocation if you want to keep the environment and workspace for further KQL exploration. Creates yet another revision under activeRevisionsMode: 'Single'. |
Related Playbook¶
See Also¶
- Memory Leak OOMKilled
- Replica Load Imbalance
- Cold Start and Scale-to-Zero Lab
- Image Size Startup Delay Lab
- Revision History Limit Lab