Pull to refresh

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

Reading time 14 min
Views 6.9K

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


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


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


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


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


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

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


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


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


Simple Playbook


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


---
- name: Simple playbook
  hosts: all

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


ansible-playbook ./playbook.yml

PLAY [Simple Playbook] ************************************************************************************************************************************
skipping: no hosts matched

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


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


123.123.123.123

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


ansible-playbook ./playbook.yml -i inventory
PLAY [Simple Playbook] ************************************************************************************************************************************

TASK [Gathering Facts] ************************************************************************************************************************************

PLAY RECAP ************************************************************************************************************************************

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


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


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

Попробуйте еще раз запустить playbook и убедиться что соединение работает. (Если вы указали root пользователя, то так же надо указать директиву become: true, что бы получить повышенные права. Как написано в документации: 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, тоесть говорим установить эти пакеты. Похожим образом, мы можем сказать их удалить, или обновить, просто поменяв state. Обратите внимание, что для работы rails с postgresql нам нужен пакет postgresql-contrib, который мы сейчас устанавливаем. Об этом опять же надо знать и сделать, ansible сам по себе этого делать не будет.


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


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


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


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

Мы создаем нового пользователя, устанавливаем ему 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).


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


Давайте добавим нашего пользователя в группу 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"

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


---
- 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 и в них можно использовать значения переменных при помощи тех же двойных фигурных скобок.


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


# 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'

Мы создаем основной конфигурационный файл nginx (можно взять его прямо с сервера, или написать самостоятельно). И так же конфигурационный файл для нашего приложения в sites_available директоорию (это не обязательно но полезно). В первом случае мы используем модуль 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 файлы для разных групп. Или можно комбинировать два подхода, если у вас много разныз хостов.


Вернемся к настройке nginx. После копирования конфигурационных файлов нам необходимо создать симлинк в sitest_enabled на my_app.conf из sites_available. И перезапустить 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. Больше данных можно найти в документации. Тут наиболее интересна директива become_user: postgres. Дело в том, что по умолчанию доступ к postgresql базе есть только у пользователя postgres и только локально. Данная директива позволяет нам выполнять команды от имени этого пользователя (если конечно у нас есть доступ).
Так же, возможно, вам придется дописать строку в pg_hba.conf что бы открыть доступ новому пользователю к базе. Это можно сделать так же, как мы меняли конфиг nginx.


Ну и конечно надо добавить роль 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

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


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


- 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

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


- 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 придется определять прямо в этом же скрипте.


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


- 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 штука такая — если не понимаешь что надо сделать, то и он тебе не сделает.

Tags:
Hubs:
+13
Comments 14
Comments Comments 14

Articles