Improving the performance of a Heroku app with Passenger!

Reading Time: 5 minutes

A few days ago, while having nothing to do, I decided to pick one of the applications I have worked on lately and improve its performance.

The app is hosted on a development instance in Heroku, and my intention was to tune up my app without having to pay for more dynos.

So what options do we have to add performance to a Heroku app without spending a single buck?

The first thing that came to my mind was to decrease the load of my dyno by moving all my assets to another place. There are options like Cloudfront or Cloudfare, but I really wanted to find a free way to achieve this, so those options had to be discarded.

Recently I worked on a project in which all of our static assets were being served by the web server (Nginx), and not by the web app, and we got excellent results in performance. I wanted to do something like that but with my app running on Heroku.

Looking for a way to run Nginx on Heroku I found this repo, explaining how to run a Ruby app on Heroku with Phusion Passenger.

So, I thought I would use Passenger for this experiment, and take advantage of its integration with Nginx.

If you are like me, you were deploying with Passenger when you started doing Rails. But now, with options like Heroku, you moved from Passenger to app servers like Thin, Unicorn or Puma, so why is Passenger useful again?

Well, this is not the Passenger we used to install as a module for Apache or Nginx, this is a stand-alone version of Passenger, and it has a Nginx web server built in. It brings all the power of Nginx to the single available dyno on my app and it works like Unicorn, Thin or Puma.

So here is when we can use Passenger's super-fast assets serving feature through Nginx. Having this, the Rails app won't serve assets anymore and it will leave that responsibility to Nginx, which will do a better job with our assets.

Installing Phussion Passenger Standalone

  1. Remove any app server gem from your Gemfile (Thin, Unicorn or Puma).
  2. Add the passenger gem.

    gem 'passenger'
    
  3. Change your Procfile to use Passenger.

    web: bundle exec passenger start -p $PORT
    
  4. bundle install, commit and push to Heroku.

Once the app is successfully deployed it will be running on Passenger. Now open your app in the browser, inspect the network traffic of your app and choose one of your assets, now look for its response headers. Notice that our assets are being served by Nginx and not the Rails app.

image alt

The second thing you should look at is the Cache-Control headers, now the browser knows that our assets can be cached and the next time we load the page they will be retrieved from the cache.

cache

And the third thing you'll see is the Content-Encoding header, our assets are being gzipped!

gzip

We got these three features without doing anything, Passenger is handling everything for us, isn't that cool?

Improvising a CDN

Now we have our assets correctly served by Nginx, but they are making requests to our single dyno, and that has impact on the performance of our app, and remember that we wanted to decrease the load of our dyno.

To do that we'll improvise a CDN (Content Delivery Network). Having a CDN our assets will be downloaded from different CNAMES and we will take advantage of the limit of parallel HTTP connections in our browser, so our assets will be downloaded in parallel.

The cheapest way to create our CDN is creating clones of our app. with Heroku we can fork our app and create the instances that will fulfill the functions of the CDN.

Let's create our CDN servers.

    heroku fork -a our-app our-app-cdn0
    heroku fork -a our-app our-app-cdn1
    heroku fork -a our-app our-app-cdn2
    heroku fork -a our-app our-app-cdn3

Now add a git remote for every forked app

    git remote add cdn0 git@heroku.com:our-app-cdn0.git
    git remote add cdn1 git@heroku.com:our-app-cdn1.git
    git remote add cdn2 git@heroku.com:our-app-cdn2.git
    git remote add cdn3 git@heroku.com:our-app-cdn3.git

Now we have four servers that will be serving assets, and the main app which will be in charge of the rest.

We need to tell our Rails app that we are using a CDN. To do that we must add the following lines to our config/environments/production.rb file:

    config.action_controller.asset_host = Proc.new do |source|
      "//our-app-cdn#{Digest::MD5.hexdigest(source).to_i(16) % 4}.herokuapp.com"
    end

With that, Rails will choose randomly the server from where our assets will be downloaded, and we will get something like this in our network:

parallel

Our assets are being downloaded in parallel!

Deploying to multiple Heroku instances at the same time

We have our app running faster, but we have 5 Heroku instances, and whenever we change something deployment to the five apps will be annoying. Let's create a rake task to automate this process.

APPS = [
  { remote: 'heroku', name: 'our-app' },
  { remote: 'cdn0',   name: 'our-app-cdn0' },
  { remote: 'cdn1',   name: 'our-app-cdn1' },
  { remote: 'cdn2',   name: 'our-app-cdn2' },
  { remote: 'cdn3',   name: 'our-app-cdn3' },
]

desc "Deploys the full app."
multitask :deploy => APPS.map { |app| "deploy:#{app[:remote]}" }

namespace :deploy do
  APPS.each do |app|
    desc "Deploys to #{app[:remote]}"
    task app[:remote] => "deploy:#{app[:remote]}:push"

    namespace app[:remote] do
      task :push do
        puts "Pushing to #{app[:remote]}"
        puts `git push #{app[:remote]} master`
      end

      task :migrate do
        puts "Migrating #{app[:name]}"
        puts `heroku run rake db:migrate --app #{app[:name]}`
      end
    end
  end

  desc 'Run migrations on every server'
  multitask :migrate => APPS.map { |app| "deploy:#{app[:remote]}:migrate" }
end

Notice that I'm using multitask (not simply task) to run all my prerequisites in parallel!

With this rake script we'll have the following rake tasks available:

    rake deploy
    rake deploy:migrate
    rake deploy:heroku:push
    rake deploy:cdn0:push
    rake deploy:cdn1:push
    rake deploy:cdn2:push
    rake deploy:cdn3:push
    rake deploy:heroku:migrate
    rake deploy:cdn0:migrate
    rake deploy:cdn1:migrate
    rake deploy:cdn2:migrate
    rake deploy:cdn3:migrate

We have 2 global tasks, deploy and deploy:migrate, these tasks will work with all our Heroku instances, and we also have individual tasks for push and migrate for every instance.

I hope you find this useful. I made this as an experiment and I got good results. In this post we achieved a decrease in the load of our app and we got a faster page-loading time. I know I could probably get better results using specialized CDN services with low latency and high data transfer speeds, but this works well enough and it is free.

Thanks for reading!

0 Shares:
You May Also Like