Smaller .NET 6 docker images

0
8629

Introduction

This post compares different strategies to dockerize a .NET 6 application and how to create a < 100mb docker image to host a .NET 6 asp.net web application. Using the docker multi stage builds feature and a self-contained .NET with some build options the final docker image can be reduced from 760mb to 83mb, almost 10x smaller!

All the images used were taken from the official Microsoft .NET docker repository.

1. .NET 6 SDK image

Let’s start with a simple Dockerfile based on the official .NET 6 SDK build image.

FROM mcr.microsoft.com/dotnet/sdk:6.0
WORKDIR /app

COPY . ./

RUN dotnet publish "WebApi.csproj" -c Release -o /app/publish

ENTRYPOINT ["dotnet", "/app/publish/WebApi.dll"]
$ docker build -t api-aspnet .
$ docker images api-aspnet
REPOSITORY   TAG       IMAGE ID       CREATED       SIZE
api-aspnet   latest    700870327c08   1 weeks ago   761MB

We are using a docker single stage build using the base .net sdk image. After building the image we can see that the final image size is 761mb. Let’s try to check where the space is being used. For that we can use the docker history to see the layers created for this image.

$ docker history api-aspnet
IMAGE          CREATED        CREATED BY                                      SIZE      COMMENT
700870327c08   1 weeks ago    ENTRYPOINT ["dotnet" "/app/publish/WebApi.dl…   0B        buildkit.dockerfile.v0
<missing>      1 weeks ago    RUN /bin/sh -c dotnet publish "WebApi.csproj…   4.18MB    buildkit.dockerfile.v0
<missing>      1 weeks ago    RUN /bin/sh -c dotnet build -c Release # bui…   12.2MB    buildkit.dockerfile.v0
<missing>      1 weeks ago    COPY . ./ # buildkit                            721kB     buildkit.dockerfile.v0
<missing>      1 weeks ago    RUN /bin/sh -c dotnet restore # buildkit        28.2MB    buildkit.dockerfile.v0
<missing>      1 weeks ago    COPY WebApi.csproj ./ # buildkit                327B      buildkit.dockerfile.v0
<missing>      1 weeks ago    WORKDIR /app                                    0B        buildkit.dockerfile.v0
<missing>      2 months ago   /bin/sh -c powershell_version=7.2.1     && c…   40.6MB
<missing>      2 months ago   /bin/sh -c curl -fSL --output dotnet.tar.gz …   392MB
<missing>      2 months ago   /bin/sh -c apt-get update     && apt-get ins…   74.8MB
<missing>      2 months ago   /bin/sh -c #(nop)  ENV ASPNETCORE_URLS= DOTN…   0B
<missing>      2 months ago   /bin/sh -c #(nop) COPY dir:a54b266469a09b122…   20.3MB
<missing>      2 months ago   /bin/sh -c #(nop)  ENV ASPNET_VERSION=6.0.1 …   0B
<missing>      2 months ago   /bin/sh -c ln -s /usr/share/dotnet/dotnet /u…   24B
<missing>      2 months ago   /bin/sh -c #(nop) COPY dir:6c537cc098876a5f6…   70.6MB
<missing>      2 months ago   /bin/sh -c #(nop)  ENV DOTNET_VERSION=6.0.1     0B
<missing>      2 months ago   /bin/sh -c #(nop)  ENV ASPNETCORE_URLS=http:…   0B
<missing>      2 months ago   /bin/sh -c apt-get update     && apt-get ins…   37MB
<missing>      2 months ago   /bin/sh -c #(nop)  CMD ["bash"]                 0B
<missing>      2 months ago   /bin/sh -c #(nop) ADD file:09675d11695f65c55…   80.4MB
base image20%
.NET SDK + runtime60%
Linux libs15%
Web App source + compiled code25%

The .NET SDK + runtime takes most of the space, around 60%. So let’s try to shrink it.

2. .NET 6 SDK image + docker multi stage build

The previous section showed us that the .NET SDK + runtime takes most of the space of the final image. This is because we are using the same docker base image to build and to run the app so the final image has all the things needed to compile and run the app. Using a docker multi stage build we can cut the .NET SDK from the final image and use it only to build our app and include only the .NET runtime in the final image to run the application.

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /app

COPY . ./

RUN dotnet publish "WebApi.csproj" -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS runtime

WORKDIR /app
COPY --from=build /app/publish .

ENTRYPOINT ["dotnet", "WebApi.dll"]

Here we define 2 images: the build image used to compile the app which includes all the things needed to build our .NET 6 application and the final one, the runtime  which only includes the .NET runtime to run our app. The first one to use for development (which contained everything needed to build your application), and a slimmed-down one to use for production, which only contained your compiled code and what is needed to run it. With multi-stage builds, you use multiple FROM statements in your Dockerfile. Each FROM instruction can use a different base, and each of them begins a new stage of the build. You can selectively copy artifacts from one stage to another, leaving behind everything you don’t want in the final image.

$ docker build -t api-aspnet-multistage .
$ docker images api-aspnet-multistage
REPOSITORY              TAG       IMAGE ID       CREATED       SIZE
api-aspnet-multistage   latest    1021a94feca4   1 weeks ago   212MB

Bang, we reduced our image to 212mb, this is because the complete SDK was removed from the final image.
Let’s see again the layers and the space being used for each one:

USER@DESKTOP-BJUBV5T MINGW64 ~/Documents/sonae/identitymanager-credenciaiscontinente (fix/invalid-account-association-check)
$ docker history api-aspnet-multistage
IMAGE          CREATED        CREATED BY                                      SIZE      COMMENT
1021a94feca4   1 weeks ago    ENTRYPOINT ["dotnet" "WebApi.dll"]              0B        buildkit.dockerfile.v0
<missing>      1 weeks ago    COPY /app/publish . # buildkit                  4.18MB    buildkit.dockerfile.v0
<missing>      1 weeks ago    WORKDIR /app                                    0B        buildkit.dockerfile.v0
<missing>      2 months ago   /bin/sh -c #(nop) COPY dir:a54b266469a09b122…   20.3MB
<missing>      2 months ago   /bin/sh -c #(nop)  ENV ASPNET_VERSION=6.0.1 …   0B
<missing>      2 months ago   /bin/sh -c ln -s /usr/share/dotnet/dotnet /u…   24B
<missing>      2 months ago   /bin/sh -c #(nop) COPY dir:6c537cc098876a5f6…   70.6MB
<missing>      2 months ago   /bin/sh -c #(nop)  ENV DOTNET_VERSION=6.0.1     0B
<missing>      2 months ago   /bin/sh -c #(nop)  ENV ASPNETCORE_URLS=http:…   0B
<missing>      2 months ago   /bin/sh -c apt-get update     && apt-get ins…   37MB
<missing>      2 months ago   /bin/sh -c #(nop)  CMD ["bash"]                 0B
<missing>      2 months ago   /bin/sh -c #(nop) ADD file:09675d11695f65c55…   80.4MB
base image50%
.NET runtime40%
Web App source + compiled code10%

Let´s try to reduce the bigger part, the base image.

3. Multi stage build with Alpine linux Microsoft official image

We can change our base image to use the Alpine Linux instead of the default one Debian bullsyeye which is the base image of the most of the official Microsoft .NET docker images.

FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine AS build
WORKDIR /app

COPY . ./
RUN dotnet publish "WebApi.csproj" -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine

WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "WebApi.dll"]
docker build -t api-multi-alpine .
docker images api-multi-alpine
REPOSITORY         TAG       IMAGE ID       CREATED       SIZE
api-multi-alpine   latest    0c7f1c3b0bfa   1 weeks ago   104MB
$ docker history api-multi-alpine
IMAGE          CREATED        CREATED BY                                      SIZE      COMMENT
0c7f1c3b0bfa   1 weeks ago    ENTRYPOINT ["dotnet" "WebApi.dll"]              0B        buildkit.dockerfile.v0
<missing>      1 weeks ago    COPY /app/publish . # buildkit                  4.12MB    buildkit.dockerfile.v0
<missing>      1 weeks ago    WORKDIR /app                                    0B        buildkit.dockerfile.v0
<missing>      2 months ago   /bin/sh -c wget -O aspnetcore.tar.gz https:/…   20.3MB
<missing>      2 months ago   /bin/sh -c #(nop)  ENV ASPNET_VERSION=6.0.1 …   0B
<missing>      2 months ago   /bin/sh -c wget -O dotnet.tar.gz https://dot…   69.8MB
<missing>      2 months ago   /bin/sh -c #(nop)  ENV DOTNET_VERSION=6.0.1     0B
<missing>      3 months ago   /bin/sh -c #(nop)  ENV ASPNETCORE_URLS=http:…   0B
<missing>      3 months ago   /bin/sh -c apk add --no-cache         ca-cer…   4.35MB
<missing>      3 months ago   /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B
<missing>      3 months ago   /bin/sh -c #(nop) ADD file:762c899ec0505d1a3…   5.61MB
base image10%
.NET runtime80%
Web App source + compiled code10%

Using the alpine instead of the debian base image we were able to reduce our base image size to 10% of our final image.
Now the .NET runtime is the part that is using most of the space of our image so let’s try to reduce this part.

4. Raw alpine with a trim-self-contained and trimmed .NET build

Instead of using the official Microsoft Alpine image which comes with the full .NET runtime we can use the vanilla Alpine base image and build our App using the .NET self-contained build PublishSingleFile option.
Publishing our app as self-contained produces an application that includes the .NET runtime and libraries in a single binary, so we don’t have to use the .NET runtime installed on our base system.


We can also use the PublishTrimmed option while building and publishing our app. With this options the final binary includes only a subset of the .NET framework assemblies, the assemblies that are referenced by our app. The other ones are removed from the final procuded binary file. However, there is a risk that the build-time analysis of the application can cause failures at run time, due to not being able to reliably analyze various problematic code patterns (largely centered on reflection use). To mitigate these problems, warnings are produced whenever the trimmer cannot fully analyze a code pattern. For information on what the trim warnings mean and how to resolve them, see Introduction to trim warnings.


Also, with a self-contained build we also need to specify the target platform which the final binary will be compiled. In this case we are using the alpine-x64 because our base image is the Alpine Linux.

FROM mcr.microsoft.com/dotnet/sdk:6.0-alpine AS build
WORKDIR /app

COPY . ./

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

FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine

WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["./WebApi"]

docker build -t api-multi-alpine-raw .
docker images api-multi-alpine-raw
$ docker history api-multi-alpine-raw
IMAGE          CREATED        CREATED BY                                      SIZE      COMMENT
ac78719fc09a   1 weeks ago    ENTRYPOINT ["./WebApi"]                         0B        buildkit.dockerfile.v0
<missing>      1 weeks ago    COPY /app/publish . # buildkit                  42.2MB    buildkit.dockerfile.v0
<missing>      1 weeks ago    WORKDIR /app                                    0B        buildkit.dockerfile.v0
<missing>      1 weeks ago    RUN /bin/sh -c apk add --no-cache libstdc++ …   35.5MB    buildkit.dockerfile.v0
<missing>      3 months ago   /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B
<missing>      3 months ago   /bin/sh -c #(nop) ADD file:8f5bc5ce64ef781ad…   5.59MB

The final image was reduced to 83mb because it only includes the Alpine base image and a single binary that includes our WebApp compiled code and the assemblies that are referenced by our app!

References

https://docs.docker.com/develop/develop-images/multistage-build/
https://docs.microsoft.com/en-us/dotnet/core/deploying/
https://docs.microsoft.com/en-us/dotnet/core/deploying/trimming/trim-self-contained

https://hub.docker.com/_/microsoft-dotnet

LEAVE A REPLY

Please enter your comment!
Please enter your name here