Speed up Rails deployment on docker with multi-stage bundle install
When Rails application becomes bigger and bigger, it takes more time and resources on bundle install, especially with gems including native extension such as nokogiri and sassc.
A solution for this problem is to split Gemfile with essential gems in one Gemfile and the other Gemfile with more frequently updated gems.
I end up with a Gemfile.essential like this as original Gemfile generated by Rails command:
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }ruby '2.6.5'gem 'rails', '~> 6.0.1'
gem 'pg', '>= 0.18', '< 2.0'
gem 'puma', '~> 4.1'
gem 'sass-rails', '>= 6'
gem 'webpacker', '~> 4.0'
gem 'turbolinks', '~> 5'
gem 'jbuilder', '~> 2.7'
# gem 'redis', '~> 4.0'
...
And another one named Gemfile like this:
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }ruby '2.6.5'instance_eval File.read('Gemfile.essential')gem 'bcrypt', '3.1.12' # to avoid error on devise
gem 'devise' # user session
...
Please note the use of instance_eval to include another Gemfile into this one. Now, we can do two steps of bundle install in Dockerfile like this:
FROM ruby:2.6.5-alpine AS gemENV RAILS_ENV productionWORKDIR /myappRUN apk add --update --no-cache nodejs yarn postgresql-client postgresql-dev tzdata build-base libidn-dev# install essential gems
COPY Gemfile.essential .
COPY Gemfile.essential.lock .
RUN bundle install --gemfile=Gemfile.essential --deployment --without development test# install extra gems
COPY Gemfile .
COPY Gemfile.lock .
RUN bundle install --deployment --without development test# install npm packages
COPY package.json .
COPY yarn.lock .
RUN yarn install --frozen-lockfile...
As you can see, it first copy Gemfile.essential and install gems, then copy Gemfile and install gems. Because docker cache build stages, edits in Gemfile will not affect the cache of Gemfile.essential. A new deploy with edited Gemfile looks like this:
⠼ Building your source code...
Step 1/34 : FROM ruby:2.6.5-alpine AS gem---> 3304101ccbe9
Step 2/34 : ENV RAILS_ENV production---> Using cache
---> 2b787698dd04
Step 3/34 : WORKDIR /myapp---> Using cache
---> c595582e2a79
Step 4/34 : RUN apk add --update --no-cache nodejs yarn postgresql-client postgresql-dev tzdata build-base libidn-dev---> Using cache
---> 02870b57713f
Step 5/34 : COPY Gemfile.essential .---> Using cache
---> 9371f5cac29d
Step 6/34 : COPY Gemfile.essential.lock .---> Using cache
---> fdaa6f4b456c
Step 7/34 : RUN bundle install --gemfile=Gemfile.essential --deployment --without development test---> Using cache
---> 8520ca39c7de
Step 8/34 : COPY Gemfile .---> 90cc661e3459
Step 9/34 : COPY Gemfile.lock .---> 84369497f95f
Step 10/34 : RUN bundle install --deployment --without development test⠸ Building your source code...
You can see that bundle install with Gemfile.essential is cached. Therefore, we save time and resource when new gems are added into Gemfile.