Rodrigo Rosenfeld Rosas

Creating web app monoliths that boot instantly with Ruby

Fri, 25 Oct 2024 20:35:00 +0000

What if I told you that it’s not only possible, but actually pretty simple, to write a huge monolith web app that boots in under a second using Ruby?

Also, it doesn’t matter how big it grows, it will still boot in under a second. So, what’s the catch? How can it work?

Well, the key thing to boot quickly, whichever stack you pick, is to lazily load as much as you can. If you architecture your web app to load everything lazily, then it will boot at no time.

This is a long article, feel free to jump ahead to the last section if you’re only curious about the solution.

Why does boot time matter?

You may have been wondering: “so, what’s the point of lazily loading everything? It’s still going to spend time loading slow code when serving a request!”. And you’re right! But it doesn’t mean that instant boot isn’t valuable at all.

If you’re adopting canary deployment, for example, it doesn’t really matter too much if your deployment takes 20 minutes or an hour to happen. Unless you need to deploy an urgent fix (security patch or severe bug), of course!

However, during the development of the application, the boot time matters a lot! I’ve worked in apps that would take several seconds to boot before I could start using the app and add features or fix bugs, that’s a terrible experience, but it’s not that much of an issue if it only happens once in a while and code reloading happens pretty fast.

Automated tests benefit a lot from fast boot

The main benefit of having quick boot though is the ability of running individual automated tests very quickly.

Imagine yourself hunting some hard-to-debug flaky test. You’re probably going to run it many many times until you can figure out what’s causing it to fail sometimes. This is how the process usually goes:

  1. If you’re lucky, you’re able to reproduce by specifying the same seed reported by CI, so you run the test with the seed and confirm it’s failing locally too;
  2. You make some changes to the code or to the test, in an attempt to detect the reason why it’s failing;
  3. You run the test again;
  4. Repeat steps 2 and 3 several times until you find the culprit and fix the flaky test.

If your test takes 30 secs just to boot your app before you test it, that means fixing the flaky test will take 10 minutes at least only running the tests if you repeat steps 2 and 3 20 times. Add to this the frustration of having to wait over 30 secs before you know if your last attempted change resulted in any progress.

What if your app boots instantly and only 3 secs are required to load all dependencies for the particular request you’re exercising in your test? Suddenly, if you repeat the steps 20 times it will only take 1 minute running your test, compared to the 10 minutes from the previous scenario. And you only need to wait for 3 secs before you know if your change resulted in any progress. And it feels even better in tests where loading all required dependencies takes less than a second.

That’s the main advantage of lazily loading your code!

Rake tasks also benefit from fast boot

If some of your Rake tasks rely on booting your app first, then running that task will take over 30 secs, if this is what it takes to boot your app. Even if your task doesn’t require all dependencies loaded during the boot process.

As an example, if you’re working on a Rails app and run the routes task, it will require the app to boot. It’s not exactly a Rake task but the idea is the same: you’re running a task that relies on your app being booted. If your app takes 15 secs to boot, bin/rails routes will also take 15 secs to run.

Automatic setup has drawbacks

I’ve been interviewing Ruby candidates in the past few weeks and I often ask them what they like in Rails and almost every candidate tells me that “things just work in Rails”. No configuration required. No manual setup. This is perceived by most people as a great advantage in Rails over the alternatives.

After all, it’s a great feeling to start working on some app you’ve never seen before and you know exactly where to look when you’re searching for controllers, models, routes, settings and tests, right?

Conventions are great, indeed, I agree. But sometimes people will abuse from automatic setup (in my opinion, of course).

Let’s take the web-console gem, bundled with Rails by default, as an example. Once you require the gem, it will install a bunch of hooks, by calling Rails::Railtie.initialize with blocks that will add a middleware to the app, among other initialization tasks. Rails engines assume the existence of a singleton Rails application (Rails.application). During the boot process, this singleton app will call those hooks registered by the required Rails engines, such as web-console.

For the sake of easiness, lots of Rails engines follow this pattern for automatic setup. So, instead of providing users with instructions on how to add the provided middlewares to the app, those engines will automatically add the middleware for you. All you have to do is bundle add web-console and you’re done. No need to perform any extra steps, super easy!

But such a feature doesn’t come for free. First, Rails will load all gems from Gemfile in the default and environment groups by default. The default group is the one containing all gems declared in the top-level group. You can create as many groups in Gemfile as you want and Rails will automatically load the default and “test” groups by default when you load the application in the test environment.

Rails calls Bundler.require(*Rails.groups) in config/application.rb, which loads all project’s gems by default for the selected environment. So, unless you create a separate group for gems you want to lazily load, you must explicitly add require: false to the gem declaration.

By the way, by no means I’m suggesting there’s a problem with the web-console railtie itself. After all, it’s only loaded in the development environment by default, and you can even configure it to mount in an arbitrary path if you dislike the default one (“/__web_console”). I’m more worried about the available pattern, which can be abused by Rails engines.

Automatic setup makes lazy loading nearly impossible sometimes

If your app is designed in a similar way as Rails engines work, and dependencies rely on the ability of hooking into the boot process, then you can’t lazily load that dependency if it has to be required during the boot phase.

The top-level app should have minimal dependencies

Rack allows applications to mount other Rack applications on top of it. That allows us to build a monolith consisting of many Rack apps, mounted on the top of the main app.

This is a good step in the direction of achieving instant boot, however, it’s not an enough condition. Unless you’re able to lazily load those apps, you still must load them with their dependencies, and you won’t achieve instant boot.

If you add a dependency to the top-level route definitions, it will add to the boot time. Unless you architect your app in a way that allows you to lazily load your dependencies, you can’t guarantee that it will boot instantly even if it gets huge with hundreds or thousands of dependencies.

For example, you can’t lazily load Devise in a Rails application (try it if you don’t believe me). So, even if you want to test some model, you have to pay the cost of loading Devise and all its dependencies because in Rails the models also require the application to be initialized (in a typical setup).

If you know how to design a Rails app to always initialize in under 2s, no matter how big it gets, please let me know in the comments. For the remainder of this article, I’m assuming it’s not currently possible with Rails, so I’m going to provide examples with alternative libraries.

Hands-on: let’s build a web application with instant boot

How fast is Ruby?

If our goal is to boot our app within a second, we must first check whether Ruby can run boot itself that fast:

/usr/bin/time ruby -e ''
        0.27 real         0.07 user         0.04 sys

Awesome! We have 730 ms left to use in our own code. Let’s add a few dependencies and see how much is left:

BOOTSNAP_CACHE_DIR=tmp/cache /usr/bin/time ruby -r bundler/setup -r bootsnap/setup -e ''
        0.59 real         0.24 user         0.16 sys

That’s fine, we still have 410 ms left for our code. Can we load Rails within 410 ms?

BOOTSNAP_CACHE_DIR=tmp/cache /usr/bin/time ruby -r bundler/setup -r bootsnap/setup \
    -r rails -r action_controller/railtie \
    -e 'class MyApp < Rails::Application; config.api_only = true; end; MyApp.initialize!'
        1.27 real         0.51 user         0.50 sys

Nope, if we really want to boot within a second, we can’t use Rails. Less than 2 secs is still good enough, however there are many more serious reasons why we can’t guarantee fast booting with big Rails apps, which I’ll explore in-depth in another article.

Bare Rack app example

In the beginning of the article I said it was not only possible, but simple to write a web app with instant boot in Ruby. Let’s start demonstrating how it can be achieved with a pure Rack app:

1# config.ru
2
3require "rack/builder"
4app = Rack::Builder.new do
5 map "/up" do
6 run lambda { |env| [ 200, { 'content-type' => 'application/json' }, [ '{ "status": "ok" }' ] ] }
7 end
8
9 map "/heavy_app" do
10 run lambda { |env|
11 require_relative "config/environment"
12 Rails.application.call env
13 }
14 end
15 # as many map blocks here as big your monolith grows
16end

As you can see, the idea is to split the huge app into many smaller apps. If you exercise the “/heavy_app” endpoint in some test, of course it will take longer to complete if the app responding to that endpoint is a heavy one. However, if you’re running some test to check the “/up” endpoint, then it will complete in under a second.

This is extremely useful when working on individual tests. Ideally we should be only loading what the test is exercising. That way, we avoid having to wait for over 10 seconds every time we need our app to boot even if we only require a few dependencies to test what we want.

You might be concerned that this could lead to uncaught bugs in production because we may have forgotten to require something and the app could break in production if some requests were made in a different order. Or maybe you’d just prefer to eagerly load everything in production, like Rails does by default.

While it’s simple to modify the app above to support that feature (and eager load the app when running all tests in CI), I’m assuming you can do that by yourself, and I’ll present yet another solution using a Roda app, adding auto-reloading and eager loading support.

A Roda app example

Roda is a library created by Jeremy Evans, the maintainer of the (not so) popular Sequel gem. I’ve written about it already years ago, so in this article I’m going to focus on the solution itself.

I know many of you enjoy automatic code loading, so I’ll add the zeitwerk gem to provide both auto-reloading and auto-loading features to this app, the same way it works in a standard Rails app. Personally I’m not a fan of autoloading, so I use the auto_reloader gem instead (authored by me, by the way), but it’s a matter of preference. I’ve written about it before too. It doesn’t really matter for the purpose of building a huge monolith with instant boot.

We’re also using a very useful Roda plugin called multi_run in this example.

Let’s start by adding all dependencies:

mkdir my-huge-app
cd my-huge-app
git init
bundle init
bundle add puma roda zeitwerk rack-test cucumber ostruct logger

In case you’re curious, some of the dependencies rely on ostruct and logger but don’t explicitly depend on them, and they are no longer bundled with newer Ruby releases.

Then we’re going to create an useful boot file that we can load in our tests too:

1# boot.rb
2
3require "bundler/setup"
4ENV["BOOTSNAP_CACHE_DIR"] ||= "tmp/cache"
5require "bootsnap/setup"
6require "zeitwerk"
7
8APP_ENV = ENV["RACK_ENV"] || "development"
9loader = Zeitwerk::Loader.new
10loader.push_dir File.expand_path("app", __dir__)
11loader.enable_reloading if APP_ENV == "development"
12loader.setup
13
14loader.eager_load if APP_ENV == "production" || ENV["CI"]
15
16AppLoader = loader

I’m keeping this example very simple, but in a real app you’d be interested in using a proper Settings class, in which you’d configure the wanted behavior by environment, such as enabling auto-reloading and eager-loading, for example.

Now, the top-level app:

1# app/apps/main.rb
2
3require "roda"
4
5module Apps
6 class Main < Roda
7 plugin :json
8 plugin :multi_run
9
10 route do |r|
11 r.get "up" do
12 { status: "ok", env: APP_ENV }
13 end
14
15 run_app "some_path", ->{ Apps::SomeApp }
16 run_app "another_path", -> { Apps::AnotherApp }
17
18 # or something like:
19 Dir[File.expand_path("config/subapps/*.rb", APP_ROOT)].each{ |subapp| load subapp }
20 end
21
22 def self.run_app(path, app)
23 if APP_ENV == "production" || ENV["CI"]
24 request.run path, app[]
25 else
26 request.run path, ->(env) {
27 app[].freeze.app.call env
28 }
29 end
30 end
31 end
32end

And finally the web server entry point:

1# config.ru
2
3require_relative 'boot'
4
5if APP_ENV == "development"
6 run lambda { |env|
7 AppLoader.reload
8 Apps::Main.freeze.app.call env
9 }
10else
11 run Apps::Main.freeze.app
12end

As you can see, it’s a really simple setup that allows you to boot a huge app in under a second. Go ahead and add tons of gems to Gemfile and notice how the app will still boot instantly. Add as many apps to it, add some "sleep 10" statements to them, and you should still notice the immediate boot.

Testing the app

It’s now time to see one of the main benefits of this approach by testing it.

For the sake of simplicity, I’m using test/unit to test it, but you should get the same results with RSpec.

test/test_runner.rb:

1require_relative "test_helper"
2
3exit Test::Unit::AutoRunner.run(true, __dir__)

test/test_helper.rb:

1require_relative "../boot"
2require "test/unit"
3require "rack/test"

test/integration/health_test.rb:

1require_relative "../test_helper" # allows for `ruby test/integration/health_test.rb`
2
3class HealthTest < Test::Unit::TestCase
4 include Rack::Test::Methods
5
6 def app
7 Apps::Main.freeze.app
8 end
9
10 def test_response_is_ok
11 get "/up"
12 assert last_response.ok?
13 end
14end

You don’t need require_relative "../test_helper" in your tests if you’re just using ruby test/test_runner.rb, but it simplifies running those test files individually by simply calling ruby test/integration/health_test.rb instead of ruby test/run_test.rb --location test/integration/health_test.rb.

The test runs instantly:

/usr/bin/time ruby test/run_test.rb
Loaded suite test
Started
Finished in 0.008055 seconds.
-------------------------------------------------------------------------------------
1 tests, 1 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
-------------------------------------------------------------------------------------
124.15 tests/s, 124.15 assertions/s
        0.77 real         0.34 user         0.22 sys

Wow! That was fast! Not only does the application boot in less than a second, but we can also test it within a second.

How about Cucumber?

Testing straightforward requests with test/unit completes within a second, but can we also achieve subsecond testing with Cucumber? Well, let’s give it a try, shall we?

First we run:

bundle binstubs --all
bin/cucumber --init

Let’s edit features/support/env.rb:

1require_relative '../../test/test_helper'
2
3module CucumberApp
4 def app
5 @app ||= Apps::Main.freeze.app
6 end
7end
8
9World(Test::Unit::Assertions)
10World(Rack::Test::Methods)
11World(CucumberApp)

Let’s create a simple test (features/health.feature):

1Feature: Health check
2
3 Scenario: Health check monitoring
4 Given a monitoring service checks for the application health state
5 Then it responds with the current status

And the step definitions for this test (features/step_definitions/health.rb):

1Given('a monitoring service checks for the application health state') do
2 get "/up"
3end
4
5Then('it responds with the current status') do
6 assert last_response.ok?
7end

Let’s run the test:

/usr/bin/time bin/cucumber
Using the default profile...
Feature: Health check

  Scenario: Health check monitoring                                    # features/health.feature:3
    Given a monitoring service checks for the application health state # features/step_definitions/health.rb:3
    Then it responds with the current status                           # features/step_definitions/health.rb:7

1 scenario (1 passed)
2 steps (2 passed)
0m0.013s
        1.07 real         0.53 user         0.32 sys

Wow, that was really close! Maybe it’s time to ask my company for a hardware upgrade.

Summary

In this article we’ve seen that with some simple architecture we can achieve instant boot while designing a Ruby web application, no matter how big it gets, as long as we lazily load our code. We’ve also seen how it allows us to run individual simple tests very quickly.

We have also briefly discussed how such an approach would be a challenge to implement in a Rails app. In my next article I’ll explore what we can do to improve the boot time of Rails applications by applying the same idea of lazy loading as much as we can.

Powered by Disqus