Rails Upgrade Guide–How to Tackle a Major Upgrade Using Dual Booting

One Medical Technology
One Medical Technology
8 min readMay 17, 2023

By Adia Porter

Photo by Maxwell Nelson on Unsplash

Here at One Medical we have a 10 year old monolith Rails application with 175 thousand lines of code that we recently upgraded from Rails 5 to 6 and then 6 to 7, back to back. Since we did the upgrade process twice back to back, we learned quite a bit and created the following process that perhaps you too will find helpful. This process was used to upgrade to Rails 7, which went a whole lot smoother than the previous upgrade. We were able to avoid a main branch code freeze and minimize any negative impact on production that could interrupt our patients’ ability to get care.

The game changer for the Rails 7 upgrade was using dual booting since it allowed us to avoid a main branch code freeze and de-risk the update process. This allowed us to roll forward and back on production with ease in case of any bugs. We rolled back a total of two times with the net effect of affecting only two users on production.

Since we had a safe, low-risk, and stress-free upgrade, we are sharing our general Rails upgrade process. If you are considering tackling a similar upgrade, we hope this information helps!

Some reasons to consider upgrading:

  • It is easier to find Rails documentation for the right version.
  • Get access to the latest Rails features.
  • Get performance improvements.
  • Get fixes for Rails bugs.
  • Get fixes for security vulnerabilities.
  • Potential to remove gems in lieu of new Rails features.
  • Being on a supported Rails version that receives security patches.

Steps to upgrade Rails

  1. Address deprecations
  2. Update gems (external and internal)
  3. Follow the Ruby on Rails upgrade guide
  4. Set up dual booting
  5. Configure framework defaults
  6. Testing tips
  7. Stay on top of Rails releases in case there are relevant bug patches to apply

1. Address Deprecations

To keep your upgrade branch small, use Shopify’s Deprecation Toolkit gem to permit existing deprecations while not allowing new ones to be introduced.

In case there are deprecations not caught by the test suite, also scan production logs for variations on the keyword deprecation warning, for example: DEPRECATION WARNING, is deprecated.

2. Update gems

Update gems to versions that support the new Rails version:

  1. Find outdated gems by running bundle outdated.
  2. Update the external and internal Rails related (ex: skip AWS SDK ones) gems.
  3. From that list, one can selectively update those that are truly necessary for the Rails upgrade. These can be found by scanning their changelogs for the keywords “Adds Rails <version> Support”.
  4. Update these gems independently from the Rails upgrade.
  5. Scan for unmaintained gems.
  6. These gems may be incompatible with the new Rails version or may no longer be needed.
  7. These may not show up when searching for outdated ones since you may already be on the latest version.
  8. To find these, review the gem’s documentation (README, changelogs) for mentions of no longer being maintained and last release date. We leveraged our security scanning tool to find gems that hadn’t been updated in years.

Bundler troubleshooting

If you encounter errors such as “Bundler could not find compatible versions for gem <gem name> (usually rails related, such as actionpack)” and there is an actual compatible version, this just means that Bundler needs an explicit command to update that gem as well, ex: `bundle update actionpack rails`.

Our gem update story

When we updated to Rails 6.1, many issues came from using incompatible gems, resulting in lots of debugging. For the next update, we side-stepped the hard work of debugging by updating incompatible gems before the update. This was a much easier approach; auditing the gems was less effort than debugging mysterious bugs. It also helped us avoid bugs that might have been missed during regression testing. For example, we would have missed out on this important update from Doorkeeper rubygem.

So how did we find out which gems to update? Unfortunately, this was a manual process of reviewing the gems’ changelogs for any updates relevant to the Rails 7 upgrade by searching for the keywords “Rails 7”. This also had the positive side effect of discovering unmaintained and no longer needed gems.

We also took a look at gems that hadn’t been updated by their publishers in years and verified that they were still actually compatible with the new Rails version. Security tools often include when a gem was last updated by the publisher.

The bulk of our Rails upgrade time was spent on updating gems. In the future, we want to lighten this load by continuously keeping our gems up to date instead of waiting for a Rails upgrade to audit and update them. Not only does this make Rails upgrades easier and less risky (less updates happening at around the same time), it also means getting these gem improvements (security and bug patches) sooner rather than later.

3. Follow the Ruby on Rails upgrade guide

Once you have addressed deprecations and updated gems, follow the steps in the Ruby on Rails’ upgrade documentation to upgrade.

4. Set up dual booting

Feature flag the Rails upgrade by setting up dual booting using Shopify’s bootboot plugin:

  1. Follow Shopify’s bootboot’s README instructions.
  2. Set up CI workflow to run specs against both Rails versions.
  3. Conditionally run some code depending on Rails version.
  4. Dual booting cleanup

Why we use dual booting

The game changer for our Rails 7 upgrade was being able to toggle between Rails versions on production with the flip of a switch, which was made possible by dual booting.

This mitigated the risk of our upgrade since we could essentially “feature flag” the entire Rails framework, allowing us to switch versions quickly and avoid a code freeze. Due to this, a bug that came up only affected two users.

Kudos to our former contractor André Arko for introducing this to us.

There are some costs to setting up dual booting that are worth considering. If your application is not risky to update, these costs may not be worth it:

  • Maintaining a parallel CI workflow and the cognitive complexity of making specs work against both Rails versions.
  • Adding dependencies becomes a bit more complex. Developers adding dependencies will have to remember to run `DEPENDENCIES_NEXT=1 bundle install` after making changes to the dependencies. There’s now a Gemfile.lock and a Gemfile_next.lock to keep track of.
  • Dual booting logic and its cleanup

CI workflow

In addition to using dual booting on the application code, we also use it to run our continuous integration specs (ex: CircleCI spec workflow) against both Rails versions to ensure that we keep the code working for both versions.

At a high level here’s how we structure our CI workflow:

  1. Install the new Rails version and its dependencies in our CircleCI step that installs Ruby dependencies:

# install gems for Rails-next only if applicable (done by checking presence of Gemfile_next.lock file since that will only exist if dual booting is in place)

if [[ -e Gemfile_next.lock ]]; then

bundle config unset clean

DEPENDENCIES_NEXT=1 bundle install

fi

Since this code will only run if a Gemfile_next.lock file exists, we keep this infrastructure in place for the next upgrade.

2. Run tests against the new Rails version.

We duplicate our test running steps to run tests on the new Rails version. For example, we duplicate “unit_tests” to create “unit_tests_rails_next” with the change that the “unit_tests_rails_next” command to run tests is prepended with DEPENDENCIES_NEXT=1 so that it runs the tests against the new Rails version. For example, `DEPENDENCIES_NEXT=1 bundle exec <run tests>`.

3. Duplicate any other steps that should also run against the new Rails version. Prepend commands with DEPENDENCIES_NEXT=1 so that they run against the new Rails version.

Conditionally run some code depending on Rails version

Sometimes code is not compatible with both Rails versions. We handle these cases by conditionally running code depending on the Rails version.

For example:

if ENV[“DEPENDENCIES_NEXT”]

# execute code that only works on Rails 7

else

# execute code that only works on Rails 6

end

Dual booting cleanup

Once the new Rails version is stable, we remove the superfluous dual booting logic:

  • Conditionals added as described in section Conditionally run some code depending on Rails version.
  • Dual booting setup in the Gemfile. For example removing these lines:

plugin “bootboot”, “~> 0.2.1”, source: “https://rubygems.org"

Plugin.send(:load_plugin, “bootboot”) if Plugin.installed?(“bootboot”)

if ENV[“DEPENDENCIES_NEXT”]

enable_dual_booting if Plugin.installed?(“bootboot”)

# install new Rails version

else

# install current Rails version

We leave in the parallel continuous integration workflows that ran against the new Rails version since those will now be a no-op and it’s nice to have that infrastructure in place for the next time. Also consider running it against the Rails main branch instead.

5. Configure framework defaults

This is our process:

  • Follow the steps in the Upgrading Ruby on Rails guide
  • Verify that configuration is applied. If we can’t verify, we add the config to our config/application.rb file. Section “Configs not actually being applied” goes into our backstory for this.
  • When flipping the `config.load_defaults value` mentioned in the guide above, we double check that no new configurations have been added since they were last reviewed.
  • Our backstory (“New configurations backported” section below) on why we do this.

Gotchas that we encountered:

Configs not actually being applied

Rails 6.1 and Rails 7 each had a config that didn’t actually have an effect when uncommented in the new framework defaults file, so if possible, we check that uncommented configs actually have an effect.

For example, the config “active_support.cache_format_version” couldn’t actually be configured until Rails 7.0.4 included a Railties bug fix for that.

New configurations backported

Also when flipping the `config.load_defaults value`, we double check that no new configurations have been added since they were last reviewed. For the Rails 6.1 update, there was a new configuration that was backported in.

6. Testing tips

Sometimes Rails caches get invalidated when switching versions so these are some tests that we do to see how the version switch goes:

  • Roll forward and back:
  • Do users get logged out? What is the impact of that, especially if they were in the middle of filling out a form, etc.
  • Do background workers transition smoothly, especially in that stage where some were generated with the old version and then processed with the new one?

These are some code spots that we review since they might break with the upgrade:

  • Monkey patches to Rails APIs
  • Gems that extend core Rails functionality. These might also include monkey patches.
  • Code that uses private or undocumented Rails APIs

These are some bugs that we encountered by updating to Rails 7.0.4.1:

  • Error: ActiveRecord::ReadOnlyRecord: <record> is marked as readonly
  • This was due to ActiveRecord’s bug fix of now respecting read only status when calling update_column/s or touch.
  • Error: undefined method `_reflect_on_association’ for NilClass:Class
  • This happens when `ActiveRecord::Associations::Preloader.new(records: <data>, associations: [<associations>]).call` is called with nil for argument records and has associations listed. Rails 6 ignored null objects, but Rails 7 raises an error.

7. Stay on top of Rails releases

Consider following the Rails repo to get timely notifications of Rails releases, which is especially relevant for security and bug patches.

Also consider subscribing to This Week in Rails newsletter for Rails updates.

Final thoughts

I hope this helps you with your own upgrades! If you have any questions or want to share any of your own lessons learned, please leave a comment below.

--

--