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.
Docker Support will create a
Dockerfile for your project, that follows some conventions and best practices, including separate
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
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
.dockerignorefile 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"]
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
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
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 . .).
FROM build AS publish) which starts from our earlier
build, and is used to produce the final binaries.
And finally, a
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.
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:
This is relative to the base path, so will match
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
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 .
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.
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