Improve container size and security with distroless containers for Blazor WASM

The blog post is about how to improve container size and security with distroless containers and Blazor WASM. The author explains how to reduce the number of packages and vulnerabilities in a Linux-based container image by using different techniques

Improve container size and security with distroless containers for Blazor WASM


Let's for the sake of argument assume that a hacker has made it to one of your containers and has access to it. Normally a standard container image for a dotnet container comes with all kinds of packages (about 140 packages) such as apt, bash, bzip, tar, etc. All that tooling is also available for the hacker when he/she tries to gain access to the container. Also, do we really need all that stuff to run a container for dotnet App?

Let's take a look at the default Dockerfile that you get, from Visual Studio, with your Blazor WASM application.


FROM mcr.microsoft.com/dotnet/runtime:6.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
COPY ["BlazorApp1/Server/BlazorApp1.Server.csproj", "BlazorApp1/Server/"]
COPY ["BlazorApp1/Client/BlazorApp1.Client.csproj", "BlazorApp1/Client/"]
COPY ["BlazorApp1/Shared/BlazorApp1.Shared.csproj", "BlazorApp1/Shared/"]
RUN dotnet restore "BlazorApp1/Server/BlazorApp1.Server.csproj"
COPY . .
WORKDIR "/src/BlazorApp1/Server"
RUN dotnet build "BlazorApp1.Server.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "BlazorApp1.Server.csproj" -c Release -o /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "BlazorApp1.Server.dll"]

If we build an image using this dockerfile we can see that the total size is about 213 MB. This is already very small compared to Windows Containers of course. ;-)

So where does that 213 MB come from? That is not because of the size of the Blazor application, but because of the size of the runtime image that we use to run the Blazor app in the container.
Now let's take a closer look at the .NET Runtime by Microsoft | Docker Hub image dockerfile.

ARG REPO=mcr.microsoft.com/dotnet/runtime-deps

# Installer image
FROM amd64/buildpack-deps:bullseye-curl AS installer

# Retrieve .NET Runtime
RUN dotnet_version=6.0.22 \
    && curl -fSL --output dotnet.tar.gz https://dotnetcli.azureedge.net/dotnet/Runtime/$dotnet_version/dotnet-runtime-$dotnet_version-linux-x64.tar.gz \
    && dotnet_sha512='c24ed83cd8299963203b3c964169666ed55acaa55e547672714e1f67e6459d8d6998802906a194fc59abcfd1504556267a839c116858ad34c56a2a105dc18d3d' \
    && echo "$dotnet_sha512  dotnet.tar.gz" | sha512sum -c - \
    && mkdir -p /dotnet \
    && tar -oxzf dotnet.tar.gz -C /dotnet \
    && rm dotnet.tar.gz


# .NET runtime image
FROM $REPO:6.0.22-bullseye-slim-amd64

# .NET Runtime version
ENV DOTNET_VERSION=6.0.22

COPY --from=installer ["/dotnet", "/usr/share/dotnet"]

RUN ln -s /usr/share/dotnet/dotnet /usr/bin/dotnet

We see that this dotnet runtime is being constructed based on an image called mcr.microsoft.com/dotnet/runtime-deps . This is a Linux image with the minimal amount of Linux libs, that are needed to run dotnet. .NET Runtime Dependencies by Microsoft | Docker Hub

On the next line, the complete dotnet runtime installation is downloaded and copied to the image. That's already about 71 MB on itself.

So let's try to make an image for a Blazor WASM App that runs on the bare minimum.

Self-contained

Since dotnet 6, Microsoft has greatly improved the ability of apps to be self-contained. That means that the application package has everything it needs to run on a Linux environment. Also since dotnet 6, Microsoft has improved Trimming options for the deployment process. But before you make your app self-contained, please take a good look at Andrew Lock's post about https://andrewlock.net/should-i-use-self-contained-or-framework-dependent-publishing-in-docker-images/

Now let's combine that power with only the dotnet/runtime-deps image. Maybe the image can be smaller. We need to change 2 two things:

  1. we need to change the image that we are gonna use to run our blazorapp. So we changed the runtime image to runtime-deps image.
  2. secondly, since we are only having runtime dependencies, we need to make the release package self-hosted since the container will no longer have the packages to run the blazorapp.

As you can see based on those changes the size did not change very much. That because we have the following image as a base. .NET Runtime Dependencies by Microsoft | Docker Hub

mcr.microsoft.com/dotnet/runtime-deps:6.0 AS base

Default this image is using the Debian 11-slim image.

Default this image is using the Debian 11-slim image and that image is still very big, with a lot of packages and also, plenty of vulnerabilities as you can see.

Switch to Alpine Linux

Fortunately, we have other options besides Debian as our base image. Here comes Alpine Linux.  index | Alpine Linux

FROM mcr.microsoft.com/dotnet/runtime-deps:6.0-alpine3.18-amd64 AS base

Also in order to use this image correctly we need to make sure that our blazorapp will support this runtime environment. Microsoft has made this easy for us by providing a runtime identifier. Let's use that in our publish command

WORKDIR "/src/BlazorApp1/Server"
RUN dotnet build "BlazorApp1.Server.csproj" -c Release -p:BlazorWebAssemblyEnableLinking=false -o /app/build

FROM build AS publish
RUN dotnet publish --runtime alpine-x64 --self-contained true "BlazorApp1.Server.csproj" -c Release -o /app/publish 
💡
Notice that we set BlazorWebAssemblyEnableLinking to false, for now. See https://learn.microsoft.com/en-us/aspnet/core/blazor/host-and-deploy/configure-linker?view=aspnetcore-3.1

We build this file and check if this will make a difference.

It really made a difference. Our image is now only 131 MB in size, instead of 213 MB that we started with. So let's see if we can take it any further.

Trim the Blazor App

With Dotnet, we have some deployment trimming possibilities.
Trimming is the process of minimizing the deployment size, by getting rid of all the assemblies that are not required to run the App. Be aware that you need to make sure that your app is still running correctly afterward since the process of trimming cannot recognize all dependencies (reflection for example). https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/incompatibilities

WORKDIR "/src/BlazorApp1/Server"
RUN dotnet build "BlazorApp1.Server.csproj" -c Release -p:BlazorWebAssemblyEnableLinking=true -o /app/build

FROM build AS publish
RUN dotnet publish --runtime alpine-x64 --self-contained true "BlazorApp1.Server.csproj"  -c Release /p:PublishTrimmed=true -o /app/publish 
💡
Notice that we now set BlazorWebassemblyEnableLinking=true

Let's build this dockerfile

How cool is this! We got it even smaller. 84 MB

Let's go Distroless

There are some cool initiatives that are looking to minimize the size of your Linux installation of the container. Basically, they remove everything that comes default with the Linux Distro.
"Distroless" images contain only your application and its runtime dependencies. They do not contain package managers, shells, or any other programs you would expect to find in a standard Linux distribution."

GitHub - GoogleContainerTools/distroless: 🥑 Language-focused docker images, minus the operating system.

I also came across this Github repo with some cool examples of how to make your dotnet image distroless.
https://github.com/dpbevin/dotnet-staticfiles

Based on the examples of that GitHub repo I created the following example of a self contained- trimmed Blazor WASM container, which also has a very limited amount of Linux libs. Its now basically Distroless ;-)

FROM mcr.microsoft.com/dotnet/runtime-deps:6.0-alpine3.18-amd64 AS base
WORKDIR /app

# Cleanup /lib
RUN find /lib -type d -empty -delete && \
    rm -r /lib/apk && \
    rm -r /lib/sysctl.d

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
COPY ["BlazorApp1/Server/BlazorApp1.Server.csproj", "BlazorApp1/Server/"]
COPY ["BlazorApp1/Client/BlazorApp1.Client.csproj", "BlazorApp1/Client/"]
COPY ["BlazorApp1/Shared/BlazorApp1.Shared.csproj", "BlazorApp1/Shared/"]
RUN dotnet restore "BlazorApp1/Server/BlazorApp1.Server.csproj"
COPY . .
WORKDIR "/src/BlazorApp1/Server"
RUN dotnet build "BlazorApp1.Server.csproj" -c Release -p:BlazorWebAssemblyEnableLinking=true -o /app/build

FROM build AS publish
RUN dotnet publish --runtime alpine-x64 --self-contained true "BlazorApp1.Server.csproj"  -c Release /p:PublishTrimmed=true -o /app/publish 


# Create runtime image
FROM scratch
COPY --from=base /lib/ /lib
COPY --from=base /usr/lib /usr/lib
COPY --from=base /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt

# chmod hack: extract tmp.tar file with correct flags
# see https://github.com/GoogleContainerTools/distroless/blob/main/base/tmp.tar
ADD tmp.tar .

ENV ASPNETCORE_URLS=http://+:80 \
    DOTNET_RUNNING_IN_CONTAINER=true \
    DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true

WORKDIR /app
COPY --from=publish /app/publish .
EXPOSE 80
CMD ["./BlazorApp1.Server"]

References

Application publishing - .NET
Learn about the ways to publish a .NET application. .NET can publish platform-specific or cross-platform apps. You can publish an app as self-contained or as framework-dependent. Each mode affects how a user runs your app.
Trim self-contained applications - .NET
Learn how to trim self-contained apps to reduce their size. .NET Core bundles the runtime with an app that is published self-contained and generally includes more of the runtime then is necessary.
Known trimming incompatibilities - .NET
Identify patterns and frameworks that are known to have problems with trimming
Configure the Linker for ASP.NET Core Blazor
Learn how to control the Intermediate Language (IL) Linker when building a Blazor app.
GitHub - GoogleContainerTools/distroless: 🥑 Language focused docker images, minus the operating system.
🥑 Language focused docker images, minus the operating system. - GitHub - GoogleContainerTools/distroless: 🥑 Language focused docker images, minus the operating system.
Should I use self-contained or framework-dependent publishing in Docker images?
In this post I compare the impact of the framework-dependent vs self-contained mode on Docker image size, taking layer caching into account
GitHub - dpbevin/dotnet-staticfiles
Contribute to dpbevin/dotnet-staticfiles development by creating an account on GitHub.