Brian J. Cardiff

Brian J. Cardiff

Shipping Crystal apps in a small Docker image

docker, crystal, deploy
10 min
Apr 3 2017

In this post we will go through the process of building bcardiff/miniserver: a 12MB docker image that runs the github:bcardiff/miniserver Crystal app.

Soon(ish) a great feature will be arriving to your Docker tool:

Essentially, it will simplify the process of building a Docker image that will contain just the required files. This leads to smaller images and reduced download times, letting us save energy and a few trees in the process.

So, let’s move ahead in time and see what we can do to save those trees.

Our time machine in this case will be docker@master. At the very end of this article there is a simple guide on how to do that. But, for now, back to the future.

Building our app

Let’s say we have created a Crystal app with the following structure:

$ tree .
.
├── shard.yml
└── src
    └── miniserver.cr

The shard.yml file will contain a description saying that the miniserver executable should be generated from src/miniserver.cr:

# ...
targets:
  miniserver:
    main: src/miniserver.cr
# ...

In our development environment $ shards build will fetch all dependencies and generate the bin/miniserver executable.

Building the app in Docker

Let’s create a Dockerfile to ship this thing. Our goal here is to be able to run this Dockerfile in an automated way. So, we will start from a Docker image that ships with a Crystal compiler. Do we really need to ship the compiler itself with the miniserver? No, we won’t need it in the final image.

FROM crystallang/crystal:latest

ADD . /src
WORKDIR /src
RUN shards build --production

If we run $ docker build . it will create a image with the /src/bin/miniserver binary but the image is ~400MB, this Docker image is based on ubuntu after all.

If we extract the binary from this new image we will be halfway there: the generated binary depends on shared libraries.

Building a small Docker image

You can follow some manual procedure to find which are the runtime dependencies or, as we will do here, use our favorite language to simplify the task, even if this task is meant to be done once (per project). Help yourself to a copy of the list-deps.cr gist and leave it in ./support/list-deps.cr if you want to be tidy. This script will collect the libs your project depends on, and output the very contents of the second-stage that you’ll need to append to your Dockerfile. So we would have something like this in our working copy:

.
├── Dockerfile
├── shard.yml
├── src
│   └── miniserver.cr
└── support
    └── list-deps.cr

Let’s update the Dockerfile to run the list-deps.cr script again ./bin/miniserver:

FROM crystallang/crystal:latest

ADD . /src
WORKDIR /src
RUN shards build --production
RUN crystal run ./support/list-deps.cr -- ./bin/miniserver

This time after running docker build . we will have some output that will help us in the next step including the new features for multi-stage builds:

$ docker build .
Sending build context to Docker daemon 2.454 MB
Step 1/5 : FROM crystallang/crystal:latest
 ---> 8ceae3fe1276
Step 2/5 : ADD . /src
 ---> 3af380f48502
Removing intermediate container 953cf6b11ae1
Step 3/5 : WORKDIR /src
 ---> cda0b4db245e
Removing intermediate container e639c5e9e720
Step 4/5 : RUN shards build --production
 ---> Running in a9ecd9fb6eea
Dependencies are satisfied
Building: miniserver
 ---> 895e4e9924a2
Removing intermediate container a9ecd9fb6eea
Step 5/5 : RUN crystal run ./support/list-deps.cr -- ./bin/miniserver
 ---> Running in 07806672cdcd
  Extracting libraries for /src/bin/miniserver ...
  Generating Dockerfile

==============================
FROM scratch
COPY --from=0 /lib/x86_64-linux-gnu/libz.so.1 /lib/x86_64-linux-gnu/libz.so.1
COPY --from=0 /lib/x86_64-linux-gnu/libz.so.1.2.8 /lib/x86_64-linux-gnu/libz.so.1.2.8
COPY --from=0 /lib/x86_64-linux-gnu/libssl.so.1.0.0 /lib/x86_64-linux-gnu/libssl.so.1.0.0
COPY --from=0 /lib/x86_64-linux-gnu/libcrypto.so.1.0.0 /lib/x86_64-linux-gnu/libcrypto.so.1.0.0
COPY --from=0 /lib/x86_64-linux-gnu/libm.so.6 /lib/x86_64-linux-gnu/libm.so.6
COPY --from=0 /lib/x86_64-linux-gnu/libm-2.19.so /lib/x86_64-linux-gnu/libm-2.19.so
COPY --from=0 /lib/x86_64-linux-gnu/libpthread.so.0 /lib/x86_64-linux-gnu/libpthread.so.0
COPY --from=0 /lib/x86_64-linux-gnu/libpthread-2.19.so /lib/x86_64-linux-gnu/libpthread-2.19.so
COPY --from=0 /lib/x86_64-linux-gnu/librt.so.1 /lib/x86_64-linux-gnu/librt.so.1
COPY --from=0 /lib/x86_64-linux-gnu/librt-2.19.so /lib/x86_64-linux-gnu/librt-2.19.so
COPY --from=0 /lib/x86_64-linux-gnu/libdl.so.2 /lib/x86_64-linux-gnu/libdl.so.2
COPY --from=0 /lib/x86_64-linux-gnu/libdl-2.19.so /lib/x86_64-linux-gnu/libdl-2.19.so
COPY --from=0 /lib/x86_64-linux-gnu/libgcc_s.so.1 /lib/x86_64-linux-gnu/libgcc_s.so.1
COPY --from=0 /lib/x86_64-linux-gnu/libc.so.6 /lib/x86_64-linux-gnu/libc.so.6
COPY --from=0 /lib/x86_64-linux-gnu/libc-2.19.so /lib/x86_64-linux-gnu/libc-2.19.so
COPY --from=0 /lib64/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2
COPY --from=0 /lib/x86_64-linux-gnu/ld-2.19.so /lib/x86_64-linux-gnu/ld-2.19.so
COPY --from=0 /src/bin/miniserver /miniserver
ENTRYPOINT ["/miniserver"]
==============================
 ---> 92557005cfbe
Removing intermediate container 07806672cdcd
Successfully built 92557005cfbe

Append all these Dockerfile commands the output suggested to your Dockerfile, and add the ENV, EXPOSE, etc. your app needs to properly work.

With these copied lines, we don’t need to run the list-deps.cr again. So the Dockerfile could be:

FROM crystallang/crystal:latest

ADD . /src
WORKDIR /src
RUN shards build --production
# RUN crystal run ./support/list-deps.cr -- ./bin/miniserver

FROM scratch

COPY --from=0 /lib/x86_64-linux-gnu/libz.so.1 /lib/x86_64-linux-gnu/libz.so.1
COPY --from=0 /lib/x86_64-linux-gnu/libz.so.1.2.8 /lib/x86_64-linux-gnu/libz.so.1.2.8
COPY --from=0 /lib/x86_64-linux-gnu/libssl.so.1.0.0 /lib/x86_64-linux-gnu/libssl.so.1.0.0
COPY --from=0 /lib/x86_64-linux-gnu/libcrypto.so.1.0.0 /lib/x86_64-linux-gnu/libcrypto.so.1.0.0
COPY --from=0 /lib/x86_64-linux-gnu/libm.so.6 /lib/x86_64-linux-gnu/libm.so.6
COPY --from=0 /lib/x86_64-linux-gnu/libm-2.19.so /lib/x86_64-linux-gnu/libm-2.19.so
COPY --from=0 /lib/x86_64-linux-gnu/libpthread.so.0 /lib/x86_64-linux-gnu/libpthread.so.0
COPY --from=0 /lib/x86_64-linux-gnu/libpthread-2.19.so /lib/x86_64-linux-gnu/libpthread-2.19.so
COPY --from=0 /lib/x86_64-linux-gnu/librt.so.1 /lib/x86_64-linux-gnu/librt.so.1
COPY --from=0 /lib/x86_64-linux-gnu/librt-2.19.so /lib/x86_64-linux-gnu/librt-2.19.so
COPY --from=0 /lib/x86_64-linux-gnu/libdl.so.2 /lib/x86_64-linux-gnu/libdl.so.2
COPY --from=0 /lib/x86_64-linux-gnu/libdl-2.19.so /lib/x86_64-linux-gnu/libdl-2.19.so
COPY --from=0 /lib/x86_64-linux-gnu/libgcc_s.so.1 /lib/x86_64-linux-gnu/libgcc_s.so.1
COPY --from=0 /lib/x86_64-linux-gnu/libc.so.6 /lib/x86_64-linux-gnu/libc.so.6
COPY --from=0 /lib/x86_64-linux-gnu/libc-2.19.so /lib/x86_64-linux-gnu/libc-2.19.so
COPY --from=0 /lib64/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2
COPY --from=0 /lib/x86_64-linux-gnu/ld-2.19.so /lib/x86_64-linux-gnu/ld-2.19.so
COPY --from=0 /src/bin/miniserver /miniserver

ENV WWW=/www
EXPOSE 80

ENTRYPOINT ["/miniserver"]

It looks like two Dockerfiles in one and that’s exactly the idea of multi-stage builds. The first FROM is the building environment. The second FROM allows us to start from scratch and will grab just the required resources to run the miniserver.

Note: if you aren’t in the future yet, here you might need to perform the jumping ahead in time steps now.

Now we finally build the image we wanted with $ docker build .. This time ~12 MB are used. And thanks to the order of the dependencies 10MB probably will be reused across Docker images. And these numbers are even before compression.

After your image is published you can tell everybody about it. In this case, go ahead and run:

$ docker run -v `pwd`:/www -p 8080:80 bcardiff/miniserver
Listening on http://0.0.0.0:80

You can see the content served by the built-in static file handler of Crystal when you navigate to http://localhost:8080.

Jumping ahead in time

If you want to do all the cool things exposed in this post today, we need to work with docker@master. That is bleeding edge. Let’s be sure we are on the working copy of our miniserver project:

$ ls
Dockerfile bin        shard.yml  src        support
$ pwd
/path/to/miniserver

Let’s checkout and build Docker

$ cd ..
$ git clone git@github.com:docker/docker.git
$ cd docker
$ make tgz
… grab a cup of coffee …

Now we need to have an inception moment. In order to use the recently compiled Docker we need to use it in a Docker image. But we want the miniserver source code available so we can build the Dockerfile with the multi-stage commands.

$ docker run --rm -v `pwd`/../miniserver:/src -v `pwd`/bundles:/go/src/github.com/docker/docker/bundles --privileged -ti docker-dev:master bash

root@f8b6a482ed7a:/go/src/github.com/docker/docker# export PATH=$PATH:`pwd`/bundles/latest/dynbinary-daemon:`pwd`/bundles/latest/binary-client/
root@f8b6a482ed7a:/go/src/github.com/docker/docker# dockerd &
[1] 12
… (press enter to get a console prompt) ....

root@f8b6a482ed7a:/go/src/github.com/docker/docker# cd /src
root@f8b6a482ed7a:/src# docker build . -t bcardiff/miniserver:latest
… grab a small snack …
Successfully tagged miniserver:latest

root@f8b6a482ed7a:/src# docker login
Username: bcardiff
Password: ******
Login Succeeded

root@f8b6a482ed7a:/src# docker push bcardiff/miniserver:latest
The push refers to a repository [docker.io/bcardiff/miniserver]
ed6705234e71: Pushed
bf067b612ffb: Pushed
…
root@f8b6a482ed7a:/src# exit
exit
$

The Docker image is now ready to be used by everybody, even if they are not running a bleeding edge Docker.