Chris Lewis-Hou

Generally speaking, I develop software

Optimizing your .NET Core Docker image size with multi-stage builds

Published 09 April 2018

My team at work has only recently started to utilise Docker for running our applications in both development and production, and as I've become more familiar with it, I've been quick to notice the commonly large sizes for even simple application images. With disk space as plentiful as it is these days, this is rarely an issue. However, there are still some scenarios where the size of your Docker images can play a large role:

For these reasons, I think it's still important to make the effort to keep your Docker images as small as possible. In the case of the .NET Core images we've been building at work recently, this is most easily accomplished using Docker's multi-stage build feature.

This will be a fairly light introductory article on multi-stage builds and size optimisation. Scott Hanselman has a great, more detailed post which also discusses building for ARM architectures. Peter Jausovec also has a post similar to mine but using separate Dockerfiles instead of multi-stage builds.

Building an un-optimised image

If we want to try and optimise our image size, we should first know what our baseline is to compare against! To do this, let's just create a default .NET Core console application by running:

dotnet new console docker-size-test

That should create a simple "Hello World" console application in a new docker-size-test directory. If you want, you can double-check that everything is working by running dotnet run in that directory before proceeding.

Now, to set up our Docker image build for the application, create a new file called Dockerfile in the docker-size-test directory using your preferred text editor, containing the following:

FROM microsoft/dotnet:2.0-sdk
COPY . ./docker-size-test
WORKDIR /docker-size-test/
RUN dotnet build -c Release
ENTRYPOINT ["dotnet", "run", "-c", "Release", "--no-build"]

Since this is a fairly introductory article, I'll give a quick run-through of what all this means:

Now let's run the Docker build and name the image docker-size-test by running:

docker build -t docker-size-test .

Once that's complete, you should be able to succesfully run the application using docker run docker-size-test.

So, now that the image is built, let's take a look at how big it is! Running docker image ls should give us the details of the image we just built:

REPOSITORY       TAG     IMAGE ID      CREATED          SIZE
docker-size-test latest  4dc5be081c0a  28 minutes ago   1.75GB

That's a whole 1.75GB image* for a Hello World application! Now, why might that be?

As I mentioned earlier, we use microsoft/dotnet:2.0-sdk as the base for our image, which contains all the appropriate dependencies to build and run our application. However, the build dependencies introduce quite a large size overhead, resulting in the large image size you see here.

Of course, when we run our image using docker run docker-size-test, we don't actually need the build dependencies to be present, do we? All we need is the .NET Core runtime to run our application. Wouldn't it be nice to use an image that only contains the runtime dependencies instead?

*1.75GB is the uncompressed image size. When downloading/uploading images, Dockers will compress them so it doesn't actually need to download that full size every time.

Introducing multi-stage builds

Multi-stage builds is a Docker feature that allows you to use multiple FROM statements in your Dockerfile - each FROM statement will define a new "stage". When defining multiple stages, Docker takes the last one and uses that as the output for your build.

What this lets us accomplish is to use the dotnet:2.0-sdk image to build our application, and then use another image - in this case dotnet:2.0-runtime which only contains the dependencies to run the application. As a result, the dotnet:2.0-runtime version of our application should be noticeably smaller.

For more information on multi-stage builds, see the Docker documentation.

Optimising our image

To utilise multi-stage builds to reduce our image size, we can update our Dockerfile to match the following:

FROM microsoft/dotnet:2.0-sdk AS build
COPY . ./docker-size-test
WORKDIR /docker-size-test/
RUN dotnet build -c Release -o output

FROM microsoft/dotnet:2.0-runtime AS runtime
COPY --from=build /docker-size-test/output .
ENTRYPOINT ["dotnet", "docker-size-test.dll"]

Going through the changes introduced here:

If you build your image again using docker build -t docker-size-test . and then run docker run docker-size-test, everything should still work as normal.

The final comparison

Alright, now that we've built our new image using multi-stage builds, let's see how its size compares to before! Running docker image ls again reveals:

REPOSITORY        TAG      IMAGE ID      CREATED           SIZE
docker-size-test  latest   b3a2d702b24a  17 minutes ago    219MB

219MB! That's nearly 90% of the entire image size cut down right there! (Still sounds a little large for Hello World perhaps, but such is the world of containerisation)

Although this is a fairly simplistic example, I think it does a great job illustrating the potential savings that can be made just by carefully choosing your runtime images and utilising Docker's multi-stage build features. So the next time you find yourself balking at the size of your Docker images, you just might be able to make some significant savings!