Categories
Articles

Creating smaller and more secure docker images for .NET 8.0

In this article, I will explain how we can write better and more secure docker images. The docker images will be smaller and contain fewer dependencies.

This is a default docker file for creating a docker image:

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env
WORKDIR /App

COPY . ./
RUN dotnet restore
RUN dotnet publish -c Release -o out

# Build runtime image
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /App
COPY --from=build-env /App/out .
ENTRYPOINT ["dotnet", "DockerTest.dll"]

Pro tip: call the docker init command to generate all the needed docker files for your project. This will generate a dockerfile, a dockerignore file, and a docker-compose file. These files already contain some of the recommendations in this article.

🐋 Use the correct image

Using a smaller image can improve security, download time, and storage size.

Benefits of using a smaller image:

  • Fewer dependencies, fewer vulnerabilities
  • Faster image loading times
  • Less memory consumption

Here are the default images for .net 8:

  • mcr.microsoft.com/dotnet/aspnet:8.0 : Used for asp .net core applications
  • mcr.microsoft.com/dotnet/runtime:8.0 Used for .net applications(ex: console)
  • mcr.microsoft.com/dotnet/runtime-deps:8.0

⚠️ If you select another image than “aspnet” and one dependency(library) uses “aspnet” functionality the application won’t start up.

It’s also possible to change the underlying OS. If the OS is not provided in the image name a default image is used. For .net 8 images this is Bookworm Debian.

This is the format to use another OS for the images: mcr.microsoft.com/dotnet/aspnet:8.0-<osname>

These OS versions are available at this moment:

  • mcr.microsoft.com/dotnet/aspnet:8.0-alpine: alpine
  • mcr.microsoft.com/dotnet/aspnet:8.0-jammy: Jammy ubuntu

A list of current supported OS can be found here: https://hub.docker.com/_/microsoft-dotnet-aspnet

⚠️ For alpine and chiseled these images only work when InvariantGlobalization has been configured in the application. InvariantGlobalization should be set to “True” in the csproj for the application to work with these images.

Example setting InvariantGlobalization to true:

<PropertyGroup>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>

We can still enable globalization by installing icu manually in the dockerfile.

Add these lines in the run step of the application:

RUN apk add --no-cache icu-libs
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false

Sample docker file that uses Alpine as an OS:

FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build-env
WORKDIR /App

COPY . ./
RUN dotnet restore
RUN dotnet publish -c Release -o out

FROM mcr.microsoft.com/dotnet/runtime:8.0-alpine
WORKDIR /App
COPY --from=build-env /App/out .
ENTRYPOINT ["dotnet", "DockerTest.dll"]

Benchmarks for image sizes:

mcr.microsoft.com/dotnet/aspnet:8.0            224mB
mcr.microsoft.com/dotnet/aspnet:8.0-alpine 107mb
mcr.microsoft.com/dotnet/runtime:8.0-alpine 82mb
scratch(AOT) 3mb

How to build the scratch AOT image is described below.

Official documentation about the images: https://learn.microsoft.com/en-us/dotnet/core/docker/container-images

🔒 Run the container as a non-root user

Running the container as a non-root user can greatly improve security. Even if the application running in the container contains a vulnerability an attacker would be limited in what he can do.

By default, the container runs with the root user. The exception is for the chiseled images they use the non-root user as a default.

In .net core 8 the images have been updated and we can set the containers to run as a non-root.

Add this line before the entry point line in the .net core 8 docker file to run it as a non-root user

⚠️ warning, the permissions of a non-root user are restricted. In newer Linux distributions ports lower than 1024 are blocked. So for a web application a higher port number will need to get used.

USER $APP_UID

Full sample for .net core 8:

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env
WORKDIR /App

COPY . ./
RUN dotnet restore
RUN dotnet publish -c Release -o out

# Build runtime image
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /App
COPY --from=build-env /App/out .

USER $APP_UID
ENTRYPOINT ["dotnet", "DockerTest.dll"]

Before .net core 8 we needed to add the manually to the docker image.

Sample:

FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build-env
WORKDIR /App

COPY . ./
RUN dotnet restore
RUN dotnet publish -c Release -o out

# Build runtime image
FROM mcr.microsoft.com/dotnet/aspnet:7.0
WORKDIR /App

# Create a system group
RUN groupadd -r appusers

# Create an user
RUN useradd -r -g appuser appusers

# Give the user permissions to the folder
RUN chown -R appuser:appusers /app

COPY --from=build-env /App/out .

USER appuser
ENTRYPOINT ["dotnet", "DockerTest.dll"]

🕑Precompile with AOT

Since .net 7 we can compile the application ahead of time. This compiles the application This has several benefits like faster application start-up time.

⚠️AOT has several limitations

– No support for reflection
– Not all libraries support AOT
– Need to include a compiled version of dependent libraries. When using some functionality like SSL or compression. We need to manually include and compile dependencies for those libraries. For SSL support we need to build and compile openssl. For compression support we need to compile and include gzip.

Sample Dockerfile for simple console application:

FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build-env
WORKDIR /app

RUN apk add clang binutils musl-dev build-base zlib-static

COPY . ./
RUN dotnet restore --runtime linux-musl-x64 DockerTest.csproj
RUN dotnet publish -r linux-musl-x64 -c Release --no-restore -o out DockerTest.csproj

FROM scratch AS runtime
WORKDIR /app
COPY --from=build-env /app/out .
ENTRYPOINT ["/app/DockerTest"]

The csproj file:

<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<!-- Minimum required settings for AOT deployment-->
<StaticallyLinked>true</StaticallyLinked>
<PublishAot>true</PublishAot>
<StaticExecutable>true</StaticExecutable>

<!--Further reduce size by excluding reflection-->
<IlcDisableReflection>true</IlcDisableReflection>
<!--Further reduce image size by excluding ICU-->
<InvariantGlobalization>true</InvariantGlobalization>
<!--Further reduce size by excluding debug symbols-->
<StripSymbols>true</StripSymbols>
</PropertyGroup>
</Project>

The result is a 3MB docker image for a simple .net core console application.

More information:

🔒 Private NuGet packages

Sometimes we want to use a private Nuget repository. This helps with improving security and doesn’t expose internal libraries to the public.

It’s not trivial to do a “dotnet restore” of a project that has a private Nuget repository because it needs to authenticate with that repository.

Option A â†’ nuget.local.config

Create a new nuget.local.config and embed the credentials in this file. This file can also be created in the CI pipeline and then copied to the docker image.

Add this step in the docker file:

COPY ["nuget.local.config", "nuget.config"]

Full sample:

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env
WORKDIR /App


COPY . ./
COPY ["nuget.local.config", "nuget.config"]
RUN dotnet restore
RUN dotnet publish -c Release -o out

# Build runtime image
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /App
COPY --from=build-env /App/out .
ENTRYPOINT ["dotnet", "DockerTest.dll"]

Option B -> Use environment variables and parameters

Create a nuget.config and add the placeholder for the environmental variable.

Sample:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
<add key="private" value="https://nuget.private/index.json" />
</packageSources>

<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
<packageSource key="private">
<package pattern="private.*" />
</packageSource>
<packageSource key="feed-private">
<package pattern="private.*" />
</packageSource>
</packageSourceMapping>

<packageSourceCredentials>
<private>
<add key="Username" value="%NUGET_USERNAME%" />
<add key="ClearTextPassword" value="%NUGET_PASSWORD%" />
</private>
</configuration>

Add this to the docker file in the build step:

ARG NUGET_USERNAME
ARG NUGET_PASSWORD

Sample docker file:

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env
WORKDIR /App

ARG NUGET_USERNAME
ARG NUGET_PASSWORD

COPY . ./
RUN dotnet restore
RUN dotnet publish -c Release -o out

# Build runtime image
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /App
COPY --from=build-env /App/out .
ENTRYPOINT ["dotnet", "DockerTest.dll"]

🕑 DockerIgnore file

The “.dockerignore” file helps with this. This file describes what files you want to ignore from a docker image. Add a new .dockerignore file in the build context folder.

Setting a .dockerignore file has these benefits:

  • Smaller docker image
  • Less chance of sensitive information being included in the docker image
  • Faster build time since it won’t be copying unneeded files

In this file, we can add wildcards to define what files and directories we should include or exclude.

Wildcard examples:

  • */bin : any directory one level below the build context
  • **/bin: any directories multiple levels below the build context
  • !readme.md: include the readme.md file even if the file has been excluded by another rule
# Build folders
**/[b|B]in/
**/[O|o]bj/

# git folder
.git/

# .vs folder
.vs/

🕑 Build cache

By adding a build cache we can cache the output of some steps in docker. This can improve the docker build speed. The ideal candidate for this is the restore functionality where we load the Nuget packages.

Example:

RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
RUN dotnet publish -c Release -o out

Using a build cache in a CI pipeline on a non-self-hosted host won’t have any benefits since the build cache is removed after each run.

Sources