DevOps, CI/CD e contêineres
Pipelines do Jenkins e o Jenkinsfile: a sintaxe declarativa a fundo
Intermediário9 min de leituraAtualizado
Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês
Um Jenkinsfile transforma seu build em código versionado e revisado, que vive junto com o projeto. Este guia percorre a sintaxe do pipeline declarativo seção por seção — escolha do agent, ambiente e parâmetros, stages condicionais e paralelos, retries e timeouts, condições de post — e depois aborda shared libraries, a válvula de escape do pipeline scripted e como depurar um pipeline sem vinte commits de tentativa.
O que você vai aprender
- Escrever um pipeline declarativo com os blocos agent, stages, steps, options, environment e post
- Escolher onde cada stage roda, inclusive com agents Docker por stage
- Parametrizar builds e vincular credenciais sem expor segredos no log
- Rodar stages condicionalmente e em paralelo, e aplicar timeouts, retries e limites de concorrência
- Extrair lógica repetida para uma shared library e saber quando o Groovy scripted se justifica
Um pipeline definido na interface do Jenkins é invisível para quem se importa com ele. Ninguém revisa, ninguém consegue comparar com a versão do mês passado, e ele some junto com o controller. Levar essa definição para um Jenkinsfile na raiz do repositório resolve tudo isso de uma vez: o build passa a fazer parte do código, as mudanças passam por revisão junto com o código que afetam, cada branch carrega o pipeline adequado a ela e um controller reconstruído recupera a definição a partir do repositório.
Este guia parte do princípio de que você já tem um controller funcionando, com agents e credenciais — o guia de instalação do Jenkins cobre essa parte — e se concentra na linguagem. O foco é a sintaxe declarativa, porque é ela que você deve escrever; o Groovy scripted aparece no final, que é o lugar dele.
A estrutura de um pipeline declarativo
Todo pipeline declarativo é um bloco pipeline com um pequeno número de seções nomeadas, seguindo uma gramática fixa que o Jenkins valida antes de o build começar.
pipeline {
agent { label 'linux && jdk21' }
options {
timeout(time: 30, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '30'))
disableConcurrentBuilds(abortPrevious: true)
timestamps()
}
environment {
MAVEN_OPTS = '-Xmx1g -Djava.awt.headless=true'
APP_VERSION = "1.4.${env.BUILD_NUMBER}"
}
stages {
stage('Build') {
steps {
sh 'mvn -B -ntp clean compile'
}
}
stage('Test') {
steps {
sh 'mvn -B -ntp verify'
}
}
}
post {
always { junit testResults: '**/target/surefire-reports/*.xml', allowEmptyResults: true }
failure { mail to: '[email protected]', subject: "Build ${env.JOB_NAME} #${env.BUILD_NUMBER} failed", body: env.BUILD_URL }
cleanup { cleanWs() }
}
}
Leia de cima para baixo. agent decide onde o trabalho roda. options configura o próprio job: quanto tempo ele pode levar, quanto histórico manter, se dois builds podem rodar ao mesmo tempo. environment define variáveis visíveis para todos os steps. stages contém a lista ordenada de blocos stage, cada um com seus steps. post roda no final, qualquer que seja o resultado, com sub-blocos escolhidos de acordo com ele.
A gramática é rígida de propósito. As seções precisam aparecer nessa ordem, steps só pode conter steps e um erro de digitação gera um erro de parsing claro antes mesmo de um agent ser alocado. É essa rigidez que mantém os pipelines declarativos legíveis um ano depois.
Escolhendo onde um stage roda
agent aparece no nível superior e, opcionalmente, de novo dentro de stages específicos. As formas mais comuns:
agent any // any agent with a free executor
agent none // allocate nothing at the top level
agent { label 'docker && linux' } // a label expression
agent { node { label 'build'; customWorkspace '/mnt/fast/ws' } }
agent { docker { image 'maven:3.9-eclipse-temurin-21'; args '-v $HOME/.m2:/root/.m2' } }
agent { dockerfile { dir 'ci'; filename 'Dockerfile.build' } }
agent { kubernetes { yamlFile 'ci/pod.yaml' } }
O agent docker é o que muda a experiência com os builds. O stage roda dentro de um contêiner iniciado a partir daquela imagem, em um agent que tenha Docker disponível, então a toolchain vem da imagem e não do que por acaso estiver instalado na máquina. Dois projetos podem usar versões diferentes do JDK sem nenhuma coordenação, e reproduzir localmente uma falha de CI é só questão de rodar a mesma imagem.
agent none no nível superior, com agents por stage, é o padrão para pipelines cujos stages precisam de ambientes diferentes. Ele também evita segurar um executor enquanto se espera uma aprovação:
pipeline {
agent none
stages {
stage('Build') {
agent { docker { image 'maven:3.9-eclipse-temurin-21' } }
steps { sh 'mvn -B -ntp package' }
}
stage('Frontend') {
agent { docker { image 'node:22-alpine' } }
steps { sh 'npm ci && npm run build' }
}
}
}
Atenção: cada agent tem seu próprio workspace. Arquivos gerados em um stage não ficam visíveis no seguinte, a não ser que você os leve adiante com stash/unstash ou os arquive.
Ambiente, parâmetros e credenciais
environment define variáveis no nível do pipeline ou do stage. As entradas do stage sobrescrevem as do pipeline, e ambas são variáveis de ambiente de shell comuns dentro de sh.
O Jenkins injeta um grande conjunto de variáveis próprias: BUILD_NUMBER, BUILD_URL, JOB_NAME, WORKSPACE, BRANCH_NAME e CHANGE_ID em jobs multibranch, além de GIT_COMMIT e GIT_BRANCH, fornecidas pelo plugin do Git. Referencie-as como env.BUILD_NUMBER em Groovy e como $BUILD_NUMBER dentro de um step de shell.
Parâmetros tornam um job interativo. Declare-os no pipeline; na primeira execução o Jenkins os registra e o job ganha um botão “Build with Parameters”.
parameters {
choice(name: 'TARGET_ENV', choices: ['dev', 'staging', 'prod'], description: 'Deployment target')
booleanParam(name: 'RUN_INTEGRATION', defaultValue: true, description: 'Run the slow suite')
string(name: 'RELEASE_VERSION', defaultValue: '', description: 'Leave blank for a snapshot build')
}
Leia-os com params.TARGET_ENV. Repare no problema do ovo e da galinha: o primeiro build depois de adicionar um parâmetro roda com os valores padrão, porque o Jenkins só descobre a definição ao executar o arquivo.
Credenciais vêm do cofre de credenciais, nunca do arquivo. Existem dois mecanismos. O helper credentials() em um bloco environment cobre os tipos mais comuns:
environment {
// secret text -> one variable
SONAR_TOKEN = credentials('sonar-token')
// username/password -> NEXUS, NEXUS_USR and NEXUS_PSW
NEXUS = credentials('nexus-deploy')
}
withCredentials é a forma geral e é obrigatória para chaves SSH, arquivos e certificados:
steps {
withCredentials([
sshUserPrivateKey(credentialsId: 'deploy-key', keyFileVariable: 'SSH_KEY'),
file(credentialsId: 'kubeconfig-staging', variable: 'KUBECONFIG')
]) {
sh 'ssh -i "$SSH_KEY" deploy@app-01 /opt/deploy.sh'
sh 'kubectl get pods -n staging'
}
}
Coloque as variáveis entre aspas no shell, mantenha o bloco o menor possível e lembre-se de que o mascaramento só esconde correspondências exatas. Um segredo transformado por um script — codificado em base64 ou para URL, quebrado em várias linhas — vai aparecer no log sem máscara.
Stages condicionais com when
when decide se os steps de um stage chegam a rodar. Por padrão, ele é avaliado depois da entrada no agent, o que faz diferença para condições que inspecionam o workspace.
stage('Deploy to staging') {
when {
allOf {
branch 'main'
not { changeRequest() }
expression { currentBuild.result == null }
}
}
steps { sh './deploy.sh staging' }
}
stage('Publish release') {
when {
beforeAgent true
tag pattern: 'v\\d+\\.\\d+\\.\\d+', comparator: 'REGEXP'
}
steps { sh 'mvn -B -ntp deploy -DskipTests' }
}
Entre as condições úteis estão branch, tag, changeRequest(), environment name: 'X', value: 'y', changeset '**/*.sql' para disparar conforme os caminhos alterados e expression { } para Groovy arbitrário. Combine-as com allOf, anyOf e not. Adicione beforeAgent true sempre que a condição não precisar de um workspace: isso evita alocar um executor para um stage que vai ser pulado, o que, em um controller sobrecarregado, é uma economia significativa.
Para uma aprovação humana, input pausa o build:
stage('Approve production') {
steps {
timeout(time: 2, unit: 'HOURS') {
input message: 'Deploy to production?', ok: 'Deploy', submitter: 'release-managers'
}
}
}
Coloque input em um stage com agent none, senão o build segura um executor durante toda a espera. O guia sobre pipelines de entrega discute quando uma aprovação manual é realmente útil e quando é puro teatro.
Rodando trabalho em paralelo
Pipelines longos costumam ser longos porque trabalho independente roda em sequência. parallel resolve isso.
stage('Verification') {
parallel {
stage('Unit tests') {
agent { label 'linux' }
steps { sh 'mvn -B -ntp test' }
}
stage('Integration tests') {
agent { label 'linux && docker' }
steps { sh 'mvn -B -ntp verify -Pintegration' }
}
stage('Static analysis') {
agent { label 'linux' }
steps { sh 'mvn -B -ntp checkstyle:check spotbugs:check' }
}
}
}
Cada ramo pode ter seu próprio agent, então os três rodam em máquinas diferentes. failFast true dentro do bloco parallel aborta os ramos irmãos assim que um deles falha, o que economiza tempo de executor quando a falha é decisiva.
matrix expande uma única definição de stage em todas as combinações de valores de vários eixos — o encaixe natural para testar várias versões de JDK ou de banco de dados:
stage('Compatibility') {
matrix {
axes {
axis { name 'JDK'; values '17', '21' }
axis { name 'DB'; values 'postgres', 'mysql' }
}
excludes {
exclude { axis { name 'JDK'; values '17' }; axis { name 'DB'; values 'mysql' } }
}
agent { label 'linux' }
stages {
stage('Test') { steps { sh "./ci/test.sh --jdk ${JDK} --db ${DB}" } }
}
}
}
Quatro combinações menos uma exclusão dão três células, que rodam em paralelo conforme os executors permitirem.
Robustez: options, retry e timeout
Builds falham por motivos que não têm nada a ver com o código, e um pipeline que trata toda falha transitória como defeito ensina as pessoas a ignorar o vermelho.
options {
timeout(time: 45, unit: 'MINUTES') // whole pipeline
retry(2) // retry the whole pipeline on failure
skipStagesAfterUnstable()
disableConcurrentBuilds(abortPrevious: true) // newer push cancels the older run
quietPeriod(15)
preserveStashes(buildCount: 5)
}
No nível do step, retry e timeout envolvem ações individuais, o que é muito mais preciso do que repetir um build inteiro:
steps {
timeout(time: 5, unit: 'MINUTES') {
retry(3) { sh 'curl -fsSL https://artifacts.example.com/toolchain.tgz -o toolchain.tgz' }
}
}
Use retries apenas para operações de rede e de infraestrutura. Repetir um teste instável esconde a instabilidade em vez de corrigi-la, e uma suíte com instabilidade escondida acaba falhando de um jeito que nenhum retry consegue salvar.
O bloco post
post é o lugar do reporting e da limpeza, porque ele roda independentemente de o build ter passado, falhado ou sido abortado. As condições rodam em uma ordem definida: always, depois changed, depois fixed, regression, aborted, failure, success, unstable e, por fim, cleanup.
post {
always { archiveArtifacts artifacts: 'target/*.jar', fingerprint: true, allowEmptyArchive: true }
unstable { slackSend channel: '#ci', color: 'warning', message: "Tests failing on ${env.JOB_NAME}" }
fixed { slackSend channel: '#ci', color: 'good', message: "Back to green: ${env.JOB_NAME}" }
regression{ slackSend channel: '#ci', color: 'danger', message: "Broken by ${env.GIT_COMMIT}" }
cleanup { cleanWs() }
}
fixed e regression só disparam quando o estado muda, e essa é a diferença entre um canal de notificações que as pessoas leem e um que elas silenciam. cleanup sempre roda por último e é o lugar certo para cleanWs(); apagar o workspace em always pode concorrer com steps que ainda precisam dele.
Blocos post também podem ser anexados a um stage específico, e aí valem só para aquele stage — útil para publicar os relatórios de um stage ao lado do código que os gerou.
Shared libraries
Quando três repositórios tiverem as mesmas quarenta linhas de pipeline, extraia essas linhas. Uma shared library é um repositório Git com uma estrutura fixa:
(root)
├── vars/
│ ├── buildJavaService.groovy # a global step: buildJavaService(...)
│ └── buildJavaService.txt # help text shown in the snippet generator
├── src/
│ └── com/example/ci/Versioning.groovy
└── resources/
└── com/example/ci/pod-template.yaml
Os arquivos em vars/ viram steps que podem ser chamados pelo nome; as classes em src/ são Groovy comum que você pode testar unitariamente; resources/ guarda arquivos carregados com libraryResource. Registre a biblioteca uma única vez em Manage Jenkins → System → Global Pipeline Libraries e depois use-a:
@Library('ci-lib@v3') _
standardJavaPipeline(
jdk: '21',
sonarProject: 'shop-api',
deployTo: ['staging']
)
Um arquivo em vars/ que define def call(Map config) pode encapsular um pipeline inteiro, reduzindo o Jenkinsfile de cada projeto a um punhado de linhas declarativas. Versione a biblioteca com tags e referencie uma tag em vez de @main em tudo o que for importante — caso contrário, uma mudança na biblioteca altera silenciosamente, e de uma só vez, todos os builds da organização.
Pipelines scripted e o bloco script
A sintaxe scripted veio primeiro: um programa Groovy com node, stage e controle imperativo total.
node('linux') {
stage('Checkout') { checkout scm }
stage('Build') {
def modules = sh(script: 'ls -d modules/*', returnStdout: true).trim().split('\n')
for (m in modules) {
sh "mvn -B -ntp -f ${m}/pom.xml package"
}
}
}
Ela é mais flexível e muito mais fácil de escrever mal. Não há validação antes de o build começar, a visão de stages se degrada e o arquivo vai virando um programa que só o autor entende. Reserve-a para pipelines cuja estrutura realmente não pode ser conhecida de antemão.
O meio-termo pragmático é um bloco script dentro de um stage declarativo, que permite Groovy arbitrário exatamente onde você precisa:
stage('Version') {
steps {
script {
def pom = readMavenPom file: 'pom.xml'
env.RELEASE_VERSION = pom.version.replace('-SNAPSHOT', '')
currentBuild.displayName = "#${env.BUILD_NUMBER} ${env.RELEASE_VERSION}"
}
}
}
Mantenha esses blocos em poucas linhas. Quando um deles passar de uma tela, o lugar dele é uma shared library.
Depurando sem vinte commits
Três técnicas economizam um tempo enorme.
Replay. Abra um build concluído, clique em Replay, edite o texto do pipeline e rode de novo. O script editado é usado sem mexer no repositório. Quando funcionar, faça o commit da mudança.
O linter. Valide a sintaxe antes de dar push:
curl -s -X POST -F "jenkinsfile=<Jenkinsfile" \
https://jenkins.example.com/pipeline-model-converter/validate
Adicione-o como hook de pre-commit ou como verificação de CI e some toda a categoria de “erro de digitação descoberto depois de o build ficar oito minutos na fila”.
O snippet generator. Toda instância do Jenkins expõe /pipeline-syntax/, que monta a partir de um formulário uma chamada de step bem formada, usando os plugins de fato instalados naquele controller. É o jeito mais rápido de descobrir os nomes dos parâmetros de um step que você não conhece.
Além disso, mantenha os pipelines curtos. Se o seu Jenkinsfile tem 400 linhas, a lógica deveria estar em um script de shell ou em uma biblioteca que você consiga rodar fora do Jenkins — o que também permite que qualquer desenvolvedor reproduza o build no próprio notebook.
Principais pontos
- A sintaxe declarativa é validada logo de início, aparece bem na interface e é fácil de ler; use-a por padrão e recorra a
scriptsó nas linhas que precisarem. agentpode ser definido por stage, e um agent Docker pega a toolchain de uma imagem, o que torna os builds reproduzíveis e permite que os projetos sejam diferentes sem coordenar os pacotes instalados nos agents.- Parâmetros,
environmente as variáveis injetadas pelo Jenkins configuram um build; as credenciais sempre vêm do cofre de credenciais, viacredentials()ouwithCredentials, em strings de shell com aspas simples. whencombeforeAgent truepula trabalho a baixo custo;parallelematrixreduzem o tempo total distribuindo stages independentes entre agents.options, além detimeouteretryno nível do step, deixam os pipelines resistentes ao ruído da infraestrutura sem esconder falhas reais de teste.postcuida do reporting e da limpeza em qualquer resultado, efixed/regressiongeram notificações que as pessoas realmente leem.- Extraia a repetição para uma shared library versionada, fixe a versão por tag e teste o código de
src/como qualquer outro Groovy. - Use Replay, o linter declarativo e
/pipeline-syntax/em vez de subir commits especulativos para uma branch.
Fontes e leituras complementares
Perguntas frequentes
- Declarativo ou scripted: qual devo escrever?
- Declarativo, a menos que você tenha um motivo específico para não usar. Ele é validado antes de o build começar, dá mensagens de erro claras, aparece corretamente no Blue Ocean e na visão de stages e oferece as diretivas options, when e post, que nos pipelines scripted precisam ser implementadas na mão. Quando esbarrar em um limite real, use um bloco script só para essas poucas linhas em vez de reescrever o arquivo inteiro em scripted.
- Por que meu pipeline falha com um erro de serialização?
- O código do pipeline roda no controller e recebe um checkpoint depois de cada step, para que o build sobreviva a um reinício. Por isso as variáveis locais precisam ser serializáveis, e objetos não serializáveis, como o resultado de um JsonSlurper ou um matcher, não podem continuar no escopo de um step para o outro. Coloque essa lógica em um método anotado com @NonCPS ou atribua null à variável antes de o próximo step rodar.
- Como impedir que um stage derrube o build inteiro?
- Envolva-o em catchError com um resultado de build, por exemplo catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE'). O stage aparece como falho na visualização, o build como um todo fica instável em vez de falho e os stages seguintes continuam rodando. Use com moderação: um stage que pode falhar toda vez é um stage que ninguém lê.
- Onde deve ficar a lógica Groovy mais pesada?
- Em uma shared library, não no Jenkinsfile. O código em src/ é Groovy compilado que você pode testar com JUnit e um harness de testes de pipeline, enquanto cem linhas de lógica dentro de um bloco script não têm como ser testadas e acabam duplicadas entre repositórios. Mantenha o Jenkinsfile declarativo e legível: ele deve descrever o que o build faz, não implementá-lo.