여기서 핵심 포인트는 운영자가 새 테넌트를 추가할 때 AWS 콘솔, kubectl & terraform apply 등
명령어를 칠 필요가 없습니다.
Argo Workflows로 온보딩을 트리거하면 Gitea에 설정 파일이 생성되고, Flux가 감지해서
앱 배포와 AWS 리소스 생성을 전부 자동으로 처리해줘요. 이게 플랫폼 엔지니어링 입니다!
Why is a platform Engineering structure necessary?
실습 환경 아키텍처에 대해서 설명드렸는데, 저도 처음에 감이 안잡혀서 부연 설명을 드리고자 합니다.
이런 플랫폼 엔지니어링 구조가 명확하게 왜 필요할까?
먼저, 일반적인 스타트업이 SaaS를 운영한다고 가정해봅시다.
고객(테넌트)이 100명이면 → 100개의 환경을 만들어야 합니다.
각 테넌트마다 DB, 큐, 앱 배포를 수동으로 하면 저희 운영팀이 힘들어요 ..
그래서 "테넌트 추가 = Git에 설정 파일 하나 올리면 끝" 이 되도록 자동화하는 게 이번 주차 핵심 !
위에서 설명드린 플랫폼 엔지니어링 아키텍처를 구현하기 위한 도구들을 설명드리겠습니다.
Gitea란? GitHub를 내 서버에 직접 설치해서 쓰는 것
GitHub, GitLab을 써봤나요? 이것들은 SaaS입니다. 즉 남의 서버에 올리는 거에요.
Gitea는 그걸 내 서버(여기서는 EKS)에 직접 설치해서 쓰는 오픈소스 Git 서버 입니다.
그렇다면 이번 실습에서 Gitea를 쓰는 이유는 추측해보기에는 ..
AWS Workshop 환경이라 인터넷의 GitHub에 접근하기 어려운 구조
실제 기업 환경도 보안상 내부망에 Git 서버를 두는 경우가 많음
Gitea Actions = GitHub Actions랑 문법이 거의 동일한 CI 기능도 내장
Flux v2란? 변경이 생기면 클러스터에 자동으로 반영해주는 쿠버네티스 컨트롤러
기존 방식이랑 비교해볼까요?
[기존 Push 방식]
운영자 → kubectl apply 또는 helm install → 클러스터 반영
문제: 누군가 kubectl로 직접 클러스터를 수동 변경하면 Git이랑 상태가 어긋남
[Flux GitOps 방식]
운영자 → Git에 Push → Flux가 감지 → 클러스터 자동 반영
장점: 클러스터 상태는 항상 Git 내용과 일치가 보장됨
Flux는 EKS 안에 Pod으로 떠 있고, Gitea 저장소를 30초~1분 간격으로 polling 합니다.
즉, 변경이 감지되면 알아서 helm upgrade 또는 kubectl apply를 실행합니다.
Tofu Controller란? Terraform 코드를 EKS Pod 안에서 실행시켜주는 컨트롤러
원래 Terraform은 운영자 로컬 PC에서 'terraform apply'를 통해 Provider 리소스를 생성합니다.
그러면 Tofu를 사용하면 뭐가 다를까요?
Git에 Terraform 코드 Push
↓
Flux가 감지
↓
Tofu Controller Pod이 terraform apply 실행
↓
AWS 리소스(DynamoDB, SQS 등) 생성
즉, Terraform을 실행하는 주체가 운영자 로컬PC / CI 서버 → EKS Pod으로 바뀌는 거에요.
이게 왜 좋냐면 테넌트가 추가될 때마다 Git에 설정 파일 하나만 올리면 Flux → Tofu Controller가 알아서
AWS 리소스까지 만들어줍니다. 사람이 'terraform apply'를 칠 필요가 없겠죠 ?
참고로 OpenTofu는 Terraform의 오픈소스 포크입니다. HashiCorp가 Terraform 라이선스를 변경한 이후에 커뮤니티가 만든 대안이고, 문법은 거의 동일합니다.
Argo Workflows란? 쿠버네티스 위에서 실행되는 작업 자동화 도구
Argo CD(배포 도구)랑 헷갈릴 수 있는데 다른 도구이고, Jenkins 같은 파이프라인과 유사합니다.
Argo CD = GitOps 배포 도구 (Flux랑 경쟁 관계)
Argo Workflows = 순서가 있는 작업(Job)을 실행하는 워크플로우 엔진
이번 실습에서 Argo Workflows의 역할은 아래와 같습니다.
새 테넌트 추가 요청 발생
↓
Argo Workflows "Onboarding" 워크플로우 실행
↓
Step 1: Gitea에 테넌트 설정 파일 생성 (Git Push)
Step 2: Flux가 변경 감지
Step 3: Tofu Controller가 DynamoDB/SQS 생성
Step 4: Helm이 앱 배포
↓
테넌트 환경 완성
반대로 테넌트 해지 시엔 "Offboarding" 워크플로우가 이 과정을 역순으로 실행해서 리소스를 정리합니다.
📌 이번 SaaS 실습에서 실행하는 마이크로 서비스
현재 소스코드는 Gitea Microservice Repository에서 관리 되고 있는 'Producer' , 'Consumer' 라는 앱이
실행중이고 코드가 Push되면 Gitea Actions가 자동으로 빌드해서 ECR에 이미지 업로드 합니다.
이때 그 이미지를 Flux + Helm이 가져다가 EKS에 배포하는 실습을 구현해볼 예정입니다.
프로비저닝해야 하는데, 그게 앞서 설명한 Tofu Controller의 역할이 훨씬 더 복잡해집니다.
04. tenant-apps 모듈 - Silo/Pool을 코드로 구현하는 방법
위에서 Silo, Pool, Hybrid 개념을 설명드렸는데, 그럼 실제로 이게 코드에서 어떻게 구현되어 있을까요?
이번 실습의 tenant-apps Terraform 모듈이 그 답입니다.
테넌트가 추가될 때마다 Tofu Controller가 호출하는 모듈은 tenant-apps 하나뿐입니다.
나머지 모듈(gitops-saas-infra, flux_cd 등)은 워크숍 환경을 사전에 한 번만 프로비저닝할 때 사용하고, 이후 테넌트가 늘어날 때마다 실행되는 건 tenant-apps가 전부입니다.
terraform/modules/
├── flux_cd/ # 워크숍 초기 1회 실행
├── gitea/ # 워크숍 초기 1회 실행
├── gitops-saas-infra/ # 워크숍 초기 1회 실행 (EKS, VPC 등)
└── tenant-apps/ # 테넌트가 추가될 때마다 실행되는 런타임 모듈
├── data.tf
├── main.tf
├── outputs.tf
├── variables.tf
└── versions.tf
입력 변수 3가지
tenant-apps 모듈의 입력 변수는 세 가지입니다.
변수
설명
tenant_id
테넌트 식별자. 리소스 이름과 SSM Parameter 경로에 사용됨
enable_producer
true → 전용 Producer IRSA Role 생성 (Silo) / false → 공유 Pool Role 재사용 (Pool)
enable_consumer
Consumer 관련 리소스(SQS, DynamoDB, IAM Role 등) 생성 여부
여기서 핵심은 enable_producer 입니다.
이 플래그 하나가 앞서 설명한 Silo(전용) vs Pool(공유)을 인프라 코드로 구현하는 분기점입니다.
📌main.tf 조건 분기
전용 모드 (enable_producer = true) → Silo 티어
# 테넌트 전용 Producer IRSA Role 생성
module "producer_irsa_role" {
count = var.enable_producer == true && var.enable_consumer == true ? 1 : 0
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
version = "5.30.0"
role_name = "producer-role-${var.tenant_id}" # 테넌트별 전용 Role
oidc_providers = {
main = {
provider_arn = local.irsa_principal_arn
namespace_service_accounts = ["${var.tenant_id}:${var.tenant_id}-producer"]
}
}
}
공유 모드 (enable_producer = false) → Pool 티어
# 전용 Role 대신, 공유 풀의 기존 Role에 Policy만 연결
resource "aws_iam_role_policy_attachment" "sto-readonly-role-policy-attach" {
count = var.enable_producer == false && var.enable_consumer == true ? 1 : 0
role = "producer-role-pool-1" # 공유 Role 재사용
policy_arn = aws_iam_policy.producer-iampolicy[0].arn
}
두 count 조건이 상호 배타적입니다. enable_producer = true면 전용 Role + Policy 연결 = 2개 리소스,
false면 공유 Role에 Policy 연결 = 1개 리소스가 생성됩니다.
이 1개 차이가 아래 terraform plan 결과에서 11개 vs 10개로 나타납니다.
$ terraform plan
# data source 읽기
module.test_tenant_apps.module.consumer_irsa_role[0].data.aws_caller_identity.current: Reading...
module.test_tenant_apps.module.producer_irsa_role[0].data.aws_caller_identity.current: Reading...
module.test_tenant_apps.data.aws_eks_cluster.eks-saas-gitops: Reading...
module.test_tenant_apps.data.aws_caller_identity.current: Read complete after 0s [id=123456789012]
module.test_tenant_apps.data.aws_eks_cluster.eks-saas-gitops: Read complete after 0s [id=eks-saas-gitops]
Terraform used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# module.test_tenant_apps.aws_dynamodb_table.consumer_ddb[0] will be created
+ resource "aws_dynamodb_table" "consumer_ddb" {
+ arn = (known after apply)
+ billing_mode = "PAY_PER_REQUEST"
+ hash_key = "tenant_id"
+ id = (known after apply)
+ name = (known after apply)
+ range_key = "message_id"
+ tags = {
+ "Name" = "test"
}
+ write_capacity = (known after apply)
+ attribute {
+ name = "message_id"
+ type = "S"
}
+ attribute {
+ name = "tenant_id"
+ type = "S"
}
}
# module.test_tenant_apps.aws_iam_policy.consumer-iampolicy[0] will be created
+ resource "aws_iam_policy" "consumer-iampolicy" {
+ arn = (known after apply)
+ id = (known after apply)
+ name = "consumer-policy-test"
+ path = "/"
+ policy = (known after apply)
}
# module.test_tenant_apps.aws_iam_policy.producer-iampolicy[0] will be created
+ resource "aws_iam_policy" "producer-iampolicy" {
+ arn = (known after apply)
+ id = (known after apply)
+ name = "producer-policy-test"
+ path = "/"
+ policy = (known after apply)
}
# module.test_tenant_apps.aws_sqs_queue.consumer_sqs[0] will be created
+ resource "aws_sqs_queue" "consumer_sqs" {
+ arn = (known after apply)
+ delay_seconds = 0
+ fifo_queue = false
+ id = (known after apply)
+ max_message_size = 262144
+ message_retention_seconds = 345600
+ name = (known after apply)
+ tags = {
+ "Name" = "test"
}
+ visibility_timeout_seconds = 30
}
# module.test_tenant_apps.aws_ssm_parameter.dedicated_consumer_ddb[0] will be created
+ resource "aws_ssm_parameter" "dedicated_consumer_ddb" {
+ arn = (known after apply)
+ id = (known after apply)
+ name = "/test/consumer_ddb"
+ type = "String"
+ value = (sensitive value)
}
# module.test_tenant_apps.aws_ssm_parameter.dedicated_consumer_sqs[0] will be created
+ resource "aws_ssm_parameter" "dedicated_consumer_sqs" {
+ arn = (known after apply)
+ id = (known after apply)
+ name = "/test/consumer_sqs"
+ type = "String"
+ value = (sensitive value)
}
# module.test_tenant_apps.random_string.random_suffix will be created
+ resource "random_string" "random_suffix" {
+ id = (known after apply)
+ length = 3
+ lower = true
+ result = (known after apply)
+ special = false
+ upper = false
}
# module.test_tenant_apps.module.consumer_irsa_role[0].aws_iam_role.this[0] will be created
+ resource "aws_iam_role" "this" {
+ arn = (known after apply)
+ assume_role_policy = jsonencode(
{
+ Statement = [
+ {
+ Action = "sts:AssumeRoleWithWebIdentity"
+ Condition = {
+ StringEquals = {
+ "oidc.eks.ap-northeast-2.amazonaws.com/id/ABCD1234EFGH5678IJKL9012MNOP3456:aud" = "sts.amazonaws.com"
+ "oidc.eks.ap-northeast-2.amazonaws.com/id/ABCD1234EFGH5678IJKL9012MNOP3456:sub" = "system:serviceaccount:test:test-consumer"
}
}
+ Effect = "Allow"
+ Principal = {
+ Federated = "arn:aws:iam::123456789012:oidc-provider/oidc.eks.ap-northeast-2.amazonaws.com/id/ABCD1234EFGH5678IJKL9012MNOP3456"
}
},
]
+ Version = "2012-10-17"
}
)
+ force_detach_policies = true
+ id = (known after apply)
+ max_session_duration = 3600
+ name = "consumer-role-test"
+ path = "/"
}
# module.test_tenant_apps.module.consumer_irsa_role[0].aws_iam_role_policy_attachment.this["policy"] will be created
+ resource "aws_iam_role_policy_attachment" "this" {
+ id = (known after apply)
+ policy_arn = (known after apply)
+ role = "consumer-role-test"
}
# module.test_tenant_apps.module.producer_irsa_role[0].aws_iam_role.this[0] will be created
+ resource "aws_iam_role" "this" {
+ arn = (known after apply)
+ assume_role_policy = jsonencode(
{
+ Statement = [
+ {
+ Action = "sts:AssumeRoleWithWebIdentity"
+ Condition = {
+ StringEquals = {
+ "oidc.eks.ap-northeast-2.amazonaws.com/id/ABCD1234EFGH5678IJKL9012MNOP3456:aud" = "sts.amazonaws.com"
+ "oidc.eks.ap-northeast-2.amazonaws.com/id/ABCD1234EFGH5678IJKL9012MNOP3456:sub" = "system:serviceaccount:test:test-producer"
}
}
+ Effect = "Allow"
+ Principal = {
+ Federated = "arn:aws:iam::123456789012:oidc-provider/oidc.eks.ap-northeast-2.amazonaws.com/id/ABCD1234EFGH5678IJKL9012MNOP3456"
}
},
]
+ Version = "2012-10-17"
}
)
+ force_detach_policies = true
+ id = (known after apply)
+ max_session_duration = 3600
+ name = "producer-role-test"
+ path = "/"
}
# module.test_tenant_apps.module.producer_irsa_role[0].aws_iam_role_policy_attachment.this["policy"] will be created
+ resource "aws_iam_role_policy_attachment" "this" {
+ id = (known after apply)
+ policy_arn = (known after apply)
+ role = "producer-role-test"
}
Plan: 11 to add, 0 to change, 0 to destroy.
IRSA 동작 흐름은 아래와 같습니다.
Producer Pod 기동
↓
Pod에 연결된 ServiceAccount (test-producer) 확인
↓
EKS OIDC Provider를 통해 JWT 토큰 발급
↓
AWS STS에 AssumeRoleWithWebIdentity 요청
↓
producer-role-test IAM Role Assume 성공
↓
SQS 메시지 발행 권한 획득
노드 전체에 IAM Role을 부여하는 방식과 달리, Pod 단위로 AWS 접근 권한을 세밀하게 제어할 수 있습니다.