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-reasonProcessExitedHealthState: 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'plusProcessExitedplusContainerTerminatedwithin the first 60 seconds after deployment. The revision'sHealthStatereportsUnhealthyandProvisioningStatereportsFailed. The container never serves HTTP traffic. - Scenario B (leak): Console logs show progressive
[leak] tick N: +30 MiB, total retained K MiBentries.MemoryPercentageclimbs 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):
WorkingSetBytesstays at ~30-50 MiB.RestartCountremains 0.HealthStatereportsHealthy. This is the control that proves the platform, the image, the network, and the registry are all fine. - Fix: After
trigger-fix.shupdates Scenario A toMODE=healthywithmemory=1.0Gi, a new revision is created. The new revision reachesHealthState: Healthywithin 90 seconds.MemoryPercentagedrops to baseline. The Portal Diagnose-and-SolveContainer App Memory Usagedetector 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¶
- Exit code 137 alone does not prove OOM. SIGKILL also comes from admin actions, deployment cycles, and manual restarts. Confirm OOM with the
MemoryPercentagecurve — it should approach 100% just before the exit timestamp. - 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. - Hard OOM versus gradual leak look different in metrics. Hard OOM:
MemoryPercentagespikes to ~100% within seconds, container dies before HTTP traffic. Gradual leak:MemoryPercentageclimbs over minutes, container serves traffic until the ceiling is hit, then restart history shows a periodic cliff pattern. - 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=healthyswitch) or include both axes (memory raise plus workload fix). - Preserve the failing revision.
az containerapp updatecreates a new revision but does not delete the old one. The Revisions blade retains the failing revision'sHealthStateandRunningStatefor 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.

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.



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).


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 Application log stream showing allocation progress lines but no "[app] listening" message](../../../assets/troubleshooting/memory-leak-oomkilled/08-ca-oom-hard-logstream-application.png)
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.





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.


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.





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.



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 Application log stream showing [leak] tick entries](../../../assets/troubleshooting/memory-leak-oomkilled/21-ca-oom-leak-logstream-application.png)
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.




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.



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.


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".

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: ca-oom-leak [leak] tick entries](../../../assets/troubleshooting/memory-leak-oomkilled/32-kql-ca-oom-leak-app-log-ticks.png)
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.

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.

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.

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.


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.




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.



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.



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.

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.

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.


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.

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.

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.

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.

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.

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.

Clean Up¶
| Command | Why it is used |
|---|---|
cleanup.sh | Deletes the resource group rg-aca-memleak-lab and all child resources asynchronously. |