На связи Андрей Леодоров, ведущий инженер по автоматизации процессов компании «Гарда». За время работы с Jenkins я видел разные сценарии его использования: от локальных инсталляций под одну команду до масштабируемых инстансов с централизованным сопровождением. Я пришёл к выводу, что Jenkins отлично подходит для использования на уровне продукта — когда команда может сама определять, какие инструменты и ресурсы нужны для организации процессов CI/CD.

Статья — это практический гайд для небольшой команды или отдельного проекта, которому нужен собственный воспроизводимый Jenkins в Kubernetes. Результатом будет полностью готовый к работе Jenkins controller с динамическими агентами в Kubernetes и зафиксированной конфигурацией в git. Дополнительными элементами решения станут:

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

Материал рассчитан на инженеров, которые уже имели опыт работы с Jenkins и Kubernetes.

Подготовка инфраструктуры

Начну с перечня инструментов. В качестве операционной системы буду использовать Linux — выбор дистрибутива остаётся за вами, я использовал Debian 12. Правда, конкретный дистрибутив здесь не даёт каких-либо выраженных преимуществ, поскольку мы разворачиваемся в Kubernetes.

В качестве платформы для деплоя использую Kubernetes. На момент написания статьи я работал с ванильным k8s версии v1.34. Также понадобится Git-репозиторий (полагаю, что в любом проекте он так или иначе есть). Для примера буду использовать github.com.

Очень желательно наличие универсального менеджера репозиториев, например Sonatype Nexus или Jfrog Artifactory. Это сильно упрощает жизнь. Для Docker registry буду использовать вымышленное имя — registry.local.

Из основных компонентов:

  • Jenkins

  • Jenkins Helm chart — версия jenkins-5.8.117

  • Jenkins Kubernetes plugin

  • JCasC plugin

  • JobDSL plugin

  • Jenkins Shared Library

Системные требования

Здесь, конечно, стоит смотреть на нужды конкретного проекта. В статье я исходил из допущения, что проект небольшой и требуется запускать несколько задач параллельно. Для этих целей использовался Kubernetes-кластер из одной ноды с ресурсами 8 CPU и 16 GB RAM.

Kubernetes-кластер очень хорошо помогает с ресурсами для Jenkins: достаточно разместить сам контроллер Jenkins, а динамические агенты поднимаются как поды. Если ресурсы заканчиваются — просто добавили ноду в Kubernetes и растём дальше. Это гибче, чем привязываться к статическим серверам.

Kubernetes

Разворачивание кластера Kubernetes выходит за рамки этой статьи и предполагается, что кластер уже установлен (я использовал kubeadm).

Если у вас single-node кластер, не забудьте убрать taint с master-ноды, чтобы на неё можно было планировать поды. Делается это так: kubectl taint node <имя master node k8s> node-role.kubernetes.io/control-plane:NoSchedule-

Helm chart

Я использовал оригинальный Helm chart, который поддерживается проектом. Все изменения, которые буду описывать далее, выложены в форке данного репозитория, а именно — в файле values.yaml. Вы можете сравнивать свои настройки с моими или взять их за базу.

Docker образы

Если у вас своё registry, рекомендую заранее выкачать нужные версии образов и отправить их в локальное registry. Это избавит от проблем с доступом к Docker Hub в будущем и ускорит деплой.

Проверить версии Docker-образов, которые понадобятся для работы, можно в файле Chart.yaml в секции Аnnotations.artifacthub.io/images.

DNS запись

Базово я исходил из того, что используется Ingress, для которого уже прописана DNS A-запись на DNS-сервере. В статье я буду использовать несуществующее имя jenkins.test.local для доступа к Jenkins, которое пропишу у себя в файле /etc/hosts.

Не забудьте добавить сертификат в секреты для Ingress. Если же разворачиваете окружение для ознакомительных целей, можно оставить этот пункт без внимания — будет достаточно стандартного Fake Certificate от Kubernetes Ingress Controller.

Настройка и деплой

Итак, инфраструктура готова: кластер Kubernetes развёрнут, Helm chart скачан, DNS-записи прописаны. Теперь перехожу к самой интересной части — настройке. На этом этапе важно не просто «нажать кнопку» и запустить контейнер, а правильно сконфигурировать Jenkins с учётом требований проекта: зафиксировать версии образов, настроить хранилище, создать учётную запись администратора и обеспечить безопасный доступ по HTTPS.

Я буду вносить изменения в values.yaml — это основной файл конфигурации Helm chart, через который передаются все параметры. Такой подход позволяет хранить всю конфигурацию в Git и при необходимости воспроизвести развёртывание с нуля.

Давайте пройдёмся по ключевым моментам.

Docker-образы

Фиксирую версии образов в values.yaml (см. команду ниже). Если локальный registry не используется, соответствующее поле можно просто не указывать.

controller:
  componentName: "jenkins-controller"
  image:
    registry: "registry.local"
    repository: "jenkins/jenkins"
    tag: 2.528.3-jdk21
...
  sidecars:
    configAutoReload:
      enabled: true
      image:
        registry: registry.local
        repository: kiwigrid/k8s-sidecar
        tag: 1.30.7
...
agent:
  image:
    registry: "registry.local"
    repository: "jenkins/inbound-agent"
    tag: "3355.v388858a_47b_33-3"

PV/PVC: хранилище данных

Предлагаемый подход к деплою Jenkins не боится потери данных: конфигурация восстанавливается из кода, а артефакты в самом Jenkins я не считаю критичными. Однако использование Persistent Volume (PV) позволит сохранить историю сборок, а для многих проектов это важное требование. Поэтому тип Kubernetes Persistent Volume выбирайте в зависимости от потребностей проекта.

Для single-node кластера k8s я взял local-storage. Но если у вас есть сетевое хранилище — лучше задействовать его: local привязывает к конкретной ноде и усложняет восстановление при сбое. Размер хранилища также зависит от ваших потребностей. Я использовал 500GB.

Если работаете с local-storage, не забудьте создать каталоги на узле, где храним PV.

Ниже делюсь примером создания PV в самой простой вариации (без отказоустойчивости).

Пример создания PV
cat << EOF | kubectl apply -f -
apiVersion: v1
kind: Namespace
metadata:
  name: jenkins
  labels:
    name: jenkins
---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: jenkins-pv
spec:
  capacity:
    storage: 500Gi
  volumeMode: Filesystem
  accessModes:
  - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: local-storage
  local:
    path: /pv/jenkins
  claimRef:
    name: jenkins-pvc
    namespace: jenkins
  nodeAffinity:
    required:
      nodeSelectorTerms:
      - matchExpressions:
        - key: kubernetes.io/hostname
          operator: In
          values:
          - <имя мастер ноды k8s>
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: jenkins-pvc
  namespace: jenkins
spec:
  storageClassName: local-storage
  volumeName: jenkins-pv
  resources:
    requests:
      storage: 500Gi
  accessModes:
  - ReadWriteOnce
EOF

Далее прописываю хранилище в values.yaml:

...
persistence:
  enabled: true
  existingClaim: jenkins-pvc
...

Учётная запись администратора

Возможно, в дальнейшем вы будете использовать другой провайдер для аутентификации, например LDAP. Но для начала нужен локальный пользователь с правами уровня Administrator.

 Создаю секрет для пароля:

kubectl -n jenkins create secret generic jenkins-admin \
    --from-literal=jenkins-admin-user=admin \
    --from-literal=jenkins-admin-password=<пароль>

Затем указываю его в values.yaml, а также задаю почту администратора системы:

controller:
...
  admin:
    existingSecret: "jenkins-admin"
  jenkinsAdminEmail: admin@<ваш домен>

Сертификаты для Ingress

Для доступа к Jenkins по HTTPS нужен секрет, который будет прописан в конфигурации Ingress. Это может быть либо сертификат, который вам выписали для внутренних ресурсов компании, либо купленный сертификат, или же можно использовать связку Let's Encrypt и CertManager.

Для простоты я выписал себе self-signed сертификат и добавил его как секрет:

kubectl -n jenkins create secret tls jenkins-test-local  --cert=jenkins.test.local.crt \
  --key=jenkins.test.local.key

Следующим шагом указываю в секции для Ingress такие параметры:

controller:
...
  ingress:
    enabled: true
    paths: []
    pathType: ImplementationSpecific
    apiVersion: "networking.k8s.io/v1"
    labels: {}
    annotations: {}
    ingressClassName: nginx
 
    # Set this path to jenkinsUriPrefix above or use annotations to rewrite path
    # -- Ingress path
    path:
    hostName: jenkins.test.local
    resourceRootUrl:
    tls:
    - secretName: jenkins-test-local
      hosts:
        - jenkins.test.local
...

Resources

Если Jenkins планируется разворачивать не только для теста, лучше сразу поправить секцию resources, указав там значения CPU и RAM, которые необходимо выделить под контроллер. Это поможет избежать ситуаций, когда под будет вытеснен с ноды из-за нехватки памяти.

controller:
...
  resources:
    requests:
      cpu: "50m"
      memory: "256Mi"
    limits:
      cpu: "2000m"
      memory: "4096Mi"
...

Деплой Jenkins

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

helm upgrade --install jenkins-main ./ --namespace jenkins --create-namespace

Я получил рабочий Jenkins. Но, если удалить его и развернуть заново — все плагины придётся ставить вручную. В следующем разделе покажу, как зафиксировать плагины так, чтобы они восстанавливались автоматически.

Плагины для Jenkins

В values.yaml есть секция installPlugins. Эта переменная позволяет заранее указать плагины, которые нужно скачать при старте Jenkins-контроллера (см. команду ниже).

controller:
...
  installPlugins:
    - kubernetes:4398.vb_b_33d9e7fe23
    - workflow-aggregator:608.v67378e9d3db_1
    - git:5.8.1
    - configuration-as-code:2006.v001a_2ca_6b_574
...

На мой взгляд, это одна из самых важных фич Jenkins. Количество проблем с плагинами, которые перестали поддерживать или которые потеряли совместимость с зависимостями, отняло немало моего времени.

Чтобы не искать последние версии плагинов и их зависимости вручную, необходимо сперва открыть и залогиниться в Jenkins (в моём случае это https://jenkins.test.local/). Затем перейти в раздел плагинов https://jenkins.test.local/manage/pluginManager/available

Я взял для примера плагин Blue Ocean и установил его через Web UI. Посмотрите, как много зависимостей он тянет за собой.

Теперь добавляю плагины, которые необходимы для работы. В частности, я загрузил pipeline-utility-steps и job-dsl. После окончания установки перехожу на страницу Script Console: https://jenkins.test.local/script. Затем копирую следующий код:

Jenkins.instance.pluginManager.plugins.each {
      println("- ${it.getShortName()}:${it.getVersion()}")
   }

Нажимаю кнопку запустить и в результате получаю следующий вывод.

Вывод
- antisamy-markup-formatter:173.v680e3a_b_69ff3
- apache-httpcomponents-client-4-api:4.5.14-269.vfa_2321039a_83
- apache-httpcomponents-client-5-api:5.6-183.ve5a_8a_b_e71e59
- asm-api:9.9.1-189.vb_5ef2964da_91
- authentication-tokens:1.144.v5ff4a_5ec5c33
- blueocean-bitbucket-pipeline:1.27.25
- blueocean-commons:1.27.25
- blueocean-config:1.27.25
- blueocean-core-js:1.27.25
- blueocean-dashboard:1.27.25
- blueocean-display-url:2.4.4
- blueocean-events:1.27.25
- blueocean-git-pipeline:1.27.25
- blueocean-github-pipeline:1.27.25
- blueocean-i18n:1.27.25
- blueocean-jwt:1.27.25
- blueocean-personalization:1.27.25
- blueocean-pipeline-api-impl:1.27.25
- blueocean-pipeline-editor:1.27.25
- blueocean-pipeline-scm-api:1.27.25
- blueocean-rest-impl:1.27.25
- blueocean-rest:1.27.25
- blueocean-web:1.27.25
- blueocean:1.27.25
- bootstrap5-api:5.3.8-895.v4d0d8e47fea_d
- bouncycastle-api:2.30.1.82-277.v70ca_0b_877184
- branch-api:2.1268.v044a_87612da_8
- caffeine-api:3.2.3-194.v31a_b_f7a_b_5a_81
- checks-api:402.vca_263b_f200e3
- cloudbees-bitbucket-branch-source:937.2.3
- cloudbees-folder:6.1073.va_7888eb_dd514
- commons-collections4-api:4.5.0-8.va_d5448ef9011
- commons-lang3-api:3.20.0-109.ve43756e2d2b_4
- commons-text-api:1.15.0-210.v7480a_da_70b_9e
- configuration-as-code:2006.v001a_2ca_6b_574
- credentials-binding:702.vfe613e537e88
- credentials:1480.v2246fd131e83
- display-url-api:2.217.va_6b_de84cc74b_
- durable-task:651.v1f5e074fc83f
- echarts-api:6.0.0-1165.vd1283a_3e37d4
- favorite:2.253.v9b_413168133b_
- font-awesome-api:7.1.0-882.v1dfb_771e3278
- git-client:6.5.0
- git:5.8.1
- github-api:1.330-492.v3941a_032db_2a_
- github-branch-source:1917.v9ee8a_39b_3d0d
- github:1.45.0
- gson-api:2.13.2-173.va_a_092315913c
- handy-uri-templates-2-api:2.1.8-38.vcea_5d521d5f3
- htmlpublisher:427
- instance-identity:203.v15e81a_1b_7a_38
- ionicons-api:94.vcc3065403257
- jackson2-api:2.20.1-423.v13951f6b_6532
- jakarta-activation-api:2.1.4-1
- jakarta-mail-api:2.1.5-1
- jakarta-xml-bind-api:4.0.6-12.vb_1833c1231d3
- javax-activation-api:1.2.0-8
- jaxb:2.3.9-143.v5979df3304e6
- jenkins-design-language:1.27.25
- jjwt-api:0.11.5-120.v0268cf544b_89
- job-dsl:1.93
- joda-time-api:2.14.0-177.vd7e9347b_e7d5
- jquery3-api:3.7.1-619.vdb_10e002501a_
- json-api:20251224-185.v0cc18490c62c
- json-path-api:2.10.0-202.va_9cc16c1e476
- junit:1369.v15da_00283f06
- kubernetes-client-api:7.3.1-256.v788a_0b_787114
- kubernetes-credentials:207.v492f58828b_ed
- kubernetes:4398.vb_b_33d9e7fe23
- mailer:525.v2458b_d8a_1a_71
- metrics:4.2.37-494.v06f9a_939d33a_
- mina-sshd-api-common:2.16.0-167.va_269f38cc024
- mina-sshd-api-core:2.16.0-167.va_269f38cc024
- okhttp-api:4.12.0-195.vc02552c04ffd
- pipeline-build-step:584.vdb_a_2cc3a_d07a_
- pipeline-graph-analysis:245.v88f03631a_b_21
- pipeline-groovy-lib:787.ve2fef0efdca_6
- pipeline-input-step:540.v14b_100d754dd
- pipeline-milestone-step:138.v78ca_76831a_43
- pipeline-model-api:2.2277.v00573e73ddf1
- pipeline-model-definition:2.2277.v00573e73ddf1
- pipeline-model-extensions:2.2277.v00573e73ddf1
- pipeline-stage-step:322.vecffa_99f371c
- pipeline-stage-tags-metadata:2.2277.v00573e73ddf1
- plain-credentials:199.v9f8e1f741799
- plugin-util-api:6.1192.v30fe6e2837ff
- prism-api:1.30.0-630.va_e19d17f83b_0
- pubsub-light:1.19
- scm-api:724.v7d839074eb_5c
- script-security:1385.v7d2d9ec4d909
- snakeyaml-api:2.5-143.v93b_c004f89de
- sse-gateway:1.28
- ssh-credentials:361.vb_f6760818e8c
- structs:362.va_b_695ef4fdf9
- token-macro:477.vd4f0dc3cb_cf1
- variant:70.va_d9f17f859e0
- workflow-aggregator:608.v67378e9d3db_1
- workflow-api:1384.vdc05a_48f535f
- workflow-basic-steps:1098.v808b_fd7f8cf4
- workflow-cps:4252.v465f588eb_52f
- workflow-durable-task-step:1464.v2d3f5c68f84c
- workflow-job:1559.va_a_533730b_ea_d
- workflow-multibranch:821.vc3b_4ea_780798
- workflow-scm-step:466.va_d69e602552b_
- workflow-step-api:710.v3e456cc85233
- workflow-support:1010.vb_b_39488a_9841
- commons-compress-api:1.28.0-2
- pipeline-utility-steps:2.20.0

Список выше нужно вставить в секцию installPlugins.

Вставка вывода в секцию installPlugins
controller:
...
  installPlugins:
  - antisamy-markup-formatter:173.v680e3a_b_69ff3
  - apache-httpcomponents-client-4-api:4.5.14-269.vfa_2321039a_83
  - apache-httpcomponents-client-5-api:5.6-183.ve5a_8a_b_e71e59
  - asm-api:9.9.1-189.vb_5ef2964da_91
  - authentication-tokens:1.144.v5ff4a_5ec5c33
  - blueocean-bitbucket-pipeline:1.27.25
  - blueocean-commons:1.27.25
  - blueocean-config:1.27.25
  - blueocean-core-js:1.27.25
  - blueocean-dashboard:1.27.25
  - blueocean-display-url:2.4.4
  - blueocean-events:1.27.25
  - blueocean-git-pipeline:1.27.25
  - blueocean-github-pipeline:1.27.25
  - blueocean-i18n:1.27.25
  - blueocean-jwt:1.27.25
  - blueocean-personalization:1.27.25
  - blueocean-pipeline-api-impl:1.27.25
  - blueocean-pipeline-editor:1.27.25
  - blueocean-pipeline-scm-api:1.27.25
  - blueocean-rest-impl:1.27.25
  - blueocean-rest:1.27.25
  - blueocean-web:1.27.25
  - blueocean:1.27.25
  - bootstrap5-api:5.3.8-895.v4d0d8e47fea_d
  - bouncycastle-api:2.30.1.82-277.v70ca_0b_877184
  - branch-api:2.1268.v044a_87612da_8
  - caffeine-api:3.2.3-194.v31a_b_f7a_b_5a_81
  - checks-api:402.vca_263b_f200e3
  - cloudbees-bitbucket-branch-source:937.2.3
  - cloudbees-folder:6.1073.va_7888eb_dd514
  - commons-collections4-api:4.5.0-8.va_d5448ef9011
  - commons-lang3-api:3.20.0-109.ve43756e2d2b_4
  - commons-text-api:1.15.0-210.v7480a_da_70b_9e
  - configuration-as-code:2006.v001a_2ca_6b_574
  - credentials-binding:702.vfe613e537e88
  - credentials:1480.v2246fd131e83
  - display-url-api:2.217.va_6b_de84cc74b_
  - durable-task:651.v1f5e074fc83f
  - echarts-api:6.0.0-1165.vd1283a_3e37d4
  - favorite:2.253.v9b_413168133b_
  - font-awesome-api:7.1.0-882.v1dfb_771e3278
  - git-client:6.5.0
  - git:5.8.1
  - github-api:1.330-492.v3941a_032db_2a_
  - github-branch-source:1917.v9ee8a_39b_3d0d
  - github:1.45.0
  - gson-api:2.13.2-173.va_a_092315913c
  - handy-uri-templates-2-api:2.1.8-38.vcea_5d521d5f3
  - htmlpublisher:427
  - instance-identity:203.v15e81a_1b_7a_38
  - ionicons-api:94.vcc3065403257
  - jackson2-api:2.20.1-423.v13951f6b_6532
  - jakarta-activation-api:2.1.4-1
  - jakarta-mail-api:2.1.5-1
  - jakarta-xml-bind-api:4.0.6-12.vb_1833c1231d3
  - javax-activation-api:1.2.0-8
  - jaxb:2.3.9-143.v5979df3304e6
  - jenkins-design-language:1.27.25
  - jjwt-api:0.11.5-120.v0268cf544b_89
  - job-dsl:1.93
  - joda-time-api:2.14.0-177.vd7e9347b_e7d5
  - jquery3-api:3.7.1-619.vdb_10e002501a_
  - json-api:20251224-185.v0cc18490c62c
  - json-path-api:2.10.0-202.va_9cc16c1e476
  - junit:1369.v15da_00283f06
  - kubernetes-client-api:7.3.1-256.v788a_0b_787114
  - kubernetes-credentials:207.v492f58828b_ed
  - kubernetes:4398.vb_b_33d9e7fe23
  - mailer:525.v2458b_d8a_1a_71
  - metrics:4.2.37-494.v06f9a_939d33a_
  - mina-sshd-api-common:2.16.0-167.va_269f38cc024
  - mina-sshd-api-core:2.16.0-167.va_269f38cc024
  - okhttp-api:4.12.0-195.vc02552c04ffd
  - pipeline-build-step:584.vdb_a_2cc3a_d07a_
  - pipeline-graph-analysis:245.v88f03631a_b_21
  - pipeline-groovy-lib:787.ve2fef0efdca_6
  - pipeline-input-step:540.v14b_100d754dd
  - pipeline-milestone-step:138.v78ca_76831a_43
  - pipeline-model-api:2.2277.v00573e73ddf1
  - pipeline-model-definition:2.2277.v00573e73ddf1
  - pipeline-model-extensions:2.2277.v00573e73ddf1
  - pipeline-stage-step:322.vecffa_99f371c
  - pipeline-stage-tags-metadata:2.2277.v00573e73ddf1
  - plain-credentials:199.v9f8e1f741799
  - plugin-util-api:6.1192.v30fe6e2837ff
  - prism-api:1.30.0-630.va_e19d17f83b_0
  - pubsub-light:1.19
  - scm-api:724.v7d839074eb_5c
  - script-security:1385.v7d2d9ec4d909
  - snakeyaml-api:2.5-143.v93b_c004f89de
  - sse-gateway:1.28
  - ssh-credentials:361.vb_f6760818e8c
  - structs:362.va_b_695ef4fdf9
  - token-macro:477.vd4f0dc3cb_cf1
  - variant:70.va_d9f17f859e0
  - workflow-aggregator:608.v67378e9d3db_1
  - workflow-api:1384.vdc05a_48f535f
  - workflow-basic-steps:1098.v808b_fd7f8cf4
  - workflow-cps:4252.v465f588eb_52f
  - workflow-durable-task-step:1464.v2d3f5c68f84c
  - workflow-job:1559.va_a_533730b_ea_d
  - workflow-multibranch:821.vc3b_4ea_780798
  - workflow-scm-step:466.va_d69e602552b_
  - workflow-step-api:710.v3e456cc85233
  - workflow-support:1010.vb_b_39488a_9841
  - commons-compress-api:1.28.0-2
  - pipeline-utility-steps:2.20.0
    ...
...

Я получил список со всеми плагинами и их зависимостями. Теперь можно не переживать за то, что плагин устарел и его удалили. При старте Jenkins будет проверять наличие этих плагинов локально, а если их нет — скачает именно те, что были зафиксированы.

Проверяю, что всё именно так, как ожидалось:

  1. Удаляю уже развернутый Jenkins (helm uninstall jenkins-main --namespace jenkins), а также все файлы в PV (/pv/jenkins# rm -rf *.*).

  2. Пробую развернуть Jenkins заново: helm upgrade --install jenkins-main ./ --namespace jenkins --create-namespace

Записи, которые можно увидеть в логах пода

...

Will install new plugin blueocean-config 1.27.25                                                                                                                                        

Will install new plugin mina-sshd-api-core 2.16.0-167.va_269f38cc024                                                                                                                   Will install new plugin github-branch-source 1917.v9ee8a_39b_3d0d

Will install new plugin snakeyaml-api 2.5-143.v93b_c004f89de

Will install new plugin jakarta-xml-bind-api 4.0.6-12.vb_1833c1231d3

Will install new plugin blueocean-pipeline-api-impl 1.27.25

...

Done

copy plugins to shared volume

finished initialization

INFO    hudson.lifecycle.Lifecycle#onReady: Jenkins is fully up and running

Чтобы посмотреть на установленные плагины, переключаюсь на WEB UI. На скрине ниже можно увидеть, что плагин Blue Ocean установился.

Итак, плагины зафиксированы, но что, если официальные зеркала недоступны? Или нужны корневые сертификаты для внутренних ресурсов? В следующем разделе разберу корпоративные ограничения и расскажу, как с ними работать.

Ограничения сети, устаревшие плагины, удаленные плагины

Все шаги, которые я проделал выше, позволяют развернуть инстанс Jenkins с теми версиями, которые были указаны на старте. Но бывает и так, что официальные зеркала не работают. На этот случай хорошо бы иметь плагины и зависимости для них в локальной сети. Для этого можно использовать менеджер репозиториев, например, Sonatype Nexus. В моем случае он уже есть. Следующим этапом создаю maven proxy repository.

Ниже возможный вариант настроек:

Корневые сертификаты

Иногда внутри компании или проекта используется свой удостоверяющий центр сертификации, и ресурсы имеют сертификаты, выписанные именно им. Другими словами, есть задача — подложить файлы корневых сертификатов в Jenkins, иначе при попытке скачать плагины из https://registry-maven.local будет возникать ошибка проверки SSL.

В этой ситуации можно пересобрать Docker-образ jenkins/jenkins:2.528.3-jdk21, который используется также как init-контейнер при загрузке плагинов. Однако я хочу показать альтернативный способ.

  1. Добавляю CA-сертификаты как секрет в NS Jenkins:

kubectl -n jenkins create secret generic ca-certs --from-file=/path_to_ca_certs/<имя_ca_сертификата>.crt

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

kubectl -n jenkins patch secret ca-certs \
     --patch="{\"data\": {\"<имя_промежуточного_ca_сертификата>.crt\": \"$(base64 -w0 /path_to_ca_certs/<имя_промежуточного_ca_сертификата>.crt)\"}}"

2. Указываю в values.yaml следующие модификации.

Модификации в values.yaml
controller:
...
  customInitContainers:
    - name: additional-ca-certs
      image: 'registry.local/jenkins/jenkins:2.528.3-jdk21'
      imagePullPolicy: Always
      command:
        - sh
      args:
        - -c
        - |
          # add new ca certs to system store file
          cat /etc/ssl/certs/ca-certificates.crt /tmp/additional-ca-certs/*.crt > /ca-certificates/ca-certificates.crt
          # copy existing JVM `cacerts` file and add all additional ca certs
          cp ${JAVA_HOME}/lib/security/cacerts /ca-certificates/cacerts
          for file in /tmp/additional-ca-certs/*.crt; do
            filename=$(basename -- "$file" .pem)
            ${JAVA_HOME}/bin/keytool -import -trustcacerts -noprompt -keystore /ca-certificates/cacerts -storepass changeit -alias "$filename" -file "$file"
          done
      volumeMounts:
        - name: additional-ca-certs
          mountPath: /tmp/additional-ca-certs
        - name: ca-certificates
          mountPath: /ca-certificates
...
persistence:
  # -- Additional volumes
  volumes:
  - name: ca-certificates
    emptyDir: {}
  - name: additional-ca-certs
    secret:
      secretName: ca-certs
  # -- Additional mounts
  mounts:
  - mountPath: /etc/ssl/certs/ca-certificates.crt
    name: ca-certificates
    subPath: ca-certificates.crt
  - mountPath: /opt/java/openjdk/lib/security/cacerts
    name: ca-certificates
    subPath: cacerts
...

Из плюсов этого решения — данные сертификаты могут быть нужны внутри агентов для работы с менеджером репозиториев. Таким образом можно проводить ротацию CA-сертификатов без необходимости пересобирать Docker-образы.

ENV JENKINS_UC_DOWNLOAD_URL

Чтобы использовать зеркало репозитория, достаточно указать ENV-переменную для init-контейнера:

controller:
...
  initContainerEnv:
    - name: JENKINS_UC_DOWNLOAD_URL
      value: "https://registry-maven.local/repository/jenkins-plugins/"
...

Можно удалить существующие файлы в PV, дабы убедиться, что все работает как задумано. Теперь обновляю/разворачиваю Jenkins:

helm upgrade --install jenkins-main ./ --namespace Jenkins

Если в логе есть следующие записи — значит, всё сделано правильно:

kubectl -n jenkins logs jenkins-main-0 init ...Will use url: https://registry-maven.local/repository/jenkins-plugins/blueocean/1.27.25/blueocean.hpi to download blueocean plugin
Downloaded and validated plugin blueocean
Checksum valid for: blueocean

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

Хотя Jenkins теперь может работать в корпоративной сети, однако конфигурация всё ещё хранится в Web UI. Нужно перенести её в код, чтобы ничего не потерять при перезапуске.

Конфигурация Jenkins as code (JCasC plugin)

Плагин JCasC позволяет хранить настройки Jenkins как код в YAML-формате. При этом не обязательно знать синтаксис на память: достаточно сконфигурировать то, что нужно, через Web UI, затем перейти на страницу https://jenkins.test.local/manage/configuration-as-code/viewExport и скопировать нужные настройки в формате JCasC YAML.

Для примера я задам настройки для SMTP-сервера:

Перехожу в экспорт конфигурации и вижу настройки:

unclassified:
...
  mailer:
    charset: "UTF-8"
    smtpHost: "10.10.10.10"
    useSsl: false
    useTls: false
...

Итак, конфигурация в коде, но пароли и ключи пока все еще нельзя хранить в Git. В следующем разделе покажу, как безопасно управлять секретами через Kubernetes.

Управление секретами

Инсталляция в Kubernetes позволяет использовать для хранения Jenkins credentials — секреты Kubernetes. Первым делом создаю пару ключей и секрет Kubernetes:

ssh-keygen -f ./crt-example -t ed25519
kubectl -n jenkins create secret generic secret-credentials-files --from-file=./crt-example

Затем редактирую запись в values.yaml:

controller:
...
  additionalExistingSecrets:
  - name: secret-credentials-files
    keyName: crt-example
  JCasC:
    configScripts:
      jenkins-creds: |
        credentials:
          system:
            domainCredentials:
            - credentials:
              - basicSSHUserPrivateKey:
                  description: "testuser private key"
                  id: "testuser-ssh-key"
                  username: testuser
                  passphrase: ""
                  scope: GLOBAL
                  privateKeySource:
                    directEntry:
                      privateKey: "${secret-credentials-files-crt-example}"
...

Для примера укажу секрет вида логин/пароль:

kubectl -n jenkins create secret generic secret-credentials --from-literal=test-username=superuser --from-literal=test-password=supersecretpassword

Добавлю его в values.yaml:

controller:
...
  additionalExistingSecrets:
  - name: secret-credentials-files
    keyName: crt-example
  - name: secret-credentials
    keyName: test-username
  - name: secret-credentials
    keyName: test-password
  JCasC:
    configScripts:
      jenkins-creds: |
        credentials:
          system:
            domainCredentials:
            - credentials:
              - basicSSHUserPrivateKey:
                  description: "testuser private key"
                  id: "testuser-ssh-key"
                  username: testuser
                  passphrase: ""
                  scope: GLOBAL
                  privateKeySource:
                    directEntry:
                      privateKey: "${secret-credentials-files-crt-example}"
              - usernamePassword:
                  description: "test username password"
                  id: "test-user"
                  password: ${secret-credentials-test-password}
                  scope: GLOBAL
                  username: ${secret-credentials-test-username}
...

Чтобы секреты стали доступны, нужно сделать следующее:

helm upgrade --install jenkins-main ./ --namespace Jenkins

При этом Jenkins будет перезапущен.

Как добавить новые секреты

Чтобы добавить новые секреты, потребуется всего лишь пара несложных манипуляций. Во-первых, обновить секрет в Kubernetes и values.yaml, а затем применить следующие правки.

# для файлов

kubectl -n jenkins patch secret secret-credentials-files --patch="{\"data\": {\"some-file\": \"$(base64 -w0 /path/somefile)\"}}"

# для string

kubectl -n jenkins patch secret secret-credentials --patch="{\"data\": {\"some-api-key\": \"$(echo 'some-secret-string' | base64 )\"}}"

#правки в values.yaml:

controller:
...
  additionalExistingSecrets:
  - name: secret-credentials-files
    keyName: crt-example
  - name: secret-credentials
    keyName: test-username
  - name: secret-credentials
    keyName: test-password
  - name: secret-credentials
    keyName: some-api-key
  JCasC:
    configScripts:
      jenkins-creds: |
        credentials:
          system:
            domainCredentials:
            - credentials:
              - basicSSHUserPrivateKey:
                  description: "testuser private key"
                  id: "testuser-ssh-key"
                  username: testuser
                  passphrase: ""
                  scope: GLOBAL
                  privateKeySource:
                    directEntry:
                      privateKey: "${secret-credentials-files-crt-example}"
              - usernamePassword:
                  description: "test username password"
                  id: "test-user"
                  password: ${secret-credentials-test-password}
                  scope: GLOBAL
                  username: ${secret-credentials-test-username}
              - string:
                  scope: GLOBAL
                  id: "some-api-key"
                  secret: "${secret-credentials-some-api-key}"
                  description: "Test secret string"

Во-вторых, использовать helm upgrade --install jenkins-main ./ --namespace Jenkins

Причем важно помнить, что если добавить секреты в Jenkins через Web UI, а затем новые секреты создавать уже через Jcasc + K8S secrets, то добавленные через Web UI секреты исчезнут.

Всё, секреты настроены, Jenkins готов к работе. Но писать одни и те же шаги в каждом пайплайне — неудобно. В следующем разделе покажу, как вынести общую логику в Shared Library.

Jenkins shared library

Shared Library позволяет выносить часто задействуемые функции и использовать их повторно в разных конвейерах. Для установки плагина понадобится репозиторий. Я буду использовать GitHub. Для private-репозитория необходимо заранее создать API-token или SSH-key и добавить его как секрет в Jenkins.

Вношу правки для values.yaml:

controller:
...
  JCasC:
    ...
    configScripts:
      jenkins-unclassified-settings: |
        unclassified:
          globalLibraries:
            libraries:
            - defaultVersion: "master"
              name: "shared-libraries"
              retriever:
                modernSCM:
                  libraryPath: "./"
                  scm:
                    gitSource:
                      # credentialsId: "git-deploy-key" - если нужно использовать доступ по ключу
                      id: "shared-libraries"
                      remote: "https://github.com/lab-lamz4/jenkins-shared-libraries.git"
                      traits:
                      - "gitBranchDiscovery"
...

В репозитории git.local:project/jenkins-libraries.git создаю каталог vars, в котором будут храниться функции. Создам простенький класс vars/printMessage.groovy:

#!/usr/bin/env groovy
 
def call(message, body) {
    def config = [:]
    body.resolveStrategy = Closure.DELEGATE_FIRST
    body.delegate = config
    body()
 
    echo """
>>> [${message}]"""
 
    return this
}

Функции переиспользуются, но создавать задачи вручную всё ещё нужно. В следующем разделе покажу, как автоматизировать создание задач через Seed-Job.

Seed-Job

В статье я рассматриваю один из подходов к хранению конвейера, а также использую только Jenkins Pipeline. Однако есть и другие варианты. Например, можно комбинировать сервис-репозиторий и Jenkinsfile, а также использовать различные branch-стратегии.

Хранение задач Jenkins как код

Мне нравится использовать служебный конвейер «Seed-Job» конвейер, который читает один или несколько репозиториев и создаёт, изменяет или удаляет задачи в Jenkins. Чтобы все работало, я добавляю в values.yaml следующий код:

Пример команды
controller:
...
  JCasC:
    ...
      jenkins-security: |
        security:
          globalJobDslSecurityConfiguration:
            useScriptSecurity: false
          scriptApproval:
            approvedSignatures:
            - "staticMethod jenkins.model.Jenkins getInstance"
            - "staticMethod java.lang.System getProperty java.lang.String"
            - "method jenkins.model.Jenkins getItemByFullName java.lang.String"
            - "method groovy.lang.GroovyObject invokeMethod java.lang.String java.lang.Object"
            - "method org.jenkinsci.plugins.workflow.support.steps.build.RunWrapper getRawBuild"
            - "field hudson.model.Run project"
            - "method hudson.model.AbstractItem setDescription java.lang.String"
      jenkins-seed-job: |
        jobs:
          - script: >
              pipelineJob('seed-job') {
                description("Этот конвейер предназначен для создания новых конвейеров, прежде чем использовать ознакомьтесь с инструкцией.")
                definition {
                  cps {
                    script('''
                      pipeline {
                          agent {
                              kubernetes {
                                  defaultContainer 'jnlp'
                              }
                          }
                          environment {
                              BRANCH = ""
                          }
                          options {
                              buildDiscarder logRotator(artifactDaysToKeepStr: '30', artifactNumToKeepStr: '30', daysToKeepStr: '30', numToKeepStr: '50')
                              disableConcurrentBuilds()
                              disableResume()
                              skipDefaultCheckout true
                              timeout(activity: true, time: 60)
                              parallelsAlwaysFailFast()
                          }
                          stages {
                              stage ('⬇️ CHECKOUT test') {
                                  steps {
                                      container('jnlp') {
                                          script {
                                               
                                              checkout_result = checkout([$class: 'GitSCM',
                                                      branches: [[name: "main"]],
                                                      doGenerateSubmoduleConfigurations: false,
                                                      extensions: [[$class: 'LocalBranch'],
                                                      [$class: 'SubmoduleOption',
                                                          disableSubmodules: false,
                                                          parentCredentials: true,
                                                          recursiveSubmodules: true,
                                                          reference: '',
                                                          trackingSubmodules: false
                                                      ]],
                                                      submoduleCfg: [],
                                                      // userRemoteConfigs: [[credentialsId: "<ваш id Jenkins секрета>", url: "git@репо.git"]]]) для приватного репозитория нужно использовать заранее созданные секреты
                                                      userRemoteConfigs: [[url: "https://github.com/lab-lamz4/jenkins-pipeline.git"]]])
                                              BRANCH = checkout_result.GIT_LOCAL_BRANCH
                                          }
                                      }
                                  }
                              }
                              stage('⚙️ Generate DSL') {
                                  steps {
                                      script {
                                          dir("jenkins") {
                                              def subfolders = sh(returnStdout: true, script: 'find -type d -print | sort').trim().split()
                                               
                                              subfolders.each { _directory ->
                                                  if ( "^${_directory}" == ".") {
                                                      return
                                                  }
                                                  jobDsl scriptText: """
                                                      folder('^${_directory.replaceFirst('^./', "")}') {
                                                          description('Generated by JobDSL')
                                                      }
                                                      """,
                                                      lookupStrategy: 'SEED_JOB',
                                                      ignoreExisting: true,
                                                      ignoreMissingFiles: true
 
                                              }
                                              // requiries pipeline-utility-steps
                                              def files = findFiles(glob: '**/*.Jenkinsfile')
                                              files.each{ f ->
                                                  def jenkins_file = "^${f.name}"
                                                  def full_path_for_jenkinsfile = "^${f.path}"
                                                  def full_directory_path = full_path_for_jenkinsfile-jenkins_file
                                                  def job_name = "^${f.name}"-".Jenkinsfile"
                                                  def need_to_run = false
                                                  if (jenkins.model.Jenkins.instance.getItemByFullName("^${full_directory_path}^${job_name}") == null) {
                                                      need_to_run = true
                                                  }
                                                  def job = jobDsl scriptText: """
                                                      pipelineJob('^${full_directory_path}^${job_name}') {
                                                          description('Pipeline создан JobDSL')
                                                          parameters {
                                                              booleanParam('UPDATE_VARS', true, '!!! Используйте если требуется обновить параметры !!!')
                                                          }
                                                          definition {
                                                              cpsScm {
                                                                  scm {
                                                                      git {
                                                                          remote {
                                                                              url("https://github.com/lab-lamz4/jenkins-pipeline.git")
                                                                              // credentials("<ваш id Jenkins секрета>")
                                                                          }
                                                                          branch("main")
                                                                      }
                                                                  }
                                                                  scriptPath('jenkins/^${full_path_for_jenkinsfile}')
                                                                  lightweight()
                                                              }
                                                          }
                                                      }
                                                      """,
                                                      lookupStrategy: 'SEED_JOB',
                                                      ignoreExisting: true
                                                  if (need_to_run) {
                                                      println("Autostart job: ^${full_directory_path}^${job_name}. Get first properties")
                                                      build(job: "^${full_directory_path}^${job_name}",
                                                          parameters: [
                                                              booleanParam(name: "UPDATE_VARS", value: true)
                                                          ],
                                                          propagate: true,
                                                          wait: false)
                                                  }
                                              }
                                          }
                                      }
                                  }
                              }
                          }
                      }
                    '''.stripIndent())
                    sandbox()    
                  }
                }
              }...

Данный код выкачивает репозиторий, в котором хранятся файлы Jenkinsfile и создаёт Jenkins Jobs с учётом структуры каталогов в репозитории. Скрипт не меняет существующие задачи и не удаляет то, что было удалено из Git. Если вам исходя из задач требуется корректировать или удалять Jenkins Jobs при изменениях в репозитории git, необходимо поправить код Seed-Job.

Итоги

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

Если остались вопросы — добро пожаловать в комментарии.