TL;DR

This post explains how to optimize a .NET 5 Docker image and address two main concerns. Image size and security vulnerabilities. The article shows how to reduce the size from ~210 to ~58 MB. Additionally, it shows how to remove all 59 vulnerabilities.

What we get from Microsoft

Visual Studio (also Visual Studio for Mac) creates a relatively simple Dockerfile for .NET projects. It is a good starting point for users new to Docker. However, users should apply several optimizations before using the Dockerfile to create Docker images for production environments.

Create the .NET 5 Web API project

Different kinds of .NET applications can run in containers. As example for this post, we use a .NET 5 Web API. Use .NET CLI dotnet or Visual Studio to create a new .NET 5 Web API project.

# create a new Web API project in .NET 5
dotnet new webapi -n ContainerSample -o ContainerSample

Prepare container execution of the API

The default ASP.NET Web API template registers the UseHttpsRedirection middleware by default. However, when running the application in a container, things like proper HTTPS configuration are in the hosting environment’s responsibility (eg. Kubernetes, Azure Container Instances, or the App Services runtime).

That said, remove the registration of UseHttpsRedirection from Startup.cs. The ConfigureServices method of your Startup.cs should now look like this:

// Startup.cs
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
        app.UseSwagger();
        app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Thns.ContainerSample v1"));
    }
    app.UseRouting();
    app.UseAuthorization();
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

Add Docker Support

Both, Visual Studio and Visual Studio for Mac can also add Docker support to different projects. Right-click the project in the IDE and select Add → Docker Support. Consider reading the detailed instructions for Visual Studio.

Both IDEs will generate a Dockerfile like this:

FROM mcr.microsoft.com/dotnet/aspnet:5.0-buster-slim AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/sdk:5.0-buster-slim AS build
WORKDIR /src
COPY ContainerSample.csproj ./
RUN dotnet restore "./ContainerSample.csproj"
COPY . .
WORKDIR "/src/."
RUN dotnet build "ContainerSample.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "ContainerSample.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "ContainerSample.dll"]

Status Quo

As mentioned during the introduction, we will verify size, function, and vulnerabilities after every optimization. First, we have to build the Docker image using docker CLI:

# navigate to the project directory
cd ContainerSample

# build the Docker image
docker build . -t container-sample:0.0.1

Docker CLI will transfer all required files and folders to the Docker daemon and start the image build process. In the end you find a new Docker image on your local machine.

Take a look at your local Docker images, which will also display the actual size of the Docker image:

# list all all container-sample docker images
docker image ls | grep container-sample

container-sample    0.0.1    6ba4eeed8e21    1 minute ago   210MB

Test if the container starts successfully:

docker run --rm container-sample:0.0.1

info: Microsoft.Hosting.Lifetime[0]
      Now listening on: http://[::]:80
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

You can terminate the application again using Ctrl + C. Because we specified the --rm flag when running the Docker image, stopping the container will remove it from the list of stopped containers.

Finally, let us take a look at the vulnerabilities of the image by using docker scan. If you want to learn more about Docker image scanning, you should read my article on Docker image scanning.

# scan image for vulnerabilities
docker scan container-sample:0.0.1

## trimmed scan logs
Tested 94 dependencies for known vulnerabilities, found 59 vulnerabilities.

Bottom line: We end up with a working Docker image that is 210 MB large and has 59 vulnerabilities.

Step 1 - Switch the Linux distribution

First, switch from Debian to Alpine Linux to decrease the size of the resulting Docker image. The .NET team provides base images for a wide variety of operating systems and architectures.

Change the Dockerfile to match the following:

# Use Alpine Base Image
FROM mcr.microsoft.com/dotnet/aspnet:5.0-alpine AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

# Use Alpine Base Image
FROM mcr.microsoft.com/dotnet/sdk:5.0-alpine AS build
WORKDIR /src
COPY ContainerSample.csproj ./
RUN dotnet restore "./ContainerSample.csproj"
COPY . .
WORKDIR "/src/."
RUN dotnet build "ContainerSample.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "ContainerSample.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "ContainerSample.dll"]

Status Quo

To build and verify the current state, use now the 0.0.2 tag:

# build the image
cd ContainerSample
docker build . -t container-sample:0.0.2

# check the size
docker image ls | grep container-sample
container-sample    0.0.1    6ba4eeed8e21    2 minutes ago   210MB
container-sample    0.0.2    5696cf7ba51b    1 minute ago    108MB  

# verify that it works
docker run --rm container-sample:0.0.2

# scan for vulnerabilities
docker scan container-sample:0.0.2
## trimmed scan logs
Tested 23 dependencies for known issues, found 1 issue.

Switching to Alpine Linux reduced our image from 210 to 108 MB. Regarding security vulnerabilities, it is down from 59 to 1.

Step 2 - Optimizing dotnet publish

At this point, optimize how the application is published. Until now, it was published for the Release configuration, using default settings. However, .NET allows several tweaks when publishing apps.

The following Dockerfile passes several arguments to dotnet publish to ensure our app is published as a self-contained executable, targeting the given runtime (alpine-x64).

Bundle trimming (/p:PublishTrimmed=true) removes unnecessary framework components from the application bundle to optimize the distributable size. Also, create a single-file distributable by adding /p:PublishSingleFile=true.

Last but not least, change the ENTRYPOINT of the Dockerfile to use the new single-file distributable.

FROM mcr.microsoft.com/dotnet/aspnet:5.0-alpine AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/sdk:5.0-alpine AS build
WORKDIR /src
COPY ContainerSample.csproj ./
RUN dotnet restore "./ContainerSample.csproj"
COPY . .
WORKDIR "/src/."
RUN dotnet build "ContainerSample.csproj" -c Release -o /app/build

FROM build AS publish

# optimize dotnet publish
RUN dotnet publish "ContainerSample.csproj" -c Release -o /app/publish \
    --runtime alpine-x64 \
    --self-contained true \
    /p:PublishTrimmed=true \
    /p:PublishSingleFile=true

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
# new ENTRYPOINT (no more .dll is generated)
ENTRYPOINT ["./ContainerSample"]

Status Quo

To build and verify the current state, use now the 0.0.3 tag:

# build the image
cd ContainerSample
docker build . -t container-sample:0.0.3

# check the size
docker image ls | grep container-sample
container-sample    0.0.1    6ba4eeed8e21    3 minutes ago   210MB
container-sample    0.0.2    5696cf7ba51b    2 minutes ago   108MB
container-sample    0.0.3    f655d10572b0    1 minute ago    148MB

# verify that it works
docker run --rm container-sample:0.0.3

# scan for vulnerabilities
docker scan container-sample:0.0.3
## trimmed scan logs
Tested 23 dependencies for known issues, found 1 issue.

The size of the image went up to 148 MB after this optimization. The final image remains having 1 vulnerability.

Step 3 - Don’t call dotnet restore multiple times

The previous Dockerfiles interacted with dotnet several times. Many dotnet commands execute restore automatically to ensure all dependencies for your applications are in place. Optimize the Dockerfile to restore dependencies only once, which leads to a faster Docker image build-time:

FROM mcr.microsoft.com/dotnet/aspnet:5.0-alpine AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

# remove the build stage
FROM mcr.microsoft.com/dotnet/sdk:5.0-alpine AS publish
WORKDIR /src
COPY ContainerSample.csproj ./
# specify target runtime for destroy
RUN dotnet restore "./ContainerSample.csproj" --runtime alpine-x64
COPY . .

# add --no-restore flag to dotnet publish
RUN dotnet publish "ContainerSample.csproj" -c Release -o /app/publish \
  --no-restore \  
  --runtime alpine-x64 \
  --self-contained true \
  /p:PublishTrimmed=true \
  /p:PublishSingleFile=true

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["./ContainerSample"]

Status Quo

To build and verify the current state, use now the 0.0.4 tag:

# build the image
cd ContainerSample
docker build . -t container-sample:0.0.4

# check the size
docker image ls | grep container-sample
container-sample    0.0.1    6ba4eeed8e21    4 minutes ago   210MB
container-sample    0.0.2    5696cf7ba51b    3 minutes ago   108MB 
container-sample    0.0.3    f655d10572b0    2 minutes ago   148MB 
container-sample    0.0.4    6586a96f6808    1 minute ago    148MB 

# verify that it works
docker run --rm container-sample:0.0.4

# scan for vulnerabilities
docker scan container-sample:0.0.4
## trimmed scan logs
Tested 23 dependencies for known issues, found 1 issue.

At this point, neither size nor number of vulnerabilities changed. This step was all about optimizing the image build-time. The image remains at 148 MB and 1 vulnerability.

Step 4 - Switch runtime image to runtime-deps

Using build a self-containing executable, we can switch to slightly different base images for the application. The .NET team offers a dedicated image, which comes with all required dependencies to run a .NET application. However, in contrast to the images used previously, neither the SDK nor the .NET runtime are part of the image. Again, this has a significant impact on the size of the final Docker image.

This is also a excellent time to clean-up the Dockerfile and remove unnecessary EXPOSE and WORKDIR instructions:

FROM mcr.microsoft.com/dotnet/sdk:5.0-alpine AS publish
WORKDIR /src
COPY ContainerSample.csproj ./

RUN dotnet restore "./ContainerSample.csproj" --runtime alpine-x64
COPY . .
RUN dotnet publish "ContainerSample.csproj" -c Release -o /app/publish \
  --no-restore \
  --runtime alpine-x64 \
  --self-contained true \
  /p:PublishTrimmed=true \
  /p:PublishSingleFile=true

# use different image
FROM mcr.microsoft.com/dotnet/runtime-deps:5.0-alpine AS final
WORKDIR /app

# just expose port 80
EXPOSE 80
COPY --from=publish /app/publish .

ENTRYPOINT ["./ContainerSample"]

Status Quo

To build and verify the current state, use now the 0.0.5 tag:

# build the image
cd ContainerSample
docker build . -t container-sample:0.0.5

# check the size
docker image ls | grep container-sample
container-sample    0.0.1    6ba4eeed8e21    4 minutes ago   210MB
container-sample    0.0.2    5696cf7ba51b    3 minutes ago   108MB 
container-sample    0.0.3    f655d10572b0    2 minutes ago   148MB 
container-sample    0.0.4    6586a96f6808    2 minutes ago   148MB
container-sample    0.0.5    5b176150ad0b    1 minute ago    54.9MB 

# verify that it works
docker run --rm container-sample:0.0.5

# scan for vulnerabilities
docker scan container-sample:0.0.5
## trimmed scan logs
Tested 23 dependencies for known issues, found 1 issue.

Switching the base image for distribution leads to decreasing the size of the image by ~100 MB. The new Docker image is ~55 MB in size and has 1 known vulnerability.

Step 5 - Don’t run as root

I am sure you have noticed that the Docker image still runs with root privileges. This is something, we should address.

On Linux, new users are created using the adduser command. (The gecos argument prevents the system from asking for additional details like full name, phone and so on…).

Additionally, use chown to set the new user (dotnetuser) as owner of the working directory (/app).

Impersonate into the user context with USER dotnetuser. Non-root users are not allowed to allocate network ports below 1024. Use EXPOSE with port 5000 and instruct Kestrel to run on that port by using the --urls argument:

FROM mcr.microsoft.com/dotnet/sdk:5.0-alpine AS publish
WORKDIR /src
COPY ContainerSample.csproj ./

RUN dotnet restore "./ContainerSample.csproj" --runtime alpine-x64
COPY . .
RUN dotnet publish "ContainerSample.csproj" -c Release -o /app/publish \
  --no-restore \
  --runtime alpine-x64 \
  --self-contained true \
  /p:PublishTrimmed=true \
  /p:PublishSingleFile=true

FROM mcr.microsoft.com/dotnet/runtime-deps:5.0-alpine AS final

# create a new user and change directory ownership
RUN adduser --disabled-password \
  --home /app \
  --gecos '' dotnetuser && chown -R dotnetuser /app

# impersonate into the new user
USER dotnetuser
WORKDIR /app

# use port 5000 because
EXPOSE 5000
COPY --from=publish /app/publish .

# instruct Kestrel to expose API on port 5000
ENTRYPOINT ["./ContainerSample", "--urls", "http://localhost:5000"]

Status Quo

To build and verify the current state, use now the 0.0.6 tag:

# build the image
cd ContainerSample
docker build . -t container-sample:0.0.6

# check the size
docker image ls | grep container-sample
container-sample    0.0.1    6ba4eeed8e21    6 minutes ago   210MB
container-sample    0.0.2    5696cf7ba51b    5 minutes ago   108MB 
container-sample    0.0.3    f655d10572b0    4 minutes ago   148MB 
container-sample    0.0.4    6586a96f6808    3 minutes ago   148MB
container-sample    0.0.5    5b176150ad0b    2 minutes ago   54.9MB
container-sample    0.0.6    db9f0a9a0370    1 minute ago    54.9MB

# verify that it works
docker run --rm container-sample:0.0.6

# scan for vulnerabilities
docker scan container-sample:0.0.6
## trimmed scan logs
Tested 23 dependencies for known issues, found 1 issue.

Running with non-root privileges does not affect the size and number of vulnerabilities. The Docker image remains at ~55 MB and having 1 known vulnerability.

Step 6 - Fix Vulnerabilities

We made huge improvements and went down from 58 vulnerabilities to just 1. Fortunately, we get further information from docker scan about how to deal with the vulnerabilities that are found in the image.

To fix the vulnerability from step 5, use apk (the package manager of alpine base image) and update the vulnerable musl package:

FROM mcr.microsoft.com/dotnet/sdk:5.0-alpine AS publish
WORKDIR /src
COPY ContainerSample.csproj ./

RUN dotnet restore "./ContainerSample.csproj" --runtime alpine-x64
COPY . .
RUN dotnet publish "ContainerSample.csproj" -c Release -o /app/publish \
  --no-restore \
  --runtime alpine-x64 \
  --self-contained true \
  /p:PublishTrimmed=true \
  /p:PublishSingleFile=true

FROM mcr.microsoft.com/dotnet/runtime-deps:5.0-alpine AS final

RUN adduser --disabled-password \
  --home /app \
  --gecos '' dotnetuser && chown -R dotnetuser /app

# upgrade musl to remove potential vulnerability
RUN apk upgrade musl

USER dotnetuser
WORKDIR /app
EXPOSE 5000
COPY --from=publish /app/publish .

ENTRYPOINT ["./ContainerSample", "--urls", "http://localhost:5000"]

Status Quo

To build and verify the current state, use now the 0.0.7 tag:

# build the image
cd ContainerSample
docker build . -t container-sample:0.0.7

# check the size
docker image ls | grep container-sample
container-sample    0.0.1    6ba4eeed8e21    7 minutes ago   210MB
container-sample    0.0.2    5696cf7ba51b    6 minutes ago   108MB 
container-sample    0.0.3    f655d10572b0    5 minutes ago   148MB 
container-sample    0.0.4    6586a96f6808    4 minutes ago   148MB
container-sample    0.0.5    5b176150ad0b    3 minutes ago   54.9MB
container-sample    0.0.6    db9f0a9a0370    2 minutes ago   54.9MB
container-sample    0.0.7    ae9c20b7f786    1 minute ago    57.4MB

# verify that it works
docker run --rm container-sample:0.0.7

# scan for vulnerabilities
docker scan container-sample:0.0.7
## trimmed scan logs
✓ Tested 23 dependencies for known vulnerabilities, no vulnerable paths found.

Removing the last vulnerability from the Docker image results in the Docker image being slightly more prominent. The final Docker image is 57.4MB small and as docker scan states, no vulnerabilities are left.

Conclusion

You nailed it! You completed seven steps that brought you here, and your Docker image is way smaller and has zero known vulnerabilities. Compare the final result with the first image build by using the standard Dockerfile. The difference is immersive.

Creating smaller Docker images has several advantages:

  • Environments can pull images faster
  • Images allocate less storage of your container registry
  • CI executions will be faster (push time decreases too)

Hopefully, removing all vulnerabilities from the Docker image does not require any further explanations ;-).