Unicorn vs Thin pour propulser une application Ruby on Rails, stress test pour choisir !

Unicorn et Thin sont deux serveurs pour Ruby assez fréquemment utilisés et cités dans la littérature informatique. A titre d'exemple, Unicorn est utilisé notamment chez Github: une bonne référence ! Thin est souvent cité pour sa robustesse et sa légèreté. Lequel choisir ?

Avant d'envisager le déploiement d'une application en production (prévision de charge : quelques centaines d'utilisateurs simultanément), j'ai souhaité les comparer.

Protocole de test

  • Linux Debian Jessie, Core M 5Y10, 4 Go de RAM, disque dur SSD ; les tests sont effectués en local sur la machine et le réseau n'intervient donc pas comme facteur dans le test.
  • Le stress test est réalisé avec Gatling (dont j'ai déjà décrit le fonctionnement ici) qui simule l'arrivée de 200 utilisateurs en 120 secondes sur l'application, chaque utilisateur exécute 8 requêtes sur l'application.
  • L'application Ruby on Rails (Rails 4.2.1, Ruby 2.2.2) est prête à l'emploi en environnement de production, les 2 gems Thin et Unicorn sont installés et fonctionnels.
  • Quand cela est nécessaire, Pound est utilisé comme "load balancer".
  • Thin est exécuté avec la commande :
thin -s 4 -e production -p 8080 start

ou, quand on le lance avec un seu

thin -e production -p 8080 start
  • Unicorn est exécuté par la commande :
unicorn -c config/unicorn.rb -E production -p 8080

avec pour contenu du fichier de config unicorn.rb une configuration très proche de celle proposée par Github sur cet article :

rails_root = "/path/to/app"
rails_env = ENV['RAILS_ENV'] || 'production'

# 4 workers, may be changed to 1 for the tests
worker_processes (rails_env == 'production' ? 4 : 1) 

# Load rails+app into the master before forking workers
# for super-fast worker spawn times
preload_app true

# Restart any workers that haven't responded in 30 seconds
timeout 30

before_fork do |server, worker|
  ##
  # When sent a USR2, Unicorn will suffix its pidfile with .oldbin and
  # immediately start loading up a new version of itself (loaded with a new
  # version of our app). When this new Unicorn is completely loaded
  # it will begin spawning workers. The first worker spawned will check to
  # see if an .oldbin pidfile exists. If so, this means we've just booted up
  # a new Unicorn and need to tell the old one that it can now die. To do so
  # we send it a QUIT.
  # Using this method we get 0 downtime deploys.

  old_pid = rails_root + '/tmp/pids/unicorn.pid.oldbin'
  if File.exists?(old_pid) && server.pid != old_pid
    begin
      Process.kill("QUIT", File.read(old_pid).to_i)
    rescue Errno::ENOENT, Errno::ESRCH
      # someone else did our job for us
    end
  end
end

after_fork do |server, worker|
  # Unicorn master loads the app then forks off workers - because of the way
  # Unix forking works, we need to make sure we aren't using any of the parent's
  # sockets, e.g. db connection
  ActiveRecord::Base.establish_connection
end

Les résultats

Match 1 : Unicorn vs Thin, avec un seul processus pour chacun

Quand un seul processus (un seul "worker" dans le vocable propre à Unicorn) est activé, l'arrivée ininterrompue de nouveaux utilisateurs charge considérablement l'application...

Voici les résultats du "stress test" : benchmark_1process.png

On observe que :

  • Thin parvient à servir tout le monde, mais au prix de temps de réponse assez longs
  • Les temps de réponse sont un petit plus courts avec Unicorn mais en revanche Unicorn échoue à servir un certain nombre d'utilisateurs - le résultat du second graphe montre que l'unique worker d'Unicorn arrête de répondre aux requêtes à plusieurs reprises et pendant des durées assez longues... je n'explique pas bien ce comportement... J'ai pensé à l'impact du paramètre 'timeout 30' mais je n'ai pas observé de différence lorsque j'ai porté la valeur du timeout à 300 (j'ai même observé une amplification du phénomène)...
  • Thin semble donc vainqueur sur ce match avec un seul processus... mais voyons vite avec plusieurs processus ce qui sera plus proche d'un déploiement réel !

Match 2 : Unicorn vs Thin, avec 4 processus pour chacun

Nous démarrons cette fois Unicorn avec 4 "workers" et 4 instances de Thin. Afin d'accéder de manière aléatoire aux différentes instances de Thin, nous utilisons cette fois Pound comme "load balancer". Pound a également été utilisé avec Unicorn pour rendre les conditions des tests équivalentes. Pound n'est toutefois "en théorie" pas nécessaire pour Unicorn qui répartit automatiquement les requêtes sur les différents "workers".

Place aux résultats : benchmark-4processus.png

On constate que :

  • Thin et Unicorn parviennent à servir tous les utilisateurs plus efficacement (normal, la multiplication des processus profite des plusieurs coeurs de la machine hôte)
  • Mais Unicorn est plus véloce que Thin : 97% des requêtes sont servies en moins de 800ms par Unicorn alors que seules 63% sont servies dans le même délai par Unicorn
  • On remarque également que les 4 processus d'Unicorn jugulent le flux des nouveaux utilisateurs avec au pic 24 utilisateurs en attente là où Thin faiblit peu à peu jusqu'à atteindre un pic à plus de 50 utilisateurs en attente dans l'application (pour rappel, dans le scénario testé, 200 utilisateurs se connectent régulièrement au cours des 120 secondes à l'application et exécutent 8 requêtes)
  • C'est donc une victoire d'Unicorn qui se tire mieux cette fois de la situation !

Conclusion

Je suis tenté de préférer Unicorn pour servir un site Ruby on Rails chargé. Cependant, il conviendra d'offrir suffisamment de workers à Unicorn pour qu'il puisse faire le travail correctement !

Quelques enseignements annexes :

L'impact de Pound sur les temps de réponse

Sans Pound, le temps de réponse moyen de Unicorn est de 117 ms. Avec Pound, toute autre chose étant égale par ailleurs, le temps de réponse moyen est de 161 ms. Il semble donc que le passage par Pound augmente le temps moyen de réponse d'environ 40 ms.

La multiplication des workers pour Unicorn : bonne ou mauvaise idée ?

La machine de test dispose de 4 coeurs. J'ai testé 4 workers et 16 workers avec Unicorn. Les résultats sont très similaires : respectivement 131 et 130 secondes pour effectuer le scénario de test complet. Les temps de réponse moyens sont là encore très proches : 113 ms vs 117 ms. Je ne pense pas que les différences constatées soient significatives. Les écart-types sur le temps de réponse diffèrent plus : 216 ms avec 4 workers et 265 ms pour 16 workers... Compte-tenu du nombre de coeurs sur la machine de test (4), l'utilisation de 16 workers ne semble pas permettre des gains substantiels. Un petit excès du nombre de workers par rapport au nombre de coeurs est peut-être à conseiller toutefois.