Почему пайплайн вообще ломается: CPS под капотом
Сначала механика, без неё лечить бесполезно. Declarative и scripted pipeline исполняются не как обычный Groovy-скрипт. Jenkins переписывает твой код в стиле CPS (Continuation Passing Style) - продолжение-передающий стиль. Зачем? Чтобы пайплайн умел пережить рестарт контроллера. Сборка может идти часами, ждать на input, висеть на агенте - и если контроллер перезапустят, выполнение должно продолжиться с того же шага. Для этого движок после каждого шага сохраняет на диск всё состояние программы: значения переменных, стек вызовов, позицию.
А чтобы состояние можно было сохранить на диск, всё, что лежит в переменных между шагами, обязано быть сериализуемым - то есть реализовывать java.io.Serializable. Строки, числа, списки, мапы - сериализуются нормально. А вот объекты вроде java.util.regex.Matcher, потоки ввода-вывода, JsonSlurper-результаты в некоторых версиях, ссылки на Jenkins.instance - нет. И когда такой объект остаётся живым через границу шага, ты получаешь jenkins ошибку сериализации.

NotSerializableException: jenkins ошибка сериализации и как её лечить
Классический пример, который ломается. Распарсили строку регуляркой, а Matcher оставили в переменной до следующего шага:
Код: Выделить всё
// ПЛОХО: Matcher несериализуем и переживает границу шага
node {
def m = (env.BRANCH_NAME =~ /release-(\d+)/)
if (m) {
echo "Release: ${m.group(1)}"
}
sh 'sleep 1' // тут движок пытается сохранить состояние и падает
echo "after"
}
Код: Выделить всё
node {
def ver = null
def m = (env.BRANCH_NAME =~ /release-(\d+)/)
if (m) { ver = m.group(1) }
m = null // ключевая строчка: убрали Matcher
sh 'sleep 1'
echo "Release: ${ver}"
}
Код: Выделить всё
@NonCPS
def parseVersion(String branch) {
def m = (branch =~ /release-(\d+)/)
return m ? m[0][1] : 'snapshot' // Matcher живёт и умирает внутри метода
}
pipeline {
agent any
stages {
stage('Detect') {
steps {
script {
env.VERSION = parseVersion(env.BRANCH_NAME)
echo "Version: ${env.VERSION}"
}
}
}
}
}
Третье правило - не злоупотребляй. Если обмазать @NonCPS половину Jenkinsfile, потеряешь главную фишку - устойчивость к рестарту, потому что non-CPS-куски не чекпойнтятся. Держи pipeline-логику простой, а сложную обработку выноси в shared library с @NonCPS.
Script approval и RejectedAccessException: неутверждённый код
Второй большой класс - jenkins script approval. Пайплайны по умолчанию крутятся в Groovy Sandbox: песочнице, которая блокирует потенциально опасные вызовы, чтобы автор Jenkinsfile не смог через CI получить контроль над контроллером. Когда твой код зовёт метод, которого нет в белом списке песочницы, прилетает RejectedAccessException, и сборка падает с текстом вида "Scripts not permitted to use method ...".
Типичный пример - кто-то полез напрямую в API Jenkins:
Код: Выделить всё
// Вызовет RejectedAccessException в песочнице
script {
def n = Jenkins.instance.getItemByFullName('deploy').lastBuild.number
echo "Last build: ${n}"
}
Но в 2026 году дёргать приватный API через рефлексию - это запах. Почти всегда есть штатный шаг. Номер последней сборки берётся через currentBuild и шаг build, а не через Jenkins.instance:
Код: Выделить всё
script {
def b = build job: 'deploy', wait: true
echo "Triggered build #${b.number}"
}
MissingMethodException и "шаг вернул не то"
Ещё две частые jenkins exception, которые путают новичков.
MissingMethodException обычно означает CPS method mismatch: ты передал замыкание (closure) в метод вроде .each, .collect, .findAll внутри CPS-кода, и движок не смог его правильно трансформировать. Симптом - ошибка про отсутствующий метод там, где метод очевидно есть. Лекарство простое: в CPS-коде вместо .each используй обычный for, либо вынеси итерацию с замыканием в @NonCPS-метод:
Код: Выделить всё
// Может дать CPS-несовместимость с замыканием
// servers.each { deploy(it) }
// Надёжно в CPS:
for (s in servers) {
deploy(s)
}
Код: Выделить всё
script {
// returnStdout - забрать вывод; trim() обязателен, иначе хвостовой \n
def commit = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
// returnStatus - не падать на ненулевом коде, а получить число
def changed = sh(script: 'git diff --quiet', returnStatus: true)
echo "commit=${commit} changed=${changed}"
}
Когда пайплайн обязан что-то доделать даже после ошибки (собрать артефакты, отправить отчёт), нужна аккуратная обработка jenkins exception. Два инструмента.
Шаг catchError - декларативный способ поймать падение и при этом задать, каким станет статус сборки и статус стейджа. Очень удобно, когда хочешь пометить стейдж как FAILURE/UNSTABLE, но не ронять весь джоб:
Код: Выделить всё
stage('Tests') {
steps {
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
sh './run-flaky-tests.sh'
}
echo 'Этот шаг выполнится даже если тесты упали'
}
}
Обычный try/catch/finally в блоке script нужен, когда логика реакции сложнее, чем "пометить и продолжить" - например, разное поведение на разные типы ошибок плюс гарантированная очистка:
Код: Выделить всё
script {
try {
sh './deploy.sh'
} catch (org.jenkinsci.plugins.scriptsecurity.sandbox.RejectedAccessException e) {
error "Неутверждённый код, одобри метод в Script Approval: ${e.message}"
} catch (e) {
echo "Деплой упал: ${e}"
currentBuild.result = 'FAILURE'
throw e // пробрасываем, иначе сборка останется зелёной
} finally {
sh 'kill $(cat app.pid) || true' // чистим в любом случае
}
}
И про jenkins error 503. Если шаг httpRequest или curl к внешнему сервису возвращает 503 Service Unavailable - это не баг пайплайна, а недоступность зависимости. Не лови это в catchError как "всё нормально". Лучше добавь retry с паузой - времянку переживёт, а постоянную проблему честно покажет:
Код: Выделить всё
retry(3) {
sleep time: 10, unit: 'SECONDS'
sh 'curl -fsS https://api.internal/health' // -f роняет шаг на 5xx
}
Shared library добавляет свой слой граблей: тот же CPS, та же песочница, плюс кэширование версии библиотеки. Что помогает. Подключай конкретную ветку или тег через @Library('mylib@feature-x'), а не плавающий default - иначе ловишь старый закэшированный код и не понимаешь, почему правка не приехала. Тяжёлую несериализуемую логику в src/ помечай @NonCPS. И помни: vars/*.groovy исполняются в CPS, а классы в src/ - тоже под CPS, если их зовут из пайплайна, так что Matcher и потоки прячь в @NonCPS-методы.
Чек-лист, когда конвейер упал с непонятным jenkins exception:
- Прочитай ПЕРВУЮ строку стектрейса, а не последнюю - там тип и причина.
- NotSerializableException - найди, какой объект назван в сообщении, и убери его из переменной через границу шага (обнули или заверни в @NonCPS).
- RejectedAccessException / "Scripts not permitted" - иди в Manage Jenkins -> In-process Script Approval; но сперва подумай, нет ли штатного шага вместо API.
- MissingMethodException - подозревай замыкание в CPS; перепиши .each на for или вынеси в @NonCPS.
- Шаг "вернул не то" - проверь returnStdout/returnStatus и trim() у sh.
- Замолчавший провал - проверь, не съел ли try/catch исключение без проброса.
- Сравни с прошлой зелёной сборкой: версия Jenkins, плагинов, ветка shared library - менялось ли что-то вне Jenkinsfile.
Руками, на тестовом джобе:
- Воспроизведи NotSerializableException: оставь Matcher в переменной перед sh. Поймай ошибку, потом вылечи через @NonCPS-метод.
- Спровоцируй RejectedAccessException вызовом Jenkins.instance в песочнице. Найди запись в In-process Script Approval, одобри. Затем перепиши на штатный шаг build.
- Сделай стейдж с заведомо падающим скриптом и оберни его в catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE'). Убедись, что джоб стал жёлтым, а не красным.
- Напиши try/catch с finally, который чистит временный файл, и проверь, что без throw сборка остаётся зелёной даже при ошибке.
- Почему переменные между шагами пайплайна обязаны быть сериализуемыми и при чём тут CPS?
- Что делает @NonCPS и почему внутри такого метода нельзя вызывать sh или echo?
- Чем RejectedAccessException отличается от NotSerializableException по причине и по способу лечения?
- В чём разница между catchError и try/catch, и как нечаянно "спрятать" провал сборки?
Большинство загадочных падений Jenkins - это не магия, а два механизма движка: CPS-сериализация и песочница со script approval. Запомни три опоры. Не держи несериализуемые объекты через границу шага, а чистые вычисления выноси в @NonCPS. Неутверждённый код одобряй через In-process Script Approval, но сначала ищи штатный шаг вместо рефлексии в API. Исключения лови осознанно: catchError - чтобы пометить статус, try/catch с throw - чтобы не проглотить провал. С этим чек-листом разбор любого jenkins error превращается из гадания в пять минут по пунктам.