logo
Back

CI/CD With Amazon EKS

28 views

AWS EKS GitOps CI/CD - SaaS 워크숍 실습

오늘은 AWS EKS 환경에서의 CI/CD 를 알아본다.

오늘은 AWS 의 최영락 님께서 강의를 해 주셨다.

아래 사진이 오늘 실습을 해볼 과정이다.

실습의 기반 워크숍은 다음과 같다. https://catalog.workshops.aws/eks-saas-gitops/en-US

GitOps

GitOps 는 Git 저장소를 단일 진실 공급원(Single Source of Truth)으로 두고, 클러스터의 상태를 Git 의 상태와 동기화하는 운영 방식이다.

이번 실습에서는 다음과 같은 컴포넌트를 사용한다.

  • Gitea: 워크숍 환경 내에서 동작하는 Git 서버
  • Flux: Git 저장소의 변경 사항을 감지하여 클러스터 상태를 동기화
  • Tofu Controller: Flux 와 연동되어 Terraform 리소스를 GitOps 방식으로 관리
  • Argo Workflows / Argo Events: SQS 메시지 기반 자동 온보딩 파이프라인 구성

실습

EKS 클러스터 및 네임스페이스 확인

bash
[ec2-user@ip-10-0-1-93 environment]$ kubectl get ns
NAME                 STATUS   AGE
argo-events          Active   2d6h
argo-workflows       Active   2d6h
aws-system           Active   2d6h
default              Active   2d7h
flux-system          Active   2d6h
karpenter            Active   2d6h
kube-node-lease      Active   2d7h
kube-public          Active   2d7h
kube-system          Active   2d7h
kubecost             Active   2d6h
onboarding-service   Active   2d6h
pool-1               Active   2d6h

gitea 설치 된 것을 확인하고

bash
# Get Gitea IPs from the configuration
export GITEA_PRIVATE_IP=$(kubectl get configmap saas-infra-outputs -n flux-system -o jsonpath='{.data.gitea_url}')
export GITEA_PUBLIC_IP=$(kubectl get configmap saas-infra-outputs -n flux-system -o jsonpath='{.data.gitea_public_url}')
export GITEA_PORT="3000"
 
# Get Gitea admin password from Systems Manager Parameter Store
export GITEA_ADMIN_PASSWORD=$(aws ssm get-parameter --name "/eks-saas-gitops/gitea-admin-password" --with-decryption --query 'Parameter.Value' --output text)
 
# Display access information for web browser login
echo "=== Gitea Web Interface Access ==="
echo "Public URL (for browser access): $GITEA_PUBLIC_IP"
echo "Username: admin"
echo "Password: $GITEA_ADMIN_PASSWORD"
echo "=================================="
echo ""
echo "Use the PUBLIC URL above to access Gitea from your web browser."
bash
=== Gitea Web Interface Access ===
Public URL (for browser access): http://34.221.28.68:3000
Username: admin
Password: 8y)kOg4yWoSyo&Q%
==================================

이렇게 출력이 되었다.

gitea 에 들어가 보면 다음과 같은 저장소들이 있었다.

bash
# Extract Gitea configuration from ConfigMap
export GITEA_TOKEN=$(kubectl get configmap saas-infra-outputs -n flux-system -o jsonpath='{.data.gitea_token}')
# Set up the repository paths used throughout the workshop
export REPO_PATH="/home/ec2-user/environment/microservice-repos"
export GITOPS_REPO_PATH="/home/ec2-user/environment/gitops-gitea-repo"
mkdir -p $REPO_PATH

해당 명령어를 통해 gitea 에 접근할 수 있는 토큰등을 설정한다.

아래 명령어를 통해 git clone 을 받고 폴더를 확인해 본다.

bash
[ec2-user@ip-10-0-1-93 microservice-repos]$ git clone http://admin:${GITEA_TOKEN}@${GITEA_PRIVATE_IP}:${GITEA_PORT}/admin/producer.git
git clone http://admin:${GITEA_TOKEN}@${GITEA_PRIVATE_IP}:${GITEA_PORT}/admin/consumer.git
git clone http://admin:${GITEA_TOKEN}@${GITEA_PRIVATE_IP}:${GITEA_PORT}/admin/payments.git
tree
Cloning into 'producer'...
remote: Enumerating objects: 8, done.
remote: Counting objects: 100% (8/8), done.
remote: Compressing objects: 100% (6/6), done.
remote: Total 8 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
Receiving objects: 100% (8/8), done.
Cloning into 'consumer'...
remote: Enumerating objects: 8, done.
remote: Counting objects: 100% (8/8), done.
remote: Compressing objects: 100% (6/6), done.
remote: Total 8 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
Receiving objects: 100% (8/8), done.
Cloning into 'payments'...
remote: Enumerating objects: 8, done.
remote: Counting objects: 100% (8/8), done.
remote: Compressing objects: 100% (6/6), done.
remote: Total 8 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
Receiving objects: 100% (8/8), done.
.
├── consumer
   ├── Dockerfile
   ├── consumer.py
   └── requirements.txt
├── payments
   ├── Dockerfile
   ├── payments.py
   └── requirements.txt
└── producer
    ├── Dockerfile
    ├── producer.py
    └── requirements.txt

Flux 시스템

플럭스 시스템이 gitea 를 감시해서 클러스터 상태와 일치하도록 구성되어 있다.

bash
[ec2-user@ip-10-0-1-93 microservice-repos]$ kubectl -n flux-system get gitrepository -o yaml
apiVersion: v1
items:
- apiVersion: source.toolkit.fluxcd.io/v1
  kind: GitRepository
  metadata:
    annotations:
      kustomize.toolkit.fluxcd.io/prune: Disabled
      kustomize.toolkit.fluxcd.io/ssa: Ignore
    creationTimestamp: "2026-04-24T05:47:48Z"
    finalizers:
    - finalizers.fluxcd.io
    generation: 1
    labels:
      app.kubernetes.io/instance: flux-system
      app.kubernetes.io/managed-by: flux-operator
      app.kubernetes.io/part-of: flux
      app.kubernetes.io/version: v2.7.5
      fluxcd.controlplane.io/name: flux
      fluxcd.controlplane.io/namespace: flux-system
    name: flux-system
    namespace: flux-system
    resourceVersion: "1839817"
    uid: 3ef2141a-52c9-4107-9b01-382ce16e5798
  spec:
    interval: 1m0s
    ref:
      name: refs/heads/main
    secretRef:
      name: flux-system
    timeout: 60s
    url: http://10.35.48.242:3000/admin/eks-saas-gitops.git
  status:
    artifact:
      digest: sha256:77be0a1a9e7b8d8603383f7f9d00d9af257393e1c36bb8c48bf7edff077f3ff7
      lastUpdateTime: "2026-04-24T05:51:53Z"
      path: gitrepository/flux-system/flux-system/49b01638ae188f9e1a9bb9f967e9473baf13e9fc.tar.gz
      revision: refs/heads/main@sha1:49b01638ae188f9e1a9bb9f967e9473baf13e9fc
      size: 29962
      url: http://source-controller.flux-system.svc.cluster.local./gitrepository/flux-system/flux-system/49b01638ae188f9e1a9bb9f967e9473baf13e9fc.tar.gz
    conditions:
    - lastTransitionTime: "2026-04-24T05:51:53Z"
      message: stored artifact for revision 'refs/heads/main@sha1:49b01638ae188f9e1a9bb9f967e9473baf13e9fc'
      observedGeneration: 1
      reason: Succeeded
      status: "True"
      type: Ready
    - lastTransitionTime: "2026-04-24T05:51:53Z"
      message: stored artifact for revision 'refs/heads/main@sha1:49b01638ae188f9e1a9bb9f967e9473baf13e9fc'
      observedGeneration: 1
      reason: Succeeded
      status: "True"
      type: ArtifactInStorage
    lastHandledReconcileAt: "2026-04-24T05:49:37.800653683Z"
    observedGeneration: 1
- apiVersion: source.toolkit.fluxcd.io/v1
  kind: GitRepository
  metadata:
    creationTimestamp: "2026-04-24T05:47:56Z"
    finalizers:
    - finalizers.fluxcd.io
    generation: 1
    labels:
      kustomize.toolkit.fluxcd.io/name: sources
      kustomize.toolkit.fluxcd.io/namespace: flux-system
    name: terraform-v0-0-1
    namespace: flux-system
    resourceVersion: "3642"
    uid: 9b8d2e0b-cf16-42da-995d-4ea76b54670b
  spec:
    interval: 300s
    ref:
      tag: v0.0.1
    secretRef:
      name: flux-system
    timeout: 60s
    url: http://10.35.48.242:3000/admin/eks-saas-gitops.git
  status:
    artifact:
      digest: sha256:00cbf55dd85a7516b9a2cf826182830cfa25a8c9317670066588a8fb431921ea
      lastUpdateTime: "2026-04-24T05:47:58Z"
      path: gitrepository/flux-system/terraform-v0-0-1/2d19a84a88a6ded7c9aa8ac76508452e3f1d48b2.tar.gz
      revision: v0.0.1@sha1:2d19a84a88a6ded7c9aa8ac76508452e3f1d48b2
      size: 29937
      url: http://source-controller.flux-system.svc.cluster.local./gitrepository/flux-system/terraform-v0-0-1/2d19a84a88a6ded7c9aa8ac76508452e3f1d48b2.tar.gz
    conditions:
    - lastTransitionTime: "2026-04-24T05:47:58Z"
      message: stored artifact for revision 'v0.0.1@sha1:2d19a84a88a6ded7c9aa8ac76508452e3f1d48b2'
      observedGeneration: 1
      reason: Succeeded
      status: "True"
      type: Ready
    - lastTransitionTime: "2026-04-24T05:47:58Z"
      message: stored artifact for revision 'v0.0.1@sha1:2d19a84a88a6ded7c9aa8ac76508452e3f1d48b2'
      observedGeneration: 1
      reason: Succeeded
      status: "True"
      type: ArtifactInStorage
    observedGeneration: 1
kind: List
metadata:
  resourceVersion: ""
[ec2-user@ip-10-0-1-93 microservice-repos]$ kubectl -n flux-system get gitrepository
NAME               URL                                                  AGE    READY   STATUS
flux-system        http://10.35.48.242:3000/admin/eks-saas-gitops.git   2d6h   True    stored artifact for revision 'refs/heads/main@sha1:49b01638ae188f9e1a9bb9f967e9473baf13e9fc'
terraform-v0-0-1   http://10.35.48.242:3000/admin/eks-saas-gitops.git   2d6h   True    stored artifact for revision 'v0.0.1@sha1:2d19a84a88a6ded7c9aa8ac76508452e3f1d48b2'

확인 해 보면 다음과 같이 설정이 된 것을 확인 해 볼 수 있다.

Terraform 및 OpenTofu 컨트롤러

bash
tree /home/ec2-user/environment/gitops-gitea-repo/terraform/modules/ -L 2

핵심 모듈은 tenant-apps 에 있다.

테라폼 모듈 테스트

bash
cd /home/ec2-user/environment/gitops-gitea-repo/
 
cat << EOF > terraform_test.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "5.100.0"
    }
  }
}
 
provider "aws" {}
 
module "test_tenant_apps" {
  source          = "./terraform/modules/tenant-apps"
  tenant_id       = "test"
  enable_producer = true
  enable_consumer = true
}
EOF

초기화 하기 전에 배포 계획을 확인 해 본다.

bash
terraform init
terraform plan | tee -a tfplan-1.txt

Plan: 11 to add, 0 to change, 0 to destroy. 로 11개의 자원이 생성된다고 한다.

두번째로 enable_producer = false 로 변경해서 다시 plan 을 실행해 본다.

bash
cat << EOF > terraform_test.tf
terraform {
  required_providers {
    aws = {
      source = "hashicorp/aws"
      version = "5.100.0"
    }
  }
}
 
provider "aws" {}
 
module "test_tenant_apps" {
  source          = "./terraform/modules/tenant-apps"
  tenant_id       = "test"
  enable_producer = false
  enable_consumer = true
}
EOF

terraform plan | tee -a tfplan-2.txt 로 실행해 본다.

Plan: 10 to add, 0 to change, 0 to destroy. 이번엔 이런 결과가 나온다. producer 가 빠진 만큼 자원이 1개 줄어든 것을 볼 수 있다.

Tofu Controller

Flux 가 git 저장소의 변화를 감지한다. 그 git 저장소 내용에 따라서 클러스터의 상태를 유지하게 된다.

아래는 해당 내용에 대한 플로우이다.

bash
1. Git 저장소에 Terraform CRD 파일 추가 (git push)


2. Flux가 변경 감지 조정 시작


3. tf-controller가 Terraform CRD 감지


4. tf-runner Pod 실행 Git에서 Terraform 모듈 pull


5. Terraform apply SQS, DynamoDB, IRSA 생성


6. 실행 상태/플랜을 Kubernetes Secret으로 저장

flux 가 정상인지 확인한다.

bash
kubectl get pod -n flux-system -l app.kubernetes.io/instance=tf-controller
kubectl get pod -n flux-system

테라폼 CRD 생성 및 배포

bash
cat << EOF > /home/ec2-user/environment/gitops-gitea-repo/application-plane/production/tenants/example-tenant-terraform-crd.yaml
---
apiVersion: infra.contrib.fluxcd.io/v1alpha2
kind: Terraform
metadata:
  name: example-tenant
  namespace: flux-system
spec:
  path: ./terraform/modules/tenant-apps
  interval: 1m
  approvePlan: auto
  destroyResourcesOnDeletion: true
  sourceRef:
    kind: GitRepository
    name: terraform-v0-0-1
  vars:
    - name: tenant_id
      value: example-tenant
    - name: "enable_producer"
      value: true
    - name: "enable_consumer"
      value: true
  writeOutputsToSecret:
    name: example-tenant-infra-output
EOF

kubectl get GitRepository terraform-v0-0-1 -nflux-system -oyaml | grep -i spec -C10 명령어로 어떤 부분을 보고 있는 지 확인한다.

다시 kustomization.yaml 에 아까 만든 파일을 flux 가 인식하게 끔 변경해 본다.

bash
cat << EOF > /home/ec2-user/environment/gitops-gitea-repo/application-plane/production/tenants/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - basic
  - advanced
  - premium
  - example-tenant-terraform-crd.yaml
EOF

git 에 해당 변경사항을 푸시한다.

bash
cd /home/ec2-user/environment/gitops-gitea-repo/
git pull origin main
git status
git add .
git commit -am "Added example terraform CRD for testing"
git push origin main

기다려도 되지만 빠르게 실행해 본다.

flux reconcile source git flux-system

해당 명령어를 입력하고 실제 반영되는지 확인 해 보자.

kubectl get po -n flux-system 를 입력하고 example-tenant-tf-runner 가 나올때까지 기다려 본다.

kubectl logs -n flux-system -l app.kubernetes.io/name=tf-runner -f 해당 명령어를 입력 시 다음과 같은 명령어로 로그를 확인한다.

우리가 만든 SQS, DynamoDB 가 실제로 잘 적용 되었는지 확인한다.

실제로 테라폼이 동작하면서 아래처럼 SQS 및 DynamoDB 테이블이 생성 된 것을 볼 수 있다.

이제는 삭제한다. 아래와 같이 추가한 내용을 삭제하고 푸시해 본다.

bash
cat << EOF > /home/ec2-user/environment/gitops-gitea-repo/application-plane/production/tenants/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
    - basic
    - advanced
    - premium
EOF

아래와 같이 변경사항을 커밋한다.

bash
cd /home/ec2-user/environment/gitops-gitea-repo/
git pull origin main
git add .
git commit -m "Removed Terraform CRD and reference from kustomization.yaml"
git push origin main

flux reconcile source git flux-system 명령어를 통해 동기화를 한다.

kubectl logs po/example-tenant-tf-runner -nflux-system -f 명령어로 테라폼이 실행되는지 확인한다.

위 사진과 같이 테라폼이 실행되어 자원이 제거된 것을 확인할 수 있다.

실제 콘솔에서도 관련 자원이 삭제가 된 것을 확인할 수 있다.

그리고 해당 terraform apply 등 내용이 secret 에 저장이 된다.

kubectl get secret -n flux-system | grep -E 'tfplan|tfstate' 를 이용해 확인 할 수 있다.

헬름차트 실습

tree /home/ec2-user/environment/gitops-gitea-repo/helm-charts/ 명령어로 헬름차트를 확인해 보자.

bash
helm-charts/
├── helm-tenant-chart/       # 테넌트 애플리케이션용 (Producer + Consumer 묶음)
   ├── Chart.yaml           # 차트 메타데이터 (이름, 버전, 설명)
   ├── values.yaml          # 기본 설정값
   └── templates/           # Kubernetes 매니페스트 템플릿
       ├── deployment.yaml
       ├── service.yaml
       ├── ingress.yaml
       ├── hpa.yaml
       └── serviceaccount.yaml
└── application-chart/       # 애플리케이션 1:1 대응 (테넌트 구분 없음)
bash
cat << EOF > /home/ec2-user/environment/gitops-gitea-repo/helm-charts/helm-tenant-chart/test-values.yaml
tenantId: "example-tenant"
 
apps:
  producer:
    enabled: true
 
  consumer:
    enabled: true
EOF

test-value 로 헬름 차트를 렌더링을 해 보자.

helm template example-tenant ./helm-charts/helm-tenant-chart --values ./helm-charts/helm-tenant-chart/test-values.yaml

추가로 다시 false 로 변경 이후에 렌더링 해보자.

bash
cat << EOF > /home/ec2-user/environment/gitops-gitea-repo/helm-charts/helm-tenant-chart/test-values.yaml
tenantId: "example-tenant"
 
apps:
  producer:
    enabled: false
 
  consumer:
    enabled: true
EOF

helm template example-tenant ./helm-charts/helm-tenant-chart --values ./helm-charts/helm-tenant-chart/test-values.yaml

로 또 렌더링을 해 보자. 두개의 차이를 확인 해 보자.

이런 식으로 values 를 관리하는 형태로 helm 의 value 값을 설정해서 관리한다.

ECR 에 패키징 된 헬름차트 확인

bash
# Get values from configmap
AWS_ACCOUNT_ID=$(kubectl get configmap saas-infra-outputs -n flux-system -o jsonpath='{.data.account_id}')
ECR_HELM_CHART_URL=$(kubectl get configmap saas-infra-outputs -n flux-system -o jsonpath='{.data.ecr_helm_chart_url}')
ECR_REGISTRY=$(echo $ECR_HELM_CHART_URL | cut -d'/' -f1)
ECR_REPOSITORY=$(echo $ECR_HELM_CHART_URL | cut -d'/' -f2-)
AWS_REGION=$(echo $ECR_HELM_CHART_URL | cut -d'.' -f4)
 
# Authenticate Docker to ECR
aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $ECR_REGISTRY
 
# List artifacts (images) in the ECR repository
aws ecr list-images --repository-name $ECR_REPOSITORY --region $AWS_REGION

0.0.1 로 태깅이 되어 있는 것을 볼 수 있다.

HelmRelease 개념

HelmRelease 는 Flux 가 제공하는 CRD 로, Helm 릴리스를 선언적으로 관리할 수 있게 해 준다.

bash
HelmRelease (Git에 선언)


Flux Helm Controller


HelmRepository (ECR의 Helm 차트 참조)


Kubernetes 클러스터에 실제 배포

HelmRelease 를 만들어 본다.

bash
cat << EOF > /home/ec2-user/environment/gitops-gitea-repo/application-plane/production/tenants/example-tenant-helmrelease.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: example-tenant
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: example-tenant-premium
  namespace: flux-system
spec:
  releaseName: example-tenant-premium
  targetNamespace: example-tenant
  storageNamespace: example-tenant
  interval: 1m0s
  chart:
    spec:
      chart: helm-tenant-chart
      version: "0.x"
      sourceRef:
        kind: HelmRepository
        name: helm-tenant-chart
  values:
    tenantId: example-tenant
    apps:
      producer:
        enabled: true
      consumer:
        enabled: true
EOF

마지막 줄에 하나 추가한다.

bash
cat << EOF >> /home/ec2-user/environment/gitops-gitea-repo/application-plane/production/tenants/kustomization.yaml
    - example-tenant-helmrelease.yaml
EOF

나의 경우 마지막 부분이 indentation 이 한칸 뒤로 가 있어서 적용이 안되었었다. indentation 을 맞추어 준다.

커밋 + 푸시한다.

bash
cd /home/ec2-user/environment/gitops-gitea-repo/
git pull origin main
git add .
git commit -m "Added HelmRelease for example-tenant"
git push origin main

동기화 한다.

flux reconcile source git flux-system

kubectl logs po/example-tenant-tf-runner -nflux-system -f 로그를 확인한다.

생성 확인

네임스페이스, 네임스페이스 내 자원, AWS 자원 등을 아래 명령어로 확인 한다.

bash
kubectl get namespaces
kubectl get all -n example-tenant
aws dynamodb list-tables
aws sqs list-queues

생성 된 것을 볼 수 있다.

제거

bash
rm /home/ec2-user/environment/gitops-gitea-repo/application-plane/production/tenants/example-tenant-helmrelease.yaml
sed -i '/example-tenant-helmrelease.yaml/d' /home/ec2-user/environment/gitops-gitea-repo/application-plane/production/tenants/kustomization.yaml
 
# 커밋 및 푸시
cd /home/ec2-user/environment/gitops-gitea-repo/
git pull origin main
git add .
git commit -m "Removed HelmRelease for example-tenant"
git push origin main
 
# 동기화
flux reconcile source git flux-system
 
kubectl logs po/example-tenant-tf-runner -nflux-system -f
 
# 자원 확인
kubectl get namespaces
kubectl get all -n example-tenant
aws dynamodb list-tables
aws sqs list-queues

정리

인프라 코드를 기준으로 실제 인프라가 맞춰지는 것을 볼 수 있었다.

SaaS 티어 전략

티어별 다른 관리 형태로 관리하는 부분을 실습해 본다.

bash
cat << EOF > /home/ec2-user/environment/gitops-gitea-repo/application-plane/production/tier-templates/advanced_tenant_template.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: {TENANT_ID}
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: {TENANT_ID}-advanced
  namespace: flux-system
spec:
  releaseName: {TENANT_ID}-advanced
  targetNamespace: {TENANT_ID}  # Deploying into the tenant-specific namespace
  interval: 1m0s
  chart:
    spec:
      chart: helm-tenant-chart
      version: "{RELEASE_VERSION}.x"
      sourceRef:
        kind: HelmRepository
        name: helm-tenant-chart
  values:
    tenantId: {TENANT_ID}
    apps:
      producer:
        envId: pool-1
        enabled: false # Pool deployment -- advanced tier shares resources with other tenants
        ingress:
          enabled: true
      consumer:
        enabled: true  # Silo deployment -- advanced tier has a dedicated deployment for each tenant
        ingress:
          enabled: true
        image:
          tag: "0.1" # {"\$imagepolicy": "flux-system:consumer-image-policy:tag"}
EOF

테넌트 추가할 폴더를 생성한다.

mkdir -p /home/ec2-user/environment/gitops-gitea-repo/application-plane/production/tenants/advanced

고급 테넌트를 수동 프로비저닝 한다.

bash
export TENANT_ID=tenant-t1d6c
export RELEASE_VERSION=0.0
 
cd /home/ec2-user/environment/gitops-gitea-repo/application-plane/production/
cp tier-templates/advanced_tenant_template.yaml tenants/advanced/$TENANT_ID.yaml
 
sed -i "s|{TENANT_ID}|$TENANT_ID|g" "tenants/advanced/$TENANT_ID.yaml"
sed -i "s|{RELEASE_VERSION}|$RELEASE_VERSION|g" "tenants/advanced/$TENANT_ID.yaml"
bash
cat << EOF > tenants/advanced/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - $TENANT_ID.yaml
EOF
bash
cd /home/ec2-user/environment/gitops-gitea-repo/
git pull origin main
git add .
git commit -am "Adding tenant-t1d6c with Advanced Tier"
git push origin main

동기화를 해 준다.

flux reconcile source git flux-system

다음과 같이 생성 된 것을 볼 수 있다.

자동화된 테넌트 온보딩

세 가지 워크플로우 템플릿이 사전 구성되어 있다. 확인 해 보자.

kubectl get workflowtemplates -n argo-workflows

bash
[ec2-user@ip-10-0-1-93 gitops-gitea-repo]$ kubectl get workflowtemplates -n argo-workflows
NAME                          AGE
tenant-deployment-template    2d8h
tenant-offboarding-template   2d8h
tenant-onboarding-template    2d8h

다음과 같이 템플릿 3개가 보이는 것을 볼 수 있다.

bash
SQS 메시지 수신 (Argo Events + Sensor)


온보딩 워크플로우 트리거


Gitea 저장소 클론


티어 템플릿 기반 테넌트 HelmRelease 파일 생성
(Basic / Advanced / Premium)


Git 커밋 & 푸시


Flux v2가 변경 감지 EKS에 리소스 배포

큐를 이용해서 Premium 테넌트를 온보딩 해보는 실습이다. SQS 에 데이터를 넣는다.

bash
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"
    }'

워크플로우를 확인해 본다.

kubectl -n argo-workflows get workflow

ArgoCD 의 화면을 봐 보자.

bash
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

다음과 같이 확인 해 볼 수 있다.

그리고 자동으로 커밋의 경우에도 추가가 되어 있다.

티어별 리소스 비교

tenant-2 (basic), tenant-3 (advanced) 도 동일한 방식으로 SQS 메시지로 온보딩 했다. 이제 티어별로 실제 Kubernetes 리소스가 어떻게 배포 되었는지 비교 해 보자.

bash
# Premium 티어: Producer + Consumer 전용 배포
kubectl -n tenant-1 get deployment
bash
NAME                READY   UP-TO-DATE   AVAILABLE   AGE
tenant-1-consumer   3/3     3            3           5m48s
tenant-1-producer   3/3     3            3           5m48s
bash
# Basic 티어: 전용 Deployment 없음 (pool-1 공유 사용)
kubectl -n tenant-2 get deployment
bash
No resources found in tenant-2 namespace.
bash
# Advanced 티어: Consumer 만 전용 배포
kubectl -n tenant-3 get deployment
bash
NAME                READY   UP-TO-DATE   AVAILABLE   AGE
tenant-3-consumer   3/3     3            3           4m29s
bash
# Pool-1: Basic + Advanced 테넌트가 공유하는 환경
kubectl -n pool-1 get deployment
bash
NAME              READY   UP-TO-DATE   AVAILABLE   AGE
pool-1-consumer   3/3     3            3           18h
pool-1-producer   3/3     3            3           18h

pool-1 네임스페이스의 Ingress 를 확인 해 보면 Basic 티어 (tenant-2) 는 Producer/Consumer 라우팅이 모두 있고, Advanced 티어 (tenant-3) 는 Producer 라우팅만 있는 것을 볼 수 있다.

bash
kubectl get ingress -n pool-1

인프라 리소스 확인

Tofu 컨트롤러가 생성한 Terraform 상태 파일도 같이 확인 해 본다.

bash
kubectl get secrets -n flux-system | grep -i state
bash
tfstate-default-pool-1       Opaque   1   44m
tfstate-default-tenant-1     Opaque   1   23m
tfstate-default-tenant-2     Opaque   1   15m
tfstate-default-tenant-3     Opaque   1   10m

여기서 한 가지 짚고 넘어 갈 점은, Basic 티어 (tenant-2) 에도 Terraform 상태가 존재 한다는 것이다. Kubernetes 리소스는 pool-1 을 공유 하지만, 테넌트 식별 정보 같은 일부 인프라 설정은 테넌트 별로 따로 관리 된다는 의미로 보면 될 것 같다.

테넌트 배포 검증

모든 테넌트가 동일한 ALB 를 사용 하고, tenantID 헤더로 테넌트 별 환경으로 라우팅 된다.

bash
export APP_LB=http://$(kubectl get ingress -n tenant-1 -o json | jq -r .items[0].status.loadBalancer.ingress[0].hostname)

tenant-1 (Premium 티어) 부터 호출 해 본다.

bash
curl -s -H "tenantID: tenant-1" $APP_LB/producer | jq
curl -s -H "tenantID: tenant-1" $APP_LB/consumer | jq
bash
{ "environment": "tenant-1", "microservice": "producer", "tenant_id": "tenant-1", "version": "0.0.1" }
{ "environment": "tenant-1", "microservice": "consumer", "tenant_id": "tenant-1", "version": "0.0.1" }

tenant-2 (Basic 티어) 도 호출 해 본다.

bash
curl -s -H "tenantID: tenant-2" $APP_LB/producer | jq
curl -s -H "tenantID: tenant-2" $APP_LB/consumer | jq
bash
{ "environment": "pool-1", "microservice": "producer", "tenant_id": "tenant-2", "version": "0.0.1" }
{ "environment": "pool-1", "microservice": "consumer", "tenant_id": "tenant-2", "version": "0.0.1" }

tenant-3 (Advanced 티어) 의 결과는 좀 다르다.

bash
curl -s -H "tenantID: tenant-3" $APP_LB/producer | jq
curl -s -H "tenantID: tenant-3" $APP_LB/consumer | jq
bash
{ "environment": "pool-1",   "microservice": "producer", "tenant_id": "tenant-3", "version": "0.0.1" }
{ "environment": "tenant-3", "microservice": "consumer", "tenant_id": "tenant-3", "version": "0.0.1" }

응답의 environment 필드를 보면 각 티어의 배포 전략이 그대로 찍히는 것을 볼 수 있다.

테넌트티어Producer 환경Consumer 환경
tenant-1Premiumtenant-1 (전용)tenant-1 (전용)
tenant-2Basicpool-1 (공유)pool-1 (공유)
tenant-3Advancedpool-1 (공유)tenant-3 (전용)

Advanced 티어가 producer 는 공유 풀 (pool-1) 에서, consumer 는 전용 (tenant-3) 에서 응답 하는 것을 볼 수 있다. 처음에 티어 템플릿에서 producer.enabled: false, consumer.enabled: true 로 설정 했던 부분이 실제 응답으로 그대로 드러나는 셈이다.

End-to-End 검증

이번엔 Producer 에 POST 를 보내고, Consumer 가 DynamoDB 에 데이터를 저장 하는지 확인 해 본다.

bash
# 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

데이터가 정상적으로 저장 되었다면 아래와 같은 응답이 반환 된다.

bash
{
    "Items": [
        {
            "consumer_environment": { "S": "tenant-3" },
            "producer_environment": { "S": "pool-1" },
            "message_id": { "S": "721accc6-e2c5-4885-b8a5-afc13c247cec" },
            "tenant_id": { "S": "tenant-3" },
            "timestamp": { "S": "2024-07-12T16:31:39+0000" }
        }
    ],
    "Count": 1
}

producer_environment: pool-1consumer_environment: tenant-3 이 같이 기록 된 것을 볼 수 있다. Advanced 티어의 혼합 모델이 데이터 레벨에서도 그대로 동작 한다는 걸 확인 할 수 있다.

테넌트 오프보딩

온보딩과 동일한 이벤트 드리븐 방식으로 오프보딩도 처리 된다. 실습 2 에서 수동으로 만들었던 tenant-t1d6c 를 오프보딩 해 본다.

오프보딩 SQS 큐로 메시지를 보낸다.

bash
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-t1d6c",
        "tenant_tier": "advanced"
    }'

오프보딩 워크플로우가 생성 되었는지 확인 해 본다.

bash
kubectl -n argo-workflows get workflow
bash
[ec2-user@ip-10-0-1-93 gitops-gitea-repo]$ kubectl -n argo-workflows get workflow
NAME                       STATUS      AGE     MESSAGE
tenant-offboarding-x7wr6   Succeeded   6m47s   
tenant-onboarding-vxcn2    Succeeded   16m     

Argo Workflows Web UI 에서도 오프보딩 진행 상황을 모니터링 할 수 있다.

bash
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

온보딩과 정확히 대칭으로 — SQS 메시지 한 건이면 테넌트 관련 자원 (HelmRelease, Terraform CRD, Git 파일) 이 모두 정리 되는 흐름이다.

마무리

오늘 실습을 통해 다음 흐름을 한 번에 확인 해 볼 수 있었다.

  • Flux + Tofu Controller: Git 에 Terraform CRD 를 푸시 하면 SQS, DynamoDB 같은 AWS 자원이 자동으로 생성/삭제 되는 흐름
  • HelmRelease: ECR 에 올라간 Helm 차트를 Git 선언만으로 클러스터에 배포
  • 티어 템플릿: Basic / Advanced / Premium 별로 Pool/Silo 형태를 다르게 가져가는 SaaS 멀티 테넌시 패턴
  • Argo Workflows + Argo Events: SQS 메시지 한 건으로 테넌트 온보딩 / 오프보딩이 자동화
  • End-to-End 검증: tenantID 헤더 기반 라우팅과 DynamoDB 까지의 데이터 흐름이 티어 별 배포 전략 그대로 응답에 찍히는 것

직접 실습 하면서 가장 와닿았던 부분은 kustomization.yaml 의 indentation 한 칸 차이로 적용이 안 되었던 부분이었다. GitOps 는 결국 Git 에 들어간 그 값이 그대로 클러스터에 반영 되기 때문에, 코드 리뷰와 lint 가 일반 PR 보다 더 중요 해 진다는 걸 다시 한 번 느꼈다.

그리고 Advanced 티어의 응답에서 producer_environmentconsumer_environment 가 서로 다른 값으로 찍히는 걸 직접 보니까, 티어 템플릿의 enabled: true/false 한 줄이 결국 SaaS 의 비용 구조와 격리 수준을 결정 한다는 게 좀 더 와 닿았다.

다음에는 이 구조를 홈랩의 RKE2 클러스터에도 비슷하게 적용 해 보고, ECR 대신 Harbor 를 사용 했을 때 어떤 차이가 있는 지 정리 해 볼 예정이다.

logo
이 블로그는 NextJS 14, Strapi를 이용해 만들었습니다
Copyright © 2026 김기찬
문의사항은 admin@devchanki.com 으로 주시면 감사 하겠습니다.

문의 보내기

남겨주면 블로그 주인에게 바로 전달돼요.

답장은 별도도 확인 후 연락 드려요.