Пользовательские стадии

Пользовательские стадии — это стадии со сборочными инструкциями из конфигурации. Другими словами, — это стадии, конфигурируемые пользователем (существуют также служебные стадии, которые пользователь конфигурировать не может). В настоящее время существует два вида сборочных инструкций: shell и ansible.

werf поддерживает четыре пользовательские стадии, которые выполняются последовательно, в следующем порядке: beforeInstall, install, beforeSetup и setup. В результате выполнения инструкций пользовательской стадии создается один Docker-слой. Т.е. по одному слою на каждую стадию, в независимости от количества инструкций.

Обоснование использования

Своя концепция организации сборки (opinionated software)

Шаблон и механизм работы пользовательских стадий основан на анализе сборки реальных приложений. В результате мы пришли к выводу, что для того, чтобы качественно улучшить сборку большинства приложений, достаточно разбить инструкции сборки на 4 группы (эти группы и есть пользовательские стадии), подчиняющиеся определенным правилам. Такая группировка уменьшает количество слоев и ускоряет сборку.

Четкая структура сборочного процесса

Наличие пользовательских стадий определяет структуру процесса сборки и, таким образом, устанавливает некоторые рамки для разработчика. Несмотря на дополнительное ограничение по сравнению с неструктурированными инструкциями Docker-файла, это наоборот дает выигрыш в скорости, т.к. разработчик знает, какие инструкции на каком этапе должны быть.

Запуск инструкций сборки при изменениях в git-репозитории

werf может использовать как локальные так и удаленные git-репозитории при сборке. Любой пользовательской стадии можно определить зависимость от изменения конкретных файлов или папок в одном или нескольких git-репозиториях. Это дает возможность принудительно пересобирать пользовательскую стадию если в локальном или удаленном репозитории (или репозиториях) изменяются какие-либо файлы.

Больше инструментов сборки: shell, ansible, …

Shell — знакомый и хорошо известный инструмент сборки. Ansible — более молодой инструмент, требующий чуть больше времени на изучение.

Если вам нужен быстрый результат с минимальными затратами времени и как можно быстрее, то использование shell может быть вполне достаточно — все работает аналогично директиве RUN в Dockerfile.

В случае с Ansible применяется декларативный подход и подразумевается идемпотентность операций. Такой подход дает более предсказуемый результат, особенно в случае проектов большого жизненного цикла.

Архитектура werf позволяет добавлять поддержку и других инструментов сборки в будущем.

Использование пользовательских стадий

werf позволяет определять до четырех пользовательских стадий с инструкциями сборки. На содержание самих инструкций сборки werf не накладывает каких-либо ограничений, т.е. вы можете указывать все те же инструкции, которые указывали в Dockerfile в директиве RUN. Однако важно не просто перенести инструкции из Dockerfile, а правильно разбить их на пользовательские стадии. Исходя из опыта работы с реальными приложениями, мы пришли к заключению, что сборка большинства приложений проходит следующие этапы:

  • установка системных пакетов
  • установка системных зависимостей
  • установка зависимостей приложения
  • настройка системных пакетов
  • настройка приложения

Какая может быть наилучшая стратегия выполнения этих этапов?

Первая мысль — лучше выполнять эти этапы последовательно, кэшируя промежуточные результаты, а с другой стороны разбивать инструкции по файловым зависимостям.

Подход с пользовательскими стадиями предлагает следующую стратегию:

  • использовать стадию beforeInstall для инсталляции системных пакетов;
  • использовать стадию install для инсталляции системных зависимостей и зависимостей приложения;
  • использовать стадию beforeSetup для настройки системных параметров и установки приложения;
  • использовать стадию setup для настройки приложения.

beforeInstall

Данная стадия предназначена для выполнения инструкций перед установкой приложения. Этот этап предназначен для системных приложений, которые редко изменяются, но требуют много времени для установки. Примером таких приложений могут быть языковые пакеты, инструменты сборки, такие как composer, java, gradle и т.д. Также сюда можно добавлять инструкции настройки системы, которые редко изменяются. Например, языковые настройки, настройки часового пояса, добавление пользователей и групп.

Поскольку эти компоненты меняются редко, они будут кэшироваться в рамках стадии beforeInstall на длительный период.

install

Данная стадия предназначена для установки приложения и его зависимостей, а также выполнения базовых настроек.

На данной стадии появляется доступ к исходному коду используемых git-репозиториев (директива git) и появляется возможность установки зависимостей на основе manifest-файлов с использованием таких инструментов, как composer, gradle, npm и т.д. Поскольку сборка стадии зависит от manifest-файла, для достижения наилучшего результата важно добавить зависимость от изменений в manifest-файлах репозитория для этой стадии. Например, если в проекте используется composer, то добавление файла composer.lock в зависимости стадии beforeInstall позволит пересобирать стадию при изменении файла composer.lock.

beforeSetup

Данная стадия предназначена для подготовки приложения перед настройкой.

На данной стадии рекомендуется выполнять разного рода компиляцию и обработку. Например, компиляция jar-файлов, бинарных файлов, файлов библиотек, создание ассетов web-приложений, минификация, шифрование и т.п. Перечисленные операции, как правило, зависят от изменений в исходном коде и на данной стадии также важно определить достаточные зависимости от изменений в репозитории. Логично, что стадия будет зависеть от большего числа файлов в репозитории, чем на предыдущей стадии, и, соответственно, ее пересборка будет выполняться чаще.

При правильно определенных зависимостях, изменения в коде приложения должны приводить к пересборке стадии beforeSetup, а изменение manifest-файла к стадии install и последующих стадий.

setup

Данная стадия предназначена для настройки приложения.

Обычно на данной стадии выполняется копирование файлов конфигурации (например, в папку /etc), создание файлов текущей версии приложения и т.д. Такого рода операции не должны быть затратными по времени, т.к. они скорее всего будут выполняться в большинстве сборок.

Пользовательская стратегия

Несмотря на изложенную четкую стратегию шаблона пользовательских стадий и функции каждой стадии, по сути для пользователя нет никаких ограничений. Предложенное использование стадий является лишь рекомендацией, которая основана на нашем опыте с реальными приложениями. Вы можете использовать только одну пользовательскую стадию, либо определить свою стратегию группировки инструкций, чтобы получить преимущества кэширования и зависимостей от изменений в git-репозиториях с учетом особенностей сборки вашего приложения.

Синтаксис

Пользовательские стадии и инструкции сборки определяются внутри двух взаимоисключающих директив — shell и ansible. Каждый образ может собираться либо используя сборочные инструкции shell, либо задачи ansible, описанные в соответствующих директивах.

В каждой директиве можно описывать инструкции для пользовательских стадий, соответственно:

  • beforeInstall
  • install
  • beforeSetup
  • setup

Также можно указывать директивы версий кэша (cacheVersion), которые по сути являются частью дайджеста каждой пользовательской стадии. Более подробно в соответствующем разделе.

Shell

Синтаксис описания пользовательских стадий при использовании сборочных инструкций shell:

shell:
  beforeInstall:
  - <bash_command 1>
  - <bash_command 2>
  ...
  - <bash_command N>
  install:
  - bash command
  ...
  beforeSetup:
  - bash command
  ...
  setup:
  - bash command
  ...
  cacheVersion: <version>
  beforeInstallCacheVersion: <version>
  installCacheVersion: <version>
  beforeSetupCacheVersion: <version>
  setupCacheVersion: <version>

Сборочные инструкции shell — это массив bash-команд для соответствующей пользовательской стадии. Все команды одной стадии выполняются как одна инструкция RUN в Dockerfile, т.е. в результате создается один слой на каждую пользовательскую стадию.

werf при сборке использует собственный исполняемый файл bash и вам не нужно отдельно добавлять его в образ (или базовый образ) при сборке.

Пример описания стадии beforeInstall содержащей команды apt-get update и apt-get install:

beforeInstall:
- apt-get update
- apt-get install -y build-essential g++ libcurl4

werf выполнит команды стадии следующим образом::

  • на хост-машине сгенерируется временный скрипт:

      #!/.werf/stapel/embedded/bin/bash -e
    
      apt-get update
      apt-get install -y build-essential g++ libcurl4
    
  • скрипт смонтируется в сборочный контейнер как /.werf/shell/script.sh
  • скрипт выполнится.

Исполняемый файл bash находится внутри Docker-тома stapel. Подробнее про эту концепцию можно узнать в этой статье (упоминаемый в статье dappdeps был переименован в stapel, но принцип сохранился)

Ansible

Синтаксис описания пользовательских стадий при использовании сборочных инструкций ansible:

ansible:
  beforeInstall:
  - <ansible task 1>
  - <ansible task 2>
  ...
  - <ansible task N>
  install:
  - ansible task
  ...
  beforeSetup:
  - ansible task
  ...
  setup:
  - ansible task
  ...
  cacheVersion: <version>
  beforeInstallCacheVersion: <version>
  installCacheVersion: <version>
  beforeSetupCacheVersion: <version>
  setupCacheVersion: <version>

Ansible config и stage playbook

Сборочные инструкции ansible — это массив Ansible-заданий для соответствующей пользовательской стадии. Для запуска этих заданий с помощью ansible-playbook werf монтирует следующую структуру папок в сборочный контейнер:

/.werf/ansible-workdir
├── ansible.cfg
├── hosts
└── playbook.yml

ansible.cfg содержит настройки для Ansible:

  • использование локального транспорта (transport = local)
  • подключение callback плагина werf для удобного логирования (stdout_callback = werf)
  • включение режима цвета (force_color = 1)
  • установка использования sudo для повышения привилегий (чтобы не было необходимости использовать become в Ansible-заданиях)

hosts — inventory-файл, содержит только localhost и некоторые ansible_* параметры.

playbook.yml — playbook, содержащий все задания соответствующей пользовательской стадии. Пример werf.yaml с описанием стадии install:

ansible:
  install:
  - debug: msg='Start install'
  - file: path=/etc mode=0777
  - copy:
      src: /bin/sh
      dest: /bin/sh.orig
  - apk:
      name: curl
      update_cache: yes
  ...

В приведенном примере, werf сгенерирует следующий playbook.yml для стадии install:

- hosts: all
  gather_facts: 'no'
  tasks:
  - debug: msg='Start install'  \
  - file: path=/etc mode=0777   |
  - copy:                        > эти строки будут скопированы из werf.yaml
      src: /bin/sh              |
      dest: /bin/sh.orig        |
  - apk:                        |
      name: curl                |
      update_cache: yes         /
  ...

werf выполняет playbook пользовательской стадии в сборочном контейнере стадии с помощью команды playbook-ansible:

$ export ANSIBLE_CONFIG="/.werf/ansible-workdir/ansible.cfg"
$ ansible-playbook /.werf/ansible-workdir/playbook.yml

Исполняемые файлы и библиотеки ansible и python находятся внутри Docker-тома stapel. Подробнее про эту концепцию можно узнать в этой статье (упоминаемый в статье dappdeps был переименован в stapel, но принцип сохранился)

Поддерживаемые модули

Одной из концепций, которую использует werf, является идемпотентность сборки. Это значит что если “ничего не изменилось”, то werf при повторном и последующих запусках сборки должен создавать бинарно идентичные образы. В werf эта задача решается с помощью подсчета дайджестов стадий.

Многие модули Ansible не являются идемпотентными, т.е. они могут давать разный результат запусков при неизменных входных параметрах. Это, конечно, не дает возможность корректно высчитывать дайджест стадии, чтобы определять реальную необходимость её пересборки из-за изменений. Это привело к тому, что список поддерживаемых модулей был ограничен.

На текущий момент, список поддерживаемых модулей Ansible следующий:

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

Копирование файлов

Предпочтительный способ копирования файлов в образ — использование git mapping. werf не может определять изменения в копируемых файлах при использовании модуля copy. Единственный вариант копирования внешнего файла в образ на текущий момент — использовать метод .Files.Get Go-шаблона. Данный метод возвращает содержимое файла как строку, что дает возможность использовать содержимое как часть пользовательской стадии. Таким образом, при изменении содержимого файла изменится дайджест соответствующей стадии, что приведет к пересборке всей стадии.

Пример копирования файла nginx.conf в образ:

ansible:
  install:
  - copy:
      content: |
{{ .Files.Get "/conf/etc/nginx.conf" | indent 8}}
      dest: /etc/nginx/nginx.conf

В результате получится подобный playbook.yml:

- hosts: all
  gather_facts: no
  tasks:
    install:
    - copy:
        content: |
          http {
            sendfile on;
            tcp_nopush on;
            tcp_nodelay on;
            ...

Шаблоны Jinja

В Ansible реализована поддержка шаблонов Jinja в playbook’ах. Однако у Go-шаблонов и Jinja-шаблонов одинаковый разделитель: {{ и }}. Чтобы использовать Jinja-шаблоны в конфигурации werf, их нужно экранировать. Для этого есть два варианта: экранировать только {{, либо экранировать все выражение шаблона Jinja.

Например, у вас есть следующая задача Ansible:

- copy:
    src: {{item}}
    dest: /etc/nginx
    with_files:
    - /app/conf/etc/nginx.conf
    - /app/conf/etc/server.conf

Тогда, выражение Jinja-шаблона {{item}} должно быть экранировано:

# экранируем только {{
src: {{"{{"}} item }}

либо

# экранируем все выражение
src: {{`{{item}}`}}

Проблемы с Ansible

  • Live-вывод реализован только для модулей raw и command. Остальные модули отображают вывод каналов stdout и stderr после выполнения, что приводит к задержкам, скачкообразному выводу.
  • Модуль apt подвисает на некоторых версиях Debian и Ubuntu. Проявляется также на наследуемых образах(issue #645).

Зависимости пользовательских стадий

Одна из особенностей werf — возможность определять зависимости, при которых происходит пересборка стадии. Как указано в статье, сборка стадий выполняется последовательно, одна за другой, и для каждой стадии высчитывается дайджест стадии. У дайджестов есть ряд зависимостей, при изменении которых дайджест стадии меняется, что служит для werf сигналом для пересборки стадии с измененным дайджестом. Поскольку каждая следующая стадия имеет зависимость, в том числе и от предыдущей стадии согласно конвейеру стадий, при изменении дайджеста какой-либо стадии, произойдет пересборка и стадии с изменённым дайджестом и всех последующих стадий.

Дайджест пользовательских стадий и соответственно пересборка пользовательских стадий зависит от изменений:

  • в инструкциях сборки
  • в директивах семейства cacheVersion
  • в git-репозитории (или git-репозиториях)
  • в файлах, импортируемых из артефактов

Первые три описанных варианта зависимостей, рассматриваются подробно далее.

Зависимость от изменений в инструкциях сборки

Дайджест пользовательской стадии зависит от итогового текста инструкций, т.е. после применения шаблонизатора. Любые изменения в тексте инструкций с учетом применения шаблонизатора Go или Jinja (в случае Ansible) в пользовательской стадии приводят к пересборке стадии. Например, вы используете следующие shell-инструкции:

shell:
  beforeInstall:
  - echo "Commands on the Before Install stage"
  install:
  - echo "Commands on the Install stage"
  beforeSetup:
  - echo "Commands on the Before Setup stage"
  setup:
  - echo "Commands on the Setup stage"

При первой сборке этого образа будут выполнены инструкции всех четырех пользовательских стадий. В данной конфигурации нет git mapping, так что последующие сборки не приведут к повторному выполнению инструкций — дайджест пользовательских стадий не изменилась, сборочный кэш содержит актуальную информацию.

Изменим инструкцию сборки для стадии install:

shell:
  beforeInstall:
  - echo "Commands on the Before Install stage"
  install:
  - echo "Commands on the Install stage"
  - echo "Installing ..."
  beforeSetup:
  - echo "Commands on the Before Setup stage"
  setup:
  - echo "Commands on the Setup stage"

Дайджест стадии install изменилась, и запуск werf для сборки приведет к выполнению всех инструкций стадии install и инструкций последующих стадий, т.е. beforeSetup и setup.

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

shell:
  beforeInstall:
  - echo "Commands on the Before Install stage for {{ env "CI_COMMIT_SHA” }}"
  install:
  - echo "Commands on the Install stage"
  ...

Первая сборка высчитает дайджест стадии beforeInstall на основе команды (хэш коммита, конечно, будет другой):

echo "Commands on the Before Install stage for 0a8463e2ed7e7f1aa015f55a8e8730752206311b"

После очередного коммита, при сборке дайджест стадии beforeInstall уже будет другой (с других хэшем коммита):

echo "Commands on the Before Install stage for 36e907f8b6a639bd99b4ea812dae7a290e84df27"

Соответственно, используя переменную CI_COMMIT_SHA дайджест стадии beforeInstall будет меняться после каждого коммита, что будет приводить к пересборке.

Зависимость от изменений в git-репозитории

Зависимость от изменений в git-репозитории

Как описывалось в статье про git mapping, существуют специальные стадии gitArchive и gitLatestPatch. Стадия gitArchive выполняется после пользовательской стадии beforeInstall, а стадия gitLatestPatch после пользовательской стадии setup, если в локальном git-репозитории есть изменения. Таким образом, чтобы выполнить сборку с последней версией исходного кода, можно пересобрать стадию beforeInstall, изменив значение директивы cacheVersion либо изменив сами инструкции стадии beforeInstall.

Пользовательские стадии install, beforeSetup и setup также могут зависеть от изменений в git-репозитории. В этом случае (если такая зависимость определена) git-патч применяется перед выполнением инструкций пользовательской стадии, чтобы сборочные инструкции выполнялись с актуальной версией кода приложения.

Во время процесса сборки исходный код обновляется только в рамках одной стадии, последующие стадии, зависящие последовательно друг от друга, будут использовать также обновленную версию файлов. Первая сборка добавляет файлы из git-репозитория на стадии gitArchive. Все последующие сборки обновляют файлы на стадии gitCache, gitLatestPatch или на одной из следующих пользовательских стадий: install, beforeSetup, setup.

Пример этого этапа (фаза подсчета дайджестов, calculating digests): git files actualized on specific stage

Зависимость пользовательской стадии от изменений в git-репозитории указывается с помощью параметра git.stageDependencies. Синтаксис:

git:
- ...
  stageDependencies:
    install:
    - <mask 1>
    ...
    - <mask N>
    beforeSetup:
    - <mask>
    ...
    setup:
    - <mask>

У параметра git.stageDependencies возможно указывать 3 ключа: install, beforeSetup и setup. Значение каждого ключа — массив масок файлов, относящихся к соответствующей стадии. Соответствующая пользовательская стадия пересобирается, если в git-репозитории происходят изменения подпадающие под указанную маску.

Для каждой пользовательской стадии werf создает список подпадающих под маску файлов и вычисляет контрольную сумму каждого файла с учетом его аттрибутов и содержимого. Эти контрольные суммы являются частью дайджеста стадии, поэтому любое изменение файлов в репозитории, подпадающее под маску, приводит к изменениям дайджеста стадии. К этим изменениям относятся: изменение атрибутов файла, изменение содержимого файла, добавление или удаление подпадающего под маску файла и т.п.

При применении маски указанной в git.stageDependencies учитываются значения параметров git.includePaths и git.excludePaths (смотри подробнее про них в соответствующем разделе. werf считает подпадающими под маску только файлы удовлетворяющие фильтру includePaths и подпадающие под маску stageDependencies. Аналогично, werf считает подпадающими под маску только файлы не удовлетворяющие фильтру excludePaths и не подпадающие под маску stageDependencies.

Правила описания маски в параметре stageDependencies аналогичны описанию параметров includePaths и excludePaths. Маска определяет шаблон для файлов и путей, и может содержать следующие шаблоны:

  • * — Удовлетворяет любому файлу. Шаблон включает . и исключает /.
  • ** — Удовлетворяет директории со всем ее содержимым, рекурсивно.
  • ? — Удовлетворяет любому одному символу в имени файла (аналогично regexp-шаблону /.{1}/).
  • [set] — Удовлетворяет любому символу из указанного набора символов. Аналогично использованию в regexp-шаблонах, включая указание диапазонов типа [^a-z].
  • \ — Экранирует следующий символ.

Маска, которая начинается с шаблона * или **, должна быть взята в одинарные или двойные кавычки в werf.yaml:

# * в начале маски, используем двойные кавычки
- "*.rb"
# одинарные также работают
- '**/*'
# нет * в начале, можно не использовать кавычки
- src/**/*.js

Факт изменения файлов в git-репозитории werf определяет, подсчитывая их контрольные суммы. Для пользовательской стадии и для каждой маски применяется следующий алгоритм:

  • werf создает список всех файлов согласно пути определенному в параметре add, и применяет фильтры excludePaths и includePaths;
  • К каждому файлу с учетом его пути применяется маска, согласно правилам применения шаблонов;
  • Если под маску подпадает папка, то все содержимое папки считается подпадающей под маску рекурсивно;
  • У получившегося списка файлов werf подсчитывает контрольные суммы, с учетом аттрибутов файлов и их содержимого.

Контрольные суммы подсчитываются вначале сборочного процесса, перед запуском какой-либо стадии.

Пример:

image: app
git:
- add: /src
  to: /app
  stageDependencies:
    beforeSetup:
    - "*"
shell:
  install:
  - echo "install stage"
  beforeSetup:
  - echo "beforeSetup stage"
  setup:
  - echo "setup stage"

В приведенном файле конфигурации werf.yaml указан git mapping, согласно которому содержимое папки /src локального git-репозитория копируется в папку /app собираемого образа. Во время первой сборки, файлы кэшируются в стадии gitArchive и выполняются сборочные инструкции стадий install, beforeSetup и setup.

Сборка следующего коммита, в котором будут только изменения файлов за пределами папки /src не приведет к выполнению инструкций каких-либо стадий. Если коммит будет содержать изменение внутри папки /src, контрольные суммы файлов подпадающих под маску изменятся, werf применит git-патч и пересоберет все пользовательские стадии? начиная со стадии beforeSetup, а именно — beforeSetup и setup. Применение git-патча будет выполнено один раз на стадии beforeSetup.

Зависимость от значения CacheVersion

Существуют ситуации, когда необходимо принудительно пересобрать все или какую-то конкретную пользовательскую стадию. Этого можно достичь, изменяя параметры cacheVersion или <user stage name>CacheVersion.

Дайджест пользовательской стадии install зависит от значения параметра installCacheVersion. Чтобы пересобрать пользовательскую стадию install (и все последующие стадии), можно изменить значение параметра installCacheVersion.

Обратите внимание, что параметры cacheVersion и beforeInstallCacheVersion имеют одинаковый эффект, — при изменении этих параметров возникает пересборка стадии beforeInstall и всех последующих стадий.

Пример: Общий образ для нескольких приложений

Вы можете определить образ, содержащий общие системные пакеты в отдельном файле werf.yaml. Изменение параметра cacheVersion может быть использовано для пересборки этого образа, чтобы обновить версии системных пакетов.

image: ~
from: ubuntu:latest
shell:
  beforeInstallCacheVersion: 2
  beforeInstall:
  - apt update
  - apt install ...

Этот образ может быть использован как базовый образ для нескольких приложений (например, если образ с hub.docker.com не удовлетворяет вашим требованиям).

Пример использования внешних зависимостей

Параметры CacheVersion можно использовать совместно с шаблонами Go, чтобы определить зависимость пользовательской стадии от файлов, не находящихся в git-репозитории.

image: ~
from: ubuntu:latest
shell:
  installCacheVersion: {{.Files.Get "some-library-latest.tar.gz" | sha256sum}}
  install:
  - tar zxf some-library-latest.tar.gz
  - <build application>

Если использовать, например, скрипт загрузки файла some-library-latest.tar.gz и запускать werf для сборки уже после скачивания файла, то пересборка пользовательской стадии install (и всех последующих) будет происходить в случае если скачан новый (измененный) файл.