Dockerizing Ruby Application

Containers are great and they are gaining more popularity all the time. It’s replacing virtualization by removing hypervisor layer and allowing to run isolated container processes on the shared kernel instead (Image 1). The most important benefit of containers is a start time. While a full virtualized system usually takes minutes to start, containers take seconds, and sometimes even less than a second. With containers there is also a standard how to package application and deliver and deploy it.

Image 1: Moving from virtualization to containerization

Dockerfile

To start putting application into a Docker container, a Dockerfile is needed. It’s like a source code of the Docker image. In Dockerfile are defined all the steps that are required to execute to get application and it's environment up and running.

Docker Image

If Dockerfile is the source code then a Docker image is the compiled version of it. Actually it’s not a single image, but a set of image layers. Image layers are cached so not the whole Docker image is needed to update if Dockerfile will change. The later the change is in Dockerfile the less image layers are required to update.

Ruby Base Images

Every Docker image extends some base image. Typically, a base image contains OS and common libraries and packages. For Ruby developers there are official Ruby base images, that contain specific Ruby versions built-in. The official Ruby base images are:

  • ruby:version
  • ruby:onbuild
  • ruby:slim
  • ruby:alpine

ruby:version is the de-facto Ruby base image. In addition to Ruby, it contains a large number of extremely common Debian packages.

ruby:onbuild base image is perhaps the easiest one to start with. You need to just extend this image and you are ready to go. It wraps your application automatically into a Docker image on build time. However, it's not recommended for long-term usage within a project due to the lack of control.

ruby:slim is still Debian based but it only contains minimal packages to run Ruby. This is a good choice when you want to use Debian packages and define your environment by yourself.

ruby:alpine is based on Alpine Linux. It’s the smallest ruby base image, but the main caveat is that it does use musl libc instead of glibc and friends, so certain software might run into issues depending on the depth of their libc requirements

Debian based base images may be easier to start with but it comes with the cost of image size (Image 2). It is almost six times bigger than image based on Alpine Linux. Besides the size itself which are faster to transfer, smaller images also make your environment small and efficient. Small images all increase security as you reduce your security footprint size.

Image 2: Sizes of the Official Ruby Images

Of course one option is not to use any of official Ruby base images, but to use other base image instead and build the whole Ruby environment from scratch. Then you have a total control what libraries and packages you want to include in your Docker image.

Docker best practices

When running application in containers there are couple of rules of thumb to follow:

Run one process per container

Decoupling applications into multiple containers makes it much easier to scale horizontally and reuse containers. You can also define Docker to monitor running process of the container and when Docker recognizes the process exits it will restart it automatically

Use a .dockerignore file

To increase the build’s performance, you can exclude files and directories by adding a .dockerignore file to that directory as well. This file supports exclusion patterns similar to .gitignore files

Use Twelve-factor Apps paradigm

If you are running your application on Heroku you are used to use twelve-factor apps paradigm. Docker and containers supports natively this kind of paradigm so if you are not yet familiar with it, you can read more about on http://12factor.net/

Don’t rely on IP addresses

Docker will generate an IP address for each container. However, the IP address will change on every time container is re-created, so you can’t really rely on those addresses. Instead, you have to use some service discovery and DNS.

Example Application

Our example application is a simple Sinatra based application with MongoDB database. You can read all the source codes and Docker files from: https://github.com/kontena/todo-example.

Dockerfile

We will use Alpine Linux based Ruby base image. First, we are adding Gemfile and Gemfile.lock files to Docker image. After that we install Bundler and run bundle install. To reduce the size of the image we will remove build-time dependencies from the Docker image after dependencies are installed. Finally, we will add our application into Docker image and set some permissions and expose a port that the application will listen to. Based on that Docker can route a traffic correctly to container’s port.

FROM ruby:2.3.1-alpine  
ADD Gemfile /app/  
ADD Gemfile.lock /app/  
RUN apk --update add --virtual build-dependencies ruby-dev build-base && \  
    gem install bundler --no-ri --no-rdoc && \
    cd /app ; bundle install --without development test && \
    apk del build-dependencies
ADD . /app  
RUN chown -R nobody:nogroup /app  
USER nobody  
ENV RACK_ENV production  
EXPOSE 9292  
WORKDIR /app  

We can build the Docker image by executing docker build -t todoapp:latest .. This will generate Docker image from the Dockerfile found in the current directory and tag it as todoapp:latest.

Docker-Compose

We can run our application container from Docker image manually with docker run command. However, the better way is to run all application services with Docker Compose. Docker Compose is a tool for defining and running multi-container Docker applications. Application services and their configurations can be defined in docker-compose.yml file:

version: '2’  
services:  
  web:
    image: todoapp:latest
    command: bundle exec puma -p 9292 -e production
    environment:
      - MONGODB_URI=mongodb://mongodb:27017/todo_production
    ports:
      - 9292:9292
    links:
      - mongodb:mongodb
  mongodb:
    image: mongo:3.2
    command: mongod --smallfiles

So, we are defining here one web service that is using our todoapp Docker image. Then we have a MongoDB service from mongo:3.2 image and it’s linked to our web application as mongodb alias.

We can deploy the whole application with docker-compose up command.

So, it’s relatively easy to Dockerize Ruby application and run it locally. When rolling to production things are not that simple anymore. There are couple of things to consider:

  • How big this app will be? How many users it will serve?
  • Do you want your application to be infrastructure agnostic or lean heavily on some cloud provider?
  • How to run databases or save other persistent data?
  • How to scale the application and handle load balancing?
  • How do you pass sensitive data to your application and where to store that data?
  • How the application can be deployed and updated with zero down-time?

You can solve all those things by yourself, but it would be a long and rocky road. Instead, you should choose a container platform that suites for your needs the best.

About Kontena

Kontena is a new open source Docker platform including orchestration, service discovery, overlay networking and all the tools required to run your containerized workloads. Kontena is built to maximize developer happiness. It works on any cloud, it's easy to setup and super simple to use. Give it a try! If you like it, please star it on Github and follow us on Twitter. We hope to see you again!

Image Credits: The Container Guide by Garry Ing