Lightweight docker containers for Scala apps

Using the SBT Native Packager it’s quite easy to dockerize your Scala apps. You don’t have to manage custom Dockerfile’s anymore. Let’s start with a minimal example application. I’m going to create a new Scala project from a simple giter8 template using the sbt new command. For this tutorial you’ll need:

  • JDK 8
  • sbt 0.13.13 or higher
  • docker console client 1.10 or higher

Bootstrapping

Let’s first generate a basic Akka HTTP web application based on the official giter8 template.

$ sbt -Dsbt.version=0.13.15 new https://github.com/akka/akka-http-scala-seed.g8

This will prompt for a few parameters. For name we will use “hello-world” and we will leave the defaults for the other parameters. The output should look similar to this:

This is a seed project which creates a basic build for an Akka HTTP
application using Scala.
name [My Akka HTTP Project]: hello-world
scala_version [2.12.3]:
akka_http_version [10.0.10]:
akka_version [2.5.4]:
organization [com.example]:
Template applied in ./hello-world

Let’s try to run the project to verify everything is working:

$ cd hello-world
$ sbt "runMain com.lightbend.akka.http.sample.QuickstartServer"
[info] Loading project definition from /Users/jeroen/hello-world/project
[info] Set current project to hello-world (in build file:/Users/jeroen/hello-world/)
[info] Running com.lightbend.akka.http.sample.QuickstartServer
Server online at http://localhost:8080/
Press RETURN to stop...

The output should be similar to the one above and a web server should be started on http://localhost:8080. We can verify this using curl:

$ curl localhost:8080/users
{"users":[]}%

Great! We are up and running.

Dockerizing

Let’s put on our hipster hat and containerize our little web app. We will use SBT Native Packager for that.

In project/plugins.sbt we will add the dependency by adding a line:

addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.2.1")

Then we enable two plugins by adding the lines to the bottom of build.sbt:

enablePlugins(JavaAppPackaging)
enablePlugins(DockerPlugin)

The first line will enable the default Java Application Archetype and adds support for other formats (e.g. .deb or .rpm packages) as well. The second line will enable support for building Docker images. To make it a runnable application we also need to specify the main class to use in build.sbt:

mainClass in Compile := Some("com.lightbend.akka.http.sample.QuickstartServer")

Alright, let’s build our docker image by running:

$ sbt docker:publishLocal

This command will build the project, package it and builds a Docker image called hello-world:0.1-SNAPSHOT on your local Docker server. Now we can run it:

$ docker run --rm hello-world:0.1-SNAPSHOT
Server online at http://localhost:8080/
Press RETURN to stop...

Awesome! Our app is running inside a Docker container ready to be shipped to production, right? Let’s do the curl test again to double check

$ curl localhost:8080/users
curl: (7) Failed to connect to localhost port 8080: Connection refused

Whoops, what happened there? Since the web server is now running inside a container the port 8080 is also bound to that container and not to the host system we are trying to access with curl. We have to tell docker to publish this port to our host system so we can access it. No worries, this is easy to fix without changing the image. First, kill the container by hitting Ctrl^C. Then restart it by:

$ docker run --rm -p8080:8080 hello-world:0.1-SNAPSHOT

We’ve added the -p flag where we specified the host port and container port to use (in the format hostPort:containerPort). For simplicity we set them both at 8080, but you could use any available host port here. Let’s retry our curl test:

$ curl localhost:8080/users
curl: (52) Empty reply from server

Hmmm, there still seems to be a problem. This happens because we bind our server to localhost which means we only accept connections from the localhost, not over the network. To fix this unfortunately we have to make a small change in the code. Once again, kill the container by hitting Ctrl^C. Then change line 24 of src/main/scala/com/example/WebServerHttpApp to:

startServer("0.0.0.0", 8080)

This will make sure we bind to all interfaces. Save the file, rebuild the Docker image and run it:

$ sbt docker:publishLocal
$ docker run --rm -p8080:8080 hello-world:0.1-SNAPSHOT

Let’s do the curl test again:

$ curl localhost:8080/users
{"users":[]}%

Yay! Now it finally works.

Optimizing

Let’s see what we got so far. We’ve managed to setup a simple Scala web project, we’re able to generate a Docker image out of it with sbt and run it. Let’s inspect the Docker image that we’ve created:

$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
hello-world 0.1-SNAPSHOT 0e636fc1f674 36 minutes ago 779MB

For crying out loud! A 779Mb image for a tiny web app?? That’s quite ridiculous. We must be able to do better than that.

Let’s inspect the Dockerfile generated by sbt:

$ cat target/docker/Dockerfile
FROM openjdk:latest
WORKDIR /opt/docker
ADD opt /opt
RUN ["chown", "-R", "daemon:daemon", "."]
USER daemon
ENTRYPOINT ["bin/hello"]
CMD []

As you can see there’s not much going on here. It’s using the latest image of the openjdk docker repository. This is actually JDK installation based on a Debian image with quite a few build dependencies installed. We probably don’t need most of that. In fact, for our little app we don’t need any build tools since we are just running a binary.

Switching to JRE

Fortunately, Sbt Native Packager allows us to configure the base image we’d like to use. Let’s pick an openjdk image with a JRE instead of a JDK. For this we need to add a line to build.sbt:

dockerBaseImage       := "openjdk:jre"

Save the file, run sbt docker:publishLocal and inspect the image size again:

$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE hello-world 0.1-SNAPSHOT 380f243e3ae9 6 seconds ago 316MB hello-world 0.1-SNAPSHOT 0e636fc1f674 52 minutes ago 779MB

Cool, that saved us already 460 megs! Let’s verify everything is still working. We’ll run a container based on the new image ID to make sure we are using our new image.

$ docker run --rm -p8080:8080 380f243e3ae9
Server online at http://0.0.0.0:8080/
Press RETURN to stop...
$ curl localhost:8080/users
{"users":[]}%

Sweet. Can we do better?

Alpine

What if we could swap to a more lightweight OS? There’s several choices to make here. The openjdk repository offers images based on debian wheezy slim and also the more recent alpine. The latter is only 5Mb in size! If you compare the JRE images based on Wheezy slim with the one based on alpine you’ll notice the difference. They’re at 207Mb and 81Mb respectively. Docker’s official images are moved to alpine already for a while now.

So, let’s use the openjdk:jre-alpine image, shall we? In our build.sbt we need to replace the line dockerBaseImage := “openjdk:jre” with dockerBaseImage := “openjdk:jre-alpine”. Then save the file, run sbt docker:publishLocal and inspect the image size again:

$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE hello-world 0.1-SNAPSHOT c0dc435cd64e 5 seconds ago 122MB hello-world 0.1-SNAPSHOT 380f243e3ae9 10 minutes ago 316MB hello-world 0.1-SNAPSHOT 0e636fc1f674 1 hour ago 779MB

Fantastic! It’s down to 122 Mb now. Let’s quickly double check if everything still works:

$ docker run --rm -p8080:8080 c0dc435cd64e
env: can't execute 'bash': No such file or directory

Uh oh. What happened here? It looks like it’s trying to execute bash, but can’t find the executable. Alpine doesn’t come with bash installed, but the startup scripts generated by SBT Native Packager depend on it. Alpine, however, comes with the Almquist Shell (ash). This is a much more limited shell than bash. To make things work again we need to make sure our startup scripts are ash-compatible.

Ash script plugin

Fortunately, the SBT Native Packager has a plugin to generate ash-compatible startup scripts. We can simply enable it by adding the following to build.sbt:

enablePlugins(AshScriptPlugin)

Save the file, run sbt docker:publishLocal and notice the generated image ID in the build output: [info] Successfully built 3b685a42b137. Then test again with the new image:

$ docker run --rm -p8080:8080 3b685a42b137
Server online at http://0.0.0.0:8080/
Press RETURN to stop...
$ curl localhost:8080/users
{"users":[]}%

Perfect. Everything looks fine again.

That’s all for now. Thanks for reading! The full source code of this tutorial is available here. I encourage you to give the docker plugin of the SBT Native Packager a try. There’s lots of options to customise your images. In most situations you don’t need to manage custom Dockerfile’s imho.

One clap, two clap, three clap, forty?

By clapping more or less, you can signal to us which stories really stand out.