Leveraging container layers for faster .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:

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. ↩︎


Articles from blogs I follow around the net

Simpler encapsulation with immutability

A worked example. I've noticed that many software organizations struggle with encapsulation with 'bigger' problems. It may be understandable and easily applicable to define a NaturalNumber type or ensure that a minim…

via ploeh blog June 12, 2024

Update on our infrastructure plans

Now that Drew has provided an update on the general state of SourceHut, I would like to follow up with one focusing on our infrastructure. Much has happened under the hood, a lot has not happened, and plans come and go as reality changes its mind every now a…

via Blogs on Sourcehut June 12, 2024

OpenBSD extreme privacy setup

# Introduction This blog post explains how to configure an OpenBSD workstation with extreme privacy in mind. This is an attempt to turn OpenBSD into a Whonix or Tails alternative, although if you really need that level of privacy, use a system from this…

via Solene'% June 10, 2024

Generated by openring