Хорошая новость: чтобы быть продуктивным в конвейерах, тебе не нужен Groovy целиком. Нужен небольшой практичный срез плюс понимание одной важной особенности - как Jenkins исполняет этот код. Этим и займёмся, а в конце выйдем на shared libraries, где Groovy раскрывается полностью.
Зачем Groovy в Jenkins и где он живёт
Весь Jenkins Pipeline - это Groovy. Declarative-конвейер, который мы изучали раньше - это аккуратная обёртка-DSL поверх Groovy: ты пишешь pipeline, stages, steps, а движок превращает это в исполняемый Groovy-код. Поэтому groovy jenkins - не два разных мира, а один. Declarative просто прячет язык за фасадом, пока тебе не понадобится логика.
Понадобится она в трёх местах. Первое - блок script внутри declarative-конвейера: островок настоящего Groovy там, где декларативного синтаксиса не хватает. Второе - scripted pipeline, целиком написанный на Groovy без declarative-каркаса. Это более старый и более низкоуровневый стиль (jenkins scripted pipeline), и в 2026 он не стандарт - по умолчанию берём declarative. Но scripted незаменим, когда нужна динамика: генерировать stage'и в цикле, ветвиться сложными условиями, строить map стадий на лету. Третье и самое ценное - shared libraries, общие библиотеки на Groovy, куда выносят повторяющуюся логику для всех команд.
Код: Выделить всё
// Declarative со вставкой настоящего Groovy через script
pipeline {
agent any
stages {
stage('Build') {
steps {
script {
def modules = ['api', 'web', 'worker']
for (m in modules) {
echo "Собираю модуль ${m}"
sh "make build MODULE=${m}"
}
}
}
}
}
}
Код: Выделить всё
node {
stage('Build') {
def modules = ['api', 'web', 'worker']
modules.each { m ->
echo "Собираю модуль ${m}"
sh "make build MODULE=${m}"
}
}
}

Минимум Groovy: переменные, строки, коллекции, условия
Пройдёмся по практичному срезу. Переменные объявляют через def (тип выводится сам) либо указывают тип явно. Точки с запятой не нужны, return в конце метода тоже опционален - возвращается значение последнего выражения.
Код: Выделить всё
def name = 'cyberlake' // тип выведется как String
String env = 'prod' // или явно
int retries = 3
def enabled = true
Код: Выделить всё
def version = '2.555.2'
echo 'Просто строка без подстановки: ${version}' // выведет ${version} как есть
echo "Версия Jenkins LTS: ${version}" // подставит значение
echo "Через год будет минимум: ${version.split('\\.')[0]}.x"
// многострочный shell через тройные двойные кавычки
sh """
echo "Деплою версию ${version}"
./deploy.sh --tag ${version}
"/'"'/""".replace('/'"'/', '')
Коллекции. Список - в квадратных скобках, map - в фигурных с двоеточием. У них куча удобных методов: each, collect, find, findAll. Замыкание (closure) пишется в фигурных скобках, неявный аргумент называется it.
Код: Выделить всё
def services = ['api', 'web', 'worker']
services.each { echo "Сервис: ${it}" } // обход
def upper = services.collect { it.toUpperCase() } // ['API','WEB','WORKER']
def hasApi = services.find { it == 'api' } != null // true
def config = [env: 'prod', replicas: 3, debug: false]
echo "Окружение ${config.env}, реплик ${config['replicas']}"
config.each { k, v -> echo "${k} = ${v}" }
Код: Выделить всё
def branch = env.BRANCH_NAME ?: 'main' // если null, берём 'main'
if (branch == 'main') {
echo 'Деплой в продакшен'
} else if (branch.startsWith('release/')) {
echo 'Релизная ветка'
} else {
echo 'Обычная фича-ветка'
}
def target = (branch == 'main') ? 'prod' : 'staging' // тернарный
Код: Выделить всё
def buildImage(String name, String tag) {
sh "docker build -t ${name}:${tag} ."
return "${name}:${tag}"
}
Тут начинается то, что отличает jenkins script от любого другого Groovy. Конвейер обязан переживать перезапуск контроллера: остановили Jenkins на середине сборки, подняли обратно - сборка продолжается с того же места. Чтобы это работало, движок должен уметь в любой момент сохранить состояние выполнения на диск и потом восстановить. Достигается это через CPS - Continuation-Passing Style: твой Groovy-код переписывается в форму, которую можно сериализовать и приостановить.
Отсюда два следствия, на которые натыкаются все.
Первое: каждая переменная в CPS-коде должна быть сериализуемой. Если положишь в переменную несериализуемый объект (например, результат парсинга через стороннюю библиотеку, java.io.File, matcher регулярки) и он переживёт шаг-паузу вроде sh - получишь java.io.NotSerializableException. Классическая ловушка - matcher:
Код: Выделить всё
// ПЛОХО: matcher несериализуем, упадёт после sh
def m = ('abc123' =~ /\d+/)
echo m[0]
sh 'sleep 1' // здесь состояние сохраняется - и падает на m
// ХОРОШО: достали нужное и сразу забыли про matcher
def num = ('abc123' =~ /\d+/)[0]
sh 'sleep 1'
@NonCPS - мощно и опасно
Для тяжёлых вычислений CPS добавляет накладные расходы, и иногда нужен кусок настоящего, не трансформированного Groovy. Для этого есть аннотация @NonCPS. Метод, помеченный ею, исполняется как обычный Groovy - быстро и со всем синтаксисом. Но цена велика, и осторожность здесь не формальность.
Правила для @NonCPS, которые нельзя нарушать:
- внутри нельзя вызывать pipeline-шаги (sh, echo, sh-подобное) - только чистый Groovy
- нельзя вызывать обычные CPS-методы
- не принимай и не возвращай несериализуемые значения - метод хорош для "посчитал и вернул сводку"
- метод не должен переживать паузу: он отрабатывает целиком и сразу
Код: Выделить всё
// Корректное применение: распарсить и вернуть простой результат
@NonCPS
def parseModules(String json) {
def data = new groovy.json.JsonSlurper().parseText(json)
// вернём обычный список строк - он сериализуем
return data.modules.collect { it.name as String }
}
pipeline {
agent any
stages {
stage('Plan') {
steps {
script {
def names = parseModules('{"modules":[{"name":"api"},{"name":"web"}]}')
names.each { echo "Модуль: ${it}" } // echo тут, СНАРУЖИ @NonCPS
}
}
}
}
}
Типичные грабли
- NotSerializableException после успешного на вид шага - почти всегда несериализуемая переменная (matcher, File, объект библиотеки) пережила sh/паузу. Извлеки примитив и не храни сам объект.
- Путаница кавычек: ${var} в одинарных кавычках не подставляется. Подстановка - только в двойных и тройных двойных.
- Цикл for с ленивыми диапазонами или сложными замыканиями иногда ведёт себя странно под CPS. Если что-то необъяснимо - перепиши на простой each или классический for по индексу.
- @NonCPS с echo внутри: тихо или странно работает, потом ломается. Шаги - только снаружи.
- Сборка списка стадий на лету в declarative невозможна - для динамики бери scripted или генерируй map стадий и запускай через parallel.
Повтори руками, это 15 минут и даёт интуицию:
- Создай scripted Jenkinsfile с node/stage и пройди each по списку из трёх сервисов, на каждом вызвав sh "echo ...".
- Сделай map с конфигом (env, replicas) и выведи его через интерполяцию ${}. Сравни одинарные и двойные кавычки на одной строке - убедись, что подстановка работает только в двойных.
- Воспроизведи NotSerializableException: положи matcher (=~) в переменную, потом вызови sh 'sleep 1' и обратись к matcher после. Затем почини, вытащив значение до sh.
- Напиши @NonCPS-метод, который принимает строку JSON и возвращает список имён. Вызови его из script-блока и выведи результат через echo (echo - снаружи метода).
- Почему declarative pipeline - это тоже Groovy, и в каких трёх местах ты пишешь Groovy-код напрямую?
- Что такое CPS-трансформация и зачем она нужна именно Jenkins?
- Почему появляется NotSerializableException и как её обычно чинят?
- Какие четыре правила надо соблюдать в методе с @NonCPS и почему к нему не стоит тянуться без нужды?
Groovy - язык, на котором стоит весь Jenkins Pipeline, и минимальный его срез нужен даже в declarative: переменные через def, строки с ${}, списки, map, условия и методы. Ключевая особенность - CPS-трансформация: код переписывается ради устойчивости к перезапускам, отсюда требование сериализуемости переменных и ограничения синтаксиса. @NonCPS - острый инструмент для редких случаев, не для повседневности. Этот фундамент - прямой мост к следующей большой теме: shared libraries, где Groovy выносит общую логику конвейеров за пределы одного Jenkinsfile.