Slim Docker Images for Rails

At Tinfoil we’ve been building and distributing our applications with Docker for a few years now. One aspect we value of our Docker images is keeping them small and nimble. By default it’s easy to have a Docker image become bloated because each command introduces a new layer and history of changes to the file system. Luckily there are some tricks to reducing the final image size without squashing all of the layers together.

We can start with a modern Rails application that uses Yarn in addition to Sprockets to manage JavaScript dependencies, Bundler to manage the Ruby gem dependencies, and an expectation that we’ll be connecting to an external PostgreSQL database.

A simple starting Dockerfile might look like the one below. We need Node.JS and Yarn installed to precompile our JavaScript assets.

FROM ruby:2.5

# Install NodeJS
RUN apt-get update
RUN apt-get install -y apt-transport-https
RUN curl --silent --show-error --location https://deb.nodesource.com/gpgkey/nodesource.gpg.key | apt-key add -
RUN echo "deb https://deb.nodesource.com/node_6.x/ stretch main" > /etc/apt/sources.list.d/nodesource.list
RUN apt-get update
RUN apt-get install -y nodejs

# Install Yarn
RUN curl --silent --show-error --location https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list
RUN apt-get update
RUN apt-get install -y yarn

WORKDIR /app

ENV RAILS_ENV=production
ENV NODE_ENV=production

COPY Gemfile Gemfile.lock /app/
RUN bundle install --jobs 4 --without development:test --deployment

COPY package.json yarn.lock /app/
RUN yarn install

COPY . /app/

RUN bin/rails assets:precompile

CMD ["bin/rails", "server"]

The final image size is 1.11GB! We can start off the weight loss program by combining the commands to install Node.js and Yarn, as well as cleaning up the apt package caches.

FROM ruby:2.5

# Install NodeJS
RUN apt-get update \
&& apt-get install -y apt-transport-https \
&& curl --silent --show-error --location https://deb.nodesource.com/gpgkey/nodesource.gpg.key | apt-key add - \
&& echo "deb https://deb.nodesource.com/node_6.x/ stretch main" > /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

# Install Yarn
RUN curl --silent --show-error --location https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
&& echo "deb https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \
&& apt-get update \
&& apt-get install -y yarn \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

WORKDIR /app

ENV RAILS_ENV=production
ENV NODE_ENV=production

COPY Gemfile Gemfile.lock /app/
RUN bundle install --jobs 4 --without development:test --deployment

COPY package.json yarn.lock /app/
RUN yarn install

COPY . /app/

RUN bin/rails assets:precompile

CMD ["bin/rails", "server"]

That made it a tiny bit smaller: 1.09GB. The ruby:2.5 image is based off of Debian, and has a lot of extra utilities and functionality preinstalled. We’ve found a lot of success making smaller images by basing the image off of Alpine Linux. Most ruby code works fine under Alpine, but since it uses musl instead of glibc, you have to be careful with some C dependencies or ruby gem extensions.

FROM ruby:2.5-alpine

RUN apk add --no-cache nodejs yarn build-base tzdata postgresql-dev

WORKDIR /app

ENV RAILS_ENV production
ENV NODE_ENV production

COPY Gemfile Gemfile.lock /app/
RUN bundle install --jobs 4 --without development:test --deployment

COPY package.json yarn.lock /app/
RUN yarn install

COPY . /app/

RUN bin/rails assets:precompile

CMD ["bin/rails", "server"]

421MB now, so we’re making some nice improvements. We don’t need all of the NPM packages at runtime, so we can use a multi-stage Dockerfile to avoid storing those layers in the final image. Multiple stages split up the build and precompilation steps in their own Docker images, and we can copy out the build artifacts into our final image.

FROM ruby:2.5-alpine as builder

RUN apk add --no-cache tzdata postgresql-dev
RUN apk add --no-cache nodejs yarn build-base

WORKDIR /app

ENV RAILS_ENV=production
ENV NODE_ENV=production

COPY Gemfile Gemfile.lock /app/
RUN bundle install --jobs 4 --without development:test --deployment

COPY package.json yarn.lock /app/
RUN yarn install

COPY . /app/

RUN bin/rails assets:precompile
############################################################
FROM ruby:2.5-alpine

RUN apk add --no-cache tzdata postgresql-dev
RUN apk add --no-cache nodejs

ENV RAILS_ENV=production

WORKDIR /app
COPY . /app/

COPY --from=builder /usr/local/bundle/config /usr/local/bundle/config
COPY --from=builder /app/vendor/bundle/ /app/vendor/bundle/
COPY --from=builder /app/public/ /app/public/

CMD ["bin/rails", "server"]

It’s now 231MB, a savings of around 75% 🎉. Note that the `uglifier` gem used by default in Rails 5.2 still requires you to have a Javascript runtime available, otherwise our final docker image could be even smaller.

For the next related post we’ll go over how to use multi-stage Dockerfiles for an Elixir Phoenix project for some more impressive size savings.

 
Synopsys Editorial Team

Posted by

Synopsys Editorial Team


More from Building secure software