Published on

Jenkins Declarative 환경변수 스코프 꼬임 해결법

Authors

서로 다른 스테이지에서 env 값이 갑자기 바뀌거나, environment 블록에서 설정한 값이 script 안에서 기대대로 유지되지 않거나, withEnv 를 썼는데 다음 스테이지까지 영향이 남는 것처럼 보이는 문제는 Jenkins Declarative Pipeline에서 자주 겪는 함정입니다. 특히 멀티 브랜치, 병렬 실행, 재시도, 에이전트 분리(agent none 후 stage별 agent) 같은 구성이 붙으면 “환경변수 스코프가 꼬였다”는 느낌을 강하게 받습니다.

이 글은 Declarative에서 환경변수가 적용되는 범위(스코프) 규칙을 정리하고, 실제로 꼬임이 발생하는 대표 패턴과 해결책(재현 가능한 코드 포함)을 제공합니다.

문제의 전형적인 증상

아래 중 하나라도 겪었다면 거의 같은 원인군입니다.

  • environment { FOO = 'a' } 로 설정했는데 sh 'echo $FOO' 에서 비어 있거나 다른 값이 출력됨
  • script { env.FOO = 'b' } 로 바꿨더니 이후 스테이지까지 값이 전파되어 의도치 않게 빌드가 오염됨
  • withEnv(['FOO=b']) { ... } 로 감싼 블록 밖에서도 FOOb 로 남아 있는 것처럼 보임
  • parallel 에서 서로 다른 브랜치가 env 를 건드린 뒤 결과가 섞임
  • when { expression { env.BRANCH_NAME == ... } } 가 어떤 스테이지에서는 맞고 어떤 스테이지에서는 틀림

CI에서 이런 문제는 “재현이 어렵고, 한 번 터지면 원인 추적이 오래 걸리는” 유형입니다. 비슷한 맥락의 복구/추적 관점은 Git rebase 후 히스토리 꼬임 복구 - reflog 글의 접근(관측 가능한 로그를 기반으로 원인을 좁히기)과도 닮아 있습니다.

Declarative에서 환경변수의 3가지 레이어

Jenkins Pipeline에서 환경변수는 대체로 아래 레이어로 이해하면 덜 헷갈립니다.

1) Declarative environment 블록

  • 위치에 따라 스코프가 다릅니다.
    • pipeline { environment { ... } } 는 파이프라인 전역(모든 stage)에 적용
    • stage { environment { ... } } 는 해당 stage 내부에서만 적용
  • 실제로는 스텝 실행 시점에 환경을 주입하는 형태에 가깝습니다.

2) withEnv([...]) 스텝

  • 블록 내부에서만 유효한 “임시 환경”을 제공합니다.
  • 블록 밖으로 나가면 원칙적으로 원복됩니다.
  • 다만 env 객체를 직접 수정(env.FOO = 'x')하면 이 원칙과 섞이면서 혼란이 생깁니다.

3) env 객체 직접 수정 (env.KEY = 'value')

  • 가장 강력하지만 가장 위험합니다.
  • “현재 빌드의 전역 상태”에 가깝게 동작할 수 있어, 이후 stage에 영향을 줄 수 있습니다.
  • 병렬 실행에서 특히 위험합니다. 공유 상태를 동시에 변경하는 레이스 컨디션이 발생할 수 있습니다.

정리하면, Declarative environmentwithEnv 는 “스코프형”, env.KEY = ... 는 “전역 상태 변경형” 으로 보는 것이 안전합니다.

재현: 스코프가 꼬인 것처럼 보이는 Jenkinsfile

아래 예시는 일부러 안 좋은 패턴을 섞어 “왜 꼬이는지”를 보여줍니다.

pipeline {
  agent any

  environment {
    TARGET_ENV = 'dev'
    IMAGE_TAG  = 'v1'
  }

  stages {
    stage('A: show') {
      steps {
        sh 'echo A1 TARGET_ENV=$TARGET_ENV IMAGE_TAG=$IMAGE_TAG'
      }
    }

    stage('B: mutate env (bad)') {
      steps {
        script {
          // 전역 상태를 바꿔버림: 이후 stage까지 영향을 줄 수 있음
          env.TARGET_ENV = 'prod'
          env.IMAGE_TAG = "v2"
        }
        sh 'echo B1 TARGET_ENV=$TARGET_ENV IMAGE_TAG=$IMAGE_TAG'
      }
    }

    stage('C: expect dev?') {
      steps {
        sh 'echo C1 TARGET_ENV=$TARGET_ENV IMAGE_TAG=$IMAGE_TAG'
      }
    }

    stage('D: withEnv') {
      steps {
        withEnv(['TARGET_ENV=staging']) {
          sh 'echo D1 TARGET_ENV=$TARGET_ENV'
        }
        sh 'echo D2 TARGET_ENV=$TARGET_ENV'
      }
    }
  }
}

많은 분들이 C 에서 dev 가 나오길 기대하지만, B 에서 env.TARGET_ENV 를 바꿔버렸기 때문에 Cprod 를 보게 됩니다. withEnvD1 에서만 staging 을 보장하고, D2 는 다시 prod 로 돌아옵니다. 이 자체는 Jenkins 관점에서 “정상”이지만, 운영 관점에서는 “스코프가 꼬였다”로 체감됩니다.

원인 1: env.KEY = ... 를 설정값(설정)처럼 사용

Declarative를 구성 파일처럼 쓰고 싶을 때, env.KEY = ... 를 “지역 변수 대체재”로 쓰는 경우가 많습니다. 하지만 이건 전역 환경을 수정하는 행위에 가깝고, 파이프라인이 길어질수록 부작용이 누적됩니다.

해결 패턴: def 로 지역 변수 사용, 필요한 구간에만 주입

  • 계산/분기 결과는 Groovy 변수로 들고 간 뒤
  • 쉘 실행 직전에 withEnv 로 주입
pipeline {
  agent any

  environment {
    DEFAULT_ENV = 'dev'
  }

  stages {
    stage('Compute') {
      steps {
        script {
          // 지역 변수로 보관
          def targetEnv = (env.BRANCH_NAME == 'main') ? 'prod' : env.DEFAULT_ENV
          def imageTag  = "${env.BUILD_NUMBER}"  // 예시

          // 필요한 범위에만 주입
          withEnv(["TARGET_ENV=${targetEnv}", "IMAGE_TAG=${imageTag}"]) {
            sh 'echo TARGET_ENV=$TARGET_ENV IMAGE_TAG=$IMAGE_TAG'
            sh 'make deploy'
          }
        }
      }
    }
  }
}

이 패턴의 장점은 “값이 어디서 바뀌었는지”가 명확해지고, 다음 stage로 오염이 덜 전파된다는 점입니다.

원인 2: stage environment 의 평가 시점/문맥 착각

Declarative environment 는 Groovy 코드가 실행되는 시점과 동일하게 동작하지 않습니다. 특히 아래처럼 “다른 변수에 의존하는 값”을 environment 에서 만들려 하면 기대와 다르게 동작할 수 있습니다.

  • environment { FOO = "${bar}" } 형태
  • sh 결과를 넣고 싶어서 FOO = sh(...) 같은 시도

Declarative의 environment 는 “파이프라인 선언”에 가깝고, 동적 계산은 script 에서 하는 것이 안전합니다.

해결 패턴: 동적 값은 script 에서 계산 후 withEnv

stage('Dynamic env safely') {
  steps {
    script {
      def gitSha = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
      withEnv(["GIT_SHA=${gitSha}"]) {
        sh 'echo GIT_SHA=$GIT_SHA'
      }
    }
  }
}

원인 3: parallel 에서 env 공유 상태 경쟁

parallel 브랜치가 동시에 env 를 수정하면, 마지막에 쓴 값이 이겨버리거나, 로그 상으로는 섞여 보입니다.

나쁜 예

stage('Parallel bad') {
  parallel {
    stage('A') {
      steps {
        script { env.COLOR = 'red' }
        sh 'echo A COLOR=$COLOR'
      }
    }
    stage('B') {
      steps {
        script { env.COLOR = 'blue' }
        sh 'echo B COLOR=$COLOR'
      }
    }
  }
}

해결 패턴: 병렬 브랜치 내부는 지역 변수 + withEnv

stage('Parallel good') {
  parallel {
    stage('A') {
      steps {
        script {
          def color = 'red'
          withEnv(["COLOR=${color}"]) {
            sh 'echo A COLOR=$COLOR'
          }
        }
      }
    }
    stage('B') {
      steps {
        script {
          def color = 'blue'
          withEnv(["COLOR=${color}"]) {
            sh 'echo B COLOR=$COLOR'
          }
        }
      }
    }
  }
}

병렬 실행에서 공유 상태를 없애는 것이 핵심입니다. 운영에서도 비슷하게 “공유 자원 경쟁”은 장애를 만듭니다. 쿠버네티스에서의 반복 재시작 원인 추적과 관점이 닮아 있어, 필요하면 EKS CrashLoopBackOff 진단 - Pod 재시작 원인 추적 처럼 “관측 포인트를 먼저 세우고” 좁혀가는 방식이 도움이 됩니다.

원인 4: agent none 와 stage별 에이전트에서 생기는 착시

pipeline { agent none } 후 stage마다 다른 노드/컨테이너에서 실행하면, 다음과 같은 착시가 생깁니다.

  • 같은 env 를 쓰는 것 같은데, 실제로는 서로 다른 실행 환경(워크스페이스, 도구, PATH)
  • sh 가 다른 쉘/이미지에서 돌면서 기대한 변수가 없거나 명령이 없음

이 경우 “환경변수 스코프” 문제처럼 보이지만, 사실은 실행 환경 자체가 다름이 원인인 경우가 많습니다.

해결 패턴: 진단 스텝을 넣어 실행 환경을 고정/검증

stage('Diag') {
  agent any
  steps {
    sh 'pwd'
    sh 'env | sort | sed -n "1,40p"'
    sh 'which bash || true'
    sh 'which git  || true'
  }
}

또한 Docker/권한 이슈가 섞이면 “변수 문제”로 오인하기 쉽습니다. 러너/에이전트 권한 문제 가능성이 있다면 GitLab CI Runner에서 Docker 권한 오류 해결 가이드 같은 체크리스트식 접근을 Jenkins 에이전트에도 적용해보는 것이 좋습니다.

실전 처방전: 스코프 꼬임을 막는 규칙 7가지

  1. env.KEY = ... 는 최후의 수단으로만 사용합니다. 가능하면 지역 변수와 withEnv 로 제한합니다.
  2. 동적 값(명령 결과, 조건 분기)은 environment 블록이 아니라 script 에서 계산합니다.
  3. 병렬 실행에서는 env 수정 금지를 팀 규칙으로 둡니다.
  4. withEnv 는 “주입 범위를 명확히” 하기 위해 블록을 짧게 유지합니다. 배포 커맨드 직전/직후 정도로.
  5. stage environment 와 pipeline environment 를 혼용할 때는, 같은 키를 중복 정의하지 않습니다. 중복이 필요하면 키를 분리합니다(TARGET_ENVSTAGE_TARGET_ENV).
  6. when 조건은 가능하면 params 또는 변경되지 않는 env 값에만 의존하게 하고, 중간에 env 를 바꾸는 설계를 피합니다.
  7. 로그에 env 를 찍을 때는 민감정보를 피하고, 필요한 키만 선택 출력합니다.

추천하는 “안전한 기본 템플릿”

팀에서 Jenkinsfile을 표준화할 때 아래 템플릿을 베이스로 두면 스코프 꼬임이 크게 줄어듭니다.

pipeline {
  agent any

  parameters {
    choice(name: 'DEPLOY_ENV', choices: ['dev', 'staging', 'prod'], description: 'Deploy target')
  }

  environment {
    APP_NAME = 'my-service'
  }

  stages {
    stage('Build') {
      steps {
        sh 'make build'
      }
    }

    stage('Deploy') {
      steps {
        script {
          // 동적 값은 지역 변수로
          def deployEnv = params.DEPLOY_ENV
          def imageTag  = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()

          // 필요한 범위에만 환경 주입
          withEnv([
            "DEPLOY_ENV=${deployEnv}",
            "IMAGE_TAG=${imageTag}",
            "APP_NAME=${env.APP_NAME}"
          ]) {
            sh 'echo Deploying $APP_NAME to $DEPLOY_ENV with tag $IMAGE_TAG'
            sh 'make deploy'
          }
        }
      }
    }
  }
}

핵심은 “값의 생명주기”를 짧게 만들고, 전역 변경을 줄이는 것입니다.

마무리: 꼬임은 대부분 ‘전역 상태’에서 시작한다

Jenkins Declarative에서 환경변수 스코프가 꼬인 것처럼 보이는 문제의 상당수는 env 를 전역 상태처럼 바꿔버리면서 시작합니다. Declarative environment 는 선언적으로, withEnv 는 범위 제한적으로, 동적 값은 script 의 지역 변수로 들고 가는 방식으로 역할을 분리하면 재현 어려운 변동성 문제가 크게 줄어듭니다.

운영에서 중요한 건 “한 번 고치고 끝”이 아니라, 다시는 같은 유형이 터지지 않도록 팀 규칙과 템플릿으로 굳히는 것입니다. 이 글의 템플릿을 기준으로 Jenkinsfile을 리팩터링하면, 환경변수로 인한 배포 오염과 병렬 레이스를 체계적으로 제거할 수 있습니다.