Tracking a process's memory usage in Ruby

Nov 8, 2022 5 min read
Tracking a process's memory usage in Ruby

The amount of memory an application consumes is fundamental to investigating memory bloating. The quest to come up with that number per a given task is a challenge on its own when you want to debug your production application. If your observability service gives you that number, you are off to the races; if not, you are on a new journey. At BetterUp, we have consolidated a few observability tools into one vendor, Datadog. At this time, Datadog does not have first-class support for memory profiling in Ruby applications. The lack of memory profiling for Ruby comes as a regression for us, given that our current tool gives us insights into memory usage.

As a first step, one could manually start tracking memory usage and report it to any observability service. There are a few different tools (gems) that can help profile Ruby code. Those tools, however, have some limitations and are often used for one-off use or as needed, and are not recommended for always-on use on production because of their added overheard.

The question becomes, how can we circumvent this overhead? Can we build a simple tool that might not be perfect but provides valuable insights into memory allocation? And there lies your good old friend, C. We could tap into Ruby C API and have a sophisticated solution based on Ruby's internal object lifecycle events and use rb_tracepoint_new (source) to register a new listener. It all sounds very complex and precisely what our observability service should be doing instead.

At BetterUp, one of our high-impact behavior is “Do Less, Deliver More”. With that behavior in mind, we challenged ourselves to come up with a much simpler solution. One can think that a good start is to look at the process' memory usage, which would provide us with a good indicator of our code's memory usage; and that's what we will be doing. Before we get to the building part, let's introduce memory bloating and a note on a process's memory usage.

Memory bloating

Memory bloating arises when memory allocation sharply increases in an application. As a result,  the amount of memory the application uses throughout its life cycle becomes abnormal, impacting its performance. That said, gathering information on memory usage is crucial. We do not need a perfect solution. Instead, we need an indicator that further investigation might be required. Why not start by looking at the process' memory usage?

Process's memory usage

In a multi-threaded world, obtaining the memory usage of a process does not tell us the whole picture since a lot of the memory usage might be coming from a different thread than the one being analyzed. However, it provides a good starting point to look at places in our application where patterns might emerge regarding memory allocation. As of Ruby 3.1.2, there is no good way to peek at a process' memory usage. One is left to either use profiling tools on production as needed, run Unix commands from Ruby code, or read a process's file system from Ruby. Why not just tap into low-level interfaces that have all that process information?

Your first Ruby C extension

Oh, wait, C code? Bye now.

We can gather process-level information using C libraries. One such library is sys/resource.h in C, which gives us getrusage(). Here is what we will be doing:

  • Create a gem
  • Add a native extension to this gem
  • Get getrusage() ru_maxrss (doc) from this native extension

First, we create a new gem. We can use bundler to scaffold the structure of this new gem.

bundle gem getmaxrss

This command will create a directory with the base structure for our gem.

Let's focus on the folder lib. By now, we should have a folder structure that looks like this:

Rakefile
lib/getmaxrss.rb
...

The files for a native extension should live in a folder under the ext directory. This folder's name should be the same as our extension's name. It should look like this:

Rakefile
lib/getmaxrss.rb
ext/getmaxrss/...
...

Now that we have the ext/getmaxrss/ directory, we need to include a couple of files. Firstly, we create a extconf.rb file under that directory. This file will have configurations that tell a Makefile how to build our extension. And lastly, the second file is our C extension source. Let's create a file named getmaxrss.c, which bears the same name as the extension. So far, we have:

Rakefile
lib/getmaxrss.rb
ext/getmaxrss/extconf.rb
ext/getmaxrss/getmaxrss.c
...

Let's configure our extconf.rb. This is the step where you might want to check for any dependencies your extension might have. In our case, we will use it to check if the target system has the sys/resource.h header and getrusage function.

Our extconf.rb should look like this:

require 'mkmf'

abort('Missing <sys/resource.h> header on this system!') unless have_header('sys/resource.h')

abort('Missing getrusage() on this system!') unless have_func('getrusage')

create_makefile('getmaxrss/getmaxrss')

The code above uses mkmf, a library shipped with Ruby, to build a Makefile. Once generated, we will use the Makefile to compile our native extension.

Next up, let's write our C code extension, which goes into getmaxrss.c.

#include <ruby.h>
#include <sys/resource.h>

VALUE ru_maxrss;

// https://man7.org/linux/man-pages/man2/getrusage.2.html
static VALUE get_maxrss(int _argc, VALUE* _argv, VALUE _self) {
  struct rusage process_rusage_struct;
  int response;

  response = getrusage(RUSAGE_SELF, &process_rusage_struct);

  if (response == -1) {
    rb_sys_fail("Failed execute getrusage!");
  }

  ru_maxrss = LONG2NUM(process_rusage_struct.ru_maxrss);

  return ru_maxrss;
}

void Init_getmaxrss(void) {
  VALUE cGetmaxrss;

  cGetmaxrss = rb_const_get(rb_cObject, rb_intern("Getmaxrss"));

  rb_define_module_function(cGetmaxrss, "call", get_maxrss, -1);
}

Ohh, wait, what's going on there? Here are the parts:

  • Init_getmaxrss  -  This is the initialization hook for our extension. It needs to have the same name as our extension Init_<name> so that require can load it.
  • VALUE - It is a C type defined for referencing pointers to Ruby objects.
  • rb_intern - Returns the ID corresponding to the object.
  • rb_const_get - Access the constant of a class/module. In our case, we are accessing the Getmaxrss module from the Ruby object (rb_cObject).
  • rb_define_module_function - Defines a module function in C. It takes the module/class pointer reference, the method's name, the C function that defines the method, and a number that describes the number of receiving arguments.

Here is an excellent reference for this API.

Let's look at our get_maxrss function.

  • Grabs the current process usage information via getrusage, RUSAGE_SELF refers to the current process.
  • Retrieves only the ru_maxrss out of the rusage struct.
  • And returns the ru_maxrss as a VALUE.

Okay, now that we have all the code, how do we compile this and use it?

We will use rake-compiler gem for development, which will help us build our extension out of our extconf.rb file in development. Let's add the following lines to our Rakefile:

require 'rake/extensiontask'

Rake::ExtensionTask.new('getmaxrss') do |ext|
  ext.lib_dir = 'lib/getmaxrss'
end

By setting lib_dir, we ensure that our extension source will be built under the lib/getmaxrss directory. And now, we can run rake compile, which will compile our native extension.

We still need to require the extension we just compiled, and we can do that by changing lib/getmaxrss.rb to:

# frozen_string_literal: true

require_relative 'getmaxrss/version'

module Getmaxrss
end

require 'getmaxrss/getmaxrss'

Note the last line requiring our C extension.

🎉 Tada! Now we can check our gem by going into the Ruby console with bin/console.

> ./bin/console                                                                                                                                                        
irb> Getmaxrss.call
=> 63455232

Don't get forget to add tests, which will not be covered in this post.

Looking for patterns, not accuracy

Now that we have Getmaxrss, we can use it to look for memory allocation increases between application tasks such as web requests and background processing jobs. This approach is limited because of what we mentioned earlier, multi-threaded applications, and does not provide a very accurate picture of memory allocation between application tasks, but it provides a starting point for looking for abnormal patterns that might indicate that further investigation is needed.


About the Author

Victor is a Full Stack Engineer at BetterUp with a strong passion for software quality, learning new technologies, and building scalable products and systems. It all started when he first joined a startup in 2012, now he has amassed over a decade of industry experience.

Join the conversation

Great! Next, complete checkout for full access to BetterUp Product Blog.
Welcome back! You've successfully signed in.
You've successfully subscribed to BetterUp Product Blog.
Success! Your account is fully activated, you now have access to all content.
Success! Your billing info has been updated.
Your billing was not updated.