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
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.
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 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.
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.
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
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
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.