Back to all articles

Optimizing Rails Docker Image Size

A week ago, I was tasked with optimizing our docker image size. In this post, I’ll briefly explain why we’ve decided to do that and discuss how I’ve approached this puzzle.

Why?

The main advantages of smaller Docker images are cost reduction and a smaller attack surface. The size of the image influences storage and data transfer fees, and a smaller attack surface helps reduce the chance of a data breach.

How?

Let’s read an example Dockerfile and edit it together.

Below is the Dockerfile version I’ve found in the beginning. This Dockerfile results in an image weighing ≈1.95GB uncompressed.

FROM ruby:2.7.6

RUN apt-get update -qq && apt-get install -y build-essential libpq-dev
RUN curl -sL https://deb.nodesource.com/setup_18.x | bash - && \
    apt-get update -qq && apt-get install -y nodejs && \
    npm install -g yarn

WORKDIR /rails

# Set production environment
ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development"

COPY Gemfile Gemfile.lock .
RUN bundle install

COPY . .

ENTRYPOINT ["/rails/bin/docker-entrypoint"]

EXPOSE 3000

CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]

Base image

FROM ruby:2.7.6

The file begins with FROM ruby:2.7.6. These instructions create a new stage with Ruby version 2.7.6 as a base. The base image is the first opportunity for optimization. If we look at the images available on Docker Hub, we will find multiple versions of ruby:2.7.6 images. Right now, we are using ruby:2.7.6, which weighs 325.6MB, but there are other base images with this ruby version that are smaller:

  • ruby:2.7.6-slim 55.17MB
  • ruby:2.7.6-alpine 19.08MB

We’ll go with Slim because it’s just the default image without all the unnecessary packages.

Why not Alpine? It’ll be a good idea if we are migrating a newer project that is still in development. However, we must consider that Alpine uses musl libc instead of glibc, and we’ll have to adjust our gems. Another issue is that Alpine packages are not available indefinitely. So, if you need a specific version of Node, you might be forced to compile it yourselves after a few years when it is no longer hosted in the official package repository.

Packages

RUN apt-get update -qq && apt-get install -y build-essential libpq-dev

There is nothing wrong with this line; we need all those things.

Node

RUN curl -sL https://deb.nodesource.com/setup_18.x | bash - && \
    apt-get update -qq && apt-get install -y nodejs && \
    npm install -g yarn

In the third line, we see that a Node is being installed. This tells us that there are gems that require Node to be used or JavaScripts in the project are bundled using webpack, vite, or similar technology, and possibly there is a frontend that uses node modules and is more complicated. And after taking a look, indeed, there is quite some Javascript that needs processing. Luckily, Rails has a great tool in its belt called assets precompilation - we are going to leverage it to remove NodeJS from the final image

We can find out which gems use NodeJS by checking the Gemfile.lock and looking for gems that have execjs as a dependency.

 # Parts of Gemfile.lock
 # ...
 uglifier (2.7.0)
      execjs (>= 0.3.0)
 # ...
 coffee-script (2.4.1)
      coffee-script-source
      execjs
 #...

So now we know which gems use NodeJS.

# Part of Gemfile
gem 'uglifier', '~> 2.7.0'
gem 'coffee-rails', '~> 5.0'

It looks like both are used only during asset compilation. We will take advantage of this later. For now, let’s assign them to a new group.

# Gemfile
group :build do
  gem 'uglifier', '~> 2.7.0'
  gem 'coffee-rails', '~> 5.0'
end

The rest

WORKDIR /rails

# Set production environment
ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development"

COPY Gemfile Gemfile.lock ./
RUN bundle install

COPY . .

ENTRYPOINT ["/rails/bin/docker-entrypoint"]

EXPOSE 3000

CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]

The rest of the Dockerfile looks fine, we will make a few more optimisations to it later on too.

Let’s get to work

Changing base

We can start with the most obvious optimization—changing the base image. However, the slim image does not include curl and git, which we use. So we have to install them ourselves.

FROM ruby:2.7.6-slim

RUN apt-get update -qq && apt-get install -y build-essential libpq-dev curl git
RUN curl -sL https://deb.nodesource.com/setup_18.x | bash - && \
    apt-get update -qq && apt-get install -y nodejs && \
    npm install -g yarn

WORKDIR /rails

# Set production environment
ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development"

COPY Gemfile Gemfile.lock ./
RUN bundle install

COPY . .

ENTRYPOINT ["/rails/bin/docker-entrypoint"]

EXPOSE 3000

CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]

The base change helped us reduce image size by ≈600MB; that’s quite a good start.

docker images | grep example
example                 slim-base   849f0e5fae56   1.32GB
example                 old         2a7bf9a9110c   1.95GB

Bundle without test gems

We should bundle without gems from groups test and development.

FROM ruby:2.7.6-slim

RUN apt-get update -qq && apt-get install -y build-essential libpq-dev curl git
RUN curl -sL https://deb.nodesource.com/setup_18.x | bash - && \
    apt-get update -qq && apt-get install -y nodejs && \
    npm install -g yarn

WORKDIR /rails

# Set production environment
ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development test"

COPY Gemfile Gemfile.lock ./
RUN bundle install

COPY . .

ENTRYPOINT ["/rails/bin/docker-entrypoint"]

EXPOSE 3000

CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]

This small change saves us another ≈80MB.

docker images | grep example
example                 without-test   29c7859e9533   1.24GB
example                 slim-base      849f0e5fae56   1.32GB
example                 old            2a7bf9a9110c   1.95GB

Multi-stage build

The next possibility for decreasing size is removing NodeJS from the final image. To achieve that, we’ll introduce a multi-stage build process.

We will begin by configuring the base of our image. We will define the configuration shared by the other stages in it.

FROM ruby:2.7.6-slim AS base

# Set production environment
ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development test"

WORKDIR /rails

Now, let’s create the first stage - assets compilation.

FROM base AS compile-assets

# We install build essentials and other packages needed to build gems 
# and precompile assets
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev curl git
# We install NodeJS
RUN curl -sL https://deb.nodesource.com/setup_18.x | bash - && \
    apt-get update -qq && apt-get install -y nodejs

# We will bundle with build group that contains gems using ExecJS
ENV BUNDLE_WITH="build" \
    RAILS_GROUPS="build"

# We copy the gemfile and install gems before copying the rest of our code
# thanks to this we are able to cache this layers
COPY Gemfile Gemfile.lock ./
RUN bundle install

COPY . .

# We precompile the assets
RUN SECRET_KEY_BASE="dummy" \
    bin/rails assets:precompile

# Remove source files, we are precompiling assets after all
RUN rm -rf node_modules
RUN rm -rf app/assets/fonts app/assets/images app/assets/javascripts app/assets/stylesheets
RUN rm -rf vendor

And gem build stage.

FROM base AS build-gems

# We bundle only the stuff needed for building gems
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev git

ENV BUNDLE_WITHOUT="development test build" \
    BUNDLE_WITH="production"

COPY Gemfile Gemfile.lock ./

# We install gems and remove the bundler cache
RUN bundle install && \
    rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git

# Then we remove useless C gem extensions leftovers too
# They are created during compilation and linking of native C extensions
RUN find /usr/local/bundle -name "*.c" -delete
RUN find /usr/local/bundle -name "*.o" -delete

And the final stage.

FROM base AS production

# We install only the packages needed during runtime. 
# We install postgresql-client instead of libpq-dev because it's smaller 
# and offers more utility in case there was need for debugging the container
RUN apt-get update -qq && \
    apt-get install -y --no-install-recommends postgresql-client && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# We copy gems and built assets from their stages
COPY --from=build-gems /usr/local/bundle /usr/local/bundle
COPY --from=compile-assets /rails /rails

# Running code as a root is a bad practice
# That's why we'll create a new user and give it access to dirs
# modified during runtime
RUN useradd rails --create-home --shell /bin/bash && \
    chown -R rails:rails tmp db

USER rails:rails

ENTRYPOINT ["/rails/bin/docker-entrypoint"]

EXPOSE 3000

CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]

Those changes helped us save another 900 MB

docker images | grep example
example                 multi-stage    9c6a6c99a79b   345MB
example                 without-test   29c7859e9533   1.24GB
example                 slim-base      849f0e5fae56   1.32GB
example                 old            2a7bf9a9110c   1.95GB

Learning Resources

Multi-stage

Understanding layers

Docker images are built layer by layer, each representing a specific instruction in the Dockerfile. When you run a command like RUN rm -rf, it doesn’t actually delete files from the image layer. Instead, it creates a new layer with the changes made. To minimize image size, we can leverage multi-stage builds or run the rm command in the same layer as a the command creating files we want to remove.

Learning Resources

Understanding the image layers

Leveraging Github Actions cache

Now that our Docker image is small we could work on making it build faster in our CI pipeline. At Lunar we are using GitHub Actions so we’ll leverage Github Actions cache to speed up our build process. Because default Docker build driver doesn’t support caching we’ll switch to using docker-container build driver.

# GitHub Actions Workflow file
jobs:
    # ...

      # we will expose github runtime to env
    - name: Expose GitHub Runtime
      uses: crazy-max/ghaction-github-runtime@v3

      # and set-up the docker buildx - we need to do that as we won't be using
      # the default build driver anymore
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v3
      
      # we have to modify our build command too
      # alternatively we could use docker build push github action
    - name: Build & tag
      id: build-image
      run: |
        docker buildx build --load --cache-to type=gha,timeout=30s,scope=project_$,mode=max,ghtoken=$ --cache-from type=gha,timeout=30s,scope=project_$ -t name:tag .
  

We’ve had to modify our basic docker build -t name:tag . command too. So what are all those new parameters?

We configure docker to use GitHub Actions cache and save the cache under the scope based on stage (staging / production) with a timeout of 30 seconds. We want to cache all layers, which is why we set the mode=max . The ghtoken has to be set to avoid throttling GitHub Actions cache reads. If you are interested in details, please see below.

  • --load non-default build drivers keep the image in the build cache by default; we have to specify we want to load it to docker images.
  • --cache-to this parameter specifies cache write settings
    • type defines the cache type - we are using GitHub Actions cache
    • timeout sets the max duration of exporting or importing cache
    • scope is a key used to identify the cache object. As we use the same workflow for building different images, we differentiate it using $
    • mode defines which layers are going to be cached. min means only the layers included in the final image are cached. max enables caching for all layers.
    • ghtoken it is GitHub token required for accessing the GitHub API to check the status of cache entries without being throttled
  • --cache-from specifies cache read settings. It accepts the same parameters as cache-to

Learning Resources

GitHub Actions cache

docker buildx build

Build drivers

Is it worth it?

That depends on the project. Writing cache takes time so each cache miss will be costly.

Let’s look at our example project.

Cache hit: -20sec

Cache miss: +40sec

Because we copy Gemfile and Gemfile.lock in a separate step we will miss cache only when we modify our bundle. Let’s assume we will miss cache 1 out of 50 times

\[EX=-20\text{sec}*\frac{49}{50}+40\text{sec}*\frac{1}{50}=-18.8\text{sec}\]

So in the long run we should save time on this change. However depending on project and Dockerfile shape, results will differ - build caching won’t be always worth it.

Closing words

In this post, we explored several techniques to optimize Docker image size, including using a minimal base image, leveraging multi-stage builds, and minimizing the number of layers. By implementing these strategies, we were able to reduce the size of our example image from a hefty 1.95GB to a much more manageable 345MB. This translates to cost savings on storage and data transfer fees, especially for frequently deployed applications.

Share this article: