UPDATE: We've come up with a potentially better way to achieve the same goal, by using a new Gemfile.initial file. See the new post here.I wrote a small script that can sync <GEM>_VERSION environment variables in a Dockerfile with versions from Gemfile.lock.
For example, if you have the following files:
Dockerfile
ENV RAKE_VERSION="12.0.0" \
RAILS_VERSION="5.0.0" \
NOKOGIRI_VERSION="1.0.0"Gemfile.lock
rake (13.0.6)
...
rails (6.0.5)
...
nokogiri (1.13.6)You can run this script to parse the versions from your Gemfile.lock and update your Dockerfile with the current versions:
#!/usr/bin/env ruby
require 'bundler'
project_dir = File.expand_path('..', __dir__)
Dir.chdir(project_dir) do
lockfile = Bundler::LockfileParser.new(Bundler.read_file('Gemfile.lock'))
dockerfile = File.read('Dockerfile')
%w[rake rails nokogiri].each do |name|
versions = lockfile.specs.select { |spec| spec.name == name }
.map { |spec| spec.version.to_s }.uniq
raise "Expected one locked version for #{name}: #{versions.inspect}" unless versions.one?
variable = "#{name.upcase}_VERSION"
pattern = /\b#{Regexp.escape(variable)}="[^"]*"/
raise "Expected one #{variable} assignment in Dockerfile" unless dockerfile.scan(pattern).one?
dockerfile.sub!(pattern) { "#{variable}=\"#{versions.first}\"" }
end
# Write only after every version and assignment has been checked.
File.write('Dockerfile', dockerfile)
end
Save this as scripts/update_dockerfile_versions.rb. It locates the project relative to the script, so it also works when the directory name contains spaces. Bundler parses the lockfile, including versions with four components, prereleases, and platform variants.Run the script after Bundler finishes, so it reads the new lockfile. A Gem.post_install hook runs during individual gem installation and can read the old lockfile before Bundler writes the new one.
bundle install && ruby scripts/update_dockerfile_versions.rb
Why would you need to do this?
It can take a long time to install gem dependencies when you're building a Docker image. To speed this up, you can put these lines near the top of your Dockerfile:
COPY Gemfile Gemfile.lock ./
RUN bundle installDocker can reuse the bundle install layer while Gemfile, Gemfile.lock, and the preceding build layers are unchanged.
You can speed this up even further if you install a couple of specific gems even earlier in your Dockerfile. This can be especially helpful if there are some gems that take a long time to compile native extensions.
The following Dockerfile will always cache the current versions of rails, rake, and nokogiri, even if you've added or updated some other gems.
ENV RAKE_VERSION="13.0.6" \
RAILS_VERSION="6.0.5" \
NOKOGIRI_VERSION="1.13.6"
RUN echo 'gem: --no-document' >> ~/.gemrc && \
gem install nokogiri --version "$NOKOGIRI_VERSION" -- --use-system-libraries && \
gem install rake --version "$RAKE_VERSION" && \
gem install rails --version "$RAILS_VERSION"
COPY Gemfile Gemfile.lock ./
RUN bundle installCan you parse the gem versions while building the Dockerfile?
You would need to copy the Gemfile.lock into your Docker image in order to parse the versions. This would break the extra layer of caching we're trying to achieve, because Gemfile.lock is updated whenever any gem is changed.
What about using an .env file or -e flags?
Another option is to use Docker build arguments: declare ARG values in the Dockerfile and pass them with docker build --build-arg. Runtime -e flags do not configure a build. I chose to keep the versions in the Dockerfile and update them after bundle install.
Let us know if you have any suggestions or a better way to do this!
We don't have a commenting feature on our blog, but please feel free to send us an email: [email protected]