Ansible и Rails — гибкая замена Capistrano с сохранением знакомого комфорта

Capistrano — любимый многими rails-разработчиками инструмент, с помощью которого можно быстро и без заморочек автоматизировать развертывание вашего приложения. Capistrano — стандарт де-факто для системы развертывания RoR, must-know технология для любого уважающего себя рубиста, тот инструмент, которому в своё время завидовали разработчики на python и PHP.
Несмотря на комфорт, от которого не хочется отказываться, чем более сложные задачи мне приходилось решать, тем чаще Capistrano показывал себя к ним не приспособленным.

Я отметил следующие недостатки:
  • Известные проблемы со скоростью. Вследствие своей универсальности, Capistrano деплоит медленно, выполняя лишние проверки и вызовы, которые вы не всегда можете контролировать.
  • Последовательный деплой. Небыстрое время развертывания нужно умножить на количество целевых серверов (однако, можно настроить распараллеливание комманд явным образом).
  • Сильная связанность с рельсами. Конфиги и зависимости Capistrano переплетаются с приложением, становясь его частью. Нельзя создать новое окружение-развертывания (например сервера для раннего выкатывания функционала) без создания нового rails-окружения. В сложных ситуациях Capistrano заставляет уходить от хорошей практики держать только development, test и production окружения.
  • Плагины — палка о двух концах. Давая возможность быстро “прикрутить” развертывание той или иной зависимости приложения, плагины лишают вас контроля ситуации, заставляют действовать так, как действует разработчик плагина. О влиянии лишних “телодвижений” плагинов на скорость деплоя я написал выше.
  • Сложный деплой гетерогенных приложений. Трендом последних лет в рельсах стало выделение самых тяжелых (бекграундных или сетевых) задач в отдельные сервисы, не обязательно написанные на ruby. В такой ситуации capistrano заставляет вас плодить зоопарк из разных систем развертывания для разных языков и технологий.

Многие ruby-разработчики перешли на Mina или решают свои проблемы с помощью ещё более сложных систем управления конфигурациями вроде Chef и Puppet. Все они имеют свои особенности и недостатки и в разной степени решают описанные выше проблемы. Мне же удалось их решить их с помощью Ansible, не растеряв преимуществ Capistrano, к которым я привык.

Ansible это инструмент для управления конфигурациями и в его задачи входит не только описанное в этой статье выполнение удаленных команд на серверах для развертывания и управления отдельным приложением, но и автоматизация серверного администрирования посредством хранимых серверных конфигураций (ролей на языке Ansible). А значит Ansible (как впрочем и Chef и Puppet) позволяет гораздо больше, чем Capistrano и в конечном счете они все не идут с ним ни в какое сравнение. Однако, задача этой статьи дать rails-разработчикам отправную точку для миграции и разъяснить на этом примере основы Ansible. В конце этой статьи, волшебная команда cap production deploy превратится в ansible-playbook deploy.yml -i inventory/production
Кому интересно как — прошу под кат.

Установка


Ansible написан на питоне. Не каждому рубисту это понравится, но я развею страхи сразу — ни одной строчки на “вражеском языке” вам писать не придется. Притягательная сила Ansible в том, что все скрипты деплоя это конфигурационные файлы в известном формате yml с простым и мощным описательным синтаксисом.

Установка ansible тоже простая и быстрая. Устанавливать ansible нужно только на локальной машине:
sudo easy_isntall pip
sudo pip install -U ansible

На этом взаимодействие с утилитами python заканчивается и теперь нам доступна команда ansible-playbook, с помощью которой и осуществляется деплой. Команда имеет лишь один обязательный аргумент — относительный путь к playbook-файлу.

Ansible-playbook


Playbook-файл это список запускаемых задач или других плейбуков. Благодаря вложенности, мы можем эффективно изолировать задачи по слоям и добиться возможности запускать только то, что нам в данный момент нужно.
В качестве примера для развертывания возьмем myawesomestartup — это некое rails-приложение со связкой passenger 5 standalone и nginx в качестве веб-сервера и sidekiq для фоновых задач. Физическая инфраструктура в примере — два продакшн сервера:
prima.myawesomestartup.com
secunda.myawesomestartup.com

И один стейджинг:
plebius.myawesomestartup.com

В папке ansible определим мастер-плейбук deploy.yml, содержащий все остальные плейбуки,
---
- hosts: hosts
- include: release.yml # создание нового релиза
- include: app.yml     # запуск сервера веб-прриложения
- include: sidekiq.yml # запуск воркеров sidekiq

Командой ansible-playbook deploy.yml, запустим деплой целиком. Однако, можно запустить плейбуки и по отдельности, если нам нужно перезапустить приложение без выкатывания нового релиза.
Обратите внимание на переменную hosts в ней содержится информация о серверах, на которых будет производиться развертывание. Эту переменную можно определить в глобальной конфигурации ansible, однако мы поступим по другому, воспользовавшись инвентарными файлами.

Инвентарные файлы и конфигурация приложения


Для хранения групп хостов, их иерархии и настроек в ansible предусмотрены инвентарные файлы. Это ini-файлы с очень простым синтаксисом.

Мы можем описать группу хостов:
[hosts:children]
prima
secunda

В группе объявим сами хосты:
[prima]
prima.myawesomestartup.com

[secunda]
secunda.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') }}

Обратите внимания фигурные на скобки — в ansible все файлы являются шаблонами Jinja2. В данном примере через шаблонизатор и команду lookup интерполируются переменные окружения, с машины, с которой выполняется развертывание. Это полезно для того, чтобы не хранить в системе контроля версий какую либо чувствительную информацию, вроде секретных ключей или строк подключения к БД.

Чтобы пример заработал, нужно объявить следующие переменные в вашем ~/.bashrc или ~/.zshrc или (что более безопасно и менее удобно) экспортировать их каждый раз перед каждым деплоем:
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

Ниже приведены файлы inventory/production и inventory/staging целиком:
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


Шаблоны конфигов положим в папку ansible/configs:
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


Для тех настроек, которые можно безопасно хранить в системе контроля версия я предпочитаю dotenv.
Создадим следующую структуру файлов в папке ansible/environments:
production/
    prima.env
    secunda.env
staging/
    plebius.env


Релизы как в Capistrano


Capistrano по умолчанию предлагает довольно продуманную структуру файлов на сервере.
releases/
  20150631130156/
  20150631130233/
  20150631172431/
  20150704162516/
  20150712165952/
current - -> /www/domain/releases/20150712165952/
shared/

Папка releases содержит пять последних последних релизов в папках с названиями вида 20150812165952, содержащих в себе таймстамп времени деплоя этого релиза. Внутри каждого релиза лежит файл REVISION содержащий в себе хеш коммита из которого был сделан релиз.
Симлинк current ссылается на последний релиз в папке releases.
Папка shared содержит общие для все релизов файлы (например .pid и .sock) и те файлы, которые исключены из системы контроля версий (например, database.yml). Все это позволяет безопасно откатывать приложение в случае сбоя деплоя или выкатывания кода с неожиданными багами.
Повторим это с помощью 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


Установка некоторых переменных была вынесена в отдельную задачу-миксин, так как эти переменные идентичны для всех плейбуков и серверов:
# 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"


Запуск passenger и sidekiq — теги и циклы Ansible


Создадим ещё один плейбук для управления состоянием приложения ansible/app.yml, с помощью которого приложение можно будет запустить, остановить или перезапустить. Как и другие плейбуки, его можно запускать отдельно, либо как часть мастер-плейбука.
Для большей гибкости добавим теги app_stop и app_start. Теги, позволяют выполнять только те части задач, которые явно указаны при деплое. Если не указывать теги при деплое — плейбук будет выполнен целиком.

Вот как это выглядит на практике:
# Перезапустить приложение:
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"

А вот реализация:
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


Задачи запуска и остановки приложения выделены отдельно в файлы ansible/tasks/app_start.yml и 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 # если вдруг приложение не запущено... игнорируем ошибки. Лучше - добавить явную проверку.


С sidekiq ситуация схожая. Для него реализуем отдельный плейбук ansible/sidekiq.yml поддерживающий соответствующие теги sidekiq_stop и sidekiq_start:
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


Задачи запуска и остановки так-же выделены отдельно в файлы ansible/tasks/sidekiq_start.yml и ansible/tasks/sidekiq_stop.yml. Помимо собственно запуска и остановки sidekiq, в этих задачах демонстрируется работа с циклами в Ansible и решается проблема запуска/остановки нескольких процессов сразу:
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 }}



Заключение


Теперь мы можем пользоваться Ansible для развертывания rails приложений:
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"

Поскольку эта статья даёт лишь пример (пусть и рабочий), отмечу пути, по которым можно пойти дальше:
  1. Реализовать graceful restart для Passenger.
  2. Использовать механизм ролей Ansible вместо вложенных плейбуков.
  3. Реализовать роллбек для отката к предыдущим релизам.
  4. И вообще привести этот пример в большее соответствие с рекомендациями разработчиков.

И самое главное. Ansible может гораздо больше, чем выкатывать релизы приложения и перезапускать сервера. Ведь, повторюсь, ansible не просто утилита для деплоя, а полноценный инструмент управления конфигурациями. К примеру, с помощью ролей вы можете настроить развертывание приложения с нуля, прямо на голое серверное железо. А простота yml-нотаций позволяет с лёгкостью модифицировать найденные решения под свои нужды.

Все исходные коды из статьи доступны на GitHub. Спасибо за внимание.
Поделиться публикацией
Комментарии 13
    0
    Последовательный деплой. Небыстрое время развертывания нужно умножить на количество целевых серверов.


    Да разве? В release notes пишут что парарллелизм есть давно.
      0
      Каюсь, не углядел за развитием cap (видимо я давно им раздосадован). В ансибл, однако, параллелизм это поведение по умолчанию, без конструкций вроде on :all, in: :parallel
        0
        Для rolling update сомнительное преимущество. Но если админ локалхоста, то нормально.
          0
          Параметр serial в настройках плейбука можно указать единожды и наслаждаться выкатыванием релизов с комфортным вам размером пачки (можно даже в процентах указать).
          Так же интересной для такого варианта, является настройка max_fail_percentage, которая прекратит деплой немедленно, если релиз завалился на определенном количестве хостов.

          docs.ansible.com/ansible/playbooks_delegation.html
            0
            Я понимаю что можно это и ансиблом сделать, суть не в этом. Суть в том что это не преимущество для системы деплоя.
      +1
      Хорошая статья!

      Нельзя создать новое окружение-развертывания (например сервера для раннего выкатывания функционала) без создания нового rails-окружения.

      Почему нельзя? Можно создать сколько угодно окружений для развертывания, указав для них в настройках существующий :rails_env

      Кстати, не заметил у вас ansible-задачи для rollback'а приложения, думаю стоит добавить упоминание о нем в статье
        0
        Написал про роллбек в заключении статьи. Спасибо!
        +1
        Сильная связанность с рельсами. Конфиги и зависимости Capistrano переплетаются с приложением, становясь его частью. Нельзя создать новое окружение-развертывания (например сервера для раннего выкатывания функционала) без создания нового rails-окружения. В сложных ситуациях Capistrano заставляет уходить от хорошей практики держать только development, test и production окружения.

        В каком месте в капистране вообще связь с рельсами???
        cap install в пустой папке и указать нужные гит-репо адрес / ветки / итд

        Плагины — палка о двух концах. Давая возможность быстро “прикрутить” развертывание той или иной зависимости приложения, плагины лишают вас контроля ситуации, заставляют действовать так, как действует разработчик плагина. О влиянии лишних “телодвижений” плагинов на скорость деплоя я написал выше.

        Можно просто не использовать плагины, а фигачить код вручную

        Сложный деплой гетерогенных приложений. Трендом последних лет в рельсах стало выделение самых тяжелых (бекграундных или сетевых) задач в отдельные сервисы, не обязательно написанные на ruby. В такой ситуации capistrano заставляет вас плодить зоопарк из разных систем развертывания для разных языков и технологий.

        Никто не запрещает деплоить капистараной что угодно. Сделать пулл и запустить баш-команду можно для любой технологии
          0
          Ваш вариант, конечно рабочий, но его принято считать экзотичным.
          Более того, с помощью cap можно деплоить что-то в чем вообще нет руби.
          Но как по мне — при таком варианте капистрано деградирует до rake + SSHKit.
            0
            Да и еще я замечу, что практика «держать только development, test и production окружения» хороша, только в случае наличия только трех этих окружений.
            При появлении стейжа, должен появится новый rails-env

            По поводу капистраны в случае использования «хорошей практики» — добавить новый стейдж в капистрану, и в стейдже написать «set :rails_env, 'super-stage'»
              –1
              А вот я против выделения stage как отдельного, отличного от production, окружения, как по концептуальным, так и по практическим причинам.
              Ведь стейдж по-сути что? Это сервер для тестирования кода, в окружении максимально близком к продакшену. А значит, добавление нового rails-окружения вносит дополнительные различия между стейджем и продакшеном, что негативно для самой идеи стеджа, не говоря о том, что всегда можно что-то упустить из виду в конфигурации нескольких окружений и получить ошибку на проде, после успешного тестирования кода на стейдже.
            +2
            github.com/ansistrano/deploy — тут люди замутили капистрано на ансибле, и про ролбэк они не забыли.
              +1
              Плюсую. Особенно напрягает качество плейбука в данной статье. За файл 'ansible/release.yml' отдельно хочется отрывать руки. Если вы сами суть работы с Ansible не понимаете, то, умоляю, не пытайтесь других людей учить плохому.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое