Michael Trojanek (relativkreativ) — Bootstrapper and creator of things

This article was published on April 26th 2017 and received its last update on January 22nd 2019. It takes about 7 minutes to read.

It has also been published on Medium. If you like it, please give it some 👏🏻!

How to cut your Rails server's provisioning time by more than 60%

One of the longest running tasks when provisioning a Rails server is compiling Ruby from source. Preparing prebuilt Rubies for rbenv can reduce your server's provisioning time by a large amount.

Provisioning a server is usually not about raw speed — it's way better to carefully build your machines than to rush and leave your production boxes in an unknown state.

However, when provisioning a server to serve your Rails application, the longest running task is probably compiling Ruby from source (this takes about 5 minutes on my machine and while it happens quicker on a powerful server, it will still make you wait for some time).

Virtual servers are cheap in every way so I end up running my provisioning playbook more often than you might think. As an example, I do not bother with downtime on my production servers due to a kernel upgrade — using DigitalOcean's awesome floating IP feature, I provision a new machine, switch my website's IP to the new server and throw away the old one. When provisioning often you quickly realize that compiling Ruby from source everytime adds little to no value to your workflow.

While currently there is no official support for prebuilt Rubies (we are talking about rbenv — you usually do not want to use your operating system's package manager to install Ruby) it is actually quite easy to package an existing Ruby installation and reuse it on another machine — under certain circumstances.

Assumptions

This article assumes that you manage your server's Ruby installations with rbenv and that your rbenv installation is already working — I want to keep things concise.

The approach described here may very well work with RVM too but my experiences with RVM date too far back (RVM also features a similar functionality builtin as I understand it).

We will be using Ansible for the example tasks but it should not be too hard to adapt to other configuration management systems. However, if you are provisioning your Rails servers with shell scripts or even manually, you really should switch to a more professional approach.

If you don't know how to do that, I may shamelessly redirect you to Efficient Rails DevOps, a series of products which will teach you everything you need to know to follow this article.

Compiling Ruby from source

This is how we usually install a Ruby version from within an Ansible role once rbenv and ruby-build are setup properly:

- name: Install dependencies for compiling Ruby
  yum: pkg={{ item }}
       state=installed
  with_items:
    - bzip2
    - gcc
    - libffi-devel
    - libyaml-devel
    - make
    - openssl-devel
    - readline-devel
    - zlib-devel

- name: Install Ruby {{ version }} for {{ user }}
  command: ~{{ user }}/.rbenv/bin/rbenv install {{ version }}
           creates=~{{ user }}/.rbenv/versions/{{ version }}
  become: yes
  become_user: "{{ user }}"

We first install developer tools and libraries to compile Ruby from source. Then we run rbenv's install command which uses ruby-build as a plugin to download, compile and install the desired Ruby version.

When we save the above role as install_ruby, we can include it in a playbook like this:

- name: Provision Rails server
  hosts: rails_hosts
  remote_user: root
  roles:
    - ...
    - { role: install_ruby, user: app, version: 2.4.1 }

Package a Ruby installation

When you are using rbenv, you probably know that each Ruby installation has its own directory under ~/.rbenv/versions. So it's easy to step into this directory and create an archive of the installation. Assuming that we already applied the above role and installed Ruby 2.4.1, we can log in to our server and run the following commands to do just that:

cd /home/app/.rbenv/versions
tar czf 2.4.1.tar.gz 2.4.1

Now we can copy the resulting 2.4.1.tar.gz file into the files directory of the install_ruby role on our local machine to make it available to Ansible's unarchive module (which we will use in a minute).

Use the prebuilt Ruby

For future provisionings, we check if our role's files directory holds a compressed archive of the Ruby version we want to install. We register this check's result in the variable prebuilt_ruby (note that we use local_action because the archive resides on the machine running the playbook and not on the machine we configure):

- name: Check for prebuilt Ruby {{ version }}
  local_action: stat
                path="{{ role_path }}/files/{{ version }}.tar.gz"
  changed_when: false
  failed_when: false
  register: prebuilt_ruby

When the check's result is negative (which means that there is no prebuilt Ruby for the version we specified), we install Ruby like before (note the when condition):

- name: Install dependencies for compiling Ruby
    yum: pkg={{ item }}
         state=installed
    with_items:
      - bzip2
      - gcc
      - libffi-devel
      - libyaml-devel
      - make
      - openssl-devel
      - readline-devel
      - zlib-devel

- name: Install Ruby {{ version }} for {{ user }}
  command: ~{{ user }}/.rbenv/bin/rbenv install {{ version }}
           creates=~{{ user }}/.rbenv/versions/{{ version }}
  become: yes
  become_user: "{{ user }}"
  when: prebuilt_ruby.stat.exists == false

In case you are wondering why we install a compiler and other dependencies nonetheless: These will later be needed to compile some gems' binary extensions (it is possible to prebuild these too but this is beyond the scope of this article).

When the check's result is positive (and the archived Ruby installation exists), we run the following tasks (which we can wrap inside a block to spare us from having to evaulate the when condition for every task):

- block:

  - name: Create directory for Ruby {{ version }}
    file: path=~{{ user }}/.rbenv/versions/{{ version }}
          state=directory

  - name: Copy prebuilt Ruby {{ version }}
    unarchive: src="{{ version }}.tar.gz"
               dest=~{{ user }}/.rbenv/versions
               owner={{ user }}
               group={{ user }}

  - name: Rehash
    command: ~{{ user }}/.rbenv/bin/rbenv rehash
    become: yes
    become_user: "{{ user }}"

  when: prebuilt_ruby.stat.exists

These tasks create the containing directory for our specified Ruby version, unpack the contents of our prebuilt package and run rbenv's rehash command (which connects all binaries to rbenv's shims).

Switching to this approach has enabled me to bring my Rails servers' provisioning time from 8 minutes down to about 3 minutes (including the initial deploy of my application).

The complete role

Summarizing all steps we did above, our finished role will look like this:

---

- name: Check for prebuilt Ruby {{ version }}
  local_action: stat
                path="{{ role_path }}/files/{{ version }}.tar.gz"
  changed_when: false
  failed_when: false
  register: prebuilt_ruby

- name: Install dependencies for compiling Ruby
  yum: pkg={{ item }}
       state=installed
  with_items:
    - bzip2
    - gcc
    - libffi-devel
    - libyaml-devel
    - make
    - openssl-devel
    - readline-devel
    - zlib-devel

- name: Install Ruby {{ version }} for {{ user }}
    command: ~{{ user }}/.rbenv/bin/rbenv install {{ version }}
             creates=~{{ user }}/.rbenv/versions/{{ version }}
    become: yes
    become_user: "{{ user }}"
    when: prebuilt_ruby.stat.exists == false

- block:

  - name: Create directory for Ruby {{ version }}
    file: path=~{{ user }}/.rbenv/versions/{{ version }}
          state=directory

  - name: Copy prebuilt Ruby {{ version }}
    unarchive: src="{{ version }}.tar.gz"
               dest=~{{ user }}/.rbenv/versions
               owner={{ user }}
               group={{ user }}

  - name: Rehash
    command: ~{{ user }}/.rbenv/bin/rbenv rehash
    become: yes
    become_user: "{{ user }}"

  when: prebuilt_ruby.stat.exists

Caveats

Depending on your environment, this approach has its downsides:

  • Compiling Ruby (or really, anything) from source takes your server's operating system (including its version) and architecture into account. So when you are mixing different Linux flavors or machines in your environment, you probably need to prepare different prebuilt Rubies for each of your platforms.
  • A packaged installation of Ruby 2.4.1 has about 66MB. Though not elegant, it is acceptable for me to keep the archive in my playbook (since it is the only Ruby version I use at the moment) but if you are supporting multiple Ruby versions — maybe even for different architectures — your playbook's size will grow dramatically and you will have to think of ways to host your prebuilt Rubies.

Get in the loop

Join my email list to get new articles delivered straight to your inbox and discounts on my products.

No spam — guaranteed.

You can unsubscribe at any time.

Got it, thanks a lot!

Please check your emails for the confirmation request I just sent you. Once you clicked the link therein, you will no longer see these signup forms.