Динамическая сборка и деплой Docker-образов с werf на примере сайта версионированной документации

Мы уже не раз рассказывали про свой GitOps-инструмент werf, а в этот раз хотели бы поделиться опытом сборки сайта с документацией самого проекта — werf.io (его русскоязычная версия — ru.werf.io). Это обычный статический сайт, однако его сборка интересна тем, что построена с использованием динамического количества артефактов.

m_oxod8ckyfsipscttspcn5opd8.png

Вдаваться в нюансы структуры сайта: генерацию общего меню для всех версий, страницы с информацией о релизах и т.п. — не будем. Вместо этого, сфокусируемся на вопросах и особенностях динамической сборки и немного на сопутствующих процессах CI/CD.

Введение: как устроен сайт


Начнем с того, что документация по werf хранится вместе с его кодом. Это предъявляет определенные требования к разработке, которые в целом выходят за рамки настоящей статьи, но как минимум можно сказать, что:

  • Новые функции werf не должны выходить без обновления документации и, наоборот, какие-либо изменения в документации подразумевают выход новой версии werf;
  • У проекта довольно интенсивная разработка: новые версии могут выходить несколько раз в день;
  • Какие-либо ручные операции по деплою сайта с новой версией документации как минимум утомительны;
  • В проекте принят подход семантического версионирования, с 5-ю каналами стабильности. Релизный процесс подразумевает последовательное прохождение версий по каналам в порядке повышения стабильности: от alpha до rock-solid;
  • У сайта есть русскоязычная версия, которая «живёт и развивается» (т.е. контент которой обновляется) параллельно с основной (т.е. англоязычной) версией.


Чтобы скрыть от пользователя всю эту «внутреннюю кухню», предложив ему то, что «просто работает», мы сделали отдельный инструмент установки и обновления werf — это multiwerf. Достаточно указать номер релиза и канал стабильности, который вы готовы использовать, а multiwerf проверит, есть ли новая версия на канале, и скачает ее при необходимости.

В меню выбора версий на сайте доступны последние версии werf в каждом канале. По умолчанию, по адресу werf.io/documentation открывается версия наиболее стабильного канала для последнего релиза — она же индексируется поисковиками. Документация для канала доступна по отдельным адресам (например, werf.io/v1.0-beta/documentation для beta-релиза 1.0).

Итого, у сайта доступны следующие версии:

  1. корневая (открывается по умолчанию),
  2. для каждого активного канала обновлений каждого релиза (например, werf.io/v1.0-beta).


Для генерации конкретной версии сайта в общем случае достаточно выполнить его компиляцию средствами Jekyll, запустив в каталоге /docs репозитория werf соответствующую команду (jekyll build), предварительно переключившись на Git-тег необходимой версии.

Остается только добавить, что:

  • для сборки используется сама утилита (werf);
  • CI/CD-процессы построены на базе GitLab CI;
  • и все это, конечно, работает в Kubernetes.


Задачи


Теперь сформулируем задачи, учитывающие всю описанную специфику:

  1. После смены версии werf на любом канале обновлений документация на сайте должна автоматически обновляться.
  2. Для разработки нужно иметь возможность иногда просматривать предварительные версии сайта.


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

  • Поскольку список версий на каналах меняется, то пересобирать необходимо только документацию для каналов, где изменилась версия. Ведь пересобирать все заново не очень красиво.
  • Сам набор каналов для релизов может меняться. В какой-то момент времени, например, может не быть версии на каналах стабильнее релиза early-access 1.1, но со временем они появятся — не менять же в этом случае сборку руками?


Получается, что сборка зависит от меняющихся внешних данных.

Реализация


Выбор подхода


Как вариант, можно запускать каждую необходимую версию отдельным pod«ом в Kubernetes. Такой вариант подразумевает большее количество объектов в кластере, которое будет расти с увеличением количества стабильных релизов werf. А это в свою очередь подразумевает более сложное обслуживание: на каждую версию появляется свой HTTP-сервер, причем с небольшой нагрузкой. Конечно, это влечет и бОльшие расходы по ресурсам.

Мы же пошли по пути сборки всех необходимых версий в одном образе. Скомпилированная статика всех версий сайта находится в контейнере с NGINX, а трафик на соответствующий Deployment приходит через NGINX Ingress. Простая структура — stateless-приложение — позволяет легко масштабировать Deployment (в зависимости от нагрузки) средствами самого Kubernetes.

Если быть точнее, то мы собираем два образа: один — для production-контура, второй — дополнительный, для dev-контура. Дополнительный образ используется (запускается) только на dev-контуре совместно с основным и содержит версию сайта из review-коммита, а маршрутизация между ними выполняется с помощью Ingress-ресурсов.

werf vs git clone и артефакты


Как уже упоминалось, чтобы сгенерировать статику сайта для конкретной версии документации, нужно выполнить сборку, переключившись в соответствующий тег репозитория. Можно было бы делать это и путем клонирования репозитория каждый раз при сборке, выбирая соответствующие теги по списку. Однако это довольно ресурсоемкая операция и, к тому же, требующая написания нетривиальных инструкций… Другой серьезный минус — при таком подходе нет возможности что-то кэшировать во время сборки.

Тут нам на помощь приходит сама утилита werf, реализующая умное кэширование и позволяющая использовать внешние репозитории. Использование werf для добавления кода из репозитория значительно ускорит сборку, т.к. werf по сути один раз делает клонирование репозитория, а затем выполняет только fetch при необходимости. Кроме того, при добавлении данных из репозитория мы можем выбрать только необходимые директории (в нашем случае это каталог docs), что значительно снизит объем добавляемых данных.

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

Пишем werf.yaml


Итак, мы определились, что будем компилировать каждую версию в отдельном артефакте werf. Однако мы не знаем, сколько этих артефактов будет при сборке, поэтому не можем написать фиксированную конфигурацию сборки (строго говоря, всё-таки можем, но это будет не совсем эффективно).

werf позволяет использовать Go-шаблоны в своём файле конфигурации (werf.yaml), а это дает возможность генерировать конфиг «на лету» в зависимости от внешних данных (то, что нужно!). Внешними данными в нашем случае выступает информация о версиях и релизах, на основании которой мы собираем необходимое количество артефактов и получаем в результате два образа: werf-doc и werf-dev для запуска на разных контурах.

Внешние данные передаются через переменные окружения. Вот их состав:

  • RELEASES — строка со списком релизов и соответствующей им актуальной версии werf, в виде списка через пробел значений в формате <НОМЕР_РЕЛИЗА>%<НОМЕР_ВЕРСИИ>. Пример: 1.0%v1.0.4-beta.20
  • CHANNELS= — строка со списком каналов и соответствующей им актуальной версии werf, в виде списка через пробел значений в формате <КАНАЛ>%<НОМЕР_ВЕРСИИ>. Пример: 1.0-beta%v1.0.4-beta.20 1.0-alpha%v1.0.5-alpha.22
  • ROOT_VERSION — версия релиза werf для отображения по умолчанию на сайте (не всегда нужно выводить документацию по наивысшему номеру релиза). Пример: v1.0.4-beta.20
  • REVIEW_SHA — хэш review-коммита, из которого нужно собрать версию для тестового контура.


Эти переменные будут наполняться в pipeline GitLab CI, а как именно — написано ниже.

Первым делом, для удобства, определим в werf.yaml переменные Go-шаблонов, присвоив им значения из переменных окружения:

{{ $_ := set . "WerfVersions" (cat (env "CHANNELS") (env "RELEASES") | splitList " ") }}
{{ $Root := . }}
{{ $_ := set . "WerfRootVersion" (env "ROOT_VERSION") }}
{{ $_ := set . "WerfReviewCommit" (env "REVIEW_SHA") }}


Описание артефакта для компиляции статики версии сайта в целом одинаково для всех необходимых нам случаев (в том числе, генерация корневой версии, а также версии для dev-контура). Поэтому вынесем его в отдельный блок с помощью функции define — для последующего переиспользования с помощью include. Шаблону будем передавать следующие аргументы:

  • Version — генерируемую версию (название тега);
  • Channel — название канала обновлений, для которого генерируется артефакт;
  • Commit — хэш коммита, если артефакт генерируется для review-коммита;
  • контекст.


Описание шаблона артефакта
{{- define "doc_artifact" -}}
{{- $Root := index . "Root" -}}
artifact: doc-{{ .Channel }}
from: jekyll/builder:3
mount:
- from: build_dir
  to: /usr/local/bundle
ansible:
  install:
  - shell: |
      export PATH=/usr/jekyll/bin/:$PATH
  - name: "Install Dependencies"
    shell: bundle install
    args:
      executable: /bin/bash
      chdir: /app/docs
  beforeSetup:
{{- if .Commit }}
  - shell: echo "Review SHA - {{ .Commit }}."
{{- end }}
{{- if eq .Channel "root" }}
  - name: "releases.yml HASH: {{ $Root.Files.Get "releases.yml" | sha256sum }}"
    copy:
      content: |
{{ $Root.Files.Get "releases.yml" | indent 8 }}
      dest:  /app/docs/_data/releases.yml
{{- else }}
  - file:
      path: /app/docs/_data/releases.yml
      state: touch
{{- end }}
  - file:
      path: "{{`{{ item }}`}}"
      state: directory
      mode: 0777
    with_items:
    - /app/main_site/
    - /app/ru_site/
  - file:
      dest: /app/docs/pages_ru/cli
      state: link
      src: /app/docs/pages/cli
  - shell: |
      echo -e "werfVersion: {{ .Version }}\nwerfChannel: {{ .Channel }}" > /tmp/_config_additional.yml
      export PATH=/usr/jekyll/bin/:$PATH
{{- if and (ne .Version "review") (ne .Channel "root") }}
{{- $_ := set . "BaseURL" ( printf "v%s" .Channel ) }}
{{- else if ne .Channel "root" }}
{{- $_ := set . "BaseURL" .Channel }}
{{- end }}
      jekyll build -s /app/docs  -d /app/_main_site/{{ if .BaseURL }} --baseurl /{{ .BaseURL }}{{ end }} --config /app/docs/_config.yml,/tmp/_config_additional.yml
      jekyll build -s /app/docs  -d /app/_ru_site/{{ if .BaseURL }} --baseurl /{{ .BaseURL }}{{ end }} --config /app/docs/_config.yml,/app/docs/_config_ru.yml,/tmp/_config_additional.yml
    args:
      executable: /bin/bash
      chdir: /app/docs
git:
- url: https://github.com/flant/werf.git
  to: /app/
  owner: jekyll
  group: jekyll
{{- if .Commit }}
  commit: {{ .Commit }}
{{- else }}
  tag: {{ .Version }}
{{- end }}
  stageDependencies:
    install: ['docs/Gemfile','docs/Gemfile.lock']
    beforeSetup: '**/*'
  includePaths: 'docs'
  excludePaths: '**/*.sh'
{{- end }}


Название артефакта должно быть уникальным. Мы можем этого достичь, например, добавив название канала (значение переменной .Channel) в качестве суффикса названия артефакта: artifact: doc-{{ .Channel }}. Но нужно понимать, что при импорте из артефактов необходимо будет ссылаться на такие же имена.

При описании артефакта используется такая возможность werf, как монтирование. Монтирование с указанием служебной директории build_dir позволяет сохранять кэш Jekyll между запусками pipeline, что существенно ускоряет пересборку.

Также в могли заметить использование файла releases.yml — это YAML-файл с данными о релизах, запрашиваемый с github.com (артефакт, получаемый при выполнении pipeline). Он нужен при компиляции сайта, но в контексте статьи нам он интересен тем, что от его состояния зависит пересборка только одного артефакта — артефакта сайта корневой версии (в других артефактах он не нужен).

Это реализовано с помощью условного оператора if Go-шаблонов и конструкции {{ $Root.Files.Get "releases.yml" | sha256sum }} в этапе стадии. Работает это следующим образом: при сборке артефакта для корневой версии (переменная .Channel равна root) хэш файла releases.yml влияет на сигнатуру всей стадии, так как он является составляющей имени Ansible-задания (параметр name). Таким образом, при изменении содержимого файла releases.yml соответствующий артефакт будет пересобран.

Обратите внимание также на работу с внешним репозиторием. В образ артефакта из репозитория werf, добавляется только каталог /docs, причем в зависимости от переданных параметров добавляются данные сразу необходимого тега или ветки master (по умолчанию).

Чтобы использовать шаблон артефакта для генерации описания артефакта переданных версий каналов и релизов, организуем цикл по переменной .WerfVersions в werf.yaml:

{{ range .WerfVersions -}}
{{ $VersionsDict := splitn "%" 2 . -}}
{{ dict "Version" $VersionsDict._1 "Channel" $VersionsDict._0 "Root" $Root | include "doc_artifact" }}
---
{{ end -}}


Т.к. цикл сгенерирует несколько артефактов (мы надеемся на это), необходимо учесть разделитель между ними — последовательность --- (подробнее о синтаксисе файла конфигурации см. в документации). Как определились ранее, при вызове шаблона в цикле мы передаем параметры версии, URL и корневой контекст.

Аналогично, но уже без цикла, вызываем шаблон артефакта для «особых случаев»: для корневой версии, а также версии из review-коммита:

{{ dict "Version" .WerfRootVersion "Channel" "root" "Root" $Root  | include "doc_artifact" }}
---
{{- if .WerfReviewCommit }}
{{ dict "Version" "review" "Channel" "review" "Commit" .WerfReviewCommit "Root" $Root  | include "doc_artifact" }}
{{- end }}


Обратите внимание, что артефакт для review-коммита будет собираться только в том случае, если установлена переменная .WerfReviewCommit.

Артефакты готовы — пора заняться импортом!

Конечный образ, предназначенный для запуска в Kubernetes, представляет собой обычный NGINX, в который добавлен файл конфигурации сервера nginx.conf и статика из артефактов. Кроме артефакта корневой версии сайта нам нужно повторить цикл по переменной .WerfVersions для импорта артефактов версий каналов и релизов + соблюсти правило именования артефактов, которое мы приняли ранее. Поскольку каждый артефакт хранит версии сайта для двух языков, импортируем их в места, предусмотренные конфигурацией.

Описание конечного образа werf-doc
image: werf-doc
from: nginx:stable-alpine
ansible:
  setup:
  - name: "Setup /etc/nginx/nginx.conf"
    copy:
      content: |
{{ .Files.Get ".werf/nginx.conf" | indent 8 }}
      dest: /etc/nginx/nginx.conf
  - file:
      path: "{{`{{ item }}`}}"
      state: directory
      mode: 0777
    with_items:
    - /app/main_site/assets
    - /app/ru_site/assets
import:
- artifact: doc-root
  add: /app/_main_site
  to: /app/main_site
  before: setup
- artifact: doc-root
  add: /app/_ru_site
  to: /app/ru_site
  before: setup
{{ range .WerfVersions -}}
{{ $VersionsDict := splitn "%" 2 . -}}
{{ $Channel := $VersionsDict._0 -}}
{{ $Version := $VersionsDict._1 -}}
- artifact: doc-{{ $Channel }}
  add: /app/_main_site
  to: /app/main_site/v{{ $Channel }}
  before: setup
{{ end -}}
{{ range .WerfVersions -}}
{{ $VersionsDict := splitn "%" 2 . -}}
{{ $Channel := $VersionsDict._0 -}}
{{ $Version := $VersionsDict._1 -}}
- artifact: doc-{{ $Channel }}
  add: /app/_ru_site
  to: /app/ru_site/v{{ $Channel }}
  before: setup
{{ end -}}


Дополнительный образ, который вместе с основным запускается на dev-контуре, содержит только две версии сайта: версию из review-коммита и корневую версию сайта (там общие ассеты и, если помните, данные по релизам). Таким образом, дополнительный образ от основного будет отличаться только секцией импорта (ну и, конечно, именем):

image: werf-dev
...
import:
- artifact: doc-root
  add: /app/_main_site
  to: /app/main_site
  before: setup
- artifact: doc-root
  add: /app/_ru_site
  to: /app/ru_site
  before: setup
{{- if .WerfReviewCommit  }}
- artifact: doc-review
  add: /app/_main_site
  to: /app/main_site/review
  before: setup
- artifact: doc-review
  add: /app/_ru_site
  to: /app/ru_site/review
  before: setup
{{- end }}


Как уже замечали выше, артефакт для review-коммита будет генерироваться только при запуске установленной переменной окружения REVIEW_SHA. Можно было бы вообще не генерировать образ werf-dev, если нет переменной окружения REVIEW_SHA, но для того, чтобы очистка по политикам Docker-образов в werf работала для образа werf-dev, мы оставим его собираться только с артефактом корневой версии (все равно он уже собран), для упрощения структуры pipeline.

Сборка готова! Переходим к CI/CD и важным нюансам.

Пайплайн в GitLab CI и особенности динамической сборки


При запуске сборки нам необходимо установить переменные окружения, используемые в werf.yaml. Это не касается переменной REVIEW_SHA, которую будем устанавливать при вызове pipeline от хука GitHub.

Формирование необходимых внешних данных вынесем в Bash-скрипт generate_artifacts, который будет генерировать два артефакта pipeline GitLab:

  • файл releases.yml с данными о релизах,
  • файл common_envs.sh, содержащий переменные окружения для экспорта.


Содержимое файла generate_artifacts вы найдете в нашем репозитории с примерами. Само получение данных не является предметом статьи, а вот файл common_envs.sh нам важен, т.к. от него зависит работа werf. Пример его содержимого:

export RELEASES='1.0%v1.0.6-4'
export CHANNELS='1.0-alpha%v1.0.7-1 1.0-beta%v1.0.7-1 1.0-ea%v1.0.6-4 1.0-stable%v1.0.6-4 1.0-rock-solid%v1.0.6-4'
export ROOT_VERSION='v1.0.6-4'


Использовать вывод такого скрипта можно, например, с помощью Bash-функции source.

А теперь самое интересное. Чтобы и сборка, и деплой приложения работали правильно, необходимо сделать так, чтобы werf.yaml был одинаковым как минимум в рамках одного pipeline. Если это условие не выполнить, то сигнатуры стадий, которые рассчитывает werf при сборке и, например, деплое, будут разными. Это приведет к ошибке деплоя, т.к. необходимый для деплоя образ будет отсутствовать.

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

Если генерация werf.yaml зависит от внешних данных (например, списка актуальных версий, как в нашем случае), то состав и значения таких данных должны фиксироваться в рамках pipeline. Это особенно важно, если внешние параметры меняются довольно часто.

Мы будем получать и фиксировать внешние данные на первой стадии пайплайна в GitLab (Prebuild) и передавать их далее в виде артефакта GitLab CI. Это позволит запускать и перезапускать задания pipelinе«а (сборка, деплой, очистка) с одинаковой конфигурацией в werf.yaml.

Содержание стадии Prebuild файла .gitlab-ci.yml:

Prebuild:
  stage: prebuild
  script:
    - bash ./generate_artifacts 1> common_envs.sh
    - cat ./common_envs.sh
  artifacts:
    paths:
      - releases.yml
      - common_envs.sh
    expire_in: 2 week


Зафиксировав внешние данные в артефакте, можно выполнять сборку и деплой, используя стандартные стадии пайплайна GitLab CI: Build и Deploy. Сам пайплайн мы запускаем по хукам из GitHub-репозитория werf (т.е. при изменениях в репозитории на GitHub). Данные для них можно взять в свойствах проекта GitLab в разделе CI / CD Settings → Pipeline triggers, а затем создадим в GitHub соответствующий Webhook (Settings → Webhooks).

Стадия сборки будет выглядеть следующим образом:

Build:
  stage: build
  script:
    - type multiwerf && . $(multiwerf use 1.0 alpha --as-file)
    - type werf && source <(werf ci-env gitlab --tagging-strategy tag-or-branch --verbose)
    - source common_envs.sh
    - werf build-and-publish --stages-storage :local
  except:
    refs:
      - schedules
  dependencies:
    - Prebuild


GitLab добавит в стадию сборки два артефакта из стадии Prebuild, так что мы экспортируем переменные с подготовленными входными данными с помощью конструкции source common_envs.sh. Запускаем стадию сборки во всех случаях, кроме запуска пайплайна по расписанию. По расписанию у нас будет запускаться пайплайн для очистки — выполнять сборку в этом случае не нужно.

На стадии деплоя опишем два задания — отдельно для деплоя на production- и dev-контуры, с использованием YAML-шаблона:

.base_deploy: &base_deploy
  stage: deploy
  script:
    - type multiwerf && . $(multiwerf use 1.0 alpha --as-file)
    - type werf && source <(werf ci-env gitlab --tagging-strategy tag-or-branch --verbose)
    - source common_envs.sh
    - werf deploy --stages-storage :local
  dependencies:
    - Prebuild
  except:
    refs:
      - schedules

Deploy to Production:
  <<: *base_deploy
  variables:
    WERF_KUBE_CONTEXT: prod
  environment:
    name: production
    url: werf.io
  only:
    refs:
      - master
  except:
    variables:
      - $REVIEW_SHA
    refs:
      - schedules

Deploy to Test:
  <<: *base_deploy
  variables:
    WERF_KUBE_CONTEXT: dev
  environment:
    name: test
    url: werf.test.flant.com
  except:
    refs:
      - schedules
  only:
    variables:
      - $REVIEW_SHA


Задания по сути отличаются только указанием контекста кластера, в который werf должен выполнять деплой (WERF_KUBE_CONTEXT), и установкой переменных окружения контура (environment.name и environment.url), которые используются затем в шаблонах Helm-чарта. Содержание шаблонов приводить не будем, т.к. там нет ничего интересного для рассматриваемой темы, но вы также можете их найти в репозитории к статье.

Финальный штрих


Поскольку версии werf выходят довольно часто, часто будут и собираться новые образы, а Docker Registry — постоянно расти. Поэтому обязательно нужно настроить автоматическую очистку образов по политикам. Сделать это очень просто.

Для реализации потребуется:

  • Добавить стадию очистки в .gitlab-ci.yml;
  • Добавить периодическое выполнение задания очистки;
  • Настроить переменную окружения с токеном доступа на запись.


Добавляем стадию очистки в .gitlab-ci.yml:

Cleanup:
  stage: cleanup
  script:
    - type multiwerf && . $(multiwerf use 1.0 alpha --as-file)
    - type werf && source <(werf ci-env gitlab --tagging-strategy tag-or-branch --verbose)
    - source common_envs.sh
    - docker login -u nobody -p ${WERF_IMAGES_CLEANUP_PASSWORD} ${WERF_IMAGES_REPO}
    - werf cleanup --stages-storage :local
  only:
    refs:
      - schedules


Почти все мы это уже видели чуть выше — только для очистки нужно предварительно авторизоваться в Docker Registry с токеном, имеющим права на удаление образов в Docker Registry (у выдаваемого автоматически токена задания GitLab CI нет таких прав). Токен нужно завести в GitLab заранее и указать его значение в переменной окружения WERF_IMAGES_CLEANUP_PASSWORD проекта (CI/CD Settings → Variables).

Добавление задания очистки с необходимым расписанием производится в CI/CD →
Schedules
.

Всё: проект в Docker Registry больше не будет постоянно расти от неиспользуемых образов.

В завершении практической части напомню, что полные листинги из статьи доступны в Git:


Результат


  1. Мы получили логичную структуру сборки: один артефакт на одну версию.
  2. Сборка универсальна и не требует ручных изменений при выходе новых версий werf: документация на сайте автоматически обновляется.
  3. Собирается два образа для разных контуров.
  4. Работает быстро, т.к. максимально используется кэширование — при выходе новой версии werf или вызове GitHub-хука для review-коммита — осуществляется пересборка только соответствующего артефакта с изменённой версией.
  5. Не нужно думать об удалении неиспользуемых образов: очистка по политикам werf будет поддерживать порядок в Docker Registry.


Выводы


  • Использование werf позволяет сборке работать быстро благодаря кэшированию как самой сборки, так и кэшированию при работе с внешними репозиториями.
  • Работа с внешними Git-репозиториями избавляет от необходимости клонировать репозиторий каждый раз полностью или изобретать велосипед с хитрой логикой оптимизации. werf использует кэш и делает клонирование только один раз, а далее использует fetch и только по необходимости.
  • Возможность использования Go-шаблонов в файле конфигурации сборки werf.yaml позволяет описать сборку, результат которой зависит от внешних данных.
  • Использование монтирования в werf значительно ускоряет сбору артефактов — за счет кэша, являющегося общим для всех pipeline.
  • werf позволяет легко настроить очистку, что особенно актуально при динамической сборке.


P.S.


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

© Habrahabr.ru