Running .NET 8 web applications in Docker

2024/02/17

#.NET #docker #devops

Table of contents

Airplane Container

There are several compelling reasons to run a .NET 8 web application in Docker:

Portability and Consistency: Docker creates standardized environments, ensuring your application runs the same way regardless of the underlying operating system. This simplifies deployment across different platforms, cloud providers, and development machines. It is easy to target both mainstream CPU architectures of our times (ARM and X86).

Isolation and Resource Management: Each container runs in a self-contained space, preventing conflicts with other applications and ensuring each receives the necessary resources. This improves reliability and security.

Simplified Development and DevOps: Docker streamlines the development workflow by providing a consistent environment for developers and testers. Additionally, containerized .NET applications integrate seamlessly with DevOps tools and CI/CD pipelines, automating deployment and management.

Creating a simple web app

I would like to use Linux for this because we are in the process of migrating from Windows to Linux at work and I wanted to have some exposure how hard is to set up the development environment.

  • First step is to install the Micrososft repo that has the dotnet SDK. I use Debian for most Linux installations and the following works on Bookworm (x86). The special thing about my setup is the use of doas instead of sudo.
wget https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb -O packages-microsoft-prod.deb
doas apt install ./packages-microsoft-prod.deb
rm packages-microsoft-prod.deb
doas apt update
doas apt install dotnet-sdk-8.0 -y

Dotnet version can be verfied the following way:

l1x@deby ~> dotnet --version
8.0.200

Great, we have the dotnet version we need.

  • Creating a web application

I opt out from the Microsoft data collection and try to figure out how to create a new project skeleton for a simple api.

l1x@deby ~> export DOTNET_CLI_TELEMETRY_OPTOUT=1
l1x@deby ~> dotnet new
The 'dotnet new' command creates a .NET project based on a template.

Common templates are:
Template Name   Short Name  Language    Tags
--------------  ----------  ----------  ----------------------
Blazor Web App  blazor      [C#]        Web/Blazor/WebAssembly
Class Library   classlib    [C#],F#,VB  Common/Library
Console App     console     [C#],F#,VB  Common/Console

An example would be:
   dotnet new console

Display template options with:
   dotnet new console -h
Display all installed templates with:
   dotnet new list
Display templates available on NuGet.org with:
   dotnet new search web

As it turns out creating a simple web api is really not that hard.

l1x@deby ~/c/dotnet-test> dotnet new webapi
The template "ASP.NET Core Web API" was created successfully.

Processing post-creation actions...
Restoring /home/l1x/code/dotnet-test/dotnet-test.csproj:
  Determining projects to restore...
  Restored /home/l1x/code/dotnet-test/dotnet-test.csproj (in 21.68 sec).
Restore succeeded.

The web application code is straightforward. The only thing I have added is the compression.

using System.IO.Compression;
using Microsoft.AspNetCore.ResponseCompression;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

builder.Services.AddResponseCompression(options => {
  options.EnableForHttps = true;
  options.Providers.Add < BrotliCompressionProvider > ();
  options.Providers.Add < GzipCompressionProvider > ();
});

var app = builder.Build();

// if (app.Environment.IsDevelopment()) {
app.UseSwagger();
app.UseSwaggerUI();
// }

app.UseResponseCompression();
app.UseHttpsRedirection();

var summaries = new [] {
  "Freezing",
  "Bracing",
  "Chilly",
  "Cool",
  "Mild",
  "Warm",
  "Balmy",
  "Hot",
  "Sweltering",
  "Scorching"
};

app.MapGet("/weatherforecast", () => {
    var forecast = Enumerable.Range(1, 5).Select(index =>
        new WeatherForecast(
          DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
          Random.Shared.Next(-20, 55),
          summaries[Random.Shared.Next(summaries.Length)]
        ))
      .ToArray();
    return forecast;
  })
  .WithName("GetWeatherForecast")
  .WithOpenApi();

app.Run();

record WeatherForecast(DateOnly Date, int TemperatureC, string ? Summary) {
  public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

Creating Dockerfiles

I am assuming that docker is already installed on the system. If not the following packages should be installed:

containerd.io/bookworm,now 1.6.28-1 amd64 [installed,automatic]
docker-ce-cli/bookworm,now 5:25.0.3-1~debian.12~bookworm amd64 [installed]
docker-ce/bookworm,now 5:25.0.3-1~debian.12~bookworm amd64 [installed]

The first docker file is using the defaut images and stages for the build:

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env
WORKDIR /app
COPY . ./
RUN dotnet restore
RUN dotnet publish -c Release -o out

FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build-env /app/out .
ENTRYPOINT ["dotnet", "dotnet-test.dll"]

Using this:

l1x@deby ~/c/dotnet-test (main)> docker build -t dotnet-test:default .
[+] Building 19.6s (14/14) FINISHED                                                                                                                                 docker:default
 => [internal] load build definition from Dockerfile                                                                                                                          0.2s
 => => transferring dockerfile: 300B                                                                                                                                          0.0s
 => [internal] load metadata for mcr.microsoft.com/dotnet/aspnet:8.0                                                                                                          0.0s
 => [internal] load metadata for mcr.microsoft.com/dotnet/sdk:8.0                                                                                                             0.0s
 => [internal] load .dockerignore                                                                                                                                             0.2s
 => => transferring context: 2B                                                                                                                                               0.0s
 => [build-env 1/5] FROM mcr.microsoft.com/dotnet/sdk:8.0                                                                                                                     1.4s
 => [stage-1 1/3] FROM mcr.microsoft.com/dotnet/aspnet:8.0                                                                                                                    0.8s
 => [internal] load build context                                                                                                                                             0.7s
 => => transferring context: 12.23MB                                                                                                                                          0.2s
 => [stage-1 2/3] WORKDIR /app                                                                                                                                                0.5s
 => [build-env 2/5] WORKDIR /app                                                                                                                                              0.2s
 => [build-env 3/5] COPY . ./                                                                                                                                                 0.2s
 => [build-env 4/5] RUN dotnet restore                                                                                                                                        8.3s
 => [build-env 5/5] RUN dotnet publish -c Release -o out                                                                                                                      8.1s
 => [stage-1 3/3] COPY --from=build-env /app/out .                                                                                                                            0.3s
 => exporting to image                                                                                                                                                        0.3s
 => => exporting layers                                                                                                                                                       0.3s
 => => writing image sha256:265648afb3c8fecba0a1c22ad196b552096819eeccf4444d0ec0f7adec678b31                                                                                  0.0s
 => => naming to docker.io/library/dotnet-test:default                                                                                                                        0.0s

We have the new buildx in docker but it seems the dangling images are still a thing. I guess these are required for caching.

l1x@deby ~/c/dotnet-test (main)> docker images
REPOSITORY                        TAG                  IMAGE ID       CREATED          SIZE
dotnet-test                       default              265648afb3c8   28 seconds ago   221MB
<none>                            <none>               cf116155bb30   2 minutes ago    221MB

Having a 221MB container is a not that bad.

Cleaning up the dangling images is easy:

docker rmi $(docker images -f "dangling=true" -q)

There are three versions of base images that I would like to use:

  • Debian (this is the default)
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env
WORKDIR /app
COPY . ./
RUN dotnet restore
RUN dotnet publish -c Release -o out

FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim
WORKDIR /app
COPY --from=build-env /app/out .
ENTRYPOINT ["dotnet", "dotnet-test.dll"]
  • Alpine 3.18
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env
WORKDIR /app
COPY . ./
RUN dotnet restore
RUN dotnet publish -c Release -o out

FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.18
WORKDIR /app
COPY --from=build-env /app/out .
ENTRYPOINT ["dotnet", "dotnet-test.dll"]
  • Alpine 3.19
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env
WORKDIR /app
COPY . ./
RUN dotnet restore
RUN dotnet publish -c Release -o out

FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.19
WORKDIR /app
COPY --from=build-env /app/out .
ENTRYPOINT ["dotnet", "dotnet-test.dll"]

I would like to build all of these at one. For the CI/CD there are going to be separate steps.

cat build.all.sh
#!/usr/bin/env bash
docker build -t dotnet-test:net8.alpine3.18 -f Dockerfile.net8.alpine3.18 .
docker build -t dotnet-test:net8.alpine3.19 -f Dockerfile.net8.alpine3.19 .
docker build -t dotnet-test:net8.bookworm-slim -f Dockerfile.net8.bookworm-slim .
docker images
REPOSITORY                        TAG                  IMAGE ID       CREATED          SIZE
dotnet-test                       net8.alpine3.19      8730308faf31   54 seconds ago   111MB
dotnet-test                       net8.alpine3.18      694a028c16e6   55 seconds ago   110MB
dotnet-test                       default              265648afb3c8   9 minutes ago    221MB
dotnet-test                       net8.bookworm-slim   265648afb3c8   9 minutes ago    221MB
mcr.microsoft.com/dotnet/sdk      8.0                  54ed1faefb92   3 days ago       866MB
mcr.microsoft.com/dotnet/aspnet   8.0                  6eedb7553b12   6 days ago       217MB
mcr.microsoft.com/dotnet/aspnet   8.0-bookworm-slim    6eedb7553b12   6 days ago       217MB
mcr.microsoft.com/dotnet/aspnet   8.0-alpine3.18       20dce08103c1   6 days ago       106MB
mcr.microsoft.com/dotnet/aspnet   8.0-alpine3.19       baa6902e9e30   6 days ago       107MB

It is time for a little performance check.

Simple performance check

Using docker run with simple port mapping is the easiest to expose the service port and run the performance test. For this test I use a new Gravitron box (c7g.2xlarge, 8 CPU, 16G RAM) on AWS.

[ec2-user@ip-10-20-1-235 ~]$ docker run -p 8000:8000 -ti dotnet-test:net8.alpine3.19
warn: Microsoft.AspNetCore.Hosting.Diagnostics[15]
      Overriding HTTP_PORTS '8080' and HTTPS_PORTS ''. Binding to values defined by URLS instead 'http://0.0.0.0:8000'.
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://0.0.0.0:8000
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /app

Results

  • Alpine 3.19
[ec2-user@ip-10-20-1-235 ~]$ drill -q --benchmark benchmark.yml --stats
Concurrency 3000
Iterations 500000
Rampup 2
Base URL http://localhost:8000

Fetch weather             Total requests            500000
Fetch weather             Successful requests       500000
Fetch weather             Failed requests           0
Fetch weather             Median time per request   23ms
Fetch weather             Average time per request  25ms
Fetch weather             Sample standard deviation 22ms
Fetch weather             99.0'th percentile        115ms
Fetch weather             99.5'th percentile        134ms
Fetch weather             99.9'th percentile        424ms

Time taken for tests      11.2 seconds
Total requests            500000
Successful requests       500000
Failed requests           0
Requests per second       44550.11 [#/sec]
Median time per request   23ms
Average time per request  25ms
Sample standard deviation 22ms
99.0'th percentile        115ms
99.5'th percentile        134ms
99.9'th percentile        424ms
  • Alpine 3.18
[ec2-user@ip-10-20-1-235 ~]$ drill -q --benchmark benchmark.yml --stats
Concurrency 3000
Iterations 500000
Rampup 2
Base URL http://localhost:8000

Fetch weather             Total requests            500000
Fetch weather             Successful requests       500000
Fetch weather             Failed requests           0
Fetch weather             Median time per request   24ms
Fetch weather             Average time per request  25ms
Fetch weather             Sample standard deviation 23ms
Fetch weather             99.0'th percentile        115ms
Fetch weather             99.5'th percentile        132ms
Fetch weather             99.9'th percentile        420ms

Time taken for tests      11.3 seconds
Total requests            500000
Successful requests       500000
Failed requests           0
Requests per second       44315.59 [#/sec]
Median time per request   24ms
Average time per request  25ms
Sample standard deviation 23ms
99.0'th percentile        115ms
99.5'th percentile        132ms
99.9'th percentile        420ms
  • Bookworm
[ec2-user@ip-10-20-1-235 ~]$ drill -q --benchmark benchmark.yml --stats
Concurrency 3000
Iterations 500000
Rampup 2
Base URL http://localhost:8000


Fetch weather             Total requests            500000
Fetch weather             Successful requests       500000
Fetch weather             Failed requests           0
Fetch weather             Median time per request   23ms
Fetch weather             Average time per request  25ms
Fetch weather             Sample standard deviation 20ms
Fetch weather             99.0'th percentile        96ms
Fetch weather             99.5'th percentile        116ms
Fetch weather             99.9'th percentile        395ms

Time taken for tests      11.1 seconds
Total requests            500000
Successful requests       500000
Failed requests           0
Requests per second       45201.00 [#/sec]
Median time per request   23ms
Average time per request  25ms
Sample standard deviation 20ms
99.0'th percentile        96ms
99.5'th percentile        116ms
99.9'th percentile        395ms

The difference is probably due to Musl / Glibc.

Alpine 3.19Alpine 3.18Bookworm
[#/sec]44550.1144315.5945201.00