How to quickly spin up a Rails app on an EC2 instance

January 14th, 2022

I’m working with a company that has many Rails apps on a single large server. They want to move to a single app per server model to minimise the blast radius if something goes wrong. They don’t want to have a complicated AMI build pipeline and they’re not at the stage where containers and Kubernetes is something they would feel comfortable running.

So I decided to experiment and find the quickest way to start an EC2 instance and boot a Rails app.

Their Rails apps are separate installs of several of thier products. They configure these differently for each customer so it’s different to a SaaS company running a single multi-tenant app either as a monolith or a set of services.

They do use Terraform so that’s how I want to create and provision the server. I’m not worried about load balancers, databases, etc. at this stage. Once the app servers are booting quickly we can add the rest of the infrastructure.

Why not Heroku?

Heroku is an obvious choice for quick deployment of standard architecture Rails apps but for various business and contractual reasons it’s not a viable solution for my customer.

I’ve worked with several clients in similar situations so knowing how to quickly stand up a Rails app on EC2 is a useful tool to have in the toolbox.

The options for installing ruby

There are several Ruby version management tools so I experimented to see which could install a specific version of Ruby the fastest. Most Ruby version managers build from source which takes minutes per version in the best case. For my specific purposes I wanted see if there were any that could install a binary Ruby on Ubuntu 20.04.

chruby / ruby-install

If ruby-install didn’t build from source this would be the tool I’d prefer using. This is because chruby has less moving parts than the other version managers. It works by setting $PATH not by shims or shell integrations.

I couldn’t see a way to have ruby-install install a binary ruby, even one I’d built myself so for this task I had to rule it out.

rbenv / ruby-build

rbenv and ruby-build are similar to chruby and ruby-install. rbenv has more moving parts as it uses shims that need maintained. I’ve used it before with success but as ruby-build also always builds from source only it’s ruled out.

asdf / ruby-build

asdf is my normal tool for managing local development tool version as it has plugins for several different languages I use. It’s a handy one stop shop for local version management. Its Ruby plugin uses ruby-build so that rules it out for this experiment.

rvm

Eliminating the above options left rvm, the original Ruby version manager. I haven’t used this in a long time as I prefered how rbenv and asdf worked.

It turns out that rvm install has a --binary option that installs pre-built version of Ruby. This worked for every version of Ruby I needed so rvm is the tool I’ll use in the rest of this experiment.

Why not the system version of Ruby?

The system Ruby is usually old and requires sudo for installing gems to system locations. It doesn’t allow for different apps to use different version of Ruby and makes it more complicated when trying to do upgrades.

I also tried the brightbox PPA for Ubuntu but they don’t have every Ruby version available.

Bootstrapping RVM though user-data in Terraform

To avoid having to use another provisioning tool I’m going to use cloud-init via EC2 User Data to install rvm, Ruby, and the test Rails app. This is not the long, or even medium term best way but it avoids needed to use another tool to provision the new instance.

Terraform has a template_cloudinit_config provider that helps to keep the infrastructure code maintainable. The user data can be in multipart format to avoid a single massive file but you have to make sure and number the parts as cloud-init runs them in alphabetical order.

main.tf

provider "aws" {
  region = "eu-west-1"
}

data "aws_ami" "ubuntu" {
  most_recent = true

  filter {
    name = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
  }

  owners = ["099720109477"]
}

data template_file "base_config" {
  template = file("${path.module}/templates/base_config.yml")

  vars = {
   env_name = "dev"
  }
}

data template_file "users" {
  template = file("${path.module}/templates/users.yml")

  vars = {
    username = "research"
  }
}

data template_file "rvm" {
  template = file("${path.module}/templates/rvm.sh")
}

data template_file "ruby" {
  template = file("${path.module}/templates/ruby.sh")

  vars = {
    username = "research"
    ruby_version = "3.0.3"
  }
}

data template_file "setup_app" {
  template = file("${path.module}/templates/setup_app.sh")

  vars = {
    ruby_version = "3.0.3"
    username = "research"
    app_repo = "https://github.com/chrismcg/hello_world.git"
    app_path = "/home/research/hello_world"
  }
}

data template_file "finished" {
  template = file("${path.module}/templates/finished.sh")

  vars = {
   env_name = "dev"
  }
}

data "cloudinit_config" "user_data" {
  gzip = false
  base64_encode = false

  part {
    filename = "001_base_config.cfg"
    content_type = "text/cloud-config"
    content = data.template_file.base_config.rendered
  }

  part {
    filename = "002_rvm.sh"
    content_type = "text/x-shellscript"
    content = data.template_file.rvm.rendered
  }

  part {
    filename = "003_users.cfg"
    content_type = "text/cloud-config"
    content = data.template_file.users.rendered
  }

  part {
    filename = "004_ruby.sh"
    content_type = "text/x-shellscript"
    content = data.template_file.ruby.rendered
  }

  part {
    filename = "005_setup_app.sh"
    content_type = "text/x-shellscript"
    content = data.template_file.setup_app.rendered
  }

  part {
    filename = "006_finished.sh"
    content_type = "text/x-shellscript"
    content = data.template_file.finished.rendered
  }
}

resource "aws_instance" "helloworld" {
  ami = data.aws_ami.ubuntu.id
  instance_type = "t2.micro"

  user_data = data.cloudinit_config.user_data.rendered

  tags = {
    Name = "ResearchRailsApp"
  }
}

templates/base_config.yml

hostname: ${env_name}

templates/rvm.sh

#!/bin/bash

sudo apt-add-repository -y ppa:rael-gc/rvm
sudo apt update
sudo apt install -y rvm libmysqlclient-dev

templates/users.yml

users:
  - name: ${username}
    groups: rvm
    sudo: ALL=(ALL) NOPASSWD:ALL

templates/ruby.sh

#!/bin/bash

set -o errexit
set -o pipefail
set -x

sudo -i -u "${username}" /bin/bash -l rvm install --binary "${ruby_version}"

templates/setup_app.sh

#!/bin/bash

set -o errexit
set -o pipefail
set -x

cat <<-SYSTEMD_SERVICE > /etc/systemd/system/puma.service
[Unit]
Description=Puma HTTP Server
After=network.target

[Service]
Type=notify
WatchdogSec=10
User=${username}
WorkingDirectory=${app_path}
ExecStart=/usr/share/rvm/wrappers/ruby-${ruby_version}/puma -C ${app_path}/config/puma.rb
Restart=always

[Install]
WantedBy=multi-user.target
SYSTEMD_SERVICE

sudo -i -u "${username}" git clone ${app_repo} ${app_path}
sudo -i -u "${username}" /usr/share/rvm/wrappers/ruby-${ruby_version}/bundle install --gemfile=${app_path}/Gemfile --jobs=4

systemctl daemon-reload
systemctl enable puma.service
systemctl start puma.service

templates/finished.sh

#!/bin/bash

echo "Finished setting up ${env_name}"

Timing

A terraform apply to eu-west-1 (the closest AWS region to me) takes ~50s to finish.

Examining /var/log/cloud-init-output.log shows everything finishing just over two minutes from boot. As this includes pulling the source code and installing gems I’m very happy with this outcome! It’s a small Rails app with only the default gems but being able to boot in less than 5 minutes without having a lot of provisioning is a great result.

The test rails app and branches

I created a simple template Rails app with just a main branch for trying this out. The terraform apply will use the latest code on this branch each time it runs. If I end up taking this code further I’d use something like git-flow so each environment knows which branch to use to get the correct code when an instance starts.

Conclusion

While this approach isn’t one I’d reach for if I was starting from scratch it does allow you to spin up an EC2 instance with a Rails app very quickly. It doesn’t compile assets so if that is necessary it would increase the time to readiness. That said as a baseline for how quickly you can get an instance booted with an app it’s a good start.

In an ideal world I’d prefer not to have to rely on rvm binary installs. I would love to be able to use ruby-install to compile from source and then use that installation as a binary package for other instances with the same configuration but that’s an investigation for another day.