A line graph showing a dip at a point when jemalloc was introduced to the server.

Over the holiday break, I decided to be a good boy and keep up with the regular updates to this website. This website uses a Dockerized Ruby on Rails app and is deployed on Render. I had four primary things I wanted to get done:

  1. Upgrade to Rails 7.1 (from Rails 7.0)
  2. Upgrade to Ruby 3.3 (from 3.2.2)
  3. Turn on jemalloc
  4. Enable YJIT

None of these were giant leaps, so everything went pretty smoothly. I'll discuss each and show you what I did to complete these tasks. I hoped to get increased speed from YJIT and nullify the extra memory usage by turning on jemalloc. I don't have any speed benchmarks to show, just vibes, but I ended up with decreased memory usage, and the site feels fast. So, yay!

Upgrading Rails to 7.1

I started the upgrade to Rails in my Gemfile:

gem "rails", "~> 7.1", ">= 7.1.2"

After this, I ran bundle install inside my Docker container to generate a new Gemfile.lock so that I could generate new images. I do my best to follow upgrade guides if provided, but they're not always available, especially for minor releases. In this case, a good article by Fiona Lapham lists many essential aspects needed to get your app up and running.

I will lean heavily on my tests if I don't have something to reference. I also like to diff the major files in my /config directory with the files from a fresh new install of the Rails version I'm upgrading to. In this case, a new line in /config/application.rb started producing errors for me.

config.autoload_lib(ignore: %w[assets tasks])

These errors led me to discover the hotly debated topic of how to use the /lib directory. I had no idea there were such strong feelings out there! I won't wade into those waters but know that Rails 7.1 is autoloading the /lib directory, and the autoload_lib(ignore:) method allows you to exclude directories where it doesn't make sense.

In my case, I had some Redcarpet renderers that I repeatedly used across multiple projects, so /lib has always seemed like a natural place to put these files. After 7.1, I had to provide a namespace to silence the errors I was getting.

Before:

class StripDownRenderer < Redcarpet::Render::StripDown

After:

class RedcarpetCompat::StripDownRenderer < Redcarpet::Render::StripDown

Precompilation and Docker

I'm a little fuzzy on the timing of this next part (even after looking through my commits), but if you use Docker, you may run into an issue with precompilation and Rails wanting your rails_master_key. You can check out the discussion on GitHub if you are unsure if this issue affects you. The gist of it is that you typically don't need the master key to precompile all your assets, so you can pass a dummy value to let that image build step complete. In your Dockerfile, you should have:

RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile

Load 7.1 defaults

Finally, I went into config/application.rb to opt into the config defaults for Rails 7.1:

config.load_defaults 7.1

Sweet, that was it for upgrading Rails on this website.

Upgrading to Ruby 3.3

Moving things to Ruby 3.3 was a matter of updating just a few files.

# Dockerfile
ARG RUBY_VERSION=3.3.0
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base
# Gemfile
ruby "3.3.0"

I use Docker Compose when I work locally, and my docker-compose.yml references a separate Dockerfile.dev. My daily driver is an M1 MacBook Pro, which runs on Apple silicon. Ruby 3.3.0 doesn't yet play nice with this architecture, as mentioned here, so I had to implement the fix offered in the answers:

# ARG RUBY_VERSION=3.3.0
# FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim

# TODO - Uncomment the lines above when 3.3.1 is released.
# This is a temporary fix for a bug found here (https://stackoverflow.com/questions/77725755/segmentation-fault-during-rails-assetsprecompile-on-apple-silicon-m3-with-rub)

FROM debian:bullseye-slim as base

# Install dependencies for building Ruby
RUN apt-get update && apt-get install -y build-essential wget autoconf

# Install ruby-install for installing Ruby
RUN wget https://github.com/postmodern/ruby-install/releases/download/v0.9.3/ruby-install-0.9.3.tar.gz \
  && tar -xzvf ruby-install-0.9.3.tar.gz \
  && cd ruby-install-0.9.3/ \
  && make install

# Install Ruby 3.3.0 with the https://github.com/ruby/ruby/pull/9371 patch
RUN ruby-install -p https://github.com/ruby/ruby/pull/9371.diff ruby 3.3.0

# Make the Ruby binary available on the PATH
ENV PATH="/opt/rubies/ruby-3.3.0/bin:${PATH}"

# End TODO

This is a known issue and has already been fixed in Ruby 3.3.1. Once available, we can remove this temporary fix and return to the usual way of specifying the Ruby version.

jemalloc

Two down, two to go! Next up was jemalloc. It's an advanced memory allocator that helps provide lower and more consistent memory usage. I chose to implement it at the server level, meaning Ruby is technically unaware of it. Back in my Dockerfile I first made sure the package was being included for in the final deployment stage.

# You will likely have many other packages, I'm just showing the
# need for libjemalloc2
apt-get install --no-install-recommends -y libjemalloc2

Now that the package is installed we can set some environment variables to get jemalloc running the way we want.

ENV LD_PRELOAD="libjemalloc.so.2" \
    MALLOC_CONF="dirty_decay_ms:1000,narenas:2,background_thread:true,stats_print:true"

That's it! There's basically no reason not to do this. The image at the top of this page shows the signifcant memory drop in my app after deploying to the server. You can just plunk it in there outside the context of your Rails app and see instant gains. I recommend you give it a shot.

YJIT

Finally, we arrive at the last goal I had, YJIT. YJIT is a just-in-time compiler that was developed to help speed up Ruby and Rails apps. There's an interesting post on Shopify's engineering blog walks through its history. YJIT is going to be enabled by default if you create a Rails 7.1 app and are running Ruby 3.3.0 so it's mature and ready to rock. Assuming you meet the aforementioned requirements, you can enable YJIT in your Dockerfile by tacking on a single line to the last bit of code I listed:

ENV LD_PRELOAD="libjemalloc.so.2" \
    MALLOC_CONF="dirty_decay_ms:1000,narenas:2,background_thread:true,stats_print:true" \
    RUBY_YJIT_ENABLE="1"

The final line above turns on YJIT and you're off to the races. Big thanks to all the people who put hard work into making Ruby & Rails faster.

So, that's it! Admittedly, I didn't have to do too much here to see some really cool upgrades to my site. That's the beauty of open source. Please be nice and encouraging to these folks who give us all wonderful tools while asking for very little in return. Thanks to all you maintainers out there.

Written by Matt Haliski

The First of His Name, Consumer of Tacos, Operator of Computers, Mower of Grass, Father of the Unsleeper, King of Bad Function Names, Feeder of AI Overlords.