Multi-Stage Docker Builds for Crystal Feb 03, 2019 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