Dockerizing Java, Ruby, Go, Elixir, and Crystal: A Comparative Exercise, Part 2

Alistair Israel
8 min readMar 22, 2018

--

In Part 1 of this series, we wrote and packaged a simple “Hello, world!” command line app written in five different languages, mainly as a base case for building the smallest executable Docker image for each language.

This time we move on to a more “real world” scenario, and a common use of Docker containers: building APIs or “microservices”.

We’re going to write a simple HTTP Web service that responds to a GET /hello?who=world with a JSON response that follows the JSON API standard:

{
"data": {
"greeting": "Hello, world!"
}
}

This allows us to flex each language and platform a little bit more compared to our previous round: we’ll be running an HTTP server, handling query parameters, and returning JSON responses. Rather than building our JSON responses as strings by hand, we’ll try to use each platform’s native JSON facilities or a popular library.

Afterwards, we’ll try to run a quick ’n’ dirty load test and benchmark using ab while collecting docker stats along the way to compare the run time characteristics of each container.

Because we’re running everything in Docker, it makes it easy for us to expose each container’s internal listening port on the same port 8000 on our host for uniformity.

Table of Contents

  1. Java + Jersey
  2. Ruby + Sinatra
  3. Go
  4. Elixir + Maru
  5. Crystal + Kemal
  6. Part 2 Results

Java + Jersey

As with the simple CLI, we turn to Maven and have it generate an archetype. Following the instructions in the Jersey User Guide, we start with:

$ mvn archetype:generate -DarchetypeArtifactId=jersey-quickstart-grizzly2 -DarchetypeGroupId=org.glassfish.jersey.archetypes -DinteractiveMode=false -DgroupId=com.example -DartifactId=jersey-docker -Dpackage=com.example

This gives us our starting “template”. We want our service to respond to GET /hello?who=world so we go ahead and replace MyResource.java with Hello.java that looks like the following. Also, rather than build our JSON response by hand using raw strings, we’ll use javax.json:

@Path("hello")
public class Hello {
@GET
@Produces(MediaType.APPLICATION_JSON)
public String get(@QueryParam("who") @DefaultValue("world") final String who) {
return Json.createObjectBuilder()
.add("data", Json.createObjectBuilder().add("greeting", "Hello, " + who + "!"))
.build()
.toString();
}
}

(Make sure to add/edit HelloTest.java accordingly!)

The only thing to note is that most Web servers, unless configured, will run on localhost. That is, they’ll bind to the 127.0.0.1 IP address.

But when running under Docker, the container’s localhost is really local, and only to the container! That is, the Docker container will get a virtual IP address and that’s what’s presented when we expose that port to the outside world (e.g., when using docker run -p 8000:8080).

In other words, for most of the frameworks for the rest of this exercise, since we won’t know beforehand what a container’s virtual IP address will be, we need to tell the framework to listen or bind to all IP addresses, or 0.0.0.0. Thus, in Main.java we need to tell Grizzly:

    // Base URI the Grizzly HTTP server will listen on
public static final String BASE_URI = "http://0.0.0.0:8080/";

Since we want to package this as a minimal Docker image, once again we want to make the resulting Java JAR executable, so we add the following to pom.xml

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
<mainClass>com.example.Main</mainClass>
</manifest>
</archive>
</configuration>
</plugin>

Now, unlike our simple CLI, this Java project requires other JARs (our Maven dependencies) in the CLASSPATH at run-time. We need a way to package our run-time JAR dependencies along in the final Docker image. Fortunately, the Maven Dependency Plugin provides a relatively easy way to do that. Again, we add to pom.xml

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>2.8</version>
<configuration>
<includeScope>runtime</includeScope>
<outputFile>target/.classpath</outputFile>
</configuration>
</plugin>

This tells the plugin to list all run-time JAR dependencies in the target/.classpath file. We’ll parse this file in our Dockerfile later to know which JARs to copy to the final image. Our build stage will look like:

FROM maven:3.5.2-jdk-8 as builder
RUN mkdir -p /usr/src/app/lib
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN mvn dependency:build-classpath && \
cat target/.classpath |\
awk -F: '{for (i = 0; ++i <= NF;) print $i }'|\
while read f; do cp "$f" lib/; done
RUN mvn package

There’s a bit of awk magic going on in there, but don’t worry about it. All it does is parse the target/.classpath file (a list of JAR files separated by :)and copies each JAR file listed there into lib/

Our final image stage just brings all the relevant JARs above in to the final image. Once again, we base off anapsix/alpine-java:8:

FROM anapsix/alpine-java:8
EXPOSE 8080
RUN mkdir -p /usr/src/app/lib
WORKDIR /usr/src/app
COPY --from=builder /usr/src/app/lib/* /usr/src/app/lib/
COPY --from=builder /usr/src/app/target/*.jar /usr/src/app/lib/
ENTRYPOINT [ "java", "-cp", "/usr/src/app/lib/*", "com.example.Main" ]

Build and run it:

$ docker build -t java-jersey-docker .
$ docker run -p 8000:8080 java-jersey-docker

Test it:

$ curl localhost:8000/hello?who=test
{"data":{"greeting":"Hello, test!"}}

The resulting image:

$ docker images java-jersey-docker
REPOSITORY TAG IMAGE ID CREATED SIZE
java-jersey-docker latest c97a787b0e63 2 minutes ago 131MB

Ruby + Sinatra

Writing APIs in Ruby is quite easy, esp. when using the Sinatra Framework. The only thing to note is, just as with Java, we need to tell the framework to bind to 0.0.0.0:

set :bind, '0.0.0.0'get '/hello' do
who = params['who'] || 'world'
json(
data: {
greeting: "Hello, #{who}!"
}
)
end

Our Dockerfile is very similar to our earlier Ruby one, with just one stage. The only difference is since we’re now using other Ruby gems as our dependencies, we need to run bundle install, and use bundle exec for good measure:

FROM ruby:2.5.0-alpine
EXPOSE 4567
RUN mkdir -p /usr/src/app/app
WORKDIR /usr/src/app
COPY . /usr/src/app/
RUN bundle install
ENTRYPOINT ["bundle", "exec", "ruby", "app/api.rb"]

Build it:

$ docker build -t ruby-sinatra-docker .
$ docker run -p 8000:4567 ruby-sinatra-docker

Test it:

$ curl localhost:4567/hello?who=test
{"data":{"greeting":"Hello, test!"}}

The image:

$ docker images ruby-sinatra-docker
REPOSITORY TAG IMAGE ID CREATED SIZE
ruby-sinatra-docker latest 742684909a6e 29 seconds ago 83.5MB

Go

With Go, you don’t really need any external libraries for a simple HTTP server. What’s nice about Go is it also has first-class support for JSON serialization. The following is pretty much all we need:

type Greeting struct {
Greeting string `json:"greeting"`
}
type Data struct {
Data interface{} `json:"data"`
}
func main() {
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
who := "world"
values, ok := r.URL.Query()["who"]
if ok && len(values) > 0 {
who = values[0]
}
data := Data{Data: Greeting{Greeting: fmt.Sprintf("Hello, %s!", who)}}
json.NewEncoder(w).Encode(data)
})
log.Fatal(http.ListenAndServe(":8080", nil))
}

Our Dockerfile is almost identical to our earlier one, too:

FROM golang:1.9.4-alpine3.7 as builder
WORKDIR /go/github.com/aisrael/go-api-docker
COPY api.go .
RUN go build -ldflags "-s -w" api.go
FROM alpine:3.7
EXPOSE 8080
WORKDIR /usr/src/app
COPY --from=builder /go/github.com/aisrael/go-api-docker/api .
ENTRYPOINT [ "/usr/src/app/api"]

Build it and test it

$ docker build -t go-api-docker .
$ docker run -p 8000:8080 go-api-docker

The image

$ docker images go-api-docker
REPOSITORY TAG IMAGE ID CREATED SIZE
go-api-docker latest a050f0b5207e Less than a second ago 8.46MB

Elixir + Maru

If you do a search for elixir rest api, you should quickly see results for either the Phoenix framework or Maru. Because I’m reserving Phoenix for the full-stack MVC round, I decided to use Maru as a framework that’s closer to Sinatra or Kemal.

Elixir + Maru is almost as simple as Ruby + Sinatra. After adding the necessary dependencies in mix.exs and following the guide, we just need to write our lib/api.ex

defmodule Api do
use Maru.Router
plug :fetch_query_params
namespace "hello" do
get do
who = conn.params["who"] || "world"
conn
|> json(%{data: %{greeting: "Hello, #{who}!"}})
end
end
rescue_from Maru.Exceptions.NotFound do
conn
|> put_status(404)
|> text("Not Found")
end
end

Elixir, like Ruby, Go, and Crystal all support JSON “out of box” with minimal effort.

However, this time our Dockerfile is slightly more involved. First, the “build” stage:

FROM elixir:1.6.0-alpine as mixer
ENV APP_NAME=api
ENV MIX_ENV=prod
COPY . /src
WORKDIR /src
RUN echo $(cat mix.exs|grep version:|head -n1|awk -F: '{print $2}'|sed 's/[\",]//g'|tr -d '[[:space:]]') > .version
RUN rm -rf /src/_build \
&& mix local.hex --force \
&& mix local.rebar --force \
&& mix deps.get \
&& mix compile \
&& mix release --env=prod
RUN mkdir -p /app \
&& tar xzf /src/_build/prod/rel/api/releases/$(cat .version)/api.tar.gz -C /app

Again, what looks like some awk and sed magic, but this is it’s really simpler: We’re really just trying to get a .version file that contains the project version, parsed from mix.exs. We need this to extract the files from the api.tar.gz that mix release builds, so that in our final Docker image we just deal with the run-time artifacts directly (no need to unpack at run-time).

One final thing to note: the executable script that mix unfortunately produces requires bash, so we need to add this to our final image:

FROM elixir:1.6.0-alpine
EXPOSE 8000
RUN mkdir -p /usr/src/app
RUN apk --update add bash
COPY --from=mixer /app /app
WORKDIR /usr/src/app
ENTRYPOINT ["/app/bin/api"]
CMD ["foreground"]

Build and test it:

$ docker build -t elixir-maru-docker .
$ docker run -p 8000:8000 elixir-maru-docker

The image:

$ docker images elixir-maru-docker
REPOSITORY TAG IMAGE ID CREATED SIZE
elixir-maru-docker latest 244cf4db650e Less than a second ago 107MB

Crystal + Kemal

If Ruby has Sinatra, Crystal has Kemal. It’s no surprise, too, that our api.cr is almost as minimal as our api.rb. Since Kemal by default already binds to 0.0.0.0, we don’t even have to configure that anymore:

require "kemal"get "/hello" do |env|
env.response.content_type = "application/json"
who = env.params.query["who"]? || "world"
{
data: {
greeting: "Hello, #{who}!"
}
}.to_json
end
Kemal.run

The only thing to note with Crystal and Kemal under alpine is it needs openssl which also precludes static linking of the final binary. Otherwise, apart from the addition of the openssl libraries our Dockerfile is again very similar from the simple “Hello, world!” Dockerfile in Part 1.

FROM ysbaddaden/crystal-alpine:0.24.2 as builder
RUN mkdir /src
WORKDIR /src
RUN apk add openssl-dev
COPY . /src/
RUN shards && crystal build --release src/api.cr
FROM alpine:3.7
EXPOSE 3000
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
RUN apk add --update openssl pcre gc libevent libgcc
COPY --from=builder /src/api /usr/src/app/api
ENTRYPOINT [ "/usr/src/app/api"]

Build it and test:

$ docker build -t crystal-kemal-docker .
$ docker run -p 8000:3000 crystal-kemal-docker

The image:

$ docker images crystal-kemal-docker
REPOSITORY TAG IMAGE ID CREATED SIZE
crystal-kemal-docker latest 20bdb0198e85 About a minute ago 11.5MB

Part 2 Results

I wrote a simple, completely unscientific benchmark script in Ruby that relies on ab, the Apache HTTP server benchmarking tool.

It starts each container then performs 10,000 requests, with 20 concurrently. All the while the script would poll the Docker API gathering container statistics, primarily CPU and RAM.

Finally, the metrics for each container are averaged, and the results for all containers presented in a simple table along with the Docker image size.

(Note that the image size reported by docker image and the benchmark script won’t agree because they differ in how they calculate MB from bytes. What I’m presenting here are the sizes reported by docker image.)

My results (on a 13" 2016 MacBook Pro with 2 GHz Intel Core i5 and 8GB of RAM) were as follows:

CONTAINER               IMAGE_SIZE      CPU  AVERAGE_RAM    MAX_RAM
java-jersey-docker 131 MB 71.52% 115.47 MB 123.31 MB
ruby-sinatra-docker 83.5 MB 33.37% 23.32 MB 23.95 MB
go-api-docker 8.46 MB 10.33% 5.78 MB 6.0 MB
elixir-maru-docker 107 MB 21.34% 57.2 MB 65.51 MB
crystal-kemal-docker 11.5 MB 5.66% 2.84 MB 3.12 MB

As before, the source for all projects in this series are available at:
https://github.com/aisrael/docker_all_the_things

Stay tuned for Part 3 of this exercise, where we build “full stack” Web MVC applications using Java Spring Boot, Ruby on Rails, Go + Revel, Elixir + Phoenix and Crystal + Amber.

Postscript

I know my benchmarks will invite some scrutiny. I know, it’s totally unscientific and non-exhaustive. It wasn’t meant to be. Perhaps in a future post I will go through a matrix of benchmark parameters and have more careful controls, but that’s not the point of this exercise.

--

--