Ansible and Rails - Capistrano flexible replacement while maintaining familiar comfort
- From the sandbox
- Tutorial
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 :
; 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
; 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
{{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
---
- 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
---
- 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
---
- 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
---
- 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/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
---
- 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
---
- 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:
- Implement graceful restart for Passenger.
- Use Ansible role mechanism instead of nested playbooks.
- Implement rollback to roll back to previous releases.
- 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.