Build a Minimal Docker Container for Ruby Apps

Written by: Ilija Eftimov

8 min read

As a Ruby and Rails developer, I've always wanted to have a working and isolated environment for my projects, regardless if it's a client project, some gem that I work on, or a pet project. Back in the day, I had always wanted to achieve this using Vagrant. However, whenever I tried to do it, I always ended up spending a couple of hours on Stack Overflow before giving up.

And even if your middle name is "perseverance" and you manage to get a working bootstrapped environment, often there are problems with system libraries or slight differences in the system environments which can cause problems.

In this article, we are going to build a Docker image with a working Ruby environment and deploy a Ruby application to a container. By using Docker, we will ensure that the environment will always be the same, and it will be easy to reproduce.

One thing to note -- we will not cover Docker installation. If you do not have Docker installed, you can head over to the Docker documentation and install it.

Part One: Our Application

Since we're going to deploy an application to our container, we need a tiny Ruby application. The app that we will deploy will be called Checker. It's only purpose is that it will check if a gem name is available on RubyGems or not. If it's available, it will return true; otherwise, false.

We will overcomplicate this application a bit. We will use a gem called curb, which is just a Ruby wrapper for curl. Yes, we can just shell out and use curl, but for the purpose of this tutorial we will use curb.

Here is the code for our tiny Checker application:

# checker.rb
require 'rubygems' require 'curb'
gem_name = ARGV[0]
raise ArgumentError.new("gem name missing") if gem_name.nil?
if Curl.get("https://rubygems.org/gems/#{gem_name}").status == '200 OK'
  $stdout.puts 'Name not available.'
else
  $stdout.puts 'Name available.'
end

Using this app is easy. Just run:

ruby checker.rb <gem-name>

Part Two: Creating the Docker Image

Since the guys behind Docker are awesome, we can use the official Docker image for Ruby. To use this image, we would write an “onbuild” Dockerfile like this:

FROM ruby:2.1-onbuild
CMD ["./your-daemon-or-script.rb"]

We need to put the Dockerfile in our app directory, next to the Gemfile. The "magic" behind the :onbuild tagged images is that they assume that your project structure is standard, and it will build your Ruby application as a generic Ruby application. If you prefer more control over the build, you can always use base Ruby image and build the app yourself:

FROM ruby:latest
RUN mkdir /usr/src/app
ADD . /usr/src/app/
WORKDIR /usr/src/app/
CMD ["/usr/src/app/main.rb"]

The extra control is really nice if you need to do some additional setup for your Ruby application. Perhaps you want to run the tests or use some external API to check for code metrics. Use your imagination!

Using official Docker images for Ruby is great and all, but there's one very big drawback. They. Are. HUGE. The Ruby image is roughly 1.6 GB. If we're running a tiny Ruby app, like the one in our example, having this big image is nonsense. Just an observation -- I'm not saying the official images are bad or useless. What I am saying is that you have to be sure that you actually need an image that advanced.

https://js.hscta.net/cta/current.js

hbspt.cta.load(1169977, 'c7747d5f-9e9d-4329-8362-71fbdb824ee9', {});

Part Three: How About We Build a Tiny Ruby Docker Image?

Since we need a really small image for running our application, let's see how we can build one. Alpine Linux is a very tiny Linux distribution. It's built on BusyBox, and it includes only the minimum files needed to boot and run the operating system.

In our Ruby application directory, let's create the Dockerfile:

FROM alpine:3.2
MAINTAINER John Doe <john@doe.com>

We will use the official Alpine image for Docker, which weighs an awesome 5MBs!

Alpine Linux uses its own package manager called apk. As a first step of the Dockerfile, we'll need to update the repositories of the package manager. Also, since we want to work with the newest available packages, we need to upgrade the installed packages.

FROM alpine:3.2
MAINTAINER John Doe <john@doe.com>
RUN apk update &amp;&amp; apk upgrade

As you can see, updating the repositories is done via "apk update", while upgrading the installed packages is done via "apk upgrade".

My personal preference is to always have a couple of useful binaries installed, like curl, wget, and bash. Installation of packages in Alpine Linux is done with the apk add <package-name> command. Let's add these to the Dockerfile:

FROM alpine:3.2
MAINTAINER John Doe <john@doe.com>
RUN apk update
RUN apk upgrade
RUN apk add curl wget bash

This means that when building our image, we'll have curl, wget, and bash installed in our image. Next, we need to install Ruby. When installing Ruby, we need to install the latest Ruby distribution and ruby-bundler. The Ruby distribution is the language itself, while ruby-bundler is Bundler wrapped in an apk package. Let's add these to the Dockerfile:

FROM alpine:3.2
MAINTAINER John Doe <john@doe.com>
# Install base packages
RUN apk update
RUN apk upgrade
RUN apk add curl wget bash
# Install ruby and ruby-bundler
RUN apk add ruby ruby-bundler

After having Ruby and ruby-bundler installed, it's considered good practice to clean up after ourselves. Since we only installed a couple of packages, we need to remove the cache of the package manager. Alpine's package manager keeps its cache in the /var/cache path, so removing the cache is as easy as removing everything in the /var/cache/apk path. Let's add that step to the Dockerfile:

FROM alpine:3.2
MAINTAINER John Doe <john@doe.com>
# Install base packages
RUN apk update
RUN apk upgrade
RUN apk add curl wget bash
# Install ruby and ruby-bundler
RUN apk add ruby ruby-bundler
# Clean APK cache
RUN rm -rf /var/cache/apk/*

Next, we need to include our tiny Checker application in this container, so that every time we create a new container it's available to us. First, we need to create a new directory and copy the files of the application in the directory. The last step is to bundle the application.

FROM alpine:3.2
MAINTAINER John Doe <john@doe.com>
# Update and install base packages
RUN apk update &amp;&amp; apk upgrade &amp;&amp; apk add curl wget bash
# Install ruby and ruby-bundler
RUN apk add ruby ruby-bundler
# Clean APK cache
RUN rm -rf /var/cache/apk/*
RUN mkdir /usr/app
WORKDIR /usr/app
COPY Gemfile /usr/app/
COPY Gemfile.lock /usr/app/
RUN bundle install
COPY . /usr/app

The last step of our app is building the image. Just a reminder, it's very important to have the Dockerfile within the project directory, so Docker can copy the project files to the image. Building the image is easy using the docker build command:

docker build jdoe/ruby .

This will build our image. Or it should. If you've been following along and tried building this image, you would get a stack trace that looks something like this:

/usr/lib/ruby/2.2.0/rubygems/core_ext/kernel_require.rb:54:in `require': cannot load such file -- io/console (LoadError)

This means that Ruby requires the "io/console" library which is provided by the io-console gem. When it comes to Alpine Linux, the folks behind the project have added a ruby-io-console package that fixes this dependency. What we only need to do is to add this package to the Dockerfile:

# Install ruby, ruby-io-console and ruby-bundler
RUN apk add ruby ruby-io-console ruby-bundler

If we try to rebuild the image, everything will go well, and we'll get to the point of installing the bundle. At this point, you will understand why the official Ruby image from Docker is ~1.6GB. The reason is that gems can have different dependencies, whether those dependencies are system binaries/packages or other gems. And this can get quite recursive. So what Docker did with the official Ruby image is they added every package that you might need when you install your bundle.

So, going back to our image, when we try to rebuild the image, we will get something like this error:

Gem::Ext::BuildError: ERROR: Failed to build gem native extension.
    /usr/bin/ruby -r ./siteconf20150714-5-1oit3ix.rb extconf.rb
mkmf.rb can't find header files for ruby at /usr/lib/ruby/include/ruby.h

As you can see in the error, we're missing header files for Ruby. Although this might look a bit scary, it's easily fixable by installing the ruby-dev package. Apart from the header files, we will need the build packages (called build-base) for Alpine Linux, so we can compile the build the gems in our image.

FROM alpine:3.2
MAINTAINER John Doe <john@doe.com>
ENV BUILD_PACKAGES curl-dev ruby-dev build-base
# Update and install base packages
RUN apk update &amp;&amp; apk upgrade &amp;&amp; apk add bash $BUILD_PACKAGES
# Install ruby and ruby-bundler
RUN apk add ruby ruby-io-console ruby-bundler
RUN mkdir /usr/app
WORKDIR /usr/app
COPY Gemfile /usr/app/
COPY Gemfile.lock /usr/app/
RUN bundle install
COPY . /usr/app
# Clean APK cache
RUN rm -rf /var/cache/apk/*

Now, if you try to rebuild your image, you can see that everything will go well. The image is built, and the application is deployed at the /usr/app path.

The penultimate step would be to clean up our Dockerfile. We will move the build-essential package names to an environment variable called BUILD_PACKAGES. We will do the same with the other, Ruby-focused packages as an environment variable called RUBY_PACKAGES.

The last step is to concatenate all of the package manipulation commands to a single command. We will do this because each RUN command creates a new layer in the image history. That means when we pull the image from the Docker Hub, the data that was removed will still end up getting pulled because it's an intermediate step. By concatenating all of the commands, we will create only one layer in the image history where the packages will be installed and the cache cleaned.

FROM alpine:3.2
MAINTAINER John Doe <john@doe.com>
ENV BUILD_PACKAGES bash curl-dev ruby-dev build-base
ENV RUBY_PACKAGES ruby ruby-io-console ruby-bundler
# Update and install all of the required packages.
# At the end, remove the apk cache
RUN apk update &amp;&amp; \
    apk upgrade &amp;&amp; \
    apk add $BUILD_PACKAGES &amp;&amp; \
    apk add $RUBY_PACKAGES &amp;&amp; \
    rm -rf /var/cache/apk/*
RUN mkdir /usr/app
WORKDIR /usr/app
COPY Gemfile /usr/app/
COPY Gemfile.lock /usr/app/
RUN bundle install
COPY . /usr/app

Conclusion

The goal of this post was to build a minimal Docker image for a Ruby application. As you can see, there are many moving parts that you have to take into consideration when it comes to building it. Various gems have various dependencies, and knowing what packages every gem depends on in advance is almost impossible.

My conclusion is that building a very minimal Docker image for Ruby is very hard. Building it is very much directed by what are the minimal requirements for the application that we want to deploy within the container. When it comes to compiled languages, like Java, one can compile the application with its dependencies outside of the container and deploy the compiled app (with its dependencies) to a container that only has a JVM. Because this is not an option with Ruby, it would be best to gradually build your own minimal images that will work for your application.

PS: If you liked this article you might also be interested in one of our free eBooks from our Codeship Resources Library. Download it here: Automate your Development Workflow with Docker

Stay up to date

We'll never share your email address and you can opt out at any time, we promise.