Published on

Jenkins Declarative Pipeline에서 env가 비는 이유

Authors

서로 다른 팀이 작성한 Jenkinsfile을 합치거나, Scripted에서 Declarative로 마이그레이션할 때 자주 터지는 증상이 있습니다. 분명히 환경변수를 설정했는데 env가 비어 있거나, 기대한 값이 아니라 null 또는 빈 문자열로 찍히는 문제입니다. 특히 environment 블록에 변수를 넣었는데도 echo env.MY_VAR가 비거나, sh에서는 보이는데 Groovy에서는 안 보이는 식으로 관측이 갈립니다.

이 글에서는 Jenkins Declarative Pipeline에서 env가 비는(비어 보이는) 주요 원인을 평가 시점, 스코프, 에이전트/노드 컨텍스트, withEnv 및 credentials 패턴 관점에서 정리하고, 재현 가능한 예제와 함께 안전한 해결 패턴을 제시합니다.

관련해서 에이전트 자체가 불안정하면 환경변수 문제처럼 보이는 케이스도 있으니, 빌드가 멈추거나 노드가 오프라인 증상이 있다면 먼저 아래 글도 함께 확인하는 것을 권합니다.

env가 비어 보이는 대표 증상

다음과 같은 형태가 흔합니다.

  • environment { FOO = "bar" }를 넣었는데 script { echo env.FOO }가 빈 값
  • sh 'echo $FOO'는 출력되는데 echo env.FOO는 비어 있음
  • env.BRANCH_NAME, env.CHANGE_ID 같은 Jenkins 기본 변수가 특정 잡에서만 비어 있음
  • env에 값을 대입했는데 다음 스테이지에서 사라짐
  • when { expression { env.FOO == 'x' } }에서 항상 false

이런 현상은 대부분 “정말 env가 비었다”기보다, 어느 시점에 어떤 컨텍스트에서 env를 읽느냐의 문제입니다.

원인 1: Declarative의 평가 시점 차이 (environment, when, options)

Declarative Pipeline은 내부적으로 모델을 만들고, 일부 블록은 실행 전에 평가됩니다. 특히 when 조건식이나 environment에서 다른 값을 참조하는 방식은 생각보다 제약이 많습니다.

잘못된 예: environment에서 동적으로 계산하려고 함

아래처럼 environment에 Groovy 표현식을 넣으면 기대한 대로 동작하지 않거나, 빈 값으로 떨어질 수 있습니다.

pipeline {
  agent any
  environment {
    // 의도: 브랜치에 따라 값을 바꾸고 싶음
    TARGET = "${env.BRANCH_NAME}" 
  }
  stages {
    stage('debug') {
      steps {
        echo "BRANCH_NAME=${env.BRANCH_NAME}"
        echo "TARGET=${env.TARGET}"
      }
    }
  }
}

BRANCH_NAME는 멀티브랜치에서 채워지지만, environment 평가 시점에 아직 값이 준비되지 않았거나, Declarative가 문자열 그대로 처리하는 방식 때문에 TARGET이 빈 값으로 보일 수 있습니다.

해결 패턴: script 블록에서 env를 세팅

런타임에 확실하게 세팅하려면 script에서 env에 대입하는 방식이 안전합니다.

pipeline {
  agent any
  stages {
    stage('init') {
      steps {
        script {
          env.TARGET = env.BRANCH_NAME ?: 'local'
        }
        echo "TARGET=${env.TARGET}"
      }
    }
  }
}

핵심은 environment는 “정적인 선언”에 가깝게 쓰고, 동적 계산은 script에서 하라는 것입니다.

원인 2: env는 Groovy 변수 스코프가 아니라 Jenkins 전역 맵

env는 Groovy의 일반 변수처럼 동작하지 않습니다. Jenkins가 제공하는 전역 변수 맵에 가깝고, 문자열 기반으로 동작합니다.

흔한 실수: def로 만든 변수와 env를 혼동

pipeline {
  agent any
  stages {
    stage('set') {
      steps {
        script {
          def FOO = 'bar'
          // 이 값은 다음 stage로 전달되지 않음
        }
      }
    }
    stage('use') {
      steps {
        script {
          // 여기서 FOO는 존재하지 않음
          echo "FOO=${FOO}"
        }
      }
    }
  }
}

위는 FOOenv가 아니라 단순 지역 변수이기 때문에 다음 스테이지에서 사라집니다.

해결: 다음 스테이지까지 유지하려면 env에 넣기

pipeline {
  agent any
  stages {
    stage('set') {
      steps {
        script {
          env.FOO = 'bar'
        }
      }
    }
    stage('use') {
      steps {
        echo "FOO=${env.FOO}"
      }
    }
  }
}

단, env는 문자열로 취급되므로 리스트/맵 같은 구조를 넣으면 직렬화 문제나 문자열 변환 문제가 생깁니다. 구조 데이터는 파일(JSON)로 남기거나 stash/unstash를 쓰는 편이 안전합니다.

원인 3: agent none 또는 서로 다른 노드에서 실행되며 환경이 끊김

Declarative에서 agent none을 쓰고 스테이지별로 다른 에이전트를 할당하면, “환경변수”라고 믿었던 값이 실제로는 해당 노드의 프로세스 환경에만 존재했던 경우가 많습니다.

특히 다음 케이스가 위험합니다.

  • 한 스테이지에서 sh 'export FOO=bar'를 실행하고 다음 스테이지에서 $FOO를 기대
  • 컨테이너 기반 에이전트에서 컨테이너가 바뀌거나 재생성됨

재현 예: export는 그 쉘 프로세스에서만 유효

pipeline {
  agent any
  stages {
    stage('export') {
      steps {
        sh 'export FOO=bar; echo "in shell FOO=$FOO"'
      }
    }
    stage('next') {
      steps {
        sh 'echo "next stage FOO=$FOO"'
      }
    }
  }
}

첫 스테이지에서는 bar가 찍히지만, 다음 스테이지에서는 빈 값입니다. export는 Jenkins 전체가 아니라 해당 sh 한 번의 프로세스에만 적용되기 때문입니다.

해결: Jenkins env로 올리거나 withEnv 사용

pipeline {
  agent any
  stages {
    stage('init') {
      steps {
        script {
          env.FOO = 'bar'
        }
      }
    }
    stage('use') {
      steps {
        sh 'echo "FOO=$FOO"'
        echo "FOO=${env.FOO}"
      }
    }
  }
}

또는 특정 블록에서만 쓸 거면 withEnv가 더 명확합니다.

pipeline {
  agent any
  stages {
    stage('scoped') {
      steps {
        withEnv(['FOO=bar']) {
          sh 'echo "FOO=$FOO"'
        }
        // 블록 밖에서는 비어 있음
        sh 'echo "FOO outside=$FOO"'
      }
    }
  }
}

원인 4: credentials 바인딩을 env처럼 다루다가 누락

Declarative의 environment에서 credentials()를 쓰면 편하지만, 범위와 타입에 따라 기대와 다르게 비어 보일 수 있습니다.

예: usernamePassword는 두 개의 env로 풀림

pipeline {
  agent any
  environment {
    // Jenkins Credentials에 등록된 ID
    GIT_CREDS = credentials('git-username-password')
  }
  stages {
    stage('debug') {
      steps {
        // usernamePassword면 보통 아래 두 변수가 생성됨
        sh 'echo "USER=$GIT_CREDS_USR"'
        sh 'echo "PASS length=${#GIT_CREDS_PSW}"'
      }
    }
  }
}

여기서 GIT_CREDS 자체를 echo "${env.GIT_CREDS}"로 찍으면 빈 값처럼 보이거나, 마스킹 처리로 출력이 가려져 “비어 있다”고 오해할 수 있습니다.

권장: withCredentials로 범위를 좁히고 명시적으로 이름 지정

pipeline {
  agent any
  stages {
    stage('clone') {
      steps {
        withCredentials([
          usernamePassword(
            credentialsId: 'git-username-password',
            usernameVariable: 'GIT_USER',
            passwordVariable: 'GIT_PASS'
          )
        ]) {
          sh 'git -c http.extraheader="AUTHORIZATION: basic" status || true'
          sh 'echo "GIT_USER=$GIT_USER"'
          sh 'echo "GIT_PASS is set"'
        }
      }
    }
  }
}

이 방식은 “어느 구간에서 env가 채워지는지”가 명확해지고, 마스킹 정책도 예측 가능해집니다.

원인 5: 멀티브랜치/PR 관련 기본 env가 특정 조건에서만 채워짐

BRANCH_NAME, CHANGE_ID, CHANGE_TARGET 같은 값은 멀티브랜치 파이프라인에서 주로 채워집니다. 반면 단일 Pipeline Job, 또는 특정 SCM 설정에서는 비어 있을 수 있습니다.

방어적 코딩 패턴

pipeline {
  agent any
  stages {
    stage('detect') {
      steps {
        script {
          def branch = env.BRANCH_NAME ?: (env.GIT_BRANCH ?: 'unknown')
          def isPR = (env.CHANGE_ID?.trim())
          echo "branch=${branch}"
          echo "isPR=${isPR ? 'true' : 'false'}"
        }
      }
    }
  }
}

여기서도 포인트는 “있을 거라고 가정하지 말고, 잡 유형에 따라 달라짐”을 전제로 처리하는 것입니다.

원인 6: when expression에서 env를 너무 일찍 읽음

when은 스테이지 실행 여부를 결정하므로, 해당 시점에 값이 준비되지 않으면 항상 false가 될 수 있습니다. 특히 이전 스테이지에서 env를 세팅하고 다음 스테이지 when에서 참조하는 패턴은 주의가 필요합니다.

문제 예

pipeline {
  agent any
  stages {
    stage('init') {
      steps {
        script {
          env.RUN_DEPLOY = 'true'
        }
      }
    }
    stage('deploy') {
      when {
        expression { env.RUN_DEPLOY == 'true' }
      }
      steps {
        echo 'deploying...'
      }
    }
  }
}

이게 항상 실패하는 건 아니지만, Declarative 모델/재시작/병렬/옵션 조합에 따라 “init에서 세팅한 값을 when이 못 보는” 것처럼 느껴지는 케이스가 생깁니다.

해결: parameters 또는 when beforeAgent, 혹은 stage 내 조건 분기

가장 확실한 방법은 parameters로 올려서 Declarative 흐름에서 일관되게 쓰는 것입니다.

pipeline {
  agent any
  parameters {
    booleanParam(name: 'RUN_DEPLOY', defaultValue: false)
  }
  stages {
    stage('deploy') {
      when {
        expression { params.RUN_DEPLOY }
      }
      steps {
        echo 'deploying...'
      }
    }
  }
}

또는 when 대신 스테이지 내부에서 분기하는 방법도 있습니다.

stage('deploy') {
  steps {
    script {
      if (env.RUN_DEPLOY != 'true') {
        echo 'skip deploy'
        return
      }
    }
    echo 'deploying...'
  }
}

디버깅 체크리스트: env가 “진짜로” 비었는지 확인

아래는 현장에서 가장 빨리 원인을 좁히는 순서입니다.

1) Groovy에서 보는 env와 쉘에서 보는 env를 분리해서 출력

stage('debug-env') {
  steps {
    script {
      echo "Groovy env.FOO=${env.FOO}"
      echo "Groovy keys count=${env.getEnvironment().keySet().size()}"
    }
    sh 'echo "Shell FOO=$FOO"'
    sh 'env | sort | sed -n "1,40p"'
  }
}
  • Groovy에서 비고 쉘에서만 보이면 withEnv 범위/쉘 export 문제일 가능성이 큽니다.
  • 둘 다 비면 실제로 세팅이 안 되었거나, 잡 유형/플러그인 문제일 수 있습니다.

2) 스테이지마다 에이전트가 바뀌는지 확인

  • agent any인지, agent none인지
  • stage별 agent { label '...' } 또는 agent { docker ... } 사용 여부

노드가 바뀌면 워크스페이스뿐 아니라 “직전 쉘의 export” 같은 것은 당연히 사라집니다.

3) credentials 마스킹 때문에 비어 보이는지 확인

비밀번호/토큰은 로그에서 마스킹됩니다. echo로 찍으면 빈 값처럼 보일 수 있습니다. 대신 길이 체크 같은 방식으로 확인하세요.

withCredentials([string(credentialsId: 'my-token', variable: 'TOKEN')]) {
  sh 'test -n "$TOKEN" && echo "TOKEN is set" || (echo "TOKEN missing"; exit 1)'
}

안전한 작성 패턴 요약

  • 정적 값은 environment에, 동적 계산은 script { env.X = ... }
  • sh 'export ...'에 의존하지 말고 Jenkins env 또는 withEnv 사용
  • credentials는 withCredentials로 범위를 좁히고 변수명을 명시
  • when에서 런타임에 세팅되는 env를 기대하지 말고, params 중심으로 설계
  • 멀티브랜치 전용 env는 잡 유형에 따라 비어 있을 수 있으니 방어적으로 처리

마무리

Declarative Pipeline에서 env가 비는 문제는 대개 Jenkins가 “환경변수”를 다루는 방식이 OS 쉘과 다르고, Declarative의 평가 시점이 직관과 다르기 때문에 생깁니다. 위의 재현 코드로 현재 파이프라인이 어느 케이스에 해당하는지 먼저 분류하고, script에서의 명시적 env 세팅, withEnv/withCredentials 스코프 관리, params 기반 분기 같은 패턴으로 정리하면 대부분 깔끔하게 해결됩니다.

에이전트 불안정이나 노드 교체가 잦은 환경에서는 증상이 더 복잡하게 나타날 수 있으니, 필요하다면 Jenkins 빌드가 멈출 때 - 에이전트 오프라인 진단도 함께 점검해보세요.