Karpenter가 노드를 정리할 때 앱의 마지막 Pod까지 쫓아내던 문제를 dev에서 PDB 설정으로 막았다. 그 이야기는 앱 하나를 껐는데 옆 앱이 재시작됐다에서 다뤘다. 그런데 나중에 브랜치끼리 비교해 보니 그 수정은 dev에만 있었다. Istio circuit breaker 튜닝도, HPA 개선도 마찬가지였다.
하나의 서비스를 여러 고객사가 각자 쓰도록 바꾸는 멀티 테넌트 SaaS 전환 프로젝트였다. 테넌트를 만들 때 쓰는 매니페스트 템플릿 레포(saas-app)는 dev, stg, prd 브랜치로 나뉘어 있었고, 각 브랜치가 각 환경의 배포에 쓰였다. dev에서 고친 것을 다른 환경으로 옮기려면 사람이 cherry-pick을 해야 했고, 사람은 잊는다. 환경이 셋이니 브랜치도 셋인 게 자연스러워 보였는데, 브랜치 사이의 실제 차이를 하나씩 분류해 보고 나서 생각이 바뀌었다. 브랜치가 갈라진 이유는 환경마다 코드가 달라서가 아니라, 값을 변수로 빼지 않아서였다.
175줄 차이 중 의미 있는 건 10줄이 안 됐다
먼저 브랜치 간 차이를 떴다. 파일 하나를 열어 보니 175줄이 다르다고 나오는데, 실제로 의미 있는 차이는 10줄도 안 됐다. dev는 줄바꿈이 LF, stg는 CRLF였다.
git diff dev stg # 줄바꿈 차이까지 전부 변경으로 잡힌다
git diff -w dev stg # 공백 차이를 무시하면 확 줄어든다
-w로 실제 차이를 확인하고, 레포에 .gitattributes(* text=auto eol=lf와 확장자별 eol=lf)를 넣어 전체를 LF로 맞췄다. git add --renormalize .로 기존 파일까지 다시 정규화하고, 내용은 그대로이고 줄바꿈만 바뀐 커밋을 따로 하나 남겼다.
줄바꿈을 걷어내고 남은 차이는 네 종류였다.
| 종류 | 실제로 있던 것 | 처리 |
|---|---|---|
| 값 차이 | 이미지 저장소 경로 접두어(dev/stg/prd), ConfigMap의 env 값, EFS host-aliases IP | 변수로 뺀다 |
| 구조 차이 | stg/prd에만 있는 virtual-service-use-api-gateway.yaml, dev에만 있는 ExternalSecret | 이번 범위 밖 |
| drift | dev에서만 고친 PDB(Karpenter 대응), Istio circuit breaker 튜닝, HPA 개선 | 형상을 합치면 자연히 해소 |
| 상태 차이 | appset 템플릿에서 어떤 앱이 주석 처리돼 있는가 | 템플릿이 아니라 데이터. 온보딩 파이프라인이 정한다 |
환경마다 리소스 구성 자체가 다른 진짜 구조 차이는 두 건뿐이었고, 그중 stg/prd 전용 파일은 나중에 보니 파이프라인이 참조하지 않는 고아 파일이었다. 값을 변수로 빼지 못하니 환경마다 사본이 필요했고, 사본이 셋이 되자 dev에서 고친 것이 나머지 둘로 흘러가지 않았다.
프로파일 파일을 전부 Git에 두었다
매니페스트는 하나로 두고 환경값은 따로 둔다는 방향은 금방 정해졌다. 어디에 둘지는 세 번 골랐다. 첫 안은 매니페스트에 dev를 그대로 두고 스크립트가 그 문자열을 표식 삼아 치환하는 방식이었는데, dev가 “환경 접두어가 들어갈 자리”라는 뜻이 코드 어디에도 남지 않는다. 같이 일하던 동료가 이 지점을 짚었고, 맞는 지적이라 접었다. 두 번째 안은 변수를 명시하고 환경값 파일은 gitignore하는 방식이었다. 그러면 새 환경을 만들 때 어떤 키를 채워야 하는지도 같이 사라진다. 따져 보니 프로파일에 들어갈 값은 레지스트리 주소, EFS IP 같은 인프라 설정값이었고, 비밀번호와 API 키는 원래 Secrets Manager에 있었다. 숨겨야 할 값이 애초에 없었다.
그래서 동료가 제안한 대로 profile-dev.yaml, profile-stg.yaml, profile-prd.yaml을 한 브랜치에 같이 두고 전부 Git으로 관리했다. Spring Boot의 application-{profile}.yml과 같은 발상이다. 환경 간 차이는 git diff profile-dev.yaml profile-stg.yaml 한 줄로 보이고, 환경 설정 변경도 일반 PR 리뷰에 들어온다.
# profiles/profile-dev.yaml
IMAGE_REGISTRY_PREFIX: "dev"
ENV_NAME: "dev"
REGISTRY_URL: "registry.dev.example.com"
EFS_FILESYSTEM_ID: "fs-0123456789abcdef0"
EFS_PRIMARY_IP: "10.0.1.10"
EFS_SECONDARY_IP: "10.0.2.10"
매니페스트 쪽은 박혀 있던 값을 변수로 바꿨다.
# ❌ 전: dev 브랜치에만 맞는 경로
image: "${REGISTRY_URL}/repository/saas/dev/hmpg:1.0.0-RELEASE"
# ✓ 후: 환경 접두어를 프로파일에서 받는다
image: "${REGISTRY_URL}/repository/saas/${IMAGE_REGISTRY_PREFIX}/hmpg:1.0.0-RELEASE"
온보딩 파이프라인은 원래 매니페스트의 ${...}를 envsubst로 채우고 있었다. 그러니 프로파일의 키를 환경변수로 올려 두기만 하면 나머지는 기존 흐름이 처리한다. 로더는 그 일만 한다.
PROFILE_ENV="${ENV:-${GIT_REPO_BRANCH}}"
PROFILE_FILE="${TEMPLATE_TEMP_DIR}/profiles/profile-${PROFILE_ENV}.yaml"
# 프로파일이 있을 때만 읽는다. 없으면 아무것도 하지 않고 기존 방식대로 간다
if [ -n "${PROFILE_ENV}" ] && [ -f "$PROFILE_FILE" ]; then
while IFS= read -r line; do
[ -z "$line" ] && continue
eval "export $line"
# yq가 키마다 KEY='value' 한 줄을 만든다(@sh로 따옴표 처리)
done < <(yq eval 'to_entries | .[] | .key + "=" + (.value | @sh)' "$PROFILE_FILE")
fi
프로파일 파일이 없으면 로더는 조용히 빠진다. 전환 중에는 프로파일이 있는 새 형상과 없는 옛 브랜치가 한동안 같이 존재해서, 새 로직이 옛 입력을 만나면 옛 방식으로 동작하게 해 두었다.
profile-template.yaml에는 값 없이 키만 적고, 각 환경 프로파일의 키 집합이 템플릿과 같은지 검사하는 CI 단계를 만들어 두었다(아직 기본 브랜치에는 합치지 않았다). dev 프로파일에만 키를 추가하고 prd에서 빠뜨리는 실수를 사람 주의력 대신 파이프라인이 잡게 하려는 것이다.
다 끝났다고 생각한 뒤에 찾은 경로들
매니페스트를 변수화하자 먼저 컨트롤 플레인(CP) 웹 화면이 깨졌다. CP 화면은 GitLab의 매니페스트에서 image: 값을 문자열로 읽어 레지스트리의 실제 이미지와 대조하는데, repository/saas/${IMAGE_REGISTRY_PREFIX}/auth와 repository/saas/dev/auth가 맞을 리 없으니 화면에 앱이 하나도 안 보였다. 처음에는 매칭 키를 모듈 이름만으로 바꾸려 했지만, 화면에 경로가 정확히 표시돼야 한다는 동료의 지적을 듣고 접었다. CP API가 자기 환경의 프로파일을 GitLab API로 한 번 더 읽어 변수를 풀게 했다. 프로파일이 없으면 빈 값을 돌려줘 옛 매니페스트가 그대로 동작한다.
dev에 반영한 뒤 온보딩·앱 변경·삭제 스크립트를 처음부터 다시 따라가 보니 두 군데가 더 나왔다. EFS 주소를 Pod의 /etc/hosts에 넣는 host-aliases.yaml은 envsubst를 거치지 않고 yq가 원본 파일을 직접 읽어 병합하고 있었다. IP가 고정값이던 시절엔 문제가 없던 경로다. 이대로 배포됐다면 ip: "${EFS_PRIMARY_IP}"라는 문자열이 그대로 들어가 전체 Pod의 스토리지 마운트가 실패했을 것이다.
# ❌ 전: 원본 파일을 yq가 바로 읽는다. 변수가 안 풀린다
yq eval '
.spec.template.spec += load("/dev/stdin")
' -i "$APP_YAML".tmp < <(yq eval '.' ${COMMON_DIR}/host-aliases.yaml)
# 후: yq에 넘기기 전에 envsubst를 한 번 거친다
yq eval '
.spec.template.spec += load("/dev/stdin")
' -i "$APP_YAML".tmp < <(envsubst < ${COMMON_DIR}/host-aliases.yaml | yq eval '.')
다른 하나는 로더였다. 신규 테넌트를 만드는 onboarding-v6.sh에만 넣고, 앱을 켜고 끌 때 도는 app-update.sh에는 빠뜨렸다. 이 상태로 앱을 추가하면 envsubst가 ${IMAGE_REGISTRY_PREFIX}, ${ENV_NAME}, ${EFS_*}를 전부 빈 문자열로 바꾼다. 이미지 경로가 saas//auth처럼 깨지고 새 앱은 뜨지 않는다. 같은 로더 블록을 app-update.sh에도 넣었다. 삭제 계열 스크립트는 처음부터 envsubst를 쓰지 않아 무관했다.
브랜치는 남았지만 같은 커밋을 가리킨다
검증은 로컬에서 할 수 있는 범위까지 했다. 세 환경 프로파일로 각각 envsubst 치환을 돌려 보고, host-aliases 병합 흐름, CP API의 변수 해석 함수, 두 스크립트의 로더를 시뮬레이션했다. Terraform plan과 실제 클러스터 통합 검증은 이 단계에서 하지 못했다.
그다음 기존 브랜치는 백업 브랜치로 남겨 두고, stg는 5월 26일, prd는 6월 16일에 dev의 커밋으로 옮겼다. 그 뒤로 세 브랜치는 dev → stg → prd 순서의 fast-forward로만 움직이고, 지금은 셋이 같은 커밋을 가리킨다. 브랜치 이름은 이제 “어느 환경까지 올라갔는가”를 나타내는 표시다. dev에만 있던 PDB, circuit breaker, HPA 수정은 이 정렬과 함께 stg와 prd에 들어갔고, stg/prd 전용이던 고아 파일도 그때 같이 빠졌다.