I've started getting into building Docker Containers as deployment packages. These are some learnings that I want to share, hopefully helping countless others with a better build, test and debug cycle with .Net on Docker with Visual Studio.

Start with the defaults

Use the built-in tools in Visual Studio to docker-ify and docker-compose your projects. The defaults all work, and provide a nice reference implementation of how to do things.

In Visual Studio 2017 (I'm using 15.7.3, so at least that version if not earlier) You can select a project from the Solution Explorer, right-click => Add and you will see options for Docker Support and Container Orchestrator Support.

Visual Studio Project context menu showing Docker commands.

Docker Support will create a Dockerfile for your project, that follows some conventions and best practices, including separate build/publish steps. (This may be limited to NetCoreApp projects, I haven't thoroughly tested this function yet outside if that scope. You can even choose between Windows and Linux Containers. This might also create the docker-compose, and .dockerignore files as well.

Container Orchestrator Support creates a new *.dcproj project in your solution that orchestrates a docker-compose file which includes your selected project. If you already have an orchestrator project, the selected project will be added to it.

You can also find instructions on docs.docker.com for .Net Core apps which covers some of the basics and recommendations too.

Proximity is key

Put your Dockerfile in the same folder as the csproj file. At some point in the future (or if someone really digs into the MSBuild files and finds a hack) this should be able to go anywhere. But for now, putting it at the root of the project with the *.csproj project file lets it work correctly with Visual Studio.

By default, you also usually have everything relative to a parent directory, such as the source or repository root. This becomes the context you use. The context can be anywhere you like, but I find shared configs from root as well as the solution file being available is handy. You may even have build scripts here that you include.

Minimum vs default

Let's start with the minimum valid Dockerfile to build our NetCoreApp2.1 application. The application itself doesn't matter, only that it builds.

FROM microsoft/dotnet:2.1-sdk
WORKDIR /src
COPY . .
RUN dotnet publish MyApp.csproj -c Release -o /app
WORKDIR /app
COPY /app .
ENTRYPOINT ["dotnet", "MyApp.dll"]

That's the least you need, but we can do better.

  • This only works if you run build in the context of the project folder
  • This will copy over local bin/obj folders (unless you have a .dockerignore file already - Visual Studio may add one for you.)
  • Our final container is large because it includes all of the dotnet CLI build tools
  • Our final container has all the build artifacts in it, making it larger again

Most of this is solved by following the Best practices which you get for free if you create using Visual Studio.

FROM microsoft/dotnet:2.1-runtime AS base
WORKDIR /app

FROM microsoft/dotnet:2.1-sdk AS build
WORKDIR /src
COPY MyApp/MyApp.csproj MyApp/
RUN dotnet restore MyApp/MyApp.csproj
COPY . .
WORKDIR /src/MyApp
RUN dotnet build MyApp.csproj -c Release -o /app

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

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

Multiple Stages

Talking through this file a bit, we have a multi-stage build, that has three parts.
Note that the actual container instance that matches the tag, is the one that starts with the base container defined in the FROM command, and has run all the instructions up until the next FROM command, or the end of the file.

First, we have a base(FROM microsoft/dotnet:2.1-runtime AS base) that serves two purposes: it defines the final result container base up front and also gets used by Visual Studio when performing a special debug build. Visual Studio will build just this target in a multi-stage build, and copy in the build results to debug with. We can declare anything here that we might want in our final output container, and also need during debugging.

Next, we have a build (FROM microsoft/dotnet:2.1-sdk AS build) which is the container the app is built in. This is where we copy over all our source files (COPY . .).

Next, a publish(FROM build AS publish) which starts from our earlier build, and is used to produce the final binaries.

And finally, a final (FROM base AS final) that starts with our base from earlier and produces the container we consider the resulting application. This container is also configured with any ports we want to expose (possibly done in base) and our application entry point (ENTRYPOINT ["dotnet", "/app/GitHubTagAndRelease.dll"]).

Using multistage in this way solves the large container size concerns from earlier, and even if we copy too much

Briefly about caching. Each build step will cache the results if all previous steps are cached, and with COPY commands, if the hash of the source files hasn't changed. For this reason, we selectively copy over the project first, run a dotnet restore, and then pull in everything else. This caches the NuGet restore step so we don't have to redownload these every time.

Ignore

We still have the issue of the bin/obj files being copied in from the source folder. Luckily, Visual Studio would add a .gitignore file to solve this. If you add your own, the ignore lines you want are:

*/bin
*/obj

This is relative to the base path, so will match MyApp/bin and MyApp.Tests/bin but not src/MyOtherProject/bin. If you want a more comprehensive version, VS gives you this:

.dockerignore
.env
.git
.gitignore
.vs
.vscode
docker-compose.yml
docker-compose.*.yml
*/bin
*/obj

Note that we don't ignore the Dockerfile, which means changes to the Dockerfile also cache-bust at the COPY step.

Build with testing in mind

Like the default conventions, I build in a build container, then publish to a publish container. This means the final container has minimal dependencies. But I add a twist.

When the script does the COPY of the project before the restore (a nice caching enhancement I really like) I also copy the test project file at the same time. This gets restored with the project in another restore. Then after doing the project build, I also run the project Tests. Now I know that my container passed all tests before it built because it has to.

ARG DOCKER_SKIP_TESTS=

FROM microsoft/dotnet:2.1-runtime AS base
WORKDIR /app

FROM microsoft/dotnet:2.1-sdk AS build
WORKDIR /src
COPY MyApp/MyApp.csproj MyApp/
COPY MyApp.Tests/MyApp.Tests.csproj MyApp.Tests/
RUN dotnet restore ./MyApp/MyApp.csproj /p:Configuration=Release
RUN dotnet restore ./MyApp.Tests/MyApp.Tests.csproj /p:Configuration=Release
COPY . .
RUN dotnet build ./MyApp/MyApp.csproj --no-restore -c Release -o /app

FROM build as test
ARG DOCKER_SKIP_TESTS

WORKDIR /src
RUN [ ! -z "$DOCKER_SKIP_TESTS" ] && : || dotnet build ./MyApp.Tests/MyApp.Tests.csproj --no-restore -c Release
RUN [ ! -z "$DOCKER_SKIP_TESTS" ] && : || dotnet test ./MyApp.Tests/MyApp.Tests.csproj --no-restore --no-build -v normal -c Release

FROM build AS publish
WORKDIR /src/MyApp
RUN dotnet publish MyApp.csproj -c Release -o /app

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

When running build and release against the target project, I also tell it to skip the restore. This avoids any restore invalidation that may occur from doing the COPY, for whatever reason. Also during the release, I skip the build, so it reuses the build result from the previous step. Splitting up these steps just saves that little bit of rebuild time and duplication along the way.

I use bash conditional logic to be able to disable the tests from running (to go faster during dev build cycles) as well. The build-arg DOCKER_SKIP_TESTS is unset, and the test commands are run. If I declare this, it will skip running the tests.

docker build --build-arg DOCKER_SKIP_TESTS=1 -f .\MyApp\Dockerfile .

Having DOCKER_SKIP_TESTS= declared on the first line means the cache is invalidated whenever I switch this setting on and off, so I get a clean build with, or without tests, and not somewhere in between.

I've even managed to convince my Dockerfile it is a ci agent, and it publishes coverage and test results to TeamCity... but that is for another article I think.

Wrap up

These are some of the tricks that I make use of in my Dockerfile. Next time, we will take a look at some more advanced features, using an AspNetCore application running in docker and Visual Studio debugging.

For an example of this working in practice, I've set up an example project on GitHub. github.com/csMACnzBlog/DockerDotnetDemo