Dual-Boot Ruby on Rails using Docker

Dual-Boot Ruby on Rails using Docker

Starting in Rails 7.1, Docker files are added by default opens a new window in new applications, but Docker opens a new window has been popular for Rails development for many years before that. At FastRuby.io, we use the Dual-Boot technique opens a new window when we work on upgrades, and using that approach when an application uses Docker requires some extra steps to keep a great development experience.

Initial Setup

We’ll use this initial basic setup as an example of the technique. Since applications can have Docker configured in completely different ways, this is more of a guide with the main elements that must be changed and should be adapted as needed.

Dockerfile

FROM ruby:3.1

WORKDIR /code

COPY Gemfile ./
COPY Gemfile.lock ./
RUN bundle install

This is the minimal setup to tell Docker to create a Ruby 3.1 container, to copy our Gemfile and Gemfile.lock files, and to install the gems during the build.

docker-compose.yml

version: "3.7"
services:
  web:
    build:
      context: .
    command: "rails s"
    volumes:
      - ".:/code:delegated"

This is the minimal setup to tell Docker Compose opens a new window what to do with our Dockerfile.

Then, all we need to do to start the Rails application is run docker-compose up.

Throughout the article, I’ll use docker-compose up, but depending on your Docker installation you may need to use docker compose up instead (without the -).

Dual-Boot Ruby

# We define a RUBY_VERSION argument with a default value of the
# Ruby version used before adding dual boot
ARG RUBY_VERSION=3.1

# We use the RUBY_VERSION argument which will have the value defined
# in the docker-compose.yml file, or the 3.1 fallback value
FROM ruby:${RUBY_VERSION}

WORKDIR /code

COPY Gemfile ./
COPY Gemfile.lock ./
RUN bundle install
version: "3.7"
services:
  web: &common
    build: &build
      context: .
      args:
        RUBY_VERSION: 3.1
    command: "rails s"
    volumes:
      - ".:/code:delegated"

  web-next:
    <<: *common
    build:
      <<: *build
      args:
        RUBY_VERSION: 3.2
    profiles: [next]

We reuse the configuration from the original service to not need to duplicate everything using the &common and &build YAML anchors and respective aliases.

We use the profiles: [next] property opens a new window in the web-next service so it is ignored when running docker-compose up. This avoids starting 2 apps at the same time trying to use the same port.

If you want to run both versions of the application with docker-compose up, you can remove this profiles: [next] line and define different ports for each.

Now we can run docker-compose up to run the Rails application using Ruby 3.1, and docker-compose up web-next to run it using Ruby 3.2.

Gemfile Consideration

In most Rails applications, the Gemfile includes a ruby x.y.z declaration to specify the expected Ruby version to use. We can remove that declaration to avoid conflicts with the next Ruby version not matching the one defined in the Gemfile. In those cases we can add this step in the Dockerfile before running bundle install:

# Remove the `ruby x.y.z` declaration from the Gemfile file
RUN sed -i 's/^ruby.*$//g' ./${BUNDLE_GEMFILE}

Dual-Boot Rails

The first step is to set up the Gemfile.next file so we can bundle and run the application with different Rails versions on demand. This article shows how to dual boot opens a new window a Rails app.

Once the Gemfile.next and Gemfile.next.lock are ready, we can proceed with the changes to Docker.

FROM ruby:3.1

WORKDIR /code

# We define a BUNDLE_GEMFILE argument with a default value of Gemfile
ARG BUNDLE_GEMFILE=Gemfile

# We use the BUNDLE_GEMFILE argument which will have the value defined
# in the docker-compose.yml file, with Gemfile as a fallback

# Since Gemfile.next is a symlink to Gemfile, we must copy Gemfile but
# with the Gemfile.next name so we have a file and not the symlink.
COPY Gemfile ./${BUNDLE_GEMFILE}
COPY ${BUNDLE_GEMFILE}.lock ./
RUN BUNDLE_GEMFILE=${BUNDLE_GEMFILE} bundle install
version: "3.7"
services:
  web: &common
    build: &build
      context: .
      args:
        BUNDLE_GEMFILE: Gemfile
    command: "rails s"
    volumes:
      - ".:/code:delegated"
    environment:
      - BUNDLE_GEMFILE=Gemfile

  web-next:
    <<: *common
    build:
      <<: *build
      args:
        BUNDLE_GEMFILE: Gemfile.next
    environment:
      - BUNDLE_GEMFILE=Gemfile.next
    profiles: [next]

When dual booting a gem, we need to add Gemfile.next both as an argument for the build process, and as an environment variable to use inside the container.

Now we can run docker-compose up to run the application using the current Rails version, and docker-compose up web-next to run it using the next Rails version.

Alternative Method to Dual-Boot Rails

Instead of using different containers, an alternative method is to use a single container and bundle all the gems from both versions:

FROM ruby:3.1

WORKDIR /code

COPY Gemfile ./
COPY Gemfile.lock ./
RUN bundle install
COPY Gemfile.next ./
COPY Gemfile.next.lock ./
RUN BUNDLE_GEMFILE=Gemfile.next bundle install

This has some pros and cons compared to the previous method.

Pros

  • We can open a bash session inside the container and run commands with the different Rails versions without having to leave the container.
  • We save some space by not having a second Docker image.
  • We only need to build one image, and we can change the docker-compose.yml file to run the container with a different BUNDLE_GEMFILE env variable as needed.

Cons

  • If we use this Docker image for production, our deployment will be slower, bundling a second set of gems that we usually won’t use.
  • If we want to do something different for the next version (like using different environment variables or running different dependent services), the setup would be more complicated.
  • If we want to run the same test in the 2 Rails apps and debug them in parallel, we can’t do this in a single container.
  • This approach is not easy to use to dual boot Ruby, so if the upgrade process requires upgrading multiple versions of Ruby and Rails over time, this would be more inconsistent for the developer experience.

Conclusion

In this article we explained 2 approaches to apply the Dual-Boot technique when using Docker, with pros and cons for you to try them out and pick what works better for your needs. Both approaches have their benefits and you can also switch between them as you need for different tasks.

You can also combine them: install all gems of both Rails versions in a single container (instead of using build arguments) and use different services to run the container with one version of Rails or the other.

It’s important to note that these are not the only possible approaches. Docker is a really flexible tool with many ways to configure and run containers.

Do you need help with an upgrade? Do you use Docker in development or production? We can help! opens a new window

Get the book