Photo by Guilherme Stecanella on Unsplash

Photo by Guilherme Stecanella on Unsplash

Building a Docker image for a given Rails application is easy – unless you want the Docker image as small as possible. Docker is awesome, but handling large files is annoying. Read how I have reduced the image size of a Rails application from 1.6GB to 329MB.

There are several ways to reduce the size of a Docker image. Here I want to describe some of them:

  1. Use Alpine as the base image
  2. Use Multi-stage building
  3. Beware of the chown pitfall
  4. Remove Bundler cache
  5. Remove parts of the app not needed in resulting image

1. Use Alpine as the base image

The official Ruby Docker image is based on Debian Stretch. It is about 860MB in size, which is quite a lot. But there is help in the form of an Alpine image, which is only 55 MB. Great, but there are some things different: Except Ruby, almost nothing is included, so you have to add the required Linux packages by yourself.

Note: Currently (April 2018) there is an open issue with the Alpine image for Ruby 2.5, which leads to some Stack level too deep errors. Until this issue is resolved, you must use the Ruby 2.4 Alpine image, which works well. Update 2018-10-03: The issue is fixed.

2. Use multi-stage building

Last year Docker 17.05 introduced multi-stage builds, which are a great way to get lean images by separating the building steps from the bundling steps. The building steps require all the build tools to be present (e.g. git, nodejs, yarn, development packages). In the resulting image, they are not needed. The idea is to first build the stuff and then copy only the resulting artifacts into the final image. Think about separate Dockerfiles with a feature to copy some files from one image to another.

Simplified example:

1
2
3
4
5
6
7
8
9
10
11
12
13
FROM ruby:alpine as Builder
RUN apk add build-base yarn git postgresql-dev
.
.
RUN bundle install
RUN rake assets:precompile
.
.
FROM ruby:alpine
RUN apk add postgresql-client
.
.
COPY --from=Builder /compiled-files /compiled-files

3. Beware of the chown pitfall

It is a common practice to set the owner of some files/folders via chown after copying the files. But with Docker this can unintentionally increase image size. See this fragment of a Dockerfile:

1
2
COPY . /dest-folder
RUN chown -R someuser:somegroup /dest-folder

This works, but the result is two layers with the same size (use docker history to see the size added by each layer).

Since Docker 17.09 there is a way to do the same things without wasting space:

1
COPY --chown=someuser:somegroup . /dest-folder

Just one layer, no size duplication. Read more about this here: https://blog.mornati.net/docker-images-and-files-chown

4. Remove Bundler cache

Bundler installs some files not needed in production, so you can delete them:

  • Cache folder
  • C source files and compiled object files (for gems with native extensions)
1
2
3
RUN rm -rf /usr/local/bundle/cache/*.gem \
 && find /usr/local/bundle/gems/ -name "*.c" -delete \
 && find /usr/local/bundle/gems/ -name "*.o" -delete

5. Remove parts of the app not needed in resulting image

If your Rails application uses Yarn to manage JavaScript packages, you will find lots of MB (usually 100MB and more) in the folder node_modules/. After the assets are precompiled, they are not needed anymore. Delete them in the build stage to save space in the resulting image. Besides that, precompiling assets leave a large number of files behind in tmp/cache/, which are not needed in production. While you’re at it, there are some more files you don’t need in production: The spec/ (or test/) folder and the assets folders.

1
2
# Remove folders not needed in resulting image
RUN rm -rf node_modules tmp/cache app/assets vendor/assets lib/assets spec

Result

For the implementation of the described actions I used my all-time example application DockerRails. Here is the resulting 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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
######################
# Stage: Builder
FROM ruby:2.5.1-alpine as Builder

RUN apk add --update --no-cache \
    build-base \
    postgresql-dev \
    git \
    imagemagick \
    nodejs-current \
    yarn \
    tzdata

WORKDIR /app

# Install gems
ADD Gemfile* /app/
RUN bundle config --global frozen 1 \
 && bundle install --without development test -j4 --retry 3 \
 # Remove unneeded files (cached *.gem, *.o, *.c)
 && rm -rf /usr/local/bundle/cache/*.gem \
 && find /usr/local/bundle/gems/ -name "*.c" -delete \
 && find /usr/local/bundle/gems/ -name "*.o" -delete

# Install yarn packages
COPY package.json yarn.lock /app/
RUN yarn install

# Add the Rails app
ADD . /app

# Precompile assets
RUN RAILS_ENV=production SECRET_KEY_BASE=foo bundle exec rake assets:precompile

# Remove folders not needed in resulting image
RUN rm -rf node_modules tmp/cache app/assets vendor/assets lib/assets spec

###############################
# Stage wkhtmltopdf
FROM madnight/docker-alpine-wkhtmltopdf as wkhtmltopdf

###############################
# Stage Final
FROM ruby:2.5.1-alpine
LABEL maintainer="mail@georg-ledermann.de"

# Add Alpine packages
RUN apk add --update --no-cache \
    postgresql-client \
    imagemagick \
    tzdata \
    file \
    # needed for wkhtmltopdf
    libcrypto1.0 libssl1.0 \
    ttf-dejavu ttf-droid ttf-freefont ttf-liberation ttf-ubuntu-font-family

# Copy wkhtmltopdf from former build stage
COPY --from=wkhtmltopdf /bin/wkhtmltopdf /bin/

# Add user
RUN addgroup -g 1000 -S app \
 && adduser -u 1000 -S app -G app
USER app

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

# Set Rails env
ENV RAILS_LOG_TO_STDOUT true
ENV RAILS_SERVE_STATIC_FILES true
ENV EXECJS_RUNTIME Disabled

WORKDIR /app

# Expose Puma port
EXPOSE 3000

# Save timestamp of image building
RUN date -u > BUILD_TIME

# Start up
ENTRYPOINT ["docker/startup.sh"]

Now the resulting image is 329MB, its (compressed) download size is 121MB.


Update 2020-01-29: There is a follow-up article in which I show how to reduce the building time:
Building Docker images, the performant way