Привет, Хабр! С вами не опять, а снова Виктор Ашакин, DevOps-инженер компании «Флант», и мы заканчиваем приготовление экосистемы управления кодом и его развёртывания в Deckhouse Kubernetes Platform на основе Gitea. В прошлой части статьи мы сделали всё необходимое для создания тестового репозитория и написания Gitea Actions-пайплайнов и можем двигаться дальше.
В этом — завершающем — материале создадим репозиторий с кодом приложения, подготовим простенький Helm-чарт и Gitea Actions-пайплайн, в котором опишем автоматический процесс сборки и деплоя приложения в кластер Kubernetes.
Ключевым компонентом автоматизации CI/CD будет werf, Open Source-утилита, созданная «Флантом» и пополнившая ряды Sandbox-проектов CNCF. Она организует полный цикл доставки приложения в Kubernetes и использует Git как единый источник истины для состояния приложения, развёрнутого в кластере. Утилита werf позволяет собирать и упаковывать код приложения в контейнеры, эффективно кэшировать стадии и изменения при сборке, что значительно ускоряет пайплайн.
С выходом версии 2.0 и переходом на новый движок werf ещё качественнее развёртывает приложения в кластер Kubernetes. В процессе активной работы над приложениями в container registry и на раннере накапливается значительное количество ненужных образов контейнеров — werf позволяет эффективно чистить весь лишний мусор.
Все эти полезные функции мы применим в нашем пайплайне.
Подготовка тестового репозитория
Для начала создадим в Gitea тестовый репозиторий hello-world
внутри организации: в правом верхнем углу кнопка «+» → Owner: your_organization → Create Repository.
Клонируем репозиторий к себе:
git clone git@your_gitea.com:team-romeo/hello-world.git
cd hello-worl
touch README.md
git add README.md
git commit -m "first commit"
git push
Теперь добавим код нашего приложения. В моём случае это простая HTML-страница, которая лежит в каталоге app/index.html
:
<!DOCTYPE html>
<html>
<body>
<header>
<h1>
Hi! I'm another one typical nginx!
</h1>
<h2>
Kubernetes, Kubernetes everywhere!
</h2>
</header>
</body>
</html>
Теперь нужно подготовить файл werf.yaml для сборки нашего приложения. В моём примере происходит простое копирование файла в контейнер nginx. В реальной ситуации может происходить многоуровневая сборка с переиспользованием стадий сборки нескольких контейнеров.
werf нативно работает с файлами Dockerfile, но при этом мы теряем преимущество кэширования стадий сборки. Для каждого изменения состояния репозитория
будет запускаться повторный сборочный процесс, что зачастую избыточно. Зачем пересобирать приложение, если мы добавили в репозиторий Helm-чарт,
поправили README.md или файл, который в коде не участвует? Также избыточно постоянно переустанавливать в контейнере одни и те же пакеты.
Я покажу, как оптимизировать сборку с помощью werf stapel. В корне создаём файл werf.yaml — это аналог Dockerfile, сценарий сборки:
# Название приложения, оно будет использоваться в чарте как .Chart.Name
project: hello-world
configVersion: 1
---
image: nginx
from: nginx:1.24.0-bullseye
# Директива указывает, что и куда кладём,
# можно задать несколько директив списком
git:
- add: /app
to: /app
# Директива указывает, что исключать при добавлении в контейнер,
# также изменения в этих файлах не будут отслеживаться
excludePaths:
- .helm
- werf.yaml
- .gitea
- README.md
# Указываем, что на стадии install отслеживаем все файлы в каталоге /app.
# Можно фильтровать файлы по маскам, например */*.html
stageDependencies:
install:
- "*/**"
# В этой директиве управляем процессом сборки,
# стадии удобно комбинировать со стадиями добавления кода
# Разные стадии кэшируются отдельно
shell:
install:
- sed -i 's/Kubernetes/Deckhouse/g' app/index.html
setup:
- rm /etc/nginx/conf.d/default.conf
Процесс сборки у меня достаточно прост: в Docker-образ nginx я помещаю каталог /app по пути /app, после выполняю команду редактирования index.html
, а затем удаляю дефолтный конфиг nginx. Последний шаг нужен потому, что мы подложим наш конфиг в виде configmap для удобства редактирования и выката. Но об этом чуть позже.
В моём werf.yaml я оставил комментарии, из которых понятны структура файла и директива управления сборкой. Пример выше расширен для наглядности, но его можно максимально упростить:
project: hello-world
configVersion: 1
---
image: nginx
from: nginx:1.24.0-bullseye
git:
- add: /app
to: /app
stageDependencies:
install:
- "*/**"
shell:
setup:
- sed -i 's/Kubernetes/Deckhouse/g' app/index.html
- rm /etc/nginx/conf.d/default.conf
Helm-чарт
Теперь подготовим Helm-чарт приложения. По умолчанию werf ищет каталог с чартом в корне репозитория в каталоге .helm. Структура нашего чарта будет выглядеть так:
├── .helm
├── templates
│ ├── deployment.yaml
│ ├── ingress.yaml
│ ├── nginx-config-cm.yaml
│ └── service.yaml
└── values.yaml
Создаём ресурсы чарта
# deployment.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Chart.Name }}
labels:
app: {{ .Chart.Name }}
annotations:
# Аннотация включает автоматическое отслеживание CM и secrets
# Перезапускает под в случае изменения
"pod-reloader.deckhouse.io/auto": "true"
spec:
revisionHistoryLimit: 1
selector:
matchLabels:
app: {{ .Chart.Name }}
replicas: 1
template:
metadata:
labels:
app: {{ .Chart.Name }}
spec:
volumes:
- name: configs
configMap:
name: {{ .Chart.Name }}
imagePullSecrets:
# Название секрета для подключения к container registry
- name: gitea-regsecret
containers:
- name: nginx
image: {{ .Values.werf.image.nginx }}
ports:
- containerPort: 80
volumeMounts:
- name: configs
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
---
# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Chart.Name }}
labels:
app: {{ .Chart.Name }}
spec:
rules:
# С помощью переменной $WERF_ENV, которая передаётся при развёртывании, определяем, какой url подставить в окружение
- host: {{ pluck .Values.werf.env .Values.app.url | first | default .Values.app.url._default }}
http:
paths:
- path: "/"
pathType: ImplementationSpecific
backend:
service:
name: {{ .Chart.Name }}
port:
number: 80
---
# service.yaml
apiVersion: v1
kind: Service
metadata:
name: {{ .Chart.Name }}
labels:
app: {{ .Chart.Name }}
spec:
selector:
app: {{ .Chart.Name }}
ports:
- name: http
port: 80
---
# nginx-config-cm.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Chart.Name }}
labels:
app: {{ .Chart.Name }}
data:
nginx.conf: |
error_log /dev/stderr;
events {
worker_connections 100000;
multi_accept on;
}
http {
charset utf-8;
server {
listen 80;
index index.html;
root /app;
error_log /dev/stderr;
location / {
try_files $uri /index.html$is_args$args;
}
}
}
---
# values.yaml
app:
url:
_default: 'hello-dev.example-domain.com'
stage: 'hello-flant.example-domain.com'
prod: 'hello.example-domain.com'
Внимательный читатель заметит, что в Helm-чарте активно используется переменная .Chart.Name
, но при этом сам файл chart.yaml, из которого обычно берётся эта переменная, отсутствует. Ошибки нет, .Chart.Name
подставляется из переменной project: hello-world
, указанной в файле werf.yaml
В манифесте deployment.yaml в директиве image
: используется переменная {{ .Values.werf.image.nginx }}
. При деплое werf подставит туда image:tag
из стадии build. Эту переменную нужно подставлять везде, где используются собранные образы из werf.yaml.
В ресурсе ingress.yaml мы подставляем доменное имя приложения согласно окружению, в которое выкатываем приложение. Название окружения задаётся на стадии выката через ключ команды werf converge --env $ENV
или через переменную окружения WERF_ENV
. Подобным методом — через Helm-функцию pluck
— удобно шаблонизировать переменные.
В ресурсе deployment.yaml есть аннотация "pod-reloader.deckhouse.io/auto": "true"
, которая показывает модулю DKP pod-reloader, что нужно следить за данным ресурсом и автоматически перезагружать контейнеры, если в связанных конфигурационных файлах или секретах произошли изменения.
Наш чарт готов, добавляем в репозиторий всё, что создали в каталоге:
git add .
git commit -am 'add chart'
git push
Gitea Actions CI/CD-пайплайн
Gitea Actions копирует подход GitHub Actions, их синтаксисы практически идентичны. Большинство пайплайнов, написанных для GitHub Actions, скорее всего, будут работать и в Gitea Actions.
Gitea Actions поддерживает контекстные переменные от GitHub Actions. На момент написания статьи не поддерживался только метод on: workflow_dispatch
(в релизе v1.22), что исключает ручной запуск пайплайна. Разработчики обещают добавить данный функционал в релизе 1.23.
Пайплайн будет состоять из двух стадий — сборки и развёртывания. Триггер для запуска пайплайна — коммит в Gitea.
Сборка (build-and-publish) — на этой стадии в раннер загружается код приложения, затем утилита werf на основании сценария сборки (werf.yaml) собирает и упаковывает код в образ Docker-контейнера. Сборка происходит на основании состояния кода, зафиксированного в коммите, который запустил пайплайн. В финале werf отправляет собранный Docker-образ в container registry.
Развёртывание (deploy) — на этой стадии werf формирует Helm-чарт (производит рендер YAML-манифестов), определяет образ, собранный на предыдущем шаге и подставляет его в Helm-чарт. Далее werf развёртывает Helm-чарт в кластере Kubernetes как Helm-релиз.
В контекстных переменных мы можем определить, из какой ветки был запущен пайплайн, на основе какого события, кто делал коммит и так далее. Эти данные позволяют задавать условия для выполнения тех или иных стадий пайплайна.
В моём примере сборка происходит из любого коммита, то есть Docker-образ будет одинаков для тестового и prod-окружения и займёт меньше места в хранилище образов. Разделение между продуктовым кодом и тестовым желательно проводить за счёт переменных окружения, которые передаются в Helm-релиз на этапе развёртывания.
Для наглядности приложение будет развёртываться в три разных окружения в зависимости от следующих условий:
Выкат в dev-окружение из любых веток и тегов, кроме веток master, stage и тегов с маской
release-*
.Выкат в prod-окружение из ветки мастер или тега с маской
release-*
.Выкат в stage-окружение из ветки stage.
Пайплайны должны находиться в каталоге .gitea/workflows
и иметь формат .yaml. Создадим каталог и пайплайн с произвольным названием:
mkdir -vp .gitea/workflows
vim ci-cd.yaml
А теперь рассмотрим готовый пайплайн. Все пояснения будут после.
Готовый пайплайн
name: build and deploy
run-name: ${{ gitea.actor }} is testing out Gitea Actions
# Условие для запуска пайплайна — запушили коммит в репозиторий
on: [push]
# Переменные, заданные на глобальном уровне, будут доступны на глобальном уровне во всех стадиях пайплайна
env:
# Обязательные переменные
APP_NAME: hello-world
WERF_VERSION: 2 stable
WERF_REPO: ${{ vars.WERF_REPO }}/${{ gitea.repository }}
WERF_IMAGE_REPO_USER: ${{ vars.WERF_IMAGE_REPO_USER }}
WERF_IMAGES_REPO_TOKEN: ${{ secrets.WERF_IMAGES_REPO_TOKEN }}
# Устанавливаем аннотации, нужные при развёртывании (необязательные, но полезные переменные)
WERF_ADD_ANNOTATION_WERF_RELEASE_CHANNEL: 'werf.io/release-channel=${{ env.WERF_VERSION }}'
WERF_ADD_ANNOTATION_PROJECT_GIT: 'project.werf.io/git=${{ gitea.event.repository.html_url }}'
WERF_ADD_ANNOTATION_CI_COMMIT: 'ci.werf.io/commit=${{ gitea.event.head_commit.url }}'
WERF_ADD_ANNOTATION_GITEA_CI_PIPELINE_URL: 'gitea.ci.werf.io/pipeline-url=${{ gitea.event.repository.html_url }}/actions/runs/${{ gitea.run_id }}'
WERF_ADD_ANNOTATION_GITEA_CI_JOB_URL: 'gitea.ci.werf.io/job-url=${{ gitea.event.repository.html_url }}/actions/runs/${{ gitea.run_id }}/jobs/${{ gitea.action }}'
# Стадии пайплайна
jobs:
# Стадия сборки
build-and-publish:
name: Build and Publish
# Указываем тег, определяющий раннер
runs-on: werf
# Обязательный шаг всех стадий, подгружаем репозиторий и его состояние
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
# Необязательный шаг, добавлен для возможности просмотра массива переменных контекста пайплайна
# Используя контекстные переменные, строим условия для запуска стадий и шагов пайплайна
- name: Dump Gitea context
env:
JOB_CONTEXT: ${{ toJson(gitea) }}
run: echo "$JOB_CONTEXT"
# werf логинится в репозиторий и производит сборку
- name: Build
run: |
source "$(~/bin/trdl use werf 2 stable)"
werf cr login -u $WERF_IMAGE_REPO_USER -p ${{ secrets.WERF_IMAGES_REPO_TOKEN }} $WERF_REPO
werf build
# Развёртывание в dev
deploy-dev:
name: Deploy dev
needs: build-and-publish
runs-on: werf
# Условие выполнения текущей стадии deploy-dev
# Пайплайн запускается не из ветки master, не из ветки stage, не из тега, который начинается с release-
if: gitea.ref != 'refs/heads/master' && gitea.ref != 'refs/heads/stage' && ! startsWith(gitea.ref, 'refs/tags/release-')
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
# Шаг развёртывания приложения в кластер
# Активируем werf с указанием версии, в данном случае
# переменная WERF_ENV участвует в формировании Helm-чарта
- name: Deploy
env:
WERF_ENV: dev
run: |
source "$(~/bin/trdl use werf ${{ env.WERF_VERSION }})"
werf converge -Z
# Развёртывание в prod
deploy-prod:
name: Deploy prod
needs: build-and-publish
runs-on: werf
if: gitea.ref == 'refs/heads/master' || startsWith(gitea.ref, 'refs/tags/release-')
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Deploy
env:
WERF_ENV: prod
run: |
source "$(~/bin/trdl use werf ${{ env.WERF_VERSION }})"
werf converge -Z
# Развёртывание в stage
deploy-stage:
name: Deploy stage
needs: build-and-publish
runs-on: werf
if: gitea.ref == 'refs/heads/stage'
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
# Добавлены ключи для converge
# Меняют поведение по умолчанию
# set -x добавлен для демонстрации отладки
- name: Deploy
env:
WERF_ENV: stage
run: |
source "$(~/bin/trdl use werf ${{ env.WERF_VERSION }})"
set -x
werf converge -Z \
--env ${WERF_ENV} \
--namespace "${APP_NAME}-${WERF_ENV}" \
--release "${APP_NAME}-${WERF_ENV}"
Анатомия пайплайна
Краткая структура пайплайна:
name: build and deploy # Произвольное название пайплайна
on: [push] # Условие для запуска пайплайна — запушили коммит в репозиторий
env: {} # Список переменных окружения, используемых в стадиях и шагах
jobs: # Секция стадий
build: # Стадия
runs-on: werf # Лейбл раннера, на котором запустим код
if: some conditions # Условия запуска стадии
steps: [] # Шаги стадии
deploy:
needs: build-and-publish # Зависимость стадии от выполнения других стадий
steps: []
На верхнем уровне Gitea Actions-пайплайна задаются глобальные директивы, такие как название, триггеры запуска on: []
и переменные env: []
. Триггами запуска пайплайна могут быть несколько событий, например создание issue, форк или удаление ветки. В моём случае это push изменений в репозиторий.
Директиву env: {}
можно задать на разных уровнях, и переменные в разных стадиях могут переопределять друг друга. В данном случае задаются глобальные переменные, которые будут использованы на шагах сборки и развёртывания. Важно отметить, что при определении переменных доступа к container registry используются контексты vars
и secrets
.
env:
WERF_REPO: ${{ vars.WERF_REPO }}/${{ gitea.repository }}
WERF_IMAGE_REPO_USER: ${{ vars.WERF_IMAGE_REPO_USER }}
WERF_IMAGES_REPO_TOKEN: ${{ secrets.WERF_IMAGES_REPO_TOKEN }}
vars
и secrets
— это массив переменных, которые задаются в Gitea-репозитории, группе или организации. Переменные WERF_REPO
, WERF_IMAGE_REPO_USER
и WERF_IMAGES_REPO_TOKEN
мы задавали в разделе настройки CI/CD.
Переменные вида WERF_ADD_ANNOTATION_*
добавляются всем YAML-манифестам во время деплоя Helm-релиза в качестве аннотаций. По ним удобно определять
репозиторий, из которого был развёрнут ресурс, сам пайплайн и его номер. Можно перейти по ссылке и перевыкатить ресурс или отследить изменение в репозитории, которое привело к поломке.
Рассмотрим основные моменты стадий пайплайна.
Директива runs-on: werf
с помощью лейбла задаёт раннер, на котором будет запущена стадия.
С помощью директивы if:
определяется условие выполнения стадии. В пайплайне в директиве if:
используется контекст массива переменных Gitea. if: gitea.ref != 'refs/heads/master' && gitea.ref != 'refs/heads/stage' && ! startsWith(gitea.ref, 'refs/tags/release-')
. Данный контекст аналогичен контексту GitHub из GitHub Actions, он тоже поддерживается в Gitea Actions.
needs:
— директива, которая задаёт зависимость одной стадии от другой, в данном случае прямая зависимость стадии выката от сборки.
Шаги steps
— отдельные процессы внутри стадии. Шагами могут быть shell-команды, например run: echo "$JOB_CONTEXT"
или werf build
. Также можно использовать уже готовые шаги, встроенные из внешних репозиториев.
Уже готовый функционал используется в шаге Checkout code с помощью директивы uses
:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
На шаге Checkout code в стадию подгружается репозиторий, это нужно делать в каждой стадии пайплайна.
Рассмотрим подробнее шаги Build и Deploy.
- name: Build
run: |
source "$(~/bin/trdl use werf ${{ env.WERF_VERSION }})"
werf cr login -u $WERF_IMAGE_REPO_USER -p ${{ secrets.WERF_IMAGES_REPO_TOKEN }} $WERF_REPO
werf build
В шаге Build с помощью |
используется многострочный shell. Команда source
активирует werf нужной версии, а с помощью переменной $WERF_VERSION
мы можем управлять версиями. Это может быть удобно при обновлении версии или использовании нового функционала. Можно создать эту переменную на глобальном уровне организации, группы или репозитория.
werf cr login
— логинимся к container registry, используя переменные и секреты. Можно обращаться к массивам vars или secrets при описании shell-команд.
werf build
— запускается сборка Docker-образа на основе сценария werf.yaml.
Шаг Deploy stage:
deploy-stage:
steps:
- name: Deploy
env:
WERF_ENV: stage
run: |
source "$(~/bin/trdl use werf ${{ env.WERF_VERSION }})"
set -x
werf converge -Z \
--env ${WERF_ENV} \
--namespace "${APP_NAME}-${WERF_ENV}" \
--release "${APP_NAME}-${WERF_ENV}"
На данном примере наглядно показано, как можно управлять релизами и деплоем. Что и куда будет развёртываться, определяется переменными окружения или ключами командной строки.
WERF_ENV
— окружение.
WERF_NAMESPACE
— пространство имён, в которое будет развёрнуто приложение, по умолчанию берётся значение project из werf.yaml + WERF_ENV (project_$WERF_ENV)
.
WERF_RELEASE
— имя, которое будет присвоено Helm-release после развёртывания, по умолчанию аналогично WERF_NAMESPACE
.
С помощью данных переменных или ключей --env
, --namespace
, --release
мы управляем развёртыванием.
Развёртывания в dev- и prod-окружения производятся командой werf converge -Z
без дополнительных ключей, так как отрабатывает поведение по умолчанию, передаём лишь WERF_ENV
. Данная переменная в Helm-чарте будет доступна при обращении к встроенному массиву переменных werf {{ .Values.werf.env }}
.
Коммитим изменения и отправляем их в репозиторий:
git add .
git commit -am 'first deploy'
git push
Проверяем, включен ли Gitea Actions: страница репозитория → Settings → Enable Repository Actions
.
Теперь можно посмотреть на список пайплайнов и их стадии: страница репозитория → Actions:
Названием пайплайна будет message коммита, на котором пайплайн сработал. Давайте взглянем на стадии выполнения: здесь видно, что сборка и выкат прошли успешно.
Листинг выката:
werf выдаёт подробную информацию по своим процессам, что удобно при отладке.
В листинге выката видны название Helm-релиза и пространство имён, в которое развернулось приложение: Succeeded release "hello-world-dev" (namespace: "hello-world-dev")
. В моём случае выкат был не из master-ветки, поэтому приложение было развёрнуто в dev-окружение. Соответственно, при выкате в prod-окружение Helm-release и пространство имён будут называться hello-world-prod.
Бонусом в стадию deploy можно добавить шаг werf plan
— эта команда покажет план развёртывания и укажет, какие ресурсы будут изменены при следующем развертывании. А если что-то не так с Helm-чартом, то она выдаст ошибку и расскажет, в чём именно проблема.
- name: Deploy
env:
WERF_ENV: dev
run: |
source "$(~/bin/trdl use werf ${{ env.WERF_VERSION }})"
werf plan
Очистка container registry
Со временем в container registry накапливаются Docker-образы, которые уже не нужны. Для их эффективной очистки создадим в репозитории пайплайн, который будет выполняться по расписанию и удалять устаревшие Docker-образы проекта.
Пайплайн cleanup.yaml желательно иметь в каждом репозитории, в котором для сборки и развёртывания используется werf.
Файл создаём в директории .gitea/workflows/
:
# .gitea/workflows/cleanup.yaml
---
name: Cleanup container registry
run-name: Cleanup container registry
on:
schedule:
- cron: '05 00 * * *'
env:
WERF_REPO: ${{ vars.WERF_REPO }}/${{ gitea.repository }}
WERF_VERSION: '2 stable'
jobs:
cleanup:
name: Cleanup
runs-on: werf
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Fetch all history for all tags and branches
run: git fetch --prune --unshallow
- name: Cleanup
env:
WERF_ENV: stage
run: |
set -x
source "$(~/bin/trdl use werf ${WERF_VERSION:-'2 stable'} )"
werf cleanup $WERF_REPO
Особенность пайплайна cleanup.yaml заключается в его триггере активации. Он срабатывает по расписанию и имеет cron-синтаксис:
on:
schedule:
- cron: '05 00 * * *'
Запуск будет происходить каждый день в 00:05 согласно часовому поясу Gitea.
В финале наш репозиторий будет выглядеть так:
hello-world
├── app
│ └── index.html
├── .gitea
│ └── workflows
│ ├── ci-cd.yaml
│ └── cleanup.yaml
├── .helm
│ ├── templates
│ │ ├── deployment.yaml
│ │ ├── ingress.yaml
│ │ ├── nginx-config-cm.yaml
│ │ └── service.yaml
│ └── values.yaml
├── README.md
└── werf.yaml
Заключение
Мы прошли долгий путь и проделали большую работу: настроили Gitea, провели его интеграцию с Deckhouse Kubernetes Platform, настроили автоматизацию CI/CD, научились писать пайплайны Gitea Actions и, наконец, познакомились с отличным лучшим инструментом CI/CD — werf.
Поздравляю, вы великолепны!
P. S.
Читайте также в нашем блоге: