Dockerizing Java, Ruby, Go, Elixir, and Crystal: A Comparative Exercise, Part 2
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
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.goFROM 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_paramsnamespace "hello" do
get do
who = conn.params["who"] || "world"
conn
|> json(%{data: %{greeting: "Hello, #{who}!"}})
end
endrescue_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
endKemal.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.crFROM 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.