Zero to Hero: Containerizing .NET 5.0 WebApis
David Kröll
Posted on July 10, 2021
In this guide I'd like to show the most important topics and aspects when talking about containerizing a .NET 5.0 WebApi application. There are many articles about it online but no one takes care of all aspects - until now. With this guide you'd be able to go from zero 0️⃣ to hero 🗻 when talking about containers and .NET 5.0 WebApi.
This articles focuses on building the app inside a Linux container.
I won't talk about .NET Framework (4.x) which would require the Windows platform to run.
What's inside
The following topics are going to be considered in this guide:
- Configuration
- Testing
- OpenAPI spec (Swaggerfile) generation
- Image size optimizations
- Container security
- Overwriting defaults
Intial Dockerfile setup
This is the base version we are going to improve step by step in this guide. The major downside is, that there is much more included in the final image than we need for production use.
FROM mcr.microsoft.com/dotnet/sdk:5.0-alpine AS build
WORKDIR /source
# Copy and restore
COPY . .
# make sure this is set in the RuntimeIdentifiers of the .csproj
RUN dotnet restore -r linux-musl-x64
# publish project (StartupProject is the startup project)
RUN dotnet publish -c Release -o /appReleased -r linux-musl-x64 \
--no-restore StartupProject/*.csproj
ENTRYPOINT ["/appReleased/StartupProject"]
Configuration
Application configuration is nowadays typically done in appsettings.json
. Nevertheless there are a bunch more configuration providers pre-built in the ASP .NET Core 5.0 framework. These include the following:
- Environment variables
- Command line args
- Directory files
In fact it does matter in which order you apply these setting because they can overwrite each other. This powerful feature enables us to have some default configuration which can be overwritten at runtime.
My way of configuring the application is a mixture of the appsettings.json
file and environment variables.
Since the settings file is a JSON, nesting is possible. When providing configuration with environment variables, you should split these using __
(a double underline
).
Taking the following file as example:
// appsettings.json
{
"JWT": {
"Secret": ""
}
// ...
}
Assuming the configuration file from above, overwriting with environment variables is possible with the following statement:
export JWT__Secret=superSecureJWTSecret
I don't want the secrets like external API keys, JWT details, etc.
inside my Git history, so they are provided as environment ariables at application runtime.
I'm using the settings file for a basic default configuration
and the environment variables for the complete setup. As already mentioned above, the environment variables will overwrite configuration done in appsettings.json
, when the same key is provided.
Testing
Running tests every time you build your application is a crucial part of any CI pipeline, and executing them inside an isolated environment is even better.
Running unit tests within the new .NET 5.0 ecosystem is as easy as calling a single command with the .NET CLI:
RUN dotnet test --no-restore
Because we restored the project right before, we do not need to restore again (which would be the default behaviour). This implies that we can save some CI-Pipeline time with the --no-restore
flag.
OpenAPI spec (Swaggerfile) generation
A very popular NuGet for a ASP .NET Core 5.0 projects is the
Swashbuckle.AspNetCore
which provides automatic OpenAPI generation.
Most of the users may not know the dotnet CLI tool they provide. With this tool it is possible to generate the OpenAPI spec directly from the startup assembly.
dotnet tool install --version 6.1.1 Swashbuckle.AspNetCore.Cli
⚠️ Make sure you use the same version of the CLI tool
and the NuGet, otherwise it will result in some strange error.
There should now be a dotnet-tools
config present, which stores all installed tools and corresponding versions. This is important as this file is required for the build process later on. Make sure to also include it in your Git repository.
# build swagger.json from controller/endpoint definitions
RUN dotnet tool restore \
dotnet swagger tofile --output swagger.json StartupProject/bin/Debug/net5.0/StartupProject.dll v1
The OpenAPI specification is now written as JSON file inside the built image. You can for example build a client with the OpenAPI toolkit or just save it for later use.
For building a seperate client, another stage in your Dockerfile would be applicable.
Official Docker docs on multi-stage builds
Image size and multi-staged builds
Until now the build process only spits out my own application and it's dependencies. As of now, the correct .NET runtime has to be installed and managed seperately. This is also called framework-dependent. Indeed, there is another type of building and publishing an application. That's called the self-contained release. When using this, the runtime is provided together with the application code itself. As a result, the runtime doesn't need to be installed seperately.
Another requirement for using self-contained
as publishing type is, to make sure that the correct RuntimeIdentifier
in your .csproj
file is set.
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<RuntimeIdentifiers>linux-musl-x64</RuntimeIdentifiers>
<OutputType>Exe</OutputType>
</PropertyGroup>
</Project>
For the Alpine distribution as base image, the linux-musl-x64
has to be added. You may enable multiple RuntimeIdentifers (seperated by ";") in order to make it executable also on other platforms as self-contained build (for example win-x64
).
Now the finally built binary has fewer dependencies than the base image we used before provides. Microsoft has also for this an answer: the base-image mcr.microsoft.com/dotnet/runtime-deps:5.0-alpine-amd64
includes everything just to run self-contained
apps.
It is now possible to switch to a multi-staged Docker build:
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:5.0-alpine AS build
WORKDIR /source
# Copy csproj and restore as distinct layers
COPY . .
RUN dotnet restore -r linux-musl-x64
RUN dotnet test --no-restore
# build swagger.json from controller/endpoint definitions
RUN dotnet tool restore \
dotnet swagger tofile --output swagger.json StartupProject/bin/Debug/net5.0/StartupProject.dll v1
# publish project (StartupProject is the startup project)
RUN dotnet publish -c Release -o /appReleased -r linux-musl-x64 \
--no-restore StartupProject/*.csproj
# Create final image
FROM mcr.microsoft.com/dotnet/runtime-deps:5.0-alpine-amd64
COPY --from=build /appReleased /app
ENTRYPOINT ["/app/StartupProject"]
Security
From the security point of view, it's important to run your
API in the context of a user with minimal operating system permissions. The default if not specified would be the root
user, but there are multiple drawdowns when using the superuser with Docker.
However a detailed explanation would go beyond the scope of this article.
You can follow along here for details: https://sysdig.com/blog/dockerfile-best-practices/
Running apps under another user is as easy as applying the following statements to your Dockerfile:
# (truncated)
# Add user, group, and change ownership
RUN adduser --disabled-password --gecos "" --home /app --no-create-home --uid 10001 appuser \
# make sure to allow your user to access your application binary
&& chown -R appuser:appuser /app
USER appuser
# (truncated)
Overriding defaults
In case you wondered where the default listening port is configured:
I am going to demistify it now.
The base image we are using here in the final layer has some default configuration
and the listening URL and port is included there.
You can view the base Dockerfile here
The specific section I am talking about is this one:
# ...
ENV \
# Configure web servers to bind to port 80 when present
ASPNETCORE_URLS=http://+:80
# ...
I'd like to change them for my application, because I don't like port 80 to be exposed. I am going with the port 5000, same as the default inside the development environment.
# Set listening host and port
ENV ASPNETCORE_URLS=http://+:5000
EXPOSE 5000
Summary
When assembling all the different parts I stated above, you will end up with this final and complete Dockerfile:
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:5.0-alpine AS build
WORKDIR /source
# Copy csproj and restore as distinct layers
COPY . .
RUN dotnet restore -r linux-musl-x64
RUN dotnet test --no-restore
# build swagger.json from controller/endpoint definitions
RUN dotnet tool restore \
dotnet swagger tofile --output swagger.json StartupProject/bin/Debug/net5.0/StartupProject.dll v1
# release project (StartupProject is the startup project)
RUN dotnet publish -c Release -o /appReleased -r linux-musl-x64 --self-contained true \
--no-restore /p:PublishTrimmed=true /p:PublishReadyToRun=true StartupProject/*.csproj
# Create final image
FROM mcr.microsoft.com/dotnet/runtime-deps:5.0-alpine-amd64
COPY --from=build /appReleased /app
# Add user, group, and change ownership
RUN adduser --disabled-password --gecos "" --home /app --no-create-home --uid 10001 appuser \
# make sure to allow your user to access your application binary
&& chown -R appuser:appuser /app
USER appuser
# Set listening host and port
ENV ASPNETCORE_URLS=http://+:5000
EXPOSE 5000
ENTRYPOINT ["/app/StartupProject"]
For me, it includes every aspect I care about and also has a minimal size with maximum security.
Posted on July 10, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.