Пришло время продолжить цикл статей о запуске личного блога. На очереди конфигурация сервера с помощью 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-адрес и увидеть развернутое приложение.
На этом все. Спасибо всем, кто дочитал до конца!)
