Une application Ruby plus légère et rapide avec jemalloc

Optimisez les temps de réponse et la consommation mémoire de votre serveur ou hébergement PaaS (Heroku, Scalingo, etc.) à l'aide d'une alternative à malloc.

Les applications Ruby ont en général une empreinte mémoire assez importante, qui peut augmenter régulièrement, même si le garbage collector entre en action. Une instance de votre application consommera peut-être 150 Mo de mémoire au lancement initial, et montera probablement rapidement au delà 300 Mo.

Ruby utilise la bibliothèque C malloc pour allouer, libérer et re-allouer dynamiquement de la mémoire, afin de stocker des objets. Il existe toutefois d'autres implémentations telles que tcmalloc ou jemalloc. Cette dernière bibliothèque a fait ses preuves dès 2005 avec FreeBSD depuis 2005. Le populaire et performant Redis l'utilise depuis également depuis plusieurs années — gage de fiabilité s'il en fallait.

Pour Ruby, utiliser jemalloc permet d'allouer, re-utiliser ou libérer plus efficacement la mémoire de votre application. En production, nombre d'utilisateurs ont constaté une augmentation des performances de l'ordre de 10% ainsi qu'une consommation mémoire qui ne s'envole pas et des fuites mémoires réduites ou même éliminées. Ce dernier cas de figure peut être un réel soulagement si vous êtes victime de ce type de désagrément. Nous sommes passés par là aussi !

Installer jemalloc

Ruby MRI, l'implémentation la plus connue et utilisée de Ruby, peut être compilé depuis la version 2.2 avec jemalloc en lieu et place de malloc. Aucun patch particulier n'est nécessaire pour se faire : seule la présence de bibliothèque jemalloc sur votre système est requise, ainsi qu'un drapeau (flag) passé lors de la compilation.

Sous Linux, installez à l'aide de votre gestionnaire de paquets préféré.

Par exemple avec Ubuntu :

$ sudo apt-get update
$ sudo apt-get install libjemalloc-dev

Sous macOS, le plus simple est d'utiliser Homebrew :

$ brew install jemalloc

Compiler Ruby avec support de jemalloc

Il vous reste à compiler Ruby avec le drapeau adapté :

$ ./configure --with-jemalloc

# Commandes habituelles post-configuration :
$ make
$ make install

Si vous utilisez un gestionnaire Ruby comme rbenv ou RVM il vous suffit de passer la commande suivante lors de l'installation :

$ RUBY_CONFIGURE_OPTS=--with-jemalloc rbenv install 2.4.0

Pour être certain que votre Ruby utilise jemalloc, lancez la commande suivante :

$ ruby -r rbconfig -e "puts RbConfig::CONFIG['LIBS']"

Si jemalloc est utilisé, vous devriez obtenir une réponse proche de celle ci :

-lpthread -ljemalloc -ldl -lobjc

La présence de -ljemalloc indique que la librairie du même nom est chargée au démarrage de Ruby.

Les plateformes PaaS, où compiler Ruby n'est pas possible

Une quantité importante d'applications sont déployées sur des plateformes se chargeant de A à Z de l'hébergement et du fonctionnement de vos applications Ruby. Avec ces solution est clé en main, il n'est ni nécessaire ni souvent possible de compiler Ruby soi même. Dans ce cas, on doit forcer l'utilisation de jemalloc à l'aide des buildpacks.

Si vous ne savez pas ce que sont les buildpacks, disons que sont des “paquets” logiciels contenant tout ce qu'il faut pour faire tourner un programme sur une plateforme d'hébergement Linux. Il existe des buildpacks Ruby, Node, Scala, Java, PhantomJS, etc.

Le buildpack jemalloc

Un buildpack n'est pas forcément synonyme de binaire executable. Comme les paquets sous Linux ou macOS, il peut très bien ne contenir qu'une bibliothèque C, par exemple.

C'est ici que le buildpack heroku-buildpack-jemalloc de Seth Fitzsimmons entre en jeu. Il contient jemalloc précompilée pour Linux x86 et stockée dans /app/vendor/jemalloc/lib/libjemalloc.so.1 pour un accès facile par Ruby MRI.

Le buildpack jemalloc doit être chargé avant le buildpack Ruby. Il vous faut configurer votre plateforme pour utiliser les deux buildpack simultanément.

Configurer les buildpacks multiples sur Heroku

Heroku supporte nativement plusieurs build packs. Commencez par définir le buildpack par défaut de votre application Ruby si ce n'estp pas déjà le cas :

$ heroku buildpacks:set heroku/ruby

Ajoutez ensuite en première position le buildpack jemalloc :

$ heroku buildpacks:set https://github.com/mojodna/heroku-buildpack-jemalloc

Configurer les buildpacks multiples sur Scalingo

Heroku a immensément popularisé les buildpacks. D'autres plateformes d'hébergement ont suivi la même philosophie. Nous utilisons depuis quelques années les services de Scalingo, solution française très similaire au géant américain, et qui progresse rapidement.

Un seul buildpack étant défini par application, l'astuce consiste à utiliser “Multi Buildpack” comme buildpack principal, qui en chargera plusieurs autres à son tour.

Il suffit de définir cette variable d'environnement :

$ scalingo env-set BUILDPACK_URL=https://github.com/Scalingo/multi-buildpack.git

Puis de créer un fichier .buildpacks à la racine de votre application et lister les buildpacks à utiliser :

https://github.com/mojodna/heroku-buildpack-jemalloc
https://github.com/Scalingo/ruby-buildpack

Au moment de déployer votre conteneur, Scalingo recherchera ce fichier et chargera un à un les buildpacks.

Démarrer son application Ruby avec jemalloc

Le buildpack jemalloc contient un script shell baptisé jemalloc.sh que l'auteur recommande de préfixer à votre ligne de commande Ruby habituelle.

Par exemple, pour lancer le serveur web Puma, on inquerait :

$ jemalloc.sh bundle exec puma

Utiliser ce script n'est pas élégant. Votre configuration devient spécifique et dépendante à ce buildpack. La même commande ne marchera pas sur une autre plateforme dépourvue du script, ou simplement sur votre machine de développement. Pas pratiqeu si vous voulez utiliser Foreman pour tester votre Procfile par exemple.

Une de nos applications est déployée continuellement sur 4 PaaS différentes, dont 3 utilisent un même fichier Procfile pour démarrer le server applicatif Ruby. Nous avons besoin d'utiliser la même configuration partout, sans être affectés par les optimisations spécifiques à Heroku ou Scalingo.

La solution est de suivre la méthodologie 12factor et de stocker la configuration dans l'environnement, pour indiquer à Ruby qu'il doit charger et utiliser jemalloc.

La variable d'environnement LD_PRELOAD indique à MRI les bibliothèques à précharger. Si Ruby détecte la présente de jemalloc, il l'utilise. Cela fonctionne avec bundle exec ruby aussi bien qu'avec l'executable d'une gem quelconque, telle que rubocop ou sidekiq.

Heroku

L'ajout d'une variable d'environnement peut se faire via interface web :

Configuration variable d'environnement sur interface web Heroku

Il est souvent plus rapide de le faire en ligne de commande :

$ heroku config:set LD_PRELOAD=/app/vendor/jemalloc/lib/libjemalloc.so.1

Scalingo

Même variable à ajouter pour Scalingo via l'interface graphique :

Configuration variable d'environnement sur interface web Scalingo

Ou à l'aide de l'outil CLI :

$ scalingo env-set LD_PRELOAD=/app/vendor/jemalloc/lib/libjemalloc.so.1

Redémarrez votre application, et si tout se passe bien, à vous les performances améliorées et l'empreinte mémoire réduite !

Expérience(s) personnelle(s) en production

Notre application la complexe (plus de 100 gems, 160 Mo au démarrage) consommait environ 500 Mo par worker Puma après avoir traité quelques dizaines de milliers de requêtes lourdes. Pour éviter de saturer notre serveur, les workers devaient être régulièrement éteints et relancés.

En passant à Ruby 2.4 compilé avec jemalloc, nous avons mesuré une consommation mémoire moyenne stabilisée autour de 350 Mo. Les temps de réponse sont également plus réguliers, avec une chute drastique du nombre d'alertes de requêtes anormalement lentes remontée par notre outil de monitoring applicatif.

Nous avons réitéré l'expérience avec d'autres applications, une légère en conteneur 512 Mo et un CMS maison plus lourd, dans une conteneur 2 Go. Le swap n'est quasiment plus utilisé, les conteneurs ne dépassent presque jamais les ressources allouées et les temps de réponse sont légèrement améliorés.

Utiliser la gem jemalloc n'est pas possible

Nous avons aussi essayé la gem jemalloc. Ce projet ne semble plus être actif depuis janvier 2015 et nous n'avons pu le faire fonctionner.

Il pose les mêmes complications à l'usage que le script jemalloc.sh : modifier toutes les lignes de commande lançant Ruby ou un executable pour les préfixer par bundle exec je.