by Georg Ledermann

Building Docker images, the performant way

Fasten your seat belt!

Photo by Russ Ward on Unsplash

Photo by Russ Ward on Unsplash

Many of the Rails applications I build these days are deployed as Docker images. Unfortunately, building Docker images usually takes a long time. This article describes how the build process can be accelerated by a factor of 2-3.

This text is a follow-up to my article Dockerize Rails, the lean way, where I described how to build small images. Now I want to write about how to reduce the time Docker needs to build such images.

Based on the official Ruby images, building a Docker image for a Rails application takes several minutes. For an application I’m currently working on, executing docker build . takes about 5 minutes. Because this is part of a CI process, this effort is repeated over and over again. This slows down the deployment process and costs money when it comes to external CI tools like GitHub Actions or CircleCI, which are usually paid on a time basis.

Much of the build time is used for installing dependencies, such as Linux packages, Ruby gems and Node.js modules. Some Ruby gems (e.g. Nokogiri or Puma) contain native extensions that have to be compiled first during installation, which takes additional time. Since these third-party components do not change constantly in the app, there is some room for optimization here.

The idea is to create a base image with pre-installed dependencies. As a result, I published a repo called DockerRailsBase, a set of multi-stage base images for Rails applications using pre-installed dependencies.

Performance comparison

Before going into the details, I want to show the numbers. I compared build times using a typical Rails application. This is the result on my local machine, measured by executing time docker build .

  • Based on the official Ruby Alpine image: 4:50 min
  • Based on DockerRailsBase: 1:57 min

As you can see, using DockerRailsBase is more than 2 times faster compared to the official Ruby image. It saves nearly 3min on every build. Of course, this may be different for your application, but it shows the potential.

Note: Before I started timing, the base image was not present on my machine, so it was downloaded first, which took some time. If the base image is already downloaded, the build time is only 1:18min (3 times faster).

Creating the base image

I first made the following assumptions about the Rails application:

This is the case for most of my Rails applications. If your apps differ, a modified base image may help.

To build a very small production image, multi-stage building is used. There are two Dockerfiles in this repo, one for the first stage (called “Builder”) and one for the resulting stage (called “Final”).

First: Builder stage

In this stage, the Ruby gems and Node modules are installed and the assets will be compiled. For doing this, some Alpine packages are installed first. Then some standard Ruby gems and Node modules are installed and some ONBUILD triggers are added to install the app’s dependencies (by re-using the standard ones – this is the most important thing).

Here are the main parts of this Dockerfile:

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
# Builder/Dockerfile

FROM ruby:alpine

# Add basic packages
RUN apk add build-base postgresql-dev git nodejs yarn tzdata file

# Install standard Node modules
COPY package.json yarn.lock /app/
RUN yarn install

# Install standard gems
COPY Gemfile* /app/
RUN bundle install

#### ONBUILD: Add triggers to the image, executed later while building a child image

# Install Ruby gems (for production only)
# This will be fast because most of the gems are already installed
ONBUILD COPY Gemfile* /app/
ONBUILD RUN bundle install --without development:test && \
            bundle clean --force # Remove unneeded gems

# Install Node modules (for production only)
# This will be fast because most of the modules are already installed
ONBUILD COPY package.json yarn.lock /app/
ONBUILD RUN yarn install

# Copy the whole application folder into the image
ONBUILD COPY . /app

# Compile assets with Webpacker and/or Sprockets
ONBUILD RUN bundle exec rails assets:precompile

Some details removed, so don’t copy this! See the full and up-to-date Dockerfile in the GitHub repo:
https://github.com/ledermann/docker-rails-base/blob/master/Builder/Dockerfile

For the selected Ruby gems and Node modules, see the Gemfile and packages.json:

The result is two folders: One with the gems (/usr/local/bundle) and one with the app (/app). They will be copied into the final image in the final stage.

Second: Final stage

The final stage is used to build the production image. It installs just the bare minimum to keep the image small. Based on the Ruby Alpine image, it adds some packages needed for production and adds some ONBUILD triggers to copy the app and the gems from the “Builder” stage. Here are the main parts of this Dockerfile:

1
2
3
4
5
6
7
8
9
10
# Final/Dockerfile

FROM ruby:alpine

# Add basic packages
RUN apk add postgresql-client tzdata file

# Copy app with gems from former build stage
ONBUILD COPY --from=Builder /usr/local/bundle/ /usr/local/bundle/
ONBUILD COPY --from=Builder /app /app

Some details removed, so don’t copy this! See the full and up-to-date Dockerfile in the GitHub repo:
https://github.com/ledermann/docker-rails-base/blob/master/Final/Dockerfile

How to use the base images

The two DockerRailsBase images are published to Docker Hub. They can be used from the application’s Dockerfile, which now can be very short and simple:

1
2
3
4
5
6
7
8
9
10
11
12
# Builder stage
FROM ledermann/rails-base-builder:latest AS Builder

# Final stage
FROM ledermann/rails-base-final:latest

# Additional setup your production image requires, e.g. additional Alpine packages
# RUN apk add ffmpeg vips

USER app

CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]

Yes, this is the complete Dockerfile of the Rails app. It is so simple because the main work is done by ONBUILD triggers.

There are some interesting parts:

  • Using ONBUILD triggers allows us to move lots of standard stuff into the base image, so the app’s Dockerfile can be very simple. Remember: With ONBUILD, a Dockerfile command is defined in the base image, but will be processed later, when the image is used as the base for another build.
  • The builder image includes lots of Ruby gems I’m using in many apps. If a particular gem is not used in one app, it will be wiped out by the bundle clean command and so will not be included in the resulting image. Of course, this enlarges the builder image (not your resulting image), but downloading is faster than installing.
  • If the app requires a Ruby gem or Node module in a different version, this doesn’t matter. They will be installed, so the pre-installed version will not be used. Of course, the more of the pre-installed dependencies are used, the better.

Hope this article is helpful for other developers.