June 12, 2020

Optimising .NET Core Docker images

Recently I was giving Ditto (one of my side projects) some love and hooking up Docker Hub to build my images. Ditto is a replication tool for Event Store providing a drop-in Docker image that can begin replicate events between two Event Store clusters. To create the best deployment experience I started to look at how I could optimise the compiled Docker images by making them as small as possible.

Multi-stage builds

One of the most popular ways for keeping Docker image size to a minimum is to use multi-stage builds. This feature allows you to define stages based on different base images. This means you can use a base image that contains all the dependencies you need to build or publish your application and then a minimal base image containing just runtime dependencies. There’s a good write-up on how to use multi-stage builds with ASP.NET Core here.

Below is the original Dockerfile from the Ditto project:

FROM mcr.microsoft.com/dotnet/core/sdk:3.1-alpine AS base

ARG BUILDCONFIG=RELEASE
ARG VERSION=1.0.0

# copy csproj and restore as distinct layers
COPY ./src/Ditto/Ditto.csproj ./Ditto/
RUN dotnet restore Ditto/Ditto.csproj

# copy everything else and build
COPY ./src/Ditto/ ./Ditto/
RUN dotnet publish Ditto/Ditto.csproj -c $BUILDCONFIG -o out /p:Version=$VERSION

# build runtime image
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-alpine
WORKDIR /app
COPY --from=base /out ./

EXPOSE 5000
ENTRYPOINT ["./Ditto"]

When building with docker build the resulting image is 111MB.

Assembly trimming

As of .NET Core 3.1, assembly trimming is supported natively. Taken from the docs:

The trimming feature works by examining the application binaries to discover and build a graph of the required runtime assemblies. The remaining runtime assemblies that aren’t referenced are excluded.

Note that you should test trimmed applications thoroughly as trimming may have undesired results, especially in applications that dynamically load or reference assemblies using reflection.

Trimming only works on self-contained deployments where the .NET Core runtime is published alongside your application. Let’s start with publishing a self-contained version of Ditto that targets Alpine Linux. You can find the full list of runtime identifiers here:

dotnet publish Ditto/Ditto.csproj --runtime linux-musl-x64 -o out

To see the total file size of the published directory we can use du -hs ./out which returns 79MB. Now let’s see the effect of assembly trimming, with a slight change to our publish command:

dotnet publish Ditto/Ditto.csproj --runtime linux-musl-x64 -o out -p:PublishTrimmed=true

The output indicates that the assemblies have been optimized.

Determining projects to restore...
All projects are up-to-date for restore.
Ditto -> /Users/ben.foster/source/personal/Ditto/src/Ditto/bin/Debug/netcoreapp3.1/linux-musl-x64/Ditto.dll
Optimizing assemblies for size, which may change the behavior of the app. Be sure to test after publishing. See: https://aka.ms/dotnet-illink
Ditto -> /Users/ben.foster/source/personal/Ditto/src/Ditto/out/

This results in a total file size of 59MB, a decrease of 25%.

Shrinking the .NET Core Docker image

To benefit from our self-contained application we no longer need to use a base image that includes the .NET Core runtime. Instead, Microsoft provide base images that include the dependencies that the .NET Core runtime needs to run. The updated Dockerfile can be seen below:

FROM mcr.microsoft.com/dotnet/core/sdk:3.1-alpine AS base

ARG BUILDCONFIG=RELEASE
ARG VERSION=1.0.0

# copy csproj and restore as distinct layers
COPY ./src/Ditto/Ditto.csproj ./Ditto/
RUN dotnet restore Ditto/Ditto.csproj

# copy everything else and build
COPY ./src/Ditto/ ./Ditto/
RUN dotnet publish Ditto/Ditto.csproj --runtime linux-musl-x64 -c $BUILDCONFIG -o out /p:Version=$VERSION -p:PublishTrimmed=true

# build runtime image
FROM mcr.microsoft.com/dotnet/core/runtime-deps:3.1-alpine
WORKDIR /app
COPY --from=base /out ./

EXPOSE 5000
ENTRYPOINT ["./Ditto"]

The resulting Docker image is just 61.6MB a whopping 45% reduction from the original image.

Conclusion

To put your .NET Core Docker images on a diet:

  • Use multi-stage builds
  • Compile to a self-contained application
  • Use assembly trimming
  • Test thoroughly
  • Use the runtime-deps base image

© 2022 Ben Foster