Multi-Stage Docker Builds for Crystal

- crystal docker

Docker’s multi-stage builds are an excellent way to reduce image size and I use them heavily in my Go projects. Today I realized that Crystal has a --static flag, which according to the documentation only works on Alpine Linux. I therefore decided to give a multi-stage build a try and compare it to other Docker approaches for Crystal.

Here’s the admittedly not very exciting example program:

# test.cr
puts "Hello Docker world!"

Building from the Crystal image

The Crystal maintainers provide official Docker images, so that’s an obvious starting point for our first build:

FROM crystallang/crystal
WORKDIR /src
COPY . .
RUN crystal build --release test.cr -o /test
ENTRYPOINT ["/test"]

Let’s verify that everything worked as expected:

❯ docker run -it --rm crystal-test:crystal
Hello Docker world!

No surprises here, but alas the image size leaves a lot to be desired:

REPOSITORY   TAG     ... SIZE
crystal-test crystal ... 635MB

Building from Alpine

The next attempt uses Alpine Linux as base image. We then install Crystal itself, the Shards dependency manager and the libc-dev meta package which will pull in the correct libc version for the platform:

FROM alpine:latest
RUN apk add -u crystal shards libc-dev
WORKDIR /src
COPY . .
RUN crystal build --release test.cr -o /test
ENTRYPOINT ["/test"]

Everything still works as expected:

❯ docker run -it --rm crystal-test:alpine
Hello Docker world!

We also managed to significantly decrease our image size, but 226MB are still far from ideal for a simple “Hello World” app.

REPOSITORY   TAG    ... SIZE
crystal-test alpine ... 226MB

Multi-stage build from Alpine

Last but not least the promised multi-stage build. We again start from an Alpine image which we call builder, but with an additional --static flag added to the crystal build command. In the second stage we copy the resulting static binary into a Busybox container and run it from there:

FROM alpine:latest as builder
RUN apk add -u crystal shards libc-dev
WORKDIR /src
COPY . .
RUN crystal build --release --static test.cr -o /src/test

FROM busybox
WORKDIR /app
COPY --from=builder /src/test /app/test
ENTRYPOINT ["/app/test"]

Let’s quickly verify that everything’s still in working order:

❯ docker run -it --rm crystal-test:multi
Hello Docker world!

The resulting image is less than 3MB, a reduction of over 630MB from the official image and over 220MB from a “normal” Alpine build.

REPOSITORY   TAG   ... SIZE
crystal-test multi ... 2.86MB

Using scratch instead of busybox further reduces the size to 1.66MB, not bad considering that the dynamically linked version on macOS weighs in at 206kB.

Summary

Using Crystal’s --static flag in Alpine Linux and Docker’s multi-stage builds it’s easy to provide very small Docker images of your Crystal applications to your users. The drastic reduction in size definitely makes up for the slightly more complicated Dockerfile and makes Docker a viable distribution format for Crystal applications.

Comments powered by Talkyard.