Допомога по Ansible

управління конфігурацією оркестру
Це практичний посібник познайомить вас з Ansible. Вам знадобиться віртуальна або реальна машина, яка буде виступати в ролі вузла для Ansible. Оточення для Vagrant йде в комплекті з цим посібником.
Ansible — це програмне рішення для віддаленого управління конфігураціями. Воно дозволяє налаштовувати віддалені машини. Головна його відмінність від інших подібних систем в тому, що Ansible використовує існуючу інфраструктуру SSH, в той час як інші (chef, puppet та ін.) вимагають установки спеціального PKI-оточення.
Допомога покриває такі теми:
  1. Установка Ansible і Vagrant
  2. Файл инвенторизации
  3. Модулі shell, copy, збір фактів, змінні
  4. Запуск на групу хостів
  5. Плейбуки
  6. Приклад: піднімаємо кластер, встановлюємо і налаштовуємо Apache і балансувальник навантажень HAproxy
  7. Обробка помилок, відкат
  8. Шаблони конфігурації
  9. Ролі
Ansible використовує так званий push mode: конфігурація «проштовхується» (push) з головної машини. Інші CM-системи зазвичай надходять навпаки – вузли «тягнуть» (pull) конфігурацію з головної машини.
Цей режим цікавий тому що вам не потрібно мати публічно доступну головну машину для віддаленого налаштування вузлів; це вузли повинні бути доступні (пізніше ми побачимо, що приховані сайти також можуть отримувати конфігурацію).
Що потрібно для Ansible
Необхідні наступні Python-модулі
  • python-yaml
  • python-jinja2
Debian/Ubuntu запустіть:
sudo apt-get install python-yaml python-jinja2 python-paramiko python-crypto

У вас також повинна бути пара ключів в ~/.ssh.
Установка Ansible
З исходников
Гілка devel завжди стабільна, тому що використовуємо її. Можливо, вам потрібно буде встановити git (
sudo apt-get install git
на Debian/Ubuntu).
git clone git://github.com/ansible/ansible.git
cd ./ansible

Тепер можна завантажити оточення Ansible.
source ./hacking/env-setup

deb пакета
sudo apt-get install make fakeroot cdbs python-support
git clone git://github.com/ansible/ansible.git
cd ./ansible
make deb
sudo dpkg -i ../ansible_1.1_all.deb (version may vary)

В цьому посібнику припускається, що ви використовували саме цей спосіб.
Установка Vagrant
Vagrant дозволяє з легкістю створювати віртуальні машини і запускати їх на VirtualBox. Vagrantfile йде в комплекті із посібником.
Щоб запустити Vagrant вам потрібно встановити:
Тепер ініціалізувати віртуальну машину за допомогою наступної команди. Майте на увазі, що вам не потрібно завантажувати будь-якої "box" вручну. Ця допомога вже містить готовий
Vagrantfile
, він містить все, що потрібно для роботи.
vagrant up

і налийте собі кави (якщо ви використовуєте vagrant-hostmaster, то вам потрібно буде ввести пароля root). Якщо щось пішло не так, загляньте в туторіал по Vagrant'у.
Додавання SSH-ключів на віртуальній машині
Щоб продовжити, вам потрібно додати свої ключі в
authorized_keys
root'а на віртуальній машині. Це не обов'язково (Ansible може використовувати sudo та авторизацію по паролю), але так буде набагато простіше.
Ansible ідеально підходить для цього завдання, тому використовуємо його. Однак, я не буду поки нічого пояснювати. Просто довіртеся мені.
ansible-playbook -c paramiko -i step-00/hosts step-00/setup.yml --ask-pass --sudo

В якості пароля введіть vagrant. Якщо виникнуть помилки "Connections refused", то перевірте налаштування брандмауера.
Тепер додайте свої ключі ssh-agent (
ssh-add
).
Inventory
Тепер нам потрібно підготувати файл inventory. Місце за замовчуванням це
/etc/ansible/hosts
.
Але ви можете налаштувати Ansible так, щоб використовувався інший шлях. Для цього використовується змінна оточення (
ANSIBLE_HOSTS
) або прапор
i
.
Ми створили такий файл inventory:
host0.example.org ansible_ssh_host=192.168.33.10 ansible_ssh_user=root
host1.example.org ansible_ssh_host=192.168.33.11 ansible_ssh_user=root
host2.example.org ansible_ssh_host=192.168.33.12 ansible_ssh_user=root

ansible_ssh_host
це спеціальна мінлива, яка містить IP-адресу вузла, до якого буде створюватися з'єднання. В даному випадку вона не обов'язкова, якщо ви використовуєте gem vagrant-hostmaster. Також, вам потрібно буде міняти IP-адреси, якщо ви встановлювали і налаштовували свою віртуальну машину з іншими адресами.
ansible_ssh_user
це ще одна спеціальна мінлива яка говорить Ansible'у підключатися під вказаним записом (юзером). За замовчуванням Ansible використовує ваш поточний аккаунт, або інше значення за замовчуванням, вказане в ~/.ansible.cfg (
remote_user
).
Перевірка
Тепер, коли Ansible встановлено, давайте перевіримо, що все працює:
ansible -m ping all -i step-01/hosts

Тут Ansible спробує запустити модуль
ping
(докладніше про модулі пізніше) на кожному хості. Висновок повинен бути приблизно таким:
host0.example.org | success >> {
"changed": false,
"ping": "pong"
}

host1.example.org | success >> {
"changed": false,
"ping": "pong"
}

host2.example.org | success >> {
"changed": false,
"ping": "pong"
}

Відмінно! Всі три хоста живі і здорові, і Ansible може спілкуватися з ними.
Спілкування з вузлами
Тепер ми готові. Давайте пограємо з вже знайомої нам командою з попереднього розділу:
ansible
. Ця команда – одна з трьох команд, яку Ansible використовує для взаємодії з вузлами.
Зробимо що-небудь корисне
В минулій команді
m ping
означав «використовуй модуль ping». Це один з безлічі модулів, доступних в Ansible. Модуль
ping
дуже простий, він не вимагає ніяких аргументів. Модулі, що вимагають аргументів, можуть отримати їх через
a
. Давайте поглянемо на декілька модулів.
Модуль shell
Цей модуль дозволяє запускати shell-команди на віддаленому вузлі:
ansible -i step-02/hosts -m shell -a 'uname -a' host0.example.org

Висновок повинен бути зразок:
host0.example.org | success | rc=0 >>
Linux host0.example.org 3.2.0-23-generic-pae #36-Ubuntu SMP Tue Apr 10 22:19:09 2012 UTC i686 i686 i386 GNU/Linux

Легко!
Модуль copy
Модуль
copy
дозволяє копіювати файл з керуючої машини на віддалений вузол. Уявімо, що нам потрібно скопіювати наш
/etc/motd
на
/tmp
вузла:
ansible -i step-02/hosts -m copy -a 'src=/etc/motd dest=/tmp/' host0.example.org

Висновок:
host0.example.org | success >> {
"changed": true,
"dest": "/tmp/motd",
"group": "root",
"md5sum": "d41d8cd98f00b204e9800998ecf8427e",
"mode": "0644",
"owner": "root",
"size": 0,
"src": "/root/.ansible/tmp/ansible-1362910475.9-246937081757218/motd",
"state": "file"
}

Ansible (точніше, модуль copy, запущений на сайті) відповів купою корисної інформації у форматі JSON. Пізніше ми побачимо, як це можна використовувати.
У Ansible є величезний
список модулів, який покриває практично все, що можна робити в системі. Якщо ви не знайшли відповідного модуля, то написання свого модуля – досить проста задача (і не обов'язково писати його на Python, головне, щоб він розумів JSON).
Багато хостів, одна команда
Все, що було вище – чудово, але нам потрібно управляти безліччю хостів. Давайте спробуємо. Припустимо, ми хочемо зібрати факти про вузол і, наприклад, хочемо дізнатися, яка версія Ubuntu встановлена на вузлах. Це досить легко:
ansible -i step-02/hosts -m shell -a 'grep DISTRIB_RELEASE /etc/lsb-release all

all
означає «всі хости в файлі inventory». Висновок буде приблизно таким:
host1.example.org | success | rc=0 >>
DISTRIB_RELEASE=12.04

host2.example.org | success | rc=0 >>
DISTRIB_RELEASE=12.04

host0.example.org | success | rc=0 >>
DISTRIB_RELEASE=12.04

Більше фактів
Легко і просто. Однак, якщо нам потрібно більше інформації (IP-адреси, розміри ОЗУ, тощо), такий підхід може швидко виявитися незручним. Рішення – використовувати модуль
setup
. Він спеціалізується на зборі фактів з вузлів.
Спробуйте:
ansible -i step-02/hosts -m setup host0.example.org

відповідь:
"ansible_facts": {
"ansible_all_ipv4_addresses": [
"192.168.0.60"
],
"ansible_all_ipv6_addresses": [],
"ansible_architecture": "x86_64",
"ansible_bios_date": "01/01/2007",
"ansible_bios_version": "Bochs"
},
---snip---
"ansible_virtualization_role": "guest",
"ansible_virtualization_type": "kvm"
},
"changed": false,
"verbose_override": true

Висновок був скорочений для простоти, але ви можете дізнатися багато цікавого з цієї інформації. Ви також можете фільтрувати ключі, якщо вас цікавить щось конкретне.
Наприклад, вам потрібно дізнатися, скільки пам'яті доступно на всіх хостах. Це легко: запустіть
ansible -i step-02/hosts -m setup -a 'filter=ansible_memtotal_mb' all
:
host2.example.org | success >> {
"ansible_facts": {
"ansible_memtotal_mb": 187
},
"changed": false,
"verbose_override": true
}

host1.example.org | success >> {
"ansible_facts": {
"ansible_memtotal_mb": 187
},
"changed": false,
"verbose_override": true
}

host0.example.org | success >> {
"ansible_facts": {
"ansible_memtotal_mb": 187
},
"changed": false,
"verbose_override": true
}

Зауважте, що вузли відповіли не в тому порядку, в якому вони відповідали вище. Ansible спілкується з хостами паралельно!
до Речі, при використанні модуля
setup
можна вказувати
*
у вираженні
filter=
. Як shell.
Вибір хостів
Ми бачили, що
all
означає «всі хости», але в Ansible є
купа інших способів вибирати хости:
  • host0.example.org:host1.example.org
    буде запущений на host0.example.org і на
    host1.example.org
  • host*.example.org
    буде запущений на всіх хостах, назви яких починаються з 'host' і закінчується на '.example.org' (теж як shell)
Угруповання хостів
Хости в inventory можна групувати. Наприклад, можна створити групу
debian
, групу
web-сервери
, групу
production
і так далі.
[debian]
host0.example.org
host1.example.org
host2.example.org

Можна навіть скоротити:
[debian]
host[0-2].example.org

Якщо хочете ставити дочірні групи, використовувати
[groupname:children]
і додайте дочірні групи у нього. Наприклад, у нас є різні дистрибутиви Лінукса, їх можна організувати наступним чином:
[ubuntu]
host0.example.org

[debian]
host[1-2].example.org

[linux:children]
ubuntu
debian

Установка змінних
Ви можете додавати змінні для хостів в кількох місцях: в файлі inventory, файли змінних хостів, файли змінних груп та ін
Зазвичай я ставлю всі змінні в файлах змінних груп/хостів (докладніше про це пізніше). Однак, найчастіше я використовую змінні безпосередньо у файлі inventory, наприклад,
ansible_ssh_host
, яка визначає IP-адреса хоста. За замовчуванням Ansible резолвит імена хостів при з'єднанні по SSH. Але коли ви инициализируете хост, він, можливо, ще не має IP-адреси.
ansible_ssh_host
буде корисний у такому випадку.
При використанні команди
ansible-playbook
(а не звичайної команди
ansible
), змінні можна задавати за допомогою прапора
--extra-vars
або
e
). Про команду
ansible-playbook
ми поговоримо в наступному кроці.
ansible_ssh_port
, як ви могли здогадатися, використовується, щоб вказати порт з'єднання по SSH.
[ubuntu]
host0.example.org ansible_ssh_host=192.168.0.12 ansible_ssh_port=2222

Ansible шукає додаткові змінні файлів змінних груп і хостів. Він буде шукати ці файли в директоріях
group_vars
та
host_vars
, усередині директорії, де розташований головний файл inventory.
Ansible буде шукати файли по імені. Наприклад, при використанні згаданого раніше файлі inventory, Ansible буде шукати змінні
host0.example.org
у файлах:
  • group_vars/linux
  • group_vars/ubuntu
  • host_vars/host0.example.org
Якщо цих файлів не існує – нічого не станеться, але якщо вони існують – вони будуть використані.
Тепер, коли ми познайомилися з модулями, інвентаризацією та змінними, давайте, нарешті, дізнаємося про справжню мощі Ansible з плейбуками.
Плейбуки Ansible
Концепція плейбуков дуже проста: це просто набір команд Ansible (завдань, tasks), схожих на ті, що ми виконували з утилітою
ansible
. Ці завдання спрямовані на конкретні набори вузлів/груп.
Приклад з Apache (a.k.a. "Hello World!" в Ansible)
Продовжуємо з допущенням, що ваш файл inventory виглядає так (назвемо його
hosts
):
[web]
host1.example.org

і всі хости — це системи на основі Debian.
Примітка: пам'ятайте, що ви можете (і в нашому вправі ми робимо це) використовувати
ansible_ssh_host
щоб поставити реальний IP-адреса хоста. Ви також можете змінювати inventory і використовувати реальний hostname. У будь-якому випадку, використовуйте машину, з якої безпечно експериментувати. На реальних хостах ми також додаємо
ansible_ssh_user=root
щоб уникнути потенційних проблем з різними конфігураціями за замовчуванням.
Давайте зберемо плейбук, який встановить Apache на машини групи
web
.
hosts: web
завдання:
- name: Installs apache web server
apt: pkg=apache2 state=installed update_cache=true

Нам потрібно лише сказати, що ми хочемо зробити, використовуючи правильні модулі Ansible. Тут ми використовуємо модуль apt, який може встановлювати пакунки Debian. Ми також просимо цей модуль оновити кеш.
Нам потрібно ім'я для цієї задачі. Це не обов'язково, але бажано для вашої ж зручності.
Ну, в цілому було досить легко! Тепер можна запустити плейбук (назвемо його
apache.yml
):
ansible-playbook -i step-04/hosts -l host1.example.org step-04/apache.yml

Тут
step-04/hosts
це файл inventory,
l
обмежує запуск хостом
host1.example.org
,
а
apache.yml
це наш плейбук.
При запуску команди буде висновок подібний цьому:
PLAY [web] *********************

GATHERING FACTS *********************
ok: [host1.example.org]

TASK: [Installs apache web server] *********************
changed: [host1.example.org]

PLAY RECAP *********************
host1.example.org : ok=2 changed=1 unreachable=0 failed=0 

Примітка: можливо, ви помітите проходить повз корову, якщо у вас встановлений
cowsay
:-) Якщо вона вам не подобається, можна відключити її так:
export ANSIBLE_NOCOWS="1"
.
Давайте проаналізуємо висновок рядок за рядком.
PLAY [web] *********************

Ansible говорить нам, що play виконується в групі
web
. Play — це набір інструкцій Ansible, пов'язаних з хостом. Якщо б у нас був інший
host: blah
в плейбуке, він би теж вивівся (але після того, як перший play завершений).
GATHERING FACTS *********************
ok: [host1.example.org]

Пам'ятаєте, коли ми використовували модуль
setup
? Перед кожним відтворенням Ansible запускає його на кожному хості і збирає факти. Якщо це не потрібне (скажімо, тому що вам не потрібна ніяка інформація про хості) можна додати
gather_facts: no
під рядком хоста (на тому ж рівні, де знаходиться
tasks:
).
TASK: [Installs apache web server] *********************
changed: [host1.example.org]

Тепер саме головне: наша перша і єдина задача запущена, і, так як там сказано
changed
, ми знаємо, що вона змінила щось на хості
host1.example.org
.
PLAY RECAP *********************
host1.example.org : ok=2 changed=1 unreachable=0 failed=0

Нарешті, Ansible виводить вижимки того, що сталося: два завдання були виконані, і одна з них змінила щось на хості (це була наша задача apache; модуль setup нічого не меняяет).
Давайте запустимо це ще раз і подивимося, що станеться:
$ ansible-playbook -i step-04/hosts -l host1.example.org step-04/apache.yml

PLAY [web] *********************

GATHERING FACTS *********************
ok: [host1.example.org]

TASK: [Installs apache web server] *********************
ok: [host1.example.org]

PLAY RECAP *********************
host1.example.org : ok=2 changed=0 unreachable=0 failed=0 

Тепер changed дорівнює '0'. Це абсолютно нормально і є однією з головних особливостей Ansible: плейбук буде робити що-то, тільки якщо є що робити. Це називається идемпотентностью. Це означає, що можна запускати плейбук скільки завгодно разів, але в результаті ми будемо мати машину в одному і тому ж стані (ну, тільки якщо ви не будете шаленіти з модулем
shell
, але тут Ansible вже не зможе нічого вдіяти).
Покращуємо набір apache
Ми встановили apache, давайте тепер налаштуємо virtualhost.
Поліпшення плейбука
Нам потрібен лише один віртуальний хост на сервері, але ми хочемо змінити дефолтний на щось більш конкретне. Тому нам доведеться видалити поточний virtualhost, відправити наш virtualhost, активувати її і перезавантажте apache.
Давайте створимо директиорию під назвою
files
і додамо нашу конфігурацію для host1.example.org, назвемо її
awesome-app
:
<VirtualHost *:80>
DocumentRoot /var/www/awesome-app

Options -Indexes

ErrorLog /var/log/apache2/error.log
TransferLog /var/log/apache2/access.log
</VirtualHost>

Тепер невелике обнулення плейбука і все готово:
hosts: web
завдання:
- name: Installs apache web server
apt: pkg=apache2 state=installed update_cache=true

- name: Push default virtual host configuration
copy: src=files/awesome-app dest=/etc/apache2/sites-available/ mode=0640

- name: Deactivates the default virtualhost
command: a2dissite default

- name: Deactivates the default ssl virtualhost
command: a2dissite default-ssl

- name: Activates our virtualhost
command: a2ensite awesome-app
notify:
- restart apache

handlers:
- name: restart apache
service: name=apache2 state=restarted

Поїхали:
$ ansible-playbook -i step-05/hosts -l host1.example.org step-05/apache.yml

PLAY [web] *********************

GATHERING FACTS *********************
ok: [host1.example.org]

TASK: [Installs apache web server] *********************
ok: [host1.example.org]

TASK: [Push default virtual host configuration] *********************
changed: [host1.example.org]

TASK: [Deactivates the default virtualhost] *********************
changed: [host1.example.org]

TASK: [Deactivates the default ssl virtualhost] *********************
changed: [host1.example.org]

TASK: [Activates our virtualhost] *********************
changed: [host1.example.org]

NOTIFIED: [restart apache] *********************
changed: [host1.example.org]

PLAY RECAP *********************
host1.example.org : ok=7 changed=5 unreachable=0 failed=0 

Круто! Ну, якщо замислитися, ми трохи випереджаємо події. Не потрібно перевірити коректність конфігурації перед тим, як перезапускати apache? Щоб не порушувати працездатність сервісу у разі якщо конфігурація містить помилку.
Перезапуск у разі помилки конфігурації
Ми встановили apache, змінили virtualhost і перезапустили сервер. Але що, якщо ми хочемо перезавантажувати сервер тільки коли конфігурація коректна?
Відкочуємося, якщо є проблеми
Ansible містить класну особливість: він зупинить всю обробку, якщо щось пішло не так. Ми використовуємо цю особливість щоб зупинити плейбук, коли конфігурація не валидна.
Давайте змінимо файл конфігурації віртуального хосту
awesome-app
і зламаємо його:
<VirtualHost *:80>
RocumentDoot /var/www/awesome-app

Options -Indexes

ErrorLog /var/log/apache2/error.log
TransferLog /var/log/apache2/access.log
</VirtualHost>

Як я сказав, якщо завдання не може здійснитися, обробка зупиняється. Так що потрібно впевнитися в валідності конфігурації перед перезапуском сервера. Ми також почнемо з додавання віртуального хосту видалення дефолтного віртуального хоста, так що наступний перезапуск (можливо, зроблений безпосередньо на сервері) не зламає apache.
Треба було зробити це на самому початку. Так як ми вже запускали цей плейбук, дефолтний віртуальний хост вже деактивовано. Не проблема: цей плейбук можна використовувати на інших невинних хостах, так що давайте захистимо їх.
hosts: web
завдання:
- name: Installs apache web server
apt: pkg=apache2 state=installed update_cache=true

- name: Push future default virtual host configuration
copy: src=files/awesome-app dest=/etc/apache2/sites-available/ mode=0640

- name: Activates our virtualhost
command: a2ensite awesome-app

- name: Check that our config is valid
command: apache2ctl configtest

- name: Deactivates the default virtualhost
command: a2dissite default

- name: Deactivates the default ssl virtualhost
command: a2dissite default-ssl

notify:
- restart apache

handlers:
- name: restart apache
service: name=apache2 state=restarted

Поїхали:
$ ansible-playbook -i step-06/hosts -l host1.example.org step-06/apache.yml

PLAY [web] *********************

GATHERING FACTS *********************
ok: [host1.example.org]

TASK: [Installs apache web server] *********************
ok: [host1.example.org]

TASK: [Push future default virtual host configuration] *********************
changed: [host1.example.org]

TASK: [Activates our virtualhost] *********************
changed: [host1.example.org]

TASK: [Check that our config is valid] *********************
failed: [host1.example.org] => {"changed": true, "cmd": ["apache2ctl", "configtest"], "delta": "0:00:00.045046", "end": "2013-03-08 16:09:32.002063", "rc": 1, "start": "2013-03-08 16:09:31.957017"}
stderr: Syntax error on line 2 of /etc/apache2/sites-enabled/awesome-app:
Invalid command 'RocumentDoot', perhaps misspelled or defined by a module not included in the server configuration
stdout: Action 'configtest' failed.
The Apache error log may have more information.

FATAL: all hosts have already failed -- aborting

PLAY RECAP *********************
host1.example.org : ok=4 changed=2 unreachable=0 failed=1 

Як ви помітили,
apache2ctl
повертає код помилки 1. Ansible бачить це і зупиняє роботу. Відмінно!
Ммм, хоча ні, не відмінно… Наш віртуальний хост все одно був доданий. При будь-якій наступній спробі перезапуску Apache буде лаятися на конфігурацію і вимикатися. Так що нам потрібен спосіб відловлювати помилки і повертатися до робочого стану.
Використання умов
Ми встановили Apache, додали віртуальний хост і перезапустили сервер. Але ми хочемо повернутися до робочого стану, якщо щось пішло не так.
Повернення при проблемах
Тут немає ніякої магії. Минула помилка – не вина Ansible. Це не система резервного копіювання, і вона не вміє відмовляти все до минулих станів. Безпека плейбуков – ваша відповідальність. Ansible просто не знає, як скасувати ефект
a2ensite awesome-app
.
Як було сказано раніше, якщо завдання не може здійснитися – обробка зупиняється… але ми можемо прийняти помилку (і нам потрібно це робити). Так ми і зробимо: продовжимо обробку в разі помилки, але тільки щоб повернути все до робочого стану.
hosts: web
завдання:
- name: Installs apache web server
apt: pkg=apache2 state=installed update_cache=true

- name: Push future default virtual host configuration
copy: src=files/awesome-app dest=/etc/apache2/sites-available/ mode=0640

- name: Activates our virtualhost
command: a2ensite awesome-app

- name: Check that our config is valid
command: apache2ctl configtest
register: result
ignore_errors: True

- name: Rolling back - Restoring old default virtualhost
command: a2ensite default
when: result|failed

- name: Rolling back - Removing our virtualhost
command: a2dissite awesome-app
when: result|failed

- name: Rolling back - Ending playbook
fail: msg="Configuration file is not valid. Please check that before re-running the playbook."
when: result|failed

- name: Deactivates the default virtualhost
command: a2dissite default

- name: Deactivates the default ssl virtualhost
command: a2dissite default-ssl

notify:
- restart apache

handlers:
- name: restart apache
service: name=apache2 state=restarted

Ключове слово
register
записує висновок команди
apache2ctl configtest
(exit
status, stdout, stderr, ...), і
when: result|failed
перевіряє, чи має змінна
(
result
) статус failed.
Поїхали:
$ ansible-playbook -i step-07/hosts -l host1.example.org step-07/apache.yml

PLAY [web] *********************

GATHERING FACTS *********************
ok: [host1.example.org]

TASK: [Installs apache web server] *********************
ok: [host1.example.org]

TASK: [Push future default virtual host configuration] *********************
ok: [host1.example.org]

TASK: [Activates our virtualhost] *********************
changed: [host1.example.org]

TASK: [Check that our config is valid] *********************
failed: [host1.example.org] => {"changed": true, "cmd": ["apache2ctl", "configtest"], "delta": "0:00:00.051874", "end": "2013-03-10 10:50:17.714105", "rc": 1, "start": "2013-03-10 10:50:17.662231"}
stderr: Syntax error on line 2 of /etc/apache2/sites-enabled/awesome-app:
Invalid command 'RocumentDoot', perhaps misspelled or defined by a module not included in the server configuration
stdout: Action 'configtest' failed.
The Apache error log may have more information.
...ignoring

TASK: [Rolling back - Restoring old default virtualhost] *********************
changed: [host1.example.org]

TASK: [Rolling back - Removing our virtualhost] *********************
changed: [host1.example.org]

TASK: [Rolling back - Ending playbook] *********************
failed: [host1.example.org] => {"failed": true}
msg: Configuration file is not valid. Please check that before re-running the playbook.

FATAL: all hosts have already failed -- aborting

PLAY RECAP *********************
host1.example.org : ok=7 changed=4 unreachable=0 failed=1 

Здається, все працює як треба. Давайте спробуємо перезапустити apache:
$ ansible -i step-07/hosts -m service -a 'name=apache2 state=restarted' host1.example.org
host1.example.org | success >> {
"changed": true,
"name": "apache2",
"state": "started"
}

Тепер наш Apache захищений від помилок конфігурації. Пам'ятайте, змінними можна користуватися практично скрізь, так що цей плейбук можна використовувати для apache і в інших випадках. Напишіть один раз і користуйтеся скрізь.
Деплоим сайт за допомогою Git
Ми встановили Apache, додали віртуальний хост і безпечно перезапустили сервер. Тепер давайте використаємо модуль git щоб зробити деплой програми.
Модуль git
Ну, чесно кажучи, тут все буде просто, нічого нового. Модуль
git
це просто ще один модуль. Але давайте спробуємо що-небудь цікаве. А пізніше це стане в нагоді, коли ми будемо працювати з
ansible-pull
.
Віртуальний хост задано, але нам потрібно внести декілька змін аби закінчити деплой. Ми деплоим додаток на PHP, так що потрібно встановити пакет
libapache2-mod-php5
. Також потрібно встановити сам
git
, так як, очевидно, модуль git вимагає його наявності.
Можна зробити так:
...
- name: Installs apache web server
apt: pkg=apache2 state=installed update_cache=true

- name: Installs php5 module
apt: pkg=libapache2-mod-php5 state=installed

- name: Installs git
apt: pkg=git state=installed
...

але в Ansible є спосіб краще. Він може проходити по набору елементів і використовувати кожен у визначеному дії, ось так:
hosts: web
завдання:
- name: Updates apt cache
apt: update_cache=true

- name: Installs packages necessary
apt: pkg={{ item }} state=latest
with_items:
- apache2
- libapache2-mod-php5
- git

- name: Push future default virtual host configuration
copy: src=files/awesome-app dest=/etc/apache2/sites-available/ mode=0640

- name: Activates our virtualhost
command: a2ensite awesome-app

- name: Check that our config is valid
command: apache2ctl configtest
register: result
ignore_errors: True

- name: Rolling back - Restoring old default virtualhost
command: a2ensite default
when: result|failed

- name: Rolling back - Removing out virtualhost
command: a2dissite awesome-app
when: result|failed

- name: Rolling back - Ending playbook
fail: msg="Configuration file is not valid. Please check that before re-running the playbook."
when: result|failed

- name: Deploy our awesome application
git: repo=https://github.com/leucos/ansible-туто-demosite.git dest=/var/www/awesome-app
tags: deploy

- name: Deactivates the default virtualhost
command: a2dissite default

- name: Deactivates the default ssl virtualhost
command: a2dissite default-ssl
notify:
- restart apache

handlers:
- name: restart apache
service: name=apache2 state=restarted

Поїхали:
$ ansible-playbook -i step-08/hosts -l host1.example.org step-08/apache.yml

PLAY [web] *********************

GATHERING FACTS *********************
ok: [host1.example.org]

TASK: [Updates apt cache] *********************
ok: [host1.example.org]

TASK: [Installs packages necessary] *********************
changed: [host1.example.org] => (item=apache2,libapache2-mod-php5,git)

TASK: [Push future default virtual host configuration] *********************
changed: [host1.example.org]

TASK: [Activates our virtualhost] *********************
changed: [host1.example.org]

TASK: [Check that our config is valid] *********************
changed: [host1.example.org]

TASK: [Rolling back - Restoring old default virtualhost] *********************
skipping: [host1.example.org]

TASK: [Rolling back - Removing out virtualhost] *********************
skipping: [host1.example.org]

TASK: [Rolling back - Ending playbook] *********************
skipping: [host1.example.org]

TASK: [Deploy our awesome application] *********************
changed: [host1.example.org]

TASK: [Deactivates the default virtualhost] *********************
changed: [host1.example.org]

TASK: [Deactivates the default ssl virtualhost] *********************
changed: [host1.example.org]

NOTIFIED: [restart apache] *********************
changed: [host1.example.org]

PLAY RECAP *********************
host1.example.org : ok=10 changed=8 unreachable=0 failed=0 

Тепер можна перейти на http://192.168.33.11 і побачити кошеня і ім'я сервера.
Рядок
tags: deploy
дозволяє запустити певну порцію плейбука. Припустимо, ви запушили нову версію сайту. Ви хочете прискорити процес і запустити тільки ту частину, яка відповідальна за деплой. Це можна зробити за допомогою тегів. Природно, "deploy" — це просто рядок, можна задавати будь-яку. Давайте подивимося, як це можна використовувати:
$ ansible-playbook -i step-08/hosts -l host1.example.org step-08/apache.yml -t deploy
X11 forwarding request failed on channel 0
PLAY [web] *****
GATHERING FACTS *****
ok: [host1.example.org]
TASK: [Deploy our awesome application] *****
changed: [host1.example.org]
PLAY RECAP *****
host1.example.org: ok=2 changed=1 unreachable=0 failed=0
Додаємо ще один веб-сервер
У нас є один веб-сервер. Ми хочемо два.
Оновлення inventory
Ми очікуємо напливу трафіку, так що давайте додамо ще один веб-сервер і балансувальник, який ми налаштуємо в наступному кроці. Давайте закінчимо з inventory:
[web]
host1.example.org ansible_ssh_host=192.168.33.11 ansible_ssh_user=root
host2.example.org ansible_ssh_host=192.168.33.12 ansible_ssh_user=root

[haproxy]
host0.example.org ansible_ssh_host=192.168.33.10 ansible_ssh_user=root

Пам'ятаєте, тут ми вказуємо
ansible_ssh_host
тому що хост має не той IP, що очікується. Можна додати ці хости до себе в
/etc/hosts
або використовувати реальні імена (що ви будете робити в звичайній ситуації).
Збірка другого веб-сервера
Ми не даремно напружувалися перед цим. Деплой другого сервера дуже простий:
$ ansible-playbook -i step-09/hosts step-09/apache.yml

PLAY [web] *********************

GATHERING FACTS *********************
ok: [host2.example.org]
ok: [host1.example.org]

TASK: [Updates apt cache] *********************
ok: [host1.example.org]
ok: [host2.example.org]

TASK: [Installs packages necessary] *********************
ok: [host1.example.org] => (item=apache2,libapache2-mod-php5,git)
changed: [host2.example.org] => (item=apache2,libapache2-mod-php5,git)

TASK: [Push future default virtual host configuration] *********************
ok: [host1.example.org]
changed: [host2.example.org]

TASK: [Activates our virtualhost] *********************
changed: [host2.example.org]
changed: [host1.example.org]

TASK: [Check that our config is valid] *********************
changed: [host2.example.org]
changed: [host1.example.org]

TASK: [Rolling back - Restoring old default virtualhost] *********************
skipping: [host1.example.org]
skipping: [host2.example.org]

TASK: [Rolling back - Removing out virtualhost] *********************
skipping: [host1.example.org]
skipping: [host2.example.org]

TASK: [Rolling back - Ending playbook] *********************
skipping: [host1.example.org]
skipping: [host2.example.org]

TASK: [Deploy our awesome application] *********************
ok: [host1.example.org]
changed: [host2.example.org]

TASK: [Deactivates the default virtualhost] *********************
changed: [host1.example.org]
changed: [host2.example.org]

TASK: [Deactivates the default ssl virtualhost] *********************
changed: [host2.example.org]
changed: [host1.example.org]

NOTIFIED: [restart apache] *********************
changed: [host1.example.org]
changed: [host2.example.org]

PLAY RECAP *********************
host1.example.org : ok=10 changed=5 unreachable=0 failed=0 
host2.example.org : ok=10 changed=8 unreachable=0 failed=0 

Все, що потрібно, це видалити
l host1.example.org
з командного рядка. Пам'ятайте,
l
дозволяє обмежити хости для запуску. Тепер обмеження не потрібно, і запуск відбудеться на всіх машинах групи
web
.
Якщо б в групі
web
були інші машини, і нам потрібно було б запустити плейбук тільки на деяких з них, можна було б використовувати, наприклад, таке:
l firsthost:secondhost:...
.
Тепер у нас є чудова ферма веб-серверів, давайте перетворимо її в кластер з допомогою балансувальника навантажень.
Шаблони
Ми будемо використовувати
haproxy
в якості балансувальника. Установка така ж, як з apache. Але конфігурація трохи складніше, тому що нам потрібно вказати список всіх веб-серверів в конфігурації
haproxy
. Як це зробити?
Шаблон конфігурації HAProxy
Ansible використовує Jinja2, систему шаблонів для Python. Всередині Jinja2-шаблону можна використовувати будь-яку змінну, яка визначена Ansible'ом.
Наприклад, якщо потрібно вивести на екран inventory_name хоста, для якого зібраний шаблон, то можна просто написати
{{ inventory_hostname }}
в Jinja2-шаблоні. Або, якщо потрібно вивести IP-адреса першого ethernet-інтерфейсу (про який Ansible знає завдяки модулю
setup
), то можна написати
{{ ansible_eth1['ipv4']['address'] }}
.
Jinja2 також підтримує умови, цикли та інше.
Давайте створимо директорію
templates/
з Jinja-шаблоном всередині. Назвемо його
haproxy.cfg.j2
. Розширення
.j2
даємо прото для зручності, воно не обов'язково.
global
daemon
maxconn 256

defaults
http mode
timeout connect 5000ms
timeout client 50000ms
timeout server 50000ms

listen cluster
bind {{ ansible_eth1['ipv4']['address'] }}:80
http mode
stats enable
balance roundrobin
{% backend for in groups['web'] %}
server {{ hostvars[backend]['ansible_hostname'] }} {{ hostvars[backend]['ansible_eth1']['ipv4']['address'] }} check port 80
{% endfor %}
option httpchk HEAD /index.php HTTP/1.0

Тут є кілька нових для нас деталей.
По-перше,
{{ ansible_eth1['ipv4']['address'] }}
заміниться на IP балансувальника навантаження на eth1.
Далі у нас є цикл. Він використовується для генерації списку бекенд-серверів. Кожен крок циклу відповідає одному хосту з групи
[web]
, і кожен такий хост буде записаний в змінну
backend
. З допомогою фактів хоста для кожного з хостів буде згенерована рядок. Факти всіх хостів доступні через змінну
hostvars
, тому дістати змінні (наприклад, ім'я хоста або IP, як у нашому випадку) з інших хостів дуже легко.
Можна було написати список хостів вручну, у нас їх всього два. Але ми сподіваємося, що популярність змусить нас заводити сотні серверів. Так що, при додаванні або зміні серверів нам потрібно лише оновити групу
[web]
.
HAProxy playbook
найскладніше позаду. Написати плейбук для устновки і конфігурації HAproxy дуже легко:
hosts: haproxy
завдання:
- name: Installs haproxy load balancer
apt: pkg=haproxy state=installed update_cache=yes

- name: Pushes configuration
template: src=templates/haproxy.cfg.j2 dest=/etc/haproxy/haproxy.cfg mode=0640 owner=root group=root
notify:
- restart haproxy

- name: Sets default starting flag to 1
lineinfile: dest=/etc/default/haproxy regexp="^ENABLED" line="ENABLED=1"
notify:
- restart haproxy

handlers:
- name: restart haproxy
service: name=haproxy state=restarted

Виглядає знайоме, правда? Новий модуль тут тільки один:
template
. У нього такі ж аргументи, як у
copy
. А ще ми обмежили цей плейбук групою
haproxy
.
А тепер… спробуємо. У нашому inventory містяться тільки необхідні для кластера хости, тому нам не потрібно робити додаткових обмежень і можна навіть запустити обидва плейбука. Ну, насправді, нам потрібно запускати їх одночасно, так як haproxy-плейбуку потрібні факти двох веб-серверів. Трохи пізніше ми дізнаємося, як уникнути цього.
$ ansible-playbook -i step-10/hosts step-10/apache.yml step-10/haproxy.yml

PLAY [web] *********************

GATHERING FACTS *********************
ok: [host1.example.org]
ok: [host2.example.org]

TASK: [Updates apt cache] *********************
ok: [host1.example.org]
ok: [host2.example.org]

TASK: [Installs packages necessary] *********************
ok: [host1.example.org] => (item=apache2,libapache2-mod-php5,git)
ok: [host2.example.org] => (item=apache2,libapache2-mod-php5,git)

TASK: [Push future default virtual host configuration] *********************
ok: [host2.example.org]
ok: [host1.example.org]

TASK: [Activates our virtualhost] *********************
changed: [host1.example.org]
changed: [host2.example.org]

TASK: [Check that our config is valid] *********************
changed: [host1.example.org]
changed: [host2.example.org]

TASK: [Rolling back - Restoring old default virtualhost] *********************
skipping: [host1.example.org]
skipping: [host2.example.org]

TASK: [Rolling back - Removing out virtualhost] *********************
skipping: [host1.example.org]
skipping: [host2.example.org]

TASK: [Rolling back - Ending playbook] *********************
skipping: [host1.example.org]
skipping: [host2.example.org]

TASK: [Deploy our awesome application] *********************
ok:[host2.example.org]
ok: [host1.example.org]

TASK: [Deactivates the default virtualhost] *********************
changed: [host1.example.org]
changed: [host2.example.org]

TASK: [Deactivates the default ssl virtualhost] *********************
changed: [host2.example.org]
changed: [host1.example.org]

NOTIFIED: [restart apache] *********************
changed: [host2.example.org]
changed: [host1.example.org]

PLAY RECAP *********************
host1.example.org : ok=10 changed=5 unreachable=0 failed=0 
host2.example.org : ok=10 changed=5 unreachable=0 failed=0 

PLAY [haproxy] *********************

GATHERING FACTS *********************
ok: [host0.example.org]

TASK: [Installs haproxy load balancer] *********************
changed: [host0.example.org]

TASK: [Pushes configuration] *********************
changed: [host0.example.org]

TASK: [Sets default starting flag to 1] *********************
changed: [host0.example.org]

NOTIFIED: [restart haproxy] *********************
changed: [host0.example.org]

PLAY RECAP *********************
host0.example.org : ok=5 changed=4 unreachable=0 failed=0 

Ніби все добре. Зайдіть на http://192.168.33.10/ і оцініть результат. Кластер задеплоен! Можна навіть подивитись на статистику HAproxy: http://192.168.33.10/haproxy?stats.
Знову змінні
Отже, ми встановили балансувальник навантаження, і він працює нормально. Ми беремо змінні з фактів і використовуємо їх для генерації конфігурації.
Ansible також підтримує інші види змінних. Ми вже бачили
ansible_ssh_host
у файлі inventory, але тепер використовуємо змінні, що задані у файлі
host_vars
та
group_vars
.
Тонка настройка конфігурації HAProxy
Зазвичай HAProxy перевіряє, чи живі бэкенды. Якщо бекенд не відгукується, то він видаляється з пулу, і HAProxy більше не шле йому запити.
У бэкэндов може бути вказана вага (від 0 до 256). Чим вище вага, тим більше запитів сервер отримає порівняно з іншими серверами. Це корисно, коли вузли відрізняються по потужності і потрібно направити трафік відповідно з цим.
Ми використовуємо змінні для налаштування цих параметрів.
Group-змінні
Інтервал перевірки haproxy буде поставлено у файлі group_vars. Таким чином, усі примірники haproxy успадкують це.
Треба створити файл
group_vars/haproxy
всередині директорії inventory. Назва файлу повинна збігатися з назвою групи, для якої задаються змінні. Якби ми ставили змінні для web групи, то назвали б файл
group_vars/web
.
haproxy_check_interval: 3000
haproxy_stats_socket: /tmp/sock

Ім'я змінної може бути будь-яким. Природно, рекомендується давати осмислені назви, але якихось спеціальних правил немає. Можна робити навіть комплексні змінні (тобто Python dict) ось так:
haproxy:
check_interval: 3000
stats_socket: /tmp/sock

Це справа смаку. Такий підхід дозволяє робити логічний угруповання. Ми поки будемо використовувати прості змінні.
Змінні хоста
З змінними хоста така ж історія, але файли живуть в директорію
host_vars
. Давайте задамо вага бекенду
host_vars/host1.example.com
:
haproxy_backend_weight: 100

та
host_vars/host2.example.com
:
haproxy_backend_weight: 150

Якщо б ми поставили
haproxy_backend_weight
на
group_vars/web
, то він би використовувався за замовчуванням:
змінні з файлу
host_vars
мають пріоритет перед змінними
group_vars
.
Оновлюємо шаблон
Тепер необхідно оновити шаблон, щоб він використовував ці змінні.
global
daemon
maxconn 256
{% if haproxy_stats_socket %}
stats socket {{ haproxy_stats_socket }}
{% endif %}

defaults
http mode
timeout connect 5000ms
timeout client 50000ms
timeout server 50000ms

listen cluster
bind {{ ansible_eth1['ipv4']['address'] }}:80
http mode
stats enable
balance roundrobin
{% backend for in groups['web'] %}
server {{ hostvars[backend]['ansible_hostname'] }} {{ hostvars[backend]['ansible_eth1']['ipv4']['address'] }} check inter {{ haproxy_check_interval }} weight {{ hostvars[backend]['haproxy_backend_weight'] }} port 80
{% endfor %}
option httpchk HEAD /index.php HTTP/1.0

Помітили блок
{% if ...
? Цей блок буде відпрацьований, якщо умова вірно. Так що, якщо ми де-небудь задамо
haproxy_stats_socket
для балансувальника навантаження (можна навіть додати
--extra-vars="haproxy_stats_sockets=/tmp/sock"
при виклику з командного рядка), то блок буде додано до згенерований конфігураційний файл.
Майте на увазі, що такий метод дуже поганий з точки зору безпеки!
Поїхали:
ansible-playbook -i step-11/hosts step-11/haproxy.yml

Ми можемо, але не зобов'язані запускати плейбук apache, тому що нічого не змінилося. Але довелося додати невеликий трюк. Ось оновлений плейбук haproxy:
hosts: web
- hosts: haproxy
завдання:
- name: Installs haproxy load balancer
apt: pkg=haproxy state=installed update_cache=yes

- name: Pushes configuration
template: src=templates/haproxy.cfg.j2 dest=/etc/haproxy/haproxy.cfg mode=0640 owner=root group=root
notify:
- restart haproxy

- name: Sets default starting flag to 1
lineinfile: dest=/etc/default/haproxy regexp="^ENABLED" line="ENABLED=1"
notify:
- restart haproxy

handlers:
- name: restart haproxy
service: name=haproxy state=restarted

Бачите? Ми додали порожній блок для веб-хостів на самому початку. У ньому нічого не відбувається. Але факт його наявності змусить Ansible зібрати факти для групи
web
. Це необхідно, тому що плейбук haproxy використовує факти з цієї групи. Якщо не зробити цього, то Ansible буде лаятися, що ключа
ansible_eth1
не існує.
Мігруючи до ролей!
Тепер, коли всі плейбуки готові, давайте все отрефакторим! Ми переробимо все через ролі. Ролі — це просто ще один спосіб організації файлів, але у них є кілька цікавих можливостей. Не будемо вдаватися в деталі, всі вони описані в
документації Ansible. Моя улюблена фіча — це залежно ролей: роль B може залежати від іншої ролі A. Тому при застосуванні ролі B, автоматично буде застосована роль A.
Структура ролей
Ролі додають трохи «магії» в Ansible: вони припускають особливу організацію файлів. Ролі покладається структурувати певним чином, хоча ви можете робити це як вам завгодно. Тим не менш, якщо дотримуватися угод, вам буде набагато легше створювати модульні плейбуки. Містити код гаразд буде набагато легше. Рубисты називають це "convention over configuration".
Структура файлів для ролей така:
roles
|
|_some_role
|
|_files
| |
| |_file1
| |_...
|
|_templates
| |
| |_template1.j2
| |_...
|
|_tasks
| |
| |_main.yml
| |_some_other_file.yml
| |_ ...
|
|_handlers
| |
| |_main.yml
| |_some_other_file.yml
| |_ ...
|
|_vars
| |
| |_main.yml
| |_some_other_file.yml
| |_ ...
|
|_meta
|
|_main.yml
|_some_other_file.yml
|_ ...

Досить просто.
Файли
main.yml
не обов'язкові. Але якщо вони присутні, то ролі додадуть їх до відпрацювання автоматично. Ці файли можна використовувати для додавання інших тасков і хендлерів.
Зверніть увагу на теку
vars
та
meta
.
vars
потрібна для випадків, коли є купа змінних, пов'язаних з роллю. Але мені особисто не подобається ставити змінні ролі та сценарії безпосередньо. Я вважаю, що змінні повинні бути частиною конфігурації, а сценарії — це структура. Іншими словами, я вважаю сценарії фабриками, а дані — параметрами для фабрик. Тому я волію бачити «дані» (наприклад, змінні) поза ролей і сценаріїв. Тоді мені легше нишпорити ролі і не розкривати занадто багато інформації про нутрощах серверів. Але це справа особистих уподобань. Ansible надає вам вибір.
В каталог
meta
перебувають у залежності, але про це поговоримо наступного разу. Сценарії лежать в теку
roles
.
Створюємо роль Apache
Тепер у нас достатньо знань, щоб створити роль для apache на основі нашого плейбука.
Кілька простих кроків:
  • створити папку ролей і структуру ролі apache
  • винести хендлер apache у файл
    roles/apache/handlers/main.yml
  • перенести файл конфігурації apache
    awesome-app
    на
    roles/apache/files/
  • створити плейбук для ролі
Задаємо структуру
Все просто:
mkdir -p step-12/roles/apache/{tasks,handlers,files}

Тепер копіюємо таски з
apache.yml
на
main.yml
. Файл виглядає так:
name: Updates apt cache
apt: update_cache=true

- name: Installs packages necessary
apt: pkg={{ item }} state=latest
with_items:
- apache2
- libapache2-mod-php5
- git

...

- name: Deactivates the default ssl virtualhost
command: a2dissite default-ssl
notify:
- restart apache

Це не повний текст файлу, а просто ілюстрація. Файл в точності повторює зміст
apache.yml
між
tasks:
та
handlers:
.
Ми також прибрали звернення до теки
files/
та
templates/
в тягаючи. Так як використовується стандартна структура ролей, Ansible сам знає, в які директорії дивитися.
Виносимо хендлер
Треба створити файл
step-12/roles/apache/handlers/main.yml
:
name: restart apache
service: name=apache2 state=restarted

Переносимо файл конфігурації
Ще простіше:
cp step-11/files/awesome-app step-12/roles/apache/files/

Роль apache працює. Але нам потрібен спосіб запустити її.
Створюємо плейбук ролі
Давайте створимо плейбук верхнього рівня для зв'язування хостів і груп хостів з ролями. Назвемо файл
site.yml
, так як нам потрібна загальна конфігурація сайту. Заодно додамо туди
haproxy
:
hosts: web
roles:
- { role: apache }

- hosts: haproxy
roles:
- { role: haproxy }

Зовсім не складно. Тепер давайте створимо роль haproxy:
mkdir -p step-12/roles/haproxy/{tasks,handlers,templates}
cp step-11/templates/haproxy.cfg.j2 step-12/roles/haproxy/templates/

потім винесемо хендлер і видалимо згадка
templates/
.
Спробуємо?:
ansible-playbook -i step-12/hosts step-12/site.yml

Якщо все добре, то ми побачимо "PLAY RECAP":
host0.example.org : ok=5 changed=2 unreachable=0 failed=0
host1.example.org : ok=10 changed=5 unreachable=0 failed=0
host2.example.org : ok=10 changed=5 unreachable=0 failed=0

Ви напевно помітили, що запуск всіх ролей у site.yml займає багато часу. Якщо потрібно зробити зміни тільки для веб-серверів? Легко! Використовуємо limit-прапор:
ansible-playbook -i step-12/hosts -l web step-12/site.yml

На цьому міграція на ролі закінчена.
(Від перекладача: в оригінальному посібнику в майбутньому з'явиться ще як мінімум одна голова. Я додам її в цю публікацію або створю нову).
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.