DEV Community

Phil Wilson
Phil Wilson

Posted on

Lightweight .NET5 containers on balena

Before joining balena I was a pure .NET guy. I've been coding .NET since it was released in 2002, back when I was a university student actively avoiding lectures on ALGOL.

Joining balena saw me take a break from .NET and focus my energy on learning the balena stack and improving my docker, python and node.js skills. Lately, however, I've had the itch for .NET again, mostly so that I can play with .NET5. I had dabbled with Blazor in my balenaLocating project but hadn't paid very much attention to the docker image size. I was so focused on releasing my first ground-up project, which was all my own code, that I merely used a multi-stage dockerfile and left it at that. The result is a ~300Mb image, for a single page webapp that doesn't do a whole lot.

Surely we can do better.

The plan

Since the, soon to be released, version 2 of the browser block has an API to dynamically configure it - I wanted to make a remote control for it. I'll use Blazor on .NET5, and explore some of the options to get the image size down to as small as possible.

Let's go!

Setting a baseline

I knocked up a quick Blazor page which sends some API calls to the browser block and displays the results. Nothing complicated. Building that project:

⚡ phil@DESKTOP-I18LU69  C:\Source\Balena_Playground\BrowserRemoteControl                                                                                                 [10:06]
❯ dotnet build
Microsoft (R) Build Engine version 16.8.3+39993bd9d for .NET
Copyright (C) Microsoft Corporation. All rights reserved.   

  Determining projects to restore...
  All projects are up-to-date for restore.
  BrowserRemoteControl -> C:\Source\Balena_Playground\BrowserRemoteControl\bin\Debug\net5.0\BrowserRemoteControl.dll
  BrowserRemoteControl -> C:\Source\Balena_Playground\BrowserRemoteControl\bin\Debug\net5.0\BrowserRemoteControl.Views.dll

Build succeeded.        
    0 Warning(s)        
    0 Error(s)

Time Elapsed 00:00:10.16
Enter fullscreen mode Exit fullscreen mode

resulted in a HUUUUUUUUUUGE 1Mb executable. :D

Alt Text

And then I did a bog basic, single stage dockerfile to build the project and push it to my balena app:

FROM balenalib/%%BALENA_MACHINE_NAME%%-debian-dotnet:5.0-sdk-buster-build as build

WORKDIR /usr/src/app
COPY ./src/* /usr/src/app/

RUN dotnet restore

ENTRYPOINT  ["dotnet", "BrowserRemoteControl.dll"]
Enter fullscreen mode Exit fullscreen mode

whoa!!!!!

[Info]     Uploading images
[Success]  Successfully uploaded images
[Info]     Built on x64_01
[Success]  Release successfully created!
[Info]     Release: b15a7405a5be9ab5df4081bdc34d0678 (id: 1678274)
[Info]     ┌─────────┬────────────┬────────────┐
[Info]     │ Service │ Image Size │ Build Time │
[Info]     ├─────────┼────────────┼────────────┤
[Info]     │ main    │ 1.04 GB    │ 17 seconds │
[Info]     └─────────┴────────────┴────────────┘
[Info]     Build finished in 1 minute, 5 seconds
                            \
                             \
                              \\
                               \\
                                >\/7
                            _.-(6'  \
                           (=___._/` \
                                )  \ |
                               /   / |
                              /    > /
                             j    < _\
                         _.-' :      ``.
                         \ r=._\        `.
                        <`\\_  \         .`-.
                         \ r-7  `-. ._  ' .  `\
                          \`,      `-.`7  7)   )
                           \/         \|  \'  / `-._
                                      ||    .'
                                       \\  (
                                        >\  >
                                    ,.-' >.'
                                   <.'_.''
                                     <'
⚡ phil@DESKTOP-I18LU69  C:\Source\Balena_Playground\BrowserRemoteControl 
Enter fullscreen mode Exit fullscreen mode

1Gb....! That's because I'm pulling the WHOLE .NET5 SDK, which I don't need just to run my little executable.

Multistage dockerfile

First port of call is a multi-stage dockerfile. We don't need all of the build dependencies to run the .NET executable once it's built. So we can build it, and then copy the result into a slimmer run image:

FROM balenalib/%%BALENA_MACHINE_NAME%%-debian-dotnet:5.0-sdk-buster-build as build

WORKDIR /usr/src/app
COPY ./src/* /usr/src/app/

RUN dotnet restore

RUN dotnet publish -c Release -o /usr/src/app/publish

FROM balenalib/%%BALENA_MACHINE_NAME%%-debian-dotnet:5.0-aspnet-run
WORKDIR /app
COPY --from=build /usr/src/app/publish .

ENTRYPOINT  ["dotnet", "BrowserRemoteControl.dll"]
Enter fullscreen mode Exit fullscreen mode

and the result is:

[Success]  Release successfully created!
[Info]     Release: 09beeb66c9be98cc10f362c14a4519c0 (id: 1678468)
[Info]     ┌─────────┬────────────┬────────────┬────────────┐
[Info]     │ Service │ Image Size │ Delta Size │ Build Time │
[Info]     ├─────────┼────────────┼────────────┼────────────┤
[Info]     │ main    │ 280.44 MB  │ 666.39 KB  │ 1 second   │
[Info]     └─────────┴────────────┴────────────┴────────────┘
[Info]     Build finished in 56 seconds
Enter fullscreen mode Exit fullscreen mode

OK! So we're down to 280Mb just by using a multi-stage dockerfile and not keeping all the build dependencies. This is on par with my previous Blazor project....but there's more to be done. Let's play with an Alpine image.

Moving the Alpine

Alpine is a lightweight linux distribution. We can use it to make smaller images for our containers, but the onus is pushed onto the developer to make sure all the runtime dependencies are there. For this project, I need to make sure the .NET5 runtime deps are installed:

FROM balenalib/%%BALENA_MACHINE_NAME%%-debian-dotnet:5.0-sdk-buster-build as build

WORKDIR /usr/src/app
COPY ./src/* /usr/src/app/

RUN dotnet restore --runtime linux-musl-x64

RUN dotnet publish -r linux-musl-x64 -p:PublishSingleFile=true -c Release -o /usr/src/app/publish

FROM balenalib/%%BALENA_MACHINE_NAME%%-alpine as run

RUN install_packages ca-certificates \
        krb5-libs \
        libgcc \
        libintl \
        libssl1.1 \
        libstdc++ \
        zlib

ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT 1
ENV ASPNETCORE_URLS=http://+:80
ENV DOTNET_RUNNING_IN_CONTAINER=true
WORKDIR /app
COPY --from=build /usr/src/app/publish .

ENTRYPOINT  ["./BrowserRemoteControl"]
Enter fullscreen mode Exit fullscreen mode

You can see there, that I'm using an Alpine image and using install_packages to bring in the .NET runtime dependencies. Then I do a couple of ENV statements to set the container up for running an AspNetCore HTTP server (Kestrel) and run the compiled executable.

And the image size? Well:

[Success]  Release successfully created!
[Info]     Release: ce1f65a85e457b4c8a116cbc933262b9 (id: 1678500)
[Info]     ┌─────────┬────────────┬────────────┬────────────┐
[Info]     │ Service │ Image Size │ Delta Size │ Build Time │
[Info]     ├─────────┼────────────┼────────────┼────────────┤
[Info]     │ main    │ 157.69 MB  │ 0 bytes    │ 1 second   │
[Info]     └─────────┴────────────┴────────────┴────────────┘
[Info]     Build finished in 38 seconds
Enter fullscreen mode Exit fullscreen mode

157Mb! That's great.
...
But there's even more.

.NET publishing optimisations

.NET now has a few tricks up it's sleeve to get your image size down even smaller. These are:

-p:PublishSingleFile=true

Create a single file distributable, rather than an exe and a bunch of DLL dependencies

--self-contained true

Package the .NET runtime in with the executable, so that we don't need a runtime to be installed. Note this was also in the Alpine step above, but I've explained it here. :)

-r linux-musl-x64

The self-contained file needs to target a device architecture (otherwise it would need to target them all and waste space). I'm running this experiment on my Intel NUC, so I'm targeting x64, and since it's an Alpine image I need to target the musl C library. The choice of RIDs (runtime identifies) can be found here.

-p:PublishTrimmed=True

Here's the magic part: bundle trimming. This step causes the source to be analysed (it takes a few minutes) and unnecessary .NET framework components removed from the published executable.

-p:TrimMode=Link

More magic. In a nutshell, without this parameter the resulting executable will have the whole of a framework assembly, even if only a part of it is being used. With this option, each member of each assembly is assessed, and only those being referenced will be included. This worked find for me, but I have seen reports of runtime issues - so YMMV. Not setting this for my project causes a difference of ~10MB, so do some testing or just err on the side of caution and leave this optimisation out.

Final result

So now the publish line in my dockerfile from above looks like this:

RUN dotnet publish -p:PublishSingleFile=true -r linux-musl-x64 --self-contained true -p:PublishTrimmed=True -p:TrimMode=Link  -c Release -o /usr/src/app/publish
Enter fullscreen mode Exit fullscreen mode

which gives me a final image size of:

[Success]  Release successfully created!
[Info]     Release: 621d73fa5676e5924f7a885726bc20d5 (id: 1678525)
[Info]     ┌─────────┬────────────┬────────────┬────────────┐
[Info]     │ Service │ Image Size │ Delta Size │ Build Time │
[Info]     ├─────────┼────────────┼────────────┼────────────┤
[Info]     │ main    │ 95.06 MB   │ 23.78 MB   │ 48 seconds │
[Info]     └─────────┴────────────┴────────────┴────────────┘
[Info]     Build finished in 1 minute, 30 seconds
Enter fullscreen mode Exit fullscreen mode

95Mb down from the baseline of 1Gb. Sweet!

So what?

So, if you're thinking about using .NET in docker, including a balena app, then take a look into multi-stage Alpine dockerfiles with some publish optimisation. You can pack quite a lot of functionality into a pretty small image.

Happy hacking!

Discussion (0)