Lightweight .NET5 containers on balena
Tags: balena, dotnet, blazor, docker
Published: 2021-01-30 17:14:16

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](https://en.wikipedia.org/wiki/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](https://devblogs.microsoft.com/dotnet/announcing-net-5-0/). I had dabbled with [Blazor](https://dotnet.microsoft.com/apps/aspnet/web-apps/blazor) in my [balenaLocating](https://github.com/balenalabs-incubator/balena-locating) 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](https://docs.docker.com/develop/develop-images/multistage-build/) 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](https://github.com/balenablocks/browser) 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
```

resulted in a HUUUUUUUUUUGE 1Mb executable. :D

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/z0z9m0q7tt6w0fbl8mrn.PNG)

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

```dockerfile
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"]
```

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 
```

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:

```dockerfile
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"]
```

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

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:

```dockerfile
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"]
```

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

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](https://docs.microsoft.com/en-us/dotnet/core/rid-catalog#linux-rids).

### -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:

```c#
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
```

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

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!