운영 설정 파일을 정리하는 작업을 맡으면서 처음에는 저장소에서 비밀번호와 토큰만 빼면 끝날 거라고 생각했다. 그런데 코드를 바꾼 뒤 실제 배포 경로를 따라가 보니 문제는 훨씬 길게 이어져 있었다. 비밀값을 어디에 저장할지, Jenkins가 빌드할 때 어떻게 꺼낼지, 원격 서버에는 어떤 권한으로 설치할지, 새 프로세스가 그 값을 실제로 읽었는지까지 전부 연결되어야 했다.
결과적으로 이번 작업은 설정 파일 몇 개를 수정하는 일이 아니라 다음 흐름을 다시 만드는 일이 됐다.
Jenkins Credentials
↓
빌드 동안만 존재하는 Secret File
↓
원격 서버의 보호 경로
↓
프로세스 시작 직전 환경변수 export
↓
프로세스·listener·health·binary 검증
이 글은 특정 회사의 Jenkins 설정을 소개하는 글이 아니다. 실제 작업에서 확인한 경계를 일반적인 이름과 예제로 바꾸고, 비슷한 배포 구조에서 다시 사용할 수 있는 판단 기준만 정리했다.
저장소 설정 파일의 한계
가장 먼저 확인한 문제는 운영 설정 파일에 비밀값이 평문으로 들어 있었다는 점이었다. 현재 브랜치의 파일을 비우는 것만으로는 충분하지 않았다. 이미 커밋된 값은 Git 이력, 개발자의 기존 clone, CI workspace와 cache, 과거 artifact, 백업 등에 남아 있을 수 있다.
따라서 저장소 정리와 비밀값 대응을 같은 작업으로 보면 안 됐다.
| 구분 | 필요한 처리 |
|---|---|
| 현재 코드 | 비밀값 제거와 환경변수 참조 |
| 애플리케이션 | 운영 환경에서 필수값 누락 시 기동 중단 |
| 자격 증명 | 기존 값 회전과 폐기 |
| 과거 흔적 | Git 이력, clone, cache, artifact, 백업 확인 |
| 배포 경로 | Jenkins에서 서버 프로세스까지 새 전달 경로 구성 |
Jenkins 공식 문서에서도 키나 인증 정보를 SCM에 저장하지 말고 Jenkins Credentials 같은 별도 저장소를 사용하도록 안내한다. 자격 증명은 가능한 가장 좁은 범위에 두는 것이 기본 원칙이다. Jenkins Credentials 보안 문서
이번 작업에서는 과거 이력을 억지로 덮어쓰는 것보다 현재 자격 증명을 먼저 회전하고, 앞으로 같은 값이 저장소에 들어가지 않는 구조를 만드는 쪽을 우선했다. 이력을 지우더라도 이미 복제된 저장소나 artifact까지 회수할 수 있는 것은 아니기 때문이다.
비밀값 전달 경로의 분리
Jenkins에 Credential을 등록한 순간 문제가 해결됐다고 생각하기 쉽다. 하지만 저장과 전달은 다른 단계다.
Credential 등록
└─ Jenkins가 값을 보관할 수 있음
Credential binding
└─ 특정 Job이 빌드 중 값을 사용할 수 있음
원격 설치
└─ 배포 서버의 제한된 경로에 파일이 존재함
프로세스 기동
└─ 애플리케이션이 환경변수로 값을 읽음
처음 기존 Job들을 읽기 전용으로 확인했을 때는 일부 값이 Jenkins Parameter로만 들어가 있거나, 애플리케이션이 요구하는 환경변수와 Job의 변수명이 맞지 않는 경우가 있었다. 더 큰 문제는 Jenkins에서 만든 변수가 원격 서버의 새 프로세스까지 전달된다는 증거가 없었다는 점이었다.
그래서 먼저 애플리케이션이 요구하는 변수 목록을 코드에서 추출하고, Jenkins binding 이름, 서버 파일의 key, 프로세스 환경변수 이름을 같은 표로 비교했다. 값은 비교하지 않고 이름과 존재 여부만 확인했다.
| 경계 | 확인 대상 |
|---|---|
| 애플리케이션 | 필수 환경변수 이름 |
| Jenkins | Credential ID와 binding 변수 |
| 배포 스크립트 | Secret File 복사 대상 |
| 시작 스크립트 | source 시점과 export 여부 |
| 실행 프로세스 | 실제 환경변수 이름과 빈 값 여부 |
이 표를 만들고 나서야 “Jenkins에 값이 있다”와 “실행 중인 프로세스가 값을 받았다” 사이에 여러 단계가 빠져 있었다는 것이 보였다.
Secret File 바인딩 구조
여러 환경변수를 각각 Secret Text로 등록할 수도 있지만, 서비스별 운영 환경변수를 하나의 파일로 관리하는 편이 배포 단위를 고정하기 쉬웠다. Jenkins의 Secret File은 파일 내용을 Credential로 저장하고, 빌드가 실행되는 동안 임시 파일 경로를 환경변수로 제공한다. Jenkinsfile의 Secret File 사용법
Pipeline으로 단순화하면 다음과 같은 형태다.
withCredentials([
file(
credentialsId: 'example-application-env-v1',
variable: 'APPLICATION_ENV_FILE'
)
]) {
sh '''
set -eu
set +x
test -s "$APPLICATION_ENV_FILE"
./deploy-secret-file.sh "$APPLICATION_ENV_FILE"
'''
}
여기서 중요한 것은 환경변수에 파일 내용이 아니라 Jenkins가 만든 임시 파일의 경로가 들어간다는 점이다. 빌드 스크립트는 그 경로를 이용해 필요한 작업만 수행하고, 값을 console에 출력하지 않는다.
Jenkins Credentials Binding Plugin도 Secret File과 Secret Text를 Job의 환경변수로 연결하는 방식을 제공한다. 공식 안내에는 셸 trace를 끄기 위한 set +x도 함께 권장한다. Credentials Binding Plugin
다만 set +x와 console masking을 보안 경계 자체로 보면 안 된다. Pipeline을 수정할 수 있는 사용자는 값을 다른 형태로 인코딩하거나 artifact에 넣는 방식으로 가져갈 수 있다. Jenkins 보안팀도 masking은 우발적인 노출을 줄이는 기능이며, 신뢰하지 않는 Pipeline에 신뢰할 수 있는 Credential을 제공해서는 안 된다고 설명한다. Credentials masking의 한계
그래서 실제 기준은 다음처럼 잡았다.
- Credential을 사용할 수 있는 Job과 사용자 범위의 최소화
- 비밀값 사용 구간의 최소화
- 원격 대상과 배포 명령의 고정
- console·artifact·workspace에 값을 남기지 않는 처리
- 빌드 스크립트 변경 권한의 제한
서버 보호 경로와 실행 환경
Jenkins의 임시 Secret File을 원격 서버의 일반 배포 폴더에 그대로 두면 다음 배포나 로그 수집 과정에서 함께 노출될 수 있다. 서비스 binary와 정적 파일을 두는 release 폴더와 비밀값 폴더를 분리했다.
/opt/example/
├─ releases/
│ ├─ 20260813-150000/
│ └─ 20260813-153000/
├─ current -> releases/20260813-153000
└─ secrets/
└─ application.env
Secret 설치 명령은 다음 정도로 단순하게 유지할 수 있다.
#!/usr/bin/env bash
set -euo pipefail
incoming_file="$1"
secret_dir="/opt/example/secrets"
secret_file="${secret_dir}/application.env"
install -d -o appuser -g appuser -m 700 "$secret_dir"
install -o appuser -g appuser -m 600 \
"$incoming_file" "$secret_file"
test "$(stat -c '%a' "$secret_dir")" = "700"
test "$(stat -c '%a' "$secret_file")" = "600"
디렉터리는 소유자만 접근할 수 있도록 700, 파일은 소유자만 읽고 쓸 수 있도록 600으로 고정했다. 단순히 cp한 뒤 chmod하는 것보다 install 명령으로 소유자와 mode를 한 번에 지정하면 중간 상태를 줄이기 좋았다.
원격 전송용 임시 파일도 성공과 실패에 관계없이 정리해야 한다.
temporary_file="/tmp/example-application.env.$$"
cleanup() {
rm -f "$temporary_file"
}
trap cleanup EXIT
receive_secret_file "$temporary_file"
install_secret_file "$temporary_file"
여기서 receive_secret_file과 install_secret_file은 설명을 위한 역할명이다. 실제 구현에서는 사용하는 Jenkins 플러그인과 SSH 전송 방식에 맞게 바꾸면 된다.
운영 환경의 fail-closed 정책
배포 경로를 만들더라도 애플리케이션이 값 누락을 조용히 무시하면 잘못된 상태로 서비스가 시작될 수 있다. 특히 운영 환경에서는 설정 파일 fallback이 남아 있으면 Secret 전달에 실패해도 예전 평문 값으로 실행되어 문제를 늦게 발견하게 된다.
그래서 운영 모드에서는 필수 환경변수가 없거나 비어 있으면 즉시 기동을 중단하도록 했다. 개발과 스테이징은 마이그레이션 기간 동안 기존 설정을 제한적으로 사용할 수 있지만, 운영에서는 환경변수만 허용하는 방식이다.
시작 스크립트의 핵심은 파일 내용을 현재 shell의 환경변수로 export한 뒤 같은 shell에서 프로세스를 실행하는 것이다.
#!/usr/bin/env bash
set -euo pipefail
secret_file="/opt/example/secrets/application.env"
test -r "$secret_file"
set -a
. "$secret_file"
set +a
required_variables=(
APP_DATABASE_HOST
APP_DATABASE_USER
APP_DATABASE_PASSWORD
APP_SESSION_KEY
)
for variable_name in "${required_variables[@]}"; do
if [[ -z "${!variable_name:-}" ]]; then
echo "missing required environment variable: ${variable_name}" >&2
exit 1
fi
done
exec /opt/example/current/application
검사 메시지에는 변수 이름만 남기고 값은 절대 출력하지 않는다. exec를 사용하면 shell 아래에 애플리케이션을 자식으로 한 번 더 남기지 않고 현재 프로세스를 교체할 수 있어 PID 추적도 단순해진다.
이 구조의 장점은 전달 실패가 배포 시점에 바로 드러난다는 것이다. 반대로 단점도 있다. Jenkins 설정과 Secret 준비가 끝나기 전에 fail-closed 코드부터 배포하면 서비스가 시작되지 않는다. 따라서 코드는 맞더라도 배포 순서를 잘못 잡으면 장애가 된다.
비활성 복제 Job의 안전장치
기존 운영 Job을 화면에서 바로 수정하는 방식은 피했다. 정상 배포 경로가 이미 동작하고 있다면 변경 도중 원래 설정과 비교하기 어렵고, 잘못 저장했을 때 복구 기준도 흐려진다.
대신 기존 Job을 복제한 뒤 이름에 secure 같은 구분을 붙이고 비활성 상태로 유지했다.
기존 Job
├─ SCM과 branch
├─ build/test
├─ artifact
├─ release/current symlink
├─ health check
└─ rollback
비활성 복제 Job
├─ 기존 구조 유지
├─ Secret File binding 추가
├─ 보호 경로 설치 추가
├─ 시작 전 source 추가
└─ startup check 추가
설정 변경 전후에는 전체 XML이나 Secret 값을 출력하지 않고 안전한 필드만 추출했다. SCM, branch, Credential ID 존재 여부, 원격 대상 역할, 명령문의 길이와 digest, Job 비활성 상태 정도만 비교했다.
또한 다음 네 단계를 따로 취급했다.
- 현재 설정 읽기와 backup digest 확보
- 후보 설정과 sanitized diff 검토
- 설정 저장
- 저장된 설정의 read-back digest 확인
여기까지 성공해도 빌드나 배포가 된 것은 아니다. Jenkins에서 Job 설정을 저장하는 것과 Build Now를 누르는 것은 완전히 다른 동작이다. 이 구분을 작업 기록에도 명확히 남겼다.
SCM 인증과 의존성 인증의 분리
복제 Job의 첫 빌드에서는 SCM checkout이 성공했는데도 build 중간에 비공개 모듈 다운로드가 실패했다. 처음에는 SSH Credential 문제가 해결됐으니 Git 인증은 모두 끝났다고 생각하기 쉬웠다.
하지만 실제로는 서로 다른 인증 경로였다.
SCM checkout
└─ Jenkins Git Plugin이 SSH Credential 사용
빌드 도중 의존성 다운로드
└─ go mod, npm, Maven 등이 별도의 Git/HTTP 인증 사용
예를 들어 메인 저장소를 SSH로 checkout했더라도 빌드 도구가 비공개 의존성을 HTTPS URL로 요청하면 Jenkins Git Plugin의 SSH Credential이 자동으로 이어지지 않는다. 반대의 경우도 마찬가지다.
이 문제를 확인할 때는 credential 값을 출력하는 대신 실제 build 사용자가 각 경로에 접근할 수 있는지만 검사했다.
set -euo pipefail
export GOPRIVATE='git.example.com/internal/*'
export GONOSUMDB='git.example.com/internal/*'
git ls-remote \
git@git.example.com:internal/shared-module.git \
HEAD >/dev/null
go mod download
여기서 얻은 교훈은 단순했다. checkout 성공은 build 의존성 인증 성공의 증거가 아니다. Job을 바꾼 뒤에는 저장소 checkout, 비공개 dependency 조회, test, artifact 생성까지 각각 확인해야 한다.
저장·빌드·배포·검증의 구분
Jenkins 작업에서 가장 헷갈리기 쉬운 부분은 초록색 SUCCESS 하나로 모든 단계를 묶는 것이다. 실제로는 다음 단계가 서로 다른 증거를 요구한다.
| 단계 | 확인 가능한 사실 | 아직 확인되지 않은 사실 |
|---|---|---|
| 설정 저장 | Job 설정이 저장됨 | build 성공 |
| build 성공 | checkout·test·artifact 성공 | 서버 반영 |
| deploy 성공 | artifact 전송과 배포 명령 성공 | 새 프로세스 정상 기동 |
| restart 성공 | 프로세스가 다시 시작됨 | 요청 처리 정상 |
| runtime 검증 | 현재 프로세스·listener·health 정상 | 장시간 운영 안정성 |
배포 스크립트가 종료 코드 0을 반환했더라도 새 프로세스가 이전 binary로 실행 중이거나, 한 서버만 반영됐거나, Secret 파일은 설치됐지만 프로세스가 source하지 않았을 수 있다.
그래서 Jenkins 결과와 서버 상태를 한 문장으로 합치지 않았다. “Job SUCCESS”와 “두 서버 런타임 검증 통과”를 별도 결과로 기록했다.
HA 런타임 검증
두 대 이상의 서버를 운영한다면 첫 번째 서버가 정상이라는 사실만으로 배포를 완료할 수 없다. 각 서버에서 같은 항목을 확인하고 서로 비교해야 했다.
검증 항목은 다음과 같이 구성했다.
Secret
- 디렉터리 owner/mode
- 파일 owner/mode
- 서버 간 file digest 일치
Process
- 실행 사용자와 PID
- 실제 CWD
- 필수 환경변수 이름 존재
- 빈 환경변수 없음
Runtime
- listener
- health endpoint
- 실행 binary SHA-256
- 최근 기동 로그의 fatal/panic/설정 누락
값 자체를 보지 않고 환경변수 이름과 빈 값 여부만 확인하는 probe를 만들었다.
check_variable_names() {
local process_id="$1"
shift
local environment_file="/proc/${process_id}/environ"
for variable_name in "$@"; do
if ! tr '\0' '\n' < "$environment_file" \
| cut -d= -f1 \
| grep -Fxq "$variable_name"; then
echo "missing variable: ${variable_name}" >&2
return 1
fi
done
}
실제 health와 listener도 별도로 확인했다.
test -n "$(pgrep -f '/opt/example/current/application')"
ss -lnt | grep -F ":${SERVICE_PORT} " >/dev/null
curl -fsS "http://127.0.0.1:${HEALTH_PORT}/health" >/dev/null
sha256sum /opt/example/current/application
Windows에서 만든 probe를 SSH로 전달할 때는 또 다른 문제가 있었다. 첫 번째 실행은 필요한 정보를 끝까지 수집했는데도 BOM과 CRLF 때문에 마지막 명령의 종료 코드가 1이 됐다. 서버 장애와 검증 스크립트 전달 형식 문제를 구분해야 했다. 스크립트를 LF로 정규화한 뒤 두 서버에 같은 방식으로 다시 실행해 결과를 고정했다.
이 경험 이후부터는 probe 자체에도 다음 조건을 넣게 됐다.
- UTF-8 BOM 제거와 LF 정규화
- 값 출력 금지
- 하나의 실패가 전체 성공으로 섞이지 않는 종료 코드
- 서버별 결과 분리
- 검증 시각과 binary digest 기록
누락된 배포 대상
주요 운영 서버 두 대의 검증을 끝낸 뒤 별도 지역 서비스용 서버가 하나 더 있다는 이야기를 들었다. 기존 작업 문서와 secure Job에는 그 대상이 포함되어 있지 않았다.
읽기 전용으로 별도 Job을 확인해 보니 같은 애플리케이션 저장소를 사용하고 있었지만 배포 구조는 달랐다. 최근 배포 시점도 오래됐고 Secret File binding, 보호 경로 설치, 시작 전 source가 없었다. 최신 fail-closed 코드를 그대로 빌드해 배포하면 필수 환경변수 부족으로 기동하지 못할 가능성이 있었다.
여기서는 “일단 빌드해서 확인”하지 않고 중단하는 쪽을 선택했다. 기존 Job을 바로 수정하지 않고 비활성 복제 Job을 만든 뒤, 검증된 Secret 전달 구조를 별도 배포 방식에 맞게 결합하는 작업으로 분리했다.
이때 가장 크게 배운 점은 Jenkins Job 목록이 단순한 운영 편의 정보가 아니라는 것이다. 애플리케이션이 배포되는 전체 대상을 모르면 일부 서버의 성공을 전체 완료로 잘못 판단할 수 있다.
저장소
├─ 일반 운영 Job → 운영 서버 A, B
├─ 별도 지역 Job → 지역 서버 C
└─ 이전 세대 Job → 현재 사용 여부 확인 필요
코드 영향 범위뿐 아니라 Job에서 서버로 이어지는 배포 토폴로지도 변경 전에 확인해야 했다.
재사용 체크리스트
비슷한 작업을 다시 한다면 다음 순서로 확인할 생각이다.
비밀값과 저장소
- 현재 파일에서 비밀값이 제거됐는가
- 노출된 기존 값이 회전·폐기됐는가
- Git 이력, clone, cache, artifact, 백업을 별도로 확인했는가
- 운영 코드가 필수값 누락 시 fail-closed하는가
Jenkins 자격 증명
- Credential 유형이 Secret Text인지 Secret File인지 구분했는가
- Credential scope가 필요한 Job과 Folder로 제한됐는가
- Pipeline을 수정할 수 있는 사용자가 제한됐는가
- console masking을 보안 경계로 오해하지 않았는가
- 임시 파일과 workspace 흔적을 정리하는가
원격 설치와 기동
- Secret 디렉터리와 파일 권한을 고정했는가
- release artifact와 Secret 경로가 분리됐는가
- 시작 스크립트가 같은 shell에서
source와exec를 수행하는가 - 필수 변수 이름과 빈 값을 검사하는가
- 실패 시 기존 release로 돌아갈 rollback 경로가 있는가
변경과 배포
- 기존 Job 대신 비활성 복제 Job에서 먼저 검토했는가
- 현재 설정 backup과 digest가 있는가
- sanitized diff와 저장 후 read-back을 확인했는가
- 설정 저장, build, deploy, restart를 별도 동작으로 취급했는가
- SCM 인증과 비공개 dependency 인증을 각각 검증했는가
런타임 검증
- 전체 배포 Job과 서버 대상 목록을 확인했는가
- HA 서버별 Secret owner, mode, digest가 일치하는가
- 프로세스의 실제 환경변수 이름과 빈 값을 확인했는가
- PID, CWD, listener, health가 정상인가
- 서버 간 실행 binary SHA가 같은가
- 최근 기동 로그에 설정 누락, panic, fatal이 없는가
배포 구조의 완성 조건
이번 작업 전에는 Jenkins의 초록색 결과를 보면 배포가 끝났다고 생각하기 쉬웠다. 하지만 Secret File을 실제 프로세스까지 연결하면서 배포 구조의 완료 조건이 훨씬 구체적으로 보이기 시작했다.
Credential이 등록되어 있어도 Job에 연결되지 않으면 사용할 수 없다. Job에 연결되어 있어도 서버에 설치되지 않으면 애플리케이션이 읽을 수 없다. 서버에 파일이 있어도 시작 스크립트가 source하지 않으면 프로세스에는 전달되지 않는다. 프로세스가 실행 중이어도 이전 binary이거나 한쪽 서버만 반영됐을 수 있다.
결국 완료를 판단하려면 저장소, Jenkins, 원격 파일, 시작 스크립트와 실행 프로세스를 하나의 증거 사슬로 봐야 했다.
SCM에 비밀값 없음
↓
Jenkins Credential 최소 범위
↓
Secret File 임시 바인딩
↓
서버 보호 설치
↓
프로세스 환경변수 주입
↓
HA 런타임 검증
Jenkins의 기능 하나를 적용한 것이 아니라, 그 사이에 비어 있던 경계를 확인하고 실패하면 멈출 수 있도록 연결한 작업에 가까웠다. 다음에 비슷한 설정 변경을 맡게 되면 코드보다 먼저 전체 Job과 배포 대상을 그리고, 마지막에는 Jenkins SUCCESS가 아니라 실제 실행 중인 프로세스를 확인할 생각이다.