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 extensionInit_<name>
so that require can load it.VALUE
- It is a C type defined for referencing pointers to Ruby objects.rb_intern
- Returns theID
corresponding to the object.rb_const_get
- Access the constant of a class/module. In our case, we are accessing theGetmaxrss
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 aVALUE
.
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.