Skip to content

Instantly share code, notes, and snippets.

@sethwebster
Last active April 12, 2023 19:06
Show Gist options
  • Select an option

  • Save sethwebster/ce7b5e81aba09b65066683c33f882fe9 to your computer and use it in GitHub Desktop.

Select an option

Save sethwebster/ce7b5e81aba09b65066683c33f882fe9 to your computer and use it in GitHub Desktop.

Revisions

  1. sethwebster revised this gist Jan 26, 2019. 3 changed files with 30 additions and 0 deletions.
    30 changes: 30 additions & 0 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,30 @@
    # Gem Install Dockerfile Hack

    If you're hacking on your Gemfile and using Docker, you know the pain of having the `bundle install` command run after you've added or removed a gem. Using `docker-compose` you _could_ mount a volume and stage your gems there, but this adds additional complexity and doesn't always _really_ solve the problem.

    Enter _this_ imperfect solution:

    > What if we installed every gem into it's own Docker layer which would be happily cached for us?
    `gem-inject-docker` does just that. It takes the list of gems used by your app via `bundle list` and transforms it into a list of `RUN gem install <your gem> -v <gem version>` statements and injects them into the Dockerfile at a point of your choosing.

    It additionally caches this list, and only _appends_ new gems to the end to prevent a full rebuild for all previously installged gems. If a gem is _removed_ it is removed from the list of instructions and the cache. This does _not_ seem to trigger Docker to rebuild all layers.

    **Pros**

    * Will absolutely increase the speed of your gem-update related Docker builds.

    **Cons**

    * Requires you to use a build script (see `build.sh`)
    * Requires you to add ./tmp/docker-cache to your `.gitignore` file. If you do not do this, other people's builds might not benefit from the speed improvement
    * Creates a bunch of Docker layers in the local cache

    ### Using

    1. Add a `bin` folder to your project
    2. Add `gem-inject-docker.rb` and `build.sh` to that path
    3. Make them executable: `chmod +x bin/gem-inject-docker.rb bin/build.sh`
    4. Instead of running `docker build . [...]` run `bin/build`

    The first build will take longer than you are used to as a new layer is created for every gem. This is normal (for this hack). Subsequent builds will benefit from this step, however.
    Empty file modified build.sh
    100644 → 100755
    Empty file.
    Empty file modified gem-inject-docker.rb
    100644 → 100755
    Empty file.
  2. sethwebster revised this gist Jan 26, 2019. 2 changed files with 2 additions and 4 deletions.
    2 changes: 1 addition & 1 deletion build.sh
    Original file line number Diff line number Diff line change
    @@ -1,4 +1,4 @@
    #!/usr/bin/env bash
    ./bin/gem-inject-docker > Dockerfile.injected && \
    docker build . -f Dockerfile.injected && \
    rm -rf Dockerfile.injected
    rm -f Dockerfile.injected
    4 changes: 1 addition & 3 deletions gem-inject-docker.rb
    Original file line number Diff line number Diff line change
    @@ -19,8 +19,6 @@
    # sourced from GitHub or another repo that
    # will not work with `gem install`
    IGNORED_GEMS = [
    'contently-jwt',
    'rack-delegate'
    ].freeze

    # Where would you like the list of previously
    @@ -43,7 +41,7 @@ def filter_ignored(gems)
    def bundled_gems
    gem_list = `bundle list`.split("\n")
    gem_list[1..gem_list.length - 1].map do |gem|
    gem.sub(' * ', '')
    gem[4..-1]
    end
    end

  3. sethwebster created this gist Jan 26, 2019.
    4 changes: 4 additions & 0 deletions build.sh
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,4 @@
    #!/usr/bin/env bash
    ./bin/gem-inject-docker > Dockerfile.injected && \
    docker build . -f Dockerfile.injected && \
    rm -rf Dockerfile.injected
    101 changes: 101 additions & 0 deletions gem-inject-docker.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,101 @@
    #!/usr/bin/env ruby
    # HACK: This file is here to inject each gem
    # dependency into the Dockerfile as a separate line.
    #
    # Doing so will increase your first build time, but
    # will allow subsequent builds to complete rather
    # quickly.
    #
    # Previously injected gems are cached to ./tmp/docker-cache
    # in docker-gems.cache.
    #
    #
    require 'fileutils'

    VERSION_MATCH = /\(?((\d+)?\.(\d+)?\.(\d+)\.?(\d+)?)\)?/.freeze

    # List the gems you wish not to inject here.
    # This is useful when you have gems that are
    # sourced from GitHub or another repo that
    # will not work with `gem install`
    IGNORED_GEMS = [
    'contently-jwt',
    'rack-delegate'
    ].freeze

    # Where would you like the list of previously
    # injected gems cached?
    TMP_DIR = './tmp/docker-cache'.freeze
    CACHE_FILE = File.join(TMP_DIR, 'docker-gems.cache').freeze

    # This token should be placed at the point in
    # your Dockerfile that you would like the gem
    # install instructions injected
    TOP_DELIMETER = '# --- INJECT GEMS HERE ---'.freeze

    # Filters the gems listed in the IGNORED_GEMS
    # above
    def filter_ignored(gems)
    gems.reject { |g| IGNORED_GEMS.any? { |i| g.start_with?(i) } }
    end

    # Returns the list of gems in your Gemfile
    def bundled_gems
    gem_list = `bundle list`.split("\n")
    gem_list[1..gem_list.length - 1].map do |gem|
    gem.sub(' * ', '')
    end
    end

    # Tranforms the list into Docker `gem install`
    # instructions
    def generate_docker_gem_install(gem_list)
    gem_list.map do |g|
    name = g.split(' ')[0]
    version = (VERSION_MATCH.match(g) || [])[1]
    "RUN gem install #{name} -v #{version}" if version && (!g.include? 'default')
    end.reject(&:nil?).join("\n")
    end

    # Tries to load the cached list of gems
    def load_previous_gems
    File.read(CACHE_FILE).split("\n")
    rescue StandardError
    []
    end

    # Saves the new list to the cachefile
    def save_new_gem_list(list)
    FileUtils.mkdir_p TMP_DIR unless Dir.exist? TMP_DIR
    File.open(CACHE_FILE, 'w') do |file|
    file.write(list.join("\n"))
    end
    end

    # Loads the dockerfile
    def load_dockerfile
    dockerfile = File.read('Dockerfile')
    return dockerfile if dockerfile.include? TOP_DELIMETER

    warn 'Dockerfile does not contain the injection point.'
    warn "Add #{TOP_DELIMETER} at the point you would like the gems inserted"
    exit(2)
    end

    # Injects the `gem install` instructions into
    # the Dockerfile and returns the result
    def inject_dockerfile
    dockerfile = load_dockerfile
    cached_gems = load_previous_gems
    required_gems = filter_ignored(bundled_gems)
    new_gems = required_gems - cached_gems
    removed_gems = cached_gems - required_gems
    new_list = (cached_gems + new_gems) - removed_gems

    save_new_gem_list(new_list)
    dockerfile.sub(TOP_DELIMETER, TOP_DELIMETER + "\n" +
    generate_docker_gem_install(new_list))
    end

    # output to STDOUT
    $stdout.puts inject_dockerfile