Containerising Blazor Applications With Docker (Part 1)


This is the first post in the series: Containerising Blazor Applications With Docker.

Part 1 - Containerising a Blazor Server App (this post)
Part 2 - Containerising a Blazor WebAssembly App
Part 3 - Publishing to Azure Container Registry using Azure Pipelines


Containers are all the rage now-a-days and for good reason. They solve the problem of how to have an application work consistently regardless of the environment it is run on. This is achieved by bundling the whole runtime environment - the application, it's dependancies, configuration files, etc... Into a single image. This image can then be shared and instances of it, known as containers, can then be run.  

In this post, I'm going to show you how to run a Blazor Server application in a container. We're going to have a look at how to create images and from there how to create containers.

All the code for this post is available on GitHub.

Before we get into things, let's cover what Docker is and a few key concepts.

What is Docker?

Docker is a platform which provides services and tools to allow the building, sharing and running of containers. These containers are isolated from one another but run on a shared OS kernel, making them far more lightweight than virtual machines. This allows more containers to be run on the same physical hardware giving containers an advantage over traditional virtual machines.

As containers only contain what is needed to run the application it makes them extremely quick to spin up. This makes them exceptionally good at scaling on demand. Where a traditional VM would need a few minutes before additional capacity comes online, a container can be started in a few fractions of a second.

Dockerfile

You can think of a dockerfile as a blueprint which contains all the commands, in order, needed to create an image of your application. Docker images are created by running the docker build command against a dockerfile.

Image

Docker images are the result of running a dockerfile. Images are built up in layers, just like an onion, and each layer can also be cached to help speed up build times. Images are immutable once created, but they can be used as base images in a dockerfile to allow customisation. Images can be stored in an image repository such as Docker Hub or Azure Container Registry - think NuGet but for containers - which allows them to be shared with others.

Container

A container is an instance of an image. You can spin up many containers from a single image. They're started by using the docker run command and specifying the image to use to create the container.

Containerising a Blazor Server App

Prerequisites

If you've not done any work with Docker before you will need to install Docker Desktop for Windows or Docker Desktop for Mac. Just follow the setup instructions and you will be up and running in a couple of minutes. For the purpose of this post we're going to be using the default project template for a Blazor Server app.

Creating a Dockerfile

The first thing we're going to do is create a dockerfile in the root of the project. If you're using something other than Visual Studio, such as VS Code then just create a new file in the root of your project called dockerfile with no extension and paste in the code from a bit further down.

If you're using Visual Studio then right click on your project and select Add > Docker Support...

You will then be asked what target OS you want.

I'm choosing Linux as I'm on a Mac anyway plus hosting is cheaper when I want to push this to Azure. If your application does require something Windows specific then make sure to chose Windows here. Once you're done then click OK. After a few seconds you should see a Dockerfile appear in the root of the project.

A word of warning here - I've found this file doesn't always seem to work properly. It seems to expect a certain folder structure where the dockerfile is one level higher than the project, if that's not the case then things won't work. Below is a version of the dockerfile after a couple of modifications to remove the folder structure assumption.

FROM mcr.microsoft.com/dotnet/core/aspnet:3.0-buster-slim AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

FROM mcr.microsoft.com/dotnet/core/sdk:3.0-buster AS build
WORKDIR /src
COPY BlazorServerWithDocker.csproj .
RUN dotnet restore "BlazorServerWithDocker.csproj"
COPY . .
RUN dotnet build "BlazorServerWithDocker.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "BlazorServerWithDocker.csproj" -c Release -o /app/publish

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

You can see that there is a repeating pattern, each section starts using the FROM keyword. As I mentioned earlier, images are like onions, they're built up with lots of layers, one on top of the other. Let's break this all down to understand what each step is doing.

FROM mcr.microsoft.com/dotnet/core/aspnet:3.0-buster-slim AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443

The first section defines the base image that we're going to use to create our applications image, although we're not actually going to use it till the end. It's provided by Microsoft and contains just the .NET Core runtime. We're setting the working directory to be app and exposing ports 80 and 443 which are the ports the container will listen on at runtime. We'll come back to this one at the end.

FROM mcr.microsoft.com/dotnet/core/sdk:3.0-buster AS build
WORKDIR /src
COPY BlazorServerWithDocker.csproj .
RUN dotnet restore "BlazorServerWithDocker.csproj"
COPY . .
RUN dotnet build "BlazorServerWithDocker.csproj" -c Release -o /app/build

The next section is responsible for building the application. This is based on another image provided by Microsoft which contains the full .NET Core SDK. The WORKDIR command sets the working directory inside the container - any actions will now be relative to that directory.

We COPY the csproj from our project to the containers working directory, then run a dotnet restore. After that the COPY command copies over all the other files in the project to the working directory before running a dotnet build in release configuration.

FROM build AS publish
RUN dotnet publish "BlazorServerWithDocker.csproj" -c Release -o /app/publish

This section publishes our app. Here we're specifying the previous build image as the base for this layer, then calling dotnet publish.

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

The last section is what creates our final image. Here you can see we're using the base image from the start of the file, which was the .NET Core runtime image. We set the WORKDIR to app then copy over the published files from the previous publish layer. Finally, we set the entry point for the application. This is the instruction that tells the image how to start the process it will run for us.

Building an Image

Now we have a dockerfile which defines our image we need to use a docker command to actually create it.

docker build -t blazor-server-with-docker .

The -t switch tells docker to tag the image with blazor-server-with-docker which is useful for identifying the image later on. The dot (.) at the end tells docker to look for the dockerfile in the current directory.

This is the output when the command is run.

Sending build context to Docker daemon  1.384MB
Step 1/16 : FROM mcr.microsoft.com/dotnet/core/aspnet:3.0-buster-slim AS base
 ---> 1d97a08ada5a
Step 2/16 : WORKDIR /app
 ---> Running in 36544828bbd4
Removing intermediate container 36544828bbd4
 ---> 900fa0dbd054
Step 3/16 : EXPOSE 80
 ---> Running in 04179e5ba6c1
Removing intermediate container 04179e5ba6c1
 ---> a1c7956d2f42
Step 4/16 : EXPOSE 443
 ---> Running in cb2158f94084
Removing intermediate container cb2158f94084
 ---> d3a0042158b2
Step 5/16 : FROM mcr.microsoft.com/dotnet/core/sdk:3.0-buster AS build
 ---> 3930ade3a33a
Step 6/16 : WORKDIR /src
 ---> Running in cee971fef261
Removing intermediate container cee971fef261
 ---> 97e38dc5f9a3
Step 7/16 : COPY BlazorServerWithDocker.csproj .
 ---> 3b84f698c505
Step 8/16 : RUN dotnet restore "BlazorServerWithDocker.csproj"
 ---> Running in 7f72de4c8de9
  Restore completed in 51.6 ms for /src/BlazorServerWithDocker.csproj.
Removing intermediate container 7f72de4c8de9
 ---> 6f5197517f47
Step 9/16 : COPY . .
 ---> 42a02544a204
Step 10/16 : RUN dotnet build "BlazorServerWithDocker.csproj" -c Release -o /app/build
 ---> Running in 865ecd1518d6
Microsoft (R) Build Engine version 16.3.0-preview-19377-01+dd8019d9e for .NET Core
Copyright (C) Microsoft Corporation. All rights reserved.

  Restore completed in 50.19 ms for /src/BlazorServerWithDocker.csproj.
  You are using a preview version of .NET Core. See: https://aka.ms/dotnet-core-preview
  BlazorServerWithDocker -> /app/build/BlazorServerWithDocker.dll
  BlazorServerWithDocker -> /app/build/BlazorServerWithDocker.Views.dll

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

Time Elapsed 00:00:03.71
Removing intermediate container 865ecd1518d6
 ---> 86742efdaf82
Step 11/16 : FROM build AS publish
 ---> 86742efdaf82
Step 12/16 : RUN dotnet publish "BlazorServerWithDocker.csproj" -c Release -o /app/publish
 ---> Running in 6531e2d1d51b
Microsoft (R) Build Engine version 16.3.0-preview-19377-01+dd8019d9e for .NET Core
Copyright (C) Microsoft Corporation. All rights reserved.

  Restore completed in 13 ms for /src/BlazorServerWithDocker.csproj.
  You are using a preview version of .NET Core. See: https://aka.ms/dotnet-core-preview
  BlazorServerWithDocker -> /src/bin/Release/netcoreapp3.0/BlazorServerWithDocker.dll
  BlazorServerWithDocker -> /src/bin/Release/netcoreapp3.0/BlazorServerWithDocker.Views.dll
  BlazorServerWithDocker -> /app/publish/
Removing intermediate container 6531e2d1d51b
 ---> b60e99eed9d5
Step 13/16 : FROM base AS final
 ---> d3a0042158b2
Step 14/16 : WORKDIR /app
 ---> Running in 7f36e7e2afe8
Removing intermediate container 7f36e7e2afe8
 ---> 07e81be05a8b
Step 15/16 : COPY --from=publish /app/publish .
 ---> a1722a0f9d32
Step 16/16 : ENTRYPOINT ["dotnet", "BlazorServerWithDocker.dll"]
 ---> Running in a964e7810763
Removing intermediate container a964e7810763
 ---> 57f30915dd16
Successfully built 57f30915dd16
Successfully tagged blazor-server-with-docker:latest

As you can see each step in the dockerfile is executed in order and intermediate images are created and removed along the way until the final image is built and tagged.

Another great thing about Docker is it's really efficient when building images. It caches each layer so future builds can be sped up. If you run the build command again you will see this in action.

Sending build context to Docker daemon  1.384MB
Step 1/16 : FROM mcr.microsoft.com/dotnet/core/aspnet:3.0-buster-slim AS base
 ---> 1d97a08ada5a
Step 2/16 : WORKDIR /app
 ---> Using cache
 ---> 900fa0dbd054
Step 3/16 : EXPOSE 80
 ---> Using cache
 ---> a1c7956d2f42
Step 4/16 : EXPOSE 443
 ---> Using cache
 ---> d3a0042158b2
Step 5/16 : FROM mcr.microsoft.com/dotnet/core/sdk:3.0-buster AS build
 ---> 3930ade3a33a
Step 6/16 : WORKDIR /src
 ---> Using cache
 ---> 97e38dc5f9a3
Step 7/16 : COPY BlazorServerWithDocker.csproj .
 ---> Using cache
 ---> 3b84f698c505
Step 8/16 : RUN dotnet restore "BlazorServerWithDocker.csproj"
 ---> Using cache
 ---> 6f5197517f47
Step 9/16 : COPY . .
 ---> Using cache
 ---> 42a02544a204
Step 10/16 : RUN dotnet build "BlazorServerWithDocker.csproj" -c Release -o /app/build
 ---> Using cache
 ---> 86742efdaf82
Step 11/16 : FROM build AS publish
 ---> 86742efdaf82
Step 12/16 : RUN dotnet publish "BlazorServerWithDocker.csproj" -c Release -o /app/publish
 ---> Using cache
 ---> b60e99eed9d5
Step 13/16 : FROM base AS final
 ---> d3a0042158b2
Step 14/16 : WORKDIR /app
 ---> Using cache
 ---> 07e81be05a8b
Step 15/16 : COPY --from=publish /app/publish .
 ---> Using cache
 ---> a1722a0f9d32
Step 16/16 : ENTRYPOINT ["dotnet", "BlazorServerWithDocker.dll"]
 ---> Using cache
 ---> 57f30915dd16
Successfully built 57f30915dd16
Successfully tagged blazor-server-with-docker:latest

As nothing has changed Docker has used the cached version of all the images used during the first build, resulting in a near instant build.

Starting a container

All that's left now is to start an instance of our new image and make sure everything works. We can start a new container using the docker run command.

docker run -p 8080:80 blazor-server-with-docker

The -p switch tell docker to map port 8080 on the host machine to port 80 on the container. Earlier, we used the EXPOSE keyword when creating the image to define which ports our container would listen on, this is where it comes into play. Also having tagged our image has made things much simpler here, we can just use the tag name to specify the image rather than its GUID.

If all goes well you should see something like this.

Open a browser and go to http://localhost:8080/ and you should see the app load.

Summary

In this post, we've looked at what Docker and containers are as well as what benefits they offer over more traditional virtual machines. As well as covering some of the core concepts in Docker. We then used the standard Blazor Server App template to build a Docker image by adding and configuring a dockerfile. Finally we used that image to create a container which ran our Blazor Server application.

Next time we'll look at how we can do the same thing with a Blazor WebAssembly application.