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

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




