Skip to content

Memory Leak OOMKilled Lab

Reproduce the canonical Azure Container Apps memory-pressure failure pattern (exit code 137 / OOMKilled), capture the evidence from CLI, KQL, and the Azure Portal Diagnose-and-Solve detectors, then verify that applying the fix creates a new healthy revision while preserving the failing revision for post-incident review.

Lab Metadata

Attribute Value
Difficulty Intermediate
Estimated Duration 30-45 minutes (10 min wait for log ingestion, ~5 min for leak to reach the cgroup ceiling)
Tier Consumption
Failure Mode Container terminated with exit code 137 (SIGKILL), CrashLoopBackOff, ProvisioningState=Failed
Skills Practiced System log analysis, KQL queries, MemoryPercentage / WorkingSetBytes correlation, Diagnose-and-Solve detector usage, fix-and-verify workflow

1) Background

When a container's resident set size (RSS) crosses the cgroup memory ceiling configured by --memory (for example 0.5Gi), the Linux kernel OOM killer sends SIGKILL to the container's main PID. Azure Container Apps surfaces this as:

  • Exit code 137 (128 + signal 9 = SIGKILL)
  • Reason: ContainerTerminated, sub-reason ProcessExited
  • HealthState: Unhealthy, ProvisioningState: Failed
  • Restart loop driven by the platform's CrashLoopBackOff behavior

The exit code does not include an explicit "OOM" string in platform logs. Operators must correlate exit 137 with the MemoryPercentage / WorkingSetBytes metric curve to confirm OOM as the root cause versus other SIGKILL sources (admin kill, deployment cycle, manual restart).

Architecture

flowchart TD
    A["Azure Container Apps environment<br/>cae-memleak-* (koreacentral)"] --> B["Scenario A: ca-oom-hard"]
    A --> C["Scenario B: ca-oom-leak"]
    A --> D["Scenario C: ca-oom-healthy"]
    B --> B1["MODE=hard-oom<br/>allocate 600 MiB at startup<br/>memory 0.5Gi"]
    C --> C1["MODE=leak<br/>+30 MiB every 20s<br/>in background thread<br/>memory 0.5Gi"]
    D --> D1["MODE=healthy<br/>no allocations<br/>memory 1.0Gi"]
    B1 --> E["Observe:<br/>ContainerAppSystemLogs_CL<br/>WorkingSetBytes<br/>RestartCount<br/>D&S Exit Events detector"]
    C1 --> E
    D1 --> E
    E --> F["Apply fix to Scenario A:<br/>MODE=healthy + memory 1Gi<br/>creates new revision"]
    F --> G["Verify:<br/>HealthState=Healthy<br/>old revision retained for evidence"]
Scenario App name Mode Memory Expected behavior
A. Hard OOM ca-oom-hard hard-oom (allocate 600 MiB at startup) 0.5Gi Immediate OOMKill before HTTP server starts. CrashLoopBackOff. ProvisioningState=Failed.
B. Gradual leak ca-oom-leak leak (+30 MiB / 20s in background thread) 0.5Gi Healthy for ~5-7 minutes, then OOMKill. Restart re-runs the leak.
C. Healthy control ca-oom-healthy healthy (no allocations) 1.0Gi Stable. RSS ~30-50 MiB. RestartCount=0. HealthState=Healthy.
Fix ca-oom-hard (updated) healthy + memory 1.0Gi 1.0Gi New revision recovers. Old failing revision retained for evidence.

Comparing A and B against C proves the OOMs are caused by the workload's memory growth, not by the image, network, registry, or platform.

2) Hypothesis

IF three Container Apps share an environment but run workloads with different memory-allocation patterns, THEN:

  • Scenario A (hard-oom): System logs show exit code '137' plus ProcessExited plus ContainerTerminated within the first 60 seconds after deployment. The revision's HealthState reports Unhealthy and ProvisioningState reports Failed. The container never serves HTTP traffic.
  • Scenario B (leak): Console logs show progressive [leak] tick N: +30 MiB, total retained K MiB entries. MemoryPercentage climbs steadily over minutes. Around tick 12-15 (corresponding to ~360-450 MiB retained), the first OOM appears, followed by CrashLoopBackOff. The restart re-runs the leak, producing a periodic cliff pattern in the metric.
  • Scenario C (healthy): WorkingSetBytes stays at ~30-50 MiB. RestartCount remains 0. HealthState reports Healthy. This is the control that proves the platform, the image, the network, and the registry are all fine.
  • Fix: After trigger-fix.sh updates Scenario A to MODE=healthy with memory=1.0Gi, a new revision is created. The new revision reaches HealthState: Healthy within 90 seconds. MemoryPercentage drops to baseline. The Portal Diagnose-and-Solve Container App Memory Usage detector flips to green (no thresholds exceeded), and the failing revision is retained inactive in the Revisions blade for post-incident review.

3) Runbook

Deploy infrastructure

export RG="rg-aca-memleak-lab"
export LOCATION="koreacentral"
export BASE_NAME="memleak"

az group create --name "$RG" --location "$LOCATION"

az deployment group create \
    --resource-group "$RG" --name main \
    --template-file labs/memory-leak-oomkilled/infra/main.bicep \
    --parameters baseName="$BASE_NAME"

export ACR_NAME="$(az deployment group show --resource-group "$RG" --name main \
    --query properties.outputs.containerRegistryName.value --output tsv)"
export ENV_NAME="$(az deployment group show --resource-group "$RG" --name main \
    --query properties.outputs.environmentName.value --output tsv)"
Command Why it is used
az group create Creates the resource group that scopes all lab resources for easy teardown.
az deployment group create Deploys the Bicep template that provisions Log Analytics workspace, Azure Container Registry, and the Container Apps environment with appLogsConfiguration.destination=log-analytics.
az deployment group show Reads the Bicep outputs to capture the generated ACR and environment names (both use random suffixes for uniqueness).

Deploy the three scenarios

bash labs/memory-leak-oomkilled/trigger-scenario-a.sh
bash labs/memory-leak-oomkilled/trigger-scenario-b.sh
bash labs/memory-leak-oomkilled/trigger-scenario-c.sh
Command Why it is used
trigger-scenario-a.sh Builds the image, pushes to ACR, and creates ca-oom-hard with MODE=hard-oom and HARD_OOM_MB=600, cpu=0.25, memory=0.5Gi. The container tries to allocate 600 MiB at startup, exceeds the 0.5Gi cgroup limit, and gets SIGKILL'd before the HTTP server binds.
trigger-scenario-b.sh Creates ca-oom-leak with MODE=leak and LEAK_MB_PER_TICK=30, cpu=0.25, memory=0.5Gi. A background thread retains 30 MiB every 20 seconds. The HTTP server stays healthy until the leak crosses the ceiling, then OOMKills.
trigger-scenario-c.sh Creates ca-oom-healthy with MODE=healthy, cpu=0.5, memory=1.0Gi. No allocations beyond the Python runtime. Acts as a control to prove the platform and image are fine.

Wait, then verify each scenario

echo "Waiting 10 minutes for log ingestion and leak progression..."
sleep 600

APP_NAME=ca-oom-hard    bash labs/memory-leak-oomkilled/verify.sh
APP_NAME=ca-oom-leak    bash labs/memory-leak-oomkilled/verify.sh
APP_NAME=ca-oom-healthy bash labs/memory-leak-oomkilled/verify.sh

verify.sh collects every available signal per app: app configuration, active revisions, replica list, Log Analytics workspace ID, system-log query for exit-137 and OOM patterns, console-log query for [leak] tick entries, and RestartCount. Output goes to labs/memory-leak-oomkilled/evidence/<APP_NAME>/report-<TIMESTAMP>.txt. Note: MemoryPercentage and WorkingSetBytes time series are read from the Portal Metrics blade (captures 22-23, 27) rather than the CLI — the az monitor metrics list calls in verify.sh return empty payloads for short observation windows on newly created revisions, so the Portal Metrics chart is the authoritative source for memory-trace evidence in this lab.

Apply fix to Scenario A and verify recovery

bash labs/memory-leak-oomkilled/trigger-fix.sh
sleep 90
APP_NAME=ca-oom-hard bash labs/memory-leak-oomkilled/verify.sh
Command Why it is used
trigger-fix.sh Updates ca-oom-hard with MODE=healthy and --memory 1.0Gi --cpu 0.5. This creates a new revision (--0000001); the failing revision (--18xosgl) is retained but deactivated.
Second verify.sh Confirms the new revision is HealthState: Healthy and the old revision is preserved as evidence. The Portal Metrics blade is the authoritative source for confirming MemoryPercentage returned to baseline.

4) Experiment Log

Tested in Azure region Korea Central, 2026-06-20, az CLI 2.79.0, containerapp extension 1.3.0b4.

Scenario A: Hard OOM evidence [Observed]

# verify.sh output for ca-oom-hard (pre-fix, 2026-06-20T04:21:13Z)
Active revisions: ca-oom-hard--18xosgl
HealthState:      Unhealthy
ProvisioningState: Failed
RunningState:     Stopped

# System log entries (excerpt):
Container 'ca-oom-hard' was terminated with exit code '137' and reason 'ProcessExited'   ContainerTerminated
Container 'ca-oom-hard' was terminated with exit code '137'                              ProcessExited
Container 'ca-oom-hard' was terminated with exit code '137' and reason 'ProcessExited'   ContainerTerminated
... (76 such entries within ~17 minutes)

# Console log entries (excerpt): allocation progress prints only.
[hard-oom] allocating 600 MiB at startup (will exceed cgroup limit)
[hard-oom] allocated 50/600 MiB
[hard-oom] allocated 100/600 MiB
... (climbs to ~400-450/600 MiB)
# The line "[app] listening on :8000" is NEVER printed —
# the kernel killed the process during the allocation loop, before serve()
# was reached, so no HTTP traffic ever flowed.

Scenario B: Gradual leak evidence [Measured]

# verify.sh output for ca-oom-leak (2026-06-20T04:22:00Z)
Active revisions: ca-oom-leak--y1hn6jn
HealthState:      Healthy (at time of capture, before first OOM)
RestartCount:     0

# Console log entries (excerpt — progressive tick prints):
[leak] tick 1:  +30 MiB, total retained  30 MiB
[leak] tick 2:  +30 MiB, total retained  60 MiB
[leak] tick 3:  +30 MiB, total retained  90 MiB
...
[leak] tick 12: +30 MiB, total retained 360 MiB
[leak] tick 13: +30 MiB, total retained 390 MiB
[leak] tick 14: +30 MiB, total retained 420 MiB    ← OOM imminent

# Portal Metrics blade — WorkingSetBytes chart (capture 23, 60s bin):
# climbs linearly from ~50 MiB to ~450 MiB over the first 5 minutes,
# then drops to ~50 MiB at restart, then climbs again.

Scenario C: Healthy control evidence [Observed]

# verify.sh output for ca-oom-healthy (2026-06-20T04:22:46Z)
Active revisions: ca-oom-healthy--dfl8p2o
HealthState:      Healthy
ProvisioningState: Provisioned
RunningState:     RunningAtMaxScale
RestartCount:     0

# Console log entries: only the startup line (request logging is suppressed
# by `log_message = return` in the HTTP handler, so individual GET hits are
# not printed by design).
[app] listening on :8000
... (no errors, no terminations)

# Portal Metrics blade — WorkingSetBytes chart (capture 27): stable at
# ~30-50 MiB for the entire observation window.

Post-fix evidence [Observed]

# verify.sh output for ca-oom-hard (post-fix, 2026-06-20T06:02:57Z)
Active revisions: ca-oom-hard--0000001    ← NEW revision after fix
HealthState:      Healthy
ProvisioningState: Provisioned
RunningState:     RunningAtMaxScale
Replicas:         1
Memory:           1Gi                       ← raised from 0.5Gi
CPU:              0.5                       ← raised from 0.25

# System log entries during fix rollout:
Replica 'ca-oom-hard--0000001-67d777f859-v6brv' has been scheduled to run on a node.    AssigningReplica
Creating a new revision: ca-oom-hard--0000001                                            RevisionCreation
Rolling Transition: Successfully completed rolling over existing to latest revision...   RollingRevisionCompleted
Successfully updated containerApp: ca-oom-hard                                           ContainerAppReady
Deactivating old revisions for ContainerApp 'ca-oom-hard'                                RevisionDeactivating
Created container 'ca-oom-hard'                                                          ContainerCreated
Started container 'ca-oom-hard'                                                          ContainerStarted

# Failing revision (ca-oom-hard--18xosgl) status:
Active:           false
RunningState:     Stopped
(retained inactive for evidence; NOT deleted)

Operator takeaway from experiment

  1. Exit code 137 alone does not prove OOM. SIGKILL also comes from admin actions, deployment cycles, and manual restarts. Confirm OOM with the MemoryPercentage curve — it should approach 100% just before the exit timestamp.
  2. System logs do not explicitly say "OOMKilled". Look for exit code '137' + ProcessExited + ContainerTerminated. The kernel does not write an OOM message into the platform log surface — only the kill is visible.
  3. Hard OOM versus gradual leak look different in metrics. Hard OOM: MemoryPercentage spikes to ~100% within seconds, container dies before HTTP traffic. Gradual leak: MemoryPercentage climbs over minutes, container serves traffic until the ceiling is hit, then restart history shows a periodic cliff pattern.
  4. Always validate the fix. Raising memory alone does not fix a true leak — it only delays the OOM. The Scenario B pattern (re-leak after restart) makes this visible. The fix must change the workload (Scenario A's MODE=healthy switch) or include both axes (memory raise plus workload fix).
  5. Preserve the failing revision. az containerapp update creates a new revision but does not delete the old one. The Revisions blade retains the failing revision's HealthState and RunningState for post-incident review. Do not delete it until evidence collection is complete.

4b) Phase B Falsification Gates

verify.sh is a pure file processor against the 35 historical-capture artifacts under evidence/ca-oom-{hard,leak,healthy}/ and emits four falsifiable gate JSONs under evidence/. Each gate is evaluated against a strict 2-path predicate per sub-gate — a Strong path that matches the exact lab specification (e.g. record-scoped 2-form OOM predicate matching the platform's two distinct exit-event row shapes, or §7 leak-tick regex parse) and a Fallback path that tolerates the same controlling behavior under minor numeric drift (e.g. the §6 5-minute bin histogram as a weaker proxy for the §5 record count). Eleven of the twelve sub-gates (4 gates × 3 sub-gates each) emitted on 2026-06-24 pass on both the Strong and Fallback paths against the canonical 2026-06-20 evidence. The twelfth — Gate 12 sub-gate (a) — passes on the Strong path only; its Fallback path is intentionally false by design (see the detailed disclosure two paragraphs below). All twelve *_pass flags evaluate to true because each sub-gate composes as Strong OR Fallback and the Strong path alone is sufficient.

Gate File Hypothesis Sub-gates Result
10 10-h1-hard-oom-immediate-gate.json H1.a (Scenario A hard-oom): a workload that allocates beyond the cgroup memory ceiling at startup produces an immediate, dense OOM burst signature distinct from a gradual leak 3 — (a) signal observed: §5 has ≥ 10 OOM records (record-scoped 2-form predicate, see below) OR §6 max 5-min bin count ≥ 10; (b) hard signature: §6 max 5-min bin ≥ 10 (dense burst) OR §6 bin count ≥ 3 (multi-bin sustained pattern); (c) state eventually fixed: sidecar healthState == "Healthy" (Strong) OR sidecar provisioningState != "Failed" (Fallback) PASS — 16 §5 OOM records (11 form-A + 5 form-B), §6 max bin 26 events @ 04:10 UTC, §6 has 23 bins spanning 04:10–06:00 UTC, sidecar healthState: Healthy + provisioningState: Provisioned + runningState: RunningAtMaxScale + trafficWeight: 0 (POST-fix revision)
11 11-h1-leak-delayed-oom-gate.json H1.b (Scenario B leak): a workload that allocates +30 MiB / 20 s in a background thread produces a delayed OOM signature with a measurable memory runway (monotonic tick prints) 3 — (a) leak runway observed: §7 parses ≥ 12 leak-tick records via regex \[leak\] tick (\d+): \+30 MiB, total retained (\d+) MiB OR ≥ 8 ticks (fallback); (b) tick + retained monotonic: tick numbers strictly increasing AND retained MiB strictly increasing OR retained values fit arithmetic invariant retained[i] = 30 × tick[i]; (c) leak eventually kills: §5 has ≥ 1 OOM record OR §6 has any nonzero bin PASS — 16 §7 ticks parsed (1→16, retained 30→480 MiB, strict monotonic both ways, arithmetic invariant holds), 1 §5 OOM record (form-A), §6 has 2 bins (13/3 at 04:10/04:15)
12 12-h2-healthy-control-gate.json H2 (Scenario C healthy baseline): a workload with no allocations produces zero OOM records, falsifying any "OOMs are environmental" theory 3 — (a) no OOM records: §5 has 0 OOM records (Strong) — Fallback intentionally false because §6 has_any('memory') KQL filter produces false-positives on the healthy scenario; (b) healthy state: sidecar healthState: Healthy OR sidecar provisioningState: Provisioned; (c) stable replicas: sidecar runningState: RunningAtMaxScale AND replicas ≥ 1 (substitutes for absent §10c restart-count metric) OR sidecar trafficWeight: 100 PASS — 0 §5 OOM records, sidecar healthState: Healthy + provisioningState: Provisioned + runningState: RunningAtMaxScale + replicas: 1 + trafficWeight: 100
13 13-h3-cross-scenario-falsification-gate.json H3 (cross-scenario): three workload patterns produce three distinguishable OOM signatures — hard dense burst vs leak monotonic runway vs healthy silence 3 — (a) OOM count ordering: exact healthy=0 AND leak ≥ 1 AND hard ≥ 10 OR weak healthy < leak < hard; (b) distinct signature classes: hard §6 max bin ≥ 10 AND leak §7 tick count ≥ 12 AND healthy §5 OOM = 0 OR weak hard has dense bin AND leak has ticks AND healthy has no OOM; (c) health states match outcome: healthy.healthState == "Healthy" AND leak.healthState ∈ {Healthy, Unhealthy} AND hard.healthState ∈ {Healthy, Unhealthy} (Strong — the control must be Healthy; the other two scenarios' latest-sidecar state is tolerated as Healthy OR Unhealthy because their snapshot timing relative to the platform's restart cadence is variable, see honest disclosure) OR healthy.healthState == "Healthy" (Fallback — control-only, used when the failing scenarios' snapshots fall in an unexpected rolling-deploy state) PASS — OOM counts 0 / 1 / 16, signature classes hard max bin 26 ≥ 10 + leak 16 ticks ≥ 12 + healthy 0 OOM, all three latest sidecars Healthy (which satisfies both the Strong and Fallback paths because all three observed states are within the allowed sets)

The Gate 10 sub-gate (a) Strong path uses a record-scoped 2-form OOM predicate rather than a whole-file substring search. The Container Apps platform emits two distinct exit-event row shapes for the same OOM event: form A Reason_s == ContainerTerminated AND Log_s contains 'exit code \'137\'' AND Log_s contains 'ProcessExited' (11 records in hard) and form B Reason_s == ProcessExited AND Log_s contains 'exit code \'137\'' (5 records in hard). The gate matches both forms and sums them — a whole-file substring search would inflate the count by counting the same OOM event in both rows. This per-record discipline is why Gate 10's predicate_inputs lists the report path (parsed) rather than asserting against the raw text.

The Gate 12 sub-gate (a) Fallback path is intentionally false and documented as such in the gate JSON's section_6_bins_note: the §6 5-minute bin histogram uses a KQL has_any('memory') filter that produces false-positives on the healthy scenario (14 events in the 04:10 bin that are platform "memory" mentions unrelated to OOM). Only the Strong path (§5 record-scoped 2-form predicate, which returned 0) is authoritative for Gate 12. This is intentional defensive design — letting the fallback path silently mask a regression would defeat the purpose of the gate. The Gate 13 sub-gate (b) Strong path keys off §7 leak-tick count (not §6 bins) for the leak signature for the same reason. See labs/memory-leak-oomkilled/evidence/README.md "Honest disclosure" section for the full empirical platform behavior documented during the 2026-06-20 live run, including the three-timestamps-for-hard pattern (two PRE-fix at 04:19:00Z + 04:21:13Z plus one POST-fix at 06:02:57Z), the leak scenario's 1-OOM cross-cycle overlap, and the §10c PTY-failure-on-macOS that motivated using runningState: RunningAtMaxScale as the Gate 12 sub-gate (c) substitute for restart count.

The H1.a gate (10) proves hard-OOM has an immediate dense burst signature. The H1.b gate (11) proves leak has a delayed monotonic-runway signature distinct from hard. The H2 gate (12) proves healthy has zero OOM, falsifying any environmental-cause theory. The H3 cross-scenario gate (13) proves the three signatures are mutually distinguishable. Together these four gates yield the operator guidance in the Operator takeaway from experiment subsection above: exit code 137 alone does not classify the failure shape; the §6 bin density, §7 tick presence, and §5 record count together distinguish hard-OOM from gradual-leak from non-OOM-baseline.

5) Verification Queries

KQL: Cross-scenario exit-event summary

ContainerAppSystemLogs_CL
| where ContainerAppName_s in ("ca-oom-hard", "ca-oom-leak", "ca-oom-healthy")
| where Log_s has "exit code" or Reason_s in ("ContainerTerminated", "ProcessExited")
| summarize ExitEvents = count() by ContainerAppName_s, Reason_s
| order by ExitEvents desc

Expected: ca-oom-hard shows the highest count concentrated in ContainerTerminated and ProcessExited. ca-oom-leak shows a smaller but non-zero count. ca-oom-healthy shows zero.

KQL: Scenario B leak-tick console log

ContainerAppConsoleLogs_CL
| where ContainerAppName_s == "ca-oom-leak"
| where Log_s has "[leak] tick"
| project TimeGenerated, Log_s
| order by TimeGenerated asc

Expected: Progressive [leak] tick N: +30 MiB, total retained K MiB entries. The K value increases by 30 each tick until the container is killed and restarted.

KQL: Scenario A container-terminated detail

ContainerAppSystemLogs_CL
| where ContainerAppName_s == "ca-oom-hard"
| where Reason_s in ("ContainerTerminated", "ProcessExited")
| where Log_s has "137"
| project TimeGenerated, Reason_s, Log_s, RevisionName_s
| order by TimeGenerated asc

Expected: Repeated exit code '137' entries scoped to the failing revision (ca-oom-hard--18xosgl). Each entry is a separate SIGKILL event.

KQL: Memory growth timechart

ContainerAppConsoleLogs_CL
| where ContainerAppName_s == "ca-oom-leak"
| where Log_s has "[leak] tick"
| extend RetainedMiB = toint(extract(@"total retained (\d+) MiB", 1, Log_s))
| project TimeGenerated, RetainedMiB
| order by TimeGenerated asc
| render timechart

Expected: A staircase that climbs by 30 MiB every 20 seconds, with a sharp reset to baseline each time the container is OOMKilled and restarted.

KQL: ProbeFailed on Scenario B (after leak crosses ceiling)

ContainerAppSystemLogs_CL
| where ContainerAppName_s == "ca-oom-leak"
| where Reason_s == "ProbeFailed"
| project TimeGenerated, Log_s
| order by TimeGenerated asc

Expected: Probe-failure entries appearing once the leak exhausts the memory budget and the process slows or stops responding before SIGKILL.

KQL: ScaledObjectCheckFailed (KEDA correlation)

ContainerAppSystemLogs_CL
| where ContainerAppName_s == "ca-oom-hard"
| where Reason_s == "ScaledObjectCheckFailed"
| project TimeGenerated, Log_s
| order by TimeGenerated asc

Expected: KEDA scaler check failures while the revision is in CrashLoopBackOff (the scaler cannot read metrics from a container that is not Ready). This is a downstream symptom — not the root cause — and it disappears after the fix.

6) Portal Evidence

All captures live in docs/assets/troubleshooting/memory-leak-oomkilled/. Each capture was taken in a sanitized Azure Portal session with the standard PII helper applied before commit (see Portal capture rules for the capture policy and per-lab capture matrix).

Resource Group landing

Portal evidence — Resource group overview

The lab's resource group rg-aca-memleak-lab contains the Log Analytics workspace, the Azure Container Registry, the Container Apps environment, and the three Container App scenarios.

Resource group overview listing all lab resources

Scenario A — ca-oom-hard (Hard OOM)

Portal evidence — Overview and revisions during failure

The Container App overview shows ProvisioningState: Failed and RunningStatus: Stopped. The Revisions blade shows the failing revision ca-oom-hard--18xosgl with HealthState: Unhealthy.

ca-oom-hard Overview blade showing failure state

ca-oom-hard Revisions blade showing the failing revision

ca-oom-hard Revision detail flyout with HealthState=Unhealthy

Portal evidence — Container configuration and scale rule

The Containers blade confirms memory: 0.5Gi and cpu: 0.25 for the failing revision. The env vars MODE=hard-oom and HARD_OOM_MB=600 are set by trigger-scenario-a.sh and visible in the Environment variables tab of the same blade (not captured here). The Scale blade shows the active KEDA configuration (min 1, max 1).

ca-oom-hard Containers blade Properties tab showing 0.5Gi memory and 0.25 vCPU

ca-oom-hard Scale blade showing min/max replicas

Portal evidence — Log stream during CrashLoopBackOff

The System log stream captures the ContainerTerminated / ProcessExited entries with exit code '137'. The Application log stream shows the [hard-oom] allocating ... line followed by [hard-oom] allocated N/600 MiB progress lines climbing to ~400-450 MiB — and then nothing. The expected [app] listening on :8000 line is never printed: the kernel killed the process during the allocation loop, before serve() was reached, so no HTTP traffic ever flowed.

ca-oom-hard System log stream showing exit code 137 entries

ca-oom-hard Application log stream showing allocation progress lines but no "[app] listening" message

Portal evidence — Metrics during failure

The Memory Percentage metric stays at the cgroup ceiling. The Memory Working Set Bytes metric mirrors the same shape in absolute bytes. CPU Usage is near zero (the container exits before doing significant work). RestartCount climbs in steps. Replica Count flips between 0 and 1 as Kubernetes attempts and aborts restarts.

ca-oom-hard Memory Percentage metric — at ceiling

ca-oom-hard Memory Working Set Bytes metric — at ceiling

ca-oom-hard CPU Usage metric — near zero

ca-oom-hard Restart Count metric — climbing

ca-oom-hard Replica Count metric — flipping during CrashLoopBackOff

Portal evidence — Logs (KQL) and Activity Log

The Logs blade running the cross-scenario KQL query confirms the failure pattern is isolated to ca-oom-hard. The Activity Log captures every Create or Update Container App operation against the resource.

ca-oom-hard Logs blade running the cross-scenario exit-event KQL

ca-oom-hard Activity Log listing the deployment operations

Portal evidence — Diagnose and Solve during the live incident (pre-fix)

Captures 16 through 16d show the Diagnose-and-Solve experience an operator sees during the incident, while the failing revision (ca-oom-hard--18xosgl) is still the active revision. Compare with captures 44 through 47 in the post-fix subsection further down — the same detectors render different summary cards once a healthy revision is in place.

Diagnose and Solve landing page during the incident

Availability and Performance category during the incident, failing revision active

Container App Memory Usage detector during the incident — thresholds exceeded

Container Exit Events detector during the incident — exit code 137 cluster

Container Exit Events detector during the incident — full-blade view

Scenario B — ca-oom-leak (Gradual leak)

Portal evidence — Overview, containers, scale

Scenario B uses the same image as Scenario A. The Containers blade shows memory: 0.5Gi and cpu: 0.25. The differentiating env vars MODE=leak and LEAK_MB_PER_TICK=30 are set by trigger-scenario-b.sh and visible in the Environment variables tab of the same blade (not captured here). The Scale blade shows the same min/max as the other scenarios.

ca-oom-leak Overview blade

ca-oom-leak Containers blade Properties tab showing 0.5Gi memory and 0.25 vCPU

ca-oom-leak Scale blade

Portal evidence — Log streams during progressive leak

The System log stream captures the eventual ContainerTerminated after the leak crosses the ceiling. The Application log stream shows the progressive [leak] tick N: +30 MiB, total retained K MiB entries — this is the signature pattern that distinguishes a gradual leak from a hard OOM.

ca-oom-leak System log stream

ca-oom-leak Application log stream showing [leak] tick entries

Portal evidence — Metrics showing the staircase pattern

Memory Percentage and Memory Working Set Bytes climb linearly, then drop to baseline when the container is OOMKilled, then climb again — the staircase pattern that proves the leak is in the workload. RestartCount and ReplicaCount track the kill-and-restart cycle.

ca-oom-leak Memory Percentage metric — staircase

ca-oom-leak Memory Working Set Bytes metric — staircase

ca-oom-leak Restart Count metric

ca-oom-leak Replica Count metric

Scenario C — ca-oom-healthy (Healthy control)

Portal evidence — Stable healthy baseline

The Container App overview shows Healthy. The Memory Percentage metric stays flat at a very low level. The Revisions blade shows a single active revision with HealthState: Healthy and no restart history. This is the control proving the platform, image, environment, and network path are all fine.

ca-oom-healthy Overview blade — Healthy

ca-oom-healthy Memory Percentage metric — flat baseline

ca-oom-healthy Revisions blade — single healthy revision

Environment and Log Analytics workspace

Portal evidence — Shared environment and workspace

All three Container Apps share one Container Apps environment, which forwards both system logs and application logs to one Log Analytics workspace. This is what makes the cross-scenario KQL queries possible.

Container Apps environment overview

Log Analytics workspace overview

KQL verification queries (Logs blade)

Portal evidence — Cross-scenario exit-event summary

The cross-scenario summarize confirms the failure pattern is concentrated on ca-oom-hard, present in smaller quantity on ca-oom-leak, and absent on ca-oom-healthy. This is the single best one-shot view of "which scenarios are OOMing".

KQL: cross-scenario exit-event summary

Portal evidence — Schema discovery and leak tick log

The schema-discovery query lists the available columns on ContainerAppConsoleLogs_CL. The leak-tick query renders the progressive [leak] tick entries on ca-oom-leak.

KQL: ContainerAppConsoleLogs_CL schema

KQL: ca-oom-leak [leak] tick entries

Portal evidence — Container terminated detail

The ContainerTerminated detail query on ca-oom-hard projects the exact log text and revision name, giving you the full audit trail of every SIGKILL the failing revision received.

KQL: ca-oom-hard container terminated detail

Portal evidence — Memory growth timechart

The render timechart of total retained MiB extracted from the leak tick logs produces the staircase visualization. Each step is a 30 MiB allocation. Each drop is an OOMKill plus restart.

KQL: ca-oom-leak memory growth timechart

Portal evidence — AzureMetrics path returns empty (teaching point)

A common operator instinct is to query the AzureMetrics table for MemoryPercentage. The query parses but returns zero rows for Azure Container Apps resources, because Azure Container Apps platform metrics (MemoryPercentage, WorkingSetBytes, CpuPercentage, RestartCount, Replicas) are not routed into the AzureMetrics Log Analytics table. The correct paths are the Azure Monitor Metrics service directly (the Metrics blade in the Portal, or az monitor metrics list --resource ... --metric ... from the CLI). The Log Analytics tables (ContainerAppSystemLogs_CL / ContainerAppConsoleLogs_CL) are for events and application log lines, not for platform metric series. This empty-result capture documents the wrong path so future operators do not waste time on it.

KQL: AzureMetrics MemoryPercentage query returning zero rows for Container Apps

Portal evidence — ProbeFailed and ScaledObjectCheckFailed (downstream symptoms)

These two queries show the downstream effects of the OOM: the readiness probe fails once the leak exhausts the memory budget, and KEDA's scaler-check fails because it cannot read metrics from a Not-Ready container. Both clear up after the fix.

KQL: ca-oom-leak ProbeFailed entries

KQL: ca-oom-hard ScaledObjectCheckFailed entries

Post-fix verification

Portal evidence — Overview, revisions, and containers after the fix

After trigger-fix.sh updates Scenario A to MODE=healthy with memory=1.0Gi, a new revision ca-oom-hard--0000001 is created. The overview shows Running. The Revisions blade shows the new healthy revision alongside the failing one (now inactive but preserved). The Containers blade confirms the new memory allocation.

ca-oom-hard Overview blade after fix — Running

ca-oom-hard Revisions blade after fix — new healthy revision

ca-oom-hard Revisions blade showing the inactive failing revision retained for evidence

ca-oom-hard Containers blade after fix — 1Gi memory

Portal evidence — Metrics after the fix

The Metrics blade after the fix shows Memory Working Set stable at ~15 MiB (the Python runtime), well below the new 1Gi ceiling. The metric picker confirms all Container Apps metric dimensions are available per revision and per replica.

ca-oom-hard Metrics blade initial view after fix

ca-oom-hard Memory Working Set Bytes metric after fix — stable

ca-oom-hard Metric picker dropdown showing available metrics

Portal evidence — Activity Log of the fix operations

The Activity Log captures the exact Create or Update Container App operations that produced the failing revision and then the fix revision, including the operation status, timestamps, and operation detail.

ca-oom-hard Activity Log listing the create and fix operations

ca-oom-hard Activity Log entry expanded

ca-oom-hard Activity Log operation detail

Diagnose and Solve Problems (Portal-native OOM diagnosis)

The Azure Portal Diagnose-and-Solve Problems blade is the single best evidence surface for OOM diagnosis: it provides the Portal's own diagnosis text, the per-revision exit-event count, and a differential view that rules out alternative causes.

Portal evidence — Diagnose and Solve landing page

The Diagnose-and-Solve landing page exposes seven troubleshooting categories. For OOM diagnosis, drill into Availability and Performance.

Diagnose and Solve Problems landing page with seven categories

Portal evidence — Availability and Performance overview

The Availability-and-Performance category aggregates 13 detectors. The Revisions Health chart shows the new revision (ca-oom-hard--0000001) green alongside the inactive failing revision (ca-oom-hard--18xosgl) red — a side-by-side fix-validation view.

Availability and Performance category — Revisions Health chart with green/red revisions and 13 detector sidebar

Portal evidence — Container App Memory Usage detector (post-fix)

The Container App Memory Usage detector independently confirms the fix: a green check reads "No revisions detected with Memory usage exceeding warning or critical thresholds". The per-replica chart shows Memory: 1Gi with the new replica running well under the 80% warning threshold.

Container App Memory Usage detector — green check, no thresholds exceeded

Container App Memory Usage detector — per-revision chart showing 1Gi config and 80% warning threshold

Portal evidence — Container Exit Events detector (the killer evidence)

The Container Exit Events detector is the single most valuable Portal capture in this lab. It contains the Portal's own OOM diagnosis text:

"Resource exhaustion - containers terminated with SIGKILL (exit code 137) ... This is commonly caused by Out of Memory (OOM) conditions."

The header counter reads 28 exit event(s). The recommended action points to the Metrics blade and the Container App CPU and Memory detectors.

Container Exit Events detector — 28 exit events, Portal text identifying SIGKILL as OOM

Portal evidence — Exit Events common root causes

The detector enumerates the common root causes of container exits: port mismatch, missing environment variables or secrets, health-probe misconfiguration, and application errors. It cross-references the same ContainerAppConsoleLogs_CL and ContainerAppSystemLogs_CL tables used in the lab's KQL queries, plus the Health Probe Failures detector.

Container Exit Events detector — common root causes and KQL table references

Portal evidence — Exit Events graph and per-revision drill-down

The Exit Events graph plots a bar chart of exit events across the 24-hour window, with two series — exit code '137' and reason 'ProcessExited' and exit code '137'. The drill-down table totals 76 exit events for the failing revision ca-oom-hard--18xosgl (52 ProcessExited + 24 exit-137-only), confirming the SIGKILL count.

Container Exit Events detector — bar chart and per-revision table totalling 76 events

Portal evidence — Successful Checks (differential diagnosis)

The Exit Events detector's Successful Checks (3) section lists the other detectors that passed: Health Probe Failures, Image Pull Failures, and Ingress Port settings check. This is the Portal's differential diagnosis: it has ruled out probes, image pull, and port configuration, leaving OOM as the explanation.

Container Exit Events detector — Successful Checks ruling out probes, image pull, and ingress port

Portal evidence — Container Create Failures detector (clean)

The Container Create Failures detector explicitly reports "No container creation failures have been detected" — proving the failing revision was created successfully then killed at runtime, not blocked at containerd. This is the differential evidence that distinguishes OOM from image-pull failure or runtime-create failure.

Container Create Failures detector — clean, proving OOM is at runtime not at container create

Portal evidence — Health Probe Failures detector (clean)

The Health Probe Failures detector explicitly reports "No Health Probe failures were detected" between the failure window timestamps — confirming the OOM was not caused by a misconfigured probe. The kernel SIGKILL fired before any probe could fail.

Health Probe Failures detector — clean, ruling out probe misconfiguration as the cause

Clean Up

bash labs/memory-leak-oomkilled/cleanup.sh
Command Why it is used
cleanup.sh Deletes the resource group rg-aca-memleak-lab and all child resources asynchronously.

See Also

Sources