처음 설계도에서 비밀값은 이렇게 흘렀다. 관리 화면에 넣은 설정 JSON이 AWS Secrets Manager에 저장된다. External Secrets Operator(ESO)가 그 값을 1분마다 테넌트의 Kubernetes Secret으로 동기화하면 앱 Pod가 그 Secret을 읽는다. 두 달 뒤 이 경로에서 ESO가 빠졌다. 고장이 난 것도, 더 좋은 도구를 찾은 것도 아니었다. 앞에서 내린 결정 하나 때문에 ESO가 할 일이 없어졌다.
하나의 서비스를 여러 고객사가 각자 쓰도록 바꾸는 멀티 테넌트 SaaS 전환 프로젝트에서, 앱 소스를 고치지 않고 고객사별 DB 접속 정보 같은 설정값을 Spring Boot 앱에 넣는 기능을 만들 때의 일이다. 처음에는 Secret을 파일로 마운트해 spring.config.import로 읽게 했는데, application-dev.yml에 같은 키가 있으면 값이 바뀌지 않아서 설정 JSON을 SPRING_APPLICATION_JSON 환경변수로 넣는 쪽으로 바꿨다. 그 이유는 application-dev.yml을 이긴 건 SPRING_APPLICATION_JSON뿐이었다에 적었다. 이 글은 그 결정의 대가에서 시작한다.
1분마다 동기화해도 떠 있는 Pod는 모른다
환경변수는 컨테이너가 시작할 때 정해진다. 떠 있는 Pod는 그 뒤에 Secret이 바뀌어도 모르고, 값을 바꾸려면 Pod를 다시 띄워야 한다. Argo Rollouts Blue-Green 배포라 새 Pod가 새 값으로 뜬 뒤에 트래픽이 넘어가니, 다시 띄운다고 서비스가 멈추지는 않았다.
그렇게 놓고 보면 ESO의 1분 주기 동기화는 하는 일이 거의 없다. ESO가 Secret을 아무리 부지런히 갱신해도 새 값이 Pod에 닿으려면 누군가 Pod를 다시 띄워야 하고, 그 누군가는 배포 파이프라인이었다. 파이프라인이 Pod를 다시 띄울 거라면 Secret도 그때 만들면 된다.
두 번째 이유도 겹쳤다. 같은 키에 앱마다 다른 값을 줘야 하는 경우가 생겼는데, ESO는 Secrets Manager의 값을 통째로 Secret 하나에 복사한다. “공통 값 + 이 앱만의 값”을 조합하려면 이 테넌트에 어떤 앱이 떠 있는지 알아야 한다. 그 목록은 테넌트 저장소의 apps/ 디렉토리에 있고, 배포할 때 그 디렉토리를 clone해서 들고 있는 건 파이프라인이다.
그래서 이 경로에서 ESO를 뺐다. 파이프라인(CodeBuild)이 aws secretsmanager get-secret-value로 값을 직접 읽고 앱별 Secret을 kubectl로 만든다. 비밀값을 다루는 구간은 set +x로 감싸 로그에 찍히지 않게 했고, 파이프라인이 만든 Secret에는 라벨을 붙여 고아가 생기면 찾아 지운다. ESO 자체는 이미지 레지스트리 인증 동기화에 여전히 쓰고 있어서 클러스터에 남겼다.
잃은 것도 하나 있다. ESO 시절에는 잘못된 값으로 Pod가 CrashLoopBackOff에 빠져도 관리 화면에서 값만 고치면 ESO가 1분 안에 Secret을 갱신했고, 다음 재시작에서 Pod가 스스로 살아났다. 이제는 “즉시 적용”을 체크해 파이프라인을 돌려야 고친 값이 Secret에 들어간다.
입력은 JSON 하나, 나누는 건 파이프라인
한 테넌트에 함께 뜨는 앱들이 같은 키 datasource.pri.jdbcUrl을 쓰는데, sp-back은 db_a를, hmpg-back과 mo-back은 db_b를 봐야 했다. 계정과 비밀번호는 공통이었다. 입력은 JSON 하나로 두고 그 안에 _perApp이라는 예약 섹션을 만들었다.
{
"datasource": { "pri": { "username": "appuser", "password": "ENC(...)" } },
"_perApp": {
"sp-back": {
"datasource": { "pri": { "jdbcUrl": "jdbc:log4jdbc:mysql://db.example.com:3306/db_a" } }
},
"hmpg-back, mo-back": {
"datasource": { "pri": { "jdbcUrl": "jdbc:log4jdbc:mysql://db.example.com:3306/db_b" } }
}
}
}
_perApp 밖의 키는 모든 앱에 들어가고, 안의 키는 배포 앱 이름이다. 각 앱의 최종값은 공통 값에 자기 블록을 합친 것이고, 겹치면 자기 블록이 이긴다. 관리 화면 백엔드는 이 JSON을 해석하지 않고 그대로 저장하고, 나누는 일은 파이프라인이 jq의 *(재귀 병합) 연산자로 한다.
# 공통 = 전체에서 _perApp만 뺀 것
COMMON_JSON=$(echo "$MANIFEST_JSON" | jq -c 'del(._perApp)')
# 이 앱 이름이 들어 있는 _perApp 블록들을 찾아 순서대로 합친다
APP_OVERRIDE=$(echo "$MANIFEST_JSON" | jq -c --arg app "$APP" '
(._perApp // {}) | if type=="object" then . else {} end | to_entries
| map(select(.key | split(",") | map(gsub("^\\s+|\\s+$";"")) | index($app)))
| map(.value) | reduce .[] as $o ({}; . * $o)')
# 최종값 = 공통 deep-merge 앱 블록 (앱 블록이 이긴다)
EFFECTIVE=$(echo "$COMMON_JSON" | jq -c --argjson ov "$APP_OVERRIDE" '. * $ov')
최종값마다 tenant-a-sp-back-info 같은 앱 전용 Secret이 하나씩 생기고, 각 앱은 자기 Secret만 secretKeyRef로 참조한다.
타임스탬프 대신 최종값의 해시를 넣었다
남은 문제는 언제 Pod를 다시 띄우느냐였다. GitOps에서 그 신호는 매니페스트 diff인데, Secret 이름은 값이 바뀌어도 같으니 ArgoCD는 바뀐 게 없다고 본다. ESO 시절에는 실행할 때마다 타임스탬프를 넣어 강제로 차이를 만들었다.
# ❌ 이전: 실행할 때마다 값이 바뀌는 타임스탬프
RESTART_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
yq eval '
.spec.template.metadata.annotations."kubectl.kubernetes.io/restartedAt" = "'"${RESTART_TIME}"'"
' -i "$APP_YAML".tmp
모든 앱이 공용 Secret 하나를 읽던 때는 이걸로 충분했다. 앱별 분기가 생기자 sp-back의 jdbcUrl 하나를 고쳐도 테스트 테넌트의 앱 19개가 전부 Blue-Green을 한 바퀴 돌게 됐다. 그래서 그 앱 최종값의 sha256을 Pod 템플릿 어노테이션에 넣었다.
# ✓ 이후: 이 앱의 최종값이 바뀔 때만 바뀌는 지문
CONFIG_HASH=$(printf '%s' "$EFFECTIVE" | sha256sum | cut -d' ' -f1)
yq eval '
.spec.template.metadata.annotations."saas/tier-secret-hash" = "'"${CONFIG_HASH}"'"
' -i "$APP_YAML"
_perApp에서 sp-back 블록만 고치면 sp-back의 최종값만 달라지고, 공통 값을 고치면 모든 앱의 최종값이 달라진다. 해시가 그 범위를 그대로 따라간다. 어노테이션이 Pod 템플릿에 있으니 Rollout은 새 revision으로 Pod를 교체하고, ArgoCD ApplicationSet이 앱마다 Application을 따로 만들어 두었으니 diff가 생긴 앱의 Application만 sync된다. 해시가 그대로면 파일도 그대로라 파이프라인은 커밋 자체를 건너뛴다. Git에는 Secret 이름과 해시만 남고 비밀값은 드러나지 않는다.
테스트 테넌트에서 값 하나를 바꿨을 때 파이프라인이 남긴 커밋은 YAML 파일 하나, 한 줄 변경이었다.
- saas/tier-secret-hash: 7de041b2654f1651ef618a1269354e49643cc6b4f24368e442a81396fbd31c63
+ saas/tier-secret-hash: 39c087147c6f41ab0f0618ce2bef21a81102fe152927d49a72993c22e1d7c19d
값 하나 바꾸는 데 온보딩 전체를 돌리지 않는다
해시를 계산하는 경로는 여전히 무거웠다. 보안설정을 바꾸면 신규 테넌트를 만들 때와 같은 onboarding-v6.sh가 돌면서 템플릿 저장소에서 앱 YAML을 처음부터 다시 만들었다. 그러면 아직 배포하지 않은 템플릿 변경이 함께 나가고, 운영자가 테넌트 저장소에서 직접 고친 HPA 값 같은 것이 템플릿 값으로 되돌아간다.
그래서 관리 화면 백엔드가 변경 종류를 change_spec에 담고, buildspec이 값만 바뀐 경우 tier-secret-update.sh, 그룹 구조가 바뀐 경우 tier-secret-restructure.sh를 고르게 했다. 둘 다 템플릿을 가져오지 않고 기존 YAML에서 시크릿 관련 부분만 고친다. 처음에는 “값만 바뀌면 구조는 그대로”라고 가정했는데, 다음 날 _perApp에 블록을 새로 넣거나 지우면 그 앱에 주입 구조를 넣거나 걷어내야 한다는 게 보였다. 그 뒤로 값 변경 스크립트도 앱마다 최종값과 기존 주입 구조가 있는지를 보고 해시만 갱신할지, 구조를 넣을지, 뺄지를 정한다.
테스트 테넌트의 커밋 기록에도 그대로 보인다. 구조가 새로 들어간 실행은 앱 YAML 하나에 14줄이 추가됐고, 구조가 빠진 실행은 앱 YAML 하나에서 14줄이 빠졌다. 19개 파일이 함께 바뀐 건 모든 앱의 최종값이 달라진 실행뿐이었다.