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
"aws" {
provider = "eu-west-1"
region
}
"aws_ami" "ubuntu" {
data = true
most_recent
filter {= "name"
name = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
values
}
= ["099720109477"]
owners
}
"base_config" {
data template_file = file("${path.module}/templates/base_config.yml")
template
= {
vars = "dev"
env_name
}
}
"users" {
data template_file = file("${path.module}/templates/users.yml")
template
= {
vars = "research"
username
}
}
"rvm" {
data template_file = file("${path.module}/templates/rvm.sh")
template
}
"ruby" {
data template_file = file("${path.module}/templates/ruby.sh")
template
= {
vars = "research"
username = "3.0.3"
ruby_version
}
}
"setup_app" {
data template_file = file("${path.module}/templates/setup_app.sh")
template
= {
vars = "3.0.3"
ruby_version = "research"
username = "https://github.com/chrismcg/hello_world.git"
app_repo = "/home/research/hello_world"
app_path
}
}
"finished" {
data template_file = file("${path.module}/templates/finished.sh")
template
= {
vars = "dev"
env_name
}
}
"cloudinit_config" "user_data" {
data = false
gzip = false
base64_encode
part {= "001_base_config.cfg"
filename = "text/cloud-config"
content_type = data.template_file.base_config.rendered
content
}
part {= "002_rvm.sh"
filename = "text/x-shellscript"
content_type = data.template_file.rvm.rendered
content
}
part {= "003_users.cfg"
filename = "text/cloud-config"
content_type = data.template_file.users.rendered
content
}
part {= "004_ruby.sh"
filename = "text/x-shellscript"
content_type = data.template_file.ruby.rendered
content
}
part {= "005_setup_app.sh"
filename = "text/x-shellscript"
content_type = data.template_file.setup_app.rendered
content
}
part {= "006_finished.sh"
filename = "text/x-shellscript"
content_type = data.template_file.finished.rendered
content
}
}
"aws_instance" "helloworld" {
resource = data.aws_ami.ubuntu.id
ami = "t2.micro"
instance_type
= data.cloudinit_config.user_data.rendered
user_data
= {
tags = "ResearchRailsApp"
Name
} }
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.