.net / containers

Running .NET 5 in Linux containers—and measuring its memory

Running .NET 5 in Linux containers—and measuring its memory

For a new ASP.NET Core service with portable dependencies, Linux is my starting point. Kestrel runs the application, configuration comes from the environment, and Docker packages the result. IIS and a Windows guest are not prerequisites for serving an HTTP request written in C#.

The interesting question comes after the first successful request: what does this service actually need to run? A small download is useful, but it does not tell me how many replicas fit on a host. A quiet process can look cheap until traffic builds a cache or starts several expensive requests together.

Here is a complete .NET 5 example, followed by the measurements I would use before choosing its memory limit. The build uses SDK 5.0.301 and ASP.NET Core 5.0.7, both available in the June servicing release.

A service small enough to understand

In an empty Probe directory, create Probe.csproj:

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <LangVersion>9.0</LangVersion>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>

Create Program.cs with the .NET 5 host and startup model:

using System;
using System.Diagnostics;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Hosting;

Host.CreateDefaultBuilder(args)
    .ConfigureWebHostDefaults(web => web.UseStartup<Startup>())
    .Build()
    .Run();

public sealed class Startup
{
    public void Configure(IApplicationBuilder app)
    {
        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapGet("/health", context =>
                context.Response.WriteAsync("ok"));

            endpoints.MapGet("/work", async context =>
            {
                var payload = new byte[64 * 1024];
                Array.Fill(payload, (byte)42);
                context.Response.ContentType = "application/octet-stream";
                await context.Response.Body.WriteAsync(
                    payload, 0, payload.Length, context.RequestAborted);
            });

            endpoints.MapGet("/memory", async context =>
            {
                using var process = Process.GetCurrentProcess();
                await context.Response.WriteAsJsonAsync(new
                {
                    managedBytes = GC.GetTotalMemory(false),
                    workingSetBytes = process.WorkingSet64,
                    allocatedBytes = GC.GetTotalAllocatedBytes(),
                    gen0Collections = GC.CollectionCount(0),
                    gen2Collections = GC.CollectionCount(2)
                });
            });
        });
    }
}

The work endpoint allocates and sends a 64 KiB buffer on each request. It exists to give our experiment repeatable activity, not to represent a useful business endpoint. The diagnostic response exposes process information; keep this sample on a local test port, rather than publishing that endpoint as an unauthenticated production feature.

Keep the build tools out of the final image

Create this Dockerfile beside the project:

FROM mcr.microsoft.com/dotnet/sdk:5.0.301-buster-slim AS build
WORKDIR /src
COPY Probe.csproj ./
RUN dotnet restore Probe.csproj
COPY Program.cs ./
RUN dotnet publish Probe.csproj -c Release -o /out --no-restore /p:UseAppHost=false

FROM mcr.microsoft.com/dotnet/aspnet:5.0.7-buster-slim AS final
RUN groupadd --gid 10001 app \
    && useradd --uid 10001 --gid app --no-create-home --shell /usr/sbin/nologin app
WORKDIR /app
COPY --from=build /out ./
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
USER 10001:10001
EXPOSE 8080
ENTRYPOINT ["dotnet", "Probe.dll"]

The historical image manifest and version definitions identify these Debian Buster images. Explicit versions make the example reviewable. Tags can still be rebuilt for base-image updates; record the resolved digest when reproducing an experiment exactly.

The SDK stage restores and compiles. The ASP.NET image contains the runtime needed by this web application. A plain runtime image suits a non-web framework-dependent application; it does not contain the ASP.NET shared framework. runtime-deps is a different choice again, intended to supply native dependencies for a self-contained deployment.

I am deliberately starting with Debian rather than chasing the smallest Alpine image. Alpine's musl environment changes the native compatibility question. Native libraries and globalization requirements need checking before an image-size saving becomes a sensible trade.

The application user is created explicitly. The final process can read the published files but does not own them, and it listens on port 8080. EXPOSE documents that port; the environment variable tells Kestrel where to listen. Publishing the port is a separate Docker setting.

For a larger project I would add the other source files to the copy steps and use a .dockerignore excluding .git, bin, and obj. This tiny build copies exactly the two files it needs, so host-generated binaries cannot accidentally enter the image.

Run it with explicit resources

On a Linux Docker host, build and start it:

docker build -t probe:net5 .
docker run -d --name probe \
  --memory=256m --memory-swap=256m --cpus=1 \
  -p 127.0.0.1:8080:8080 probe:net5
curl --fail http://127.0.0.1:8080/health
docker exec probe id

The last command should show UID and GID 10001. Equal memory and total memory-plus-swap limits disable container swap where the host supports that accounting. This makes a memory-pressure experiment easier to interpret than quietly letting the service page its way through the test.

The 256 MiB limit is an experiment setting, not a capacity claim. CPU is also constrained because available processing time affects how quickly requests and garbage collections finish. On a Mac or Windows development machine, Linux containers run inside a Linux VM; the VM's own budget is another layer to record when comparing results.

Environment variables work for ordinary application settings too: a nested configuration key uses double underscores in its environment-variable form. Secrets belong in the deployment system's secret mechanism, not a Dockerfile layer. Log to standard output so the hosting system can collect the output.

Ask which memory number you are looking at

There are several useful measurements here, and none substitutes for all the others:

  • Image size describes filesystem layers. Compressed transfer size and local uncompressed size differ, and layers can be shared across images.
  • Working set describes resident process memory. It includes more than managed objects and is not the container's full accounting boundary.
  • Managed memory from GC.GetTotalMemory(false) estimates allocated managed bytes without forcing a collection. It is not a reserved-heap or whole-process measurement, and some unreachable objects may await collection.
  • Allocated bytes accumulate over the process lifetime. Their change over an interval describes allocation activity, not retained memory.
  • Container memory includes charges at the cgroup level. The Docker CLI applies cache-accounting adjustments, so its display need not equal a raw cgroup counter or the process working set.

That last distinction is documented in Docker's stats reference. Use the same collection method on both sides of a comparison, and preserve the host and Docker version with the result.

A repeatable first experiment

Record the image identity and a baseline after the service answers:

docker image inspect probe:net5 --format '{{.Id}} {{.Size}}'
docker stats --no-stream probe
curl --fail http://127.0.0.1:8080/memory

In a second terminal leave docker stats probe running. Warm the work endpoint, then apply a bounded load from the first terminal:

for i in $(seq 1 100); do
  curl --fail --silent --show-error http://127.0.0.1:8080/work -o /dev/null || break
done

curl --fail http://127.0.0.1:8080/memory
seq 1 1000 | xargs -P 8 -I '{}' \
  curl --fail --silent --show-error http://127.0.0.1:8080/work -o /dev/null
curl --fail http://127.0.0.1:8080/memory
docker stats --no-stream probe
docker inspect probe --format '{{.State.OOMKilled}} {{.State.ExitCode}}'

This launches up to eight concurrent curl processes. It is an allocation exercise, not a throughput benchmark: starting curl repeatedly has its own cost, and the driver can become the bottleneck. Keep the request count, concurrency, payload, CPU limit, and host the same between runs. Check the driver's exit status and server logs rather than counting failed requests as completed work.

After traffic stops, sample again following a fixed quiet interval. Repeat from a fresh container several times. Keep the peak during traffic as well as the quiet value; a snapshot after the work can miss the number that actually determines whether a container survives.

For a production decision, repeat with the real route mix, realistic data, and latency measurements. Our toy endpoint has no database pool, TLS work, application cache, or downstream queue. It cannot establish a universal “.NET uses this much RAM” figure.

Give the GC room, then investigate the application

Container awareness predates .NET 5. Microsoft's container runtime explanation describes the resource-limit work introduced with .NET Core 3.0. The GC responds to container constraints, but it cannot make an unbounded cache fit inside a bounded process.

The GC heap hard-limit discussion also explains why heap and process limits differ. Threads, native libraries, generated code, and runtime bookkeeping need memory too. Giving the managed heap the entire container budget would leave no room for them.

I would first look for retained objects, excessive buffering, and work arriving faster than it completes. Then I would compare GC settings only if measurements suggest a reason. Workstation GC can be worth investigating for a small service, but a quieter memory graph is not a win if latency or throughput becomes unacceptable. Forcing collections in request handlers would also distort the experiment rather than solve its cause.

When the test is finished, docker rm -f probe removes this sample container. For deployment, my useful result is an image digest, a repeatable workload, observed peaks and latency, and a limit with room for variation. “The image is small” is useful packaging information. It is only the beginning of the hosting decision.

Deyan Peev

Written by

Deyan Peev

Founding Engineer · Sofia, Bulgaria

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

Elsewhere

© 2026 Deyan Peev