Dockerizing Java, Ruby, Go, Elixir, and Crystal: A Comparative Exercise, Part 1
At Shore Suite, our Ruby on Rails front-end is largely React-based, interfacing directly with an API built in Ruby Sinatra + Grape. Recently, we’ve begun to feel the “bloat” in our stack — case in point, our main front end Rails app when packaged as a Docker image weighs in at over 1GB!
Excessively large Docker images create “drag” on a project. Builds take longer, deploys take longer, local development is slowed. When pulling a 1GB image on a slow or congested network, our engineers sometimes have time for a snack!
From an operational standpoint, the more CPU cycles and RAM your containers require, the less of them you can fit on any given Docker or Kubernetes node. This means more servers, which means higher operating costs and more monitoring and maintenance efforts.
Obviously, we needed a better way. We wanted to move away from Ruby as our primary back-end stack to something that was leaner and more resource efficient. Additionally, we wanted a language that was more type safe, so preferably a language that enforced type safety at compile-time, and that compiled to small, lean binaries.
Over the past few weeks I’ve been evaluating various languages by creating representative apps for each one of them, using their respective frameworks, and then noting the size and memory usage of the resulting containers.
In this series of articles I will present by findings withholding judgement of any kind. I will write about our final platform choice in a separate article later on.
Table of Contents
The Setup
For a meaningful comparison, we need a framework or structure of evaluation.
First, the three “apps” we will set out to create are:
- A simple
Hello, world!
binary / executable or script - A simple REST API that responds to
GET /hello?who=world
with a JSONHello, world!
response. - Finally, a “full stack” Web app in a representative MVC framework that presents an HTML Web page that, you guessed it, greets us with “Hello, world!”
Additionally, we give ourselves the following guidelines:
- We will try and choose the most popular or representative API or Web MVC framework for each language, respectively. For Ruby, maybe that means Sinatra + Grape for APIs, and obviously Rails for Web MVC, and so on.
- To make things simpler and more uniform, we will use
/usr/src/app
as the working directory in all our Docker images. - Whenever possible, we will use Alpine Linux-based container variants of the respective platforms. After all, we are after the smallest possible resultant Docker image. However,
- We will refrain from “hyper optimizing”. Rather, we will restrict ourselves to only what an engineer relatively new to Docker and each platform would be able to accomplish in a few days time. In other words, while we could try and roll out our own Crystal compiler image that would build against
musl
andlibressl
on Alpine and try to shave off a few more KB, instead we’ll just go with readily available or official images.
With all that in mind, let’s start off with a simple executable or binary.
Hello, Java!
For Java, we’ll use Java 8. The “official” java
images have been deprecated in favor of openjdk
, hence we’ll be primarily working with openjdk:8
(openjdk:8-jdk-slim
to be precise).
We’ll also use Maven as our primary build tool. While it is possible to directly compile to and execute a .class
in Java, the overhead of a .jar
package is practically negligible and, after all, we are trying to simulate real world use and so we employ common or best practices.
For a simple, executable Java .jar
built using Maven, we’ll start with mvn archetype:generate -DgroupId=com.example -DartifactId=hello-world
. For the rest of this exercise, we’ll mostly accept the defaults to any generators or builders. That gives us a template project based on the Maven Quickstart Archetype.
Edit src/main/java/com/example/App.java
so it’ll greet us properly:
public static void main(String[] args)
{
final String who;
if (args.length > 0) {
who = args[0];
} else {
who = "world";
}
System.out.println("Hello, " + who + "!");
}
Adding the appropriate lines in the Maven pom.xml
to build a .jar
with a mainClass
:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
<mainClass>com.example.App</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
Now we can proceed to create our Dockerfile
.
Java, as with most other compiled languages in this exercise, will have a separate “compile” phase that builds the final run-time artifact. In most cases, we won’t need the compile-time tooling and baggage just to execute the resulting package or binary. Thus, for Java (and later on Go, Elixir, and Crystal) we will employ Docker multi-stage builds (a relatively “new” feature introduced in Docker 17.05).
For the build stage, we’ll go ahead and use maven:3.5.2-jdk-8
as the the build stage base. Since Java executables run on the JVM and are largely cross-platform, it shouldn’t really matter which OS variant (-alpine
or -slim
we use at this point).
We just need to copy the project directory into /usr/src/app
then run mvn package
from there like we would normally.
In the second stage, we’ll be building our final image based on openjdk:8-jre-slim
.
First we copy /usr/src/app/target/hello-world-1.0-SNAPSHOT.jar
from the build stage. Then we set our image ENTRYPOINT
to java -jar /usr/src/app/hello-world-1.0-SNAPSHOT.jar
.
Our Dockerfile
should now look like this:
FROM maven:3.5.2-jdk-8-slim as maven
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN mvn packageFROM openjdk:8-jre-slim
WORKDIR /usr/src/app
COPY --from=maven /usr/src/app/target/hello-world-1.0-SNAPSHOT.jar /usr/src/app
ENTRYPOINT ["java", "-jar", "hello-world-1.0-SNAPSHOT.jar"]
Let’s go ahead and build it.
$ docker build -t java-hello-docker .
After a minute or two, let’s test the image:
$ docker run java-hello-docker test
Hello, test!
Splendid. Just how big is our Java CLI image?
REPOSITORY TAG IMAGE ID CREATED SIZE
java-hello-docker latest dd23954f5293 35 seconds ago 205MB
205MB. Note that the openjdk:8-jre-slim
image itself is already 205MB. That means that our JAR (all 2.4KB of it) itself adds next to nothing to the JVM run-time environment base image.
Adding a small delay at the end of the main()
method allows us to use docker stat
to gauge memory consumption:
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
3622188912c6 java-hello-docker 0.23% 9.059MiB / 1.952GiB 0.45% 688B / 0B 0B / 0B 12
Can we do better? Over at Docker Hub if we search for “java alpine” we should find anapsix/alpine-java
. The anapsix/alpine-java:8
image weighs in at 125MB.
Substituting that as the base image for our final build and we getdocker-java-hello
down to a 125MB that still runs using around 9MB of memory.
Hello, Ruby!
Ruby distinguishes itself from all the other languages in this exercise in that it’s interpreted, rather than compiled. Hence, for the simple CLI all we need to do is work with the official ruby:2.5.0-alpine
image .
Also, with Ruby, our “Hello, world!” is little more a one line hello.rb
file:
puts "Hello, #{ARGV[0] || "world"}!"
All we need for our Docker image is, FROM ruby:2.5.0-alpine
, COPY hello.rb
into /usr/src/app
and set the ENTRYPOINT
accordingly:
FROM ruby:2.5.0-alpine
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
COPY hello.rb /usr/src/app/
ENTRYPOINT ["ruby", "hello.rb"]
(This is the main reason why we chose Ruby for our initial stack — it’s super easy to get up and running with, and it’s a pleasant enough language to work with.)
Building and running this works as expected:
$ docker build -t ruby-hello-docker .
$ docker run ruby-hello-docker test
Hello, test!
Our Docker image?
REPOSITORY TAG IMAGE ID CREATED SIZE
ruby-hello-docker latest 9af6f2b68008 10 seconds ago 61.4MB
At this point, there’s very little else we can do to slim it down (without “hyper optimizing”). Adding a sleep
to hello.rb
and docker stats
show us:
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
0ef0c8d42778 ruby-hello-docker 0.00% 5.129MiB / 1.952GiB 0.26% 968B / 0B 0B / 0B 2
Hello, Go!
Go, like Java, is compiled. Go, unlike every other language we’re evaluating, requires a certain project location.
This means that to use the golang:1.10.0-alpine3.7
image properly, Go expects to find our project to be somewhere under${GOPATH}/src/
Fortunately, “Hello, world!” in Go isn’t very hard to write, either:
func main() {
who := "world"
if len(os.Args) > 1 {
who = os.Args[1]
}
fmt.Printf("Hello, %s!\n", who)
}
For Go, just as with Java, we will use a two-stage build. For the builder stage, we use golang:1.10.0-alpine3.7
and copy hello.go
to /go/src/github.com/aisrael/go-hello-docker
:
FROM golang:1.10.0-alpine3.7 as builder
WORKDIR /go/github.com/aisrael/go-hello-docker
COPY hello.go .
RUN go build hello.go
For the final image, we can use just alpine:3.7
and copy the hello
executable from /go/github.com/aisrael/go-hello-docker/hello
into /usr/src/app
:
FROM alpine:3.7
WORKDIR /usr/src/app
COPY --from=builder /go/github.com/aisrael/go-hello-docker/hello .
ENTRYPOINT ["/usr/src/app/hello"]
Build and test the image:
$ docker build -t go-hello-docker .
$ docker run go-hello-docker test
The result:
REPOSITORY TAG IMAGE ID CREATED SIZE
go-hello-docker latest ebb6ed7fec58 5 seconds ago 5.37MB
Finally, we add a time.Sleep
line again and attempt to gauge memory use:
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
beea4bd39d80 go-hello-docker 0.00% 724KiB / 1.952GiB 0.04% 688B / 0B 0B / 0B 4
Hello, Elixir!
Elixir is a curious beast. Its syntax is inspired by Ruby, but it is actually based on the Erlang platform: Elixir code is compiled into BEAM byte code and runs on the Erlang VM.
Elixir is also the only language in our survey that’s considered a “functional language”. Functional languages and platforms have been getting attention lately because of the “best practices” that they encourage or require (such as immutability, lack of state), as well as some niceties they provide or allow (e.g. pattern matching, or Elixir/Erlang’s Supervisor and GenServers for concurrency and shared nothing, message-driven architectures).
It’s remarkable how we’re seeing more and more Ruby on Rails folks shifting to Elixir and its companion Phoenix framework.
Elixir ships with a build tool, mix
. To get started with our CLI project, we just need to call mix new hello
and that gives us a boilerplate Elixir ‘app’ project with a basic “Hello, world” app already helpfully written out for us.
To turn this into a compact executable, we need to create an escript by following the instructions here.
First, we add an :escript
key in our project definition in mix.exs
:
def project do
[
app: :hello,
version: "0.1.0",
elixir: "~> 1.6",
start_permanent: Mix.env() == :prod,
escript: [main_module: Hello],
deps: deps()
]
end
Next, we modify our Hello
module, in hello.ex
, to have a main/1
function and accept the optional argument
def hello([]) do
:world
end def hello([who|_]) do
who
end def main(args) do
IO.puts "Hello, #{Hello.hello(args)}!"
end
For our build stage, we just go ahead and use elixir:1.6.0
even if we’re building for Alpine. As with Java, we only need the Erlang VM to be ‘native’ to our Docker image base OS. The Elixir code should happily run on top on the Erlang VM anywhere else. To minimize our executable , we go ahead and set MIX_ENV
to prod
:
FROM elixir:1.6.0 as mixer
COPY . /usr/src/app
WORKDIR /usr/src/app
ENV MIX_ENV=prod
RUN mix escript.build
For the final image, again we turn to alpine:3.7
:
FROM alpine:3.7
WORKDIR /usr/src/app
RUN apk --update add erlang && rm -rf /var/cache/apk/*
ENV LC_ALL="C.UTF-8"
COPY --from=mixer /usr/src/app/hello /usr/src/app
ENTRYPOINT [ "/usr/src/app/hello" ]
We need theENV LC_ALL=”C.UTF-8"
to avoid some warnings. Build and test:
$ docker build -t elixir-hello-docker .
$ docker run elixir-hello-docker test
The resulting image?
REPOSITORY TAG IMAGE ID CREATED SIZE
elixir-hello-docker latest bb449e67eab9 8 seconds ago 17.7MB
Memory consumption while sleeping?
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
af20a5c2abf6 elixir-hello-docker 0.13% 32.02MiB / 1.952GiB 1.60% 688B / 0B 0B / 0B 30
Hello, Crystal!
Crystal is the newest kid on the block, so to speak. Like Elixir, its syntax is heavily inspired by Ruby. Unlike Elixir, it’s strongly typed with some type inference and compile-time type checks. It also aims to be compact and fast — like Go, it can compile to tiny, self-contained executables.
Unlike both Elixir and Go, but just like Ruby, it’s object-oriented with a bit of functional programming thrown in. In short, Crystal can’t just be boxed in with any of the other languages in our survey.
While Crystal is nascent and its tooling less mature than any of the others, as of today it ships with a decent, working compiler and dependency management out of the box.
Crystal support for Alpine at present leaves much to be desired. While there is an unofficial Docker Alpine image (by Julien Portalier, one of the Crystal core contributors), a lot of Crystal libraries that depend on native libraries still don’t build or work properly under Alpine.
Fortunately, for our “Hello, world!” exercise we won’t run into any of these problems. Remarkably, our one-line “Hello, world” Ruby program works just as well in Crystal, as hello.cr
:
puts "Hello, #{ARGV[0] || "world"}!"
To compile and run this, we just go crystal build --release hello.cr
Since Crystal is compiled, our Dockerfile
is once again two-stage. For the first stage, we use ysbaddaden/crystal-alpine
as our builder image:
FROM ysbaddaden/crystal-alpine:0.24.2 as compiler
WORKDIR /usr/src/app
COPY hello.cr /usr/src/app/
RUN crystal build --release hello.cr
For our final Docker image, we again base it on just alpine:3.7
, but this time we first need to install the pcre
, gc
, libevent
, and libgcc
packages:
FROM alpine:3.7
RUN apk add --update pcre gc libevent libgcc
WORKDIR /usr/src/app
COPY --from=compiler /usr/src/app/hello /usr/src/app/
ENTRYPOINT ["/usr/src/app/hello"]
Build and test:
$ docker build -t crystal-hello-docker .
$ docker run crystal-hello-docker test
The final image:
REPOSITORY TAG IMAGE ID CREATED SIZE
crystal-hello-docker latest d9e1f71f0d6a 37 seconds ago 6.94MB
And when sleeping:
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
e2e8b470dcaa crystal-hello-docker 0.00% 660KiB / 1.952GiB 0.03% 688B / 0B 0B / 0B 2
Part 1 Results
To summarize, here are the resulting image sizes, with their base image sizes for comparison, and a cursory look at memory consumption:
REPOSITORY IMAGE SIZE BASE IMAGE SIZE RAM (IDLE)
java-hello-docker 125.00 MB 125.00 MB 9 MB
ruby-hello-docker 61.40 MB 61.40 MB 5 MB
go-hello-docker 5.37 MB 4.15 MB 724 KB
elixir-hello-docker 17.70 MB 4.15 MB 32 MB
crystal-hello-docker 6.94 MB 4.15 MB 660 KB
The base image here is anapsix/alpine-java:8
for Java, ruby:2.5.0-alpine
for Ruby, and alpine:2.7
for everything else.
The source code for all projects is available on GitHub at:
https://github.com/aisrael/docker_all_the_things
In Part 2 of this series, we’ll build a simple REST API that sends “Hello, world!” JSON data in response to an HTTP GET request.
Postscript
Why not Python? Node.js?
Recall that we were looking for something a) compiled, and b) type safe. Because of this, I didn’t even consider other interpreted languages or platforms similar to Ruby, e.g. Python or Javascript/Node, because then we’d just be “running in place”.
Where’s <insert language here>?
I probably will add Python, Javascript/Node, even Swift to the survey in a follow up article. Please feel free to contribute to https://github.com/aisrael/docker_all_the_things if you feel like adding your favorite language/platform there. Thanks!