기존 환경의 배포 스크립트는 열 줄 남짓이었다. 그중 Pod를 다루는 부분은 이렇다.
kubectl delete deployment $SERVICE_NM -n saas
sleep 2
kubectl apply -f $DEPLOY_PATH/latest_yaml/$SERVICE_NM.yaml -n saas
Jenkins가 관리 서버에 SSH로 들어가 이 스크립트를 실행했다. 떠 있던 Pod를 지우고 2초 쉰 뒤 새 YAML을 적용한다. 환경이 한 벌일 때는 이걸로 돌아갔다. 이번 프로젝트는 하나의 서비스를 여러 고객사가 각자 쓰도록 바꾸는 멀티 테넌트 SaaS 전환이었고, 목표는 고객사가 계약하면 운영자가 관리 화면에서 버튼을 누르고 몇 분 뒤 그 고객사 전용 환경이 뜨는 것이었다. 이 스크립트를 고객사 수만큼 복사하는 길은 처음부터 없었다.
테넌트를 만드는 일은 명령이 아니라 기록이어야 했다. 관리 화면은 무엇을 실행하라고 시키지 않고 “이 테넌트가 이런 모양이어야 한다”는 레코드 한 건만 쓴다. 그 레코드가 EventBridge, Step Functions, CodeBuild, Git을 거쳐 ArgoCD에 닿으면 환경이 뜬다. 이 글은 그 길을 처음부터 끝까지 따라가면서 길목마다 무엇을 고르고 버렸는지 적는다. 이 프로젝트에서 쓴 글들의 출발점이기도 하다.
클러스터 상태가 마지막에 실행한 스크립트의 결과로만 남아 있었다
전환 전 배포 흐름은 이랬다.
Jenkins가 소스를 빌드해 컨테이너 이미지를 만들고 사내 레지스트리에 올린다.
Jenkins가 관리 서버에 SSH로 접속해
deploy.sh [서비스명] [빌드번호]를 실행한다.deploy.sh는 베이스 YAML을 복사해BUILD_NO문자열을sed로 빌드 번호로 바꾸고,kubectl delete deployment후kubectl apply를 실행한다.
여기에 세 가지가 걸렸다.
| 문제 | 설명 |
|---|---|
| 배포 중 끊김 | 기존 Pod를 먼저 지우고 새로 띄우므로 그 사이 서비스가 멈춘다 |
| 상태 추적 불가 | 무엇이 떠 있어야 맞는지 적힌 곳이 없다. 관리 서버의 latest_yaml/이 그나마 흔적이다 |
| 복제 불가 | 고객사가 늘 때마다 사람이 같은 작업을 반복해야 한다 |
끊김은 예전에 EC2 서버를 LB에서 하나씩 빼는 방식으로 다룬 적이 있는 문제다. 이번에는 Kubernetes 위라 Argo Rollouts의 Blue-Green으로 풀었다. 더 큰 문제는 나머지 둘이었다. 명령형 배포에서는 클러스터의 “정답”이 어디에도 적혀 있지 않다. 정답이 없으니 같은 환경을 하나 더 찍어 낼 수도 없다.
그래서 미션을 네 줄로 정했다.
고객사(테넌트)마다 서로 영향을 주지 않는 환경을 제공한다.
테넌트 생성, 변경, 삭제를 화면 조작만으로 끝낸다.
클러스터 상태를 Git에 적힌 내용과 항상 일치시킨다(GitOps).
배포 중에도 서비스가 끊기지 않는다.
3번의 기본 구조는 Git 레포를 ArgoCD가 바라보게 하는 방식이다. 이번에 새로 풀어야 했던 건 그 Git 레포를 누가, 언제, 무엇으로 채우는가였다.
격리는 네임스페이스로, 노드 분리는 티어로 고르게 했다
먼저 테넌트 하나를 무엇으로 자를지 정해야 했다.
| 방식 | 격리 수준 | 비용 | 채택 |
|---|---|---|---|
| 테넌트마다 클러스터 | 가장 강함 | 매우 높음 | |
| 테넌트마다 네임스페이스 + 전용 노드 그룹(티어) | 중간 | 중간 | ✓ |
| 네임스페이스만 분리(노드 공유) | 약함 | 낮음 |
클러스터를 통째로 주는 방식은 격리는 확실하지만 EKS 컨트롤 플레인과 공용 인프라를 고객사마다 하나씩 들고 가야 한다. 네임스페이스만 나누면 싸지만, 한 고객사의 Pod가 노드 자원을 다 먹으면 옆 고객사가 같이 느려진다.
우리는 가운데를 골랐다. 기본 단위는 네임스페이스이고, 필요하면 티어(전용 노드 그룹)로 노드까지 나눌 수 있다. 티어는 Karpenter NodePool 한 개에 대응한다. 노드에 tier: <이름> 라벨과 taint가 붙어 있어서 다른 테넌트의 Pod는 그 노드에 들어오지 못한다. 격리 수준은 테넌트를 만들 때 고르는 옵션이고, 처음부터 모든 테넌트에 가장 강한 격리를 강제하지 않는다.
비싸고 오래 걸리는 것과 자주 바뀌는 것을 다른 계층에 뒀다
테넌트 위에는 계층이 두 개 더 있다.
flowchart TD
AG["앱그룹<br/>어떤 앱을 제공할지 + 이미지 저장소"]
AP1["앱플레인 A<br/>EKS 클러스터 + 공용 인프라"]
AP2["앱플레인 B"]
T1["테넌트 tenant-a<br/>네임스페이스"]
T2["테넌트 tenant-b<br/>네임스페이스"]
T3["테넌트 tenant-c<br/>네임스페이스"]
AG --> AP1
AG --> AP2
AP1 --> T1
AP1 --> T2
AP2 --> T3앱그룹은 어떤 앱을 제공할지를 상품 단위로 정한다. 위로 올려 두면 한 번 바꿔서 여러 테넌트에 반영할 수 있다.
앱플레인은 클러스터, VPC, 게이트웨이처럼 만드는 데 오래 걸리고 비싼 자원이다. 테넌트와 떼어 내 여러 테넌트가 재사용한다.
테넌트는 가장 자주 생기고 없어지는 단위라 가볍게 유지한다.
나누는 기준은 하나다. 생성 비용과 변경 빈도가 다른 것은 같은 계층에 두지 않는다. 테넌트를 하나 만들 때마다 VPC부터 다시 만든다면 “버튼 한 번에 몇 분 뒤”는 불가능하다. 앱플레인을 미리 깔아 두었기 때문에 테넌트 생성은 네임스페이스 하나와 그 안의 매니페스트만 만들면 끝나는 일이 된다.
앱 목록을 실제로 앱그룹에서 정하게 된 건 서비스 카탈로그를 만들면서다. 그 전까지는 테넌트를 만들 때마다 담당자가 템플릿에서 앱 폴더를 손으로 지웠다. 목록을 앱그룹 토글로 올리고, 같은 앱 목록이 세 곳에 저장돼 있던 것을 정리한 과정은 앱 목록을 정하는 자리를 한 계층 위로 옮겼다에 있다.
템플릿과 결과물을 다른 레포에 뒀다
레포도 세 개로 나눴다.
| 레포 | 담는 것 | 누가 바꾸나 |
|---|---|---|
saas-framework | Terraform, 파이프라인 스크립트, 관리 화면(CP) 웹앱 | 플랫폼 개발자 |
saas-app | 앱 매니페스트 템플릿(${TENANT_ID} 같은 변수 포함) | 플랫폼 개발자, 앱 담당자 |
saas-tenants | 변수가 채워진 테넌트별 실제 매니페스트 | 파이프라인(자동), 운영자 |
ArgoCD는 이 중 saas-tenants만 본다. 템플릿을 고쳐도 파이프라인이 다시 돌기 전까지 운영 중인 테넌트는 바뀌지 않는다. 템플릿 하나를 ArgoCD가 직접 보게 하고 테넌트별 값만 따로 넘기는 구성도 생각할 수 있지만, 그러면 템플릿의 커밋 하나가 모든 테넌트에 동시에 퍼진다. 결과물을 별도 레포에 두면 saas-tenants의 diff가 곧 “이 테넌트에 실제로 무엇이 바뀌었나”의 기록이 된다. 전환 전에 없던 “정답이 적힌 곳”이 여기다.
관리 화면은 파이프라인을 부르지 않고 레코드를 쓴다
이제 버튼을 누른 순간부터 따라가 본다. 전체 그림은 이렇다.
flowchart TB
subgraph CPZ["Control Plane (관리 영역)"]
UI["CP 웹 화면<br/>SvelteKit"]
API["CP API<br/>FastAPI on Lambda"]
DDB[("DynamoDB<br/>단일 테이블")]
SM[("Secrets Manager")]
EB["EventBridge"]
SF["Step Functions"]
CB["CodeBuild"]
end
subgraph GIT["Git"]
TPL[("saas-app<br/>템플릿")]
TEN[("saas-tenants<br/>실제 매니페스트")]
end
subgraph APZ["Application Plane (EKS 클러스터)"]
ARGO["ArgoCD"]
NS1["tenant-a 네임스페이스"]
NS2["tenant-b 네임스페이스"]
end
UI --> API
API --> DDB
API --> SM
DDB -.->|"status=READY 레코드 감지"| EB
EB --> SF --> CB
SM -.->|"설정값 주입"| CB
TPL -->|"clone"| CB
CB -->|"변수 치환 후 push"| TEN
CB -->|"Terraform apply"| APZ
TEN -.->|"감시"| ARGO
ARGO --> NS1
ARGO --> NS2CP API는 파이프라인을 직접 호출하지 않는다. DynamoDB에 레코드를 쓰기만 한다.
PK = TENANT#tenant-a
SK = ONBOARDING#...
record_type = CURRENT
action = onboarding
status = READY # 이 값이 트리거
tenant_id = tenant-a
app_plane_id = ap-dev
tier = standard
change_spec = (비어 있으면 전체 온보딩)
레코드를 새로 쓸 때는 기존 CURRENT 레코드를 HISTORY로 바꾸고 새 CURRENT를 만든다. 트리거를 레코드 상태 하나로 모으면 변경 이력은 덤으로 생긴다. 누가 언제 어떤 사양으로 테넌트를 바꿨는지가 테이블에 그대로 남는다. API가 CodeBuild를 바로 호출하는 방식이었다면 이력은 따로 쌓아야 했고, 호출은 실패했는데 기록은 성공으로 남는 식의 어긋남까지 막아야 했다.
레코드에서 Step Functions까지
“EventBridge가 레코드를 감지한다”를 코드로 풀면 몇 단계가 더 있다. DynamoDB Stream을 EventBridge Pipe가 읽고, 필터에 걸린 이벤트만 이벤트 버스로 보낸다.
filters = [
{
pattern_json = jsonencode({
dynamodb = {
NewImage = {
action = { S = ["onboarding", "offboarding"] }
# READY만이 아니라 RETRY도 트리거다
status = { S = ["READY", "RETRY"] }
}
}
})
}
]
이벤트 버스의 규칙이 이벤트를 SQS 큐에 넣는다. 큐를 받는 Lambda(onboarding-event-handler)가 Step Functions를 시작하고, 곧바로 레코드의 status를 IN-PROCESS로 바꾼다.
Lambda가 정하는 이름 규칙: 최상위 키를 대문자로
CodeBuild에 넘길 환경변수 목록은 이 Lambda의 아래 코드가 만든다.
environment_variables = []
for name, value in normalized.items():
# 모든 값을 문자열로 바꾼다
if isinstance(value, bool):
string_value = "1" if value else "0"
elif isinstance(value, (list, dict, set)):
string_value = json.dumps(value)
else:
string_value = str(value)
# 키 이름은 대문자로. 이것이 CodeBuild 환경변수 이름이 된다
environment_variables.append({"Name": name.upper(), "Value": string_value, "Type": "PLAINTEXT"})
레코드의 최상위 키가 전부 대문자 환경변수가 되어 CodeBuild로 넘어간다. tenant_id는 TENANT_ID, change_spec은 CHANGE_SPEC, PK와 SK도 그대로 환경변수가 된다. 불리언은 "1"/"0", 리스트와 딕셔너리는 JSON 문자열이다. buildspec에 [ "${USE_NATS}" = "1" ]처럼 문자열 "1"과 비교하는 코드가 나오는 것도 이 때문이다.
이 규칙을 알면 스크립트의 변수가 어디서 오는지 추적할 수 있다. 반대로 레코드에 없는 필드는 스크립트에서 그냥 빈 값이다. 테넌트 빌드에서 ENV가 늘 비어 있던 이유를 이 규칙을 거꾸로 따라가 찾은 일은 buildspec에 없는 환경변수는 어디서 왔을까에 적었다.
buildspec 한 파일이 CHANGE_SPEC으로 길을 나눈다
Step Functions는 레코드가 무엇을 만들려는지에 따라 길을 고른다.
flowchart TD
S["레코드 감지"] --> C{"무엇을 만드나?"}
C -->|"앱플레인"| N["네트워크"]
N --> P["피어링"]
P --> K["컴퓨팅(EKS)"]
K --> G["노드 그룹 + 게이트웨이<br/>(병렬)"]
C -->|"테넌트"| T["테넌트 스택<br/>buildspec.tenant.yml"]
C -->|"티어 변경"| TC["티어 변경 스택"]
G --> OK["성공 처리 Lambda<br/>status 갱신"]
T --> OK
TC --> OK
G -.->|"실패"| ERR["실패 처리 Lambda"]
T -.->|"실패"| ERR앱플레인은 네트워크부터 클러스터까지 단계가 많아 오래 걸리지만, 테넌트는 CodeBuild 한 번이면 끝난다. 앞에서 계층을 나눈 효과가 여기서 시간으로 드러난다.
테넌트 CodeBuild는 buildspec.tenant.yml 한 파일로 생성, 변경, 삭제를 모두 처리한다. 처음에는 “테넌트 생성 = Terraform apply 후 전체 온보딩 스크립트” 한 길뿐이었다. 그런데 운영을 시작하면 테넌트를 새로 만드는 일보다 이미 있는 테넌트의 앱 하나를 켜고 끄거나 설정값 하나를 바꾸는 일이 훨씬 잦다. 이런 변경에는 AWS 인프라 변경이 없다.
그래서 레코드의 change_spec, 즉 CHANGE_SPEC 환경변수로 변경 종류를 구분해 Terraform을 건너뛰는 경량 경로를 넣었다.
# Terraform 단계: 경량 변경이면 통째로 건너뛴다
if [ "${ACTION}" = "onboarding" ] && echo "${CHANGE_SPEC:-}" | grep -qE "^(app-images:|tier-secret:)"; then
echo "경량 변경 모드(${CHANGE_SPEC}): Terraform 단계를 건너뜁니다."
else
terraform init ...
terraform workspace select -or-create $TENANT_ID
# plan → apply (offboarding이면 plan -destroy)
fi
# 스크립트 단계: 변경 종류마다 필요한 스크립트만 실행한다
if [ "${ACTION}" = "onboarding" ]; then
if echo "${CHANGE_SPEC:-}" | grep -q "^app-images:"; then
./app-update.sh || exit $? # 앱 켜고 끄기
elif echo "${CHANGE_SPEC:-}" | grep -q "^tier-secret:update:"; then
./tier-secret-update.sh || exit $? # 보안설정 값 변경
elif echo "${CHANGE_SPEC:-}" | grep -q "^tier-secret:restructure:"; then
./tier-secret-restructure.sh || exit $? # 보안설정 구조 변경
else
./onboarding-v6.sh || exit $? # 전체 생성
fi
elif [ "${ACTION}" = "offboarding" ]; then
./offboarding-v4.sh || exit $?
fi
변경 종류마다 Step Functions와 CodeBuild 프로젝트를 따로 만드는 방법도 있었지만, 그러면 트리거가 여러 갈래가 되고 “레코드 한 건이 길을 정한다”는 구조가 깨진다. 레코드에 문자열 하나를 더 실어 보내는 쪽이 이력도, 권한도, 로그 위치도 그대로 둘 수 있었다.
Terraform은 workspace = 테넌트 ID로 state를 테넌트마다 나눈다. 삭제는 생성의 역순이다. buildspec이 Terraform을 plan -destroy까지만 해 두고, offboarding-v4.sh로 Kubernetes 리소스를 먼저 지운 다음에야 apply로 AWS 리소스를 지운다.
템플릿이 결과물이 되는 여덟 단계
전체 생성 스크립트 onboarding-v6.sh는 877줄이고 아래 순서로 동작한다.
| 단계 | 하는 일 | 예시 |
|---|---|---|
| 1 | 환경별 프로파일을 읽어 환경변수로 등록 | IMAGE_REGISTRY_PREFIX=dev |
| 2 | saas-app 템플릿을 clone | tenants/<프로젝트>/ |
| 3 | 앱그룹에서 꺼 둔 앱의 디렉토리 삭제 | is_use=false인 앱 제거 |
| 4 | envsubst로 변수 치환 | ${TENANT_ID} → tenant-a |
| 5 | yq로 공통 조각 병합 | 라벨, toleration 등 |
| 6 | 보안설정을 앱별 Secret으로 생성 | DB 접속정보 등 |
| 7 | saas-tenants에 commit, push | tenants/tenant-a/ |
| 8 | ArgoCD Project와 ApplicationSet 생성 | argocd appset create --upsert |
치환 전후를 나란히 놓으면 템플릿과 결과물을 왜 나눴는지가 보인다.
# saas-app (템플릿)
metadata:
name: ${TENANT_ID}-${APP}
namespace: ${TENANT_ID}
spec:
template:
spec:
containers:
- image: "${REGISTRY_URL}/repository/saas/${IMAGE_REGISTRY_PREFIX}/auth:1.0.0-RELEASE"
# saas-tenants (결과물)
metadata:
name: tenant-a-auth-back
namespace: tenant-a
spec:
template:
spec:
containers:
- image: "registry.example.com/repository/saas/dev/auth:1.0.0-RELEASE"
1단계의 환경별 프로파일은 원래 dev/stg/prd 브랜치로 갈라져 있던 템플릿을 한 형상과 프로파일 파일로 합친 결과다. 6단계에서 넣은 DB 접속정보가 소스를 고칠 수 없는 Spring 앱의 application-dev.yml을 이기게 만든 과정은 SPRING_APPLICATION_JSON 글에 따로 있다.
ApplicationSet은 앱 디렉토리 단위로 본다
마지막 8단계에서 테넌트마다 ApplicationSet이 한 개 생긴다. 안에 앱 목록이 있고, ArgoCD는 목록의 항목마다 Application을 만든다.
kind: ApplicationSet
metadata:
name: tenant-a
spec:
generators:
- matrix:
generators:
- list:
elements:
- cluster: ap-dev-eks
- list:
elements:
# 앱 하나 = Application 하나 = 디렉토리 하나
- name: tenant-a-auth-back
path: tenants/tenant-a/apps/auth-back
- name: tenant-a-mo-back
path: tenants/tenant-a/apps/mo-back
각 Application은 자기 디렉토리(apps/auth-back)만 감시한다. 한 앱의 매니페스트만 바뀌면 그 앱만 다시 배포된다. 테넌트 전체를 Application 하나로 묶었다면, 앱 하나의 설정을 바꿔도 테넌트 전체가 sync 대상이 된다. 앞의 경량 경로가 의미를 갖는 것도 이 성질 덕분이다. tier-secret-update.sh가 값이 바뀐 앱의 디렉토리만 고쳐서 push하면 ArgoCD는 그 앱만 다시 띄운다.
앱 개발자는 이 길을 거치지 않는다. Jenkins가 새 이미지를 레지스트리에 올리면 ArgoCD Image Updater가 감지해 saas-tenants의 이미지 정보를 갱신하고, ArgoCD가 Blue-Green으로 교체한다.
sequenceDiagram
participant J as Jenkins
participant R as 이미지 레지스트리
participant IU as Image Updater
participant G as saas-tenants
participant A as ArgoCD
participant K as 클러스터
J->>R: 새 이미지 push
IU->>R: 주기적으로 확인
IU->>G: 이미지 정보 갱신 commit
A->>G: 변경 감지
A->>K: 새 버전 Pod 기동 (preview)
K-->>A: Ready
A->>K: 트래픽 전환 후 구버전 종료Jenkins는 남았지만 역할이 바뀌었다. 전에는 빌드부터 클러스터 조작까지 다 했다면, 지금은 이미지를 올리는 데서 끝난다. 클러스터를 바꾸는 주체는 Git의 내용을 보는 ArgoCD 하나다.
만들면서 걸린 것들
길 자체보다 길목에서 더 오래 멈췄다. 짧게 모은다.
| 이슈 | 내용 | 대응 |
|---|---|---|
| CodeBuild 기본 셸은 POSIX sh | buildspec 명령에서 [[ ]]를 쓰면 실패한다 | [ ]를 쓰거나 echo를 grep -q로 넘기는 패턴을 쓴다 |
| FastAPI 라우트 순서 | /apps/{app_id}를 /apps/image-config보다 먼저 선언하면 정적 경로가 {app_id}로 잡힌다 | 정적 경로를 먼저 선언한다 |
| 같은 데이터가 여러 곳에 있음 | 앱 목록이 DynamoDB, Secrets Manager, 앱플레인 레코드에 각각 있었다 | 원본 한 곳을 정하고 나머지는 사본으로 취급한다 |
| 전체 온보딩의 부작용 | onboarding-v6.sh는 테넌트 디렉토리를 통째로 다시 만든다. 운영자가 saas-tenants에서 직접 고친 내용이 사라진다 | 작은 변경은 경량 경로로 보낸다 |
| 최초 배포가 두 번 일어남 | 템플릿의 기본 태그로 한 번 뜨고, Image Updater가 최신 이미지로 한 번 더 배포한다 | 결과는 맞는 배포라 감수하고, 노드 정리 대기 시간에 이 몇 분을 셈에 넣었다 |
위 buildspec 코드의 분기가 전부 echo ... | grep -q인 이유가 첫 줄이다.
전체 온보딩은 여전히 무겁다
경량 경로를 만들었다고 전체 온보딩의 성질이 바뀐 건 아니다. onboarding-v6.sh는 지금도 saas-tenants의 테넌트 디렉토리를 rm -rf로 지우고 템플릿에서 다시 복사한다.
# 기존 테넌트 디렉터리가 존재할 경우, 삭제 후 복사하며 재생성
if [ -d "${TENANT_REPO_PATH}" ]; then
rm -rf "${TENANT_REPO_PATH}"
fi
cp -r "${TEMPLATE_TENANT_REPO_PATH}" "${TENANT_REPO_PATH}"
템플릿이 정답이라는 원칙에는 맞는 동작이다. 대신 결과물 레포에만 있던 정보는 전부 날아간다. Image Updater가 kustomization.yaml에 써 둔 이미지 핀도 그중 하나였다. 핀이 사라지면 바뀌지 않은 앱까지 베이스 태그로 돌아가 전 앱이 재배포된다. 지금은 지우기 전에 앱별 핀을 스냅샷해 두었다가 다시 주입하도록 스크립트에 보존 단계를 넣었다. 하지만 이건 핀 하나를 위한 예외 처리고, “결과물 레포에만 있는 값”이 하나 생길 때마다 이런 예외가 하나씩 늘어난다. 작은 변경을 경량 경로로 보내는 것이 아직은 가장 확실한 방어선이다.
정리
명령형 배포에서는 클러스터에 무엇이 떠 있어야 맞는지 적힌 곳이 없었다. 지금은
saas-tenants의 커밋이 그 기록이고, ArgoCD는 그것만 본다.격리는 네임스페이스를 기본으로 하고, 노드 분리는 티어(Karpenter NodePool)로 고른다.
앱플레인을 미리 깔아 두어 테넌트 생성이 CodeBuild 한 번으로 끝난다.
관리 화면은 레코드 한 건만 쓰고, 그 레코드의 최상위 키가 대문자 환경변수가 되어 스크립트까지 내려간다. 잦은 작은 변경은
CHANGE_SPEC분기로 Terraform을 건너뛰고, 바뀐 앱만 다시 뜬다.
이 구조 위에서 생긴 일들은 이어지는 글에서 하나씩 다룬다.