When developing a Julia library or application I typically find myself incrementally adding or modifying my package dependencies. If the particular Julia application also needs to be included in a Docker image this iterative workflow can be quite annoying as any change to the dependencies results in all of the dependencies having to be installed and precompiled from scratch. There seemed like a better approach was possible so after some research and discovering the RUN --mount=type=cache
feature in Docker I came up with the following Dockerfile
:
# syntax=docker/dockerfile:1
ARG JULIA_VERSION=1.8.5
FROM julia:${JULIA_VERSION}
# Switch the Julia depot to use the shared cache storage. As `.ji` files reference
# absolute paths to their included source files care needs to be taken to ensure the depot
# path used during package precompilation matches the final depot path used in the image.
# If a source file no longer resides at the expected location the `.ji` is deemed stale and
# will be recreated.
RUN ln -s /tmp/julia-cache ~/.julia
# Install Julia package registries.
RUN --mount=type=cache,sharing=locked,target=/tmp/julia-cache \
julia -e 'using Pkg; Pkg.Registry.add("General")'
# Disable automatic package precompilation. We'll control when packages are precompiled.
ENV JULIA_PKG_PRECOMPILE_AUTO "0"
# Instantiate the Julia project environment and precompile dependencies.
ENV JULIA_PROJECT /project
COPY Project.toml Manifest.toml ${JULIA_PROJECT}/
RUN --mount=type=cache,sharing=locked,target=/tmp/julia-cache \
julia -e 'using Pkg; Pkg.instantiate(); Pkg.precompile(strict=true)'
# Copy the shared ephemeral Julia depot into the image and remove any installed packages
# not used by our Manifest.toml.
RUN --mount=type=cache,target=/tmp/julia-cache \
rm ~/.julia && \
mkdir ~/.julia && \
cp -rp /tmp/julia-cache/* ~/.julia && \
julia -e 'using Pkg, Dates; Pkg.gc(collect_delay=Day(0))'
Using this approach the Julia registries, packages, and precompilation files are stored in a Docker cache which persists between image builds. The end result is that iterative package development results in much faster image builds as only the missing packages need to be added and precompiled, just like how local development works.
Additionally, this cache is shared between all Docker image builds so this can also help accelerate workflows where multiple Dockerfile
s and Julia Docker images need to be built. That said, through some experimentation it is possible that concurrent Docker image builds can result in file access collisions so I decided to use sharing=locked
to avoid running into these problems even though they seem rare in practice. The downside of sharing=locked
is that concurrent builds will be slower than if we used sharing=shared
but should still be faster than building all dependencies from scratch.
Let me know if this approach to building Docker applications is useful for your workflow. Maybe I’ll try to add this as documentation in docker-library/julia if this is useful.