Deploying a Ruby Application with Ansible
Introduction
Nowadays, you can get your own dedicated server up and running in seconds. Once you get it up, do you really need to spend several hours on configuring it for your application's needs? Do you really want to repeat the same steps with each new server? In this article, I will give you an idea about automated installation with Ansible, a Simple IT Automation toolkit, and Ubuntu 14.04 LTS server as your box OS.
Background
You would need a basic understanding of Ansible file syntax. If you haven't played around with Ansible yet, I would recommend you review some intro articles like http://docs.ansible.com/ansible/intro.html or perhaps one of the SlideShares like http://www.slideshare.net/robertreiz/ansible-40167296?
Cooking
We would need to deploy the following components: Ruby, Web server with Passenger, and your application itself. For the purposes of this demo, we will install a well-known starter Devise https://github.com/RailsApps/rails-devise.git
Ruby switcher chruby
I used to choose RMV previously, but recently I am a fan of chruby - it is lightweight, quick to understand and just works - it's available at GitHub https://github.com/postmodern/chruby
Upon studying the installation instructions, we can automate manual installation steps with Ansible, and get a bonus: a reusable recipe to install ch_ruby
.
---
- name: Ruby | Check if chruby is present
shell: test -x /usr/local/bin/chruby-exec
when: ansible_system == "Linux"
ignore_errors: yes
register: chruby_present
tags: ruby
- name: Ruby | Download chruby distribution
get_url: url="http://github.com/postmodern/chruby/archive/v{{ chruby_version }}.tar.gz"
dest="/tmp/chruby-{{ chruby_version }}.tar.gz"
when: chruby_present|failed
tags: ruby
- name: Ruby | unpack chruby
command: tar xf "/tmp/chruby-{{ chruby_version }}.tar.gz"
chdir="/tmp"
when: chruby_present|failed
tags: ruby
- name: Ruby | chruby install target
command: make install
chdir="/tmp/chruby-{{ chruby_version }}"
become: yes
when: chruby_present|failed
tags: ruby
- name: Ruby | autoload script
template: src="{{role_dir}}/templates/ch_ruby.sh.j2" dest=/etc/profile.d/chruby.sh
become: yes
tags: ruby
Ruby install
Ruby-install is the second toolkit from the same author. At the present moment, this approach is the #1 choice for me when I need to install a specific Ruby version. This tool is available at GitHub under address https://github.com/postmodern/ruby-install
Once we study ruby-install
setup notes, we can automate these steps with a series of ansible steps:
---
- name: Ruby | Check if ruby install is present
shell: test -x /usr/local/bin/ruby-install
when: ansible_system == "Linux"
ignore_errors: yes
register: rubyinstall_present
tags: ruby
- name: Ruby | Ruby install | package dependencies
apt: pkg={{ item }} state=present force="yes" update_cache="yes"
when: ansible_system == "Linux"
with_items:
- build-essential
- libffi-dev
- libgdbm-dev
- libncurses5-dev
- libreadline-dev
- libreadline6-dev
- libtinfo-dev
- libyaml-dev
become: yes
tags: ruby
- name: Ruby | Download rubyinstall
get_url: url=http://github.com/postmodern/ruby-install/archive/v{{ ruby_install_version }}.tar.gz
dest=/tmp/ruby-install-{{ ruby_install_version }}.tar.gz
when: rubyinstall_present | failed
tags: ruby
- name: Ruby | Unpack ruby-install
command: tar xf /tmp/ruby-install-{{ ruby_install_version }}.tar.gz
chdir=/tmp
when: rubyinstall_present | failed
tags: ruby
- name: Ruby | Run ruby-install install target
command: make install
chdir=/tmp/ruby-install-{{ ruby_install_version }}
when: rubyinstall_present | failed
become: yes
tags: ruby
- name: Ruby | Download list of rubies available
command: ruby-install
when: rubyinstall_present | failed
become: yes
tags: ruby
Ruby
Now it is time to install Ruby. Small comment here: if you deploy on a shared server, you most likely would like to have an ability to have multiple Ruby versions and to switch between them. On the other hand, if you deploy your application to a dedicated host, usually I also replace default system Ruby with the same Ruby version.
With the tools above, your Ruby installation recipe will be compact & clear:
---
- name: Ruby | Find out if ruby_version is already installed
stat: path={{rubies_location}}/ruby-{{ruby_version}}
register: ruby_version_present
tags: ruby
- name: Ruby | Install ruby_version if necessary
command: '/usr/local/bin/ruby-install ruby {{ruby_version}}'
when: not ruby_version_present.stat.exists
become: yes
tags: ruby
- debug: var="ruby_install_setsystem"
- name: Ruby | Update SYSTEM ruby_version if necessary
command: '/usr/local/bin/ruby-install --system ruby {{ruby_version}}'
when: option_ruby_install_setsystem
become: yes
tags: ruby
Webserver & passenger
Thanks to the Phusion Passenger team, who did a great job to provide pre-built binaries for most of the popular platforms and configurations at https://oss-binaries.phusionpassenger.com/, we can skip the steps of compiling phusion passengers from a source, recompiling, webserver, etc & use their pre-built binary instead.
Historically, I prefer Nginx over the classic choice Apache, thus we will install a pre-built Nginx with passenger:
---
- name: Nginx | Check if is present
command: test -x /usr/sbin/nginx
when: ansible_os_family == "Debian"
ignore_errors: yes
register: nginx_present
tags: nginx
- name: Passenger | Add GPG key to apt keyring
apt_key: keyserver=keyserver.ubuntu.com id=561F9B9CAC40B2F7
when: ansible_os_family == "Debian" and nginx_present|failed
tags: passenger
become: yes
- name: Passenger | Install needed packages
apt: state=present pkg="{{item}}"
with_items:
- apt-transport-https
- ca-certificates
when: ansible_os_family == "Debian" and nginx_present|failed
become: yes
tags: passenger
- name: Passenger | Add nginx extras repository
apt_repository: repo="deb https://oss-binaries.phusionpassenger.com/apt/passenger trusty main" state=present
when: ansible_os_family == "Debian" and nginx_present|failed
tags: passenger
become: yes
- name: Ruby | Install Nginx extra and Phusion Passenger
apt: state=present update_cache=yes pkg="{{item}}"
when: ansible_os_family == "Debian" and nginx_present|failed
with_items:
- nginx-extras
- passenger
become: yes
tags: passenger
- name: Nginx | Create sites available/enabled directories
file: path={{item}} state=directory mode=0755
with_items:
- /etc/nginx/sites-available
- /etc/nginx/sites-enabled
when: ansible_os_family == "Debian" and nginx_present|failed
tags:
- nginx
- passenger
become: yes
- name: Nginx | Configure include sites-enabled
lineinfile: dest=/etc/nginx/nginx.conf regexp=".*sites-enabled.*" line=" include /etc/nginx/sites-enabled/*;" insertbefore="}" state=present
tags:
- nginx
- passenger
when: ansible_os_family == "Debian" and nginx_present|failed
become: yes
- name: Nginx | Disable default site
file: path=/etc/nginx/sites-enabled/default state=absent
tags:
- nginx
- passenger
when: ansible_os_family == "Debian" and nginx_present|failed
become: yes
- name: Nginx | Uncomment server_names_hash_bucket_size
lineinfile: dest=/etc/nginx/nginx.conf regexp="^(\s*)#\s*server_names_hash_bucket_size" line="\1server_names_hash_bucket_size 64;" backrefs=yes
become: yes
when: ansible_os_family == "Debian" and nginx_present|failed
tags:
- nginx
- passenger
- name: Nginx | Set ruby to system one
lineinfile: dest=/etc/nginx/nginx.conf regexp="^(\s*)#\s*passenger_ruby" line="passenger_ruby /usr/local/bin/ruby;" backrefs=yes
become: yes
when: ansible_os_family == "Debian" and nginx_present|failed
tags:
- nginx
- passenger
- name: Nginx | Set ruby to system one
lineinfile: dest=/etc/nginx/nginx.conf regexp="^(\s*)#\s*passenger_root" line="passenger_root /usr/lib/ruby/vendor_ruby/phusion_passenger/locations.ini;" backrefs=yes
become: yes
when: ansible_os_family == "Debian" and nginx_present|failed
tags:
- nginx
- passenger
- name: Nginx | Reload
service: name=nginx state=reloaded
when: ansible_os_family == "Debian" and nginx_present|failed
tags:
- nginx
- passenger
become: yes
Several comments on the installation script above, which might require changes based on your own scenarios:
-
The task named "Create sites available/enabled directories" & the next one actually implements an Apache-like "sites-available / sites-enabled" folder structure for VHosts configs. If you prefer the default setup, comment that part out.
-
Specifying ruby location to passenger with
lineinfile: dest=/etc/nginx/nginx.conf regexp="^(\s*)#\s*passenger_ruby" line="passenger_ruby /usr/local/bin/ruby;" backrefs=yes
As you see, the replacement above assumes that your system Ruby is used. You might want to specify a different ruby path here.
The goal of these two tasks is to take nginx.conf
& set two parameters: passenger_root
and passenger_ruby
as per original instuctions in above comment.
##
# Uncomment it if you installed passenger or passenger-enterprise
##
passenger_root /usr/lib/ruby/vendor_ruby/phusion_passenger/locations.ini;
passenger_ruby /usr/local/bin/ruby;
How would you validate that if you've installed nginx with passenger right ?
You can do so by executing these commands to validate the setup:
sudo /usr/bin/passenger-config validate-install
What would you like to validate?
Use <space> to select.
If the menu doesn't display correctly, press '!'
‣ ⬢ Passenger itself
⬡ Apache
-------------------------------------------------------------------------
* Checking whether this Passenger install is in PATH... ✓
* Checking whether there are no other Passenger installations... ✓
Everything looks good. :-)
As for /usr/sbin/passenger-memory-stats
, you should see both Nginx & passenger processes.
sudo /usr/sbin/passenger-memory-stats
Version: 5.0.26
Date : 2016-03-18 11:17:57 +0200
------------- Apache processes -------------
*** WARNING: The Apache executable cannot be found.
Please set the APXS2 environment variable to your 'apxs2' executable's filename, or set the HTTPD environment variable to your 'httpd' or 'apache2' executable's filename.
--------- Nginx processes ----------
PID PPID VMSize Private Name
------------------------------------
8768 9991 138.1 MB 1.1 MB nginx: worker process
8769 9991 137.8 MB 0.9 MB nginx: worker process
8770 9991 137.8 MB 0.9 MB nginx: worker process
8771 9991 137.8 MB 0.9 MB nginx: worker process
9991 1 137.8 MB 0.9 MB nginx: master process /usr/sbin/nginx
### Processes: 5
### Total private dirty RSS: 4.68 MB
---- Passenger processes -----
PID VMSize Private Name
------------------------------
8742 436.3 MB 1.0 MB Passenger watchdog
8745 982.9 MB 2.0 MB Passenger core
8756 444.5 MB 1.1 MB Passenger ust-router
8806 387.1 MB 69.3 MB Passenger RubyApp: /var/www/public (production)
### Processes: 4
### Total private dirty RSS: 73.47 MB
slavko@ERM:/etc/nginx$
Application setup itself
Let's define your application parameters: in particular, required OS packages to build gems, app secret and to hash the passwords, application environment parameters, database connection details.
app_dependencies:
- libsqlite3-dev
- libmysqlclient-dev
- libpq-dev
- git
- nodejs
- npm
app_short_name: app
app_env: production
app_domain: domain.local
app_secret: 82d58d3dfb91238b495a311eb8539edf5064784f1d58994679db8363ec241c745bef0b446bfe44d66cbf91a2f4e497d8f6b1ef1656e3f405b0d263a9617ac75e
app_repository: https://github.com/RailsApps/rails-devise.git
# app_repository_keyname: id_rsa_app
app_base_dir: /var/www
app_www_root: "{{app_base_dir}}/public"
app_env_vars:
- {name: SECRET_KEY_BASE, value: "{{app_secret}}" }
- {name: DATABASE_URL, value: "postgres://{{app_db_user}}:{{app_db_password}}@{{app_db_host}}/{{app_db_name}}"}
- {name: RAILS_ENV, value: "{{app_env}}" }
- {name: DOMAIN_NAME, value: "{{app_domain}}" }
app_db_host: localhost
app_db_user: app_user
app_db_password: app_password
app_db_name: app_database
app_directories:
- "{{app_base_dir}}"
The application provisioning script itself goes into stages: OS packages dependencies, Gem dependencies (for devise it is sqlite3), checking out the source code, patching Gem file so the ruby version matches the one installed on host + introducing production gems for uglifyjs (this is current app specifics), bundle install, patching database configuration, assets compilation, db migration, generating nginx site configuration, restarting the web server.
---
- name: APP STUB | Dependencies
apt: pkg={{ item }} state=present force="yes" update_cache="yes"
when: ansible_system == "Linux"
with_items: "{{app_dependencies}}"
become: yes
tags: app_stub
- name: APP STUB | Install gem dependencies
shell: "gem install --no-rdoc --no-ri {{item}}"
with_items:
- sqlite3
become: yes
tags: app_stub
- name: APP STUB | Re-create base app directory
file: path={{app_base_dir}} state=absent
become: yes
tags: app_stub
- name: APP STUB | Create directories
file: path={{item}} state=directory mode=0755 owner={{ansible_user_id}} group={{ansible_user_id}}
with_items: "{{app_directories}}"
become: yes
tags: app_stub
- name: APP STUB | Checkout app without key
git: repo="{{app_repository}}" dest="{{app_base_dir}}" accept_hostkey="yes" force="yes"
when: app_repository_keyname is not defined
tags: app_stub
- name: APP STUB | Install global rails gem
shell: gem install --no-rdoc --no-ri rails
become: yes
tags: app_stub
- name: APP STUB | Eliminate ruby req
lineinfile: dest="{{app_base_dir}}/Gemfile" regexp="^(\s*)*ruby" line="ruby '{{ruby_version}}'"
tags: app_stub
- name: APP STUB | gem therubyracer - uglifyjs
lineinfile: dest="{{app_base_dir}}/Gemfile" regexp="^(\s*)*gem 'therubyracer'" line="gem 'therubyracer', :platforms => :ruby" insertafter="^group :production do"
tags: app_stub
- name: APP STUB | gem execjs - uglifyjs
lineinfile: dest="{{app_base_dir}}/Gemfile" regexp="^(\s*)*gem 'execjs'" line="gem 'execjs'" insertafter="^group :production do"
tags: app_stub
- name: APP STUB | gem pg
lineinfile: dest="{{app_base_dir}}/Gemfile" regexp="^(\s*)*gem 'pg'" line="gem 'pg'" insertafter="^group :production do"
tags: app_stub
- name: APP STUB | Run bundle install --path .bundle/gems --binstubs .bundle/bin
shell: bundle install --path .bundle/gems --binstubs .bundle/bin
args:
chdir: "{{app_base_dir}}"
tags: app_stub
- name: APP STUB | database.yml
template: src="{{root_dir}}/templates/app/database.yml.j2" dest="{{app_base_dir}}/config/database.yml"
become: yes
tags: app_stub
- name: APP STUB | Precompile assets
shell: bundle exec rake assets:precompile
args:
chdir: "{{app_base_dir}}"
environment:
RAILS_ENV: "{{app_env}}"
DATABASE_URL: "postgres://{{app_db_user}}:{{app_db_password}}@{{app_db_host}}/{{app_db_name}}"
SECRET_KEY_BASE: "{{app_secret}}"
DOMAIN_NAME: "{{app_domain}}"
tags: app_stub
- name: APP STUB | DB Migrate
shell: bundle exec rake db:migrate
args:
chdir: "{{app_base_dir}}"
environment:
RAILS_ENV: "{{app_env}}"
DATABASE_URL: "postgres://{{app_db_user}}:{{app_db_password}}@{{app_db_host}}/{{app_db_name}}"
SECRET_KEY_BASE: "{{app_secret}}"
DOMAIN_NAME: "{{app_domain}}"
tags: app_stub
- name: APP STUB | Nginx conf
template: src="{{root_dir}}/templates/nginx_app.conf.j2" dest="/etc/nginx/sites-enabled/{{app_short_name}}.conf"
become: yes
tags: app_stub
- name: Nginx | Reload
service: name=nginx state=reloaded
become: yes
tags: app_stub
In particular, we patch the database config/database.yml with real connection details
# On Heroku and other platform providers, you may have a full connection URL
# available as an environment variable. For example:
#
# DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase"
#
# You can use this database configuration with:
#
production:
url: <%= ENV['DATABASE_URL'] %>
And we patch the Nginx app site config to provide app environment variables to ruby app with passenger_env_var
instructions.
server {
listen 80 default_server;
passenger_enabled on;
{% for envvar in app_env_vars %}
passenger_env_var {{ envvar.name }} "{{ envvar.value }}";
{% endfor %}
passenger_app_env {{app_env}};
root {{app_www_root}};
}
Running the code
Let's execute the provisioning & test it. For purposes of the demo, we will use a local postgres as our DB.
---
- hosts: www
vars:
- root_dir: ..
roles:
- {
role: "sa-postgres",
option_create_app_user: true
}
- {
role: "sa-ruby",
ruby_install_setsystem: true,
ruby_version: 2.3.0,
option_install_sampleapp: true,
option_install_nginx_passenger: true
}
Once the application provisioning process is over:
TASK: [sa-ruby | Nginx | Reload] **********************************************
changed: [192.168.0.17] => {"changed": true, "name": "nginx", "state": "started"}
PLAY RECAP ********************************************************************
192.168.0.17 : ok=55 changed=46 unreachable=0 failed=0
Play run took 23 minutes
So, depending on your network speed, you'll have your application installed.
Let's check through the ip address:
Points of Interest
Now you aware of another way to deploy your ruby applications.
Demo deployment script is available at https://github.com/Voronenko/devops-ruby-app-demo, recipes packed as ansible reusable role available at https://github.com/softasap/sa-ruby.