Upgrading Rails apps with dual boot

Spring is here, at least where I’m sitting. And that means a new version of Ruby on Rails is on its way! As I write this, we’re getting our first look at Rails 6. Are you ready to upgrade? If you’re like a lot of Rails shops, you may be a version or two, or three, behind. Upgrades are hard work, especially for large codebases that have been around for awhile.

There are plenty of reasons to adopt new versions early. New framework APIs and development experience improvements help get ideas shipped sooner. Performance enhancements make apps work faster, more efficiently, and more reliably. And security patches keep the business and its customers safe from bad guys on the internet.

But there are also plenty of reasons to hold off on upgrades. The first release of a major new version always has bugs and stability issues, right? Can’t risk releasing a bug or slowdown to production. What about dependencies — will they be updated in time? And upgrades can take months, or longer, taking valuable time away from the user-facing features that pay our employers’ bills.

Selling management on upgrades can be tricky, especially if they require putting everything else on hold. How can we reduce risk and time spent on large upgrades, while also not neglecting new feature requests?

O’Reilly’s Rails engineers have historically approached upgrades with a long-running git branch focused only on the upgrade, with regular feature work continuing as usual in separate branches. This technique is common and time-tested, but it’s got inherent complexity:

  • It’s up to us to test, merge, and test again each feature branch into the upgrade branch regularly, lest we merge code that’s incompatible with the new framework version.
  • Speaking of merges, we can only merge feature branches into the upgrade branch, not the other way around — otherwise, we’d prematurely introduce an incomplete upgrade to production. So new application features can’t leverage new framework features, even though they’re just a branch away.
  • If any Rails APIs changed between the old version and the new, we need to carefully manage the difference between the two, being mindful not to overwrite with each future feature merge.
  • When it’s time to release, the upgrade branch has potentially spanned across months of the calendar and hundreds of lines of code — not easy to parse in a code review!

Never mind selling management on upgrades — selling other engineers may be hard enough!

But in 2018, GitHub made news by announcing they’d finished a long upgrade process to use the latest version of Rails. This was a big deal because, prior to this announcement, they were known for being behind a major version or two. Patches were back-ported to a bespoke version of Rails. So when GitHub engineering shared not only the news, but also a high level look at how they did it, my team wanted to try a similar approach.

After reading the post from GitHub, I researched further. As it turns out, engineers at Shopify and other Rails shops employ a similar technique. The common term for it is dual boot, but in reality, we could add support for any number of Rails versions, including edge. Shopify actually uses it to continually build against edge releases of Rails! The intent isn’t to ship to production using a pre-beta version of the framework. But knowing what’s going to break ahead of time reduces risk and upgrade time — fewer unknown unknowns. It identifies code that will no longer work, and dependencies that need to be replaced. It’s a proactive, incremental solution to large upgrades.

Dual boot leverages a Rubyist’s ability to reopen any class and modify its behavior. That sounds scary, but is powerful when used carefully. Using a per-environment configuration, it’s totally possible to specify which dependencies to load at application boot, and which application code to process at runtime.

Salahutdinov Dmitry gets deeper into the weeds in a post on how the team at Amplifir uses dual boot. In particular, Salahutdinov’s post helped me get through the tricky part of handling multiple dependency lock files and loading the correct file at boot, rather than managing via aliases (which can’t be kept in version control).

Our solution is based heavily on the Amplifir approach. To start, the dual boot code patches Bundler to allow us to load dependencies from environment-specific lock files, and Ruby’s Kernel to provide global access to a helper called rails_next? to determine which version to run. We set the version with the RAILS_NEXT environment variable, and load dependencies accordingly.

module Bundler::SharedHelpers
def default_lockfile=(path)
@default_lockfile = path
end
  def default_lockfile
@default_lockfile ||= Pathname.new("#{default_gemfile}.lock")
end
end
module ::Kernel
def rails_next?
ENV["RAILS_NEXT"] == '1'
end
end
if rails_next?
Bundler::SharedHelpers.default_lockfile =
Pathname.new(
"#{Bundler::SharedHelpers.default_gemfile}_next.lock"
)
class Bundler::Dsl
unless method_defined?(:to_definition_unpatched)
alias_method :to_definition_unpatched, :to_definition
end
    def to_definition(_bad_lockfile, unlock)
to_definition_unpatched(
Bundler::SharedHelpers.default_lockfile, unlock
)
end
end
end

I’m calling the method and environment variable rails_next here to be general for the sake of this article, but you may also consider using a more version-specific name like rails_6? or rails_master?.

We can now use dual boot in our Gemfile:

require_relative “./lib/dual_boot”
source ‘https://rubygems.org'
if rails_next?
gem “rails”, “6.0.0.beta2”
else
gem “rails”, “5.2.1”
end
# …

With this code in place, whenever making a dependency change, we can also generate version-specific lock files:

RAILS_NEXT=”1" bundle install
Keep both lock files checked into version control, and remember to run bundle install with and without RAILS_NEXT whenever updating application dependencies. If you using Spring, you may need to bin/spring stop, too.

We can also use rails_next? anywhere else in our code where changes between framework versions require version-specific solutions.

if rails_next?
# do it the Rails 6 way
else
# stick to the Rails 5 way
end

We’re just started using dual boot ourselves. We’ve stubbed our toes once or twice figuring things out, but overall, but early returns are promising. It helps that we’re reasonably confident in our test suite. We’ve set up two continuous delivery jobs to run on each build; one with the flag enabled, one without. If a test fails on either, we can address accordingly. Ideally, we replace outdated code with a single, forward-facing approach, but if necessary, we can wrap version-dependent code in the rails_next? helper, and clean up later. We also have an extra staging environment where QA can perform their tests on the RAILS_NEXT version, ensuring a consistent experience across both versions.

Since we started down this path, Shopify shared more about their approach to dual boot, and a gem called Bootboot to simplify some of the extra Gemfile management. I’m also intrigued by its implementation as a Bundler plugin. If it piques your interest, you may also like Edouard Chin’s appearance on the Ruby on Rails Podcast, where he talks about Shopify’s approach in practice.

I’m really excited about using dual boot on all my future Rails upgrades, large and small. If it pays off the way it has at GitHub, Shopify, and elsewhere, it’ll mean my team and I can use new Rails APIs sooner, and keep our codebase modern. We’ll never stress about using obsolete, insecure versions of the framework. Pull requests will merge faster and more cleanly, and deploys will follow suit. And most importantly, we’ll continue pleasing stakeholders and customers by releasing new features frequently and reliably.

How do you tackle framework upgrades? Do you dual boot, or does another solution work better for you? Or if you work outside of Rails and are still reading (thanks, by the way), what are the conventions for upgrades in your ecosystem of choice? Let us know, we’re always interested in learning from others and helping spread great ideas.