- Published on
Jenkins Declarative Pipeline env·withCredentials 정리
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 팀이 공유하는 Jenkins 파이프라인에서 가장 자주 터지는 문제는 결국 두 가지입니다. 첫째, 환경변수(env)가 어디까지 살아있는지(스코프) 헷갈려서 값이 비거나 덮어써지는 문제. 둘째, 자격증명(withCredentials)을 잘못 써서 로그에 토큰이 노출되거나, 반대로 필요한 단계에서만 써야 할 비밀이 전역으로 퍼지는 문제입니다.
이 글은 Jenkins Declarative Pipeline 기준으로 env와 withCredentials를 “언제, 어디서, 어떤 형태로” 쓰는 게 안전하고 유지보수에 유리한지 실전 관점에서 정리합니다.
Declarative Pipeline에서 env의 정체
Jenkins Pipeline에서 env는 빌드 전체 컨텍스트에 붙어 있는 환경변수 맵입니다. Declarative 문법에서는 주로 environment { ... } 블록으로 선언하고, 런타임에는 env.VAR_NAME 형태로 접근합니다.
핵심은 다음 3가지입니다.
environment {}에 선언한 값은 기본적으로 파이프라인 전역(모든 stage)에서 사용됩니다.- stage 내부에
environment {}를 또 선언하면 해당 stage에서만 유효하며, 같은 키는 stage 값이 우선합니다. env.X = "..."처럼 스크립트에서 대입하면 이후 단계에 영향을 주지만, 병렬 실행이나 재시도(retry)와 결합될 때 의도치 않은 공유 상태가 될 수 있어 주의가 필요합니다.
전역 env와 stage env 우선순위 예제
pipeline {
agent any
environment {
APP_NAME = 'payments'
DEPLOY_ENV = 'dev'
}
stages {
stage('Print global') {
steps {
sh 'echo APP_NAME=$APP_NAME'
sh 'echo DEPLOY_ENV=$DEPLOY_ENV'
}
}
stage('Override in stage') {
environment {
DEPLOY_ENV = 'prod'
}
steps {
sh 'echo DEPLOY_ENV=$DEPLOY_ENV'
}
}
stage('Back to global') {
steps {
sh 'echo DEPLOY_ENV=$DEPLOY_ENV'
}
}
}
}
이 예제에서 Override in stage에서만 DEPLOY_ENV가 prod로 바뀌고, 다음 stage에서는 다시 전역값인 dev로 돌아옵니다.
env를 선언할 때 흔히 하는 실수
실수 1: Groovy 문자열과 쉘 문자열의 혼동
Declarative에서 sh는 기본적으로 쉘에서 실행됩니다. 아래처럼 작성하면 Groovy가 먼저 ${env.BRANCH_NAME}를 치환한 뒤 쉘에 전달합니다.
sh "echo branch=${env.BRANCH_NAME}"
반면 아래는 쉘에서 $BRANCH_NAME를 확장합니다.
sh 'echo branch=$BRANCH_NAME'
둘 다 동작하지만, 민감정보가 섞이면 “어디에서 치환되는지”가 중요합니다. 예를 들어 Groovy 레벨에서 이미 문자열이 만들어지면, 그 문자열이 다른 로그/에러 경로로 흘러갈 수 있습니다. 비밀은 가능하면 쉘에서 확장되는 형태로 두고, withCredentials로 최소 범위에서만 주입하는 편이 안전합니다.
실수 2: 전역 env에 비밀을 넣기
environment { AWS_SECRET_ACCESS_KEY = credentials('...') } 같은 패턴은 간편하지만, 비밀이 파이프라인 전 구간에 퍼집니다. 또한 도구/플러그인에 따라 환경변수 덤프가 발생할 수 있어 리스크가 커집니다.
비밀은 전역 environment {}가 아니라 withCredentials {}로 “필요한 steps를 감싸는 방식”이 기본값이어야 합니다.
withCredentials의 핵심: 최소 범위, 최소 노출
withCredentials는 Jenkins Credentials Store의 비밀을 임시로 환경변수에 바인딩해주는 스텝입니다. 블록을 벗어나면 환경변수는 사라지고(정확히는 바인딩이 해제되고), 콘솔 로그 마스킹도 함께 적용됩니다.
대표 바인딩 타입은 다음과 같습니다.
string: 토큰, API KeyusernamePassword: 사번/계정 기반 인증file: JSON 키 파일, kubeconfig 등 파일로 내려받아야 하는 비밀
string 토큰 예제
pipeline {
agent any
stages {
stage('Call API') {
steps {
withCredentials([string(credentialsId: 'my-api-token', variable: 'API_TOKEN')]) {
sh 'curl -sS -H "Authorization: Bearer $API_TOKEN" https://example.internal/health'
}
}
}
}
}
여기서 중요한 포인트는 API_TOKEN이 필요한 curl 단계에만 존재한다는 점입니다.
usernamePassword 예제
withCredentials([
usernamePassword(credentialsId: 'docker-registry-creds', usernameVariable: 'REG_USER', passwordVariable: 'REG_PASS')
]) {
sh 'echo "$REG_PASS" | docker login -u "$REG_USER" --password-stdin registry.example.com'
}
docker login은 표준입력으로 비밀번호를 넘기는 방식이 로그 노출을 줄이는 데 유리합니다.
env와 withCredentials를 같이 쓸 때의 설계 패턴
패턴 1: 비밀이 아닌 값은 env, 비밀은 withCredentials
env: 리전, 서비스명, 태그 규칙, 배포 대상 클러스터 이름withCredentials: 토큰, 패스워드, 클라우드 키, kubeconfig
아래는 Docker 이미지 빌드/푸시에서 흔히 쓰는 조합입니다.
pipeline {
agent any
environment {
APP_NAME = 'payments'
REGISTRY = 'registry.example.com'
IMAGE = "${env.REGISTRY}/${env.APP_NAME}"
}
stages {
stage('Build') {
steps {
sh 'docker build -t "$IMAGE:$BUILD_NUMBER" .'
}
}
stage('Push') {
steps {
withCredentials([
usernamePassword(credentialsId: 'docker-registry-creds', usernameVariable: 'REG_USER', passwordVariable: 'REG_PASS')
]) {
sh 'echo "$REG_PASS" | docker login -u "$REG_USER" --password-stdin "$REGISTRY"'
sh 'docker push "$IMAGE:$BUILD_NUMBER"'
}
}
}
}
}
패턴 2: stage-level environment로 컨텍스트를 좁히기
운영/스테이징/개발을 하나의 Jenkinsfile로 운영하면, stage별로 environment {}를 둬서 “어떤 단계가 어떤 환경인지”를 코드 구조로 고정하는 게 좋습니다.
stage('Deploy to prod') {
when {
branch 'main'
}
environment {
DEPLOY_ENV = 'prod'
K8S_NAMESPACE = 'payments'
}
steps {
// deploy steps...
}
}
이 방식은 env.DEPLOY_ENV를 전역에서 덮어써서 생기는 혼란을 줄입니다.
실전: Kubernetes 배포에서의 withCredentials 활용
Kubernetes에 배포할 때는 크게 두 갈래입니다.
- Jenkins 에이전트가
kubectl로 직접 배포 - GitOps(Argo CD 등)로 매니페스트만 업데이트
어느 쪽이든 “레지스트리 인증”과 “클러스터 인증”이 자주 문제를 일으킵니다. 특히 레지스트리 인증이 꼬이면 Pod가 ImagePullBackOff로 떨어지는데, 이 경우 원인 진단 관점은 아래 글이 함께 도움이 됩니다.
kubeconfig를 file credential로 주입하는 예제
Jenkins Credentials에 kubeconfig를 Secret file로 저장해두고, 배포 stage에서만 파일로 꺼내 쓰는 방식입니다.
pipeline {
agent any
environment {
K8S_NAMESPACE = 'payments'
}
stages {
stage('Deploy') {
steps {
withCredentials([file(credentialsId: 'kubeconfig-prod', variable: 'KUBECONFIG_FILE')]) {
sh 'export KUBECONFIG="$KUBECONFIG_FILE" && kubectl -n "$K8S_NAMESPACE" rollout status deploy/payments --timeout=120s'
}
}
}
}
}
포인트는 다음과 같습니다.
KUBECONFIG는 블록 내부에서만 설정- 파일 경로는 Jenkins가 워크스페이스 어딘가에 임시로 내려줌
- 배포 실패 시에도 민감정보가 콘솔에 직접 찍히지 않게 커맨드를 구성
마스킹이 있어도 비밀이 새는 케이스
Jenkins는 withCredentials로 바인딩된 값에 대해 콘솔 마스킹을 시도하지만, 다음 패턴은 여전히 위험합니다.
set -x로 쉘 디버깅을 켠 상태에서 커맨드 라인에 토큰을 직접 포함- JSON/YAML을
echo로 만들면서 비밀을 그대로 출력 - 오류 메시지에 요청 헤더/바디가 포함되는 도구 사용
권장사항은 다음과 같습니다.
- 토큰은 헤더로 보내더라도 커맨드 라인에 노출이 최소화되게 구성
- 가능한
--password-stdin같은 표준입력 경로 사용 - 디버깅이 필요하면 비밀이 없는 stage에서만
set -x사용
Declarative에서 안전한 공통 함수화(Shared Library) 팁
파이프라인이 커지면 Shared Library로 빼고 싶어집니다. 이때 흔한 실수는 “라이브러리 함수에서 withCredentials를 감싼 다음, 바인딩된 값을 리턴”하려는 시도입니다. 값 자체를 리턴해 다른 scope에서 쓰면, 결국 비밀이 더 넓은 범위로 퍼질 수 있습니다.
대신 아래처럼 “비밀이 필요한 작업 자체를 클로저로 전달”하는 패턴이 안전합니다.
// vars/withApiToken.groovy (Shared Library)
def call(Closure body) {
withCredentials([string(credentialsId: 'my-api-token', variable: 'API_TOKEN')]) {
body()
}
}
// Jenkinsfile
stage('Notify') {
steps {
withApiToken {
sh 'curl -sS -H "Authorization: Bearer $API_TOKEN" https://example.internal/notify'
}
}
}
이렇게 하면 비밀은 “작업이 실행되는 동안”에만 존재하고, 호출자에게 토큰 문자열을 넘기지 않아도 됩니다.
네트워크 이슈가 자격증명 문제로 보일 때
배포/푸시 단계에서 인증 실패처럼 보이지만, 실제로는 네트워크 문제인 경우가 있습니다. 예를 들어 클러스터 API 서버나 외부 레지스트리로의 연결이 불안정하면 TLS 핸드셰이크 타임아웃, DNS 문제로 인증이 실패한 것처럼 보일 수 있습니다.
EKS 환경에서 TLS handshake timeout이 반복된다면 아래 글의 체크리스트가 도움이 됩니다.
또한 Jenkins 에이전트가 Kubernetes 위에서 돌고 있고 Pod 네트워크 할당 자체가 꼬이면, 파이프라인 단계가 시작도 못 하거나 외부 통신이 실패합니다. 이런 경우는 CNI 진단이 우선입니다.
체크리스트: env·withCredentials 운영 가이드
- 비밀이 아닌 값만
environment {}에 둔다 - 비밀은 항상
withCredentials {}로 최소 steps 범위에서만 주입한다 - stage별
environment {}로 컨텍스트를 좁혀, 덮어쓰기 혼란을 줄인다 - 커맨드 라인에 비밀을 직접 넣지 말고 표준입력/파일 바인딩을 선호한다
- Shared Library에서는 비밀 값을 “리턴”하지 말고 “작업을 감싸는 방식”으로 캡슐화한다
- 인증 실패처럼 보여도 네트워크/TLS/DNS 이슈를 함께 의심한다
마무리
Declarative Pipeline에서 env는 “구성값을 읽기 쉽게 만드는 도구”이고, withCredentials는 “비밀을 짧게 빌려 쓰는 도구”입니다. 둘을 섞을 때의 핵심은 전역 상태를 늘리지 않는 것입니다. 전역 env에 비밀을 넣거나, 스크립트에서 env를 마구 덮어쓰는 순간 파이프라인은 빨리 복잡해지고, 디버깅은 느려지며, 보안 사고 가능성도 커집니다.
위의 패턴(전역은 비밀 제외, 비밀은 최소 범위, 작업 단위로 감싸기)을 기본 규칙으로 삼으면, Jenkinsfile이 커져도 안정적으로 운영할 수 있습니다.