Containers / Programming Languages

Ruby in Containers

16 Nov 2018 3:00am, by

Joannah Nanjekye
Joannah Nanjekye is a software engineer from Kampala Uganda. She is a proud open source contributor having been mentored through programs like Rails Girls Summer of Code and Outreachy, writing mostly Python, Ruby, and Golang. She is the author of Python 2 and 3 Compatibility, a book published by Apress. She also organizes Rails Girls Kampala. Recently reading a lot about the latest developer tools and space.

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:

#app.rb
require '
sinatra
'
require '
sinatra
/base'class MyApp < Sinatra::Base
get '/' do
"Welcome to sinatra in Containers"
end
end
# 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.

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.

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.

FROM alpine:latest
MAINTAINER nanjekyejoannah "https://github.com/nanjekyejoannah"

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.

FROM alpine:latest
MAINTAINER nanjekyejoannah "https://github.com/nanjekyejoannah"
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.

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.

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

./container-structure-test-linux-amd64 test --image sample-docker-image sample_test_config.yaml

Using the Docker image;

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

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.

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.

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.

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

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.

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

Feature image via Pixabay.


A digest of the week’s most important stories & analyses.

View / Add Comments

Please stay on topic and be respectful of others. Review our Terms of Use.