Containerize your .NET Core app – the right way
By: Nico Meisenzahl
Tags: Dotnet Core, Containers, DevOps, Dockerfile, Kubernetes

There are millions of use cases out there and that’s why there isn’t a one fits all solution. I would like to introduce you to two different options I use most. You will get all the details to decide which of them works best for you. Let us start with the basics first.

The common way

This is a common example Dockerfile which comes across my way quite often:

FROM mcr.microsoft.com/dotnet/core/aspnet:3.1 WORKDIR /app

COPY /app/output . EXPOSE 8080

ENTRYPOINT ["dotnet", " sample-mvc .dll"]

There is nothing particularly wrong with it. It will work, but it is not tuned or optimized at all. Neither for performance nor for security-related issues and therefore not optimal for a production environment. Some examples are:

containers executing their process as root

an inefficient sorting order that results in slower build times due to invalid image layer caches

an improper image layer management that affects the final image size

slower builds due to missing .dockerignore file

Let’s have a closer look at another Dockerfile example:

FROM mcr.microsoft.com/dotnet/core/sdk:3.1 WORKDIR /app

ADD /src .

RUN dotnet publish \

-c Release \

-o ./output EXPOSE 8080

ENTRYPOINT ["dotnet", " sample-mvc .dll"]

This example uses a containerized build. This means that the application build itself is moved into the Docker build process. Doing this a pretty good pattern that allows you to build in an immutable and isolated build environment with all dependencies build in. But as a downside, you need to build your image based on a bigger SDK image. The SDK image provides the needed dependencies to build the application but wouldn’t be needed to execute it afterward. Luckily, there is a solution that addresses this particular issue.

Multi-stage builds

If you are using a similar version of the above Dockerfiles you might not have heard about a feature called multi-stage build. Multi-stage builds allow us to split our image build process into multiple stages.

The first stage is used to build our application that requires that we need to provide the required dependencies. In the second stage, we are copying the application artifacts into a smaller runtime environment which then is used as our final image. This corresponding Dockerfile could look like this:

FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build-env WORKDIR /app

ADD /src .

RUN dotnet publish \

-c Release \

-o ./output FROM mcr.microsoft.com/dotnet/core/aspnet:3.1 WORKDIR /app

COPY --from=build-env /app/output . EXPOSE 8080

ENTRYPOINT ["dotnet", " sample-mvc .dll"]

Let’s take a closer look at the individual steps:

FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build-env

...

RUN dotnet publish \

-c Release \

-o ./output

...

Our first stage is based on the SDK image which provides all dependencies to build our app.

...

FROM mcr.microsoft.com/dotnet/core/aspnet:3.1

...

COPY --from=build-env /app/output .

...

In the second stage, we define a new base image that this time only contains our runtime dependencies. We then copy the application artifacts from the first stage into the second one.

With this in place, we are now able to build a smaller and more secure container image because it only contains the dependencies needed to execute the application. But we still have room for further improvement which we talk about in the next paragraph.

If you like to learn more about Dockerfile best practices I would recommend you check out this page of the official Docker documentation.

You have the choice

As already mentioned above, there isn’t a single best practice. It varies from use case to use case. With the examples below, you will get two blueprints as well as their pros and cons, which you can then use to adapt them according to your needs.

A good starting point

The below Dockerfile is an optimized version of the above multi-stage example and should be a good fit for most scenarios.

ARG VERSION=3.1-alpine3.10 FROM mcr.microsoft.com/dotnet/core/sdk:$VERSION AS build-env WORKDIR /app ADD /src/*.csproj .

RUN dotnet restore ADD /src .

RUN dotnet publish \

-c Release \

-o ./output

RUN adduser \

--disabled-password \

--home /app \

--gecos '' app \

&& chown -R app /app

USER app

WORKDIR /app

COPY --from=build-env /app/output .

ENV DOTNET_RUNNING_IN_CONTAINER=true \

ASPNETCORE_URLS= FROM mcr.microsoft.com/dotnet/core/aspnet:$VERSIONRUN adduser \--disabled-password \--home /app \--gecos '' app \&& chown -R app /appUSER appWORKDIR /appCOPY --from=build-env /app/output .ENV DOTNET_RUNNING_IN_CONTAINER=true \ASPNETCORE_URLS= http://+:8080 EXPOSE 8080

ENTRYPOINT ["dotnet", "sample-mvc.dll"]

Again, we take a closer look at the individual steps:

ARG VERSION=3.1-alpine3.10

FROM mcr.microsoft.com/dotnet/core/sdk:$VERSION AS build-env

...

We are first defining our base image tag using an ARG instruction. This helps us to easily update the tag instead of changing several lines. As you may have noticed, we use a different tag. The tag 3.1-alpine3.10 states that this image contains the ASPNET version 3.1 and is based on Alpine 3.10.

Alpine Linux is a Linux distribution designed for security, simplicity, and resource efficiency use cases. In this stage, Alpine Linux already can help us to reduce the footprint of our build stage.

...

FROM mcr.microsoft.com/dotnet/core/aspnet:$VERSION

...

Because we are using a multi-stage build we also need to define the image used in our final stage. Once again we will use the Alpine based ASPNET runtime as our base image. As already said, building our image based on Alpine allows us to build a smaller and more secure container image.

ADD /src/*.csproj .

RUN dotnet restore ADD /src .

RUN dotnet publish \

-c Release \

-o ./output

Unlike in the above example, we this time splitting the build process into multiple pieces. The dotnet restore command uses NuGet to restore dependencies as well as project-specific tools that are specified in the project file. The dependencies restore is also part of the dotnet pubish command but separating it allows us to build the dependencies into a separate image layer. This shortens the time needed to build the image and reduces the download size since the image layer dependencies are only rebuilt if the dependencies get changed.

...

RUN adduser \

--disabled-password \

--home /app \

--gecos '' app \

&& chown -R app /app

USER app

...

To secure the runtime of our application we need to execute them without any root privileges. Because of this, we are creating a new user and changing the user context using the USER definition.



ENV DOTNET_RUNNING_IN_CONTAINER=true \

ASPNETCORE_URLS=

EXPOSE 8080

... ...ENV DOTNET_RUNNING_IN_CONTAINER=true \ASPNETCORE_URLS= http://+:8080 EXPOSE 8080...

Because we run our app without any root privileges we need to expose it on a port higher 1024. In this example, 8080 was chosen. With the ENV definition, we are exposing further environment variables to our application process. DOTNET_RUNNING_IN_CONTAINER=true is only an informal environment variable to let a developer/application know that the process is running within a container. ASPNETCORE_URLS=http://+:8080 is used to provide the runtime with the information to expose the process on port 8080.

Smaller, smaller, smaller

As already mentioned, the above example should fit for most of the scenarios. The following example describes a way to build the smallest possible container image. A possible use-case might be for IoT Edge use cases or environments that need optimized start times. Unfortunately, we also get some disadvantages which I will talk about in detail below.

ARG VERSION=3.1-alpine3.10 FROM mcr.microsoft.com/dotnet/core/sdk:$VERSION AS build-env WORKDIR /app

ADD /src .

RUN dotnet publish \

--runtime alpine-x64 \

--self-contained true \

/p:PublishTrimmed=true \

/p:PublishSingleFile=true \

-c Release \

-o ./output

RUN adduser \

--disabled-password \

--home /app \

--gecos '' app \

&& chown -R app /app

USER app

WORKDIR /app

COPY --from=build-env /app/output .

ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 \

DOTNET_RUNNING_IN_CONTAINER=true \

ASPNETCORE_URLS= FROM mcr.microsoft.com/dotnet/core/runtime-deps:$VERSIONRUN adduser \--disabled-password \--home /app \--gecos '' app \&& chown -R app /appUSER appWORKDIR /appCOPY --from=build-env /app/output .ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 \DOTNET_RUNNING_IN_CONTAINER=true \ASPNETCORE_URLS= http://+:8080

ENTRYPOINT ["./sample-mvc", "--urls", " EXPOSE 8080ENTRYPOINT ["./sample-mvc", "--urls", " http://0.0.0.0:8080 "]

Once again, we take a closer look at the individual steps:

...

RUN dotnet publish \

--runtime alpine-x64 \

--self-contained true \

/p:PublishTrimmed=true \

/p:PublishSingleFile=true \

-c Release \

-o ./output

...

The big difference to the upper one is that we will build a self-contained application. Providing the parameter --self-contained true will force the build to include all dependencies into the application artifact. Wich includes the .NET Core runtime. Because of this, we also need to define the runtime we would like to execute the binary in. This is done with the --runtime alpine-x64 parameter.

Since the final image should be optimized for size we are defining the /p:PubishTrimmed=true flag that advises the build process to not include any unused libraries. The /p:PublishSingleFile=true flag allows us to speed up the build process itself. As a downside, you will have to define dynamically loaded assemblies upfront to make sure that required libraries aren’t trimmed and therefore not available in the image. More details on this are available here.

A second disadvantage of having a smaller image is that code changes result in a bigger change. This is because the code and runtime are packed together in a single image layer. Every time the code changes the whole image layer needs to rebuild and also redistributed to the system running the code.

...

FROM mcr.microsoft.com/dotnet/core/runtime-deps:$VERSION

...

Because the application artifact is self-contained we do not need to provide a runtime with the image. In this example, I have chosen the runtime-deps image based on Alpine Linux. This image is stripped down to the minimum native dependencies needed to execute the application artifact.

Another image size improvement is to use the globalization invariant mode. This mode is useful for applications that are not globally aware and that can use the formatting conventions, casing conventions, and string comparison and sort order of the invariant culture. The globalization invariant mode is enabled via the DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 environment variable. If your application requires globalization you will need to install the ICU library and remove the above environment variable. This will increase your container image size by about 28 MB. You will find more details on the globalization invariant mode here.

For self-contained applications, we need to change the ENTRYPOINT definition to run the binary itself.

The size of this image will be around 73 MB (including my sample application). Let’s compare this to other images:

an image based on a common multi-stage Dockerfile: 250 MB

an image based on the above multi-stage Dockerfile: 124 MB

As already mentioned above: Which Dockerfile is most suitable for you depends on your use case. Smaller is not necessarily better.