Troubleshooting Azure Container Apps in Production | Part 2 of 4
Introduction
There is a particular kind of frustration that comes not when your application fails to start, but when it starts too slowly. The container is running, the health probes pass, your monitoring shows green — and yet every few minutes a user somewhere in the world hits a request that takes 15 seconds to respond. Your support team starts getting tickets. Your SLA dashboard turns amber.
This is the cold start problem, and it is one of the most widely discussed pain points with any serverless container platform. Azure Container Apps is no different. But what most engineers do not realize is that the cold start is only one part of the story. Scaling delays, inefficient image layers, wrong resource allocations, and misconfigured KEDA rules all compound to create latency spikes that feel indistinguishable from cold starts but have completely different root causes and fixes.
In this part of the series, we break down each cause systematically and show you exactly how to address it.
Understanding What "Cold Start" Actually Means in Container Apps
Before we fix anything, it helps to understand what is actually happening during a cold start. When a new replica is created, Azure Container Apps needs to do several things in sequence before your application can serve a single request:
- The platform schedules the new replica on available infrastructure.
- The container runtime pulls the image layers that are not already cached on that node.
- The container starts and the process inside it begins executing.
- Your application framework initializes (the .NET DI container, Django's ORM layer, loaded ML models, etc.).
- The readiness probe passes, signaling that the replica can accept traffic.
Every one of these steps takes time. The total duration is your cold start latency. When you have `minReplicas: 0`, this full cycle happens for every "first request after idle" scenario. With `minReplicas: 1`, steps 1 and 2 are already done, but steps 3–5 still happen whenever a new replica is created due to scaling out.
Scenario 1: Requests Spike to 10+ Seconds After a Period of Inactivity
What You See
Everything looks fine during load testing, but the next morning after a quiet night, the first user to hit the app gets a timeout or a very slow response. You check your Application Insights or Log Analytics and you see exactly one request with a dramatically higher duration than all the others.
Why This Happens
You have `minReplicas` set to `0` (or it defaults to 0). When there are no replicas running and a new request arrives, the entire cold start sequence kicks off — and the request waits in the ingress queue the entire time. Depending on your image size and application initialization time, this can easily reach 15–30 seconds for a .NET application with a large DI graph, or even longer for a Python application that imports heavy libraries.
The Fix
Option A (Recommended for most workloads): Set `minReplicas` to 1.
This ensures at least one replica is always warm and ready to handle requests. You will pay for that one replica's compute even during idle periods, but you eliminate the cold start for your users:
az containerapp update
--name my-dotnet-api
--resource-group my-rg
--min-replicas 1
--max-replicas 10
Or in your Container App YAML:
scale:
minReplicas: 1
maxReplicas: 10
rules:
- name: http-scaling-rule
http:
metadata:
concurrentRequests: "10"
Option B: Reduce image size to speed up the pull.
Every megabyte in your container image adds time to cold starts. A production .NET API should not be a 2 GB image. Use multi-stage builds to strip away the SDK, test tools, and development dependencies:
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["MyApp.csproj", "."]
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish --no-restore
# Stage 2: Runtime only - much smaller
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
# Run as non-root for security
USER app
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApp.dll"]
For Django, the equivalent pattern is:
FROM python:3.11-slim AS base
# Install only production dependencies
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
&& find /usr/local -name "*.pyc" -delete
&& find /usr/local -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
COPY . .
RUN SECRET_KEY=placeholder python manage.py collectstatic --noinput
USER nobody
EXPOSE 8000
CMD ["gunicorn", "myproject.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2"]
Option C: Use a startup probe to manage the readiness window.
If your app genuinely needs 20–30 seconds to initialize (loading configuration, warming caches, establishing connection pools), configure a startup probe separately from your liveness probe. This gives the container time to start without the liveness probe killing it prematurely:
probes:
- type: Startup
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 12 # 12 * 5s = 60 seconds total window
- type: Liveness
httpGet:
path: /health
port: 8080
periodSeconds: 10
failureThreshold: 3
- type: Readiness
httpGet:
path: /health/ready
port: 8080
periodSeconds: 5
failureThreshold: 3
Scenario 2: New Replicas Lag Behind Traffic Spikes
What You See
Your application handles steady traffic just fine. Then a sudden burst arrives — a marketing email goes out, a scheduled batch job triggers API calls, or a downstream system fires webhooks — and for 30–60 seconds your error rate jumps and your latency spikes. After that window, everything recovers. The scaling logs show new replicas were created, but they came online too late.
Why This Happens
KEDA (the scaling engine behind Container Apps) works reactively. By default, HTTP-based scaling triggers new replicas when concurrent requests exceed the configured threshold. But there is an inherent delay between the moment traffic spikes, the moment KEDA detects it, and the moment a new replica is warm and serving traffic. This window is where your users experience the pain.
Additionally, if your image pull takes a long time (large image, first pull on a new node), the new replica arrives even later. KEDA cannot compensate for slow image pulls.
The Fix
Step 1 — Tune your KEDA scaling rules to trigger earlier.
Rather than waiting until you are already at capacity, configure scaling to trigger with a lower concurrency threshold. If your app can handle 20 concurrent requests comfortably, set the threshold to 10 so new replicas spin up before you are overwhelmed:
scale:
minReplicas: 1
maxReplicas: 20
rules:
- name: http-rule
http:
metadata:
concurrentRequests: "10" # Scale earlier, not at capacity
For Azure Service Bus or Event Hubs-triggered scaling (common in job-style workloads), use a queue length threshold that gives you a buffer:
scale:
minReplicas: 0
maxReplicas: 30
rules:
- name: servicebus-rule
custom:
type: azure-servicebus
metadata:
queueName: my-processing-queue
namespace: my-servicebus-namespace
messageCount: "5" # Scale when queue depth reaches 5, not 100
auth:
- secretRef: servicebus-connection
triggerParameter: connection
Step 2 — Pre-warm your connection pools in .NET.
One of the biggest contributors to new replica slowness is the time spent establishing database connections and other external connections. The first request that hits a new replica bears the cost of opening the connection pool. Configure your connection pool to warm up eagerly at startup:
// In Program.cs, after building the app
if (app.Environment.IsProduction())
{
// Warm up the database connection pool before accepting traffic
using var scope = app.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await dbContext.Database.ExecuteSqlRawAsync("SELECT 1");
}
await app.RunAsync();
Step 3 — Enable HTTP/2 keep-alive and connection reuse.
In .NET applications running behind the Container Apps ingress, configure your HTTP client to use connection pooling properly:
builder.Services.AddHttpClient("downstream-api", client =>
{
client.BaseAddress = new Uri("https://my-downstream-service");
client.DefaultRequestVersion = new Version(2, 0);
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
MaxConnectionsPerServer = 20
});
Scenario 3: Django Startup Is Slow Due to Import Time
What You See
Your Django application takes 8–12 seconds to start even on a warm node. You check the Gunicorn startup logs and see it spending most of that time in Python module imports before it ever processes a request.
Why This Happens
Python's import system is synchronous and single-threaded. When you import `django`, `rest_framework`, `pandas`, `numpy`, or any large library, Python reads and executes every module file in the dependency chain. A Django project with Django REST Framework, Celery, and a few third-party packages can easily spend 5–8 seconds just on imports. Multiply that by the number of Gunicorn workers (each is a separate process that imports everything independently) and startup time balloons.
The Fix
Step 1 — Profile import time to find the worst offenders.
Add this to your Dockerfile's entrypoint or run it manually:
# Run this in a container shell to see which imports take the longest
python -X importtime -c "import django; django.setup()" 2>&1 | sort -k2 -rn | head -20
Step 2 — Use lazy imports for heavy dependencies that are not needed at startup.
Instead of importing everything at the module level, defer imports to the functions that actually need them:
# Instead of this at the top of your file:
import pandas as pd
import numpy as np
# Do this — import only when the function is actually called:
def process_data(data):
import pandas as pd
import numpy as np
df = pd.DataFrame(data)
return df.describe().to_dict()
Step 3 — Reduce Gunicorn worker count for memory-constrained environments.
Having too many workers means too many independent Python processes all importing everything at the same time. For Container Apps with 0.5–1 vCPU, 2 workers is usually the right starting point:
CMD ["gunicorn", "myproject.wsgi:application",
"--bind", "0.0.0.0:8000",
"--workers", "2",
"--worker-class", "gthread",
"--threads", "4",
"--timeout", "120",
"--keep-alive", "5",
"--log-level", "info"]
Step 4 — Consider switching from Gunicorn to Uvicorn for async Django.
If you are on Django 4.x with ASGI support, Uvicorn with async workers can handle significantly more concurrent requests per worker than synchronous Gunicorn workers:
CMD ["uvicorn", "myproject.asgi:application",
"--host", "0.0.0.0",
"--port", "8000",
"--workers", "2",
"--log-level", "info"]
Scenario 4: Resource Limits Are Causing Throttling and Slow Responses
What You See
Your application starts fine and handles light traffic well, but under moderate to heavy load — even well below your max replicas — individual requests become slow and CPU metrics show your replicas running near 100% utilization. You may also see the .NET GC (garbage collector) running very frequently, or Django showing slow database queries that are actually fast queries being delayed because the process has no CPU to run.
Why This Happens
Container Apps defaults to 0.25 vCPU and 0.5 Gi memory if you do not specify resource limits. For a production .NET API or a Django application serving real traffic, this is almost always too little. When a container hits its CPU limit, the container runtime throttles it — the process continues to run but gets less CPU time, making everything slower without any obvious error signal.
The Fix
Step 1 — Measure actual resource usage before guessing.
Query Log Analytics for actual CPU and memory usage to establish a baseline:
ContainerAppSystemLogs_CL
| where ContainerAppName_s == "my-dotnet-api"
| where TimeGenerated > ago(7d)
| summarize
AvgCpuUsage = avg(todouble(CpuUsageNanoCores_d)) / 1000000,
MaxCpuUsage = max(todouble(CpuUsageNanoCores_d)) / 1000000,
AvgMemoryMB = avg(todouble(MemoryWorkingSetBytes_d)) / 1048576,
MaxMemoryMB = max(todouble(MemoryWorkingSetBytes_d)) / 1048576
by bin(TimeGenerated, 1h)
| order by TimeGenerated desc
Step 2 — Update resource allocations based on what you observed.
az containerapp update
--name my-dotnet-api
--resource-group my-rg
--cpu 0.5
--memory 1.0Gi
Container Apps has specific valid CPU/memory combinations. The valid pairs are: `0.25 vCPU / 0.5 Gi`, `0.5 vCPU / 1.0 Gi`, `0.75 vCPU / 1.5 Gi`, `1.0 vCPU / 2.0 Gi`, and up to `4.0 vCPU / 8.0 Gi`. You cannot mix arbitrary values.
Step 3 — Configure .NET GC for server workloads.
By default, .NET uses the workstation GC mode which is tuned for interactive applications. For server containers, use server GC mode and configure the heap size appropriately:
// In runtimeconfig.template.json or via environment variables
{
"configProperties": {
"System.GC.Server": true,
"System.GC.HeapHardLimit": 805306368,
"System.GC.HighMemoryPercent": 75
}
}
Or as environment variables in your Container App:
az containerapp update
--name my-dotnet-api
--resource-group my-rg
--set-env-vars
"DOTNET_GCConserveMemory=5"
"DOTNET_GCHeapHardLimit=805306368"
Measuring the Impact of Your Changes
After making changes, use this Log Analytics query to track your startup times over the past 24 hours and confirm the improvements:
ContainerAppConsoleLogs_CL
| where ContainerAppName_s == "my-dotnet-api"
| where Log_s contains "Application started" or Log_s contains "Now listening on"
| project TimeGenerated, Log_s, ContainerName_s
| order by TimeGenerated desc
And check request duration percentiles in Application Insights:
requests
| where cloud_RoleName == "my-dotnet-api"
| where timestamp > ago(24h)
| summarize
p50 = percentile(duration, 50),
p90 = percentile(duration, 90),
p99 = percentile(duration, 99),
count = count()
by bin(timestamp, 1h)
| order by timestamp desc
Summary: Your Performance Tuning Quick Reference
Here is a quick decision guide based on what you are seeing:
| Symptom | Most Likely Cause | First Fix to Try |
| First request after idle is very slow | `minReplicas: 0` | Set `minReplicas: 1` |
| Spike period has errors, then recovers | KEDA scaling too slow | Lower concurrentRequests threshold |
| New replicas start slowly | Large image size | Multi-stage Docker build |
| High CPU at moderate traffic | Under-allocated resources | Increase CPU/memory allocation |
| Django startup is slow | Heavy Python imports | Profile and defer imports |
| .NET app slow under load | Workstation GC mode | Enable server GC |
References and Sample Resources
Use these links to tune startup performance, scaling behavior, and runtime efficiency.
Azure Container Apps docs (core)
- Scale applications in Azure Container Apps
- Workload profiles overview
- Health probes in Azure Container Apps
- Revisions in Azure Container Apps
- Monitoring and logging in Azure Container Apps
Runtime and framework performance references
- Docker multi-stage builds
- .NET performance best practices for ASP.NET Core
- .NET runtime GC configuration
- Django performance optimization
- Uvicorn deployment guide
Scaling engine references and samples
- KEDA concepts and documentation
- KEDA scaler samples
- Azure Samples: .NET on Azure Container Apps
- Azure Samples: Python on Azure Container Apps
What's Next
In Part 3, we go deeper into the most specialized and complex scenario in this series: troubleshooting AI workloads in Azure Container Apps. Loading large ML models, managing GPU and CPU resource constraints, and dealing with memory pressure from inference workloads all require techniques that go beyond standard web application troubleshooting.
Part of the series: Troubleshooting Azure Container Apps in Production
Next: Part 3 — Troubleshooting ML Model Loading, GPU Issues, and Memory Pressure in Azure Container Apps