Привет Хабр! На связи Рустем, IBM Senior DevOps Engineer & Integration Architect.

Сегодня я хотел бы поговорить про Jenkins Dynamic Agents в Kubernetes.

Jenkins — это инструмент CI/CD с достаточно долгой историей, который продолжает развиваться. Его архитектура Master/Agent отлично подходит для масштабируемости для выполнения распределенных сборок. Существует множество способов интеграции и использования агента Jenkins: от использования физических(bare metal) до виртуальных машин, динамических инстансов EC2, Docker-контейнеров или кластеров Kubernetes.

Провести интеграцию между Jenkins и кластером Kubernetes — это было отличным решением. Благодаря этим преимуществам я полностью перенес конвейер CI/CD с агента на основе хоста (VM) на агент на основе Pod.

Что я получил в итоге:

  • Динамический агент Jenkins в Kubernetes, который является легковесным и инициализируется по запросу в течение нескольких секунд.

  • Как говорится, always fresh, а главное — воспроизводимая агентская среда Jenkins для каждой сборки, с соблюдением принципа идемпотентности.

  • Экономия ресурсов/затрат благодаря Kubernetes (под с агентом внутри существует только в момент сборки).

В этой статье я хотел бы поделиться своим недавним подходом к динамической подготовке агента Jenkins с помощью простых строк кода в пайплайне Jenkins с использованием метода общей библиотеки Jenkins.

Первый шаг можно описать двумя способами: декларативно и скриптованно.

Декларативный пайплайн

@Library("k8sagent@v0.1.0") _  <-- вызываем библиотеку
pipeline {
  agent {
    kubernetes(k8sagent(name: 'mini+pg')) <- подготавливаем агент
  }
  stages {
    stage('demo') {
      steps {
        echo "this is a demo"
        script {
          container('pg') {
            sh 'su - postgres -c \'psql --version\''
          }
        }
      }
    }
  }
}

Скриптованный пайплайн

@Library("k8sagent@v0.1.0") _  <-- вызываем библиотеку
my_node = k8sagent(name: 'mini+pg')
podTemplate(my_node) { <--подготавливаем агент
  node(my_node.label) {
    sh 'echo hello world'
    container('pg') {
      sh 'su - postgres -c \'psql --version\''
    }
  }
}

В этом примере с имя «mini+pg» предназначено для запроса агента Jenkins пода типа «mini» со стандартным контейнером JNLP плюс контейнером «postgresql». Типы «mini» и «pg» являются определенными шаблонами в файлах ресурсов. В библиотеке есть и другие шаблоны, такие как «small», «large», «privileged», которые мы можем смешивать, добавлять или изменять.

Помимо возможности динамического запроса агента Jenkins для запуска ваших конвейеров CI/CD, это также и метод динамического определения требуемого агента.

Как Дженкинс работает с Kubernetes

Существует 2 вида метода интеграции.

Существующий экземпляр Jenkins подключается к новому кластеру Kubernetes.

Когда я делал интеграцию в первый раз, у меня уже был запущен рабочий Jenkins, так что это определенно был прямой путь для меня.

Но есть и второй метод, как я уже сказал ранее.

Можно создать новый экземпляр Jenkins внутри кластера Kubernetes.

После запуска первой модели в Dev-environment, я попробовал вторую модель и думаю, что это более практичный метод.

Плагин Kubernetes для Jenkins предоставляет документацию и примеры для различных методов определения простого/сложного агента.

Агент Jenkins — это под, предоставленный Kubernetes, с несколькими контейнерами, которые запускают указанные образы Docker. Основной контейнер должен быть из jenkins/jnlp-slave или совместимого образа. Лучше собрать свой собственный образ, включая содержимое Dockerfile из jenkins/docker-slave.

В основном существует 3 метода предоставления агента:

  • YAML в декларативном пайплайне;

  • Конфигурации в скриптовом пайплайне;

  • Конфигурации в пользовательском интерфейсе Jenkins.

Пример декларативного описания:

pipeline {
  agent {
    kubernetes {
      yaml '''
        apiVersion: v1
        kind: Pod
        metadata:
          labels:
            some-label: some-label-value
        spec:
          containers:
          - name: maven
            image: maven:alpine
            command:
            - cat
            tty: true
          - name: busybox
            image: busybox
            command:
            - cat
            tty: true
        '''
    }
  }
  stages {
    stage('Run maven') {
      steps {
        container('maven') {
          sh 'mvn -version'
        }
        container('busybox') {
          sh '/bin/busybox'
        }
      }
    }
  }
}

Пример UI-метода

Метод конфигурации через UI является для меня отправной точкой, так как он аналогичен другим типам агентов, определенным в мастере Jenkins.

Я определил несколько «шаблонов Pod» для различных задач. Например, для большинства сборок подходит «slave» 4 CPU и 8 GB RAM. Некоторым крупным проектам может потребоваться 8 CPU и 16 GB RAM,, что называется «large slave». Некоторым другим может понадобиться дополнительный контейнер PostgreSQL для тестирования, который называется «slave-pg».

Некоторые шаблоны похожи тем, что мы даже можем использовать функцию «Наследование шаблонов», чтобы сэкономить усилия, что приятно.

В пайплайне легко использовать метку шаблона пода для выбора агента.

pipeline {
 agent {
  label "slave"
 }
...
}

Однако несколько проблем, которые мы можем наблюдать с течением времени.

Шаблон Pod слишком сложен для настройки из пользовательского интерфейса Jenkins.

Один шаблон пода может легко содержать более 30 элементов конфигурации. Это демонстрирует большую гибкость Kubernetes, но не очень хорошо для работы в UI.

Несколько шаблонов Pod не управляются.

По мере увеличения потребности в «разных задачах» — увеличивается и количество настраиваемых шаблонов. Поскольку количество шаблонов увеличивается, то пользовательский интерфейс конфигурации Jenkins будет имеет более 10 страниц для этой секции. Будет достаточно долго и сложно искать правильное место для внесения правильных изменений.

Конфигурации шаблонов подов в экземплярах Jenkins

По мере того, как я запускаю в производство больше экземпляров Jenkins, становится проблемой настроить эти шаблоны для разных экземпляров. Сомневаюсь, что кому-то захочется копировать целую кучу 10-ти страничных конфигураций с одного Jenkins на другой.

Также конфигурация Дженкинса как код (JCasC) еще не является панацеей.

Отсутствие детерминизма

То, как проект использует «метку агента А» в своем пайплайне, создает зависимость от «Агента А», определенного в Jenkins, как тайну. Невозможно точно узнать, что находится в агенте A, является ли A сегодня таким же, как вчера, или A отличается среди экземпляров Jenkins. Это проблема, если Агент создан изменяемым образом.

Отсутствие возможности отслеживания

По мере развития проекта требования к Агенту также меняются. Например, для проекта версии 1.0.0 может потребоваться только стандартный агент, а для версии 1.5.0 — агент с контейнером PostgreSQL для тестирования. Иногда образы докеров, используемые в агенте, также нуждаются в обновлении. Звучит легко, можно попросить администратора Jenkins изменить агент или создать новый. Но вскоре такие изменения будет все сложнее отслеживать.

Даже с этими минусами этот метод по-прежнему “мастхэв” для тех пайплайнов, которые работают на различных типах агентов, отличных от Pod в Kubernetes.

Шаблон Pod в Pipeline Source Code

Глядя на документацию плагина Kubernetes для Jenkins, я полагаю, что идея состоит в том, чтобы написать шаблон пода в исходном коде пайплайна. Существует множество примеров как для декларативных, так и для скриптовых пайплайнов.

По моему наблюдению, по мере обновления версий плагина он предпринимает шаги, чтобы предпочесть декларативный метод для определения агента, а не скриптовый. Я приветствую это изменение, так как очень удобно использовать тот же способ YAML для конвейера Jenkins и других задач Kubernetes.

При попытке использовать этот подход я столкнулся с двумя основными проблемами.

Ниже приведен пример пайплайна, скопированный из одного из примеров плагина.

// Reference https://github.com/jenkinsci/kubernetes-plugin/blob/master/examples/dind.groovy

/*
“Docker-in-Docker”: runs a Docker-based build where the Docker daemon and client are both defined in the pod.
This allows you to control the exact version of Docker used.
(For example, try DOCKER_BUILDKIT=1 to access advanced Dockerfile syntaxes.)
There is no interaction with the container system used by Kubernetes:
docker.sock does not need to be mounted as in dood.groovy.
May or may not work depending on cluster policy: https://kubernetes.io/docs/concepts/policy/pod-security-policy/
*/
podTemplate(yaml: '''
apiVersion: v1
kind: Pod
spec:
  containers:
  - name: docker
    image: docker:19.03.1
    command:
    - sleep
    args:
    - 99d
    env:
      - name: DOCKER_HOST
        value: tcp://localhost:2375
  - name: docker-daemon
    image: docker:19.03.1-dind
    securityContext:
      privileged: true
    env:
      - name: DOCKER_TLS_CERTDIR
        value: ""
''') {
    node(POD_LABEL) {
        git 'https://github.com/jenkinsci/docker-jnlp-slave.git'
        container('docker') {
            sh 'docker version && DOCKER_BUILDKIT=1 docker build --progress plain -t testing .'
        }
    }
}

В этом простом примере чать для определения шаблона пода составляет 20 строк кода, а сборка всего 5 строк.

По моему реальному опыту, YAML файл может легко превышать 50 строк. В пайплайне это будет долго отрабатываться.

Если у вас есть 100 проектов, сложно изменить 100 проектов для шаблона пода YAML в коде пайплайне.

Разные организации имеют разные модели развития. Нет единого ответа, кто будет отвечать за пайплайн проекта(DevOps, System Administrator, Jenkins manager, Build Engineer), особенно за часть шаблона пода в коде конвейера.

Если это индивидуальная проектная команда, невозможно вносить изменения одновременно. Например, когда необходимо обновить образ докера для исправления безопасности, то это обновление должно быть выполнено одновременно для всех проектов.

Если это выделенная команда, то сложно будет себе представить усилия по эффективной модификации 100 проектов.

Переместим шаблон модуля в общую библиотеку Jenkins

Когда в коде конвейера Jenkins есть общие вещи, естественно переместить их в общую библиотеку Jenkins.

Когда я искал решение для использования библиотеки для подготовки агента Kubernetes, меня вдохновил проект salemove/pipeline-lib.

Это общая библиотека Jenkins, которая предоставляет метод inPod.
inPod — это тонкая оболочка вокруг podTemplate, обеспечивающая гибкость для объединения предопределенных конфигураций с аргументами. Используя inPod в качестве основы, некоторые другие оболочки определяют различные аргументы, например inDockerAgent, inRubyBuildAgent, для предоставления различных типов агентов.

Например, приведенный ниже фрагмент кода предоставляет агент с  образом докера «node: 9-alpine».

inPod(containers: [interactiveContainer(name: 'node', image: 'node:9-alpine')]) {
  checkout(scm)
  container('node') {
    sh('npm install && npm test')
  }
}

Основная идея состоит в том, чтобы собрать параметры из слоев-оболочек и объединить их для шаблона podTemplate, который будет использоваться плагином Kubernetes.

Здесь реализована ключевая функция addWithoutDuplicates, позволяющая объединить список, отдавая приоритет аргументам по умолчанию.

// For containers, add the lists together, but remove duplicates by name, giving precedence to the user specified args.
  def finalContainers = addWithoutDuplicates((args.containers ?: []), defaultArgs.containers) { it.getArguments().name }

Идея хорошая, и когда я захотел импортировать ее для собственного использования, я обнаружил, что она не подходит для моего случая.

Библиотека работает только для скриптовых конвейеров. В моей работе есть как декларативные конвейеры, так и скриптовые конвейеры. Мне нужно одно решение для обоих.

Существует множество конфигураций для podTemplate, но это не все, что поддерживает Kubernetes. На самом деле существует конфигурация «yaml» для предоставления raw yaml. Raw yaml будет объединен с другими конфигурациями.

Библиотека ориентирована на предоставление агенту различных образов/томов докеров. Это хорошо, но мне нужен более обширный, который поддерживаются только в «raw yaml».

С учетом этих соображений я хотел бы изучить новое решение с такими требованиями:

  • На основе декларативного метода, полный формат YAML.

  • Работает как для скриптовых конвейеров, так и для декларативных конвейеров.

  • Гибкость предоставления различных типов агентов.

  • Легко использовать в конвейерном коде(коде пайплайна) и скрывать детали в библиотеке.

  • Простота разработки библиотеки для поддержки растущих требований к типам агентов.

После некоторого исследования я считаю, что моя идея осуществима, глядя на эти 2 примера из плагина Kubernetes: dind.groovy и declarative.groovy.

Общее между этими двумя примерами, как показано ниже:

# scripted pipeline
podTemplate(Map) {
}
# declarative pipeline 
 agent {
    kubernetes {
      Map
    }
 }

Цель состоит в том, чтобы сгенерировать Map с желаемым содержимым yaml, которое определяет спецификацию пода для агента.

Подход состоит в том, чтобы определить небольшие фрагменты частичного yaml, каждый для конкретного требования к агенту, и объединить их вместе, чтобы составить окончательную спецификацию формата yaml для пода.

Например, агенту может потребоваться выполнить сборку maven или gradle, файл ресурсов с именем «maven», чтобы включить контейнер с образом докера «maven», другой файл «gradle», чтобы включить образ докера «gradle». Агенту может потребоваться меньше или больше вычислительной мощности, сопоставление с файлами ресурсов «small»/«large»/«fast» для запроса некоторых ресурсов CPU/memory. Агенту может потребоваться «привилегированный» под в сборке, «привилегированный» файл ресурсов для определения «securityContext» и, в моем случае, вместе с «podAntiAffinity», чтобы убедиться, что только один привилегированный под на одном хост-компьютере.

Чтобы сложить их вместе, агент определяется в формате «A+B+C», например, «maven+small+privileged», и библиотека вернет объединенный yaml.

Пример кода для «small+pg»:

# base
spec:
  hostAliases:
  - ip: "192.168.1.15"
    hostnames:
    - "jenkins.example.com"
  volumes:
  - hostPath:
      path: /data/jenkins/repo_mirror
      type: ""
    name: volume-0
  containers:
  - name: jnlp
    image: jenkinsci/jnlp-slave:3.29-1
    imagePullPolicy: Always
    command:
    - /usr/local/bin/jenkins-slave
    volumeMounts:
    - mountPath: /home/jenkins/repo_cache
      name: volume-0
# small
spec:
  containers:
  - name: jnlp
    resources:
      limits:
        memory: 8Gi
      requests:
        memory: 4Gi
        cpu: 2
# pg
spec:
  containers:
  - name: pg
    image: postgres:9.5.19
    tty: true
# merged
spec:
  hostAliases:
  - ip: "192.168.1.15"
    hostnames:
    - "jenkins.example.com"
  volumes:
  - hostPath:
      path: /data/jenkins/repo_mirror
      type: ""
    name: volume-0
  containers:
  - name: jnlp
    image: jenkinsci/jnlp-slave:3.29-1
    imagePullPolicy: Always
    command:
    - /usr/local/bin/jenkins-slave
    volumeMounts:
    - mountPath: /home/jenkins/repo_cache
      name: volume-0
    resources:
      limits:
        memory: 8Gi
      requests:
        memory: 4Gi
        cpu: 2
  - name: pg
    image: postgres:9.5.19
    tty: true

Итак, у нас есть проект Gradle, который имеет следующую структуру:

.
├── build.gradle
├── resources
│   └── podtemplates <- Yaml files
├── src
│   └── com/github/liejuntao001/jenkins/MyYaml.groovy <- merge Yaml
├── test
│   └── groovy
│       └── K8sAgentTest.groovy <- test
├── testjobs
│   ├── k8sagent_Jenkinsfile.groovy
│   └── simple_Jenkinsfile.groovy <- samples
│   └── simple_scripted.groovy
└── vars
    └── k8sagent.groovy <- method

Прогоним тесты:

./gradlew clean test

> Task :test
K8sAgentTest: testSdk25: SUCCESS
K8sAgentTest: testSmall: SUCCESS
K8sAgentTest: testBase: SUCCESS
K8sAgentTest: testDind: SUCCESS
K8sAgentTest: testFast: SUCCESS
K8sAgentTest: testPg: SUCCESS
K8sAgentTest: testPrivileged: SUCCESS
Tests: 7, Failures: 0, Errors: 0, Skipped: 0

Насколько я понимаю, не существует универсального метода слияния Yaml.

Например, основной структурой данных Yaml является map и list. В спецификации Kubernetes List иногда чувствителен к последовательности, иногда нечувствителен.

# List чувствителен к последовательности
command: ["/bin/sh"]
args: ["-c", "while true; do echo hello; sleep 10;done"]
# List нечувствителен к последовательности
containers:
- name: A
- name: B

В плагине Kubernetes есть параметр yamlMergeStrategy: merge() или override(), который решает проблему с объединением Yaml в наследовании.

В своей реализации я повторно использовал код из OndraZizka/yaml-merge и модифицировал его до уровня «просто работает», чтобы соответствовать моим сценариям. Он разрешает вариант использования, как показано ниже:

# a.yaml
containers:
- name: A
  some_config
# b.yaml
containers:
- name: A
  some_other_config
# merged yaml
containers:
- name: A
  some_config
  some_other_config

Осталось добавить NodeSelector

k8sagent(name: 'base', selector: 'kubernetes.io/hostname: worker1')

И последнее — параметр jnplimage

k8sagent(name: 'base', jnlpImage: 'jenkinsci/jnlp-slave:my_test_version')

Не так уж и сложно :)


В заключение скажу, что сегодня пройдет открытый урок по устройству kubernetes. Изучим, из каких основных и вспомогательных частей состоит кластер-kubernetes, и поймем, чем отличаются компоненты и как взаимодействуют друг с другом. Если актуально, присоединяйтесь по ссылке.

Комментарии (0)