Using container layers for .NET Continuous Integration

At my $CURRENT_JOB I work on a mid-sized project1 that has an increasing build time since its inception. One of the biggest culprits is a code-generated project that transforms Protocol Buffers into idiomatic F# code.

Given these facts, our whole CI, from building, to testing, to deploying was taking ~9 minutes. Well, compared to some projects I have worked with, this is not that bad. However, we have lots of room to improve this. While a full build from scratch can take 5 minutes, we shouldn't be waiting for it each code review change.

The project has the known architecture of an OCI container running on Kubernetes. This made me think: "Why not use Docker to cache most of the steps taking during CI such as building the most heavyweight project and running tests?". This was enough to start a journey to make it happen.

Caching external dependencies

The first challenge I faced was to cache the result of a dotnet restore. This command is responsible to restore the dependencies of a project, given that they don't change a lot, it makes sense to cache them. You can see some Docker caching strategies and the .NET one is not really that different. If it wasn't for a small detail.

Some .NET projects, such as the one I'm working on, follow this directory structure:

.
src/
  SomeProject/SomeProject.fsproj
  ...
tests/
  SomeProject.Tests/SomeProject.Tests.fsproj
  ...

Why is that a problem? Copying the *.[f|c]sproj files is kind of tricky because Docker's COPY command won't maintain the original directory structure. In this case, we have to do some shell-fu:

FROM mcr.microsoft.com/dotnet/sdk:$DOTNET_VERSION AS restore-preparation

COPY . ./src/
RUN mkdir ./cache && cd ./src && \
  find . -type f -a \( -iname "*.sln" -o -iname "*.fsproj" -o -iname "*.csproj" \) \
    -exec cp --parents "{}" ../cache/ \;

This code basically copies all the solution and project files to a directory called cache. Most importantly, this keeps the project's directory structure untouched!

After this we can write a new stage that runs dotnet restore on top of these project files:

FROM mcr.microsoft.com/dotnet/sdk:$DOTNET_VERSION AS build
WORKDIR app

# copies the cached solution and project files
COPY --from=restore-preparation ./cache /app
RUN dotnet restore

Building the project

Now, we have to actually build the project/solution. Let's reuse what we did on the previous step for the restore part. Do you remember that we have a heavier dependency project on our solution? This project is perfect to be cached because it doesn't have any other external resources, just generated code.

COPY --from=restore-preparation ./src/src/GRPC /app/src/GRPC
RUN dotnet build --no-restore -c Release src/GRPC/

It's important to build this with these two flags:

  • --no-restore: this avoids restoring the dependencies again as we already did it.
  • -c Release: at the end of this stage we want to have a project ready to be delivered. Building this with the Release configuration allows us to only build it once.

After this we are ready to build the "deployable" solution, don't worry, we are running the tests afterwards.

COPY --from=restore-preparation ./src/ /app/
RUN dotnet publish --no-restore -c Release -o /app/publish /src/<project>

At this project we use Expecto as the testing library. In order to run a test project, you just need to dotnet run as you would do on a regular project. You might be asking: "why run tests as the last step?". Here we are just leveraging the already built project. dotnet run will not try to build it again.

# run tests
RUN dotnet run --no-restore -c Release tests/<test-project>

Conclusion

I won't go over the publishing or running DLL stage in this post as the official documentation is good enough for it.

This small tweak made our CI go from 8 minutes to about 30 seconds during code review. Each change fetches the cached layers and only runs the build and tests. You should be able to adapt this Dockerfile to your project's needs. Caching multiples projects that don't change much or not caching projects at all, only the restore portion. I don't know, it's up to you!

You can see the final Dockerfile version below:

FROM mcr.microsoft.com/dotnet/sdk:$DOTNET_VERSION AS restore-preparation

COPY . ./src/
RUN mkdir ./cache && cd ./src && \
  find . -type f -a \( -iname "*.sln" -o -iname "*.fsproj" -o -iname "*.csproj" \) \
    -exec cp --parents "{}" ../cache/ \;

FROM mcr.microsoft.com/dotnet/sdk:$DOTNET_VERSION AS build
WORKDIR app

# copies the cached solution and project files
COPY --from=restore-preparation ./cache /app
RUN dotnet restore

COPY --from=restore-preparation ./src/src/GRPC /app/src/GRPC
RUN dotnet build --no-restore -c Release src/GRPC/

COPY --from=restore-preparation ./src/ /app/
RUN dotnet publish --no-restore -c Release -o /app/publish /src/<project>

# run tests
RUN dotnet run --no-restore -c Release tests/<test-project>
  1. I mean, this is totally subjective. I consider this to be mid-sized because we have about 5 people working on it and some services deployed from the same codebase.