Мы уже не раз рассказывали про свой GitOps-инструмент werf, а в этот раз хотели бы поделиться опытом сборки сайта с документацией самого проекта — werf.io (его русскоязычная версия — ru.werf.io). Это обычный статический сайт, однако его сборка интересна тем, что построена с использованием динамического количества артефактов.
Вдаваться в нюансы структуры сайта: генерацию общего меню для всех версий, страницы с информацией о релизах и т.п. — не будем. Вместо этого, сфокусируемся на вопросах и особенностях динамической сборки и немного на сопутствующих процессах 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).
Итого, у сайта доступны следующие версии:
- корневая (открывается по умолчанию),
- для каждого активного канала обновлений каждого релиза (например, werf.io/v1.0-beta).
Для генерации конкретной версии сайта в общем случае достаточно выполнить его компиляцию средствами Jekyll, запустив в каталоге /docs
репозитория werf соответствующую команду (jekyll build
), предварительно переключившись на Git-тег необходимой версии.
Остается только добавить, что:
- для сборки используется сама утилита (werf);
- CI/CD-процессы построены на базе GitLab CI;
- и все это, конечно, работает в Kubernetes.
Задачи
Теперь сформулируем задачи, учитывающие всю описанную специфику:
- После смены версии werf на любом канале обновлений документация на сайте должна автоматически обновляться.
- Для разработки нужно иметь возможность иногда просматривать предварительные версии сайта.
Перекомпиляцию сайта необходимо выполнять после смены версии на любом канале из соответствующих 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
для импорта артефактов версий каналов и релизов + соблюсти правило именования артефактов, которое мы приняли ранее. Поскольку каждый артефакт хранит версии сайта для двух языков, импортируем их в места, предусмотренные конфигурацией.
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:
Результат
- Мы получили логичную структуру сборки: один артефакт на одну версию.
- Сборка универсальна и не требует ручных изменений при выходе новых версий werf: документация на сайте автоматически обновляется.
- Собирается два образа для разных контуров.
- Работает быстро, т.к. максимально используется кэширование — при выходе новой версии werf или вызове GitHub-хука для review-коммита — осуществляется пересборка только соответствующего артефакта с изменённой версией.
- Не нужно думать об удалении неиспользуемых образов: очистка по политикам werf будет поддерживать порядок в Docker Registry.
Выводы
- Использование werf позволяет сборке работать быстро благодаря кэшированию как самой сборки, так и кэшированию при работе с внешними репозиториями.
- Работа с внешними Git-репозиториями избавляет от необходимости клонировать репозиторий каждый раз полностью или изобретать велосипед с хитрой логикой оптимизации. werf использует кэш и делает клонирование только один раз, а далее использует
fetch
и только по необходимости. - Возможность использования Go-шаблонов в файле конфигурации сборки
werf.yaml
позволяет описать сборку, результат которой зависит от внешних данных. - Использование монтирования в werf значительно ускоряет сбору артефактов — за счет кэша, являющегося общим для всех pipeline.
- werf позволяет легко настроить очистку, что особенно актуально при динамической сборке.
P.S.
Читайте также в нашем блоге:
- «Запуск команд в процессе доставки нового релиза приложения в Kubernetes»;
- «Сборка и деплой однотипных микросервисов с werf и GitLab CI»;
- «Использование werf для выката комплексных Helm-чартов»;
- «Представляем werf 1.0 stable: при чём тут GitOps, статус и планы».
Автор: Артем