Lambda Managed Instances for .NET
Lambda Managed Instances (LMI) let you run Lambda functions on EC2 instances while AWS manages everything. But you get EC2 pricing (including Savings Plans for up to 72% off) and multi-concurrency (multiple requests per execution environment).
For .NET teams, this is particularly compelling:
- No cold starts: pre-provisioned environments eliminate the .NET startup penalty entirely
- Multi-concurrency works naturally with ASP.NET-style request handling (you're already used to thread-safe code)
- Savings Plans apply: the same compute discounts you use for ECS now cover your Lambda functions
- Up to 32GB memory / 16 vCPUs: well beyond standard Lambda's 10GB limit
- .NET supported from day one: managed runtime, no need for AoT (though you can still use it)
When LMI Makes Sense for .NET
The .NET cold start problem is what drives many teams to either AoT (complex) or provisioned concurrency (expensive). LMI eliminates the tradeoff:
| Approach | Cold start | Cost model | Complexity |
|---|---|---|---|
| Standard Lambda (.NET managed) | 1.5-3s | Per-invocation | Low |
| Standard Lambda (AoT) | 200-400ms | Per-invocation | High (no reflection) |
| Provisioned concurrency | 0ms | Per-invocation + provisioned cost | Low |
| Lambda Managed Instances | 0ms | EC2 + 15% fee | Low (thread safety) |
If you have sustained traffic (not spiky/event-driven), LMI gives you zero cold starts AND lower cost. You don't need to adopt AoT just for startup performance.
Thread Safety for Multi-Concurrency
The main code change: your handler runs concurrently. Multiple requests execute in the same process simultaneously. If you're coming from ASP.NET Core, this is exactly how you already write code. If you're coming from single-request Lambda handlers, audit for:
Safe patterns (already thread-safe)
// AWS SDK clients are thread-safe β share them
private static readonly IAmazonDynamoDB _dynamoClient = new AmazonDynamoDBClient();
private static readonly IAmazonS3 _s3Client = new AmazonS3Client();
// Immutable configuration
private static readonly AppConfig _config = LoadConfig();
// Using ConcurrentDictionary for shared caches
private static readonly ConcurrentDictionary<string, CachedItem> _cache = new();
Unsafe patterns (need fixing)
// BAD: Writing to shared file path from multiple requests
File.WriteAllText("/tmp/output.json", result); // Multiple requests overwrite each other
// GOOD: Use request-unique paths
var path = $"/tmp/{Guid.NewGuid()}.json";
File.WriteAllText(path, result);
// BAD: Shared mutable state without synchronization
private static int _requestCount = 0;
_requestCount++; // Race condition
// GOOD: Use Interlocked or thread-safe types
private static int _requestCount = 0;
Interlocked.Increment(ref _requestCount);
// BAD: Reusing a non-thread-safe HttpClient instance improperly
private static HttpClient _client = new();
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", userToken);
// Another request's token gets used!
// GOOD: Don't modify shared client state per-request
private static readonly HttpClient _client = new();
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", userToken);
await _client.SendAsync(request);
DI with multi-concurrency
If you use Microsoft.Extensions.DependencyInjection, scoped services work correctly. Each request gets its own scope. Singletons are shared (and must be thread-safe):
var services = new ServiceCollection();
services.AddSingleton<IAmazonDynamoDB, AmazonDynamoDBClient>(); // Thread-safe, shared
services.AddScoped<IOrderService, OrderService>(); // Per-request instance
services.AddScoped<RequestContext>(); // Per-request state
Handler Pattern
Your handler code doesn't change structurally. It's the same event handler:
public class Function
{
private static readonly IAmazonDynamoDB _dynamoClient = new AmazonDynamoDBClient();
private static readonly OrderRepository _repository = new(_dynamoClient);
public async Task<APIGatewayProxyResponse> Handler(
APIGatewayProxyRequest request, ILambdaContext context)
{
// This now executes concurrently with other requests
// No changes needed if your code is already thread-safe
var orderId = request.PathParameters["id"];
var order = await _repository.GetOrderAsync(orderId);
return new APIGatewayProxyResponse
{
StatusCode = 200,
Body = JsonSerializer.Serialize(order, AppJsonContext.Default.Order),
};
}
}
Do You Still Need AoT?
Maybe not. The reasons to adopt AoT on standard Lambda are:
- Cold start reduction (AoT: 200-400ms vs managed: 1.5-3s)
- Lower memory usage
With LMI, cold starts are eliminated (environments are pre-provisioned). So AoT's primary benefit disappears. You can use the full managed .NET runtime with reflection, DI, EF Core, and all the libraries AoT breaks. While still getting zero cold starts.
When AoT still helps on LMI:
- Lower memory usage means you can fit more in a smaller instance
- Faster execution speed (AoT is slightly faster per-request)
- But the complexity tradeoff is no longer forced by cold starts
CDK Setup (C#)
using Amazon.CDK;
using Amazon.CDK.AWS.Lambda;
using Amazon.CDK.AWS.EC2;
// Create capacity provider
var capacityProvider = new CfnCapacityProvider(this, "AppCapacity", new CfnCapacityProviderProps
{
VpcConfig = new CfnCapacityProvider.VpcConfigProperty
{
SubnetIds = vpc.SelectSubnets(new SubnetSelection
{
SubnetType = SubnetType.PRIVATE_WITH_EGRESS,
}).SubnetIds,
SecurityGroupIds = new[] { appSg.SecurityGroupId },
},
// Use Graviton4 for best price/performance
InstanceTypes = new[] { "c8g.large", "c8g.xlarge", "m8g.large" },
ScalingConfig = new CfnCapacityProvider.ScalingConfigProperty
{
MaxVcpus = 32,
ScalingPolicy = "AUTO",
},
});
// .NET function on LMI
var orderApi = new Function(this, "OrderApi", new FunctionProps
{
Runtime = Runtime.DOTNET_8,
Architecture = Architecture.ARM_64,
Handler = "OrderApi::OrderApi.Function::Handler",
Code = Code.FromAsset("./src/OrderApi/publish"),
MemorySize = 1024,
// Attach to capacity provider
CapacityProviderArn = capacityProvider.AttrArn,
});
// Permissions work exactly the same
table.GrantReadWriteData(orderApi);
Migration from Standard Lambda
- Audit thread safety. The biggest task. Review shared state, file I/O, and HTTP client usage.
- Create a capacity provider. Define VPC, instance types, scaling limits.
- Attach your function. Point it at the capacity provider.
- Test under concurrency. Use load testing to verify thread safety.
- Apply Savings Plans. Commit to the sustained compute baseline for maximum savings.
No code changes required if your handler is already thread-safe (which it likely is if you're using async/await and DI properly).
Cost Example
A .NET API handling 50M requests/month, 150ms average duration, 1GB memory:
| Standard Lambda | Lambda Managed Instances (with Savings Plan) | |
|---|---|---|
| Requests | $10 | $10 |
| Compute | $125 | ~$25-40 (72% SP discount) |
| Management fee | : | ~$5-8 (15% of EC2) |
| Monthly total | ~$135 | ~$40-58 |
The savings grow with traffic volume and Savings Plans commitment depth.
Further Reading
Related Blog Posts
Looking for hands-on help? View my .NET on AWS services β