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

The four tenets of SOA revisited

Twenty years after. In the January 2004 issue of MSDN Magazine you can find an article by Don Box titled A Guide to Developing and Running Connected Systems with Indigo. Buried within the (now dated) discussion of the technology…

via ploeh blog March 4, 2024

Building a demo of the Bleichenbacher RSA attack in Rust

Recently while reading Real-World Cryptography, I got nerd sniped1 by the mention of Bleichenbacher's attack on RSA. This is cool, how does it work? I had to understand, and to understand something, I usually have to build it. Well, friends, that is what…

via ntietz.com blog March 4, 2024

How to unbreak Dolphin on SteamOS after the QT6 update

A recent update to Dolphin made it switch to QT6. This makes it crash with this error or something like it: dolphin-emu: symbol lookup error: dolphin-emu: undefined symbol: _Zls6QDebugRK11QDockWidget, version Qt_6 This is fix…

via Xe Iaso's blog March 3, 2024

Generated by openring