Хабрахабр

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

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

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

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

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

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

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

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

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

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

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

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

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

Пишем werf.yaml

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

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

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

  • 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-шаблонов, присвоив им значения из переменных окружения:

}
{{ $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 }}. Но нужно понимать, что при импорте из артефактов необходимо будет ссылаться на такие же имена.

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

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

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

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

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.

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

Содержание стадии 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.

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

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»