Налаштування сервера для розгортання Rails програми за допомогою Ansible

Нещодавно мені було необхідно написати кілька ansible playbooks для підготовки сервера до деплою rails програми. І, напрочуд, я не знайшов простого покрокового мануалу. Копіювати чужий плейбук без розуміння того, що відбувається, я не хотів і в результаті довелося читати документацію, збираючи все самостійно. Можливо, комусь я зможу допомогти цей процес прискорити за допомогою цієї статті.

Насамперед варто розуміти, що ansible надає вам зручний інтерфейс для виконання заздалегідь визначеного списку дій на віддаленому сервері через SSH. Тут немає ніякої магії, не можна поставити плагін і отримати з коробки zero downtime деплою своєї програми з докером, моніторингом та іншими плюшками. Для того щоб написати плейбук ви повинні знати, що саме ви хочете зробити і як це зробити. Тому мене не влаштовують готові плейбуки з гітхабу, або статті виду: "Скопіюйте та запустіть, буде працювати".

Що нам потрібно?

Як я вже казав, щоб написати плейбук треба знати, що ви хочете зробити і як це зробити. Давайте визначимося з тим, що нам потрібне. Для Rails програми нам знадобиться кілька системних пакетів: nginx, postgresql (redis, etc). Крім цього нам потрібний ruby ​​певної версії. Ставити його найкраще через rbenv (rvm, asdf…). Запускати все це з-під root користувача - завжди погана ідея, тому треба створити окремого користувача, і налаштувати йому права. Після цього необхідно залити код на сервер, скопіювати конфіги для nginx, postgres, etc і запустити всі ці сервіси.

У результаті послідовність дій така:

  1. Логін під рутом
  2. встановлюємо системні пакети
  3. створюємо нового користувача, налаштовуємо права, ssh ключ
  4. налаштовуємо системні пакети (nginx etc) та запускаємо їх
  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, який ми зараз встановлюємо. Про це знову ж таки треба знати і зробити, а сам сам по собі цього робити не буде.

Спробуйте запустити 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, etc
Створимо файлову структуру: ./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 в основний плейбук.

Спільні файли.

Загалом на цьому налаштування можна було б закінчити. Далі залишається запустити 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, etc).
У цей файл необхідно перенести всі змінні, які мають бути зашифровані за допомогою стандартного 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 штука така - якщо не розумієш, що треба зробити, то і він тобі не зробить.

Джерело: habr.com

Додати коментар або відгук