Хабрахабр

Настройка сервера для развертывания Rails приложения при помощи Ansible

И, на удивление, я не нашел простого пошагового мануала. Не так давно мне было необходимо написать несколько ansible playbooks для подготовки сервера к деплою rails приложения. Возможно кому-то я смогу помочь этот процесс ускорить при помощи данной статьи. Копировать чужой плейбук без понимая происходящего я не хотел и в итоге пришлось читать документацию, собирая все самостоятельно.

Тут нет никакой магии, нельзя поставить плагин и получить из коробки zero downtime деплой своего приложения с докером, мониторингом и прочими плюшками. Первым делом стоит понимать, что ansible предоставляет вам удобный интерфейс для выполнения заранее определенного списка действий на удаленном сервере (серверах) через SSH. Поэтому меня не устраивают готовые плейбуки с гитхаба, или статьи вида: “Скопируйте и запустите, — будет работать”. Для того чтобы написать плейбук вы должны знать что именно вы хотите сделать и как это сделать.

Что нам нужно?

Давайте определимся с тем, что нам нужно. Как я уже говорил, для того чтобы написать плейбук надо знать, что вы хотите сделать и как это сделать. Помимо этого нам нужен ruby определенной версии. Для Rails приложения нам понадобится несколько системных пакетов: nginx, postgresql (redis, e.t.c.). Запускать все это из под root пользователя — всегда плохая идея, поэтому надо создать отдельного пользователя, и настроить ему права. Ставить его лучше всего через rbenv (rvm, asdf…). и запустить все эти сервисы. После этого необходимо залить наш код на сервер, скопировать конфиги для nginx, postgres, e.t.c.

В итоге последовательность действий такая:

  1. Логинимся под рутом
  2. устанавливаем системные пакеты
  3. создаем нового пользователя, настраиваем права, shh ключ
  4. настраиваем системные пакеты (nginx e.t.c) и запускаем их
  5. Создаем пользователя в БД (можно сразу и базу создать)
  6. Логинимся новым пользователем
  7. Устанавливаем rbenv и ruby
  8. Устанавливаем бандлер
  9. Заливаем код приложения
  10. Запускаем Puma сервер

Все это можно сделать и при помощи Ansible, но зачем? Причем последние этапы можно делать при помощи capistrano, по крайней мере она из коробки умеет копировать код в релизные директории, переключать релиз симлинком при успешном деплое, копировать конфиги из shared директории, рестартовать puma и.т.д.

Файловая структура

Причем не так важно, будет она в самом rails приложении, или отдельно. Ansible имеет строгую файловую структуру для всех своих файлов, поэтому лучше всего держать все это в отдельной директории. Лично мне удобнее всего оказалось создать директорию ansible в /config директории rails приложения и хранить все в одном репозитории. Можно хранить файлы в отдельном git репозитории.

Simple Playbook

Давайте создадим первый плейбук, который не делает ничего: Playbook — это yml файл, в котором при помощи специального синтаксиса описано, что и как ansible должен сделать.

---
- name: Simple playbook hosts: all

Мы можем сохранить его в /ansible директории с именем playbook.yml и попробовать запустить: Здесь мы просто говорим, что наш playbook называется Simple Playbook и что выполняться его содержимое должно для всех хостов.

ansible-playbook ./playbook.yml PLAY [Simple Playbook] ************************************************************************************************************************************
skipping: no hosts matched

Их надо перечислить в специальном inventory файле. Ansible говорит что не знает хостов, которые бы соответсвовали списку all.

Давайте создадим его в той же ansible директории:

123.123.123.123

Вот так просто указываем хост (в идеале хост своего VPS для тестов, или же можно localhost прописать) и сохраняем его под именем inventory.
Можно попробовать запустить ansible с invetory файлом:

ansible-playbook ./playbook.yml -i inventory
PLAY [Simple Playbook] ************************************************************************************************************************************ TASK [Gathering Facts] ************************************************************************************************************************************ PLAY RECAP ************************************************************************************************************************************

(дефолтный TASK [Gathering Facts] ) после чего даст краткий отчет о выполнении (PLAY RECAP). Если у вас есть доступ по ssh к указанному хосту то ansible подключится и соберет информацию об удаленной системе.

На хосте его, скорее всего, не будет. По умолчанию для соединения используется имя пользователя под которым вы залогинены в системе. Так же информация об удаленной системе вам часто может быть ненужна и не стоит стратить время на ее сбор. В playbook файле можно указать какого пользователя использовать для подключения при помощи директивы remote_user. Эту задачу так же можно выключить:

---
- name: Simple playbook hosts: all remote_user: root become: true gather_facts: no

(Если вы указали root пользователя, то так же надо указать директиву become: true, что бы получить повышенные права. Попробуйте еще раз запустить playbook и убедиться что соединение работает. Как написано в документации: become set to ‘true’/’yes’ to activate privilege escalation. хотя не совсем понятно, зачем).

Возможно вы получите ошибку вызыванную тем, что ansible не может определить интерпритатор питона, тогда его можно указать вручную:

ansible_python_interpreter: /usr/bin/python3

где у вас лежит python можно узнать командой whereis python.

Установка системных пакетов

Сейчас нам понадобится один из таких модулей для обновления системы и установки системных пакетов. В стандартной поставке Ansible входит множетсво модулей для работы с различными системными пакетами, благодаря чему нам не приходится по любому поводу писать bash скрипты. Если у вас используется другая операционная система то, возможно, понадобится другой модуль (помните я в начале говорил, что надо заранее знать что и как будем делать). У меня на VPS стоит Ubuntu Linux соответсвенно для установки пакетов я использую apt-get и модуль для него. Однако синтаксис, скорее всего будет похожим.

Дополним наш плейбук первыми задачами:

---
- name: Simple playbook hosts: all remote_user: root become: true gather_facts: no tasks: - name: Update system apt: update_cache=yes - name: Install system dependencies apt: name: git,nginx,redis,postgresql,postgresql-contrib state: present

Мы даем задаче имя, что бы отслеживать ее выполнение в логе. Task — это как раз задача которую ansible будет выполнять на удаленных серверах. В данном случае apt: update_cache=yes — говорит обновить пакеты системы при помощи модуля apt. И описываем, при помощи синтаксиса конкретного модуля, что ему нужно сделать. Мы передаем в модуль apt список пакетов, и говорим что их state должен стать present, тоесть говорим установить эти пакеты. Вторая команда несколько сложнее. Обратите внимание, что для работы rails с postgresql нам нужен пакет postgresql-contrib, который мы сейчас устанавливаем. Похожим образом, мы можем сказать их удалить, или обновить, просто поменяв state. Об этом опять же надо знать и сделать, ansible сам по себе этого делать не будет.

Попробуйте запустить playbook еще раз и проверить, что пакеты установятся.

Создание новых пользователей.

Добавим еще один task (я скрыл уже известные части плейбука за комментариям, что бы не копировать его целиком каждый раз): Для работы с пользователями у Ansible так же есть модуль — user.

---
- name: Simple playbook # ... tasks: # ... - name: Add a new user user: name: my_user shell: /bin/bash password: "}"

И тут же сталкиваемся с несколькими проблемами. Мы создаем нового пользователя, устанавливаем ему schell и пароль. Да и хранить пароль в открытом виде в плейбуке очень плохая идея. Что если имена пользователей должны быть для разных хостов разными? Для начала вынесем имя пользователя и пароль в переменные, а ближе к концу статьи я покажу как пароль зашифровать.

---
- name: Simple playbook # ... tasks: # ... - name: Add a new user user: name: "{{ user }}" shell: /bin/bash password: "{{ user_password | password_hash('sha512') }}"

при помощи двойных фигурных скобок в плэйбуках устанавливаются переменные.

Значения переменных мы укажем в inventory файле:

123.123.123.123 [all:vars]
user=my_user
user_password=123qweasd

Обратите внимание на директиву [all:vars] — она говорит о том, что следующий блок текста — это переменные (vars) и они применимы для всех хостов (all).

Дело в том, что ansible не устанавливает пользователя через user_add как вы бы делали это вручную. Так же интересна конструкция "{{ user_password | password_hash('sha512') }}". А сохраняет все данные напрямую, из-за чего пароль мы так же должны заранее преобразовать в хэш, что и делает данная команда.

Однако, перед этим необходимо убедиться что такая группа есть потому что за нас этого никто делать не будет: Давайте добавим нашего пользователя в группу sudo.

---
- name: Simple playbook # ... tasks: # ... - name: Ensure a 'sudo' group group: name: sudo state: present - name: Add a new user user: name: "{{ user }}" shell: /bin/bash password: "{{ user_password | password_hash('sha512') }}" groups: "sudo"

После чего достаточно прописать эту группу пользователю (groups: "sudo").
Так же полезно добавить этому пользователю ssh ключ, что бы мы могли логиниться под ним без пароля: Все достаточно просто, у нас так же есть модуль group для создания групп, с синтаксисом очень похожим на apt.

---
- name: Simple playbook # ... tasks: # ... - name: Ensure a 'sudo' group group: name: sudo state: present - name: Add a new user user: name: "{{ user }}" shell: /bin/bash password: "{{ user_password | password_hash('sha512') }}" groups: "sudo" - name: Deploy SSH Key authorized_key: user: "{{ user }}" key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}" state: present

В данном случае интересна конструкция "{{ lookup('file', '~/.ssh/id_rsa.pub') }}" — она копирует содержимое файла id_rsa.pub (у вас название может и отличаться), тоесть публичную часть ssh ключа и загружает его в список авторизованных ключей для пользователя на сервер.

Роли

Для этого в ansible существуют роли.
Согласно указанной в самом начале файловой структуре, роли необходимо положить в отдельную директорию roles, для каждой роли — отдельная директория с аналогичным названием, внутри директории tasks, files, templates, e.t.c.
Cоздадим файловую структуру: ./ansible/roles/user/tasks/main.yml (main — это основной файл который будет подгружаться и выполняться при подключении роли к плейбуку, в нем можно подключать другие файлы роли). Все три задачи для создания пользоваться можно легко отнести к одной группе задач, и неплохо было бы хранить эту группу отдельно от основного плейбука, что бы он слишком не разрастался. Теперь можно перенести в этот файл все задачи относящиеся к пользователю:

# Create user and add him to groups
- name: Ensure a 'sudo' group group: name: sudo state: present - name: Add a new user user: name: "{{ user }}" shell: /bin/bash password: "{{ user_password | password_hash('sha512') }}" groups: "sudo" - name: Deploy SSH Key authorized_key: user: "{{ user }}" key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}" state: present

В основном же плейбуке необходимо указать использовать роль user:

---
- name: Simple playbook hosts: all remote_user: root gather_facts: no tasks: - name: Update system apt: update_cache=yes - name: Install system dependencies apt: name: git,nginx,redis,postgresql,postgresql-contrib state: present roles: - user

Так же, возможно имеет смысл выполнять обновление системы раньше всех остальных задач, для этого можно переименовать блок tasks в котором они определены в pre_tasks.

Настройка nginx

Давайте делать это сразу в роли. Nginx у нас должен быть уже установлен, необходимо его сконфигурировать и запустить. Создаем файловую структуру:

- ansible - roles - nginx - files - tasks - main.yml - templates

Разница между ними в том, что файлы ansible копирует напрямую, как есть. Теперь нам понадобятся файлы и шаблоны. А шаблоны должны иметь расширение j2 и в них можно использовать значения переменных при помощи тех же двойных фигурных скобок.

Для этого у нас есть модуль systemd: Давайте включим nginx в main.yml файле.

# Copy nginx configs and start it
- name: enable service nginx and start systemd: name: nginx state: started enabled: yes

Тут мы не только говорим, что nginx должен быть started (тоесть запускаем его), но сразу говорим что он должен быть enabled.
Теперь скопируем конфигурационные файлы:

# Copy nginx configs and start it
- name: enable service nginx and start systemd: name: nginx state: started enabled: yes - name: Copy the nginx.conf copy: src: nginx.conf dest: /etc/nginx/nginx.conf owner: root group: root mode: '0644' backup: yes - name: Copy template my_app.conf template: src: my_app_conf.j2 dest: /etc/nginx/sites-available/my_app.conf owner: root group: root mode: '0644'

И так же конфигурационный файл для нашего приложения в sites_available директоорию (это не обязательно но полезно). Мы создаем основной конфигурационный файл nginx (можно взять его прямо с сервера, или написать самостоятельно). Во втором — копируем шаблон, подставляя значения переменных. В первом случае мы используем модуль copy для копирования файлов (файл должен лежать в /ansible/roles/nginx/files/nginx.conf). И выглядеть он может примерно так: Шаблон должен лежать в /ansible/roles/nginx/templates/my_app.j2).

upstream {{ app_name }} { server unix:{{ app_path }}/shared/tmp/sockets/puma.sock;
} server { listen 80; server_name {{ server_name }} {{ inventory_hostname }}; root {{ app_path }}/current/public; try_files $uri/index.html $uri.html $uri @{{ app_name }}; ....
}

Это полезно, если использовать плейбук для разных групп хостов. Обратите внимание на вставки {{ app_name }}, {{ app_path }}, {{ server_name }}, {{ inventory_hostname }} — это все переменные, значения которых ansible подставит в шаблон перед копированием. Например мы может дополнить наш inventory файл:

[production]
123.123.123.123 [staging]
231.231.231.231 [all:vars]
user=my_user
user_password=123qweasd [production:vars]
server_name=production
app_path=/home/www/my_app
app_name=my_app [staging:vars]
server_name=staging
app_path=/home/www/my_stage
app_name=my_stage_app

Но при этом для staging хоста переменные будут отличатся от production, и не только в ролях и плейбуках, но и в конфигах nginx. Если мы запустим теперь наш плейбук, то он выполнит указанные задачи для обоих хостов. {{ inventory_hostname }} не надо указывать в inventory файле — это специальная перменная ansible и там хранится хост для которого выполняется плейбук в данный момент.
Если вы хотите иметь inventory файл для нескольких хостов, а запускать только для одной группы, это можно сделать следующей командой:

ansible-playbook -i inventory ./playbook.yml -l "staging"

Или можно комбинировать два подхода, если у вас много разныз хостов. другой вариант — иметь отдельные inventory файлы для разных групп.

После копирования конфигурационных файлов нам необходимо создать симлинк в sitest_enabled на my_app.conf из sites_available. Вернемся к настройке nginx. И перезапустить nginx.

... # old code in mail.yml - name: Create symlink to sites-enabled file: src: /etc/nginx/sites-available/my_app.conf dest: /etc/nginx/sites-enabled/my_app.conf state: link - name: restart nginx service: name: nginx state: restarted

Но есть один момент. Тут все просто — опять модули ansible с достаточно стандартным синтаксисом. Вы обратили внимание, что мы не пишем команды вида: "сделать вот это вот так", синтаксис выглядит скорее как "вот у этого должно быть вот такое состояние". Перезапускать nginx каждый раз не имеет смысла. Если группа уже существует, или системный пакет уже установлен, то ansible проверит это и пропустит задачу. И чаще всего именно так ansible и работает. Мы можем этим воспользоваться и перезапускать nginx только если конфигурационные файлы были изменены. Так же файлы не будут копироваться, если они полностью совпадают с тем, что уже есть на сервере. Для этого существует директива register:

# Copy nginx configs and start it
- name: enable service nginx and start systemd: name: nginx state: started enabled: yes - name: Copy the nginx.conf copy: src: nginx.conf dest: /etc/nginx/nginx.conf owner: root group: root mode: '0644' backup: yes register: restart_nginx - name: Copy template my_app.conf template: src: my_app_conf.j2 dest: /etc/nginx/sites-available/my_app.conf owner: root group: root mode: '0644' register: restart_nginx - name: Create symlink to sites-enabled file: src: /etc/nginx/sites-available/my_app.conf dest: /etc/nginx/sites-enabled/my_app.conf state: link - name: restart nginx service: name: nginx state: restarted when: restart_nginx.changed

И только если эта переменная была зарегистрирована, выполнится перезапуск сервиса. Если один из конфигурационных файлов меняется, то будет выполнено копирование и зарегестрирована переменная restart_nginx.

Ну и, конечно, нужно добавить роль nginx в основной playbook.

Настройка postgresql

Нам необходимо включить postgresql при помощи systemd точно так же как мы это делали с nginx, а так же создать пользователя, которого мы будет использовать для доступа к базе данных и саму базу данных.
Создадим роль /ansible/roles/postgresql/tasks/main.yml:

# Create user in postgresql
- name: enable postgresql and start systemd: name: postgresql state: started enabled: yes - name: Create database user become_user: postgres postgresql_user: name: "{{ db_user }}" password: "{{ db_password }}" role_attr_flags: SUPERUSER - name: Create database become_user: postgres postgresql_db: name: "{{ db_name }}" encoding: UTF-8 owner: "{{ db_user }}"

Больше данных можно найти в документации. Я не буду расписывать, как добавлять переменные в inventory, это уже делалось много раз, так же как и синтаксис модулей postgresql_db и postgresql_user. Дело в том, что по умолчанию доступ к postgresql базе есть только у пользователя postgres и только локально. Тут наиболее интересна директива become_user: postgres. Это можно сделать так же, как мы меняли конфиг nginx. Данная директива позволяет нам выполнять команды от имени этого пользователя (если конечно у нас есть доступ).
Так же, возможно, вам придется дописать строку в pg_hba.conf что бы открыть доступ новому пользователю к базе.

Ну и конечно надо добавить роль postgresql в основной плейбук.

Установка ruby через rbenv

Поэтому эта задачка становится самой нестандартной. В ansible нет модулей для работы с rbenv, а устанавливается он путем клонирования git репозитория. Создадим для нее роль /ansible/roles/ruby_rbenv/main.yml и начнем ее заполнять:

# Install rbenv and ruby
- name: Install rbenv become_user: "{{ user }}" git: repo=https://github.com/rbenv/rbenv.git dest=~/.rbenv

Так как rbenv устанавливается в его home директории, а не глобально. Мы опять используем директиву become_user что бы работать из под созданного нами для этих целей пользователя. И так же мы используем модуль git для того, что бы склонировать репозиторий, указывя repo и dest.

Для этого у нас есть модуль lineinfile: Далее нам необходимо прописать rbenv init в bashrc и там же добавиьт rbenv в PATH.

- name: Add rbenv to PATH become_user: "{{ user }}" lineinfile: path: ~/.bashrc state: present line: 'export PATH="${HOME}/.rbenv/bin:${PATH}"' - name: Add rbenv init to bashrc become_user: "{{ user }}" lineinfile: path: ~/.bashrc state: present line: 'eval "$(rbenv init -)"'

После чего надо установить ruby_build:

- name: Install ruby-build become_user: "{{ user }}" git: repo=https://github.com/rbenv/ruby-build.git dest=~/.rbenv/plugins/ruby-build

Это делается через rbenv, тоесть просто bash командой: И, наконец, установить ruby.

- name: Install ruby become_user: "{{ user }}" shell: | export PATH="${HOME}/.rbenv/bin:${PATH}" eval "$(rbenv init -)" rbenv install {{ ruby_version }} args: executable: /bin/bash

Однако тут мы наткнемся на то, что ansible не запускает код, содержащийся в bashrc перед запуском команд. Мы говорим, какую команду выполнить и чем. А значит, rbenv придется определять прямо в этом же скрипте.

Тоесть автоматической проверки, установлена эта версия ruby или нет — не будет. Следующая проблема связана с тем, что shell команда не имеет состояния с точки зрения ansible. Мы можем сделать это самостоятельно:

- name: Install ruby become_user: "{{ user }}" shell: | export PATH="${HOME}/.rbenv/bin:${PATH}" eval "$(rbenv init -)" if ! rbenv versions | grep -q {{ ruby_version }} then rbenv install {{ ruby_version }} && rbenv global {{ ruby_version }} fi args: executable: /bin/bash

И остается установить bundler:

- name: Install bundler become_user: "{{ user }}" shell: | export PATH="${HOME}/.rbenv/bin:${PATH}" eval "$(rbenv init -)" gem install bundler

И опять же добавить нашу роль ruby_rbenv в основной плейбук.

Shared files.

Далее остается запустить capistrano и оно само скопирует код, создаст нужные каталоги и запустит приложение (если настроено все верно). В целом на этом настройку можно было бы закончить. Есть только одна тонкость. Однако зачастую capistrano необходимы дополнительные конфигурационные файлы, такие как database.yml или .env Их можно скопировать точно так же как файлы и шаблоны для nginx. Перед копированием файлов необходимо создать для них структуру каталогов, что-то вроде такого:

# Copy shared files for deploy
- name: Ensure shared dir become_user: "{{ user }}" file: path: "{{ app_path }}/shared/config" state: directory

мы указываем только одну директорию и ansible автоматически создаст родительские, если нужно.

Ansible Vault

Если вы создали .env файл для приложения, и database.yml то там, должно быть еще больше таких критичных данных. Мы уже натыкались на то, что в переменных могут оказываться секретные данные такие как пароль пользователя. Для этого используется ansible vault. Их хорошо бы скрыть от посторонних глаз.

Создадим файл для перменных /ansible/vars/all.yml (тут можно создавать разные файлы для разных групп хостов, точно так же как в inventory файле: production.yml, staging.yml, e.t.c).
В этот файл необходимо перенести все переменные, которые должны быть зашифрованы, используя стандартный yml синтаксис:

# System vars
user_password: 123qweasd
db_password: 123qweasd # ENV vars
aws_access_key_id: xxxxx
aws_secret_access_key: xxxxxx
aws_bucket: bucket_name
rails_secret_key_base: very_secret_key_base

После чего этот файл можно зашифровать командой:

ansible-vault encrypt ./vars/all.yml

Можете посмотреть, что окажется внутри файла после вызова этой команды. Естественно, при шифровании необходимо будет установить пароль для дешифровки.

При помощи ansible-vault decrypt файл можно расшифровать, изменить и потом зашифровать снова.

Вы храните его в зашифрованном виде и запускаете playbook с аргументом --ask-vault-pass. Для работы расшифровывать файл не надо. Все данные останутся зашифрованными. Ansible спросит пароль, достанет переменные и выполнит задачи.

Полностью команда для нескольких групп хостов и ansible vault будет выглядеть примерно так:

ansible-playbook -i inventory ./playbook.yml -l "staging" --ask-vault-pass

Потому что ansible штука такая — если не понимаешь что надо сделать, то и он тебе не сделает. А полный текст плейбуков и ролей я вам не дам, пишите сами.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть