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.
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.
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.
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);
}
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:
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"]
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"]
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.
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
[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
[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
[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.19 | Alpine 3.18 | Bookworm | |
---|---|---|---|
[#/sec] | 44550.11 | 44315.59 | 45201.00 |