CI/CD, инфраструктура и облака Jenkins: архитектура, Jenkinsfile, агенты, плагины и когда он всё ещё уместен
0%

Jenkins: архитектура, Jenkinsfile, агенты, плагины и когда он всё ещё уместен

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

Ментальная модель простая: контроллер — это диспетчер, который сам ничего не должен собирать; агенты — руки.

Ключевые понятия, которые надо развести:

Понятие Что это Практическое следствие
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. Это не рекомендация, это правило.

Раскладка JENKINS_HOME: что бэкапить, что чистить, что вообще не должно там лежать

Как агент подключается — и почему это первый архитектурный выбор

Направление TCP-соединения определяет, какие правила нужны в firewall, и потому решается раньше всего остального.

Outbound SSH против inbound WebSocket: кто инициирует соединение

Практическое правило: если агенты в том же 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')

Две вещи, которые обязательно сделать сразу:

  1. Версионировать библиотеку тегами, а не жить на main. @Library('shared@v2.4.0') означает, что коммит в библиотеку не сломает 40 продовых пайплайнов одновременно. main оставьте для канареечных сервисов.
  2. Тестировать библиотеку. Есть JenkinsPipelineUnit — юнит-тесты на Groovy с моками шагов. Библиотека без тестов — это продовый код без тестов, просто вы называете его «скриптом».

Плагины: суперсила и главный технический долг

Почему это вообще проблема. Все плагины грузятся в одну 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 джоб не работает. Работает вот такая последовательность:

  1. Заморозить рост. Новые сервисы — только на новой платформе. Jenkins переходит в режим «только поддержка существующего».
  2. Классифицировать джобы. Обычно 20% джоб дают 80% сборок, а половина каталога не запускалась год. Начните с curl по REST API: /api/json?tree=jobs[name,lastBuild[timestamp]] — и удалите мёртвое.
  3. Перевести типовые пайплайны. Стандартный «собрать-протестировать-запушить образ» переносится механически. Здесь помогает то, что shared library уже унифицировала логику.
  4. Оставить остаток. Экзотика (маки, стенды, air-gap) может остаться на Jenkins навсегда — это нормальный исход. Цель — не «убить Jenkins», а «свести его к тому, что действительно требует Jenkins».
  5. Держать оба 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 раннеры и эксплуатационная нагрузка.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов