blog post image
Andrew Lock avatar

Andrew Lock

~14 min read

Building ASP.NET Core apps using Cake in Docker

In a previous post, I showed how you can use Docker Hub to automatically build a Docker image for a project hosted on GitHub. To do that, I created a Dockerfile that contains the instructions for how to build the project by calling the dotnet CLI.

In this post, I show an alternative way to build your ASP.NET Core app, by using Cake to build your project inside the Docker container. We'll create a Cake build script that lets you both build outside and inside Docker, while taking advantage of the layer-caching optimization inherant to Docker.

tl;dr; You can optimise your Cake build scripts for running in Docker. To jump straight to the scripts themselves click here for the Cake script and here for the Dockerfile.

Background: why bother using Cake?

Building and publishing an ASP.NET Core project involves a series of steps that you have to undertake in order :

  • dotnet restore - Restore the NuGet packages for the solution
  • dotnet build - Build the solution
  • dotnet test - Run the unit tests in a project, don't publish if the tests fail.
  • dotnet publish - Publish a project, optimising it for production

Some of those steps can be implicitly run by later ones, for example dotnet test automatically calls dotnet build and dotnet restore, but fundamentally all those steps need to be run.

Whenever you have a standard set of commands to run, automation/scripting is the answer! Oftentimes people use Bash scripts when you're building in Docker containers, as that's a natural scripting language for Linux, and is available without any additional dependencies.

However, my preferred approach is to use Cake so that I can write my scripts in C#. This is even better now as you can get Intellisense for your .Cake files in Visual Studio Code. Using Cake has the added benefit of being cross platform (unlike Bash scripts), so I can run Cake "natively" on my dev machine, and also as the build script in a Docker container.

The two versions of Cake

Cake is built on top of the Roslyn compiler, and is available cross platform (Windows, macOS, Linux). There's actually two different versions of Cake:

  • Cake - Runs on .NET Framework on Windows, or Mono on macOS and Linux
  • Cake.CoreClr - Runs on .NET Core, on all platforms

You'd think the Cake.CoreClr version would be perfect for this situation - we have the .NET Core SDK installed in our docker container, and so Cake should be able to use it right?

The problem is that currently, Cake.CoreClr targets .NET Core 1.0 - you can't use it on a machine (or Docker container) that only has the .NET Core 2.0 SDK installed. This is a known issue, but it rather negates some of the benefits of Cake.CoreClr for our situation. We'll either have to install the .NET Core 1.0 SDK or Mono in order to run Cake in our Docker containers.

For that reason, I decided to go with the full Cake version. This is mostly so that I don't need to install any prerequisites (previous versions of the .NET Core SDK) on my dev Windows machine (.NET Framework is obviously already available). In the Docker container, we can install Mono for Cake.

Installing Cake into your project

If you're new to Cake I recommend following the getting started tutorial on the Cake website. On top of that, I strongly recommend the Cake extension for Visual Studio Code. This extension lets you easily add the necessary bootstrapping and build files to your project, as well install Intellisense!

Cake commands

Once you've installed a bootstrapper and you have a build script, you'll be able to run it using PowerShell in Windows:

> .\build.ps1
Preparing to run build script...
Running build script...
Running target Default in configuration Release

or using Bash on Linux:

$ ./build.sh
Preparing to run build script...
Running build script...
Running target Default in configuration Release

Optimising Cake build scripts for Docker

Normally, when I'm building on a dev (or CI) machine directly, I use a script very similar to the one described by Muhammad Rehan Saeed in this post. However, Docker has an important feature, layer caching, that it's worth optimising for.

I won't go into how layer caching works just yet. For now it's enough to know that we want to be able to perform the same individual steps that you can with the dotnet CLI, such as restore, build, and test. Normally, each of the higher level tasks in my cake build script is dependent on earlier tasks, for example:

Task("Build")
    .IsDependentOn("Restore")
    .Does(() => { /* do the build */ });

Task("Restore")
    .IsDependentOn("Clean")
    .Does(() => { /* do the restore */ });

You can invoke specific tasks by passing them to the -Target parameter when you call the build script. For example, the Build task would be invoked on windows using:

> .\build.ps1 -Target=Build

Cake works out the tree of dependencies, and performs each necessary task in order. In this case, Cake would execute Clean, Restore, and finally Build.

To make it easier to optimise the Dockerfile, I remove the IsDependentOn() dependencies from the tasks, so they only perform the Does() action. I then create "meta" tasks that are purely chains of dependencies, for example:

Task("BuildAndTest")
    .IsDependentOn("Clean")
    .IsDependentOn("Restore")
    .IsDependentOn("Build")
    .IsDependentOn("Test");

This configuration allows fine grained control over what's executed. If you only want to execute a specific task, without its dependencies you can do so. When you want to perform a series of tasks in sequence, you can use a "meta" task instead.

The Cake build script

With that in mind, here is the full Cake build script for the example ASP.NET Core from my last post. You can see a similar script in the example GitHub repository in the cake-in-docker branch. For simplicity, I've ignored versioning your project with VersionSuffix etc in this script (see Muhammad's post for more detail)

// Target - The task you want to start. Runs the Default task if not specified.
var target = Argument("Target", "Default");
var configuration = Argument("Configuration", "Release");

Information($"Running target {target} in configuration {configuration}");

var distDirectory = Directory("./dist");
 
// Deletes the contents of the Artifacts folder if it contains anything from a previous build.
Task("Clean")
    .Does(() =>
    {
        CleanDirectory(distDirectory);
    });
 
// Run dotnet restore to restore all package references.
Task("Restore")
    .Does(() =>
    {
        DotNetCoreRestore();
    });
 
// Build using the build configuration specified as an argument.
 Task("Build")
    .Does(() =>
    {
        DotNetCoreBuild(".",
            new DotNetCoreBuildSettings()
            {
                Configuration = configuration,
                ArgumentCustomization = args => args.Append("--no-restore"),
            });
    });
 
// Look under a 'Tests' folder and run dotnet test against all of those projects.
// Then drop the XML test results file in the Artifacts folder at the root.
Task("Test")
    .Does(() =>
    {
        var projects = GetFiles("./test/**/*.csproj");
        foreach(var project in projects)
        {
            Information("Testing project " + project);
            DotNetCoreTest(
                project.ToString(),
                new DotNetCoreTestSettings()
                {
                    Configuration = configuration,
                    NoBuild = true,
                    ArgumentCustomization = args => args.Append("--no-restore"),
                });
        }
    });
 
// Publish the app to the /dist folder
Task("PublishWeb")
    .Does(() =>
    {
        DotNetCorePublish(
            "./src/AspNetCoreInDocker.Web/AspNetCoreInDocker.Web.csproj",
            new DotNetCorePublishSettings()
            {
                Configuration = configuration,
                OutputDirectory = distDirectory,,
                ArgumentCustomization = args => args.Append("--no-restore"),
            });
    });

// A meta-task that runs all the steps to Build and Test the app
Task("BuildAndTest")
    .IsDependentOn("Clean")
    .IsDependentOn("Restore")
    .IsDependentOn("Build")
    .IsDependentOn("Test");

// The default task to run if none is explicitly specified. In this case, we want
// to run everything starting from Clean, all the way up to Publish.
Task("Default")
    .IsDependentOn("BuildAndTest")
    .IsDependentOn("PublishWeb");

// Executes the task specified in the target argument.
RunTarget(target);

As you can see, none of the main tasks have dependencies; so Build only builds, it doesn't restore (it explicitly doesn't try and restore in fact, by using the --no-restore argument). We'll use these tasks in the next section, when we create the Dockerfile that we'll use to build our app (on Docker Hub/).

A brief introduction to Docker files and layer caching

A Dockerfile is effectively a "build script" for Docker images. It contains the series of steps, starting from a "base" image, that should be run to create your image. Each step can do something like set an environment variable, copy a file, or run a script. Whenever a step is run, a new layer is created. Your final Docker image consists of all the changes introduced by the layers in your Dockerfile.

Docker is quite clever about caching these layers. Multiple images can all share the same base image, and even multiple layers, as long as nothing has changed from when the image was created.

For example, say you have the following Dockerfile:

FROM microsoft/dotnet:2.0.3-sdk

COPY ./my-solution.sln  ./
COPY ./src ./src

RUN dotnet build

This Dockerfile contains 4 commands:

  • FROM - This defines the base image. All later steps add layers on top of this base image.
  • COPY - Copy a file from your filesystem to the Docker image. We have two separate COPY commands. The first one copies the solution file into the root folder, the second copies the whole src directory across.
  • RUN - Executes a command in the Docker image, in this case dotnet build.

When you build a Docker image, Docker pulls the base image from a public (or private) registry like Docker Hub, and applies the changes defined in the Dockerfile. In this case it pulls the microsoft/dotnet:2.0.3-sdk base image, copies across the solution file, then the src directory, and finally runs dotnet build in your image.

Docker "caches" each individual layer after it has applied the changes. If you build the Docker image a second time, and haven't made any changes to my-solution.sln, Docker can just reuse the layers it created last time, up to that point. Similarly, if you haven't changed any files in src, Docker can just reuse the layer it created previously, without having to do the work again.

Optimising for this layer caching is key to having performant Docker builds - if you can structure things such that Docker can reuse results from previous runs, then you can significantly reduce the time it takes to build an image.

This was a very brief introduction to how Docker builds images, if you're new to Docker, I strongly suggest reading Steve Gordon's post series on Docker for .NET developers, as he explains it all a lot clearer an in greater detail than I just have!

The Dockerfile I will show shortly uses a feature called multi-stage builds. This lets you use multiple base images to build your Docker images, so your final image is as small as possible. Typically, applications require many more dependencies to build them than to run them. Multi-stage builds effectively allow you to build your image in a large image with many dependencies installed, and then copy your published app to a small lightweight container to run. Scott Hansleman has a great post on this which is worth checking out for more details.

The Dockerfile - calling Cake inside Docker

In this section I show you what you've been waiting for: the actual Docker file that uses Cake to build an ASP.NET Core app. I'll start by showing the whole file to give you some context, then I'll walk through each command to explain why it's there and what it does.

# Build image
FROM microsoft/aspnetcore-build:2.0.3 AS builder

# Install mono for Cake
ENV MONO_VERSION 5.4.1.6

RUN apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF

RUN echo "deb http://download.mono-project.com/repo/debian stretch/snapshots/$MONO_VERSION main" > /etc/apt/sources.list.d/mono-official.list \
  && apt-get update \
  && apt-get install -y mono-runtime \
  && rm -rf /var/lib/apt/lists/* /tmp/*

RUN apt-get update \
  && apt-get install -y binutils curl mono-devel ca-certificates-mono fsharp mono-vbnc nuget referenceassemblies-pcl \
  && rm -rf /var/lib/apt/lists/* /tmp/*

WORKDIR /sln

COPY ./build.sh ./build.cake ./NuGet.config   ./

# Install Cake, and compile the Cake build script
RUN ./build.sh -Target=Clean

# Copy all the csproj files and restore to cache the layer for faster builds
# The dotnet_build.sh script does this anyway, so superfluous, but docker can 
# cache the intermediate images so _much_ faster
COPY ./aspnetcore-in-docker.sln ./
COPY ./src/AspNetCoreInDocker.Lib/AspNetCoreInDocker.Lib.csproj  ./src/AspNetCoreInDocker.Lib/AspNetCoreInDocker.Lib.csproj
COPY ./src/AspNetCoreInDocker.Web/AspNetCoreInDocker.Web.csproj  ./src/AspNetCoreInDocker.Web/AspNetCoreInDocker.Web.csproj
COPY ./test/AspNetCoreInDocker.Web.Tests/AspNetCoreInDocker.Web.Tests.csproj  ./test/AspNetCoreInDocker.Web.Tests/AspNetCoreInDocker.Web.Tests.csproj
RUN sh ./build.sh -Target=Restore

COPY ./test ./test
COPY ./src ./src

# Build, Test, and Publish
RUN ./build.sh -Target=Build && ./build.sh -Target=Test && ./build.sh -Target=PublishWeb

#App image
FROM microsoft/aspnetcore:2.0.3
WORKDIR /app
ENV ASPNETCORE_ENVIRONMENT Production
ENTRYPOINT ["dotnet", "AspNetCoreInDocker.Web.dll"]
COPY --from=builder ./sln/dist .

This file is for the same solution I described in my previous post, which contains 3 projects:

  • AspNetCoreInDocker.Lib - A .NET Standard class library project
  • AspNetCoreInDocker.Web - A .NET Core app based on the default templates
  • AspNetCoreInDocker.Web.Tests - A .NET Core xUnit test project.

It fundamentally builds the app in the normal way - it installs the prerequisites, restores nuget packages, builds and tests the solution, and finally publishes the app. We just do all that inside of Docker, using Cake.

Dissecting the Dockerfile

This post is already pretty long, but I wanted to walk through the Dockerfile and explain why it's written the way it is.

FROM microsoft/aspnetcore-build:2.0.3 AS builder

The first line in the Dockerfile defines the base image. I've used the microsoft/aspnetcore-build:2.0.3 base image, which has the prerequisites for .NET Core and the 2.0.3 SDK already installed. I also give it a name builder which we can refer to later when we build our runtime image, as part of the multi-stage build.

ENV MONO_VERSION 5.4.1.6

RUN apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF

RUN echo "deb http://download.mono-project.com/repo/debian stretch/snapshots/$MONO_VERSION main" > /etc/apt/sources.list.d/mono-official.list \  
  && apt-get update \
  && apt-get install -y mono-runtime \
  && rm -rf /var/lib/apt/lists/* /tmp/*

RUN apt-get update \  
  && apt-get install -y binutils curl mono-devel ca-certificates-mono fsharp mono-vbnc nuget referenceassemblies-pcl \
  && rm -rf /var/lib/apt/lists/* /tmp/*

The next big chunk of the Dockerfile is installing Mono. As discussed previously, I'm using the "full" version of Cake, which runs on .NET Framework on Windows and Mono on Linux/macOS, so I need to install Mono into our build image.

The installation script shown above is pulled from the official Mono Dockerfiles for both the mono:5.4.1.6 image and the mono:5.4.1.6-slim image it's based on. By installing Mono before anything else, Docker can cache the output layer, and will not need to perform the (relatively slow) installation on my machine again, even if my ASP.NET Core app completely changes.

WORKDIR /sln

COPY ./build.sh ./build.cake ./

RUN ./build.sh -Target=Clean

After installing Mono, I copy across my Cake bootstrapper (build.sh) and my Cake build script (build.cake), and run the first of the Cake tasks, Clean. This task just deletes anything in the output dist directory.

This probably seem superflous - we're building a clean Docker image, so that directory won't even exist, let alone have anything in it.

Instead, I include this task here as it will cause the bootstrapper to install Cake and compile the build script. Given the bootstrapper and .cake file will rarely change, we can again take advantage of Docker layer caching to avoid taking the performance hit of installing Cake every time we change an unrelated file.

COPY ./aspnetcore-in-docker.sln ./  
COPY ./src/AspNetCoreInDocker.Lib/AspNetCoreInDocker.Lib.csproj  ./src/AspNetCoreInDocker.Lib/AspNetCoreInDocker.Lib.csproj  
COPY ./src/AspNetCoreInDocker.Web/AspNetCoreInDocker.Web.csproj  ./src/AspNetCoreInDocker.Web/AspNetCoreInDocker.Web.csproj  
COPY ./test/AspNetCoreInDocker.Web.Tests/AspNetCoreInDocker.Web.Tests.csproj  ./test/AspNetCoreInDocker.Web.Tests/AspNetCoreInDocker.Web.Tests.csproj  
RUN ./build.sh -Target=Restore

In this step, I copy across the solution file, and all of the project files into their respective folders. We can then run the Cake Restore task, which runs dotnet restore.

The project files will generally only change when you change a NuGet package, or perform a major revision like adding or removing a project. By specifically copying these across first, Docker can cache the "restored" solution layer, even though it doesn't have the solution source code in the image yet. That way, if we change a source code file, we don't need to go through the restore process again, we can just use the cached layer.

This is the one part of the file that frustrates me. In order to preserve the correct directory structure, you have to explicitly copy across each file to it's destination. Ideally, you could do something like COPY ./**/*.csproj ./ but that doesn't work unfortunately.

COPY ./test ./test
COPY ./src ./src

RUN ./build.sh -Target=Build && ./build.sh -Target=Test && ./build.sh -Target=PublishWeb

Now we're into the meat of the file. At this point I copy across all the remaining files in the src and test directories, and run the Build, Test, and PublishWeb tasks. Pretty much any changes we make are going to affect these layers, so there's not a lot of point in splitting them into cacheable layers. Instead, I just run them all in one go. If any of them fail, the whole build fails.

Once this layer is complete, we'll have built our app, tested it, and published it to the /sln/dist directory in our "builder" docker image. All we need to do now is copy the output to the runtime base image.

FROM microsoft/aspnetcore:2.0.3
WORKDIR /app
ENV ASPNETCORE_ENVIRONMENT Production
ENTRYPOINT ["dotnet", "AspNetCoreInDocker.Web.dll"]
COPY --from=builder ./sln/dist .

I used the microsoft/aspnetcore:2.0.3 base image for the runtime image, set the default hosting environment to Production (not strictly necessary, but I like to be explicit), and define the ENTRYPOINT for the image. The Entrypoint is the command that will be run by default when a container is created from the image, in this case, dotnet AspNetCoreInDocker.Web.dll.

Finally, I copy the publish output from the builder image into the runtime image, and we're done! To build the image simply push to GitHub if your'e using the automated builds from my previous post, or alternatively use docker build:

docker build .

We now have 3 ways to build the project:

  • Using Cake on Windows with .\build.ps1
  • Using Cake on Linux (if Mono is installed) with .\build.sh
  • Using Cake in Docker with docker build .

You can see a similar example in the sample repository in GitHub, and the output Docker file on Docker Hub with the cake-in-docker tag.

Summary

In this post I described my motivation for using Cake in Docker to build ASP.NET Core apps, why I chose the Mono version of Cake over Cake.CoreClr, and provided an example build script. I discussed at length how both the Cake build script and Docker build scripts are optimised to take advantage of Docker's layer caching mechanism, and walked through an example Dockerfile that builds Cake in Docker.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?