loading...
Amplifr.com

How to migrate monolith to the scary new version of Rails

dsalahutdinov profile image Salahutdinov Dmitry Updated on ・9 min read

Migration to the new version of Rails always looks scary. Thinking about the upgrade raises many questions, especially if your application is an older monolith. The most critical points are:

  • upgrading without blocking feature development
  • affecting as fewer users as possible
  • making sure all logic works correctly on the new version of Rails

In this article, we will review some common techniques and tips which let Amplifr’s team have the painless incremental upgrade to the latest version of Rails.

❗The main tip - is to upgrade incrementally only to the latest patch version of Rails at once, because most of the bugs have already been solved. And this choice minimises breaking changes you need to deal with each upgrade.

We had done three upgrades steps: 4.2.0 => 5.0.7 , 5.0.7 => 5.1.6, 5.1.6 => 5.2.0.

Here are the standard steps of one Rails version upgrade cycle and delivering it to production:

  1. deprecations: clean up all deprecations, which block the next Rails version update
  2. dual boot: make application runnable with current and next version of Rails
  3. green tests: make tests pass for both Rails (matrix build)
  4. deploy → monitor → (revert) → fix: switch to the new version of Rails, monitor application for the new failures and bugs, revert if bugs are critical and you need time to fix them
  5. keep doing point number four until everything is ok

Repeat until you are on the top Rails version 😁

Deprecations

Deprecations say that something can break when a third party dependency gets updated. As we are going to upgrade Rails - we have to fix the blockers for the next version of Rails.

The main problem is to separate the target version blockers from fixing them first. Hiding the future version deprecations by using this dangerous and ugly-hack worked for us:

    if Rails::VERSION::MAJOR == 5
      # temporary mute test params deprecations
      ActiveSupport::Deprecation.behavior = lambda do |msg, stack|
        unless msg =~ /Using positional arguments in functional tests has been deprecated/
          ActiveSupport::Deprecation::DEFAULT_BEHAVIORS[:stderr].call(msg, stack)
        end
      end
    end

It is better to fix all deprecations at once if it’s possible, it will save a lot of time in the future

Dual boot

The idea is to support both versions of Rails at the same time. Keeping application runnable on current Rails version guarantees that you will be able to run the application on it if something goes wrong. Making application runnable on the next Rails version will allow you to have continuous upgrade.

Make your application able to run with both versions of Rails depending on environment variable RAILS_NEXT:
This command should boot current Rails version

    bundle exec rails c

And this will boot application with the new version of Rails:

    RAILS_NEXT=1 bundle exec rails c

The way to do it - is to hack bundler a little bit by putting this code at the beginning of Gemfile:

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

gem 'rails' #relax dependency

This code does a couple of things:

  • Defines the rails_next? method in Kernel class, so this method is available to use everywhere: in Gemfile, in your ruby application’s code and so on
  • Defines singleton-method to hold the current bundler’s lock-file name
  • Reset the lock-file name to Gemfile_next.lock if rails_next? is activated

❗Note, that we use one Gemfile and two lock-files for every version of Rails. It is much easier to maintain.

Dependencies

First of all, relax Rails dependencies in Gemfile to enable higher versions of Rails:
If you have rails version locked like this:

gem 'rails', '~> 4.2.3'

Unlock it.

gem 'rails'

It allows Gemfile to be compatible with all versions of Rails. Current and next rails version will be locked in the corresponding lock -files.

Then copy Gemfile.lock to Gemfile_next.lock and try to update rails on rails_next:

    $ cp Gemfile.lock Gemfile_next.lock
    # run bundle for fixing dependencies matedata in original Gemfile
    $ bundle
    # run bundle on rails_next to update rails
    $ RAILS_NEXT=1 bundle update rails

Here you will get bundler working hard to resolve dependencies and probably fail in the middle. Failure means that some of the dependencies are not allowed to work with the newer version of Rails.
Here are some scenarios:

  • Gem has current version locked and not supported. And it has the newer version to support the newer version of Rails. We have to update gem if only the rails_next? works:
    if rails_next?
      gem 'devise'
    else
      gem 'devise', git: 'https://github.com/plataformatec/devise', branch: '3-stable'
    end
  • Gem has Rails version locked from above: rails < 5.0 but probably works with newer version. You need to unlock dependencies and give it a try. Try to clone this gem from GitHub locally, relax dependency of Rails in gemspec file and plug gem locally. If it works - great, move to the next blocker, if not - try other solutions.
  • Gem has Rails locked, because of incompatibility and does not work with newer version. You need to unlock dependencies, understand how gem works and where is the problem, then fix all the conflicts and enable tests for the new version of rails in this gem.

Dealing with lasts cases - you will have to update gem source code. First of all, you will fix the problem in your fork. Despite this way looks very simple, fast and easy - there are many reasons for avoiding keeping it in forks:

  • Authors (or a group of authors) maintain their gems in a centralised way. Nobody is interested in supporting your fork 😢 Your fork is your problem!
  • Forking let you fix the problem fast, without design work and tests, but it is just postponement of the problem. You will pay back when upgrading the fork from the remote source in future
  • Fix a problem in a centralised way (by sending pull-request) helps other developers to deal with the same problem. Feel free to contribute by submitting the pull-request!

❗The better way to fix gem’s source code - is to send pull-request to the gem’s repository.

Unfortunately, some gems do not have anybody to maintain because of many reasons. And this is the signal for you to inspect your dependencies and get rid of unmaintainable ones in favour of more popular.

❗Note that you have to update all gems twice to support two version of Rails, and commit both Gemfile.lock and Gemfile_next.lock to keep them similar.

    bundle update some-gem
    RAILS_NEXT=1 bundle update some-gem

Runnable app

When all dependencies are okay, and your bundler successfully resolved for the rails_next - you are ready to proceed to the next step - check if the application is runnable.

You could try to run Rails console or local web-server, but it would be better to start from running the tests:

    RAILS_NEXT=1 bundle exec rspec

With a high probability tests would not start or even fail. The main problem here is the incompatibilities of your code or some gems’ code with new Rails version. So only one tip: go deeper and fix it.

❗ One trick we used - is to write Rails version dependent code using rails_next? method temporarily:

    project.run_callbacks(:commit) if rails_next?

It helps to write the version-specific code:

    if rails_next?
      redirect_to user_omniauth_authorize_path(:instagram)
    else
      redirect_to user_instagram_omniauth_authorize_path
    end

Green tests

Add the extra build to you CI for running tests with the next Rails version. If you are running on TravisCI, it might look like this:

    matrix:
      include:
        - env: RAILS_NEXT=0
        - env: RAILS_NEXT=1

If your next Rails version tests not pass yet - you can mark them optional like this:

    matrix:
      include:
        - env: RAILS_NEXT=0
        - env: RAILS_NEXT=1
      allow_failures:
        - env: RAILS_NEXT=1

This approach will let you merge matrix-build to the main branch without affecting feature-development, and you’ll be able to work on tests in parallel.

Finally, you need to make your tests green for both versions.

Getting upgrade without having tests - is not a very good idea 🙂, because it’s impossible to ensure crucial application parts work on the next version.

Deploy & Monitor

It time to rollout!

If you have staging - deploy there first and check the main functionality by hands. It minimises risks and may give a chance to catch some bugs.

Running in production is more effective. It makes the real users test application within the reals cases. The main idea is to deploy the new Rails version by “a little bit” and keeping in mind revert strategy, that would help you not to affect users too much or too long.

Depending on your infrastructure you may prefer to use one of the rollout strategies, or combine them:

  1. serving specific endpoints with new Rails version application instance, starting from low-loaded to high-loaded, or starting from non-critical.
  2. serving the percentage of overall load, and then run to increase the rate.
  3. rollout the application at the low-loaded time (if you have those), e.g. at night-time
  4. rollout 100% and keep calm 🙂

We decided to roll out the all Amplifr with new Rails version, because it is the most progressive way, and rolling back to the previous Rails version took a couple of minutes for us.

Once you’ve rolled out the application (or it’s part) and let real users face it - it is essential to keep monitor errors.
There are two general ways to get to know about problems:

  1. automatic monitor with some tools like Honeybadger, Rollbar, Sentry, NewRelic. Having it - is a must! They are useful to be notified about causes of issues and bugs online with Slack, email or other ways
  2. real users are critically helpful for small teams like Amplifr. No matter how much you’ve tested the application and how high test-coverage you have – it’s likely that you missed few bugs, especially in business logic. Always be kind and patient with your users, and they will behave the same way when you need their help.🙂

If something goes wrong - revert by changing the value of RAILS_NEXT environment variable. The exact way depends on the infrastructure you have:

    export RAILS_NEXT=0

Rolling back does not mean failure, so do not worry. It gives you the time to fix newly found errors while the application runs stable on the previous version of Rails. Just keep it up!

We have several rollbacks for fixing the critical bugs in Amplifr.

❗After the successful upgrade, let the application to work on the new version of Rails for a long-term to minimise risks and fix all the bugs.

Cleaning up

After the successful upgrade, we have clean up the source code and cut off the support of the previous Rails.

  • Make the application run with the new version of Rails by default. You have to copy next lock-file content and commit it: cat Gemfile_next.lock > Gemfile.lock git commit ...
  • Deploy the application and switch RAILS_NEXT 10
  • Cut off the Gemfile_next.lock: git rm Gemfile_next.lock git commit
  • Remove all the version dependent code; you can easily find it: grep -ir "rails_next\?" ./
  • Remove Bunder's hacks from the Gemfile

We haven’t removed the dual boot code and kept running on the non-updated lock-file for a couple of days. 🤦‍♂️ Be careful with it. 🙂

❗If you are going to keep upgrading - run from the begging with the next version.

Stay on edge

If you are on the last stable version, you might prefer to stop on it. Another option is to keep upgrading with the master branch as next version of Rails.

    gem 'rails', git: 'git@github.com/rails/rails'

This way is not single-valued but probably is the progressive one. Most of the gems do not support the newer version, and you will have to contribute there. It could be dangerous and make you catch bugs of the unstable features in Rails code.
But from the other hand - you will keep on the wave with the new version of Rails could make your upgrade painless and comfortable in the future, and be helpful by contributing to Ruby’s gems source codes.

Conclusion

Although upgrade Rails version might look like a tremendous amount of work, it is essential to be on the latest version, because of this reasons:

  • it is more stable, fast
  • it has new features
  • it lets you use the latest versions of gem’s

Doing upgrades iteratively, step by step, and keeping in mind revert strategy can make your upgrade less painful.

Further reading(watching):

Posted on Sep 25 '18 by:

dsalahutdinov profile

Salahutdinov Dmitry

@dsalahutdinov

Lead backend developer (Ruby) at Amplifr, testing fan, simplicity propagandist Family man, nature-lover, mathematician, red car driver

Amplifr.com

The easiest way to publish and analyze content on social media for media companies, e-commerce and small businesses.

Discussion

markdown guide
 

We have some resources from Shopify put together by Rafael, Rails' core contributor, and our Rails team at Shopify.

Upgrading the Monolith to Rails 5: engineering.shopify.com/blogs/engi...
Deprecation Toolkit: engineering.shopify.com/blogs/engi...

Hope these help :)

 

Julian! Thank you very much for providing links, I've added them to "Futher Reading" section.
Deprecation Toolkit looks very helpful 👍

 

Dual boot is a concept that had not come across my radar but makes a lot of sense.

Thanks for a great overall article, definitely reference material for our next upgrade. cc: @maestromac

 

Thank you, Dmitry! It is always nice to see who doesn't hide the experience and shares it with a community even though it's not unique. There will be less shit code in the world - what is important!

BTW, I've one remark. Don't use rails_next? outside of Gemfile. Because when you'll do the second and following upgrades then your code will be broken. Use only Rails::VERSION for the checks. I see that you write about that is temporary, but there is always a probability of forgetting about it =)

e.g.:

If we have Rails v4.2 this code is working well.

if rails_next?
  method_working_on_5_0_and_higher
else
  method_working_on_4_2
end

But after the transition to v5.0 it will be broken.

I suggest always to use clean version conditions.

if Rails::VERSION >= '5.0'
  method_working_on_5_0_and_higher
else
  method_working_on_4_2
end
 

Hey, Michail! Nice catch, thank you for the note!

It depends on the flow you use. If you clean up all the rails_next? checks after any upgrade iteration - it's okay

But checking with Rails::VERSION is much flexible I think.