Добрый день.


На Хабре уже есть несколько статей о 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, набор предустановленых плагинов, доменное имя и прочую конфигурацию


demo-values.yaml
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:


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 для управления жизненным циклом приложения, а именно:



В Jenkinsfile каждого проекта, можно добавить вызов deploy pipeline который будет выполнятся при каждой успешной компиляции master ветки либо при явном запросе deploy ветки на тестовое окружение.


Jenkins file deploy pipeline call
...
        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)


  1. gecube
    05.03.2019 09:44

    Согласен, что semver сложнее реализовать.
    Но он очень нужен. Теоретически либо хранилище образов должно поддерживать функцию "не перезаписывать образы, если они уже есть" (это умеет DTR), либо такой функционал можно реализовать в рамках пайплайна (банально вставить проверку наличия тега и если он есть, то сразу ошибка)


    1. gmandnepr Автор
      05.03.2019 10:30

      Вы правы, но тогда необходимо каждый раз перед коммитом менять версию вручную (либо автоматизировать) иначе потеряется CD составляющая и не каждый коммит будет попадать на staging.

      Если же CD часть не важна, то можно настроить пайплайн на создание образа если комит помечен git тегом с паттерном соответствующим semver. В таком случае не понадобится проверка на перезапись, не нужно менять что-то в коде перед релизом, а так же можно релизить любой коммит из прошлого.


      1. gecube
        05.03.2019 10:41

        Ну, варианты какие.
        Либо нам нужно генерировать образы и промоутить их до продакшн-образа. Либо можно попросту при пуше тега прогонять полный цикл тестов, а не пытаться брать какой-то непонятный образ и менять на нем тег.
        В первом случае Ваша методика тегирования образа по timestamp+название ветки выглядит оптимальной. Внутри образа можно через label зашить то, из чего он получен (sha commit и прочие метаданные).
        Во втором случае — у нас проблем особо нет, т.к. пуш тега в gitlab-ci генерирует новый пайплайн. Полностью новый пайплайн. Единственное, в чем мы теряем — в скорости релиза. Ну, и немного разваливается концепция прогона образа по пайплайну между средами.

        За кадром остались вопросы конкурентности сборок ) Очень неприятно, когда у тебя пайплайн на тег может отработать быстрее, чем основной пайплайн. Или что-то нужно делать с пайплайнами, которые пишут в образ с одним и тем же тегом (например, latest). В принципе, можно их ограничить кол-вом 1 пайплайна за раз… Но тоже такое решение.

        Поэтому я полностью согласен, что это реально кроличья нора и чем больше закапываешься, то тем больше нюансов всплывает. А самое главное, что как сервис, это все должно быть удобно разработчикам…


  1. toshyak
    05.03.2019 13:49

    Jenkins будет сразу же настроен на автоматическое создание одноразовых slave для каждой сборки.

    Можно поподробнее, как это сделано? кажется что логичнее для такого использовать k8s jobs


    1. gmandnepr Автор
      05.03.2019 14:09

      Эта функциональность реализована в kubernetes-plugin.


      Если я верно понимаю ваш вопрос, то k8s jobs хорошой подойдут для переодически запускаемых задач с фиксированным интервалом. Jenkins позволит создавать slave только когда обнаружено изменение в VCS либо сборка запущена вручную. Основным преймуществом этого подхода является то что slaves существуют только во время сборки, а не все время ожидая начало новой задачи.


      1. dkDer3k
        05.03.2019 19:51

        Тут все проще, кмк. k8s job не используются, т.к. есть некоторая потребность в управлении жизненным циклом pod-в на стороне Jenkins: управлять переиспользованием и временем жизни поднятых исполнителей и тому подобное.
        У меня на практике встречался случай, когда приходилось держать некоторое время под для сборки приложения на JS (которому требовалось выкачивать пару Гб в node_modules), чтобы сократить время повторных сборок. С k8s job такое вряд ли удалось бы провернуть просто потому что, насколько я помню, k8s не позволяет переиспользовать job-ы


        1. gmandnepr Автор
          05.03.2019 20:01

          Сократить время сборки можно выделив PersistenceVolume для node_modules и каждый раз монтировать его к pod который собирает проект


          1. dkDer3k
            06.03.2019 12:06

            Да, я знаю, но у нас не было возможности использовать PersistanceVolume — долгая история)


    1. gecube
      05.03.2019 14:16

      аналогичная история в openshift. Главное — иметь каталог уже готовых образов, из которых будут slave разворачиваться…