Ruby on Rails containerization example
Tags: rails, docker, ruby
Published: 2024-03-14 17:02:41

This article describes the process of containerizing a Ruby on Rails application to use in local development process. There are no innovative ideas here, just some specific requirements and issues we encountered, along with methods to address them. So, let's get started.

The Project. Started over 10 years ago, it is a RoR monolith responsible for both backend and frontend (SSR). It contains a considerable number of background jobs for handling long processes. Additionally, it includes a couple of engines for separate parts of the system. It is fairly well covered by tests, including many feature tests using Capybara, which required various drivers to run. At the time of starting work on containerization, local project setup was available, including through virtualization on Vagrant.

## Containers requirements

- Updating gems does not require rebuilding containers.
- On all staging and production servers, we had a specific OS version - Ubuntu 22.04. Therefore, we wanted to reproduce this context in the container. However, this required additional configuration, as the official images with Ruby on [dockerhub](https://hub.docker.com/_/ruby) were based on different versions of Debian OS.
- All existing feature tests have to be executed within the container.
- We had a number of Ruby scripts that performed specific tasks on the servers. For example, a script for conveniently reading logs from the server. It connects to the main server via SSH and then greps logs on each instance that received requests or processed background workers. Such scripts required a working context of our application, and we wanted them to be able to run directly from the container.
- Ability to debug the project/tests in the container.

## The main steps

Since our application handles requests through a web API and contains background process handlers, it makes sense to separate these two parts into individual containers. However, the main dependencies for these parts will be identical, allowing us to use a shared configuration in the Dockerfile. Furthermore, the job container fully replicates this base configuration. The web container does have additional dependencies for the frontend part, as well as for running feature tests:

```
FROM ubuntu:22.04 AS build

<Install all common libraries and deps>

FROM build as web

<Install web-specific dependencies>

FROM build as job

<Just set the CMD>
```

We used docker-compose to configure interaction between containers. In addition to the 2 images for our application, we also configured a container for the database and another small busybox container, which, along with a shared volume, served as a storage for our gems. This configuration allowed us to avoid rebuilding containers when adding/changing gems. Each time the services were started, they check the gems in the cache, install missing libraries, and then start the main process. For example, the command for the web container looks like this:
```
command: bash -c "bundle check || bundle install && rails s -b 0.0.0.0 -p 3003"
```

Since we wanted to have images based on Ubuntu 22.04, we had to work with dependencies required for the project at the OS level. We installed some standard libraries like _gnupg cmake g++ file_, some tools necessary for installing other dependencies and local work within the container (_wget postgresql-client git_), and several libraries related to the specific requirements of our system (for example, _imagemagick_ for working with images).

We used Ruby version 3.2.2 at the time of creating our configuration. We downloaded and compiled it from source:
```
ENV RUBY_MAJOR 3.2
ENV RUBY_VERSION 3.2.2

RUN wget -O ruby.tar.gz "https://cache.ruby-lang.org/pub/ruby/3.2/ruby-3.2.2.tar.gz"; \
    mkdir -p tmp/src/ruby; \
    tar -xzf ruby.tar.gz -C tmp/src/ruby --strip-components=1; \
    rm ruby.tar.gz; \
    cd tmp/src/ruby; \
    ./configure --disable-install-doc; \
    make; \
    make install
```

## Feature specs
Since our system is SSR, integration specs also verify the functionality of the frontend part by emulating user actions on the page, checking the logic of request behavior, and the functioning of various frontend elements on the pages. At the time of containerization, we used 2 drivers to run different tests - Chromedriver and Firefox, both of which needed to be present in our web container. However, it turned out that the standard libraries available in the repository and the container itself based on Ubuntu 22 were not suitable for us. In other words, standard commands like
```
apt-get -y --no-install-recommends install firefox
apt-get -y install chromium-driver
```
installed the correponding packages but our specs didn't work with them. So, we had to customize the installation of these drivers too. The main idea - to use custom repositories as driver sources and choosing the certain lib version with _apt preferences_. Taking Firefox as an example:
```
RUN apt-get -y install software-properties-common; \
    add-apt-repository -y ppa:mozillateam/ppa
RUN echo $' \n\
Package: *\n\
Pin: release o=LP-PPA-mozillateam\n\
Pin-Priority: 1001' | tee /etc/apt/preferences.d/mozilla-firefox
RUN apt-get -y install firefox
```
Another problem related to installing drivers for feature tests was the fact that different developers' local machines had different architectures. Therefore, for example, the versions of Chromium also differed on different machines: both arm64 and amd64 versions were installed, which directly affected the specs. Attempting to install a specific architecture during the driver installation process with
```
deb [arch=amd64 signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://deb.debian.org/debian buster main
```
was unsuccessful. The solution was found to specify a certain architecture in the docker-compose configuration 

```
platform: linux/amd64
```
and also configure Rosetta for Apple M1 chips:
```
softwareupdate --install-rosetta
```
(and turn on Settings->General->"Use Rosetta for x86/amd64 emulation on Apple Silicon" in your Docker Desktop settings).

## SSH

To enable SSH connection from the container to our servers, it was necessary to forward the SSH agent from the local machine (where, as assumed, all keys were already configured) into the container itself. We did this using two lines in the docker-compose configuration:
```
environment:
  SSH_AUTH_SOCK: /ssh-agent
volumes:
  - ${HOST_SSH_SOCKET_PATH}:/ssh-agent
```
We use the environment variable _HOST_SSH_SOCKET_PATH_, which is set in the _.env_ file because team members worked with different operating systems, and the SSH socket path may be different for them.

## Debug

To enable local code debugging, we also used a fairly standard solution by adding the following configuration to the docker-compose:
```
tty: true
stdin_open: true
```
for each container. This way, after adding breakpoints in the code, a developer could execute 

```
docker attach <CONTAINER_NAME>
```
from their local machine and enter the run process.

## Improvements

During the configuration setup and adjustments, we accumulated some common commands and configurations in the respective Docker files. We extracted these common parts using a common build image for the Dockerfile and using the standard YAML anchors for the docker-compose.yml.

This is what the general configuration of our containers looks like: https://gist.github.com/IvanIvashchenko/eb43e502593eb4793808a03771fa6c33

In the future, we plan to modify the images for using containers on remote servers and configure the deployment of these containers through integration with GitHub/ECR.