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.