Usando Nginx e Unicorn no Heroku

Publicado por Paulo Casaretto no dia dev

Ao investigar problemas de performance em uma requisição específica do RD Station, notei que em determinadas vezes muito tempo era gasto em um middleware chamando Rack::MethodOverride.call .

O MethodOverride procura no corpo de um POST por um parâmetro _method que muda o tipo da requisição. Felizmente este é um daqueles problemas que alguém já teve antes e encontrei um bom ponto de partida no Stack Overflow.

http://stackoverflow.com/questions/24639701/random-slow-rackmethodoverridecall-on-rails-app-on-heroku

Unicórnios ansiosos

Nosso servidor é o Unicorn que aparentemente não lida nada bem com IO. A documentação diz:

if you’re handling large requests (uploads), worker processes will also be bottlenecked by the speed of the client connection

Faz sentido, a requisição em questão é um POST que tem um corpo bem grande (~100Kb). O autor da segunda resposta no SO diz que resolveu o problema passando a usar Passenger ao invés de Unicorn. Mudar de servidor me parecia um pouco radical e um tanto arriscado. A resposta também fazia menção ao Nginx:

The included Nginx buffers requests and responses, thus protecting your app against slow clients (e.g. mobile devices on mobile networks) and improving performance.

Promissor! Ao entrar em contato com o suporte do Heroku sobre o problema descobri que existe um Buildpack que servia como uma luva.

https://github.com/ryandotsmith/nginx-buildpack

Usando o Buildpack

O Buildpack é muito bem escrito. É agnostico em relação a tecnologia, tem logs amigáveis e permite a configuração do Nginx. Os requisitos são:

  • O servidor deve esperar requisições em um socket em /tmp/nginx.socket
  • Deve-se fazer um touch no arquivo /tmp/app-initialize para avisar que aplicação está pronta para servir
  • O servidor deve poder ser iniciado via um comando shell

Configurando o Buildpack multiple

O Buildpack do Nginx não oferece nada além dele, então é necessário usar o Buildpack multiple.

As instruções no README no projeto estão bem completas, mas tive um problema ao testar. Ao fazer o boot, o Heroku não conseguia encontrar o executável do Nginx apesar de a instalação ser bem sucedida de acordo com os logs.

Vale dizer que tive problemas ao seguir as instruções no README no projeto. No final das contas deixar o buildpack do Nginx por último funcionou. O nosso arquivo .buildpack está assim:

1
2
https://codon-buildpacks.s3.amazonaws.com/buildpacks/heroku/ruby.tgz
https://github.com/ryandotsmith/nginx-buildpack.git

Reconciliando com ambiente local

Após testar rapidamente no nosso ambiente de staging e verificar que tudo estava funcionando um novo problema surgiu. As configurações do ambiente de produção ficariam muito diferentes das configurações locais.

Script de inicialização

Localmente, usamos o Boxen, então o nginx esta daemonizado. Em produção o Nginx é iniciado com bin/start-nginx

O comando para subir o server agora deveria ser

1
bin/start-nginx bundle exec unicorn -c ./config/unicorn.rb

Esse foi fácil, simplesmente criei um executável bin/start-nginx cujo conteúdo é:

1
2
#!/bin/bash
eval $@

Configuração do unicorn

Esse foi um pouco mais problemático. O arquivo config/unicorn.rb já tinha alguns if Rails.env == para coisas como número de workers e agora precisávamos escutar em sockets diferentes dependendo do ambiente.

A solução foi extrair as partes dependentes para arquivos separados e carrega-los dinâmicamente.

1
2
3
4
5
6
7
8
9
10
def custom_load(file)
  eval File.read(file)
  true
end

custom_load File.expand_path("../unicorn/common.rb", __FILE__)
begin
  custom_load File.expand_path("../unicorn/#{ENV['RACK_ENV']}.rb", __FILE__)
rescue LoadError
end

O custom_load aqui foi necessário para poder avaliar o código no contexto atual.

O arquivo config/unicorn/common.rb não surpreendemente as coisas comuns a todos os ambientes como a instrução preload_app true. No arquivo config/unicorn/production.rb temos instruções como:

1
listen '/tmp/nginx.socket'

Callbacks por enviroment

Um problema um pouco mais complicado foi acertar os callbacks. Da mesma maneira que as configurações, certos callbacks deveria rodar somente em determinados ambientes. O mecanismo de callback padrão do Unicorn não aceita vários callbacks então a solução foi extende-lo.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# config/unicorn
@before_callbacks = []
@after_callbacks = []

def custom_before_fork(&block)
  @before_callbacks << block
end

def custom_after_fork(&block)
  @after_callbacks << block
end

before_fork do |server, worker|
  @before_callbacks.each do |proc|
    proc.call(server, worker)
  end
end

after_fork do |server, worker|
  @after_callbacks.each do |proc|
    proc.call(server, worker)
  end
end

Isso nos permitiu manter o comportamento comum de restabelecer as conexões pós fork no common.rb e incluir o código que faz o touch necessário no production.rb.

1
2
3
custom_before_fork do |server,worker|
  FileUtils.touch('/tmp/app-initialized')
end

Resultados

O Nginx se comportou muito bem. Desde a implementação não vimos mais ocorrências do problema descrito no começo do post. Além disso, nos permitiu recentemente configurar o nginx para servir diretamente do sistema de arquivos qualquer coisa que estiver na pasta public (como o nosso favicon) e assim pular todo o overhead do Ruby.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
server {
  listen <%= ENV["PORT"] %>;
  server_name _;
  keepalive_timeout 5;
  root /app/public;

  location / {
    try_files $uri @ruby;
  }

  location @ruby {
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Host $http_host;
    proxy_redirect off;
    proxy_pass http://app_server;
  }
}

Paulo Casaretto

Paulo Casaretto

Full Stack Developer

Comentários