Upgrading Rails: Steps to a Smooth Transition

Rails Renewed: Strategies for Safely and Efficiently Upgrading Your Application

Upgrading a Ruby on Rails application may sound like a daunting task, but fear not! This guide presents a strategic approach to handling a Rails upgrade that will make the process smoother, more manageable, and less stressful.

The journey involves a series of steps, each catering to a specific aspect of the upgrade. Upgrading a Ruby on Rails application is a crucial step to ensure compatibility, security, and performance, and it is also essential to leverage the latest features. By breaking down the process into manageable steps, from reproducing the application using containers to gradually upgrading components.

First of all, containers!

The process commences by reproducing the existing application environment using containers. This ensures a consistent setup across different machines and servers, eliminating the dreaded "it works on my machine" scenario. Containerization also streamlines deployment, making the transition to new environments effortless. Using technologies like Docker enables the replication of the application environment, minimizing configuration discrepancies.

So, what are your dependencies? Let’s say that you are using Ruby 2.2 with Rails 4.2. Is this reasonable? Not today! The end of life of Rails 4.2 was around 2017, 6 years ago! And the ruby? EOL on 2018! So many changes since then, but it is what it is, let’s focus on our problem and stop complaining about it. Now, how I should create my container? Well, if your application is not container-ready for production, let’s focus only on the development side.

To choose the base image to run the container, you can look at the official docker repository, called "docker hub". There you will see the official images from ruby, and in the tags list, looking for "2.2.", you will see a bunch of images, look: https://hub.docker.com/_/ruby/tags?page=1&name=2.2.

If we focus on the latest 2.2 ruby version (the 2.2.10 one), the suffix tags are:

  • onbuild: Can be used to install the gems of your project, but it’s the same as the "jessie" version
  • jessie: Is using the "jessie" version of Debian as base;
  • slim: Use jessie as base, but does not include a bunch of utilities to build dependencies, like git, bison, automake, libmysqlclient-dev and more, resulting in a ~65% smaller image (256MB vs 92MB);
  • slim-jessie: Same as the "slim" version, just to be more specific about the base image version in the tag name.
  • alpine: Is using the "alpine" as base. The Alpine is another SO that uses apk instead of apt as the package manager and has only the bare minimum files necessary to run a Linux version with a package manager. The image size is smaller than the "slim" version, around 32MB. The default version is 8x bigger!
  • alpine3.4: Same as the "alpine" version, just to be more specific about the base image version in the tag name.
  • no suffix: Same as "jessie"

To make things easier, choose the "jessie" version that will have all the tools that you’ll need in your project. The "jessie" is the Debian 8, with ELTS until 2025. The more recent the ruby version is, the more recent version of Debian will be as the base, like "Stretch" (9), "Buster" (10), "Bullseye" (11), and the more recently, "Bookworm" (12), and yes, the names are from "Toy Story".
If you look at the commands in each layer of the image, you will see that the image does not have "nodejs" inside of it:
https://hub.docker.com/layers/library/ruby/2.2.10/images/sha256-6c8e6f9667b21565284ef1eef81086adda867e936b74cc74a119ac380d9f00f9?context=explore

The rails application needs a backend javascript runtime to "execjs", and this will perform the assets minification, CoffeeScript compilation (yeah yeah, we know), and even run babel. Some applications use "therubyracer", "miniracer" or nodejs. Since nodejs is the most common runtime, you can install it using the "nvm" inside of the dockerfile.

If we look for some LTS node versions that were launched around 2018 (the year that the latest ruby 2.2 was released), we found the "Argon" (4), "Boron" (6), "Carbon" (8), and the "Dubnium" (10) versions (yeah yeah, chemistry things bla bla, let’s move on…). To keep the probability of having issues at a minimum, let’s choose the Argon version, and install it using a custom Dockerfile. With that, the Dockerfile below should be enough:

FROM ruby:2.2.10-jessie

ENV BUNDLE_PATH /bundle
ENV BUNDLE_BIN /bundle/bin:${BUNDLE_BIN}
ENV GEM_HOME /bundle
ENV PATH ${BUNDLE_BIN}:${PATH}

# Install node
ENV NVM_DIR /root/.nvm
ENV NVM_VERSION v0.39.4
ENV NODE_VERSION 4.9.1
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh | bash \
  && . ~/.nvm/nvm.sh \
  && nvm install ${NODE_VERSION} \
  && nvm alias default ${NODE_VERSION} \
  && nvm use default
ENV NODE_PATH ${NVM_DIR}/versions/node/v${NODE_VERSION}/lib/node_modules
ENV PATH ${NVM_DIR}/versions/node/v${NODE_VERSION}/bin:${PATH}

WORKDIR /var/app

In the file above, we will use Ruby "2.2.10", node "4.9.1" installed by nvm, and define the paths to install the gems at /bundle inside of the container. That should make it easier to track and cache gems between the runs. And now to use, let’s create a simple docker-compose:

services:
  app:
    build:
      context: .
    command: bundle exec rails server --bind 0.0.0.0
    ports:
      - 3000:3000
    volumes:
      - .:/var/app
      - bundle_cache:/bundle

volumes:
  bundle_cache:

That will map the current folder to /var/app inside of the image, and the bundle cache as a docker volume, and to use it just:

$ docker compose build app
$ docker compose run --rm app bundle install
$ docker compose run --rm app bundle exec rake db:migrate
$ docker compose run --rm app bundle exec rake db:seed
$ docker compose up

If your application depends on any kind of definitions, environments, additional steps, or a database service, such as Postgres, ElasticSearch, Redis, or even another service/custom service, update your docker-compose with the requirements and then go to the next step when you have the "up" running and working properly.

Then, code coverage!

Code coverage assessment is the next pit stop. By evaluating the comprehensiveness of your tests, you pinpoint areas that need attention during the upgrade. This step is like shining a flashlight in the dark corners of your codebase, ensuring nothing remains overlooked.

Thank god the Ruby ecosystem has great tools for it, and the biggest one is the "SimpleCov". The instructions are straightforward on the GitHub page of the project:
https://github.com/simplecov-ruby/simplecov

Basically you will add the simplecov gem in the test group, and load it at the begun of the test_helper.rb file:

Gemfile:

group :test do
  # Track test coverage
  gem "simplecov", require: false
end

test_helper.rb

# SimpleCov has to come first
if ENV["USE_COVERAGE"]
  require "simplecov"
end

.simplecov

SimpleCov.start "rails" do
  add_filter "test"
  add_filter "vendor"
end

then:

bundle install
bundle exec rake test

Install the tool, run the suite, and check the coverage. Change the example above to what fit in your suite, if you’re using minitest, rspec or what. Depending on your workflow, you can merge the coverage from multiple runs or parallel runs, use the .simplecov file to group the code, or using a custom format, for more, read the simplecov documentation.
The "magic number" 100% of coverage is the ideal, if it is not 100%, go ahead and take the risks.

Do not rush! The path is long, but you must be patient!

Rather than attempting an all-in-one upgrade, opt for a gradual approach. This entails upgrading components one by one. Compatibility between Rails and Ruby versions is pivotal. Start by ensuring Rails and Ruby compatibility, then take small leaps by upgrading your Ruby version while keeping your current Rails version.

With each step, test diligently and address issues promptly. If you cannot upgrade the ruby version anymore, it’s time to upgrade the rails.

If we continue with our example, we need to check if Rails 4.2 accepts a new version of the ruby. The Ombulabs team has a nice table about the compatibility between rails and ruby, check out: https://www.fastruby.io/blog/ruby/rails/versions/compatibility-table.html

Progress with small PRs. Instead of tackling the upgrade in one colossal move, break it into bite-sized chunks. Each pull request becomes a step towards the finish line, ensuring that issues are spotted and fixed incrementally. Analyze the existing test suite for failures or deprecated methods.

Use some tools, the "next" is in the view… two rails versions?

Introducing the "next" gem for dual-booting with different Rails versions further enhances your arsenal. It allows you to switch between Rails versions, making the transition smoother and giving you the flexibility to test new waters while keeping your old ship afloat.

The next is based on the "Ten Years of Rails Upgrades".
https://github.com/clio/ten_years_rails
https://github.com/fastruby/next_rails

With the gem, you can also add a method called next? in your Gemfile:

def next?
  File.basename(__FILE__) == "Gemfile.next"
end

And then you can simple have a link called Gemfile.next, and enabling it using export BUNDLE_GEMFILE=Gemfile.next to define multiple gems in the Gemfile file, like:

if next?
  gem "rails", "5.0.7.2"
else
  gem "rails", "4.2.11.3"
end

With that, the BUNDLE_GEMFILE will define if you will use the next or not, and then, the helper of the next_rails gem can be helpful to you start defining the next? on the Gemfile to each version by looking for what will not work:

bundle exec bundle_report compatibility --rails-version=5.0.7.2

Documentation, read the f** documentation

"The Rails Guides" is an awesome documentation! It always has the basic upgrade tips there, read it:
https://guides.rubyonrails.org/upgrading_ruby_on_rails.html
https://guides.rubyonrails.org/upgrading_ruby_on_rails.html#upgrading-from-rails-4-2-to-rails-5-0

Based on the links above, you can see that, for example, the mysql adapter should be changed to mysql2, you should use byebug instead of debugger, the protected_attributes is no longer supported, you should use the ruby 2.2 at least, and more.

Now you can try the first change outside of your machine…

Enhancing your Continuous Integration (CI) pipeline comes next. A robust CI system ensures your tests run seamlessly across different Rails versions, detecting any incompatibilities early on. A CI workflow is a "must to have" to keep the project growing up when some of your team starts the upgrade, using the dual-boot feature with the rails-next gem. This step is your hand, and depending on what kind of solution you are using to that, you will have the matrix feature, parallel, multiple step and so on… choose it wisely.

Keep your eyes on the track

Conditional code and TODOs are your allies during the upgrade. By adding version-specific conditions to your code and marking areas needing attention, you’re building a roadmap to follow.

For example, you can starting using something like this:

# TODO: Remove this condition when we fully upgrade to rails 5
if Rails::VERSION::MAJOR >= 5
  # Call the rails 5 method
else
  # Call the old rails method
end

This structured approach keeps you on track and prevents any upgrade-induced chaos.
Also, you can use a bug tracker tool to push the rails/ruby warnings there, and with that, you can create tickets to clean up the warnings before setting the new version as default, using the notify from rails. For example inside of the application.rb or the production.rb file:

ActiveSupport::Deprecation.behavior = [:stderr, :notify]
ActiveSupport::Notifications.subscribe('deprecation.rails') do |_name, _start, _finish, _id, payload|
  # Do the notify action to your bugtracker
end

More about this, you can look at the documentation (Look for at your specific rails version):
https://api.rubyonrails.org/v4.2.11.3/classes/ActiveSupport/Deprecation/Behavior.html#method-i-behavior-3D

Set the new version as the default

As the dust settles, you’ll switch your new Rails version to be the default one. The thorough testing and preparation done thus far set the stage for a smooth transition. Basically you can set in your production the BUNDLE_GEMFILE=Gemfile.next do the deploy steps, and check the results. If anything goes wrong, just change the value BUNDLE_GEMFILE=Gemfile, and restart your application (maybe some deployment steps are required).

Cleanup and be ready for the "next" steps

With the application finally humming along on the new Rails version, it’s time for cleanup. Eliminate remnants of the old version, ensuring your application is optimized and streamlined.
But the journey doesn’t end here. Repeat the steps for each subsequent version upgrade. By embracing this iterative process, you maintain the integrity of your application and stay up to date with the latest improvements.

In conclusion, upgrading a Ruby on Rails application becomes an easily achievable feat when approached strategically. This comprehensive guide empowers developers to navigate the upgrade process with confidence, ensuring that the application remains stable, performant, and ready for the future.

To Summarize:

  • Always replicate your application using isolated containers;
  • To choose the dependencies versions, focus on the date of the ruby release that you’re using today;
  • Check the code coverage before deep changes;
  • Do not make a big PR to change everything in a single try;
  • Look at the compatibility list between Ruby and Rails, go to the next Ruby version compatible with the current Rails version, and then upgrade Rails (each step is a new PR);
  • Rails dual boot is a good thing;
  • A CI system is a "must have" on this work;
  • The TODO annotation is your friend, use it!

We want to work with you. Check out our "What We Do" section!