В этом уроке разберём, как программировать конвейер по-настоящему: переменные, условия, цикл, функция, обработка ошибок и динамическая генерация стейджей. И главное - честно поговорим, когда jenkins scripted оправдан, а когда это выстрел себе в ногу.
Как устроен jenkins scripted pipeline: node как обёртка
Declarative начинается с pipeline { ... } и жёсткой структуры. Scripted - это голый Groovy-скрипт, который Jenkins исполняет сверху вниз. Корневая конструкция тут не pipeline, а node - блок, который выделяет агента (executor) и рабочую директорию (workspace). Всё, что внутри node, выполняется на этом агенте.
Код: Выделить всё
node('linux') {
stage('Checkout') {
checkout scm
}
stage('Build') {
sh './gradlew clean build'
}
}
Важная деталь про код в jenkins groovy: пайплайн исполняется на контроллере (controller), а реальные команды через sh уходят на агента. Поэтому Groovy-логика (циклы, условия) крутится на контроллере, а sh, git, docker - на агенте. Из этого растёт половина граблей, до которых дойдём.

Переменные, условия и функция в jenkins pipeline
В scripted ты пишешь обычный Groovy. Переменные объявляются через def (или с типом), условия - привычный if/else, есть тернарный оператор.
Код: Выделить всё
node {
def branch = env.BRANCH_NAME ?: 'main'
def isRelease = branch.startsWith('release/')
stage('Resolve config') {
if (isRelease) {
env.DEPLOY_ENV = 'staging'
echo "Релизная ветка, деплоим в staging"
} else if (branch == 'main') {
env.DEPLOY_ENV = 'dev'
} else {
echo "Фича-ветка ${branch}, деплой пропускаем"
env.DEPLOY_ENV = ''
}
}
}
Код: Выделить всё
def buildService(String name) {
stage("Build ${name}") {
sh "docker build -t registry.local/${name}:${env.BUILD_NUMBER} services/${name}"
}
}
node {
checkout scm
buildService('auth')
buildService('billing')
}
Цикл и динамическая генерация стейджей
Это коронный приём scripted, ради которого его чаще всего и берут. У тебя есть список сервисов - и jenkins цикл генерирует по стейджу на каждый, не заставляя писать их руками.
Код: Выделить всё
node('docker') {
def services = ['auth', 'billing', 'gateway', 'notifications']
stage('Checkout') {
checkout scm
}
// обычный for по списку
for (svc in services) {
stage("Test ${svc}") {
sh "docker compose run --rm ${svc} ./run-tests.sh"
}
}
}
Часто хочется гонять сервисы не последовательно, а параллельно. Тогда собираем Map с замыканиями и отдаём его шагу parallel. Здесь есть классическая ловушка с замыканием в цикле, поэтому показываю сразу правильный вариант через .each и .collectEntries:
Код: Выделить всё
node('docker') {
def services = ['auth', 'billing', 'gateway']
stage('Checkout') { checkout scm }
def branches = services.collectEntries { svc ->
["Test ${svc}", {
stage("Test ${svc}") {
sh "docker compose run --rm ${svc} ./run-tests.sh"
}
}]
}
parallel branches
}
Обработка ошибок: try/catch/finally
В declarative для уборки есть секция post. В scripted ты пишешь обработку сам - и это даёт полный контроль. Любой упавший шаг (sh с ненулевым кодом) бросает исключение, которое ловится обычным try/catch.
Код: Выделить всё
node {
try {
stage('Build') {
sh './gradlew build'
}
stage('Deploy') {
sh './deploy.sh staging'
}
currentBuild.result = 'SUCCESS'
} catch (err) {
currentBuild.result = 'FAILURE'
echo "Сборка упала: ${err.getMessage()}"
throw err
} finally {
stage('Cleanup') {
sh 'docker compose down --remove-orphans || true'
junit testResults: '**/build/test-results/**/*.xml', allowEmptyResults: true
}
}
}
Когда scripted оправдан, а когда это переусложнение
Правило 2026 года простое: declarative - выбор по умолчанию, scripted - когда упёрся в его потолок. Бери scripted, если:
- стейджи генерируются динамически из данных (список сервисов, матрица версий, ответ внешнего API);
- нужна сложная логика ветвления, которую when уже не вытягивает;
- ты пишешь переиспользуемый код для shared library.
Если динамика нужна, но Jenkinsfile разбух - переноси логику в shared library. Это отдельный Git-репозиторий с папкой vars/, где каждый файл - твой собственный шаг. Грубый скелет vars/buildAll.groovy:
Код: Выделить всё
// vars/buildAll.groovy в shared library
def call(List services) {
services.each { svc ->
stage("Build ${svc}") {
sh "docker build -t ${svc}:${env.BUILD_NUMBER} services/${svc}"
}
}
}
Пара слов про актуальность. На середину 2026 свежая LTS - линейка 2.555.x, минимальная Java уже 17, а новые сборки тянутся к Java 21. Groovy-движок и шаги node/stage/parallel/sh за годы не менялись, так что код из урока рабочий и на последних версиях. Для сравнения: в GitLab CI и GitHub Actions динамическую матрицу делают декларативно (matrix, parallel: matrix), без полноценного языка - там это плюс к простоте, но когда логика выходит за рамки матрицы, начинается пляска с генерацией YAML. Jenkins scripted в этом смысле честнее: у тебя сразу настоящий язык, плати за это дисциплиной.
Типичные грабли
- Замыкание в for захватывает переменную по ссылке: классический баг, когда все параллельные ветки вдруг тестируют последний сервис из списка. Лечится через .each / .collectEntries, как выше, либо локальной копией def s = svc внутри тела цикла.
- Тяжёлая Groovy-логика на контроллере. Циклы и обработка строк исполняются на контроллере, а не на агенте. Не вычитывай гигабайтные файлы в Groovy - перекладывай это на sh внутри node.
- NotSerializableException: scripted-пайплайн сериализуется между шагами. Объекты вроде матчеров регэкспа или потоков нельзя хранить в переменных через границу sh/stage. Заворачивай такую работу в метод с аннотацией @NonCPS либо считай результат сразу.
- Забытый throw в catch - билд зелёный, хотя всё упало. Всегда пробрасывай исключение, если не глушишь ошибку осознанно.
- readFile/httpRequest вне node. Шаги, которым нужен workspace или executor, обязаны жить внутри node {}, иначе словишь "Required context class hudson.FilePath is missing".
Повтори руками, чтобы улеглось:
- Создай Pipeline job со scripted-скриптом: node, внутри переменная def services = ['a', 'b', 'c'] и for, генерирующий стейдж "Echo X" с sh "echo X" на каждый элемент. Запусти, посмотри стейджи в Stage View.
- Переделай тот же цикл на parallel через collectEntries. Сравни, как стейджи легли по времени.
- Оберни весь node в try/catch/finally. В одном из sh поставь exit 1 и убедись, что finally всё равно отработал, а билд стал красным благодаря throw.
- Вынеси генерацию стейджей в функцию def runStages(list) и вызови её. Прочувствуй, как Jenkinsfile стал короче.
- Чем node в scripted отличается от pipeline в declarative и где физически исполняется Groovy-логика, а где шаги sh?
- Как из списка сервисов сгенерировать стейджи циклом, и почему для параллельного запуска берут collectEntries, а не просто for?
- Зачем в catch нужен throw и что произойдёт с результатом билда, если его забыть?
- По каким признакам ты выберешь scripted вместо declarative, и как shared library помогает не утонуть в сложности?
Scripted pipeline - это Jenkins, когда декларации перестаёт хватать. Ты получаешь настоящий язык: переменные, if/else, цикл, функцию, try/catch/finally и динамическую генерацию стейджей из данных. Цена - дисциплина и читаемость, поэтому держи declarative дефолтом, переключайся на scripted ради реальной динамики, а разросшуюся логику прячь в shared library. Тогда даже тринадцатый микросервис добавляется одной строчкой в список.