Back-End

21 jul, 2016

Um pequeno experimento com Rails sobre o JRuby 9.x no Heroku

Publicidade

Depois de brincar com linguagens diferentes do Ruby (como Elixir ou Crystal), achei que era hora de voltar para Ruby e ver como está a performance do Ruby atualmente.

JRuby sempre perseguiu a meta de ser um substituto competente para MRI. Se você não acompanhou o que está acontecendo, Ruby mudou os esquemas de versão, quando eles mudaram da série 1.7 para a série 9.x.

Em resumo, se você quer compatibilidade com MRI 2.2, deve usar JRuby 9.0.x, e se você quiser compatibilidade  com MRI 2.3, deve usar a série 9.1.x do JRuby. A versão mais atual é a 9.1.2.0. Qualquer coisa antes disso, você pode se referir a essa tabela de versões da documentação do Heroku.

Existem outras recomendações importantes também:

  • Idealmente, você deve usar Rails 4.2. Tente estar, pelo menos, acima de 4.0, e você pode acionar o config.threadsafe! por padrão no arquivo “config/environments/production.rb”. Para entender sobre esse assunto, consulte a excelente explicação do Tenderlove.
  • Se você estiver fazendo o deploy no Heroku, não se incomode tentando usar o dyno gratuito ou 1X, que só lhe dá 512MB de RAM. Embora seja o suficiente para a maioria das aplicações Rails pequenas (mesmo com 2 ou 3 Puma trabalhando simultaneamente), descobri que mesmo os menores aplicativos podem facilmente ir acima disso. Então, você deve considerar pelo menos o dyno 2X. Em qualquer configuração do servidor, sempre considere mais de 1GB de RAM.

Gems

Existem várias gems com extensões C que simplesmente não funcionam. Algumas delas têm substituições drop-in, outras não. Você deve consultar a Wiki delas para uma lista de casos.

Na minha pequena aplicação de amostra – que nada mais é do que um site de conteúdo suportado por um banco de dados PostgreSQL, ActiveAdmin para gerenciar conteúdo, RMagick + Paperclip (sim, é um aplicativo antigo) para lidar com upload e redimensionamento de imagem, não havia muito para mudar. Os bits importantes da minha “Gemfile” acabam ficando assim:

source 'https://rubygems.org'

ruby '2.3.0', :engine => 'jruby', :engine_version => '9.1.1.0'
# ruby '2.3.1'

gem 'rails', '~> 4.2.6'

gem 'devise'
gem 'haml'
gem 'puma'
gem 'rack-attack'
gem 'rack-timeout'
gem 'rakismet'

# Database
gem 'pg', platforms: :ruby
gem 'activerecord-jdbcpostgresql-adapter', platforms: :jruby

# Cache
gem 'dalli'
gem "actionpack-action_caching", github: "rails/actionpack-action_caching"

# Admin
gem 'activeadmin', github: 'activeadmin'
gem 'active_skin'

# Assets
gem 'therubyracer', platforms: :ruby
gem 'therubyrhino', platforms: :jruby

gem 'asset_sync'
gem 'jquery-ui-rails'
gem 'sass-rails'
gem 'uglifier',     '>= 1.3.0'
gem 'coffee-rails', '~> 4.0.0'
gem 'compass-rails'
gem 'sprockets', '~>2.11.0'

# Image Processing
gem 'paperclip'
gem 'fog'
gem 'rmagick', platforms: :ruby
gem 'rmagick4j', platforms: :jruby

group :test do
  gem 'shoulda-matchers', require: false
end

group :test, :development do
  gem "sqlite3", platforms: :ruby
  gem "activerecord-jdbcsqlite3-adapter", platforms: :jruby

  # Pretty printed test output
  gem 'turn', require: false
  gem 'jasmine'

  gem 'pry-rails'

  gem 'rspec'
  gem 'rspec-rails'
  gem 'capybara'
  gem 'poltergeist'
  gem 'database_cleaner', '< 1.1.0'

  gem 'letter_opener'
  gem 'dotenv-rails'
end

group :production do
  gem 'rails_12factor'
  gem 'rack-cache', require: 'rack/cache'
  gem 'memcachier'
  gem 'newrelic_rpm'
end

Veja como eu emparelhei as gems para as plataformas :rubi e :jruby. Depois de fazer essa mudança e empacotar e instalar tudo, eu executei minha suíte Rspec e – com sorte – todos eles passaram de primeira, sem quaisquer alterações! Sua milhagem pode variar, dependendo da complexidade da sua aplicação, portanto, tenha seus testes prontos.

Puma

No caso do Puma, a configuração é um pouco mais complicada, a minha tem esta aparência:

web_concurrency = Integer(ENV['WEB_CONCURRENCY'] || 1)
if web_concurrency > 1
  workers web_concurrency
  preload_app!
end

threads_count = Integer(ENV['RAILS_MAX_THREADS'] || 5)
threads threads_count, threads_count

rackup      DefaultRackup
port        ENV['PORT']     || 3000
environment ENV['RACK_ENV'] || 'development'

on_worker_boot do
  # Worker specific setup for Rails 4.1+
  # See: https://devcenter.heroku.com/articles/deploying-rails-applications-with-the-puma-web-server#on-worker-boot
  ActiveRecord::Base.establish_connection
end

É um pouco diferente do que você pode encontrar em outras documentações. A parte importante é desligar os workers e o preload_app ao carregar no JRuby. Ele vai reclamar e quebrar. No meu deploy MRI original, eu estou usando uma pequena dyno 1X e eu pude deixar WEB_CONCURRENCY = 2 e RAILS_MAX_THREADS = 5, mas no deploy do Ruby eu configurei isso para WEB_CONCURRENCY = 1 (para desligar os workers) e RAILS_MAX_THREADS = 16 (porque eu estou assumindo que o JRuby pode lidar com mais multithreading que o MRI).

Outro ponto importante: a maioria das pessoas ainda assume que a MRI não pode tirar vantagem de threads paralelas nativas em tudo por causa do GIL (Global Interpreter Lock), mas isso não é inteiramente verdade. MRI Ruby pode paralelizar threads em esperas de I/O. Assim, se uma parte do seu aplicativo está à espera do banco de dados para processar e retornar linhas, por exemplo, outro segmento pode assumir e fazer outra coisa, em paralelo. Não é totalmente multi-threaded, mas pode fazer alguma simultaneidade, então definir uma pequena quantidade de threads para o Puma pode ajudar um pouco.

Não se esqueça de definir o tamanho do pool, pelo menos para o mesmo número do RAILS_MAX_THREADS. Você pode usar o config/database.yml para Rails 4.1+ ou adicionar um inicializador. Siga a documentação do Heroku para saber como fazê-lo.

Benchmarks iniciais

Então, eu fui capaz de fazer o deploy com sucesso de uma versão JRuby secundária de meu app original Rails baseado no MRI.

O aplicativo original ainda está em um Hobby Dyno, dimensionado em 512 MB de RAM. O aplicativo secundário precisava de um Dyno 1X padrão, dimensionado em 1GB de RAM.

O aplicativo em si é super simples e como eu estou usando o cache – como você sempre deveria! -, o tempo de resposta é muito baixo, na casa das dezenas de milissegundos.

Eu tentei usar a ferramenta Boom para benchmark das requisições. Eu fiz um monte de aquecimento na versão JRuby, executando os benchmarks várias vezes e até mesmo usando o Loader.io para pressão adicional.

Estou executando este teste:

$ boom -n 200 -c 50 http://foo-my-site/

A versão MRI tem esta performance:

Summary:
  Total:    16.4254 secs
  Slowest:  9.0785 secs
  Fastest:  0.8362 secs
  Average:  2.6551 secs
  Requests/sec: 12.1763
  Total data:   28837306 bytes
  Size/request: 144186 bytes

Status code distribution:
  [200] 200 responses

Response time histogram:
  0.836 [1] |
  1.660 [57]    |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎
  2.485 [57]    |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎
  3.309 [33]    |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎
  4.133 [22]    |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎
  4.957 [16]    |∎∎∎∎∎∎∎∎∎∎∎
  5.782 [6] |∎∎∎∎
  6.606 [3] |∎∎
  7.430 [1] |
  8.254 [3] |∎∎
  9.079 [1] |

Latency distribution:
  10% in 1.2391 secs
  25% in 1.5910 secs
  50% in 2.1974 secs
  75% in 3.4327 secs
  90% in 4.5580 secs
  95% in 5.6727 secs
  99% in 8.1567 secs

E a versão Ruby tem esta performance:

Summary:
  Total:    15.5784 secs
  Slowest:  7.4106 secs
  Fastest:  0.5770 secs
  Average:  2.3224 secs
  Requests/sec: 12.8383
  Total data:   28848475 bytes
  Size/request: 144242 bytes

Status code distribution:
  [200] 200 responses

Response time histogram:
  0.577 [1] |
  1.260 [23]    |∎∎∎∎∎∎∎∎∎∎∎∎∎∎
  1.944 [62]    |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎
  2.627 [51]    |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎
  3.310 [24]    |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎
  3.994 [26]    |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎
  4.677 [8] |∎∎∎∎∎
  5.361 [1] |
  6.044 [0] |
  6.727 [1] |
  7.411 [3] |∎

Latency distribution:
  10% in 1.1599 secs
  25% in 1.5154 secs
  50% in 2.0781 secs
  75% in 2.8909 secs
  90% in 3.7409 secs
  95% in 4.2556 secs
  99% in 6.7685 secs

Em geral, eu diria que eles são quase os mesmos. Como esse não é um processo particularmente intenso da CPU, e a maior parte do tempo seja gasto no Rails e batendo no Memcachier para trazer o mesmo conteúdo o tempo todo, talvez seja justo esperar resultados semelhantes.

Por outro lado, eu não tenho certeza de que estou usando a ferramenta da melhor maneira possível. O log diz algo como isto aqui para cada pedido:

source=rack-timeout id=c8ad5f0c-b5c1-47ec-b88b-3fc597ab01dc wait=29ms timeout=20000ms state=ready
Started GET "/" for XXX.35.10.XXX at 2016-06-03 18:54:34 +0000
Processing by HomeController#home as HTML
Read fragment views/radiant-XXXX-XXXXX.herokuapp.com/en (6.0ms)
Completed 200 OK in 8ms (ActiveRecord: 0.0ms)
source=rack-timeout id=c8ad5f0c-b5c1-47ec-b88b-3fc597ab01dc wait=29ms timeout=20000ms service=19ms state=completed
source=rack-timeout id=a5389dc4-9a1a-46b7-a1e5-53f334ca0941 wait=35ms timeout=20000ms state=ready

Started GET "/" for XXX.35.10.XXX at 2016-06-03 18:54:36 +0000
Processing by HomeController#home as HTML
Read fragment views/radiant-XXXX-XXXXX.herokuapp.com/en (6.0ms)
Completed 200 OK in 9ms (ActiveRecord: 0.0ms)
source=rack-timeout id=a5389dc4-9a1a-46b7-a1e5-53f334ca0941 wait=35ms timeout=20000ms service=21ms state=completed
at=info method=GET path="/" host=radiant-XXXX-XXXXX.herokuapp.com request_id=a5389dc4-9a1a-46b7-a1e5-53f334ca0941 fwd="XXX.35.10.XXX" dyno=web.1 connect=1ms service=38ms status=200 bytes=144608

Os tempos relatados pela ferramenta Boom são muito maiores (2 segundos?) do que os tempos de processamento nos logs (10ms?). Mesmo considerando alguma sobrecarga para o roteador e assim por diante, ainda é uma grande diferença, gostaria de saber se ele está sendo colocado na fila por muito tempo, porque o aplicativo não está sendo capaz de responder mais das solicitações simultâneas.

A quantidade de pedidos, dividida pelo número de conexões simultâneas, trará o desempenho global e a taxa de transferência para baixo se você aumentar demais, e eu não fui capaz de ir muito acima de 14 rpm com essa configuração, apesar de tudo.

Se você tem mais experiência com o benchmarking http e percebeu que eu estou fazendo algo errado aqui, por favor, deixe-me saber na seção de comentários abaixo.

Conclusão

O JRuby continua a evoluir, e você pode se beneficiar se você já tem um grande conjunto de Dynos e grandes servidores ao seu redor. Eu não o recomendo para pequenas e médias aplicações.

Eu vi muitas ordens de magnitude de melhoria em casos de uso específicos (creio que foi um ponto de extremidade de API de muito alto tráfego). Esse caso em particular que eu testei não é provavelmente o seu ponto forte e mudando de MRI para o JRuby não me deu muita vantagem, portanto, nesse caso, eu recomendo ficar com o MRI.

Tempo de inicialização ainda é um problema. Há uma entrada na Wiki dando algumas recomendações, mas mesmo no deploy do Heroku acabei tendo erros R10 (Boot Timeout) de vez em quando para esse pequeno aplicativo.

Eu não tentei aumentar os dynos para a camada de desempenho introduzida no ano passado. Eu apostaria que JRuby seria melhor para isso e mais capaz de alavancar o poder extra de ter de 2.5GB até 14GB se você tem realmente um grande tráfego (da ordem de milhares ou dezenas de milhares de pedidos por minuto). Com MRI, a recomendação seria usar dynos pequenos (2X ou no máximo de desempenho-M dynos) e escalar horizontalmente. Com JRuby, você poderia ter menos dynos com tamanhos maiores (Desempenho-L, por exemplo). Mais uma vez, depende do seu caso.

Não tome os benchmarks acima como “fatos” para generalizar em todos os lugares, eles estão lá apenas para lhe dar uma noção de um caso de uso específico meu. Sua milhagem pode variar, por isso você deve testá-lo sozinho.

Outro caso de uso (que eu não testei) não é apenas “portar” de um aplicativo MRI para JRuby, mas alavancar os pontos fortes exclusivos do JRuby como este texto do Heroku explica, no caso de usar JRuby com Ratpack, por exemplo.

No geral, o JRuby ainda é um grande projeto para experimentar. O MRI também veio de um longo caminho e o 2.3.1 não é tão mal. Na maioria das vezes, desce até as suas escolhas de arquitetura, não apenas a linguagem. Se você não tentou ainda, você definitivamente deveria. “Simplesmente funciona”.

***

Artigo traduzido com autorização do autor. Publicado originalmente em  http://www.akitaonrails.com/2016/06/03/small-experiment-with-rails-over-jruby-9-x-on-heroku