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