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!"
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
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
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
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.
--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.