How to get a .NET 5 docker image below 40 MB
If you ask yourself what’s the best language/platform to build a cloud-native application, you may find that Go is the way to go. The vast majority of open-source software written in this space is done with Go as it’s a perfect language for obtaining a working app with a very small amount of code and a very small footprint on disk/memory. However, as I love .NET and all the tooling around it, I’m digging into the .NET 5 to see what makes it a premium platform for developing cloud-native apps.
In this blog post, I will show you how .NET 5 is a great platform to build docker images that are very small in terms of disk footprint.
TL;DR; With .NET 5, the trimming options combined with an Alpine base image permit us to achieve a docker image for less than 40 MB!
Why does size matter?
The image size is important for docker container for several reasons. It’s maybe not as important as some guys are saying in their blog posts, but let’s pass some arguments here :
Transferring images over the network can be costly
As docker images are most of the time stored in any form of container registry, you have to consider the bandwidth used to transfer it from there to your workstation (and vice versa) or to your execution environments. Moreover, when using your images on a cloud-provider platform, you must consider that you may pay for any data transferred between cloud regions or between sites. You may think that bandwidth is cheap or is free, but it’s not. Enterprises pay a lot for their redundant premium internet connections and you should use it carefully. In any case, if you can easily reduce your bandwidth usage, why not reducing it?
Yes, but we have caching!
Sure, it’s true. But where will you cache your image? On your workstation? How many developers do you have in your organisation? How many servers will have to download the same image to “cache” it locally? Maybe more than you think isn’t it?
It’s not all about the size, but the speed
You may think that the number of bytes transferred is not a concern. But, what about the time required to do that? Personally, I don’t want to wait that much. I consider that my wait time is a muda (Lean) and we need to optimize it since it’s a pure waste.
OK, so we need the smallest possible image. Yes, and no…
Size is important, but it’s not the only concern. You may have to make some trade-offs when choosing your base image for docker containers. Maybe you need some additional packages to be able to run your app. Perhaps your build time is more important than size in certain scenarios. So, we have to take a step back and breathe. 🙂 Maybe you’ll find that bigger images are more suitable for you for maintainability concerns or something else. After all, in computer science, it’s all about compromises and choices.
How .NET 5 is a great platform for docker image size?
Ok, enough arguing about the importance of the image size. Microsoft made tremendous work about it in .NET 5 and I’ll show you why.
Here, there is a simple dockerfile for a base .NET 5 WebAPI project :
#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. FROM mcr.microsoft.com/dotnet/runtime-deps:5.0-alpine3.12-amd64 AS base WORKDIR /app EXPOSE 80 EXPOSE 443 FROM mcr.microsoft.com/dotnet/sdk:5.0-alpine3.12-amd64 AS build WORKDIR /src COPY ["SmallContainer.csproj", ""] RUN dotnet restore "./SmallContainer.csproj" COPY . . WORKDIR "/src/." RUN dotnet build "SmallContainer.csproj" -c Release -o /app/build FROM build AS publish RUN dotnet publish "SmallContainer.csproj" -p:PublishSingleFile=true -r linux-musl-x64 --self-contained true -p:PublishTrimmed=True -p:TrimMode=Link -c Release -o /app/publish FROM base AS final WORKDIR /app COPY --from=publish /app/publish . ENTRYPOINT ["./SmallContainer"]
Let’s review all the important parts of this dockerfile.
As you can see, we base our image on alpine. Alpine is very small and lightweight linux image weighting only 5.57 MB at the moment.
A best-practice for building your docker image is to leverage multi-stage dockerfile. By doing this, you can have all the tooling needed to build your image, run your tests and so on but keep your final image clean and light.
Publishing small dotnet executables
The magic comes a lot from the dotnet cli and its publishing function. Since the base image is very small, the total size of the image is related to the app size and its dependencies (.NET Runtime, .NET deps, …). You’ll notice that we must use some parameters to get a very small total image size.
This first parameter indicates that you want a single file as the output of your build. You can think of it like a zip file containing all the files you would normally have on disk.
An interesting option since .NET Core is that you can embed the .NET Runtime dependencies inside your executable. Before that, you would typically rely on a locally deployed runtime along with your app files or on a pre-installed .NET runtime.
The PublishSingleFile option also includes the self-contained flag. I put it explicitly in the dockerfile, but it’s not required. It’s not always the best option as we could save disk space when deploying our apps in a framework-dependant way. If you have a lot of apps on the same machine, you could make some real gains by deploying your .NET Runtime only once per version. In large enterprises, it’s a valuable scenario. Since we’re a talking about creating a docker container, it’s less relevant. Moreover, packaging a self-contained/single-file executable will be leveraged by the following publishing option, which will take the app size very small.
As we encapsulated our app in an Alpine docker image, we have to specify a runtime target identifier (RID) to tell the compiler what optimization it can do according to the target runtime the app would run on. The linker will also be able to optimize its linking to the musl C Standard library which is the only one available on Alpine.
This option is REALLY cool! It removes all the unnecessary code from all your dependencies while packaging. It’s like black magic without any sacrifice or obscure rituals. The result you’ll get is a single assembly containing only the source code you’re really using at runtime. This concept is well-known in web development but it’s called TreeShaking. But, since we’re using a static language, it’s a lot more efficient.
Well… there’s a trick. If you use Reflection, you may experience some problems. But you have some handles to keep some code you are aware of. More details on that here :
By default, if you omit this parameter, the trimming operation will keep the entire assemblies that you use even if it’s just for a single method. However, with the Link trim mode, the operation will be a lot more specific when analyzing all the static method calls to improve the size of the resulting file. Combined with the previous cli parameters, we reduce the size of our app, but also of the .NET Runtime embedded in our app.
Here are the results
You can see all the image sizes according to the different parameter combinations:
|Publishing option||Size in MB|
|Trimmed (Default mode)||50|
I recognize that the application is the default WebAPI application template from .NET Core and is basically a HelloWorld. But it was not possible to get this kind of image size before anyway for the same app. Adding some code will not consume a lot in my experience.
Am I lazy?
Is it possible to get this farther? Absolutely! But you’ll have to sacrifice other things. Technically, it would be possible to remove some other packages you don’t use like the shell, pdb files, and so on. You won’t save a lot in disk space and you’ll lose some great features with your containers. But you definitely can optimize it a bit, but remember the three optimization rules and work on real problems :).
.NET is a great platform for building a large diversity of applications. For Cloud-Native, I think it is a must-consider platform. Small container images is not the only concern for any team, but it’s something that must be considered.
Not sure what will be my next post, but I’ll continue to dig into what makes .NET 5 a great choice for cloud-native apps.