When to Use Containers over Lambda
Lambda is great for event-driven, short-lived work. But some .NET workloads are better served by containers:
- Long-running processes (background workers, queue consumers that hold connections open)
- Large memory/CPU requirements (Lambda caps at 10GB RAM, 6 vCPU)
- Existing ASP.NET Core applications that you want to lift to AWS without refactoring
- WebSocket connections or long-lived HTTP streams
- Workloads with predictable, steady traffic where Lambda's per-invocation pricing is more expensive than always-on compute
ECS vs EKS
| ECS/Fargate | EKS | |
|---|---|---|
| Complexity | Medium | High |
| Control | Good | Full |
| Cost at scale | Good | Best (with Karpenter) |
| Best for | Most .NET apps | Kubernetes shops |
| .NET fit | Excellent | Good |
For most .NET teams, ECS with Fargate is the sweet spot: no cluster management, no nodes to patch, good integration with ALB and service discovery.
Dockerfile for .NET
Standard multi-stage build
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Restore first (layer caching)
COPY ["src/MyApi/MyApi.csproj", "src/MyApi/"]
COPY ["src/MyDomain/MyDomain.csproj", "src/MyDomain/"]
RUN dotnet restore "src/MyApi/MyApi.csproj"
# Build
COPY . .
RUN dotnet publish "src/MyApi/MyApi.csproj" -c Release -o /app/publish --no-restore
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime
WORKDIR /app
EXPOSE 8080
# Non-root user
USER $APP_UID
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "MyApi.dll"]
AoT container (smallest possible image)
# Build stage with full SDK
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
RUN apt-get update && apt-get install -y clang zlib1g-dev
WORKDIR /src
COPY . .
RUN dotnet publish "src/MyApi/MyApi.csproj" -c Release -r linux-x64 --self-contained -o /app/publish
# Runtime stage β minimal base image
FROM mcr.microsoft.com/dotnet/runtime-deps:8.0-alpine AS runtime
WORKDIR /app
EXPOSE 8080
USER $APP_UID
COPY --from=build /app/publish/MyApi .
ENTRYPOINT ["./MyApi"]
The AoT image uses runtime-deps (no .NET runtime needed) and can be as small as 30-50MB.
Health Checks
ECS needs health checks to know when your container is ready and when to replace unhealthy instances:
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks()
.AddCheck("db", () =>
{
// Check database connectivity
return HealthCheckResult.Healthy();
})
.AddCheck("cache", () =>
{
// Check Redis/Valkey
return HealthCheckResult.Healthy();
});
var app = builder.Build();
app.MapHealthChecks("/health");
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
});
app.Run();
Configure the port: ECS Fargate doesn't use port 80 by default for non-root users:
// Program.cs or appsettings.json
builder.WebHost.UseUrls("http://+:8080");
Service Discovery
For microservices that need to find each other without going through an ALB:
// Use AWS Cloud Map for service discovery
// Services register automatically, others resolve via DNS
// e.g., order-service.my-namespace β private IP of the task
CDK Setup (C#)
Fargate service with ALB
using Amazon.CDK;
using Amazon.CDK.AWS.ECS;
using Amazon.CDK.AWS.ECS.Patterns;
using Amazon.CDK.AWS.EC2;
var cluster = new Cluster(this, "AppCluster", new ClusterProps
{
Vpc = vpc,
ContainerInsights = true,
});
// ALB-backed Fargate service
var service = new ApplicationLoadBalancedFargateService(this, "OrderApi",
new ApplicationLoadBalancedFargateServiceProps
{
Cluster = cluster,
DesiredCount = 2,
TaskImageOptions = new ApplicationLoadBalancedTaskImageOptions
{
Image = ContainerImage.FromAsset("./src/OrderApi"),
ContainerPort = 8080,
Environment = new Dictionary<string, string>
{
["ASPNETCORE_URLS"] = "http://+:8080",
["TABLE_NAME"] = table.TableName,
},
},
Cpu = 512,
MemoryLimitMiB = 1024,
RuntimePlatform = new RuntimePlatform
{
CpuArchitecture = CpuArchitecture.ARM64,
OperatingSystemFamily = OperatingSystemFamily.LINUX,
},
HealthCheck = new Amazon.CDK.AWS.ECS.HealthCheck
{
Command = new[] { "CMD-SHELL", "curl -f http://localhost:8080/health || exit 1" },
Interval = Duration.Seconds(30),
Timeout = Duration.Seconds(5),
Retries = 3,
},
CircuitBreaker = new DeploymentCircuitBreaker { Rollback = true },
});
// Auto-scaling
var scaling = service.Service.AutoScaleTaskCount(new EnableScalingProps
{
MinCapacity = 2,
MaxCapacity = 10,
});
scaling.ScaleOnCpuUtilization("CpuScaling", new CpuUtilizationScalingProps
{
TargetUtilizationPercent = 70,
ScaleInCooldown = Duration.Seconds(60),
ScaleOutCooldown = Duration.Seconds(60),
});
// Grant DynamoDB access
table.GrantReadWriteData(service.TaskDefinition.TaskRole);
Background worker (no ALB)
var workerService = new QueueProcessingFargateService(this, "OrderWorker",
new QueueProcessingFargateServiceProps
{
Cluster = cluster,
Queue = orderQueue,
Image = ContainerImage.FromAsset("./src/OrderWorker"),
Cpu = 256,
MemoryLimitMiB = 512,
MinScalingCapacity = 1,
MaxScalingCapacity = 5,
Environment = new Dictionary<string, string>
{
["TABLE_NAME"] = table.TableName,
},
});
Graceful Shutdown
ECS sends SIGTERM before stopping a task. Handle it properly:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
lifetime.ApplicationStopping.Register(() =>
{
// Finish in-flight requests, close connections
Log.Information("Shutting down gracefully...");
// Give ALB time to drain connections
Thread.Sleep(TimeSpan.FromSeconds(10));
});
app.Run();
Set the ECS task stop timeout to give your app time to drain:
TaskDefinition = new FargateTaskDefinition(this, "TaskDef", new FargateTaskDefinitionProps
{
// ...
});
// Default stop timeout is 30 seconds β sufficient for most apps
Tips
- Use arm64 (Graviton) tasks: 20% cheaper than x86, same or better performance for .NET.
- Always use multi-stage Docker builds. The SDK image is 800MB+. The runtime image is ~100MB. Alpine variants are smaller still.
- Health check endpoints should be fast. Don't do deep dependency checks on the liveness probe. Just verify the process is responding. Use readiness probes for dependency checks.
- Set proper resource limits. A .NET API typically runs fine at 512 CPU / 1024MB. Don't over-provision. You're paying for it.
- Use deployment circuit breaker. If new tasks fail health checks, ECS automatically rolls back to the last working version.
- ECR for container images. Store your images in ECR (same region as ECS) for fastest pulls and no cross-region transfer costs.
Further Reading
Looking for hands-on help? View my .NET on AWS services β