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.