Automated tenant onboarding in a SaaS multi-tenant EKS environment
포스팅은 CloudNet@팀 서종호(Gasida)님이 진행하시는
AWS EKS Workshop Study 내용을 참고하여 작성합니다.
이번 챕터에서는 워크숍에서 제공해준 GitOps 인프라 중
실습 3번 항목인 테넌트 온보딩/오프로딩에 대해 학습해보도록 하겠습니다.

이번 실습에서는 Argo Workflows를 활용하여 위 과정을 완전히 자동화하는 방법을 살펴볼 예정입니다.
자동화의 핵심은 SQS 메시지 하나로 전체 온보딩 파이프라인이 시작되는 Event-Driven 구조입니다.
먼저 Lab을 실습해보기에 앞서, 위 전체 구조를 이해하고 넘어가고자 합니다.
- 개발자가 마이크로서비스 소스를 커밋 → CI → Container Image 빌드
- 각 마이크로서비스는 Git repo에 Helm Chart로 패키징되어 관리됨 (버전: 0.0.1)
- AWS Cloud 안에 Infrastructure resources + Amazon EKS Cluster가 존재
💡 Argo Workflows 역할
Lab 실습 첫번째에서 수동으로 진행한 템플릿 복사 → 변수 치환 → Git commit → git push 과정을 자동화하는 것이 전부임.
Git 변경이 발생하면 그 이후는 여전히 Flux v2가 담당하여 EKS에 리소스를 배포합니다.
01. Verify Argo Workflows configuration
세 가지 워크플로우 템플릿이 사전 구성되어 있습니다.

템플릿 역할은 아래와 같습니다.
| 워크플로우 템플릿 | 역할 |
| onboarding | 새 테넌트를 환경에 프로비저닝 |
| offboarding | 테넌트를 환경에서 제거 |
| deployment | 테넌트 HelmRealse 버전 업데이트 |
전체 워크플로우 정의 파일은 다음 경로에서 확인이 가능합니다.
tree /home/ec2-user/environment/gitops-gitea-repo/control-plane/production/workflows/
/home/ec2-user/environment/gitops-gitea-repo/control-plane/production/workflows/
├── event-bus.yaml
├── kustomization.yaml
├── tenant-deployment-sensor.yaml
├── tenant-deployment-workflow-template.yaml
├── tenant-offboarding-sensor.yaml
├── tenant-offboarding-workflow-template.yaml
├── tenant-onboarding-sensor.yaml
└── tenant-onboarding-workflow-template.yaml
Check onboarding template content.
tenant-onboarding-sensor.yaml ← EventSource + Sensor (이벤트 수신 & 트리거)
tenant-onboarding-workflow-template.yaml ← WorkflowTemplate (실제 실행 로직)
Sensor가 Workflow를 호출하는 구조입니다. 역할이 명확히 분리되어 있어요. (아래 전체 yaml 코드)
[ec2-user@ip-10-0-1-207 workflows]$ cat tenant-onboarding-sensor.yaml
---
apiVersion: argoproj.io/v1alpha1
kind: EventSource
metadata:
name: aws-sqs-onboarding
namespace: argo-events
spec:
template:
serviceAccountName: argo-events-sa
sqs:
tenant-provisioning:
jsonBody: true
region: "${aws_region}"
queue: "argoworkflows-onboarding-queue"
waitTimeSeconds: 20
---
apiVersion: argoproj.io/v1alpha1
kind: Sensor
metadata:
name: aws-sqs-onboarding
namespace: argo-events
spec:
template:
serviceAccountName: argo-events-sa
dependencies:
- name: tenant-provisioning-dep
eventSourceName: aws-sqs-onboarding
eventName: tenant-provisioning
triggers:
- template:
name: tenant-onboarding-template
k8s:
operation: create
source:
resource:
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: tenant-onboarding-
namespace: argo-workflows
spec:
serviceAccountName: argoworkflows-sa
entrypoint: tenant-provisioning
synchronization:
mutex:
name: workflow
arguments:
parameters:
- name: TENANT_ID
value: "" # ID of your tenant, use this patter eg. tenant-xx (tenant-10, tenant-11)
- name: TENANT_TIER
value: "" # Valid values are: silo, pool, hybrid
- name: RELEASE_VERSION
value: "" # I.E. 0.0 or 1.0
- name: REPO_URL
value: "http://${gitea_url}:3000/admin/eks-saas-gitops.git"
- name: GIT_USER_EMAIL
value: "admin@example.com"
- name: GIT_USERNAME
value: "admin"
- name: GIT_TOKEN
value: "${gitea_token}"
- name: GIT_BRANCH
value: "main" # Can change based on your configs
templates:
- name: tenant-provisioning
steps:
- - name: clone-repository
templateRef:
name: tenant-onboarding-template
template: clone-repository
- - name: validate-if-tenant-exists
templateRef:
name: tenant-onboarding-template
template: validate-if-tenant-exists
- - name: create-tenant-helm-release
templateRef:
name: tenant-onboarding-template
template: create-tenant-helm-release
volumeClaimTemplates:
- metadata:
name: workdir
spec:
storageClassName: gp2
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 1Gi
parameters:
- src:
dependencyName: tenant-provisioning-dep
dataKey: body.tenant_id
dest: spec.arguments.parameters.0.value
- src:
dependencyName: tenant-provisioning-dep
dataKey: body.tenant_tier
dest: spec.arguments.parameters.1.value
- src:
dependencyName: tenant-provisioning-dep
dataKey: body.release_version
dest: spec.arguments.parameters.2.value
[ec2-user@ip-10-0-1-207 workflows]$ cat tenant-onboarding-workflow-template.yaml
apiVersion: argoproj.io/v1alpha1
kind: WorkflowTemplate
metadata:
name: tenant-onboarding-template
namespace: argo-workflows
spec:
serviceAccountName: argoworkflows-sa # SA with IRSA permissions
templates:
- name: validate-if-tenant-exists
container:
image: "${ecr_argoworkflow_container}:0.1"
command: ["/bin/sh","-c"]
args: ['./00-validate-tenant.sh {{workflow.parameters.TENANT_ID}}']
volumeMounts:
- name: workdir
mountPath: /mnt/vol
env:
- name: GIT_USERNAME
value: "{{workflow.parameters.GIT_USERNAME}}"
- name: GIT_TOKEN
value: "{{workflow.parameters.GIT_TOKEN}}"
- name: clone-repository
container:
image: "${ecr_argoworkflow_container}:0.1"
command: ["/bin/sh","-c"]
args: ['./01-tenant-clone-repo.sh {{workflow.parameters.REPO_URL}} {{workflow.parameters.GIT_BRANCH}} {{workflow.parameters.GIT_USERNAME}} {{workflow.parameters.GIT_TOKEN}} && cp -r eks-saas-gitops /mnt/vol/eks-saas-gitops']
volumeMounts:
- name: workdir
mountPath: /mnt/vol
env:
- name: GIT_USERNAME
value: "{{workflow.parameters.GIT_USERNAME}}"
- name: GIT_TOKEN
value: "{{workflow.parameters.GIT_TOKEN}}"
- name: create-tenant-helm-release
container:
image: "${ecr_argoworkflow_container}:0.1"
command: ["/bin/sh","-c"]
args: ['./02-tenant-onboarding.sh {{workflow.parameters.TENANT_ID}} {{workflow.parameters.RELEASE_VERSION}} {{workflow.parameters.TENANT_TIER}} {{workflow.parameters.GIT_USER_EMAIL}} {{workflow.parameters.GIT_USERNAME}} {{workflow.parameters.GIT_BRANCH}} {{workflow.parameters.GIT_TOKEN}}']
volumeMounts:
- name: workdir
mountPath: /mnt/vol
env:
- name: GIT_USERNAME
value: "{{workflow.parameters.GIT_USERNAME}}"
- name: GIT_TOKEN
value: "{{workflow.parameters.GIT_TOKEN}}"
volumeClaimTemplates:
- metadata:
name: workdir
spec:
storageClassName: gp2
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 1Gi
1) EventSource + Sensor 흐름
SQS (argoworkflows-onboarding-queue)
│ longpolling (waitTimeSeconds: 20)
▼
EventSource: aws-sqs-onboarding
│ body.tenant_id / tenant_tier / release_version 추출
▼
Sensor: aws-sqs-onboarding
│ Workflow 오브젝트 동적 생성 (generateName: tenant-onboarding-)
▼
Workflow 실행 (argo-workflows 네임스페이스)
SQS 메시지에서 파라미터 3개를 추출해서 Workflow arguments에 주입하는 게 핵심입니다.
parameters:
- src:
dataKey: body.tenant_id → TENANT_ID
- src:
dataKey: body.tenant_tier → TENANT_TIER
- src:
dataKey: body.release_version → RELEASE_VERSION
나머지 REPO_URL, GIT_USERNAME, GIT_TOKEN 등은 Sensor에 하드코딩된 기본값.
2) WorkflowTemplate 3단계
Step 1: clone-repository
└── 01-tenant-clone-repo.sh
└── Git clone → /mnt/vol/eks-saas-gitops 복사
Step 2: validate-if-tenant-exists
└── 00-validate-tenant.sh
└── 이미 존재하는 테넌트면 중단
Step 3: create-tenant-helm-release
└── 02-tenant-onboarding.sh
└── HelmRelease 파일 생성 → Git commit & push
Point 1. 공유 볼륨 (workdir)으로 step 간 데이터 전달
각 step은 별도 컨테이너에서 실행되는데, /mnt/vol을 PVC로 공유해서 clone한 repo를 다음 step에서 그대로 사용
volumeClaimTemplates:
- metadata:
name: workdir
spec:
storageClassName: gp2
accessModes: [ "ReadWriteOnce" ]
storage: 1Gi
Point 2. synchronization.mutex로 동시 실행 방지를 합니다.
synchronization:
mutex:
name: workflow
여러 SQS 메시지가 동시에 들어와도 Workflow가 순차 실행되도록 뮤텍스로 직렬화 합니다.
Check deployment template content.
온보딩은 tenant_id + tenant_tier + release_version 3개였는데, 디플로이먼트는
{
"tenant_tier": "basic",
"release_version": "1.0"
}
tenant_id가 없습니다. 특정 테넌트 하나가 아니라 tier 전체를 한 번에 업데이트하는 구조에요.
[ec2-user@ip-10-0-1-207 workflows]$ cat tenant-deployment-sensor.yaml
---
apiVersion: argoproj.io/v1alpha1
kind: EventSource
metadata:
name: aws-sqs-deployment
namespace: argo-events
spec:
template:
serviceAccountName: argo-events-sa
sqs:
tenant-deployment:
jsonBody: true
region: "${aws_region}"
queue: "argoworkflows-deployment-queue" # Static value defined in TF module
waitTimeSeconds: 20
---
apiVersion: argoproj.io/v1alpha1
kind: Sensor
metadata:
name: aws-sqs-deployment
namespace: argo-events
spec:
template:
serviceAccountName: argo-events-sa
dependencies:
- name: tenant-deployment-dep
eventSourceName: aws-sqs-deployment
eventName: tenant-deployment
triggers:
- template:
name: tenant-deployment-template
k8s:
operation: create
source:
resource:
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: tenant-deployment-
namespace: argo-workflows
spec:
serviceAccountName: argoworkflows-sa
entrypoint: tenant-deployment
synchronization:
mutex:
name: workflow
arguments:
parameters:
- name: TENANT_TIER
value: "" # Valid values are: silo, pool, hybrid
- name: RELEASE_VERSION
value: "" # Valid values are: silo, pool, hybrid
- name: REPO_URL
value: "http://${gitea_url}:3000/admin/eks-saas-gitops.git"
- name: GIT_USER_EMAIL
value: "admin@example.com"
- name: GIT_USERNAME
value: "admin"
- name: GIT_TOKEN
value: "${gitea_token}"
- name: GIT_BRANCH
value: "main" # Can change based on your configs
templates:
- name: tenant-deployment
steps:
- - name: clone-repository
templateRef:
name: tenant-deployment-template
template: clone-repository
- - name: update-tenant-helm-release
templateRef:
name: tenant-deployment-template
template: update-tenant-helm-release
volumeClaimTemplates:
- metadata:
name: workdir
spec:
storageClassName: gp2
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 1Gi
parameters:
- src:
dependencyName: tenant-deployment-dep
dataKey: body.tenant_tier
dest: spec.arguments.parameters.0.value
- src:
dependencyName: tenant-deployment-dep
dataKey: body.release_version
dest: spec.arguments.parameters.1.value
[ec2-user@ip-10-0-1-207 workflows]$ cat tenant-deployment-workflow-template.yaml
apiVersion: argoproj.io/v1alpha1
kind: WorkflowTemplate
metadata:
name: tenant-deployment-template
namespace: argo-workflows
spec:
serviceAccountName: argoworkflows-sa # SA with IRSA permissions
templates:
- name: clone-repository
container:
image: "${ecr_argoworkflow_container}:0.1"
command: ["/bin/sh","-c"]
args: ['./01-tenant-clone-repo.sh {{workflow.parameters.REPO_URL}} {{workflow.parameters.GIT_BRANCH}} {{workflow.parameters.GIT_USERNAME}} {{workflow.parameters.GIT_TOKEN}} && cp -r eks-saas-gitops /mnt/vol/eks-saas-gitops']
volumeMounts:
- name: workdir
mountPath: /mnt/vol
env:
- name: GIT_USERNAME
value: "{{workflow.parameters.GIT_USERNAME}}"
- name: GIT_TOKEN
value: "{{workflow.parameters.GIT_TOKEN}}"
- name: update-tenant-helm-release
container:
image: "${ecr_argoworkflow_container}:0.1"
command: ["/bin/sh","-c"]
args: ['./03-tenant-deployment.sh {{workflow.parameters.RELEASE_VERSION}} {{workflow.parameters.TENANT_TIER}} {{workflow.parameters.GIT_USER_EMAIL}} {{workflow.parameters.GIT_USERNAME}} {{workflow.parameters.GIT_BRANCH}} {{workflow.parameters.GIT_TOKEN}}']
volumeMounts:
- name: workdir
mountPath: /mnt/vol
env:
- name: GIT_USERNAME
value: "{{workflow.parameters.GIT_USERNAME}}"
- name: GIT_TOKEN
value: "{{workflow.parameters.GIT_TOKEN}}"
volumeClaimTemplates:
- metadata:
name: workdir
spec:
storageClassName: gp2
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 1Gi
1) Workflow 2단계
Step 1: clone-repository
└── 01-tenant-clone-repo.sh (온보딩이랑 동일한 스크립트 재사용)
└── Git clone → /mnt/vol 복사
Step 2: update-tenant-helm-release
└── 03-tenant-deployment.sh
└── 해당 tier의 모든 HelmRelease 버전 업데이트 → Git commit & push
온보딩의 02-tenant-onboarding.sh는 새 파일 생성이었다면,
03-tenant-deployment.sh는 기존 파일의 버전 값 수정이 목적입니다.
2) validate step이 없는 이유
온보딩은 중복 생성 방지가 필요해서 00-validate-tenant.sh로 이미 존재하는지 체크했는데,
디플로이먼트는 이미 존재하는 테넌트를 업데이트하는 거니까 체크 자체가 불필요 합니다.
Check offloading templates content.
SQS 구조를 보면 아래와 같아요.
{
"tenant_id": "tenant-1",
"tenant_tier": "basic"
}
release_version 없네요. 삭제할 때 버전은 무관하니까요 ! (아래 전체 yaml 코드)
[ec2-user@ip-10-0-1-207 workflows]$ cat tenant-offboarding-sensor.yaml
---
apiVersion: argoproj.io/v1alpha1
kind: EventSource
metadata:
name: aws-sqs-offboarding
namespace: argo-events
spec:
template:
serviceAccountName: argo-events-sa
sqs:
tenant-provisioning:
jsonBody: true
region: "${aws_region}"
queue: "argoworkflows-offboarding-queue"
waitTimeSeconds: 20
---
apiVersion: argoproj.io/v1alpha1
kind: Sensor
metadata:
name: aws-sqs-offboarding
namespace: argo-events
spec:
template:
serviceAccountName: argo-events-sa
dependencies:
- name: tenant-provisioning-dep
eventSourceName: aws-sqs-offboarding
eventName: tenant-provisioning
triggers:
- template:
name: tenant-offboarding-template
k8s:
operation: create
source:
resource:
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: tenant-offboarding-
namespace: argo-workflows
spec:
serviceAccountName: argoworkflows-sa
entrypoint: tenant-provisioning
synchronization:
mutex:
name: workflow
arguments:
parameters:
- name: TENANT_ID
value: "" # ID of your tenant, use this patter eg. tenant-xx (tenant-10, tenant-11)
- name: TENANT_TIER
value: "" # Valid values are: premium, advanced, basic
- name: REPO_URL
value: "http://${gitea_url}:3000/admin/eks-saas-gitops.git"
- name: GIT_USER_EMAIL
value: "admin@example.com"
- name: GIT_USERNAME
value: "admin"
- name: GIT_TOKEN
value: "${gitea_token}"
- name: GIT_BRANCH
value: "main" # Can change based on your configs
templates:
- name: tenant-provisioning
steps:
- - name: clone-repository
templateRef:
name: tenant-offboarding-template
template: clone-repository
- - name: validate-if-tenant-exists
templateRef:
name: tenant-offboarding-template
template: validate-if-tenant-exists
- - name: remove-tenant-helm-release
templateRef:
name: tenant-offboarding-template
template: remove-tenant-helm-release
volumeClaimTemplates:
- metadata:
name: workdir
spec:
storageClassName: gp2
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 1Gi
parameters:
- src:
dependencyName: tenant-provisioning-dep
dataKey: body.tenant_id
dest: spec.arguments.parameters.0.value
- src:
dependencyName: tenant-provisioning-dep
dataKey: body.tenant_tier
dest: spec.arguments.parameters.1.value
[ec2-user@ip-10-0-1-207 workflows]$ cat tenant-offboarding-workflow-template.yaml
apiVersion: argoproj.io/v1alpha1
kind: WorkflowTemplate
metadata:
name: tenant-offboarding-template
namespace: argo-workflows
spec:
serviceAccountName: argoworkflows-sa # SA with IRSA permissions
templates:
- name: validate-if-tenant-exists
container:
image: "${ecr_argoworkflow_container}:0.1"
command: ["/bin/sh","-c"]
args: ['./00-validate-tenant.sh {{workflow.parameters.TENANT_ID}}']
volumeMounts:
- name: workdir
mountPath: /mnt/vol
env:
- name: GIT_USERNAME
value: "{{workflow.parameters.GIT_USERNAME}}"
- name: GIT_TOKEN
value: "{{workflow.parameters.GIT_TOKEN}}"
- name: clone-repository
container:
image: "${ecr_argoworkflow_container}:0.1"
command: ["/bin/sh","-c"]
args: ['./01-tenant-clone-repo.sh {{workflow.parameters.REPO_URL}} {{workflow.parameters.GIT_BRANCH}} {{workflow.parameters.GIT_USERNAME}} {{workflow.parameters.GIT_TOKEN}} && cp -r eks-saas-gitops /mnt/vol/eks-saas-gitops']
volumeMounts:
- name: workdir
mountPath: /mnt/vol
env:
- name: GIT_USERNAME
value: "{{workflow.parameters.GIT_USERNAME}}"
- name: GIT_TOKEN
value: "{{workflow.parameters.GIT_TOKEN}}"
- name: remove-tenant-helm-release
container:
image: "${ecr_argoworkflow_container}:0.1"
command: ["/bin/sh","-c"]
args: ['./04-tenant-offboarding.sh {{workflow.parameters.TENANT_ID}} {{workflow.parameters.TENANT_TIER}} {{workflow.parameters.GIT_USER_EMAIL}} {{workflow.parameters.GIT_USERNAME}} {{workflow.parameters.GIT_BRANCH}} {{workflow.parameters.GIT_TOKEN}}']
volumeMounts:
- name: workdir
mountPath: /mnt/vol
env:
- name: GIT_USERNAME
value: "{{workflow.parameters.GIT_USERNAME}}"
- name: GIT_TOKEN
value: "{{workflow.parameters.GIT_TOKEN}}"
volumeClaimTemplates:
- metadata:
name: workdir
spec:
storageClassName: gp2
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 1Gi
1) Workflow 3단계
Step 1: clone-repository
└── 01-tenant-clone-repo.sh (3개 워크플로우 모두 동일하게 재사용)
Step 2: validate-if-tenant-exists
└── 00-validate-tenant.sh (온보딩이랑 동일한 스크립트)
└── 존재하지 않는 테넌트면 중단
Step 3: remove-tenant-helm-release
└── 04-tenant-offboarding.sh
└── HelmRelease 파일 삭제 → Git commit & push
흥미로운 포인트는 00-validate-tenant.sh 입니다 (온보딩이랑 동일한 스크립트임)
오프보딩의 validate 스크립트는 온보딩과 목적이 반대인데요,
온보딩 validate script : 이미 존재하면 → 중단 (중복 방지)
오프보딩 validate script : 존재하지 않으면 → 중단 (없는 테넌트 삭제 방지)
세 워크플로우를 최종적으로 비교해보도록 하겠습니다.
| 온보딩 | 디플로이먼트 | 오프보딩 | |
| SQS 파라미터 | tenant_id + tenant_tier + release_version |
tenant_tier + release_version | tenant_id + tenant_tier |
| validate step | 중복 방지 | ❌ | 존재 확인 |
| 대상 범위 | 테넌트 단위 | tier 전체 | 테넌트 단위 |
| Git 조작 | 파일 생성 | 파일 수정 | 파일 삭제 |
| 스크립트 | 02-onboarding.sh | 03-deployment.sh | 04-offboarding.sh |
| 공통 스크립트 | 01-clone-repo.sh | 01-clone-repo.sh | 01-clone-repo.sh |
02. The entire flow of the onboarding workflow
위에서 설명드린 온보딩 워크플로우 템플릿을 참고하시면 되고, 전체 흐름을 설명드리겠습니다.

Step1. SQS 메시지 발행
{
"tenant_id": "tenant-1",
"tenant_tier": "basic",
"release_version": "0.0"
}
누군가(관리자/SaaS 컨트롤 플레인)가 이 메시지를 argoworkflows-onboarding-queue에 던지는 것에서 시작.
Step2. Argo Events가 수신 (EventSource → Sensor)
EventSource (aws-sqs-onboarding)
└── SQS를 20초 간격으로 longpolling
└── 메시지 수신 시 Sensor에 전달
Sensor (aws-sqs-onboarding)
└── body.tenant_id → TENANT_ID 파라미터로 주입
└── body.tenant_tier → TENANT_TIER 파라미터로 주입
└── body.release_version → RELEASE_VERSION 파라미터로 주입
└── Workflow 오브젝트 동적 생성 (generateName: tenant-onboarding-xxxx)
Step3. Argo Workflows 실행 (3단계)
Step 1: clone-repository
└── eks-saas-gitops 레포 클론
└── /mnt/vol (PVC) 에 복사
└── 다음 step과 공유하기 위해 PVC에 저장
↓
Step 2: validate-if-tenant-exists
└── /mnt/vol 안에서 tenant-1 관련 파일 존재 여부 확인
└── 이미 존재하면 → 워크플로우 중단 (중복 방지)
└── 없으면 → 다음 step 진행
↓
Step 3: create-tenant-helm-release
└── TENANT_TIER (basic) 에 맞는 템플릿 선택
└── TENANT_ID, RELEASE_VERSION 변수 치환하여 HelmRelease 파일 생성
└── Git commit & push → eks-saas-gitops 레포 반영
Step4. Flux v2가 변경 감지
eks-saas-gitops 레포 변경 감지
└── 새로 생긴 HelmRelease 파일 확인
└── EKS 클러스터에 테넌트 리소스 배포
📌 핵심 설계 포인트
1) 이벤트 드리븐: SQS 메시지 하나로 전체 파이프라인이 자동으로 시작됨. 사람이 개입할 지점이 없음.
2) GitOps 경계: Argo Workflows의 역할은 Git 조작까지만. EKS 배포는 Flux가 담당. 둘의 역할이 명확히 분리
3) PVC 공유: 각 step은 독립된 컨테이너지만 /mnt/vol PVC를 통해 clone한 레포를 공유.
4) mutex: 동시에 여러 SQS 메시지가 와도 워크플로우가 순차 실행됨. Git 충돌 방지.
03. Premium Tier Tenant Onboarding
이제, Premium 테넌트 프로비저닝을 해봅시다.
새 테넌트를 온보딩하려면 필요한 메타데이터를 담은 메시지를 SQS 큐로 전송을 해야겠죠 ?
(여기서 필요한 메타데이터는 tenant_id, tenant_tier, release_version)
tenant_tier: premium 은 테넌트 전용 Kubernetes 네임스페이스에 Producer, Consumer 마이크로서비스와
인프라 리소스(SQS, DynamoDB)가 모두 전용으로 프로비저닝됨을 의미함.
export ARGO_WORKFLOWS_ONBOARDING_QUEUE_SQS_URL=$(kubectl get configmap saas-infra-outputs -n flux-system -o jsonpath='{.data.argoworkflows_onboarding_queue_url}')
aws sqs send-message \
--queue-url $ARGO_WORKFLOWS_ONBOARDING_QUEUE_SQS_URL \
--message-body '{
"tenant_id": "tenant-1",
"tenant_tier": "premium",
"release_version": "0.0"
}'
SQS 큐로 전송하였다면, 온보딩 워크플로우 생성 여부를 확인해봅시다.

이때 Argo Workflows Web UI에서 워크플로우 실행 과정을 시각적으로 확인할 수 있습니다.
ARGO_WORKFLOW_URL=$(kubectl -n argo-workflows get service/argo-workflows-server -o json | jq -r '.status.loadBalancer.ingress[0].hostname')
echo http://$ARGO_WORKFLOW_URL:2746/workflows
출력된 URL로 접근하시어, 확인하시면 되겠습니다.


온보딩 템플릿을 실행하여 Argo Workflows 실행 (3단계) 과정을 거친 것을 확인 할 수 있습니다.
Verify GitOps changes
워크플로우가 완료되면 Gitea 저장소에 새 Premium 테넌트의 HelmRealse 파일이 자동으로
커밋되어 있음을 확인할 수 있습니다.
Gitea Web UI 접속 정보
export GITEA_PUBLIC_IP=$(kubectl get configmap saas-infra-outputs -n flux-system -o jsonpath='{.data.gitea_public_url}')
export GITEA_ADMIN_PASSWORD=$(aws ssm get-parameter --name "/eks-saas-gitops/gitea-admin-password" --with-decryption --query 'Parameter.Value' --output text)
echo "=== Gitea Web Interface Access ==="
echo "Public URL (for browser access): $GITEA_PUBLIC_IP"
echo "Username: admin"
echo "Password: $GITEA_ADMIN_PASSWORD"
echo "=================================="
이 Gitea 확인 과정이 실제 Argo Workflows가 Git Push를 잘 했는지 검증하는 과정입니다.

Helm Release 파일이 자동으로 커밋이 되었음을 확인하였습니다.
이 시점부터 Flux v2가 변경 사항을 감지하여, 테넌트를 플랫폼에 추가할 것입니다.

위와 같이 premium 테넌트를 플랫폼에 추가하였고, 실제로 producer, consumer 파드가 동작합니다.
04. Basic Tier Tenant Onboarding
Basic 티어는 Lab 실습 두번째에서 살펴봤듯이 공유 인프라를 사용합니다.
basic_env_template.yaml 기반의 Pool 환경이 여러 Basic 테넌트를 수용하며,
리소스 공유를 통해 비용을 최적화 하는 구조입니다.
tenant_tier: basic으로 변경하여 SQS 메시지 전송
aws sqs send-message \
--queue-url $ARGO_WORKFLOWS_ONBOARDING_QUEUE_SQS_URL \
--message-body '{
"tenant_id": "tenant-2",
"tenant_tier": "basic",
"release_version": "0.0"
}'
메시지를 전송하면 아래와 같이 응답을 반환받습니다.

- MD5OfMessageBody: 메시지 본문 무결성 체크값. 전송 중 데이터가 변조되지 않았는지 확인용.
- MessageId: SQS가 이 메시지에 부여한 고유 ID.
둘 다 SQS가 "메시지 잘 받았어"라고 돌려주는 확인 응답입니다.
이게 나오면 전송 성공한 거고, 이제 Argo Events Sensor가 polling해서 가져갈 차례.
Verify GitOps changes

tenant-onboarding-n46wb 워크플로우가 SQS 메시지 수신 후 자동 트리거되었고
3단계(clone → validate → create-helm-release) 모두 Succeeded 입니다.
여기서 흥미로운 점은 DURATION 전체 약 13초인데요, premium 테넌트 보다 훨씬 빠릅니다
왜? basic 테넌트가 더 단순하기 때문 (공유 구조)
워크플로우 자체는 tier를 모릅니다. TENANT_TIER 파라미터를 스크립트에 넘길 뿐이고,
어떤 리소스가 만들어지는지는 티어 템플릿이 결정합니다.
이게 이번 실습의 핵심 설계 포인트 입니다.
Argo Workflows는 Git 조작 자동화 엔진일 뿐이고, 비즈니스 로직(티어별 리소스 구성)은 템플릿에 캡슐화됨

premium 템플릿과 비교해보면 kind: 부분이 premium은 namespace 였는데,
basic은 HelmRelease인 것을 확인하실 수 있습니다. 추가적으로 targetNamespace도 pool-1 이네요.
즉, 이미 존재하는 공유 네임스페이스(pool-1)에 배포가 되었다는 것을 입증하는 것입니다.

05. Advanced Tier Tenant Onboarding
Advanced 티어는 Lab 실습 두번째에서 정의한 혼합 모델(Producer 공유 + Consumer 전용)을 의미.
컴플라이언스 요구나 Noisy Neighbor 문제 완화가 필요한 고객군에 적합한 구조입니다.
tenant_tier: advanced로 변경하여 tenant-3을 온보딩
aws sqs send-message \
--queue-url $ARGO_WORKFLOWS_ONBOARDING_QUEUE_SQS_URL \
--message-body '{
"tenant_id": "tenant-3",
"tenant_tier": "advanced",
"release_version": "0.0"
}'
Verify GitOps changes

워크플로우 완료 후 Gitea 저장소에서 tenant-3에 대해 생성된 파일을 확인해봅시다.

이 변경된 yaml 템플릿을 보면 Advanced 티어의 특성을 확인할 수 있습니다.
producer:
enabled: false # Pool deployment -- advanced tier shares resources with other tenants
envId: pool-1 # producer는 pool-1 공유
consumer:
enabled: true # Silo deployment -- advanced tier has a dedicated deployment for each tenant
# consumer는 tenant-3 전용 네임스페이스에 배포
이제 flux v2가 배포한 플랫폼을 확인해볼까요?

tenant-3 네임스페이스의 파드들은 consumer만 전용으로 생성된 것을 확인할 수 있고,
producer는 공유 pool-1을 사용하는 것까지 확인하였습니다.
06. Check resources
Step1. Git 저장소 상태 확인
Argo Workflows가 자동 커밋한 내용을 로컬로 pull해서 테넌트 파일 구조 확인.
우리가 Gitea UI에서 봤던 커밋들이 로컬에도 반영되는지 확인 해보겠습니다.

Argo Workflows가 Gitea에 자동 커밋한 내용 3개를 로컬로 내려받았습니다.
tenant-1.yaml +37줄 ← premium 온보딩
tenant-2.yaml +30줄 ← basic 온보딩
tenant-3.yaml +35줄 ← advanced 온보딩
각 tier의 kustomization.yaml도 같이 업데이트됐고, pool-1.yaml도 변경됐네요.
(advanced producer가 pool-1 공유하면서 생긴 변경).
요약하면 우리가 SQS로 트리거한 3번의 온보딩이 전부 Git에 반영된 걸 로컬에서 확인한 것입니다.

각 tier 디렉토리마다 kustomization.yaml이 있어서 Flux가 해당 디렉토리 안의 리소스를
자동으로 감지하는 구조입니다. 새 테넌트가 추가될 때마다 kustomization.yaml에 항목이 추가되고
yaml 파일이 생기는 패턴이에요.
Step2. Flux HelmRelease 확인
flux get helmreleases
NAME REVISION SUSPENDED READY MESSAGE
argo-events 2.4.3 False True Helm install succeeded for release argo-events/argo-events.v1 with chart argo-events@2.4.3
argo-workflows 0.40.11 False True Helm install succeeded for release argo-workflows/argo-workflows.v1 with chart argo-workflows@0.40.11
aws-load-balancer-controller 1.6.2 False True Helm install succeeded for release aws-system/aws-load-balancer-controller.v1 with chart aws-load-balancer-controller@1.6.2
karpenter 1.4.0 False True Helm install succeeded for release karpenter/karpenter.v1 with chart karpenter@1.4.0
kubecost 2.1.0 False True Helm install succeeded for release kubecost/kubecost.v1 with chart cost-analyzer@2.1.0
metrics-server 3.11.0 False True Helm install succeeded for release kube-system/metrics-server.v1 with chart metrics-server@3.11.0
onboarding-service 0.0.1 False True Helm install succeeded for release onboarding-service/onboarding-service.v1 with chart application-chart@0.0.1
pool-1 0.0.1 False True Helm install succeeded for release pool-1/pool-1 with chart helm-tenant-chart@0.0.1
tenant-1-premium 0.0.1 False True Helm upgrade succeeded for release tenant-1/tenant-1-premium.v2 with chart helm-tenant-chart@0.0.1
tenant-2-basic 0.0.1 False True Helm install succeeded for release pool-1/tenant-2-basic.v1 with chart helm-tenant-chart@0.0.1
tenant-3-advanced 0.0.1 False True Helm upgrade succeeded for release tenant-3/tenant-3-advanced.v2 with chart helm-tenant-chart@0.0.1
tf-controller 0.16.0-rc.4 False True Helm install succeeded for release flux-system/tf-controller.v1 with chart tf-controller@0.16.0-rc.4
3개 테넌트가 모두 READY=True 상태인지 확인하였습니다.

추가적으로 모든 테넌트가 동일한 helm-tenant-chart를 사용하며 버전 0.0.1로 배포되었음을 확인.
💡 핵심 : 모든 티어가 동일한 차트(helm-tenant-chart)를 사용하면서도, values 설정만으로
완전히 다른 배포 구조를 갖게 되었습니다.
Step3. 테넌트 배포 검증
모든 테넌트에 동일한 ALB를 사용하며, tenantID 헤더로 테넌트별 환경으로 라우팅됩니다.
export APP_LB=http://$(kubectl get ingress -n tenant-1 -o json | jq -r .items[0].status.loadBalancer.ingress[0].hostname)
curl -s -H "tenantID: tenant-1" $APP_LB/producer | jq
{
"environment": "tenant-1",
"microservice": "producer",
"tenant_id": "tenant-1",
"version": "0.0.1"
}
curl -s -H "tenantID: tenant-1" $APP_LB/consumer | jq
{
"environment": "tenant-1",
"microservice": "consumer",
"tenant_id": "tenant-1",
"version": "0.0.1"
}
curl -s -H "tenantID: tenant-2" $APP_LB/producer | jq
{
"environment": "pool-1",
"microservice": "producer",
"tenant_id": "tenant-2",
"version": "0.0.1"
}
curl -s -H "tenantID: tenant-2" $APP_LB/consumer | jq
{
"environment": "pool-1",
"microservice": "consumer",
"tenant_id": "tenant-2",
"version": "0.0.1"
}
curl -s -H "tenantID: tenant-3" $APP_LB/producer | jq
{
"environment": "pool-1",
"microservice": "producer",
"tenant_id": "tenant-3",
"version": "0.0.1"
}
curl -s -H "tenantID: tenant-3" $APP_LB/consumer | jq
{
"environment": "tenant-3",
"microservice": "consumer",
"tenant_id": "tenant-3",
"version": "0.0.1"
}
- tenant-1 (premium): producer/consumer 모두 tenant-1 → 전용
- tenant-2 (basic): producer/consumer 모두 pool-1 → 공유
- tenant-3 (advanced): producer는 pool-1 (공유), consumer는 tenant-3 (전용) → hybrid
의도된 대로 마이크로서비스 엔드포인트들이 정상 동작 하는것을 확인하였습니다.
Step4. 인프라 End-to-End 검증
Producer에 POST 요청을 보내고,
Consumer가 DynamoDB에 데이터를 정상적으로 저장하는지 검증
# POST 요청 전송
curl --location --request POST "$APP_LB/producer" \
--header 'tenantID: tenant-3' \
--header 'tier: advanced'
# tenant-3의 DynamoDB 테이블명 조회 (랜덤 해시 포함)
TABLE_NAME=$(aws dynamodb list-tables --region $AWS_REGION --query "TableNames[?contains(@, 'tenant-3')]" --output text)
# DynamoDB 데이터 확인
aws dynamodb scan --table-name $TABLE_NAME --region $AWS_REGION
consumer, producer environment와 message_id, tenant_id, timestamp 값이
정상적으로 반환되었는지 확인하시면 되겠습니다.
07. Tenant Offboarding
온보딩과 동일한 Event-Driven 방식으로 오프로딩도 처리됩니다.
오늘 Lab 실습으로 진행한 premium 테넌트를 오프보딩 해보겠습니다.
오프로딩 SQS 메시지 전송:
export ARGO_WORKFLOWS_OFFBOARDING_QUEUE_SQS_URL=$(kubectl get configmap saas-infra-outputs -n flux-system -o jsonpath='{.data.argoworkflows_offboarding_queue_url}')
aws sqs send-message \
--queue-url $ARGO_WORKFLOWS_OFFBOARDING_QUEUE_SQS_URL \
--message-body '{
"tenant_id": "tenant-1",
"tenant_tier": "premium"
}'
오프로딩 워크플로우 생성 여부를 확인하였고, 현재 Running 상태로 프로세스가 돌고 있습니다.
kubectl -n argo-workflows get workflow
NAME STATUS AGE MESSAGE
tenant-offboarding-67zwc Running 9s
tenant-onboarding-7mztv Succeeded 49m
tenant-onboarding-n46wb Succeeded 63m
tenant-onboarding-tnnnp Succeeded 86m
Argo Workflows에서 오프로딩 진행 상황을 모니터링이 가능합니다.

Gitea 저장소에서 테넌트 리소스가 제거된 커밋을 확인하실 수 있습니다.

destroyResourcesOnDeletion: true 설정에 의해 Terraform CRD와 함께 AWS 리소스도 자동 정리됨.
긴 글 읽어주셔서 감사합니다.
이번 실습에서는 Event-Driven 자동화를 통해 멀티테넌트 SaaS 환경의 온보딩/오프보딩이
완전 자동화되는 과정을 확인해보았습니다.
