Upgrading to Rails 7.1, Ruby 3.3, jemalloc and YJIT
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:
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.