В начале этого года мы посчитали, что наша Open Source-утилита для сопровождения процессов CI/CD — dapp версии 0.25 — обладает достаточным набором функций и была начата работа над нововведениями. В версии 0.26 появился синтаксис YAML, а Ruby DSL был объявлен классическим (далее перестанет поддерживаться вовсе). В следующей версии, 0.27, основным нововведением можно считать появление сборщика с Ansible. Пришло время рассказать об этих новинках подробнее.

Предыстория


Мы разрабатываем dapp более 2 лет и активно применяем в повседневном обслуживании множества проектов различных масштабов. Первые версии утилиты задумывались с целью использовать Chef для сборки образов. Когда мы добавили к этому то обстоятельство, что Ruby был знаком практически всем нашим инженерам и разработчикам, приняли логичное решение реализовать dapp как Ruby gem. Посчитали уместным и сделать конфиг Dappfile в виде Ruby DSL — тем более, что известен успешный пример из близкой области — Vagrant.

По мере развития утилиты пришло понимание, что в dapp нужна вторая специализация — доставка приложений в Kubernetes. Так появился режим работы с Helm charts, а инженеры освоили синтаксис YAML и шаблоны на Go в то время, как разработчики начали отправлять патчи в Helm. С одной стороны, доставка в Kubernetes стала неотъемлемой частью dapp, а с другой — стандартом де-факто в экосистеме Docker и Kubernetes является Go. Наш dapp, будучи написанным на Ruby, теперь выбивается из общей картины: если нам сложно повторно использовать код Docker, то пользователям зачастую просто не хочется ставить Ruby на сборочные машины — ведь куда проще и привычнее скачать бинарник… Как результат, основными целями развития dapp стали: а) перевод кодовой базы на Go, б) реализация синтаксиса YAML.

Кроме того, за прошедшее время Chef перестал нас устраивать по ряду причин как для управления машинами, так и для сборки. Как выяснилось, переход на Ansible решает часть проблем не только наших DevOps-инженеров: самым частым вопросом на конференциях стала поддержка Ansible в dapp. Таким образом, третьей целью стала реализация Ansible-сборщика.

Синтаксис YAML


Ранее знакомство с синтаксисом YAML я уже представлял в этой статье, однако теперь рассмотрю его подробнее.

Конфигурация сборки может быть описана в файле dappfile.yaml (или dappfile.yml). Этапы обработки конфигурации — следующие:

  1. dapp читает dappfile.y[a]ml;
  2. запускается Go-шаблонизатор, рендерится итоговый YAML;
  3. отрендереный конфиг разбивается на YAML-документы (--- с переводом строки);
  4. проверяется, что каждый YAML-документ содержит на верхнем уровне атрибут dimg или artifact;
  5. проверяется состав остальных атрибутов;
  6. если всё в порядке — составляется окончательный конфиг из указанных dimg’ей и artifact’ов.

Классический Dappfile — это Ruby DSL, благодаря чему было возможно некоторое программирование: обращение к словарю ENV за переменными окружения, определение dimg в циклах, определение общих инструкций сборки с помощью наследования контекста. Чтобы не отбирать такие возможности у разработчиков, было решено добавить в dappfile.yml поддержку Go-шаблонов — аналогично chart’ам Helm.

Однако мы отказались от наследования контекста через вложенность и через dimg_group’ы, т.к. это вносило больше неразберихи, чем удобства. Поэтому dappfile.yml — это линейный массив YAML-документов, каждый из которых представляет собой описание dimg или artifact.

Как и раньше, dimg может быть один и он может быть безымянным:

dimg: ~
from: alpine:latest
shell:
  beforeInstall:
    - apk update

Артефакты обязаны иметь имя, т.к. теперь описывается не экспорт файлов из образа-артефакта, а импорт (аналогично возможности multi-stage из Dockerfile). Потому нужно указывать, из какого артефакта требуется получить файлы:

artifact: application-assets
...
---
dimg: ~
...
import:
- artifact: application-assets
  add: /app/public/assets
  after: install
- artifact: application-assets
  add: /vendor
  to: /app/vendor
  after: install

Директивы git, git remote, shell перешли из DSL в YAML практически «как есть», но есть два момента: вместо подчеркиваний используется camelCase (как в Kubernetes) и нужно не повторять директивы, а объединять параметры, указывая массив:

git:
- add: /
  to: /app
  owner: app
  group: app
  excludePaths:
  - public/assets
  - vendor
  - .helm
  stageDependencies:
    install:
    - package.json
    - Bowerfile
    - Gemfile.lock
    - app/assets/*
- url: https://github.com/kr/beanstalkd.git
  add: /
  to: /build

shell:
  beforeInstall:
    - useradd -d /app -u 7000 -s /bin/bash app
    - rm -rf /usr/share/doc/* /usr/share/man/*
    - apt-get update
    - apt-get -y install apt-transport-https git curl gettext-base locales tzdata
  setup:
    - locale-gen en_US.UTF-8

Основное описание всех доступных атрибутов доступно в документации.

docker ENV и LABEL


В dappfile.yml переменные окружения и метки можно добавить так:

docker:
  ENV:
    <key>: <value>
    ...
  LABELS:
    <key>: <value>
    ...

В YAML не получится повторять ENV или LABELS, как это было в Dappfile и в Dockerfile.

Шаблонизатор


Шаблоны можно использовать для определения общей конфигурации сборки для разных dimg или artifact'ов. Это может быть, например, простое указание общего базового образа с помощью переменной:

{{ $base_image := "alpine:3.6" }}

dimg: app
from: {{ $base_image }}
...
---
dimg: worker
from: {{ $base_image }}

… или нечто более сложное с применением определяемых шаблонов:

{{ $base_image := "alpine:3.6" }}
{{- define "base beforeInstall" }}
  - apt: name=php update_cache=yes
  - get_url:
      url: https://getcomposer.org/download/1.5.6/composer.phar
      dest: /usr/local/bin/composer
      mode: 0755

{{- end}}

dimg: app
from: {{ $base_image }}
ansible:
  beforeInstall:
  {{- include "base beforeInstall" .}}
  - user:
    name: app
    uid: 48
...
---
dimg: worker
from: {{ $base_image }}
ansible:
  beforeInstall:
  {{- include "base beforeInstall" .}}
...

В этом примере часть инструкций для стадии beforeInstall определены как общая часть и далее подключаются в каждом dimg.

Подробнее о возможностях Go-шаблонов можно почитать в документации на модуль text/template и в документации на модуль sprig, функции из которого дополняют стандартные возможности.

Поддержка Ansible


Ansible-сборщик состоит из трёх частей:

  1. Образ dappdeps/ansible, в котором лежит Python 2.7, собранный со своей glibc и остальными библиотеками, чтобы работать в любом дистрибутиве (особенно актуально для Alpine). Тут же установлен Ansible.
  2. Поддержка синтаксиса описания сборки стадий с помощью Ansible в dappfile.yaml.
  3. Builder в dapp, запускающий контейнеры для стадий. В этих контейнерах выполняются таски, указанные в dappfile.yml. Builder создаёт playbook и генерирует команду для его запуска.

Ansible разрабатывается как система управления большим количеством удалённых хостов и поэтому вещи, которые актуальны для локального запуска, могут игнорироваться разработчиками. Например, нет вывода в реальном времени от запускаемых команд, как это было в Chef: сборка может включать длительную команду, вывод которой было бы хорошо видеть в реальном времени, но Ansible покажет вывод только после завершения. При запуске через GitLab CI это может быть расценено как подвисание билда.

Второй неприятностью стали stdout callbacks, которые входят в состав Ansible. Среди них не оказалось «умеренно информативного». Тут либо слишком многословный вывод с полным результатом в виде JSON, либо минимализм с названием хоста, именем модуля и статусом. Конечно, я утрирую, но подходящего модуля для сборки образов действительно нет.

Третье, с чем мы столкнулись, — зависимость некоторых модулей Ansible от внешних утилит (не страшно), модулей Python (ещё менее страшно) и от бинарных модулей Python (кошмар!). Опять же, авторы Ansible не учитывали, что их творение будут запускать отдельно от системных бинарников и что, например, userdel будет находиться не в /sbin, а где-то в другой директории…

Проблема с бинарными модулями — это особенность модуля apt. В нём используется модуль python-apt в виде SO-библиотеки. Другой особенностью модуля apt оказалось, что при выполнении таска, в случае неудачной загрузки python-apt, происходит попытка установить пакет с этим модулем в систему.

Чтобы решить вышеперечисленные проблемы, был реализован «живой» вывод для тасков raw и script, т.к. они могут запускаться без механизма Ansiballz. Также пришлось реализовать свой stdout callback, добавить в dappdeps/ansible сборку useradd, userdel, usermod, getent и подобных утилит и скопировать модули python-apt.

В итоге, сборщик Ansible в dapp работает с Linux-дистрибутивами Ubuntu, Debian, CentOS, Alpine, но не все модули ещё протестированы и потому в dapp есть список модулей, которые точно поддерживаются. Если в конфигурации использовать модуль не из списка, то сборка не запустится — это временная мера. Список поддерживаемых модулей можно увидеть здесь.

Конфигурация сборки с помощью Ansible в dappfile.yml похожа на конфигурацию shell. В ключе ansible перечисляются нужные стадии и для каждой из них определяется массив тасков — практически как в обычном playbook, только вместо атрибута tasks указывается имя стадии:

ansible:
  beforeInstall:
  - name: "Create non-root main application user"
    user:
      name: app
      comment: "Non-root main application user"
      uid: 7000
      shell: /bin/bash
      home: /app
  - name: "Disable docs and man files installation in dpkg"
    copy:
      content: |
        path-exclude=/usr/share/man/*
        path-exclude=/usr/share/doc/*
      dest: /etc/dpkg/dpkg.cfg.d/01_nodoc
  install:
  - name: "Precompile assets"
    shell: |
      set -e
      export RAILS_ENV=production
      source /etc/profile.d/rvm.sh
      cd /app
      bundle exec rake assets:precompile
    args:
      executable: /bin/bash

Пример взят из документации.

Теперь возникает вопрос: если в dappfile.yml есть только список тасков, то где всё остальное (верхний уровень playbook, inventory), как включить become и где говорящие коровы (или как их отключить)? Пора описать способ запуска Ansible.

За запуск отвечает билдер — это не очень сложный кусок кода, который определяет параметры запуска Docker-контейнера со стадией: переменные среды, команду запуска ansible-playbook, нужные монтирования. Также билдер создаёт во временной директории приложения каталог, где генерируется несколько файлов:

  • hosts — inventory для Ansible. Здесь только один хост localhost с указанием пути к Python внутри монтируемого образа dappdeps/ansible;
  • ansible.cfg — конфигурация Ansible. В конфиге указан тип подключения local, путь к inventory, путь к callback stdout, пути к временным директориям и настройки become: все таски запускаются от пользователя root; если использовать become_user, то процессу пользователя будут доступны все переменные среды и будет правильно установлена $HOME (sudo -E -H);
  • playbook.yml — этот файл генерируется из списка тасков для выполняемой стадии. В файле указывается фильтр hosts: all и отключается неявный сбор фактов настройкой gather_facts: no. Модули setup и set_fact — в списке поддерживаемых, поэтому можно использовать их для явного сбора фактов.

Список тасков для стадии beforeInstall из примера ранее превращается в такой playbook.yml:

---
hosts: all
gather_facts: no
tasks:
  - name: "Create non-root main application user"
    user:
      name: app
      ...
  - name: "Disable docs and man files installation in dpkg"
    copy:
      content: |
        path-exclude=/usr/share/man/*
        path-exclude=/usr/share/doc/*
      dest: /etc/dpkg/dpkg.cfg.d/01_nodoc

Особенности применения Ansible для сборки


Become


Настройки become в ansible.cfg такие:

[become]
become = yes
become_method = sudo
become_flags = -E -H
become_exe = path_to_sudo_insdie_dappdeps/ansible_image

Поэтому в тасках достаточно указать только become_user: username, чтобы запустить скрипт или копирование от пользователя.

Модули command


В Ansible есть 4 модуля для запуска команд и скриптов: raw, script, shell и command. raw и script выполняются без механизма Ansiballz, что немного быстрее, и для них есть live-вывод. С помощью raw можно выполнять многострочные скрипты ad-hoc:

- raw: |
     mvn -B -f pom.xml -s /usr/share/maven/ref/settings-docker.xml dependency:resolve
     mvn -B -s /usr/share/maven/ref/settings-docker.xml package -DskipTests

Правда, не поддерживается атрибут environment, но это можно обойти так:

- raw: |
     mvn -B -f pom.xml -s $SETTINGS dependency:resolve
     mvn -B -s $SETTINGS package -DskipTests
  args:
    executable: SETTINGS=/usr/share/maven/ref/settings-docker.xml /bin/ash -e

Файлы


На данном этапе нет механизма проброса файлов из репозитория в контейнеры, кроме директивы git. Для добавления в образ различного рода конфигов, скриптов и других небольших файлов можно воспользоваться модулем copy:

  - name: "Disable docs and man files installation in dpkg"
    copy:
      content: |
        path-exclude=/usr/share/man/*
        path-exclude=/usr/share/doc/*
      dest: /etc/dpkg/dpkg.cfg.d/01_nodoc

Если файл большой, то, чтобы не хранить его внутри dappfile.yml, можно воспользоваться Go-шаблоном и функцией .Files.Get:

  - name: "Disable docs and man files installation in dpkg"
    copy:
      content: |
{{.Files.Get ".dappfiles/01_nodoc" | indent 6}}
      dest: /etc/dpkg/dpkg.cfg.d/01_nodoc

В дальнейшем будет реализован механизм подключения файлов в сборочный контейнер, чтобы было проще копировать большие и бинарные файлы, а также использовать include* или import*.

Шаблонизация


Про Go-шаблоны в dappfile.yaml уже было сказано. Ansible со своей стороны поддерживает шаблоны jinja2, а разделители этих двух систем совпадают, поэтому вызов jinja нужно экранировать от Go-шаблонизатора:

  - name: "create temp file for archive"
    tempfile:
      state: directory
    register: tmpdir
  - name: Download archive
    get_url:
      url: https://cdn.example.com/files/archive.tgz
      dest: '{{`{{ tmpdir.path }}`}}/archive.tgz'

Отладка проблем со сборкой


При выполнении таска может случиться какая-то ошибка, но сообщений на экране иногда не хватает для понимания. В этом случае можно начать с указания переменной окружения ANSIBLE_ARGS="-vvv" — тогда в выводе будут все аргументы для тасков и все аргументы результатов (похоже на использование json stdout callback).

Если ситуация не проясняется, можно запустить сборку в режиме introspect: dapp dimg bulid --introspect-error. Тогда сборка остановится после ошибки и в контейнере будет запущен shell. Будет видна команда, вызвавшая ошибку, а в соседнем терминале можно зайти во временную директорию и править playbook.yml:



Переход на Go


Это наша третья цель в развитии dapp, однако с точки зрения пользователя мало что меняет, кроме упрощения установки. Для релиза 0.26 на Go был реализован парсер dappfile.yaml. Сейчас продолжается работа по переводу на Go основной функциональности dapp: запуск сборочных контейнеров, билдеры, работа с Git. Поэтому будет не лишней ваша помощь в тестировании — в том числе, модулей Ansible. Ждём issue на GitHub или заходите в нашу группу в Telegram: dapp_ru.

P.S.


Так что там с коровами-то? Программы cowsay нет в dappdeps/ansible, а используемый callback stdout не вызывает те методы, где включается cowsay. К сожалению, Ansible в dapp без коров (но вас никто не остановит от создания issue).

P.P.S.


Читайте также в нашем блоге:

Комментарии (1)


  1. tru_pablo
    26.03.2018 14:28

    А какие аналогичные dapp тулзы есть ещё и в чем отличия/плюсы dapp?