Пришло время продолжить цикл статей о запуске личного блога. На очереди конфигурация сервера с помощью Ansible.
Данная статья будет разделена на три блока:
Рекомендую ознакомиться с первым коротким блоком, прочитав который, вы сможете понять будет ли вам полезно или интересно читать дальше. После чего, если вы знаете что такое Ansible, можно смело переходить к третьему блоку.
Несколько слов о результатах первой статьи
В предыдущей статье я рассказывал про настройку и запуск сервера с помощью Terraform в облаке Selectel, а также о планах, о которых хочется напомнить. Для начала я планирую сделать динамический блог cо своей cms на минималках, используя IaC-подход и такие инструменты, как Terraform и Ansible. Это цель в первую очередь носит для меня образовательно-практический характер. Если бы цель была просто сделать блог, то Terraform и Ansible, как правильно подметили в комментариях к предыдущей статье, больше усложняет задачу. Правда я писал об этом, но видимо не достаточно акцентировано, исправляюсь)) В первую очередь я frontend-разработчик и мои знания об облачной инфраструктуре не такие, как у профильных специалистов. Поэтому важно отметить, что я не претендую на то, чтобы мои решения имели звания «эталонные» или даже «хорошие», но буду рад конструктивной критике и замечаниям.
И немного о приятном - в честь первой статьи в этом году, я провожу розыгрыш в своем тг-канале. На этот раз разыграю механическую клавиатуру Redragon CARAXES PRO. А теперь к делу.
Что такое Ansible?
Ansible - это инструмент для управления конфигурацией, а управление конфигурацией — это контроль и отслеживание изменений в системе. Сюда входит и контроль за версией установленных пакетов, и за файлами конфигурации, и за настройками операционной системы на хосте.
Задачи SCM (Supply Chain Management | Управление цепочками поставок:
определить, что является изменением, влияющим на продукт, а что нет;
предоставлять информацию, кто и когда сделал изменение;
быстро и удобно накатывать определённую версию рабочей конфигурации.
Исторически для этих задач использовалась консоль и bash скрипты.
Есть несколько инструментов для управления конфигурацией, например, Puppet или Chef, но Ansible самый популярный из них, так как имеет низкий порог входа, написан на Python и не требует дополнительной инфраструктуры. Ansible работает без агентов, он подключается к серверам по известным протоколам удаленного доступа. Для Linux это, в основном, SSH, а для Windows - WinRM. Другие инструменты требуют развёртывания головного сервера и установки агентов на хостах.
Ansible содержит огромное количество модулей и плагинов для разнообразных задач. Если модули используются непосредственно в задачах конфигурации и запускаются на целевых серверах, то плагины упрощают работу с Ansible на контрольной ноде — сервере, где запускается сам Ansible.
Как бы выглядела команда копирования файла с помощью Ansible:
$ ansible localhost -m copy -a "src=name.service dest=/tmp/name.service"
localhost | CHANGED => {
"changed": true,
"checksum": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
"dest": "/tmp/name.service",
"gid": 1002,
"group": "ansible",
"md5sum": "d41d8cd98f00b204e9800998ecf8427e",
"mode": "0664",
"owner": "ansible",
"size": 0,
"src": "/home/ansible/.ansible/tmp/ansible-tmp-1645871871.061007-194067518564439/source",
"state": "file",
"uid": 1000
}
Здесь мы используем модуль copy. Ansible подключается к указанному удалённому хосту (localhost) и выполняет копирование файла с контрольной ноды (указан в параметре src) на удалённый хост в директорию /tmp/ (указан в параметре dest)
Нужно обратить внимание на вывод и значение поля "changed". Модуль всегда проверяет необходимость выполнения действия, иначе, он выполнит действия для приведения системы в требуемое состояние. Если мы запустим ту же команду второй раз:
$ ansible localhost -m copy -a "src=name.service dest=/tmp/name.service"
localhost | SUCCESS => {
"changed": false,
"checksum": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
"dest": "/tmp/name.service",
"gid": 1002,
"group": "ansible",
"mode": "0664",
"owner": "ansible",
"path": "/tmp/name.service",
"size": 0,
"state": "file",
"uid": 1000
}
Поле "changed" изменилось на "false". В случае использования модуля copy, Ansible проверит чек-сумму ("checksum") копируемого файла. Если они совпадают, то никаких действий не требуется. Это сильное и важное свойство систем управления конфигурацией и Ansible в частности, которое называется идемпотентность.
Идемпотентность — свойство функции или операции при повторном применении к объекту давать тот же результат, что и при первом.
Конечно с Ansible обычно не работаю через консоль, а используют декларативное описание выполняемых задач в YAML-файлах. Это описание включает в себя следующие элементы:
Ansible task;
Ansible role;
Ansible play;
Ansible playbook;
Ansible inventory.
Ansible Task — это блок в YAML, с помощью которого можно сообщить Ansible, что мы ожидаем получить на целевом сервере: установленный пакет, желаемую конфигурацию и запущенный сервис.
# Пример установки NodeJS
- name: Update apt cache
ansible.builtin.apt:
update_cache: yes
- name: Download nodejs shell script
ansible.builtin.uri:
url: "https://deb.nodesource.com/setup_23.x"
dest: "/tmp/nodesource_setup.sh"
when: not nodesource.stat.exists
- name: Run nodejs shell script
ansible.builtin.command:
cmd: "bash nodesource_setup.sh"
chdir: "/tmp"
- name: Install the nodejs
ansible.builtin.apt:
name: nodejs
state: present
- name: Remove nodejs shell script
ansible.builtin.file:
path: "/tmp/nodesource_setup.sh"
state: absent
Каждой задаче даётся имя, которое будет отображаться в отчёте при запуске Ansible. Далее указывается используемый модуль и его параметры. Параметры модуля хорошо описаны в его документации, которую можно найти на сайте Ansible или вызвав команду ansible-doc <имя модуля>
.
Когда задач становится много или когда нужно управлять таким набором задач как единым модулем, они организуются в Ansible role. Ansible role инкапсулирует всю сложную логику, как функция в программировании, и позволяет вызывать код этой логики одной строкой с параметрами.
Ansible role представляет собой директорию, с упорядоченными по назначению YAML-файлами. Файловая структура роли чётко определена:
tasks/main.yml
— Ansible-задачи, запускаемые ролью;defaults/main.yml
— дефолтные значения переменных для роли;vars/main.yml
— переменные для роли;templates/
— директория с jinja2-шаблонами;handlers/main.yml
— обработчики, запускаются только при получении соответствующих уведомлений (notify) от задач;files/
— директория с файлами, не требующими шаблонизации;meta/main.yml
— метаданные с описанием авторов роли, зависимостей и версии.
Файловая структура роли может быть автоматически сгенерирована с помощью утилиты ansible-galaxy
, которая поставляется в пакете Ansible:
ansible-galaxy init <my role name>
Чтобы применить задачи или роли на целевом сервере потребуется play
. Ansible play — это блок описания в YAML, в котором указан шаблон целевых хостов (или ещё говорят host pattern) и список задач или ролей для этих хостов.
---
- name: Play for frontend-service start
hosts: frontend
roles:
- frontend
Список плеев организуется в Ansible playbook — YAML-файл, который передаётся утилите ansible-playbook:
ansible-playbook playbook.yaml
Конфигурация, необходимая Ansible для подключения к целевым серверам, называется inventory. В простом случае Ansible inventory — это текстовый файл со списком серверов:
localhost
31.129.45.39
В списке могут быть DNS-имена или IP-адреса.
Серверов, на которых нужно выполнить один play, может быть несколько, поэтому хосты организуют в группы. Группы могут содержать список хостов или вложенные группы, а inventory тогда удобнее описать в том же YAML:
all: # Все серверы в inventory, all - обязателен
children: # Дочерние группы для all
backend: # Группа хостов с именем backend
hosts:
10.66.101.8:
frontend:
hosts:
frontend.example.com:
Такая вложенность групп позволяет разбить инфраструктуру на окружения (dev/stage/prod):
all:
children:
backend:
hosts:
10.66.101.8:
dev-backend.example.com:
frontend:
hosts:
10.66.101.7:
frontend.example.com:
dev:
hosts:
dev-backend.example.com:
10.66.101.7:
prod:
hosts:
10.66.101.8:
frontend.example.com:
И запускать playbook с параметром --limit, только по нужному окружению:
ansible-playbook playbook.yaml --limit dev
В inventory можно описать переменные группы или хоста, которые можно будет использовать в задачах:
all:
children:
backend:
hosts:
dev-backend.example.com:
# Переменная только для этого хоста
ansible_user: ansible
vars: # Переменные для группы backend
backend_version: 0.1.0
vars: # Переменные для всех хостов в inventory
ansible_connection: ssh
Но такой формат передачи переменных перегружает файл inventory и лучшей практикой будет расположить переменные групп в директории group_vars
рядом с файлом playbook:
$ tree -L 2 .
.
├── group_vars
│ ├── all.yml
│ ├── backend.yml
│ └── frontend.yml
└── playbook.yaml
В Ansible переменные используются для управления поведением задач. Переменные могут быть определены в Ansible playbooks, в Ansible inventory, внутри Ansible roles или передаваться в командной строке запуска playbook. В последнем случае такие переменные называются «внешними» или «extra vars»:
ansible-playbook playbook.yaml --extra-vars "my_var=value"
Внешние переменные имеют наивысший приоритет. Например, если переменная my_var
определена в файле group_vars/<my group>.yaml
, то запуск плейбука с параметром —extra-vars "my_var=value"
заменит значение этой переменной.
В то же время, переменные, указанные в файлах group_vars/
, имеют больший приоритет над переменными, заданными в Ansible-ролях.
Переменные также возможно использовать в шаблонах. Ansible использует движок шаблонизации Jinja2 для подстановки переменных или для динамического формирования конфигурационных файлов. В любом YAML-блоке Ansible можно указать переменную в двойных фигурных скобках и Jinja2 подставит соответствующее значение.
Например, ссылка одной переменной на значение другой в group_vars/all.yml
:
answer: 42
question: "{{ answer }}"
Ansible-модуль template использует Jinja2 для создания файлов на основе шаблонов с помощью переменных в процессе выполнения:
- name: Apply Nginx template
ansible.builtin.template:
src: templates/nginx.conf.j2
dest: /etc/nginx/sites-available/default
notify: Reload nginx
Если мы опишем переменные в group_vars/frontend.yml:
front_path: "/var/www-data"
То сможем сгенерировать nginx.conf
файл на основе шаблона nginx.conf.j2
:
server {
listen 80;
root {{ front_path }}/dist;
index index.html index.htm;
server_name _;
location / {
default_type "text/html";
try_files $uri.html $uri $uri/ =404;
}
}
Также можно конфигурировать и сам Ansible, используя ansible.cfg
файл. В файле ansible.cfg
определены директивы конфигурации для всего набора утилит (ansible, ansible-playbook, ansible-galaxy, ansible-vault, etc...). В этом файле указывается расположение ролей, конфигурация inventory и имя пользователя для подключения к хостам:
[defaults]
roles_path = roles
inventory = inventory
remote_user = ansible
vault_password_file = .vault
host_key_checking = False
[privilege_escalation]
become = true ; повышать привилегии (sudo) для выполняемых задач
Ansible поддерживает следующие способы передачи параметров конфигурации (в порядке убывания приоритета):
Переменные (если значение переменной присвоено несколько раз — используется последнее значение);
Значения из playbook;
Значения, установленные через аргументы командной строки;
Значения из файла конфигурации.
В свою очередь, Ansible ищет конфигурационные файлы в следующих местах и порядке:
Путь из переменной окружения
ANSIBLE_CONFIG
;./ansible.cfg
(файл в текущем каталоге);~/.ansible.cfg
(файл в домашнем каталоге);/etc/ansible/ansible.cfg
.
Используется только первый найденный файл, остальные игнорируются.
В коде Ansible могут быть чувствительные данные — пароли и токены, а в силу того, что код Ansible лежит в VCS-репозитории, возникает необходимость такие данные скрывать. В Ansible поддерживается функциональность шифрования чувствительных переменных или даже целых файлов с помощью команды ansible-vault
, а при запуске Ansible передать специальный пароль, который расшифрует данные в процессе работы:
$ ansible-vault encrypt_string "my_password" --name the_secret
Затем в group_vars/<my group>.yml
указывается вывод команды:
the_secret: !vault |
$ANSIBLE_VAULT;1.1;AES256
32616561306230333465396431633334353535333966343239373565623933613539363430626233
3239336365303531306236633338336261313064323931660a336437343530333765326139653463
39356530653266316132313235346237376238623330613864613534383831653630376236653532
3436623737653361620a663863663635303332393266646631333132303265346437616334346330
6461
Передача пароля для расшифровки происходит в интерактивной сессии при запуске Ansible playbook со специальным параметром:
ansible-playbook playbook.yaml --ask-vault-pass
Но при запуске Ansible в CI-системе этот способ не подойдёт и потребуется механизм передачи пароля без участия пользователя.
Для этого можно сформировать файл с паролем на предыдущем шаге CI и указать его расположение в ansible.cfg
:
vault_password_file = .vault
Конфигурация сервера с помощью Ansible
Полную конфигурацию, что я буду описывать далее, можно посмотреть в репозитории на Github. Проект будет развиваться, поэтому для удобства я зафиксировал текущую версию конфигураций в тэгах для ansible и terraform.
Для начала давайте посмотрим на файловую структуру моего Ansible-проекта:
$ tree -L 4
.
├── ansible.cfg
├── group_vars
│ ├── all.yaml
│ └── frontend.yaml
├── inventory.yaml
├── playbook.yaml
├── roles
│ └── frontend
│ ├── files
│ │ └── nginx.service
│ ├── handlers
│ │ └── main.yaml
│ ├── tasks
│ │ └── main.yaml
│ └── templates
│ └── nginx.conf.j2
└── run.sh
Точкой входа здесь будет bash-скрипт run.sh
:
ansible-playbook -e host="{public_ip} -i ./inventory.yaml playbook.yaml
В inventory пока лежит только один хост для фронта, который как раз приходит из внешней переменной:
all: # Все серверы в нашем inventory, all - обязателен
children: # Дочерние группы для all
frontend:
hosts:
frontend:
ansible_host: "{{ host }}"
ansible_user: root
И playbook пока также содержит только один play для фронта:
---
- name: Play for frontend-service start
hosts: frontend
roles:
- frontend
Теперь можно рассмотреть всю роль целиком. После чего остановлюсь на каждой задаче отдельно.
- block:
- name: Update apt cache
ansible.builtin.apt:
update_cache: yes
- name: Check that the /tmp/nodesource_setup.sh exists
ansible.builtin.stat:
path: "/tmp/nodesource_setup.sh"
register: nodesource
- name: Download nodejs shell script
ansible.builtin.uri:
url: "https://deb.nodesource.com/setup_23.x"
dest: "/tmp/nodesource_setup.sh"
when: not nodesource.stat.exists
- name: Run nodejs shell script
ansible.builtin.command:
cmd: "bash nodesource_setup.sh"
chdir: "/tmp"
- name: Install the nodejs
ansible.builtin.apt:
name: nodejs
state: present
- name: Remove nodejs shell script
ansible.builtin.file:
path: "/tmp/nodesource_setup.sh"
state: absent
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: latest
install_recommends: yes
notify: Start nginx
- name: Install Git
ansible.builtin.apt:
name: git
state: latest
install_recommends: yes
- name: Add user {{ front_user }}
ansible.builtin.user:
name: "{{ front_user }}"
groups: "{{ front_groups }}"
shell: /sbin/bash
create_home: true
- name: Add start dir
ansible.builtin.file:
path: "/home/{{ front_user }}"
state: directory
mode: "0755"
owner: "{{ front_user }}"
group: "{{ front_group }}"
- name: Add {{ front_path }} dir
ansible.builtin.file:
path: "{{ front_path }}"
state: directory
mode: "0755"
owner: "{{ front_user }}"
group: "{{ front_group }}"
- name: Clone frontend repo
ansible.builtin.git:
repo: "{{ front_repo_url }}"
dest: "/home/{{ front_user }}/frontend"
version: "{{ front_version }}"
force: yes
# accept_hostkey: yes
# key_file: /home/ansible/.ssh/id_rsa
- name: Install npm packages
ansible.builtin.command:
cmd: "npm install"
chdir: "/home/{{ front_user }}/frontend"
- name: Build frontend
ansible.builtin.command:
cmd: "npm run build"
chdir: "/home/{{ front_user }}/frontend"
- name: Add /opt/log dir
ansible.builtin.file:
path: "/opt/log"
state: directory
mode: "0777"
owner: "{{ front_user }}"
group: "{{ front_group }}"
- name: Copy frontend build
ansible.builtin.copy:
src: "/home/{{ front_user }}/frontend/dist"
dest: "{{ front_path }}"
remote_src: yes
- name: Apply Nginx template
ansible.builtin.template:
src: templates/nginx.conf.j2
dest: /etc/nginx/sites-available/default
notify: Reload nginx
become_method: sudo
become: true
Итак, пойдем по порядку.
Обновление кэша apt
Первая задача в роли это обновление кэша apt, используя модуль apt:
- name: Update apt cache
ansible.builtin.apt:
update_cache: yes
Advanced Packaging Tool - программа для работы с программными пакетами. Насколько я понимаю, общепринято обновлять кэш apt перед каждой установкой или хотя бы раз в день, чтобы проверить наличие свежих версий пакетов, что нужно установить.
Установка NodeJS
Следующие пять задач отвечают за установку NodeJS, который понадобиться для установки зависимостей и сборки приложения.
Первая из пяти задач проверяет наличие уже загруженного скрипта для установки:
- name: Check that the /tmp/nodesource_setup.sh exists
ansible.builtin.stat:
path: "/tmp/nodesource_setup.sh"
register: nodesource
Я добавил этот шаг из-за ошибок в процессе написания роли. Для этого шага я использую модуль stat. По существу на одном из следующих шагов реализовано удаление скрипта, поэтому сейчас этот шаг не нужен, но я решил его оставить, так как здесь есть пример сохранения результата задачи в переменную для дальнейшего использования. Обратите внимание на поле register
.
Вторая задача скачивает скрипт, используя модуль uri, если его не обнаружили на прошлом шаге:
- name: Download nodejs shell script
ansible.builtin.uri:
url: "https://deb.nodesource.com/setup_23.x"
dest: "/tmp/nodesource_setup.sh"
when: not nodesource.stat.exists
Посмотрите на поле when
, где идет обращение к сохраненной на предыдущем шаге переменной.
Затем запускаем скаченный скрипт, используя модуль command:
- name: Run nodejs shell script
ansible.builtin.command:
cmd: "bash nodesource_setup.sh"
chdir: "/tmp"
Поле cmd
принимает команду к исполнению, а поле chdir
путь до директории, где нужно запустить команду.
После чего идет непосредственно сама установка NodeJS, с помощью apt
:
- name: Install the nodejs
ansible.builtin.apt:
name: nodejs
state: present
Актуальную инструкцию по установке бинарного дистрибутива NodeJS вы можете найти здесь.
Потом я удаляю скаченный скрипт, используя модуль file:
- name: Remove nodejs shell script
ansible.builtin.file:
path: "/tmp/nodesource_setup.sh"
state: absent
Установка Nginx
Для установки Nginx также использую модуль apt
:
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: latest
install_recommends: yes
notify: Start nginx
Но здесь также нужно обратить внимание на поле notify
. Это поле запускает обработчик при успешном изменении, что позволяет сделать что-то дополнительно. В этом случае запускается обработчик Start nginx, который лежит в директории handlers
:
- name: Start nginx
systemd:
name: nginx
enabled: yes
state: started
systemd — подсистема инициализации и управления службами в Linux. Представьте, что вы включаете свой компьютер, и он автоматически запускает все программы и процессы, которые вам нужны для работы. Система инициализации делает это за вас, чтобы вам не пришлось вручную запускать каждую программу. То есть здесь мы пишем, чтобы ansible запустил nginx, используя его systemd-unit.
Установка Git
Используя apt
, устанавливаю Git:
- name: Install Git
ansible.builtin.apt:
name: git
state: latest
install_recommends: yes
Создание пользователя и директории для проекта
После установки необходимых пакетов, нужно создать пользователя и директорию под проект. Для этого использую модули user и file
:
- name: Add user {{ front_user }}
ansible.builtin.user:
name: "{{ front_user }}"
groups: "{{ front_groups }}"
shell: /sbin/bash
create_home: true
- name: Add start dir
ansible.builtin.file:
path: "/home/{{ front_user }}"
state: directory
mode: "0755"
owner: "{{ front_user }}"
group: "{{ front_group }}"
- name: Add {{ front_path }} dir
ansible.builtin.file:
path: "{{ front_path }}"
state: directory
mode: "0755"
owner: "{{ front_user }}"
group: "{{ front_group }}"
В первую очередь создаю пользователя и группу пользователей, а также с помощью поля shell
указываю ему интерфейс для работы с ним, а также с помощью поля create_home
, сообщаю, что нужно создать домашнюю директорию пользователя.
Дальше создаю директории, в которых буду собирать проект, а потом размещать с помощью nginx.
Загрузка и сборка проекта
Используя модули git и command, скачиваю и собираю проект:
- name: Clone frontend repo
ansible.builtin.git:
repo: "{{ front_repo_url }}"
dest: "/home/{{ front_user }}/frontend"
version: "{{ front_version }}"
force: yes
# accept_hostkey: yes
# key_file: /home/ansible/.ssh/id_rsa
- name: Install npm packages
ansible.builtin.command:
cmd: "npm install"
chdir: "/home/{{ front_user }}/frontend"
- name: Build frontend
ansible.builtin.command:
cmd: "npm run build"
chdir: "/home/{{ front_user }}/frontend"
- name: Copy frontend build
ansible.builtin.copy:
src: "/home/{{ front_user }}/frontend/dist"
dest: "{{ front_path }}"
remote_src: yes
Применение шаблона Nginx
Для раздачи статики применю простенькую nginx-конфигурацию, с помощью модуля template:
- name: Apply Nginx template
ansible.builtin.template:
src: templates/nginx.conf.j2
dest: /etc/nginx/sites-available/default
notify: Reload nginx
А вот и сам шаблон:
server {
listen 80;
root {{ front_path }}/dist;
index index.html index.htm;
server_name _;
location / {
default_type "text/html";
try_files $uri.html $uri $uri/ =404;
}
}
После применения шаблона перезагружаю nginx с помощью notify
и запуска обработчика Reload nginx
:
- name: Reload nginx
systemd:
name: nginx
state: reloaded
Если сервис на этот момент не был запущен, то эта задача запустит его.
Результат
Чтобы применить данную конфигурацию нужно в файле run.sh
назначить переменной host
IP сервера, получить который можно, применив конфигурацию terraform из прошлой статьи. После применения Ansible конфигурации можно ввести в браузере IP-адрес и увидеть развернутое приложение.
На этом все. Спасибо всем, кто дочитал до конца!)