На связи Андрей Леодоров, ведущий инженер по автоматизации процессов компании «Гарда». За время работы с Jenkins я видел разные сценарии его использования: от локальных инсталляций под одну команду до масштабируемых инстансов с централизованным сопровождением. Я пришёл к выводу, что Jenkins отлично подходит для использования на уровне продукта — когда команда может сама определять, какие инструменты и ресурсы нужны для организации процессов CI/CD.
Статья — это практический гайд для небольшой команды или отдельного проекта, которому нужен собственный воспроизводимый Jenkins в Kubernetes. Результатом будет полностью готовый к работе Jenkins controller с динамическими агентами в Kubernetes и зафиксированной конфигурацией в git. Дополнительными элементами решения станут:
плагины с зафиксированными версиями. Вы сможете полностью контролировать процесс их обновления;
пример размещения Jenkins credentials как Kubernetes secrets;
вариант хранения Jenkins Job как код;
инструкция по созданию Jenkins Jobs с помощью seed-job;
базовая схема настройки Jenkins shared libraries.
Предлагаемый в статье подход позволит разворачивать идентичные среды с нуля, не завися от конкретных версий плагинов.
Материал рассчитан на инженеров, которые уже имели опыт работы с 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 будет проверять наличие этих плагинов локально, а если их нет — скачает именно те, что были зафиксированы.
Проверяю, что всё именно так, как ожидалось:
Удаляю уже развернутый Jenkins (
helm uninstall jenkins-main --namespace jenkins), а также все файлы в PV (/pv/jenkins# rm -rf *.*).Пробую развернуть 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-контейнер при загрузке плагинов. Однако я хочу показать альтернативный способ.
Добавляю 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, плагины, или провести необходимые проверки безопасности, а по готовности — предложить проектам обновить среды и быть уверенным, что все работает как нужно.
Если остались вопросы — добро пожаловать в комментарии.
