Build a Minimal Docker Container for Ruby Apps

Development

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.

New Call-to-action

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 && 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 && apk upgrade && 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 && apk upgrade && 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 && \
    apk upgrade && \
    apk add $BUILD_PACKAGES && \
    apk add $RUBY_PACKAGES && \
    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

Subscribe via Email

Over 60,000 people from companies like Netflix, Apple, Spotify and O'Reilly are reading our articles.
Subscribe to receive a weekly newsletter with articles around Continuous Integration, Docker, and software development best practices.



We promise that we won't spam you. You can unsubscribe any time.

Join the Discussion

Leave us some comments on what you think about this topic or if you like to add something.

  • Pingback: 1 – Build a Minimal Docker Container for Ruby Apps | Exploding Ads()

  • Great post, I was all prepared to recommend Alpine when I started. =P

    Deleted the second part of my comment because I missed the very last iteration of the docker file when posting it. Apologies.

  • What happens if you don’t require bundler?

    • Reply above, Discus on mobile issues… Or just me having issues. Heh

  • If you don’t need it — e.g. no dependencies or directly installing gems in the docker build — you should be fine, I would assume.

    • I usually don’t need bundler: it does WAY more than I need to manage dependencies. I use a combination of gs+dep that hasn’t give me any issues in almost 4 years.

      Still, I’ve removed everything bundler and I’ve come up with a 183.2 MB container: https://gist.github.com/inkel/17f5a1f728a7c0674b79

      Minimal Ruby has *nothing* to do with Bundler. You can write fast and minimal Ruby if you avoid over-bloated gems like Bundler. I liked the post, but I don’t think “My conclusion is that building a very minimal Docker image for Ruby is very hard” is a correct conclusion. I just did it and was really easy.

      • eftimov

        Leandro, Joshua
        thank you for your comments. I am very happy you liked the post. :-)

        Just a clarification:
        When I wrote “…building a very minimal Docker image for Ruby is very hard” I meant that building a *minimal silver-bullet image* is hard. And by silver-bullet image, I mean an image that can build pretty much any Ruby application. In my mind, the official Docker Ruby image is a silver bullet, but it’s impossible to strip it down because it contains tons of (possible) gem dependencies.

        On the other hand, if your objective is to build an image that can run just one app (which in my opinion is the sane thing to do), then writing that Dockerfile, as you already know, is pretty simple.

        Re: removing bundler – in this particular example it doesn’t really make a difference. The image I got by building the last Dockerfile is 182.1MBs.

  • Janko Marohnić

    What is the filesize of the resulting minimal Docker image?

    • jbgo

      I built the image myself and it was under 200 MB. That’s pretty good compared to most ruby images. I wonder how much larger it would be for a real-world app though. The smallest ruby container I have for a real app is still over 900 MB.

      [10:01:11] ../ruby-min-container $ docker images | grep -E ‘(ruby|web)’
      ruby-minimal latest d16e1dbdb4e9 4 minutes ago 182.1 MB
      newt_web latest 05a564355ece 12 days ago 984.3 MB
      courses_web latest fdbeebd66cc1 2 weeks ago 971.1 MB
      ruby 2.1.5 afba1b22768e 3 months ago 777.3 MB
      ruby 2.1.4 766162a99b23 8 months ago 798.3 MB

  • vadviktor

    I have built my own, a flexible one: https://github.com/vadviktor/docker-rbenv

  • Pingback: Build a Minimal Docker Container for Ruby Apps | Dinesh Ram Kali.()

  • Andreas Wenk

    Thanks for this very good post. I could not build the image with

    `docker build jdoe/ruby .`

    After checking the docker help I figured it should be:

    docker build -t jdoe/ruby .

    And the docker daemon has to be started ….

    • You need to also create a Gemfile with curb and run bundle install to get the Gemfile.lock

  • Daniel

    Thanks for sharing this nice container. Works smoothly.

  • Pingback: پادکست Tech5: شماره پنج | سلام دنیا | رسانه تخصصی نرم‌افزارهای آزاد / متن‌باز()

  • Thanks for this, helped me find the missing pieces I needed for my app!

  • Instead of running rm -rf /var/cache/apk/* you can just tack –no-cache onto your apk add instruction.