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.17MBruby: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
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 settingstype
defines the cache type - we are using GitHub Actions cachetimeout
sets the max duration of exporting or importing cachescope
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 ascache-to
Learning Resources
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
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.