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.
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.
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.
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