It’s painless to get started with Docker in .NET, thanks to the built-in tooling that comes with Visual Studio. At the core of that tooling is the Dockerfile that is auto-generated with the ASP.NET Core and Worker Service templates. Understanding the inner workings of the Dockerfile comes in handy when you’re trying to make changes to it, as I recently found out for myself.
To that end, this article breaks down the ASP.NET Core Dockerfile by looking at how each of the steps works individually and within the whole Docker ecosystem.
Here’s what the Dockerfile looks like when you create a new project in Visual Studio:
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim AS base WORKDIR /app EXPOSE 80 FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster AS build WORKDIR /src COPY ["DockerExplained/DockerExplained.csproj", "DockerExplained/"] RUN dotnet restore "DockerExplained/DockerExplained.csproj" COPY . . WORKDIR "/src/DockerExplained" RUN dotnet build "DockerExplained.csproj" -c Release -o /app/build FROM build AS publish RUN dotnet publish "DockerExplained.csproj" -c Release -o /app/publish FROM base AS final WORKDIR /app COPY --from=publish /app/publish . ENTRYPOINT ["dotnet", "DockerExplained.dll"]
At first glance, it looks like any other script that restores, builds and publishes an application. Surprisingly, there are a large number of Docker concepts in action within this file. Let’s start looking at them by first talking about images.
Docker is built around the concept of layering images. Each layer builds on the previous layer by adding dependencies, packages, runtimes and the like. Eventually, a final image is produced that contains the compiled application and everything it needs to run.
Two concepts to be aware of are those of the base image and parent image. The parent image of the Dockerfile above is
aspnet:3.1-buster-slim, as indicated in the FROM statement.
If you were to go look at
aspnet:3.1-buster-slim‘s Dockerfile, you’d find its parent is
runtime:3.1-buster-slim, which in turn has
runtime-deps:3.1-buster-slim as its parent, which descends from
amd64/debian:buster-slim. The Debian image has something called
scratch as its parent. Any image that inherits from
scratch is called a base image.
Each layer of the inheritence hierarchy above adds something to the mix that’s needed to run an ASP.NET Core application:
amd64/debianinstalls the operating system that the application will run on.
runtime-depssets the ASPNETCORE_URLS variable to http://+:80, which ASP.NET Core uses to determine which port to listen on.
runtimeinstalls the .NET Core runtime.
aspnetinstalls ASP.NET Core.
There are two different parent images used in the Dockerfile above:
sdk:3.1-buster. The ASP.NET Core image is used to run the application. The SDK image is used only to build and publish the application. It contains more tooling is therefore larger than the ASP.NET Core image.
A Docker image is composed of a name and an optional tag. In the case of the
aspnet:3.1-buster-slim image, the name is
aspnet and the tag is
aspnetgives you an indication of what’s in the image. In this case, the runtime for ASP.NET Core.
3.1-buster-slimtag indicates two things:
3.1tells you that it’s running .NET Core 3.1.
buster-slimrefers to the variant of Debian Linux that it’s running.
There is nothing in a Dockerfile to indicate what its name or tags will be once it is succesfully built. The name and tags are specified when you run the
docker build command.
Notice how some of the images in the Dockerfile have forward slashes in them (
amd64/debian). The slashes are there to create a directory structure, if needed. Microsoft use this feature to logically separate the .NET Core images from the .NET Framework images. The ASP.NET and SDK images for .NET Core are both in the
dotnet/core directory, whereas the equivalent images for .NET Framework are in the
The Dockerfile is split into stages. Each stage is represented with a
FROM...AS statement. Stages can be re-used throughout the Dockerfile. You can see this in action in the above Dockerfile:
- The first stage,
base, sets the working directory on the container and exposes port 80 for use by ASP.NET Core.
- The second stage,
build, restores any NuGet packages and builds the application.
- The third stage,
publish, runs the dotnet publish command, creating a publishable version of the application.
finalstage copies the published application onto the base stage that was created at the start of the Dockerfile. This way, the final image is kept as small as possible.
Building The Application
There is a lot of insight to be gleaned from the output of the Docker build command. Here’s a partial output of what happens when building the above Dockerfile:
Sending build context to Docker daemon 18.43kB Step 1/16 : FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim AS base ---> e2cd20adb129 Step 2/16 : WORKDIR /app ---> Using cache ---> 02e10c384850 Step 3/16 : EXPOSE 80 ---> Using cache ---> 537726e12a5b Step 4/16 : FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster AS build ---> cfc38403c6bc Step 5/16 : WORKDIR /src ---> Using cache ---> 54acea1f59d6 Step 6/16 : COPY ["DockerExplained/DockerExplained.csproj", "DockerExplained/"] ---> 65d5c9704db5 Step 7/16 : RUN dotnet restore "DockerExplained/DockerExplained.csproj" ---> Running in 7acad73d1fd7 Restore completed in 1.52 sec for /src/DockerExplained/DockerExplained.csproj. Removing intermediate container 7acad73d1fd7 ---> 988f3f482544 ... Step 16/16 : ENTRYPOINT ["dotnet", "DockerExplained.dll"] ---> Running in 27d7da85d9ec Removing intermediate container 27d7da85d9ec ---> 75bb0acdc7e8 Successfully built 75bb0acdc7e8
Each step in the output refers back to a line of the Dockerfile. There are a few concepts worth pointing out here:
- Lines starting with
---> <hash>indicate the ID of the intermediate image layer that was created by executing the step. An intermediate image be re-used from one build to the next. You can see that happening in the
---> Using cachestatements above.
- Lines starting with
---> Running in <hash>indicate the ID of the container that’s being used to execute a step that could alter the state of the image. Every step generates an intermediate layer, but not every step needs to be run in an intermediate container. Commands such as COPY and RUN need to execute in a container while WORKDIR, FROM, and EXPOSE do not.
Removing intermediate container <hash>: Once a state-altering step has run, the intermediate container that was used to create the layer is no longer needed. It’s cleaned up to save on disk space, but the layer it created is still there.
Visual Studio Fast Mode
I started the previous section by showing the output of the
docker build command. Normally, you’d be debugging from Visual Studio. By default, it builds a Dockerfile with something called container fast mode. Fast mode shortcuts the Dockerfile by executing only the base stage, and copying the compiled application into the container, without regard to anything else within the Dockerfile.
Fast mode is good for reducing build cycle time, but be aware of what’s happening. More than a few of my colleagues have been stumped when making a change in the Dockerfile that is frustratingly ignored by Studio. It’s possible to disable fast mode in Visual Studio at the expense of build time.
Building a .NET Docker image, at the macro level, can seem a bit like magic. Once you start digging, you notice that there are many things going on on under the covers: optimizing the use of parent images, logically layering those images together, and building an application in stages.
The key takeaway is to have a good understanding of what’s going on in the background so that you can solve any potential issues you might encounter.
This was my first post about Docker and .NET. I’ll be building on this post in the near future and covering more about the topic.