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

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

На първо място, е важно да разберете, че ansible предоставя удобен интерфейс за изпълнение на предварително зададен списък от действия на отдалечен сървър (сървъри) през SSH. Тук няма никаква магия, не можете да инсталирате плъгин и да получите от кутията zero downtime деплой на вашето приложение с Docker, мониторинг и всякакви други екстри. За да напишете плейбук, трябва да знаете какво точно искате да направите и как да го направите. Затова не ме удовлетворяват готовите плейбуци от GitHub или статии от типа: “Копирайте и стартирайте, — ще работи”.

Какво ни трябва?

Както вече споменах, за да напишете плейбук, трябва да знаете какво искате да направите и как да го направите. Нека определим какво ни е нужно. За Rails приложение ще ни трябват няколко системни пакета: nginx, postgresql (redis и др.). Освен това, имаме нужда от определена версия на ruby. Най-добре е да го инсталирате чрез rbenv (rvm, asdf...). Стартирането на всичко това под root потребител е винаги лоша идея, затова трябва да създадете отделен потребител и да му настроите правата. След това е необходимо да качите кода на сървъра, да копирате конфигурациите за nginx, postgres и др. и да стартирате всички тези услуги.

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

  1. Логинираме се под рут
  2. инсталираме системните пакети
  3. създаваме нов потребител, настройваме правата, ssh ключ
  4. настройваме системните пакети (nginx и др.) и ги стартираме
  5. Създаваме потребител в БД (можем веднага да създадем и базата)
  6. Логинираме се новия потребител
  7. Инсталираме rbenv и ruby
  8. Инсталираме бандлера
  9. Качваме кода на приложението
  10. Стартираме Puma сървъра

Всъщност последните етапи могат да се изпълнят с помощта на capistrano, понеже тя от кутията може да копира кода в директории за релиз, да превключва релиза с символен линк при успешен деплой, да копира конфигурации от споделена директория, да рестартира puma и т.н. Всичко това може да се направи и с помощта на Ansible, но защо?

Структура на файловете

Ansible има строга файлова структура за всичките си файлове, затова е най-добре да ги държите в отделна директория. Не е толкова важно дали ще е в самото rails приложение или отделно. Можете да съхранявате файловете в отделен git репозиторий. На мен ми е най-удобно да създам директория ansible в /config директорията на rails приложението и да съхраня всяко в един репозиторий.

Simple Playbook

Playbook е yml файл, в който с помощта на специален синтаксис е описано какво и как Ansible трябва да изпълни. Нека създадем нашия първи playbook, който не прави нищо:

---
- 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) и да го запазим под името инвентор.
Можем да опитаме да стартираме Ansible с inventory файла:

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’ за активиране на привилегировано ескалиране. въпреки че не е съвсем ясно защо).

Възможно е да получите грешка, причинена от това, че ansible не може да определи интерпретатора на Python, следователно можете да го укажете ръчно:

ansible_python_interpreter: /usr/bin/python3 

къде се намира python можете да разберете с командата whereis python.

Инсталиране на системни пакети

В стандартния пакет на Ansible има множество модули за работа с различни системни пакети, благодарение на което не е необходимо да пишем bash скриптове при всяка ситуация. Сега ни трябва един от тези модули за обновяване на системата и инсталиране на системни пакети. На моя VPS е инсталиран Ubuntu Linux, съответно за инсталиране на пакети използвам apt-get и модул за него. Ако имате друга операционна система, може да се наложи да използвате друг модул (помнете, че в началото казах, че трябва предварително да знаем какво и как ще правим). Обаче синтаксисът вероятно ще бъде подобен.

Да допълним нашия playbook с първите задачи:

---
- 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. Ще добавим още една задача (скривах вече известните части на плейбука в коментари, за да не я копираме изцяло всеки път):

---
- name: Прост плейбук
  # ...
  tasks:
    # ...
    - name: Добавяне на нов потребител
      user:
        name: my_user
        shell: /bin/bash
        password: "{{ 123qweasd | password_hash('sha512') }}"

Създаваме нов потребител, задаваме му shell и парола. И веднага се сблъскваме с няколко проблема. Какво, ако имената на потребителите трябва да са различни за различни хостове? Освен това е много лоша идея да съхранявате паролата в открит вид в плейбука. Първо ще изнесем името на потребителя и паролата в променливи, а по-близо до края на статията ще покажа как да шифроваме паролата.

---
- name: Прост плейбук
  # ...
  tasks:
    # ...
    - name: Добавяне на нов потребител
      user:
        name: "{{ user }}"
        shell: /bin/bash
        password: "{{ user_password | password_hash('sha512') }}"

С помощта на двойни фигурни скобки в плейбуците се задават променливи.

Стойностите на променливите ще посочим в инвентарния файл:

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: Прост плейбук
  # ...
  tasks:
    # ...
    - name: Уверете се, че група 'sudo' съществува
      group:
        name: sudo
        state: present
    - name: Добавяне на нов потребител
      user:
        name: "{{ user }}"
        shell: /bin/bash
        password: "{{ user_password | password_hash('sha512') }}"
        groups: "sudo"

Всичко е доста просто, имаме също така модул group за създаване на групи, със синтаксис, много подобен на apt. След това е достатъчно да зададем тази група на потребителя (groups: "sudo").
Също така е полезно да добавим SSH ключ на този потребител, за да можем да влизаме под него без парола:

---
- name: Опростен плейбук
  # ...
  tasks:
    # ...
    - name: Осигурете група 'sudo'
      group:
      name: sudo
        state: present
    - name: Добавете нов потребител
      user:
        name: "{{ user }}"
        shell: /bin/bash
        password: "{{ user_password | password_hash('sha512') }}"
        groups: "sudo"
    - name: Разполагане на SSH ключ
      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 и т.н.
Създаваме файловата структура: ./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: Опростен плейбук
  hosts: all
  remote_user: root
  gather_facts: no

  tasks:
    - name: Актуализиране на системата
      apt: update_cache=yes
    - name: Инсталиране на системни зависимости
      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 трябва да бъде стартиран (тоест да го пуснем), но веднага казваме, че той трябва да бъде активиран.
Сега ще копираме конфигурационните файлове:

# 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. След копирането на конфигурационните файлове, трябва да създадем симлинк в sites_enabled на my_app.conf от sites_available. И да рестартираме nginx.

... # стар код в mail.yml

- name: Създаване на симлинк към sites-enabled
  file:
    src: \/etc\/nginx\/sites-available\/my_app.conf
    dest: \/etc\/nginx\/sites-enabled\/my_app.conf
    state: link

- name: рестартиране на 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 в основния playbook.

Инсталиране на 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 се инсталира в домашната му директория, а не глобално. Също така използваме модула 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 и т.н.).
В този файл е необходимо да прехвърлите всички променливи, които трябва да бъдат шифровани, използвайки стандартния 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

Купете надежден хостинг за сайтове с защита от DDoS, VPS VDS сървъри 🔥 Купете надежден хостинг за сайтове с защита от DDoS, VPS VDS сървъри | ProHoster