Ruby in Containers

Software changes environments from a development machine to a UAT (user acceptance testing) server environment or even from a test environment to production. It is required that the software runs consistently and reliably in these environments in the process.
There was a time when deploying software was an event, a ceremony because of the difficulty that was required to keep this consistency. Teams spent a lot of time making the destination environments run the software as the source environment. They thereafter prayed that the gods kept the software running perfectly in production as in development.
With containers, deployments are more frequent because we package our applications with their libraries as a unit making them portable thereby helping us maintain consistency and reliability when moving software between environments. For developers, this is improved productivity, portability and ease of scaling.
Because of this portability, containers have become the universal language of the cloud allowing us to move software from one cloud to another without much trouble.
In this article, I will discuss two major concepts to note while working with containers in Ruby. I will discuss how to create small container images and how to test them.
Requirements
To run the source code as you follow along the tutorial, you may need to have the following tools installed on your system.
- Ruby
- Docker
- A Linux operating system the image tests implemented using the Container Test Framework may not work on windows for now.
- Get complete source code.
As we get started, given a hello world sinatra application such this one:
1 2 3 4 5 6 7 8 9 10 11 |
#app.rb require ' sinatra ' require ' sinatra /base'class MyApp < Sinatra::Base get '/' do "Welcome to sinatra in Containers" end end |
1 2 3 4 5 6 7 8 9 |
# Gemfile source 'https://rubygems.org' gem 'sinatra' gem 'thin' #config.ru $:.unshift(File.dirname(__FILE__)) require 'app' run MyApp |
Containerizing this application with Docker requires us to write a Dockerfile such as one below. Read more about how to create Docker files on the Docker website.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
FROM ubuntu: latest RUN apt-get updateRUN apt-get -y update RUN apt-get -y install git git-core curl gawk g++ gcc make libc6-dev libreadline6-dev \ zlib1g-dev libssl-dev libyaml-dev libsqlite3-dev sqlite3 autoconf \ libgdbm-dev libncurses5-dev automake libtool bison pkg-config libffi-dev RUN apt-get install -y postgresql postgresql-contrib postgresql-client libpq5 libpq-dev RUN curl -sSL https://rvm.io/mpapis.asc | gpg --import - RUN curl -L https://get.rvm.io | bash -s stable ENV PATH /usr/local/rvm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin RUN rvm install ruby-2.1.5 RUN rvm reload CMD source /etc/profile CMD source /usr/local/rvm/scripts/rvm RUN apt-get install -y nodejs npm RUN ln -s /usr/bin/nodejs /usr/bin/node RUN ["/bin/bash", "-l", "-c", "rvm use 2.1.5 --default" ] RUN ["/bin/bash", "-l", "-c", "rvm requirements; gem install bundler --no-ri --no-rdoc"] RUN ["/bin/bash", "-l", "-c", "gem list"] RUN apt-get update -qq &amp;&amp; apt-get install -y build-essential libpq-dev RUN mkdir /myapp WORKDIR /myapp ADD Gemfile /myapp/Gemfile RUN ["/bin/bash", "-l", "-c", "bundle install "] |
We need to care about the performance and security of the images. The way to ensure good performance and secure image is to reduce its size.This is not enough though, there are other aspects we need to give thought to create effective images.
A small image takes a shorter time to build, push and pull to and from the image registry.
Small images also have a small surface area of attack hence reducing security vulnerability. Let us discuss some tips on creating small Docker images.
Reducing the Size of the Image
The instructions we use to create a container image affect the size of the resulting image. To reduce the image size, use the following best practices ;
- Use a small base image.
- Command chaining.
- Clean up your containers.
- Install what you need.
Let us discuss each of these in depth.
Small Base Image
When creating a Dockerfile for a Ruby application, you have three options to use for your base images. A standard operating system image like Ubuntu, the lightweight Linux Alpine, the Ruby base image and the Ruby alpine base images. We use these base images in different ways and they come in different sizes.
Image | Version | Size |
---|---|---|
Ubuntu | latest | 187.9MB |
Alpine | latest | 5.249MB |
Ruby | 2.5.1-alpine | 55.5MB |
Ruby | 2.5.1 | 863MB |
A close analysis of the sizes of the available base images makes us come to a conclusion that the Linux Alpine image is the smallest.
Using a small base image significantly reduces the container image. We can now change the Dockerfile to use Linux alpine as the base image.
Linux alpine is lightweight and may lead to extra development work because it doesn’t come with some libraries compared to a full operating system. There are situations where using a full operating system to reduce this development work but also when conforming to standards and security in full operating systems.
Chaining Commands
Commands such as RUN can be placed on separate lines but this increases the container layers of the image. The more layers we have, the bigger the image. To reduce the layers, we should chain these commands.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
FROM alpine:latest MAINTAINER nanjekyejoannah "https://github.com/nanjekyejoannah" RUN apk --update add --virtual build-dependencies ruby-dev build-base ENV APP_ROOT /var/www/docker-sinatra RUN mkdir -p $APP_ROOT WORKDIR $APP_ROOT ADD Gemfile* $APP_ROOT/ ADD . $APP_ROOT RUN gem install bundler --no-ri --no-rdoc && cd /$APP_ROOT RUN bundle install --without development test && apk del build-dependencies RUN chown -R nobody:nogroup /app USER nobody ENV RACK_ENV production EXPOSE 80 CMD ["bundle", "exec", "rackup", "config.ru", "-p", "80", "-s", "thin", "-o", "0.0.0.0"] RUN gem install bundler --no-ri --no-rdoc && cd /app ; bundle install --without development test && apk del build-dependencies RUN chown -R nobody:nogroup /app USER nobody ENV RACK_ENV production EXPOSE 80 CMD ["bundle", "exec", "rackup", "config.ru", "-p", "80", "-s", "thin", "-o", "0.0.0.0"] |
Install What You NeedThe RUN command has been chained into one.
When installing packages in Linux, use -no-install-recommends flag to install only what you need in terms of packages.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
FROM alpine:latest MAINTAINER nanjekyejoannah "<a class="ext-link" href="https://github.com/nanjekyejoannah" rel="external ">https://github.com/nanjekyejoannah</a>" ENV APP_ROOT /var/www/docker-sinatra RUN mkdir -p $APP_ROOT WORKDIR $APP_ROOT ADD Gemfile* $APP_ROOT/ RUN bundle install ADD . $APP_ROOT 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 -no-install-recommends RUN chown -R nobody:nogroup /app USER nobody ENV RACK_ENV production EXPOSE 80 CMD ["bundle", "exec", "rackup", "config.ru", "-p", "80", "-s", "thin", "-o", "0.0.0.0"] |
Clean up your ContainersBy leaving out recommended packages, we are able to work with what we need and yet benefit from a smaller image.
After installing packages in your containers, clean up all cache files to make the image smaller.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
FROM alpine:latest MAINTAINER nanjekyejoannah "<a class="ext-link" href="https://github.com/nanjekyejoannah" rel="external ">https://github.com/nanjekyejoannah</a>" ENV APP_ROOT /var/www/docker-sinatra RUN mkdir -p $APP_ROOT WORKDIR $APP_ROOT ADD Gemfile* $APP_ROOT/ RUN bundle install ADD . $APP_ROOT 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 -no-install-recommends && \ rm -rf /var/lib/apt/lists/* RUN chown -R nobody:nogroup /app USER nobody ENV RACK_ENV production EXPOSE 80 CMD ["bundle", "exec", "rackup", "config.ru", "-p", "80", "-s", "thin", "-o", "0.0.0.0"] |
From the Docker file we build an image and start the container while mapping the container port 80 to localhost:4000.That is all on creating smaller images. After these changes, we can now build and start our container.
1 2 |
sudo docker build -t ruby-image . sudo docker run -p 4000:80 ruby-image |
Unit Testing Docker Images
A container image is a blueprint from which we create several container instances. We create this images using instructions in a Dockerfile. Like, software we need to ensure that before a container goes to production, it is tested to ensure it works as required.
We unit test container images to validate the instructions in the Dockerfile work as required. Container images should be tested during development to ascertain the structure and contents of the containers before they are shipped to production.
Container Structure Test Framework
Early this year, Google released the Container Structure Test Framework to help us validate the structure of container images.
Setup
The tests are specified in a .yaml or .json file and run through a standalone binary, or a Docker image. Download the binary here or pull the Docker Image.
1 2 |
sudo docker docker pull gcr.io/gcp-runtimes/container-structure-test |
Once you have downloaded the binary or pulled the docker image, then run the tests as below, using the binary:
Running the Tests
1 |
./container-structure-test-linux-amd64 test --image sample-docker-image sample_test_config.yaml |
Using the Docker image;
1 |
./structure-test -test.v -image sample-docker-image sample_test_config.yaml |
Getting back to our Dockerfile, let us create a test file for it with the following contents.
ruby_container_image_test.yaml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
schemaVersion: '2.0.0' globalEnvVars: - key: "VIRTUAL_ENV" value: "/env" - key: "PATH" value: "/env/bin:$PATH" fileExistenceTests: - name: 'Gemfile' path: '/app/Gemfile' shouldExist: true permissions: '-rwxr-xr-x' - name: 'Gemfile lock' path: '/app/.lock' shouldExist: true permissions: '-rwxr-xr-x' fileContentTests: - name: 'Gemfile' path: '/Gemfile' expectedContents: [''https://rubygems.org''] commandTests: - name: "ruby package installation" command: "which" args: ["ruby"] expectedOutput: ["/usr/bin/ruby"] - name: "bundler package installation" command: "which" args: ["bundler"] expectedOutput: ["/usr/bin/bundler"] metadataTest: env: - key: 'RACK_ENV' value: 'production' labels: - key: 'MAINTAINER' value: 'Joannah Nanjekye' workdir: ['/app'] exposedPorts: ['9292'] cmd: ["bundle", "exec", "rackup", "config.ru", "-p", "80", "-s", "thin", "-o", "0.0.0.0"] |
Command TestsThere are four types of tests we can perform on a container image with this framework;
These tests allow us to execute a given command inside the container image and verify if the output matches what is expected, or is an error. A good example of a command test is to verify the installation of packages or binaries in the container image.
In our case, we test for ruby and bundler installations.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
commandTests : - name: "ruby package installation" command: "which" args: ["ruby"] expectedOutput : ["/ usr /bin/ruby"]- name: "bundler package installation" command: "which" args: ["bundler"] expectedOutput : ["/ usr /bin/bundler"] |
File existence tests are used to check for the existence of expected files in a container image. We often create working directories in our container images and even move around files to this directory. We can check if the files exist in the working directory with the File existence tests.
File Existence Tests
For our Dockerfile, we will test if the Gemfile and Gemfile.lock exists.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
fileExistenceTests: - name: 'Gemfile' path: '/app/Gemfile' shouldExist: true permissions: '-rwxr-xr-x' - name: 'Gemfile lock' path: '/app/.lock' shouldExist: true permissions: '-rwxr-xr-x' fileContentTests: - name: 'Gemfile' path: '/Gemfile' expectedContents: ['echo'] |
File Contents Tests
These are used to verify the contents of the files container file system.
1 2 3 4 |
FileContentTests: - name: 'Gemfile' path: '/Gemfile' expectedContents: [''https://rubygems.org' '] |
This test checks to ensure that given container metadata is accurate. Use Metadata tests to check instructions such as ENV, LABEL, ENTRYPOINT, CMD, EXPOSE, VOLUME, WORKDIR.
Metadata Test
1 2 3 4 5 6 7 8 9 10 |
metadataTest: env: - key: 'RACK_ENV' value: 'production' labels: - key: 'MAINTAINER' value: 'Joannah Nanjekye' workdir: ['/app'] exposedPorts: ['9292'] cmd: ['ruby app.py'] |
After adding these tests, let us run the tests on it.
1 2 |
./structure-test -test.v -image ruby-image ruby_container_image_test.yaml |
Small container images give us better performance and security for applications. To ensure reliable containers in production, unit test the container images to validate their structure using the container structure test framework from Google.