Published on

Jenkins Declarative Pipeline에서 env가 안 먹는 6가지 이유

Authors

Jenkins Declarative Pipeline을 쓰다 보면 env가 분명히 설정됐는데도 쉘에서 비어 있거나, when/environment에서 기대대로 평가되지 않거나, 스테이지마다 값이 달라지는 문제가 자주 발생합니다. 대부분은 Declarative와 Scripted의 평가 시점 차이, Groovy 문자열 처리, 스텝이 만드는 서브 프로세스 경계, withEnv/environment의 스코프, Credentials 바인딩 방식, 에이전트/컨테이너 분리 같은 “경계”에서 생깁니다.

아래는 현장에서 가장 많이 만나는 “env 안 먹는” 원인 6가지와, 각 원인별로 바로 적용 가능한 해결 패턴입니다.

1) env.FOO = ...environment {}에서 동적으로 만들려고 함

Declarative의 environment {} 블록은 기본적으로 파이프라인 로딩/컴파일 단계에서 해석되는 성격이 강합니다. 즉, 런타임에 계산되는 값(예: sh 결과, 파일 읽기, API 호출)을 그대로 넣으려고 하면 안 먹거나, 예상과 다르게 동작합니다.

흔한 실패 예

pipeline {
  agent any
  environment {
    // 런타임 계산을 넣고 싶어도 Declarative 환경에서는 제약이 큼
    VERSION = "${sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()}"
  }
  stages {
    stage('Print') {
      steps {
        sh 'echo $VERSION'
      }
    }
  }
}

위는 Jenkins 버전/플러그인 조합에 따라 아예 에러가 나거나, 빈 값/이상한 값이 들어갈 수 있습니다.

해결 패턴

런타임 계산이 필요하면 script {}에서 계산하고 env에 대입한 뒤 사용합니다.

pipeline {
  agent any
  stages {
    stage('Init') {
      steps {
        script {
          env.VERSION = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
        }
      }
    }
    stage('Print') {
      steps {
        sh 'echo VERSION=$VERSION'
      }
    }
  }
}

2) Groovy 싱글쿼트 때문에 변수 치환이 안 됨 (특히 sh 인자)

sh에 넘기는 문자열은 Groovy 문자열 규칙을 그대로 따릅니다. 싱글쿼트는 문자열 보간(interpolation) 이 일어나지 않아서, ${env.FOO} 같은 표현이 그대로 문자열로 전달됩니다.

흔한 실패 예

pipeline {
  agent any
  stages {
    stage('Bad') {
      steps {
        script {
          env.IMAGE = 'myapp:1.0.0'
        }
        sh 'echo ${env.IMAGE}'
      }
    }
  }
}

위 코드는 쉘에 ${env.IMAGE} 그대로 전달됩니다(쉘 변수도 아니어서 비어 보이거나 그대로 출력).

해결 패턴 1: 쉘 변수로 접근하기

env는 Jenkins가 쉘 환경변수로 주입하므로, 쉘에서는 $IMAGE로 읽는 게 가장 단순합니다.

sh 'echo $IMAGE'

해결 패턴 2: Groovy 보간이 필요하면 더블쿼트 사용

sh "echo ${env.IMAGE}"

3) sh 내부에서 export한 값이 다음 sh에 안 이어짐 (프로세스 경계)

Declarative에서 sh 스텝은 호출될 때마다 별도 프로세스로 실행됩니다. 그래서 한 번의 sh에서 export FOO=bar를 해도, 다음 sh에서는 사라집니다. 이걸 env가 안 먹는 것으로 오해하는 경우가 매우 많습니다.

흔한 실패 예

pipeline {
  agent any
  stages {
    stage('Bad') {
      steps {
        sh 'export FOO=bar'
        sh 'echo $FOO' // 비어 있음
      }
    }
  }
}

해결 패턴 1: 한 번의 sh로 묶기

sh '''
  export FOO=bar
  echo $FOO
'''

해결 패턴 2: Jenkins env로 올리기

script {
  env.FOO = 'bar'
}
sh 'echo $FOO'

4) withEnv/environment 스코프를 벗어나서 사용함

withEnv([...])해당 블록 내부에서만 환경변수를 주입합니다. 블록 밖에서 $FOO를 기대하면 안 먹습니다. environment {}도 마찬가지로, 선언 위치/스테이지 경계에 따라 적용 범위가 달라집니다.

흔한 실패 예: withEnv 범위 밖

pipeline {
  agent any
  stages {
    stage('Bad') {
      steps {
        withEnv(['FOO=bar']) {
          sh 'echo inside=$FOO'
        }
        sh 'echo outside=$FOO' // 비어 있음
      }
    }
  }
}

해결 패턴

  • 블록 밖에서도 필요하면 env.FOO에 대입하거나
  • 필요한 구간 전체를 withEnv로 감싸거나
  • 스테이지 레벨 environment {}로 올립니다.
pipeline {
  agent any
  stages {
    stage('Good') {
      environment {
        FOO = 'bar'
      }
      steps {
        sh 'echo $FOO'
      }
    }
  }
}

5) Credentials 바인딩/마스킹 때문에 “안 먹는 것처럼” 보임

withCredentials로 주입된 값은 로그에 그대로 찍히지 않도록 마스킹되며, 경우에 따라 빈 값처럼 보이거나 ****로 표시됩니다. 또한 environment {}에서 credentials('id')로 주입하는 방식은 타입(Secret text, Username/Password 등)에 따라 변수명이 달라져 혼동이 생깁니다.

흔한 실패 예: 로그에 안 찍혀서 비어 보임

pipeline {
  agent any
  stages {
    stage('Cred') {
      steps {
        withCredentials([string(credentialsId: 'my-token', variable: 'TOKEN')]) {
          sh 'echo $TOKEN' // 마스킹되어 **** 또는 빈 것처럼 보일 수 있음
        }
      }
    }
  }
}

해결 패턴

  • 토큰이 “존재하는지”만 확인하려면 길이/해시 등으로 검증합니다.
  • Jenkins 로그 마스킹 정책을 존중하고, 원문 출력은 피합니다.
withCredentials([string(credentialsId: 'my-token', variable: 'TOKEN')]) {
  sh 'test -n "$TOKEN" && echo "TOKEN is set" || (echo "TOKEN missing"; exit 1)'
  sh 'echo "len=${#TOKEN}"'
}

추가로, Username/Password는 보통 USERNAME/PASSWORD 두 변수를 만들므로 변수명을 착각하지 않게 주의합니다.

6) 에이전트/도커 컨테이너/병렬 실행으로 실행 환경이 갈라짐

Declarative에서 agent를 스테이지별로 다르게 쓰거나, docker/kubernetes 에이전트로 컨테이너가 바뀌면 “같은 파이프라인”이라도 실행 환경이 완전히 분리됩니다. 이때 env는 Jenkins 레벨에서 전달되지만, 컨테이너 진입 방식이나 스텝 실행 위치에 따라 기대와 다른 결과가 나올 수 있습니다.

대표 케이스:

  • 스테이지 A는 agent any, 스테이지 B는 agent { docker { ... } }로 컨테이너가 바뀜
  • parallel에서 각 브랜치가 서로 다른 워크스페이스/노드에서 실행
  • Kubernetes 플러그인에서 컨테이너별로 sh가 실행되는 컨테이너가 다름

흔한 실패 예: 컨테이너가 바뀌며 기대가 틀어짐

pipeline {
  agent any
  stages {
    stage('Init') {
      steps {
        script { env.FOO = 'bar' }
      }
    }
    stage('Docker') {
      agent {
        docker { image 'alpine:3.19' }
      }
      steps {
        sh 'echo $FOO'
      }
    }
  }
}

대부분은 동작하지만, 플러그인/설정에 따라 컨테이너 실행 래퍼가 환경 전달을 제한하거나, 다른 셸/엔트리포인트로 인해 값이 누락된 것처럼 보일 수 있습니다.

해결 패턴

  • 컨테이너/노드가 바뀌는 구간에서는 필요한 값을 명시적으로 전달합니다.
  • withEnv로 스테이지 내부에서 재주입하거나, 아예 커맨드 인자로 넘깁니다.
stage('Docker') {
  agent { docker { image 'alpine:3.19' } }
  steps {
    withEnv(["FOO=${env.FOO}"]) {
      sh 'echo $FOO'
    }
  }
}

병렬에서는 브랜치별로 env 변경이 섞일 수 있으니(공유 상태) branch 내부 로컬 변수 또는 withEnv를 우선 고려합니다.

디버깅 체크리스트: “env가 안 먹는다”를 빨리 분해하는 법

문제를 재현했을 때 아래 순서로 확인하면 원인을 빠르게 좁힐 수 있습니다.

  1. Groovy 레벨 확인
script {
  echo "FOO(groovy)=${env.FOO}"
}
  1. 쉘 레벨 확인
sh 'printenv | sort | sed -n "1,120p"'
sh 'echo "FOO(shell)=$FOO"'
  1. 스코프 확인
  • withEnv/withCredentials 블록 밖에서 읽고 있지 않은지
  • 스테이지별 environment {} 선언 위치가 의도한 범위인지
  1. 실행 경계 확인
  • 다른 agent/컨테이너/노드로 넘어갔는지
  • parallel로 동시에 env를 덮어쓰고 있지 않은지

운영에서 자주 같이 터지는 이슈들

CI에서 환경변수 문제는 종종 “네트워크/권한/인프라 문제”와 함께 나타납니다. 예를 들어 특정 노드에서만 env가 비어 보인다면, 사실은 에이전트의 접속 불안정이나 세션 종료로 스텝이 재시도/재스케줄링되면서 실행 컨텍스트가 바뀐 것일 수 있습니다. 이런 경우는 리눅스 SSH 접속 끊김·Timeout 원인 10가지 같은 관점으로 에이전트 안정성도 같이 점검하는 편이 좋습니다.

또한 Kubernetes 기반 Jenkins 에이전트에서 권한 문제로 특정 커맨드가 실패하고, 그 결과로 env 설정 로직이 스킵되며 “env가 안 먹는 것처럼” 보이는 경우가 있습니다. AWS KMS/IRSA 권한 이슈가 얽히면 증상이 더 헷갈리니 EKS IRSA는 되는데 KMS Decrypt 403 해결법 같은 케이스도 참고할 만합니다.

정리

Declarative Pipeline에서 env가 안 먹는 문제는 대체로 다음 6가지로 귀결됩니다.

  1. environment {}에 런타임 계산을 넣으려 함(평가 시점 문제)
  2. Groovy 싱글쿼트로 보간이 안 됨(문자열 처리 문제)
  3. shexport가 이어진다고 착각함(프로세스 경계)
  4. withEnv/environment 스코프 밖에서 사용함(범위 문제)
  5. Credentials 마스킹/바인딩 방식으로 “안 보이는” 착시(보안/표현 문제)
  6. 에이전트/컨테이너/병렬로 실행 환경이 갈라짐(실행 컨텍스트 문제)

위에서 제시한 재현 코드로 본인 파이프라인을 하나씩 대조해 보면, 대부분은 특정 경계(시점/스코프/프로세스/컨테이너)에서 원인을 바로 찾을 수 있습니다.