Jenkins: архитектура, Jenkinsfile, агенты, плагины и когда он всё ещё уместен
Про Jenkins принято говорить в прошедшем времени. При этом он продолжает крутить сборки в банках, телекоме, автопроме и у половины энтерпрайза, который вы можете вспомнить, — и не потому, что там «не слышали про GitHub Actions». Причины конкретные: сборки идут на железе, до которого SaaS-раннер не дотянется; лицензии на тулчейн привязаны к MAC-адресу; регуляторика запрещает исходникам покидать периметр; или просто накоплено 900 джоб, миграция которых стоит человеко-год.
Цель статьи — не «продать» Jenkins и не похоронить его, а дать инженерную модель: как он устроен, какой код вы реально пишете, во что обходится эксплуатация и по каким признакам принимать решение «оставляем / мигрируем». Общие принципы CI — триггеры, кэш, матрицы — разобраны в статье Непрерывная интеграция; здесь мы предполагаем, что вы их знаете, и погружаемся в конкретный инструмент.
Откуда он взялся и почему это важно для понимания архитектуры
Из этой линии следуют все главные свойства Jenkins, включая неприятные.
Он UI-first по происхождению. Модель данных — это Java-объекты, сериализованные в XML на диске контроллера. Pipeline as Code прикрутили позже, поверх. Поэтому даже сегодня часть настроек живёт в глобальной конфигурации, а не в репозитории, и без JCasC ваш CI — это снежинка, которую нельзя воспроизвести.
Он расширяется плагинами, а не встроенными фичами. Экосистема — около 1900 плагинов — это одновременно суперсила (интеграция есть почти со всем) и главный источник боли (о ней ниже).
Pipeline написан на Groovy с CPS-трансформацией. Чтобы сборка переживала рестарт контроллера, каждый шаг сериализуется в стейт на диске. Отсюда странные ограничения языка, которые ловят новичков.
Архитектура: контроллер, агенты, executors
Ментальная модель простая: контроллер — это диспетчер, который сам ничего не должен собирать; агенты — руки.
4 executors
label: linux docker"] A2["Физический сервер
1 executor
label: macos xcode"] K8S["Эфемерный под
1 executor, живёт одну сборку
label: k8s"] end A1 --> WS1["workspace/ на диске агента"] K8S --> WS2["emptyDir, умирает вместе с подом"]
Ключевые понятия, которые надо развести:
| Понятие | Что это | Практическое следствие |
|---|---|---|
| Controller | JVM-процесс с веб-интерфейсом и планировщиком | Single point of failure в OSS-версии: нативного HA нет |
| Agent (node) | Машина, на которой исполняются шаги | Масштабируется горизонтально, ставится под нужный тулчейн |
| Executor | Слот параллелизма на агенте | Обычно 1 executor на 1–2 vCPU; больше — драка за I/O |
| Workspace | Каталог с исходниками на агенте | Не бэкапится, чистится; не путать с JENKINS_HOME |
| Label | Тег агента для маршрутизации | agent { label 'linux && docker' } — булева алгебра работает |
| JENKINS_HOME | Состояние контроллера на диске | Единственное, что надо бэкапить |
Самая частая ошибка на старте — оставить executors на контроллере. Тогда сборка с rm -rf в неудачном каталоге или JVM-хип-боль от компиляции роняет весь CI. В глобальной конфигурации у built-in node число executors должно быть 0. Это не рекомендация, это правило.
Как агент подключается — и почему это первый архитектурный выбор
Направление TCP-соединения определяет, какие правила нужны в firewall, и потому решается раньше всего остального.
Практическое правило: если агенты в том же VPC и вы ими управляете — SSH-launcher; если агенты в Kubernetes, за NAT, в DMZ или у подрядчика — inbound WebSocket. Старый inbound-режим по отдельному TCP-порту (JNLP) в новых установках смысла не имеет: WebSocket ходит по тому же 443, что и UI, и переживает корпоративные прокси.
Жизненный цикл сборки
Отдельно про Unstable (жёлтый шар): это состояние, которого нет в большинстве современных CI, и оно полезнее, чем кажется. Флаки-тест не должен блокировать релиз так же жёстко, как ошибка компиляции. Но если жёлтый статус становится нормой — вы просто перестали смотреть на тесты.
Установка, которую не стыдно показать: Docker + JCasC
Кликать в UI — путь к невоспроизводимому CI. Правильная база — образ с зафиксированным списком плагинов и конфигурация в YAML.
# Dockerfile — контроллер с зафиксированными версиями плагинов
FROM jenkins/jenkins:2.492.3-lts-jdk21
USER root
# докер-клиент нужен, только если запускаете docker-агентов на том же хосте
RUN apt-get update && apt-get install -y --no-install-recommends git curl \
&& rm -rf /var/lib/apt/lists/*
USER jenkins
# отключаем мастера установки и включаем JCasC
ENV JAVA_OPTS="-Djenkins.install.runSetupWizard=false -Dhudson.model.DirectoryBrowserSupport.CSP=" \
CASC_JENKINS_CONFIG=/var/jenkins_home/casc.yaml
# ВСЕ плагины с точными версиями — иначе сборка образа невоспроизводима
COPY plugins.txt /usr/share/jenkins/ref/plugins.txt
RUN jenkins-plugin-cli --plugin-file /usr/share/jenkins/ref/plugins.txt --latest false
COPY casc.yaml /var/jenkins_home/casc.yaml
# plugins.txt — минимальный вменяемый набор, версии закреплены
configuration-as-code:1932.v75cb_b_f1b_698d
workflow-aggregator:608.v67378e9d3db_1
git:5.7.0
github-branch-source:1810.v0eb_dbf294f39
kubernetes:4331.v92c1a_6c1c99a_
credentials-binding:687.v619cb_15e923f
pipeline-stage-view:2.34
timestamper:1.27
ws-cleanup:0.48
job-dsl:1.90
# casc.yaml — конфигурация, которую можно ревьюить в PR
jenkins:
systemMessage: "Управляется JCasC. Ручные правки будут перезатёрты."
numExecutors: 0 # на контроллере не собираем НИЧЕГО
mode: EXCLUSIVE # задачи идут только на агентов с явным label
securityRealm:
local:
allowsSignup: false
users:
- id: admin
password: "${ADMIN_PASSWORD}" # из переменной окружения / секрета
authorizationStrategy:
roleBased:
roles:
global:
- name: "readonly"
permissions: ["Overall/Read", "Job/Read"]
entries:
- group: "authenticated"
clouds:
- kubernetes:
name: "k8s"
serverUrl: "https://kubernetes.default.svc"
namespace: "jenkins-agents"
jenkinsUrl: "http://jenkins.jenkins.svc.cluster.local:8080"
directConnection: false
containerCapStr: "40" # предохранитель от «съели весь кластер»
templates:
- name: "default"
label: "k8s"
yamlMergeStrategy: merge
containers:
- name: jnlp
image: "jenkins/inbound-agent:3283.v92c105e0f819-8"
resourceRequestCpu: "200m"
resourceRequestMemory: "512Mi"
resourceLimitMemory: "1Gi"
credentials:
system:
domainCredentials:
- credentials:
- usernamePassword:
scope: GLOBAL
id: "registry-creds"
username: "ci-bot"
password: "${REGISTRY_TOKEN}"
unclassified:
location:
url: "https://ci.example.com/"
adminAddress: "devops@example.com"
globalLibraries:
libraries:
- name: "shared"
defaultVersion: "main"
implicit: false
retriever:
modernSCM:
scm:
git:
remote: "https://github.com/example/jenkins-shared-library.git"
Проверить, что YAML валиден, до применения:
$ docker run --rm -v "$PWD/casc.yaml:/casc.yaml" \
-e CASC_JENKINS_CONFIG=/casc.yaml \
-e JAVA_OPTS="-Djenkins.install.runSetupWizard=false" \
myorg/jenkins:2.492.3 --version
2.492.3
# а из работающего инстанса — экспортировать текущее состояние в YAML:
$ curl -u admin:$TOKEN https://ci.example.com/manage/configuration-as-code/export -o current.yaml
$ diff -u casc.yaml current.yaml # дрейф конфигурации виден глазами
Этот diff — то, ради чего всё затевалось. Он отвечает на вопрос «кто и что накликал руками за последний месяц».
Jenkinsfile: Declarative Pipeline от простого к продовому
Declarative — то, что вы пишете в 95% случаев. Он даёт структуру, валидируется до запуска и понятен человеку, который Groovy не знает.
// Jenkinsfile — сборка Java-сервиса с эфемерными агентами в Kubernetes
pipeline {
// агент задаётся на уровне pipeline или отдельного stage
agent none
options {
buildDiscarder(logRotator(numToKeepStr: '30', artifactNumToKeepStr: '5'))
timeout(time: 45, unit: 'MINUTES')
disableConcurrentBuilds(abortPrevious: true) // новый push отменяет старую сборку ветки
timestamps()
ansiColor('xterm')
}
environment {
REGISTRY = 'registry.example.com'
IMAGE = "${REGISTRY}/payments/api"
// короткий SHA удобнее номера сборки: он воспроизводим
TAG = "${env.GIT_COMMIT.take(12)}"
}
stages {
stage('Build & Test') {
agent {
kubernetes {
// под описываем полноценным манифестом — так контролируем ресурсы и тома
yaml '''
apiVersion: v1
kind: Pod
spec:
securityContext:
runAsUser: 1000
fsGroup: 1000
containers:
- name: maven
image: maven:3.9-eclipse-temurin-21
command: ["cat"]
tty: true
resources:
requests: {cpu: "1", memory: "2Gi"}
limits: {memory: "4Gi"}
volumeMounts:
- name: m2
mountPath: /home/jenkins/.m2
volumes:
- name: m2
persistentVolumeClaim:
claimName: maven-cache # общий кэш зависимостей между сборками
'''
}
}
steps {
container('maven') {
sh 'mvn -B -ntp verify'
}
}
post {
always {
junit testResults: 'target/surefire-reports/*.xml', skipPublishingChecks: true
// stash — передать артефакт следующему stage на ДРУГОМ агенте
stash name: 'jar', includes: 'target/*.jar'
}
}
}
stage('Quality gates') {
// независимые проверки идут параллельно — это экономит больше всего времени
parallel {
stage('SAST') {
agent { label 'k8s' }
steps { sh 'semgrep --config=auto --error --json -o semgrep.json .' }
}
stage('Lint') {
agent { label 'k8s' }
steps { sh 'mvn -B -ntp checkstyle:check' }
}
stage('Licenses') {
agent { label 'k8s' }
steps { sh 'mvn -B -ntp license:check' }
}
}
}
stage('Image') {
when { anyOf { branch 'main'; buildingTag() } }
agent {
kubernetes {
yaml '''
apiVersion: v1
kind: Pod
spec:
containers:
- name: kaniko
image: gcr.io/kaniko-project/executor:v1.23.2-debug
command: ["/busybox/cat"]
tty: true
volumeMounts:
- name: docker-config
mountPath: /kaniko/.docker
volumes:
- name: docker-config
secret: {secretName: registry-creds-dockerjson}
'''
}
}
steps {
unstash 'jar'
container('kaniko') {
// kaniko собирает образ без docker-демона и без privileged
sh """
/kaniko/executor --context=\$PWD \
--dockerfile=Dockerfile \
--destination=${IMAGE}:${TAG} \
--destination=${IMAGE}:latest \
--cache=true --cache-ttl=168h \
--reproducible
"""
}
}
}
stage('Deploy to prod') {
when { branch 'main' }
options { timeout(time: 2, unit: 'HOURS') }
steps {
// ручной гейт: input вне agent-блока, чтобы не держать executor занятым
timeout(time: 30, unit: 'MINUTES') {
input message: "Катим ${TAG} в прод?", submitter: 'release-managers'
}
node('k8s') {
withCredentials([file(credentialsId: 'kubeconfig-prod', variable: 'KUBECONFIG')]) {
sh "kubectl -n payments set image deploy/api api=${IMAGE}:${TAG} --record"
sh "kubectl -n payments rollout status deploy/api --timeout=300s"
}
}
}
}
}
post {
failure {
slackSend channel: '#ci-alerts',
color: 'danger',
message: "FAILED ${env.JOB_NAME} #${env.BUILD_NUMBER} <${env.BUILD_URL}|логи>"
}
cleanup { cleanWs() } // не оставляем мусор на статичных агентах
}
}
Разберём неочевидные, но важные места.
agent none + агент на каждом stage. Иначе весь пайплайн, включая паузу на input, держит executor. На большом инстансе это главная причина «очередь стоит, а агенты простаивают».
input вне node. Правило то же: пока ждём человека, ресурсы должны быть свободны. Вложенный timeout вокруг input обязателен — иначе забытая сборка висит вечно.
stash / unstash. Артефакты между stage на разных агентах передаются через контроллер. Это удобно, но stash идёт по сети и хранится в JENKINS_HOME — гонять через него 2 ГБ не надо, для этого есть реестр или S3.
disableConcurrentBuilds(abortPrevious: true). Простейшая экономия ресурсов: пять пушей подряд не должны занимать пять агентов.
Declarative или Scripted
// Scripted — когда нужен настоящий код: генерация stage'ей во время выполнения
node('k8s') {
def modules = sh(script: "ls -d services/*/ | xargs -n1 basename", returnStdout: true)
.trim().split('\n')
// строим map параллельных веток динамически — Declarative так не умеет
def branches = [:]
modules.each { m ->
branches["build-${m}"] = {
stage("build ${m}") {
sh "make -C services/${m} build"
}
}
}
branches.failFast = true
parallel branches
}
Правило: Declarative по умолчанию, Scripted — точечно и внутри script { } или shared library. Смешанный стиль на 800 строк — самый распространённый вид долга в Jenkins-проектах.
CPS: главный источник загадочных ошибок
Groovy в пайплайне проходит CPS-трансформацию: каждый шаг сериализуется, чтобы сборка пережила рестарт контроллера. Отсюда ограничения:
// ❌ NotSerializableException: java.util.regex.Matcher
def m = (text =~ /version=(\d+)/)
echo m[0][1]
sh 'долгий шаг' // здесь состояние сериализуется — и падает
// ✅ несериализуемое прячем в @NonCPS
@NonCPS
def extractVersion(String text) {
def m = (text =~ /version=(\d+)/)
return m ? m[0][1] : null
}
// ❌ .each с замыканием на большой коллекции — медленно и иногда падает
list.each { echo it }
// ✅ обычный цикл
for (int i = 0; i < list.size(); i++) { echo list[i] }
Внутри @NonCPS нельзя вызывать pipeline-шаги (sh, echo в цикле, checkout) — метод исполняется целиком, без точек сериализации. Ещё важная настройка производительности:
options { durabilityHint('PERFORMANCE_OPTIMIZED') }
По умолчанию Jenkins пишет стейт на диск после каждого шага (MAX_SURVIVABILITY). На инстансе с сотнями параллельных сборок это убивает диск. PERFORMANCE_OPTIMIZED снижает I/O в разы ценой того, что при жёстком падении контроллера сборки не восстановятся, — почти всегда правильный обмен.
Shared Library: как перестать копировать 300 строк по репозиториям
Когда у вас 40 микросервисов, Jenkinsfile должен быть на 10 строк, а логика — в общей библиотеке.
jenkins-shared-library/
├── vars/
│ ├── standardJavaPipeline.groovy # шаг верхнего уровня
│ └── notifySlack.groovy
├── src/com/example/ci/
│ └── Docker.groovy # обычные Groovy-классы
└── resources/com/example/ci/
└── pod-maven.yaml # шаблоны, читаются libraryResource
// vars/standardJavaPipeline.groovy — вызывается как standardJavaPipeline(...)
def call(Map cfg = [:]) {
// значения по умолчанию + переопределение на стороне сервиса
def conf = [jdk: '21', deployTo: 'staging', sonar: true] << cfg
pipeline {
agent none
options { timeout(time: 30, unit: 'MINUTES'); timestamps() }
stages {
stage('Build') {
agent { kubernetes { yaml libraryResource('com/example/ci/pod-maven.yaml') } }
steps {
container('maven') { sh "mvn -B -ntp -Djava.version=${conf.jdk} verify" }
}
post { always { junit '**/surefire-reports/*.xml' } }
}
stage('Sonar') {
when { expression { conf.sonar } }
agent { label 'k8s' }
steps { withSonarQubeEnv('main') { sh 'mvn -B sonar:sonar' } }
}
}
post { failure { notifySlack(status: 'FAILED') } }
}
}
// Jenkinsfile в каждом сервисе — ровно две строки
@Library('shared@main') _
standardJavaPipeline(jdk: '21', deployTo: 'prod')
Две вещи, которые обязательно сделать сразу:
- Версионировать библиотеку тегами, а не жить на
main.@Library('shared@v2.4.0')означает, что коммит в библиотеку не сломает 40 продовых пайплайнов одновременно.mainоставьте для канареечных сервисов. - Тестировать библиотеку. Есть JenkinsPipelineUnit — юнит-тесты на Groovy с моками шагов. Библиотека без тестов — это продовый код без тестов, просто вы называете его «скриптом».
Плагины: суперсила и главный технический долг
решается через sh?"} Q1 -->|"да"| CORE["Делаем без плагина ✅"] Q1 -->|"нет"| Q2{"Плагин обновлялся
за последние 12 мес?"} Q2 -->|"нет"| RISK["Заброшен: не ставим
или форкаем осознанно ⚠️"] Q2 -->|"да"| Q3{"Тянет ли он
десятки зависимостей?"} Q3 -->|"да"| WEIGH["Считаем цену обновлений
всего инстанса"] Q3 -->|"нет"| Q4{"Есть свежие CVE
в security advisories?"} Q4 -->|"да"| PATCH["Только с патчем
и в изоляции"] Q4 -->|"нет"| OK["Ставим, фиксируем версию ✅"]
Почему это вообще проблема. Все плагины грузятся в одну JVM контроллера, разделяют classpath и зависят друг от друга. Отсюда три хронические болезни:
- Dependency hell. Обновление одного плагина требует обновления пяти его зависимостей, одна из которых требует более свежего ядра, которое ломает шестой плагин. На инстансе со 120 плагинами обновление превращается в проект.
- Поверхность атаки. Jenkins публикует security advisories регулярно, и подавляющее большинство CVE — именно в плагинах, а не в ядре. Плагин с правами контроллера имеет доступ ко всем вашим credentials.
- Заброшенность. Значительная часть каталога не обновлялась годами. Опираться на такой плагин в критичном пайплайне — принимать на себя его поддержку.
Практическая гигиена:
# инвентаризация: что стоит, что устарело, что помечено как заброшенное
$ java -jar jenkins-cli.jar -s https://ci.example.com/ -auth @token list-plugins | head
configuration-as-code Configuration as Code 1932.v75cb_b_f1b_698d
git Git plugin 5.7.0
kubernetes Kubernetes 4331.v92c1a_6c1c99a_ (4353.v0badc0d1b_a_e5)
# сколько плагинов реально нужно — сравните с тем, сколько стоит
$ java -jar jenkins-cli.jar -s https://ci.example.com/ -auth @token list-plugins | wc -l
137
137 — типичная цифра для инстанса, который никто не пропалывал. Реально нужных обычно 25–40. Каждый лишний — это обязательство обновлять и риск CVE.
Минимальный здоровый набор: workflow-aggregator, git + github-branch-source (или gitlab-branch-source), configuration-as-code, credentials-binding, kubernetes, ws-cleanup, timestamper, job-dsl, matrix-auth. Всё остальное — по доказанной необходимости, а не «на всякий случай». Отдельно про то, как правильно обращаться с секретами и цепочкой поставок, — в статье Безопасность конвейера.
Как это работает в динамике: от push до пода
Здесь видно, где реально уходит время, и это важно для оптимизации: провижининг пода (0,5–2 минуты: планирование + pull образа), checkout (на монорепе легко минута), и только потом полезная работа. Отсюда два практических рычага: предварительно прогретые образы агентов на нодах (imagePullPolicy: IfNotPresent + DaemonSet-прогрев) и checkout с shallow: true, depth: 1 вместо полного клона.
checkout([$class: 'GitSCM',
branches: [[name: env.BRANCH_NAME]],
extensions: [
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 20],
[$class: 'CheckoutOption', timeout: 20]
],
userRemoteConfigs: [[url: 'https://github.com/example/monorepo.git',
credentialsId: 'github-app']]
])
Честное сравнение: во что обходится Jenkins против альтернатив
Разберём сценарий: 40 разработчиков, ~600 сборок в сутки, средняя сборка 6 минут на 4 vCPU. Это примерно 3600 агент-минут в сутки, около 1800 агент-часов в месяц.
| Вариант | Инфраструктура, $/мес | Эксплуатация, FTE | Комментарий |
|---|---|---|---|
| Jenkins + агенты on-demand в AWS | ~500 | 0,2–0,4 | контроллер m6i.xlarge ≈ 140 + агенты ≈ 300 + LB/EBS/S3 ≈ 60 |
| Jenkins + агенты на spot | ~310 | 0,3–0,5 | агенты ≈ 110; spot требует retry-логики и терпимости к прерываниям |
| Jenkins на своём железе | ~150 (амортизация) | 0,4–0,6 | дешевле по счёту, дороже по вниманию: диски, сеть, обновления ОС |
| GitHub Actions, hosted runners | ~1700 | ~0,05 | 108 000 мин × 0,016 $ за 4-ядерный раннер; включённых минут почти не хватает |
| GitHub Actions, self-hosted runners | ~300 | 0,15–0,25 | минуты бесплатны, платите только за железо агентов — часто оптимум |
| GitLab CI (SaaS) + свои runners | лицензии + ~300 | 0,15–0,25 | если GitLab уже стоит как SCM, отдельный CI не нужен |
| CloudBees CI (Jenkins enterprise) | лицензии по прайсу + инфра | 0,15–0,3 | HA-контроллеры, RBAC, поддержка; берут ради compliance и SLA |
Три вывода, которые обычно неприятно слышать.
Инфраструктура — не главная статья. 0,3 FTE SRE при полной стоимости специалиста порядка 6000 $/мес — это 1800 $/мес, то есть больше, чем весь счёт AWS. Любое сравнение CI, где считают только vCPU-часы, врёт.
Managed CI дороже по счёту, но дешевле по вниманию. 1700 $ против 500 $ — это 1200 $ разницы, примерно 0,2 FTE. Если ваш Jenkins требует больше 0,2 FTE (а с 137 плагинами он требует), managed выигрывает даже по деньгам.
Гибрид почти всегда лучше крайностей. Managed-плоскость управления (GitHub/GitLab) + self-hosted раннеры на своём железе: минуты бесплатны, доступ к внутренней сети есть, контроллер поддерживать не надо. Это же и главный конкурент Jenkins сегодня — не «облачный CI», а self-hosted runner чужой платформы. Детальное сравнение платформ — в статье Современные платформы CI, а методика расчёта TCO — в Стоимости инфраструктуры.
Где Jenkins объективно проигрывает
| Критерий | Jenkins | Современные платформы |
|---|---|---|
| Порог входа | высокий: Groovy, CPS, плагины, JVM-тюнинг | низкий: YAML, документация с примерами |
| HA из коробки | нет в OSS (только CloudBees) | да, это забота вендора |
| Обновления | ручные, с риском сломать плагины | прозрачные для пользователя |
| Marketplace-безопасность | плагин = код в JVM контроллера | actions изолированнее, но supply chain тоже риск |
| Локальный запуск пайплайна | практически невозможен | act, gitlab-runner exec — частично |
| Скорость холодного старта | 0,5–2 мин на провижининг | сопоставимо или лучше |
Где Jenkins всё ещё выигрывает
- Экзотические агенты. macOS с Xcode, AIX, Windows со специфичным SDK, стенд с подключённым железом, HSM, лицензионный сервер. Jenkins подключит что угодно, где есть JVM.
- Полностью изолированный контур. Air-gapped среда без выхода в интернет: свой update-center, свой реестр, никаких вызовов наружу.
- Оркестрация, а не только сборка. Сложные многодневные пайплайны с ручными гейтами, матрицами железа, ветвлением по результатам — Jenkins это выражает лучше, чем YAML-платформы.
- Накопленная интеграция. 900 джоб, шесть shared library и интеграция с внутренним ITSM — миграция стоит человеко-годы и создаёт риск без бизнес-выгоды.
Читается так: новый Jenkins в 2026 году заводят только при явной причине из правой половины графика. Если вы в левом нижнем квадранте и кто-то предлагает поднять Jenkins «потому что мы его знаем» — это решение стоит оспорить.
Типичные ошибки в продакшене
Executors на контроллере. Уже сказано, но это ошибка №1. numExecutors: 0, mode: EXCLUSIVE.
JENKINS_HOME на 400 ГБ. Артефакты копятся годами, бэкап перестаёт помещаться в окно, восстановление занимает сутки. Лечение: buildDiscarder в каждом пайплайне (лучше — глобально через JCasC), артефакты в S3/реестр, а не в archiveArtifacts.
Нет тюнинга JVM. Контроллер с дефолтным хипом на 8 ГБ памяти уходит в бесконечный GC при 200 джобах. Базовая настройка:
JAVA_OPTS="-Xms4g -Xmx4g \
-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:+UseStringDeduplication \
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/jenkins \
-Djenkins.model.Jenkins.slaveAgentPort=-1 \
-Dhudson.model.LoadStatistics.clock=2000"
-Xms = -Xmx — чтобы хип не рос рывками. Дальше смотрите на метрики через плагин prometheus и стройте дашборд: длина очереди, время в очереди, GC pause, число онлайн-агентов. Про то, какие сигналы важны и как ставить SLO, — в статье Наблюдаемость и дежурства.
Секреты в переменных окружения и в логах. withCredentials маскирует значения в выводе, но sh "curl -H 'Token: ${TOKEN}'" в двойных кавычках подставит секрет прямо в команду, видимую в ps. Правильно — одинарные кавычки Groovy и подстановка на стороне shell:
withCredentials([string(credentialsId: 'api-token', variable: 'TOKEN')]) {
// ✅ Groovy НЕ подставляет $TOKEN — это делает shell, значения нет в логе шага
sh 'curl -sS -H "Authorization: Bearer $TOKEN" https://api.example.com/deploy'
}
Один гигантский монолитный пайплайн на 1500 строк. Разбивайте по shared library и отдельным джобам с build job:. Отладка полуторатысячестрочного Jenkinsfile — это чтение логов, потому что локально запустить нельзя.
Отсутствие плана обновлений. Инстанс на Jenkins 2.319 с плагинами трёхлетней давности не обновляется одним прыжком. Дисциплина: LTS обновляем раз в квартал, плагины — раз в месяц на staging-инстансе, восстановленном из бэкапа прода.
Ветвление без Multibranch. Ручное создание джобы на каждую ветку — это работа, которую делает multibranchPipelineJob из Job DSL или github-branch-source автоматически, включая удаление джоб мёртвых веток.
Стратегия миграции, если решили уходить
Big-bang миграция 900 джоб не работает. Работает вот такая последовательность:
- Заморозить рост. Новые сервисы — только на новой платформе. Jenkins переходит в режим «только поддержка существующего».
- Классифицировать джобы. Обычно 20% джоб дают 80% сборок, а половина каталога не запускалась год. Начните с
curlпо REST API:/api/json?tree=jobs[name,lastBuild[timestamp]]— и удалите мёртвое. - Перевести типовые пайплайны. Стандартный «собрать-протестировать-запушить образ» переносится механически. Здесь помогает то, что shared library уже унифицировала логику.
- Оставить остаток. Экзотика (маки, стенды, air-gap) может остаться на Jenkins навсегда — это нормальный исход. Цель — не «убить Jenkins», а «свести его к тому, что действительно требует Jenkins».
- Держать оба CI обязательными в течение переходного периода, чтобы сравнивать результаты, а потом отключать старый.
Сама механика деплоя и стратегии выката при этом не меняются — они разобраны в статье Continuous Delivery и стратегии релизов.
Мини-итог
- Jenkins — это диспетчер + агенты. Контроллер ничего не собирает, executors на нём должны быть равны нулю.
- Всё состояние живёт в JENKINS_HOME; он должен быть маленьким и бэкапиться целиком, вместе с
secrets/. - JCasC — не опция, а условие вменяемости. Конфигурация в YAML, плагины с закреплёнными версиями в Dockerfile,
diffпротив экспорта как детектор дрейфа. - Declarative Pipeline по умолчанию,
agent noneна верхнем уровне, агент на каждом stage,inputвнеnode. - Плагины — главный долг. Держите 25–40 вместо 137, следите за advisories, не ставьте заброшенное.
- Выбор платформы решают эксплуатационная нагрузка и порог входа, а не цена vCPU-часа: 0,3 FTE стоят дороже всего счёта за инфраструктуру.
- Jenkins уместен там, где нужны экзотические агенты, изолированный контур или сложная оркестрация. В остальных случаях в 2026 году новый Jenkins заводить не стоит.
Источники
- Jenkins Handbook — Pipeline — официальное руководство, разделы Declarative/Scripted и синтаксис.
- Pipeline Syntax Reference — исчерпывающая справка по директивам Declarative.
- Scaling Jenkins — официальные рекомендации по масштабированию и архитектуре агентов.
- Configuration as Code plugin — документация и примеры JCasC.
- Kubernetes plugin — конфигурация podTemplate и эфемерных агентов.
- Jenkins Security Advisories — ленту стоит читать регулярно, а не при инциденте.
- JenkinsPipelineUnit — юнит-тестирование shared library.
- Jenkins Java Support Policy — какие версии JVM требуются актуальными LTS.
- Jez Humble, David Farley. Continuous Delivery — базовая книга про пайплайн доставки, не устарела.
- martinfowler.com — Continuous Integration — принципы, к которым стоит возвращаться при любом выборе инструмента.
Что дальше
GitHub Actions, GitLab CI, Drone и другие: сравнение современных платформ CI — разберём главных конкурентов Jenkins по тем же критериям: модель исполнения, стоимость, self-hosted раннеры и эксплуатационная нагрузка.