Back to Home

Ansible and Rails - Capistrano flexible replacement while maintaining familiar comfort

ansible · capistrano · configuration management · deployment tools · deploy · ruby · ruby ​​on rails · sidekiq · passenger

Ansible and Rails - Capistrano flexible replacement while maintaining familiar comfort

Capistrano is a tool beloved by many rails developers, with which you can quickly and easily automate the deployment of your application. Capistrano is the de facto standard for the RoR deployment system, a must-know technology for any self-respecting hacker, the tool that python and PHP developers envied at one time.
Despite the comfort that I do not want to give up, the more complex tasks I had to solve, the more often Capistrano showed himself to be not adapted to them.

I noted the following disadvantages:
  • Known speed issues. Due to its versatility, Capistrano will deploy slowly, performing unnecessary checks and challenges that you cannot always control.
  • Sequential deploy. Non-quick deployment times must be multiplied by the number of target servers (however, you can configure parallelization of commands explicitly).
  • Strong connection with rails. Capistrano configs and dependencies intertwine with the application, becoming part of it. You cannot create a new deployment environment (for example, a server for rolling out functionality early) without creating a new rails environment. In difficult situations, Capistrano forces you to abandon good practice to keep only development, test and production environments.
  • Plugins are a double edged sword. Giving you the opportunity to quickly “fasten” the deployment of one or another application dependency, plugins deprive you of control of the situation, make you act like the plug-in developer does. I wrote about the effect of unnecessary “body movements” of plugins on deployment speed.
  • Complex deployment of heterogeneous applications. The trend of recent years in rails has been the separation of the most difficult (background or network) tasks into separate services, not necessarily written in ruby. In such a situation, capistrano forces you to produce a zoo from different deployment systems for different languages ​​and technologies.

Many ruby ​​developers switched to Mina or solve their problems with even more complex configuration management systems like Chef and Puppet . All of them have their own characteristics and disadvantages and to various degrees solve the problems described above. I managed to solve them with the help of Ansible , without losing the advantages of Capistrano that I was used to.

Ansible is a configuration management tool and its tasks include not only the execution of remote commands on servers for deploying and managing a separate application described in this article, but also automation of server administration through stored server configurations (roles in Ansible language). So Ansible (as well as Chef and Puppet, by the way) allows much more than Capistrano and ultimately they all can not be compared with him. However, the purpose of this article is to give rails developers a starting point for migration and to explain the basics of Ansible in this example. At the end of this article, the magic cap production deploy command will turn into ansible-playbook deploy.yml -i inventory / production
Who cares how - ask for cat.

Installation


Ansible is written in python. Not every rubist will like it, but I will dispel fears right away - you won’t have to write a single line in the “enemy language”. The attractive power of Ansible is that all deployment scripts are configuration files in the well-known yml format with a simple and powerful descriptive syntax.

Installing ansible is also quick and easy. Install ansible only on the local machine:
sudo easy_isntall pip
sudo pip install -U ansible

This is where the interaction with the python utilities ends, and now we can use the ansible-playbook command , with which we deploy. The command has only one required argument - the relative path to the playbook file.

Ansible-playbook


A playbook file is a list of running tasks or other playbooks. Thanks to nesting, we can effectively isolate tasks across layers and achieve the ability to run only what we currently need.
As an example for deployment, take myawesomestartup - this is a kind of rails application with a bunch of passenger 5 standalone and nginx as a web server and sidekiq for background tasks. The physical infrastructure in the example is two production servers:
prima.myawesomestartup.com
secunda.myawesomestartup.com

And one staging:
plebius.myawesomestartup.com

In the ansible folder, define the deploy.yml master playbook containing all the other playbooks,
---
- hosts: hosts
- include: release.yml # создание нового релиза
- include: app.yml     # запуск сервера веб-прриложения
- include: sidekiq.yml # запуск воркеров sidekiq

Team Ansible-playbook deploy.yml , run deploy fully. However, you can run playbooks individually if we need to restart the application without rolling out a new release.
Pay attention to the hosts variable; it contains information about the servers on which the deployment will be performed. This variable can be defined in the global ansible configuration, but we will do differently using inventory files.

Inventory files and application configuration


Ansible provides inventory files for storing host groups, their hierarchy, and settings . These are ini files with very simple syntax.

We can describe a group of hosts:
[hosts:children]
prima
secunda

In the group, declare the hosts themselves:
[prima]
prima.myawesomestartup.com
[secunda]
secunda.myawesomestartup.com

Declare variables specific to each specific host:
[prima:vars]
ansible_env_name=production
rails_env_name=production
database_name={{ lookup('env', 'PRIMA_DB_NAME') }}
database_username={{ lookup('env', 'PRIMA_DB_LOGIN') }}
database_password={{ lookup('env', 'PRIMA_DB_PASSWORD') }}
database_host={{ lookup('env', 'PRIMA_DB_HOST') }}
database_port={{ lookup('env', 'PRIMA_DB_PORT') }}

Pay attention to curly brackets - in ansible all files are Jinja2 templates . In this example , environment variables are interpolated through the template engine and lookup command from the machine from which the deployment is performed. This is useful in order not to store any sensitive information, such as secret keys or database connection strings, in the version control system.

In order for the example to work, you need to declare the following variables in your ~ / .bashrc or ~ / .zshrc or (which is safer and less convenient) export them every time before each deployment:
export PRIMA_DB_NAME=myawesomestartup_production
export PRIMA_DB_LOGIN=myawesomestartup
export PRIMA_DB_PASSWORD=secret
export PRIMA_DB_HOST=db.myawesomestartup.com
export PRIMA_DB_PORT=3306

The following are the complete inventory / production and inventory / staging files :
inventory / production
; production
[prima]
prima.myawesomestartup.com
[prima:vars]
ansible_env_name=production
rails_env_name=production
database_name={{ lookup('env', 'PRIMA_DB_NAME') }}
database_username={{ lookup('env', 'PRIMA_DB_LOGIN') }}
database_password={{ lookup('env', 'PRIMA_DB_PASSWORD') }}
database_host{{ lookup('env', 'PRIMA_DB_HOST') }}
database_port={{ lookup('env', 'PRIMA_DB_PORT') }}
git_branch=master
app_path=/srv/www/prima.myawesomestartup.com
custom_server_options=--no-friendly-error-pages
sidekiq_process_number=4
[secunda]
secunda.myawesomestartup.com
[secunda:vars]
ansible_env_name=production
rails_env_name=production
database_name={{ lookup('env', 'SECUNDA_DB_NAME') }}
database_username={{ lookup('env', 'SECUNDA_DB_LOGIN') }}
database_password={{ lookup('env', 'SECUNDA_DB_PASSWORD') }}
database_host={{ lookup('env', 'SECUNDA_DB_HOST') }}
database_port={{ lookup('env', 'SECUNDA_DB_PORT') }}
git_branch=master
app_path=/srv/www/secunda.myawesomestartup.com
custom_server_options=--no-friendly-error-pages
sidekiq_process_number=4
[hosts:children]
prima
secunda


inventory / staging

; staging
[plebius]
plebius.myawesomestartup.com
[plebius:vars]
ansible_env_name=staging
rails_env_name=production
database_name={{ lookup('env', 'PLEBIUS_DB_NAME') }}
database_username={{ lookup('env', 'PLEBIUS_DB_LOGIN') }}
database_password={{ lookup('env', 'PLEBIUS_DB_PASSWORD') }}
database_host={{ lookup('env', 'PLEBIUS_DB_HOST') }}
database_port={{ lookup('env', 'PLEBIUS_DB_PORT') }}
git_branch=develop
app_path=/srv/www/plebius.myawesomestartup.com
custom_server_options=--friendly-error-pages
sidekiq_process_number=4
[hosts:children]
plebius


We put the config templates in the ansible / configs folder:
configs / database.yml
# configs/database.yml
{{rails_env_name}}:
  adapter: mysql2
  database: {{database_name}}
  username: {{database_username}}
  password: {{database_password}}
  host: {{database_host}}
  port: {{database_port}}
  secure_auth: false


For those settings that can be safely stored in the version control system, I prefer dotenv .
Create the following file structure in the ansible / environments folder:
production/
    prima.env
    secunda.env
staging/
    plebius.env


Releases in Capistrano


Capistrano by default offers a fairly well thought out file structure on the server.
releases/
  20150631130156/
  20150631130233/
  20150631172431/
  20150704162516/
  20150712165952/
current - -> /www/domain/releases/20150712165952/
shared/

The releases folder contains the five latest recent releases in folders with the names of the form 20150812165952 , containing the timestamp of the deployment time for this release. Inside each release is a REVISION file containing the hash of the commit from which the release was made.
Simlink current refers to the latest release in the releases folder .
The shared folder contains files common to all releases (for example .pid and .sock ) and those files that are excluded from the version control system (for example, database.yml) All this allows you to safely roll back the application in the event of a deployment failure or rolling out code with unexpected bugs.
Repeat this with Ansible:
ansible / release.yml
# ansible/release.yml
---
- hosts: hosts # хосты объявлены в inventory-файле для каждого окружения
  tasks:
    # установка некоторых переменных вроде app_path и shared_path вынесена в отдельный миксин. Об этом ниже
    - include: tasks/_set_vars.yml tags=always
    # создадим таймстамп текущего релиза и установим папку
    - set_fact: timestamp="{{ lookup('pipe', 'date +%Y%m%d%H%M%S') }}"
    - set_fact: release_path="{{ app_path }}/releases/{{ timestamp }}"
    # Проверим существование необходимых папок. Если их нет ansible их создаст
    - name: Ensure shared directory exists
      file: path={{ shared_path }} state=directory
    - name: Ensure shared/assets directory exists
      file: path={{ shared_path }}/assets state=directory
    - name: Ensure tmp directory exists
      file: path={{ shared_path }}/tmp state=directory
    - name: Ensure log directory exists
      file: path={{ shared_path }}/log state=directory
    - name: Ensure bundle directory exists
      file: path={{ shared_path }}/bundle state=directory
    # Оставим последние пять релизов включая текущий
    - name: Leave only last releases
      shell: "cd {{ app_path }}/releases && find ./ -maxdepth 1 | grep -G .............. | sort -r | tail -n +{{ keep_releases }} | xargs rm -rf"
    - name: Create release directory
      file: path={{ release_path }} state=directory
    # Скачаем приложение из системы контроля версий
    - name: Checkout git repo into release directory
      git:
        repo={{ git_repo }}
        dest={{ release_path }}
        version={{ git_branch }}
        accept_hostkey=yes
    # получим хеш последнего коммита для файла REVISION и запишем его
    - name: Get git branch head hash
      shell: "cd {{ release_path }} && git rev-parse --short HEAD"
      register: git_head_hash
    - name: Create REVISION file in the release path
      copy: content="{{ git_head_hash.stdout }}" dest={{ release_path }}/REVISION
    # создадим симлинки необходимые для rails приложения
    - name: Set assets link
      file: src={{ shared_path }}/assets path={{ release_path }}/public/assets state=link
    - name: Set tmp link
      file: src={{ shared_path }}/tmp path={{ release_path }}/tmp state=link
    - name: Set log link
      file: src={{ shared_path }}/log path={{ release_path }}/log state=link
    # скопируем шаблоны .env и database.yml в новый релиз. При этом в шаблоны подставятся нужные переменные для каждого хоста.
    - name: Copy .env file
      template: src=environments/{{ansible_env_name}}/{{ansible_hostname}}.env dest={{ release_path }}/.env
    - name: Copy database.yml
      template: src=configs/database.yml dest={{ release_path }}/config
    - set_fact: rvm_wrapper_command="cd {{ release_path }} && RAILS_ENV={{ rails_env_name }} rvm ruby-{{ ruby_version }}@{{ full_gemset_name }} --create do"
    # Bundle, миграции, компиляция ассетов...
    - name: Run bundle install
      shell: "{{ rvm_wrapper_command }} bundle install --path {{ shared_path }}/bundle --deployment --without development test"
    - name: Run db:migrate
      shell: "{{ rvm_wrapper_command }} rake db:migrate"
    - name: Precompile assets
      shell: "{{ rvm_wrapper_command }} rake assets:precompile"
    # Симлинкнем наш релиз в папку current
    - name: Update app version
      file: src={{ release_path }} path={{ app_path }}/current state=link


Some variables were set in a separate mixin task, since these variables are identical for all playbooks and servers:
# ansible/tasks/_set_vars.yml
---
- set_fact: app_name="myawesomestartup"
- set_fact: ruby_version="2.2.2"
- set_fact: ruby_gemset="myawesomestartup"
- set_fact: git_repo="ilpagency/rails-sidekiq-ansible-sample"
- set_fact: keep_releases="5"
- set_fact: full_app_name="{{ app_name }}-{{ ansible_env_name }}"
- set_fact: full_gemset_name="{{ ruby_gemset }}-{{ ansible_env_name }}"
- set_fact: current_path="{{ app_path }}/current"
- set_fact: shared_path="{{ app_path }}/shared"


Running passenger and sidekiq - Ansible tags and loops


We will create another playbook for managing the state of the ansible / app.yml application , with which you can start, stop, or restart the application. Like other playbooks, it can be run separately, or as part of a master playbook.
For more flexibility, add the tags app_stop and app_start . Tags allow you to perform only those parts of the tasks that are explicitly specified during the deployment. If you do not specify tags during the deployment, the playbook will be executed in its entirety.

Here's what it looks like in practice:
# Перезапустить приложение:
ansible-playbook app.yml -i inventory/production
# Только остановить:
ansible-playbook app.yml -i inventory/production -t "app_stop"
# Только запустить:
ansible-playbook app.yml -i inventory/production -t "app_start"
# Это тоже перезапуск:
ansible-playbook app.yml -i inventory/production -t "app_stop,app_start"

And here is the implementation:
ansible / app.yml
# ansible/app.yml
---
- hosts: hosts # хосты объявлены в inventory-файле для каждого окружения
  tasks:
    - include: tasks/_set_vars.yml tags=always # always это специальный тег, задача отмеченная им будет выполнена всегда, при любых указанных команде деплоя тегах
    - set_fact: socks_path={{ shared_path }}/tmp/socks
      tags: always
    - name: Ensure sockets directory exists
      file: path={{ socks_path }} state=directory
      tags: always
    - set_fact: app_sock={{ socks_path }}/app.sock
      tags: always
    - set_fact: pids_path={{ shared_path }}/tmp/pids
      tags: always
    - name: Ensure pids directory exists
      file: path={{ pids_path }} state=directory
      tags: always
    - set_fact: app_pid={{ pids_path }}/passenger.pid
      tags: always
    - set_fact: rvm_wrapper_command="cd {{ current_path }} && RAILS_ENV={{ rails_env_name }} rvm ruby-{{ ruby_version }}@{{ full_gemset_name }} --create do"
      tags: always
    - include: tasks/app_stop.yml tags=app_stop #эта задача будет запщуена если не указан ни один тег или указан тег app_start
    - include: tasks/app_start.yml tags=app_start # поведение аналогично предыдущему, только тег - app_stop


The tasks of starting and stopping the application are highlighted separately in the files ansible / tasks / app_start.yml and ansible / tasks / app_stop.yml :
ansible / tasks / app_start.yml
# ansible/tasks/app_start.yml
---
- name: start passenger
  shell: "{{ rvm_wrapper_command }} bundle exec passenger start -d -S {{ app_sock }} --environment {{ rails_env_name }} --pid-file {{ app_pid }} {{ custom_server_options }}"


ansible / tasks / app_stop.yml
# ansible/tasks/app_stop.yml
---
- name: stop passenger
  shell: "{{ rvm_wrapper_command }} bundle exec passenger stop --pid-file {{ app_pid }}"
  ignore_errors: yes # если вдруг приложение не запущено... игнорируем ошибки. Лучше - добавить явную проверку.


With sidekiq, the situation is similar. For it, we implement a separate playbook ansible / sidekiq.yml supporting the corresponding sidekiq_stop and sidekiq_start tags :
ansible / app.yml
# ansible/sidekiq.yml
---
- hosts: hosts
  tasks:
    - include: tasks/_set_vars.yml tags=always
    - set_fact: pids_path={{ shared_path }}/tmp/pids
      tags: always
    - name: Ensure pids directory exists
      file: path={{ pids_path }} state=directory
      tags: always
    - set_fact: rvm_wrapper_command="cd {{ current_path }} && RAILS_ENV={{ rails_env_name }} rvm ruby-{{ ruby_version }}@{{ full_gemset_name }} --create do"
      tags: always
    - include: tasks/sidekiq_stop.yml tags=sidekiq_stop
    - include: tasks/sidekiq_start.yml tags=sidekiq_start


Start and stop tasks are also separately allocated to the files ansible / tasks / sidekiq_start.yml and ansible / tasks / sidekiq_stop.yml . In addition to actually starting and stopping sidekiq, these tasks demonstrate working with loops in Ansible and solve the problem of starting / stopping several processes at once:
ansible / tasks / sidekiq_start.yml
# ansible/tasks/sidekiq_start.yml
---
- name: start sidekiq
  shell: "{{ rvm_wrapper_command }} bundle exec sidekiq --index {{ item }} --pidfile {{ pids_path }}/sidekiq-{{ item }}.pid --environment {{ rails_env_name }} --logfile {{ shared_path }}/log/sidekiq.log --daemon" # переменная item - суть i в цикле. Если в with_sequence указать 4, то item будет 1,2,3,4
  with_sequence: count={{ sidekiq_process_number }} # число процессов sidekiq указано в инвентарном файле для каждого сервера и каждого окружения


ansible / tasks / sidekiq_stop.yml
# ansible/tasks/sidekiq_stop.yml
---
- name: stop sidekiq
  shell: "{{ rvm_wrapper_command }} bundle exec sidekiqctl stop {{ pids_path }}/sidekiq-{{ item }}.pid 20"
  ignore_errors: yes # И снова, желательно реализовать проверку на то, запущен ли процесс, а не игонорировать ошибки.
  with_sequence: count={{ sidekiq_process_number }}



Conclusion


Now we can use Ansible to deploy rails applications:
cd myawesomestartup/ansible
# Деплой:
ansible-playbook deploy.yml -i inventory/production
# Перезапустить приложение:
ansible-playbook app.yml -i inventory/production
# Перезапустить sidekiq:
ansible-playbook sidekiq.yml -i inventory/production
# Деплой в стейджинг из кастомной ветки:
ansible-playbook deploy.yml -i inventory/staging -e git_branch="hotfix/14082015-777-production_bug"

Since this article gives only an example (albeit a working one), I’ll note the ways in which we can go further:
  1. Implement graceful restart for Passenger.
  2. Use Ansible role mechanism instead of nested playbooks.
  3. Implement rollback to roll back to previous releases.
  4. And in general, bring this example in greater compliance with the recommendations of the developers .

And the most important thing. Ansible can do much more than roll out application releases and restart servers. Indeed, I repeat, ansible is not just a deployment tool, but a complete configuration management tool. For example, with the help of roles, you can configure the deployment of an application from scratch, directly to bare server hardware. And the simplicity of yml notations makes it easy to modify the solutions found to fit your needs.

All source code from the article is available on GitHub . Thanks for attention.

Read Next