Добрый день.
На Хабре уже есть несколько статей о jenkins, ci/cd и kubernetes, но в данной я хочу сконцентрироваться не на разборе возможностей этих технологий, а на максимально простой их конфигурации для постройки ci/cd pipeline.
Я подразумеваю, что читатель имеет базовое понимание docker, и не буду останавливаться на темах установки и конфигурирования kubernetes. Все примеры будут показаны на minikube, но так же могут быть применены на EKS, GKE, либо подобных без значительных изменений.
Окружения
Я предлагаю использовать следующие окружения:
- test — для ручного деплоя и тестирования веток
- staging — окружение, куда автоматически деплоятся все изменения попавшие в master
- production — окружение используемое реальными пользователями, куда изменения попадут только после подтверждения их работоспособности на staging
Окружения будут организованы используя kubernetes namespaces в рамках одного кластера. Такой подход является максимально простым и быстрым на старте, но так же имеет свои недостатки: namespaces не полностью изолированы друг от друга в kubernetes.
В это примере каждый namespace будет иметь одинаковый набор ConfigMaps с конфигураций данного окружения:
apiVersion: v1
kind: Namespace
metadata:
name: production
---
apiVersion: v1
kind: ConfigMap
metadata:
name: environment.properties
namespace: production
data:
environment.properties: |
env=production
Helm
Helm это приложение которое помогает управлять ресурсами установленными на kubernetes.
Инструкцию по установке можно найти здесь.
Для начала работы необходимо инициализировать tiller pod для использования helm с кластером:
helm init
Jenkins
Я буду использовать Jenkins так как это достаточно простая, гибкая и популярная платформа для сборки проектов. Он будет установлен в отдельном namespace для изоляции от других окружений. Так как я планирую использовать helm в дальнейшем, то можно упростить установку Jenkins используя уже имеющиеся open source charts:
helm install --name jenkins --namespace jenkins -f jenkins/demo-values.yaml stable/jenkins
demo-values.yaml содержат версию Jenkins, набор предустановленых плагинов, доменное имя и прочую конфигурацию
Master:
Name: jenkins-master
Image: "jenkins/jenkins"
ImageTag: "2.163-slim"
OverwriteConfig: true
AdminUser: admin
AdminPassword: admin
InstallPlugins:
- kubernetes:1.14.3
- workflow-aggregator:2.6
- workflow-job:2.31
- credentials-binding:1.17
- git:3.9.3
- greenballs:1.15
- google-login:1.4
- role-strategy:2.9.0
- locale:1.4
ServicePort: 8080
ServiceType: NodePort
HostName: jenkins.192.168.99.100.nip.io
Ingress:
Path: /
Agent:
Enabled: true
Image: "jenkins/jnlp-slave"
ImageTag: "3.27-1"
#autoadjust agent resources limits
resources:
requests:
cpu: null
memory: null
limits:
cpu: null
memory: null
#to allow jenkins create slave pods
rbac:
install: true
Данная конфигурация использует admin/admin в качестве имени пользователя и пароля для входа, и может быть перенастроена в дальнейшем. Один из возможных вариантов — SSO от google (для этого необходим плагин google-login, его настройки находятся в Jenkins > Manage Jenkins > Configure Global Security > Access Control > Security Realm > Login with Google).
Jenkins будет сразу же настроен на автоматическое создание одноразовых slave для каждой сборки. Благодаря этому команда больше не будет ожидать свободный агент для сборки, а бизнес сможет сэкономить на количестве необходимых серверов.
Так же из коробки настроен PersistenceVolume для сохранения pipelines при перезапуске либо обновлении.
Для корректной работы скриптов автоматического деплоя понадобится дать разришение cluster-admin для Jenkins для получения списка ресурсов в kubernetes и манипулирвоания с ними.
kubectl create clusterrolebinding jenkins --clusterrole cluster-admin --serviceaccount=jenkins:default
В дальнейшем можно обновить Jenkins используя helm, в случае выхода новых версий плагинов либо изменений конфигурации.
helm upgrade jenkins stable/jenkins -f jenkins/demo-values.yaml
Это можно сделать и через интерфейс самого Jenkins, но с helm у вас появится возможность откатится к предыдущим ревизиям используя:
helm history jenkins
helm rollback jenkins ${revision}
Сборка приложениея
В качетве примера я буду собирать и деплоить простейшее spring boot приложение. Аналогично с Jenkins я буду использовать helm.
Сборка будет происходить в такой последовательности:
- checkout
- компиляция
- unit test
- integration test
- сборка артефакта
- деплой артефакта в docker registry
- деплой артефакта на staging (только для master branch)
Для этого я использую Jenkins file. На мой взгляд это очень гибкий (но, к сожалению, не самый простой) способ сконфигурировать сборку проекта. Одним из его преймуществ является возможность держать конфигрурацию сборки проекта в репозитории с самим проектом.
checkout
В случае с bitbucket либо github organization можно настроить Jenkins переодически сканировать целый аккаунт на наличие репозиториев с Jenkinsfile и автоматически создавать сборки для них. Jenkins будет собирать как master, так и ветки. Pull requests будут выведены в отдельную вкладку. Существует и более простой вариант — добавить отдельный git репозиторий, независимо от того где он хостится. В этом примере я именно так и сделаю. Все что необходимо это в меню Jenkins > New item > Multibranch Pipeline выбрать имя сборки и привязать git репозиторий.
Компиляция
Так как Jenkins для каждой сборки создает новый pod, то в случае использования maven либо подобных сборщиков, зависимости будут скачиваться заново каждый раз. Чтобы избежать этого, можно выделить PersistenceVolume для .m2 либо аналогичных кешей и монтировать в pod который осуществляет сборку проекта.
apiVersion: "v1"
kind: "PersistentVolumeClaim"
metadata:
name: "repository"
namespace: "jenkins"
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
В моем случае это позволило ускорить pipeline примерно с 4х до 1й минуты.
Версионирование
Для корректной работы CI/CD каждая сборка нуждается в уникальной версии.
Очень хорошим вариантом может быть использование semantic versioning. Это позволит отслеживать обратно совместимые и не совместимые изменения, но такое версионирование сложнее автоматизировать.
В данном примере я буду генерировать версию из id и даты коммита, а так же названия ветки, если это не master. Например 56e0fbdc-201802231623 или b3d3c143-201802231548-PR-18.
Преимущества данного подхода:
- простота автоматизации
- из версии легко получить исходный код и время его создания
- визуально можно отличить версию релиз кандидата (из мастера) либо эксперементальную (из ветки)
но: - такую версию тяжелее использовать в устной коммуникации
- не ясно, были ли несовместимые изменения.
Так как docker image может иметь несколько тегов одновременно то можно совместить подходы: все релизы используют сгенерированные версии, а те, которые попадают на продакшен, дополнительно (вручную) помечаются тегами с semantic versioning. В свою очередь это связано с еще еще большей сложностью реализации и неоднозначностью того, какую версию должно показывать приложение.
Артефакты
Результатом сборки будет:
- docker image с приложением который будет хранится и загружаться из docker registry. В примере будет использоваться встроенный registry от minikube, который может быть заменен на docker hub либо приватный registry от amazon (ecr) либо google (не забывайте предоставить credentials к ним используя конструкцию withCredentials).
- helm charts с описанием деплоймента приложения (deployment, service, etc) в директории helm. В идеале они должны хранится на отдельном репозитории артефактов, но, для упрощения, их можно использовать делая чекаут нужного коммита из git.
Jenkinsfile
В результате сборка приложения будет осуществлятся при помощи следующего Jenkinsfile:
def branch
def revision
def registryIp
pipeline {
agent {
kubernetes {
label 'build-service-pod'
defaultContainer 'jnlp'
yaml """
apiVersion: v1
kind: Pod
metadata:
labels:
job: build-service
spec:
containers:
- name: maven
image: maven:3.6.0-jdk-11-slim
command: ["cat"]
tty: true
volumeMounts:
- name: repository
mountPath: /root/.m2/repository
- name: docker
image: docker:18.09.2
command: ["cat"]
tty: true
volumeMounts:
- name: docker-sock
mountPath: /var/run/docker.sock
volumes:
- name: repository
persistentVolumeClaim:
claimName: repository
- name: docker-sock
hostPath:
path: /var/run/docker.sock
"""
}
}
options {
skipDefaultCheckout true
}
stages {
stage ('checkout') {
steps {
script {
def repo = checkout scm
revision = sh(script: 'git log -1 --format=\'%h.%ad\' --date=format:%Y%m%d-%H%M | cat', returnStdout: true).trim()
branch = repo.GIT_BRANCH.take(20).replaceAll('/', '_')
if (branch != 'master') {
revision += "-${branch}"
}
sh "echo 'Building revision: ${revision}'"
}
}
}
stage ('compile') {
steps {
container('maven') {
sh 'mvn clean compile test-compile'
}
}
}
stage ('unit test') {
steps {
container('maven') {
sh 'mvn test'
}
}
}
stage ('integration test') {
steps {
container ('maven') {
sh 'mvn verify'
}
}
}
stage ('build artifact') {
steps {
container('maven') {
sh "mvn package -Dmaven.test.skip -Drevision=${revision}"
}
container('docker') {
script {
registryIp = sh(script: 'getent hosts registry.kube-system | awk \'{ print $1 ; exit }\'', returnStdout: true).trim()
sh "docker build . -t ${registryIp}/demo/app:${revision} --build-arg REVISION=${revision}"
}
}
}
}
stage ('publish artifact') {
when {
expression {
branch == 'master'
}
}
steps {
container('docker') {
sh "docker push ${registryIp}/demo/app:${revision}"
}
}
}
}
}
Дополнительные Jenkins pipelines для управления жизненным циклом приложения
Предположим, что репозитории организованы так что:
- содержат отдельное приложение в виде docker image
- могут быть задеплоены использую helm файлы, которые рассположены в директории helm
- версионируются используя один и тот же подход и имеют файл helm/setVersion.sh для установки ревизии в helm charts
Тогда мы можем построить несколько Jenkinsfile pipelines для управления жизненным циклом приложения, а именно:
- деплоя на любое окружение
- удаления с любого окружения
- promote с staging на production
- отката на предыдущую версию
В Jenkinsfile каждого проекта, можно добавить вызов deploy pipeline который будет выполнятся при каждой успешной компиляции master ветки либо при явном запросе deploy ветки на тестовое окружение.
...
stage ('deploy to env') {
when {
expression {
branch == 'master' || params.DEPLOY_BRANCH_TO_TST
}
}
steps {
build job: './../Deploy', parameters: [
[$class: 'StringParameterValue', name: 'GIT_REPO', value: 'habr-demo-app'],
[$class: 'StringParameterValue', name: 'VERSION', value: revision],
[$class: 'StringParameterValue', name: 'ENV', value: branch == 'master' ? 'staging' : 'test']
], wait: false
}
}
...
Тут можно найти Jenkinsfile с учетом всех шагов.
Таким образом можно построить continuous deployment на выбраное тестовое либо боевое окружение, также используя jenkins либо его email/slack/etc нотификации, иметь аудит того, какое приложение, какой версии, кем, когда и куда было задеплоено.
Заключение
Используя Jenkinsfile и helm можно достаточно просто построить ci/cd для вашего приложеня. Этот способ может быть наиболее актуальным для небольших команд которые недавно начали использовать kubernetes и не имеют возможности (независимо от причины) использовать сервисы которые могут предоставлять такую функциональность из коробки.
Примеры конфигурации для окружений, Jenkins и pipeline для управления жизненным циклом приложения вы можете найти здесь и пример приложения с Jenkinsfile здесь.
Комментарии (9)
toshyak
05.03.2019 13:49Jenkins будет сразу же настроен на автоматическое создание одноразовых slave для каждой сборки.
Можно поподробнее, как это сделано? кажется что логичнее для такого использовать k8s jobsgmandnepr Автор
05.03.2019 14:09Эта функциональность реализована в kubernetes-plugin.
Если я верно понимаю ваш вопрос, то k8s jobs хорошой подойдут для переодически запускаемых задач с фиксированным интервалом. Jenkins позволит создавать slave только когда обнаружено изменение в VCS либо сборка запущена вручную. Основным преймуществом этого подхода является то что slaves существуют только во время сборки, а не все время ожидая начало новой задачи.
dkDer3k
05.03.2019 19:51Тут все проще, кмк. k8s job не используются, т.к. есть некоторая потребность в управлении жизненным циклом pod-в на стороне Jenkins: управлять переиспользованием и временем жизни поднятых исполнителей и тому подобное.
У меня на практике встречался случай, когда приходилось держать некоторое время под для сборки приложения на JS (которому требовалось выкачивать пару Гб в node_modules), чтобы сократить время повторных сборок. С k8s job такое вряд ли удалось бы провернуть просто потому что, насколько я помню, k8s не позволяет переиспользовать job-ы
gecube
05.03.2019 14:16аналогичная история в openshift. Главное — иметь каталог уже готовых образов, из которых будут slave разворачиваться…
gecube
Согласен, что semver сложнее реализовать.
Но он очень нужен. Теоретически либо хранилище образов должно поддерживать функцию "не перезаписывать образы, если они уже есть" (это умеет DTR), либо такой функционал можно реализовать в рамках пайплайна (банально вставить проверку наличия тега и если он есть, то сразу ошибка)
gmandnepr Автор
Вы правы, но тогда необходимо каждый раз перед коммитом менять версию вручную (либо автоматизировать) иначе потеряется CD составляющая и не каждый коммит будет попадать на staging.
Если же CD часть не важна, то можно настроить пайплайн на создание образа если комит помечен git тегом с паттерном соответствующим semver. В таком случае не понадобится проверка на перезапись, не нужно менять что-то в коде перед релизом, а так же можно релизить любой коммит из прошлого.
gecube
Ну, варианты какие.
Либо нам нужно генерировать образы и промоутить их до продакшн-образа. Либо можно попросту при пуше тега прогонять полный цикл тестов, а не пытаться брать какой-то непонятный образ и менять на нем тег.
В первом случае Ваша методика тегирования образа по timestamp+название ветки выглядит оптимальной. Внутри образа можно через label зашить то, из чего он получен (sha commit и прочие метаданные).
Во втором случае — у нас проблем особо нет, т.к. пуш тега в gitlab-ci генерирует новый пайплайн. Полностью новый пайплайн. Единственное, в чем мы теряем — в скорости релиза. Ну, и немного разваливается концепция прогона образа по пайплайну между средами.
За кадром остались вопросы конкурентности сборок ) Очень неприятно, когда у тебя пайплайн на тег может отработать быстрее, чем основной пайплайн. Или что-то нужно делать с пайплайнами, которые пишут в образ с одним и тем же тегом (например, latest). В принципе, можно их ограничить кол-вом 1 пайплайна за раз… Но тоже такое решение.
Поэтому я полностью согласен, что это реально кроличья нора и чем больше закапываешься, то тем больше нюансов всплывает. А самое главное, что как сервис, это все должно быть удобно разработчикам…