diff --git a/.gitignore b/.gitignore index 0dc9b13..67e666a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,11 @@ dist/ *.log .plan/ .claude/session-log.md + +# Terraform +*.tfstate +*.tfstate.* +.terraform/ +.terraform.lock.hcl +*.tfvars +!*.tfvars.example diff --git a/CLAUDE.md b/CLAUDE.md index dbf6476..de98d68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,12 +5,12 @@ DevOps 시뮬레이터 프로젝트. ## 브랜치 전략 ``` -main ← week1, week2, week3, ... +main ← feat/* PR merge ``` -- 주차 단위로 브랜치 생성 (`week1`, `week2`, ...) -- 주차 브랜치에서 기능 단위로 커밋 후 PR → main merge -- 멘토 리뷰는 PR 단위로 진행 +- 기능 단위로 `feat/` 브랜치 생성 +- PR → main merge (멘토 리뷰) +- 릴리즈는 GitHub Tag/Release로 관리 예정 ## 커밋 컨벤션 @@ -25,12 +25,64 @@ test: 테스트 ## 모노레포 구조 -- `packages/api` — Fastify 앱 (대상 서비스) -- `packages/shared` — 공통 유틸리티 -- `infra/` — Docker, K8s, Terraform -- `scenarios/` — 장애 시나리오 스크립트 +``` +packages/ + api/ Fastify CRUD API + /chaos/* + /metrics + shared/ 공통 유틸리티 (pino logger, types) + +infra/ + docker/ docker-compose 로컬 실행 + k8s/ Kustomize 매니페스트 (base + overlays/local) + helm/ + api/ api Helm Chart (Deployment, Service, Ingress, migrate Job) + db/ PostgreSQL Helm Chart (StatefulSet, Service) + terraform/ AWS 인프라 IaC (VPC, EKS, ECR) +``` -## 레이어 구조 (api) +## 현재 배포 상태 + +### 로컬 (docker-compose) +```bash +cd infra/docker && docker compose up -d --build +``` + +### 로컬 K8s (minikube + Kustomize) +```bash +kubectl apply -k infra/k8s/overlays/local +``` + +### 로컬 K8s (minikube + Helm) +```bash +helm install db infra/helm/db +helm install api infra/helm/api -f infra/helm/api/values-local.yaml +``` + +### AWS EKS (Helm) +```bash +# 이미지 빌드 (amd64 필수) +docker buildx build --platform linux/amd64 \ + -t 893286712531.dkr.ecr.us-east-2.amazonaws.com/devopsim/api:0.0.1 \ + -f packages/api/Dockerfile --push . + +helm install db infra/helm/db +helm install api infra/helm/api -f infra/helm/api/values-production.yaml +``` + +## AWS 인프라 (Terraform) + +- **리전**: us-east-2 +- **클러스터**: devopsim-prod-cluster (K8s 1.35) +- **노드**: t3.medium × 2 +- **ECR**: 893286712531.dkr.ecr.us-east-2.amazonaws.com/devopsim/api +- **State**: s3://nurihaus-terraform-state/devopsim/terraform.tfstate + +```bash +cd infra/terraform +terraform apply # VPC + EKS + ECR +aws eks update-kubeconfig --region us-east-2 --name devopsim-prod-cluster --profile devopsim +``` + +## api 레이어 구조 ``` src/ @@ -45,7 +97,7 @@ src/ index.ts → listen만 담당 test/ → Vitest 테스트 -migrations/ → SQL 마이그레이션 파일 +migrations/ → node-pg-migrate JS 마이그레이션 파일 ``` ### 의존성 방향 @@ -62,3 +114,23 @@ domain ← 아무것도 import 안 함 - `AppError(statusCode, message)` throw → `setErrorHandler`에서 일괄 처리 - Fastify schema validation 에러 → `error.validation` 체크 후 400 반환 + +## API 엔드포인트 + +| Method | Path | 설명 | +|--------|------|------| +| GET | /health | liveness | +| GET | /ready | readiness (DB 연결 확인) | +| GET | /api/version | 버전 확인 | +| POST | /api/items | 아이템 생성 | +| GET | /api/items | 목록 조회 | +| GET | /api/items/:id | 상세 조회 | +| PUT | /api/items/:id | 수정 | +| DELETE | /api/items/:id | 삭제 | + +## 주의사항 + +- EKS 배포 시 반드시 `--platform linux/amd64`로 빌드 (M-series Mac → amd64 EKS) +- db StatefulSet에 `PGDATA=/var/lib/postgresql/data/pgdata` 필요 (EBS lost+found 회피) +- migrate Job은 Helm pre-install hook — db Chart 먼저 설치 후 api Chart 설치 +- Secrets는 `kubectl create secret`으로 직접 생성 (Helm Chart에 포함 안 함) diff --git a/docs/terraform.md b/docs/terraform.md new file mode 100644 index 0000000..e9ab1b5 --- /dev/null +++ b/docs/terraform.md @@ -0,0 +1,174 @@ +# Terraform 인프라 구성 기록 + +## 개요 + +AWS 리전: `us-east-2` (Ohio) +Terraform 버전: `>= 1.11.0` +AWS Provider: `~> 6.0` +State Backend: S3 (`nurihaus-terraform-state/devopsim/terraform.tfstate`) + +--- + +## 모듈 구조 + +``` +infra/terraform/ + versions.tf provider, terraform 버전 고정 + backend.tf S3 remote state 설정 + variables.tf 입력 변수 (region, project, VPC, EKS 설정) + main.tf 모듈 호출 (vpc, ecr, eks) + outputs.tf 출력값 (vpc_id, cluster_name, kubeconfig 명령어 등) + modules/ + vpc/ VPC, 서브넷, 게이트웨이, S3 VPC Endpoint + eks/ + main.tf EKS 클러스터, 노드그룹, IAM Role + irsa.tf OIDC Provider, IRSA 역할들 + addons.tf EKS Addon (EBS CSI Driver) + ecr/ ECR 리포지토리 +``` + +--- + +## 리소스 구성 + +### VPC (`modules/vpc/`) + +| 리소스 | 이름 | 설명 | +|---|---|---| +| VPC | devopsim-prod-vpc | 10.0.0.0/16 | +| Public Subnet | devopsim-prod-public-us-east-2a/b | 10.0.0.0/24, 10.0.1.0/24 | +| Private Subnet | devopsim-prod-private-us-east-2a/b | 10.0.10.0/24, 10.0.11.0/24 | +| Internet Gateway | devopsim-prod-igw | Public 서브넷 인터넷 출구 | +| NAT Gateway | devopsim-prod-nat | Private 서브넷 아웃바운드 출구 (1개) | +| Elastic IP | devopsim-prod-nat-eip | NAT Gateway용 고정 IP | +| Public Route Table | devopsim-prod-public-rt | 0.0.0.0/0 → IGW | +| Private Route Table | devopsim-prod-private-rt | 0.0.0.0/0 → NAT Gateway | +| S3 Gateway Endpoint | devopsim-prod-s3-endpoint | ECR 이미지 레이어 pull (무료) | + +**서브넷 태그 (ALB Controller가 사용):** +- Public: `kubernetes.io/role/elb: "1"` — 외부 ALB 배치용 +- Private: `kubernetes.io/role/internal-elb: "1"` — Internal ALB 배치용 + +**VPC Endpoint 결정:** + +| 타입 | 비용 | 결정 | +|---|---|---| +| Gateway (S3, DynamoDB) | 무료 | S3 적용 | +| Interface (ECR, STS 등) | ~$14/월/엔드포인트 | 미적용 | + +Interface Endpoint는 트래픽이 많을 때 NAT 비용 절감 효과가 있지만, 학습 프로젝트 수준에서는 NAT Gateway 데이터 처리 비용이 더 저렴합니다. 트래픽이 늘어나면 추가 검토합니다. + +--- + +### EKS (`modules/eks/`) + +**`main.tf` — 클러스터 + 노드그룹** + +| 리소스 | 설명 | +|---|---| +| EKS Cluster | devopsim-prod-cluster, K8s 1.35 | +| Cluster IAM Role | eks.amazonaws.com AssumeRole + AmazonEKSClusterPolicy | +| Managed Node Group | t3.medium, desired 2 / min 1 / max 3 | +| Node IAM Role | AmazonEKSWorkerNodePolicy, AmazonEKS_CNI_Policy, AmazonEC2ContainerRegistryReadOnly | + +EKS 엔드포인트: private + public 모두 활성화 (로컬 kubectl 접근) + +**`irsa.tf` — OIDC + IRSA** + +IRSA(IAM Role for Service Accounts): EKS OIDC Provider를 통해 특정 ServiceAccount에 IAM Role을 부여합니다. Pod에 static credential 없이 AWS API 접근이 가능합니다. + +``` +Pod → ServiceAccount → OIDC → IAM Role → AWS API +``` + +| IAM Role | ServiceAccount | 용도 | +|---|---|---| +| ebs-csi-role | kube-system/ebs-csi-controller-sa | EBS 볼륨 생성/관리 | +| alb-controller-role | kube-system/aws-load-balancer-controller | ALB 자동 생성 | +| external-secrets-role | external-secrets/external-secrets | Secrets Manager 접근 | + +**`addons.tf` — EKS Addon** + +| Addon | 용도 | +|---|---| +| aws-ebs-csi-driver | StatefulSet PVC → EBS 볼륨 자동 프로비저닝 | + +EKS 1.23+에서 EBS CSI Driver가 기본 내장에서 제거되어 별도 addon 설치 필요. + +--- + +### ECR (`modules/ecr/`) + +| 리소스 | 값 | +|---|---| +| Repository | devopsim/api | +| scan_on_push | true (CVE 스캔) | +| Lifecycle Policy | 최근 10개 이미지만 유지 | + +--- + +## 네트워크 트래픽 흐름 + +``` +인터넷 + ↓↑ +Internet Gateway + ↓ +Public Subnet (ALB, NAT Gateway 위치) + +Private Subnet (EKS 노드) + → ECR 이미지 레이어 pull → S3 Gateway Endpoint (NAT 안 거침) + → ECR API / STS / 기타 → NAT Gateway → Internet Gateway + → ALB → 노드 (인바운드) → ALB에서 직접 +``` + +--- + +## 배포 명령어 + +```bash +# 초기화 +terraform init + +# 변경사항 미리보기 +terraform plan + +# 배포 (~15-20분) +terraform apply + +# kubeconfig 설정 +aws eks update-kubeconfig \ + --region us-east-2 \ + --name devopsim-prod-cluster \ + --profile devopsim + +# 클러스터 확인 +kubectl get nodes +kubectl get pods -A + +# 삭제 +terraform destroy +``` + +--- + +## 비용 추정 (us-east-2 기준) + +| 리소스 | 월 예상 비용 | +|---|---| +| EKS Cluster | ~$72 | +| t3.medium × 2 노드 | ~$60 | +| NAT Gateway | ~$32 + 데이터 처리량 ($0.045/GB) | +| S3 Gateway Endpoint | 무료 | +| ECR | 무료 (500MB/월 이하) | +| **합계** | **~$164/월 + 데이터 처리량** | + +Interface VPC Endpoint 제거로 월 ~$57 절감. + +--- + +## 주의사항 + +- `terraform destroy` 시 EKS 노드에 PVC가 붙어있으면 EBS 볼륨이 남을 수 있음 → 수동 삭제 필요 +- NAT Gateway는 1개만 생성 (비용 절감). HA 필요 시 AZ별 1개씩 추가 +- EKS 엔드포인트 public 접근 허용 상태 — 운영 환경에서는 IP 제한 권장 diff --git a/infra/helm/api/values-production.yaml b/infra/helm/api/values-production.yaml new file mode 100644 index 0000000..b98245f --- /dev/null +++ b/infra/helm/api/values-production.yaml @@ -0,0 +1,22 @@ +replicaCount: 2 + +image: + repository: 893286712531.dkr.ecr.us-east-2.amazonaws.com/devopsim/api + tag: "0.0.1" + pullPolicy: Always + +ingress: + enabled: true + className: alb + host: "" + annotations: + alb.ingress.kubernetes.io/scheme: internet-facing + alb.ingress.kubernetes.io/target-type: ip + +resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "500m" + memory: "256Mi" diff --git a/infra/helm/db/templates/statefulset.yaml b/infra/helm/db/templates/statefulset.yaml index 84e1150..3eb1532 100644 --- a/infra/helm/db/templates/statefulset.yaml +++ b/infra/helm/db/templates/statefulset.yaml @@ -38,6 +38,8 @@ spec: secretKeyRef: name: {{ .Values.auth.secretName }} key: postgres-password + - name: PGDATA + value: /var/lib/postgresql/data/pgdata livenessProbe: exec: diff --git a/infra/terraform/backend.tf b/infra/terraform/backend.tf new file mode 100644 index 0000000..f082fb4 --- /dev/null +++ b/infra/terraform/backend.tf @@ -0,0 +1,8 @@ +terraform { + backend "s3" { + bucket = "nurihaus-terraform-state" + key = "devopsim/terraform.tfstate" + region = "ap-northeast-2" + profile = "devopsim" + } +} diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf new file mode 100644 index 0000000..32dbffa --- /dev/null +++ b/infra/terraform/main.tf @@ -0,0 +1,40 @@ +locals { + name = "${var.project}-${var.environment}" + + tags = { + Project = var.project + Environment = var.environment + ManagedBy = "terraform" + } +} + +module "vpc" { + source = "./modules/vpc" + + name = local.name + vpc_cidr = var.vpc_cidr + availability_zones = var.availability_zones + tags = local.tags +} + +module "ecr" { + source = "./modules/ecr" + + project = var.project + repositories = ["api"] + tags = local.tags +} + +module "eks" { + source = "./modules/eks" + + name = local.name + cluster_version = var.eks_cluster_version + vpc_id = module.vpc.vpc_id + private_subnet_ids = module.vpc.private_subnet_ids + node_instance_type = var.eks_node_instance_type + node_desired_size = var.eks_node_desired_size + node_min_size = var.eks_node_min_size + node_max_size = var.eks_node_max_size + tags = local.tags +} diff --git a/infra/terraform/modules/ecr/main.tf b/infra/terraform/modules/ecr/main.tf new file mode 100644 index 0000000..56815ed --- /dev/null +++ b/infra/terraform/modules/ecr/main.tf @@ -0,0 +1,31 @@ +resource "aws_ecr_repository" "this" { + for_each = toset(var.repositories) + + name = "${var.project}/${each.value}" + image_tag_mutability = "MUTABLE" + + image_scanning_configuration { + scan_on_push = true # 푸시 시 CVE 스캔 + } + + tags = var.tags +} + +resource "aws_ecr_lifecycle_policy" "this" { + for_each = toset(var.repositories) + + repository = aws_ecr_repository.this[each.value].name + + policy = jsonencode({ + rules = [{ + rulePriority = 1 + description = "Keep last 10 images" + selection = { + tagStatus = "any" + countType = "imageCountMoreThan" + countNumber = 10 + } + action = { type = "expire" } + }] + }) +} diff --git a/infra/terraform/modules/ecr/outputs.tf b/infra/terraform/modules/ecr/outputs.tf new file mode 100644 index 0000000..2fce759 --- /dev/null +++ b/infra/terraform/modules/ecr/outputs.tf @@ -0,0 +1,4 @@ +output "repository_urls" { + description = "ECR repository URLs" + value = { for k, v in aws_ecr_repository.this : k => v.repository_url } +} diff --git a/infra/terraform/modules/ecr/variables.tf b/infra/terraform/modules/ecr/variables.tf new file mode 100644 index 0000000..702d8aa --- /dev/null +++ b/infra/terraform/modules/ecr/variables.tf @@ -0,0 +1,16 @@ +variable "project" { + description = "Project name used as ECR repository prefix" + type = string +} + +variable "repositories" { + description = "List of ECR repository names" + type = list(string) + default = ["api"] +} + +variable "tags" { + description = "Tags to apply to all resources" + type = map(string) + default = {} +} diff --git a/infra/terraform/modules/eks/addons.tf b/infra/terraform/modules/eks/addons.tf new file mode 100644 index 0000000..44d6b65 --- /dev/null +++ b/infra/terraform/modules/eks/addons.tf @@ -0,0 +1,9 @@ +resource "aws_eks_addon" "ebs_csi" { + cluster_name = aws_eks_cluster.this.name + addon_name = "aws-ebs-csi-driver" + service_account_role_arn = aws_iam_role.ebs_csi.arn + + tags = var.tags + + depends_on = [aws_eks_node_group.this] +} diff --git a/infra/terraform/modules/eks/irsa.tf b/infra/terraform/modules/eks/irsa.tf new file mode 100644 index 0000000..8e6ebef --- /dev/null +++ b/infra/terraform/modules/eks/irsa.tf @@ -0,0 +1,115 @@ +data "tls_certificate" "eks" { + url = aws_eks_cluster.this.identity[0].oidc[0].issuer +} + +resource "aws_iam_openid_connect_provider" "eks" { + client_id_list = ["sts.amazonaws.com"] + thumbprint_list = [data.tls_certificate.eks.certificates[0].sha1_fingerprint] + url = aws_eks_cluster.this.identity[0].oidc[0].issuer + + tags = var.tags +} + +locals { + oidc_host = replace(aws_iam_openid_connect_provider.eks.url, "https://", "") +} + +# EBS CSI Driver IRSA +resource "aws_iam_role" "ebs_csi" { + name = "${var.name}-ebs-csi-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { + Federated = aws_iam_openid_connect_provider.eks.arn + } + Action = "sts:AssumeRoleWithWebIdentity" + Condition = { + StringEquals = { + "${local.oidc_host}:sub" = "system:serviceaccount:kube-system:ebs-csi-controller-sa" + "${local.oidc_host}:aud" = "sts.amazonaws.com" + } + } + }] + }) + + tags = var.tags +} + +resource "aws_iam_role_policy_attachment" "ebs_csi" { + role = aws_iam_role.ebs_csi.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy" +} + +# ALB Controller IRSA +resource "aws_iam_role" "alb_controller" { + name = "${var.name}-alb-controller-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { + Federated = aws_iam_openid_connect_provider.eks.arn + } + Action = "sts:AssumeRoleWithWebIdentity" + Condition = { + StringEquals = { + "${local.oidc_host}:sub" = "system:serviceaccount:kube-system:aws-load-balancer-controller" + "${local.oidc_host}:aud" = "sts.amazonaws.com" + } + } + }] + }) + + tags = var.tags +} + +resource "aws_iam_role_policy" "alb_controller" { + name = "${var.name}-alb-controller-policy" + role = aws_iam_role.alb_controller.id + policy = file("${path.module}/policies/alb-controller-policy.json") +} + +# External Secrets IRSA +resource "aws_iam_role" "external_secrets" { + name = "${var.name}-external-secrets-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { + Federated = aws_iam_openid_connect_provider.eks.arn + } + Action = "sts:AssumeRoleWithWebIdentity" + Condition = { + StringEquals = { + "${local.oidc_host}:sub" = "system:serviceaccount:external-secrets:external-secrets" + "${local.oidc_host}:aud" = "sts.amazonaws.com" + } + } + }] + }) + + tags = var.tags +} + +resource "aws_iam_role_policy" "external_secrets" { + name = "${var.name}-external-secrets-policy" + role = aws_iam_role.external_secrets.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret" + ] + Resource = "*" + }] + }) +} diff --git a/infra/terraform/modules/eks/main.tf b/infra/terraform/modules/eks/main.tf new file mode 100644 index 0000000..8399494 --- /dev/null +++ b/infra/terraform/modules/eks/main.tf @@ -0,0 +1,88 @@ +# EKS Cluster IAM Role +resource "aws_iam_role" "cluster" { + name = "${var.name}-eks-cluster-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "eks.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) + + tags = var.tags +} + +resource "aws_iam_role_policy_attachment" "cluster_policy" { + role = aws_iam_role.cluster.name + policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy" +} + +# EKS Cluster +resource "aws_eks_cluster" "this" { + name = "${var.name}-cluster" + version = var.cluster_version + role_arn = aws_iam_role.cluster.arn + + vpc_config { + subnet_ids = var.private_subnet_ids + endpoint_private_access = true + endpoint_public_access = true + } + + tags = var.tags + + depends_on = [aws_iam_role_policy_attachment.cluster_policy] +} + +# Node Group IAM Role +resource "aws_iam_role" "node" { + name = "${var.name}-eks-node-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "ec2.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) + + tags = var.tags +} + +resource "aws_iam_role_policy_attachment" "node_policy" { + for_each = toset([ + "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy", + "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy", + "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly", + ]) + + role = aws_iam_role.node.name + policy_arn = each.value +} + +# EKS Managed Node Group +resource "aws_eks_node_group" "this" { + cluster_name = aws_eks_cluster.this.name + node_group_name = "${var.name}-node-group" + node_role_arn = aws_iam_role.node.arn + subnet_ids = var.private_subnet_ids + + instance_types = [var.node_instance_type] + + scaling_config { + desired_size = var.node_desired_size + min_size = var.node_min_size + max_size = var.node_max_size + } + + update_config { + max_unavailable = 1 + } + + tags = var.tags + + depends_on = [aws_iam_role_policy_attachment.node_policy] +} diff --git a/infra/terraform/modules/eks/outputs.tf b/infra/terraform/modules/eks/outputs.tf new file mode 100644 index 0000000..609e620 --- /dev/null +++ b/infra/terraform/modules/eks/outputs.tf @@ -0,0 +1,29 @@ +output "cluster_name" { + description = "EKS cluster name" + value = aws_eks_cluster.this.name +} + +output "cluster_endpoint" { + description = "EKS cluster API endpoint" + value = aws_eks_cluster.this.endpoint +} + +output "cluster_ca" { + description = "EKS cluster certificate authority" + value = aws_eks_cluster.this.certificate_authority[0].data +} + +output "oidc_provider_arn" { + description = "OIDC Provider ARN for IRSA" + value = aws_iam_openid_connect_provider.eks.arn +} + +output "alb_controller_role_arn" { + description = "IAM Role ARN for ALB Controller" + value = aws_iam_role.alb_controller.arn +} + +output "external_secrets_role_arn" { + description = "IAM Role ARN for External Secrets" + value = aws_iam_role.external_secrets.arn +} diff --git a/infra/terraform/modules/eks/policies/alb-controller-policy.json b/infra/terraform/modules/eks/policies/alb-controller-policy.json new file mode 100644 index 0000000..1a5b4d6 --- /dev/null +++ b/infra/terraform/modules/eks/policies/alb-controller-policy.json @@ -0,0 +1,247 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:CreateServiceLinkedRole" + ], + "Resource": "*", + "Condition": { + "StringEquals": { + "iam:AWSServiceName": "elasticloadbalancing.amazonaws.com" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "ec2:DescribeAccountAttributes", + "ec2:DescribeAddresses", + "ec2:DescribeAvailabilityZones", + "ec2:DescribeInternetGateways", + "ec2:DescribeVpcs", + "ec2:DescribeVpcPeeringConnections", + "ec2:DescribeSubnets", + "ec2:DescribeSecurityGroups", + "ec2:DescribeInstances", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribeTags", + "ec2:GetCoipPoolUsage", + "ec2:DescribeCoipPools", + "ec2:GetSecurityGroupsForVpc", + "elasticloadbalancing:DescribeLoadBalancers", + "elasticloadbalancing:DescribeLoadBalancerAttributes", + "elasticloadbalancing:DescribeListeners", + "elasticloadbalancing:DescribeListenerCertificates", + "elasticloadbalancing:DescribeSSLPolicies", + "elasticloadbalancing:DescribeRules", + "elasticloadbalancing:DescribeTargetGroups", + "elasticloadbalancing:DescribeTargetGroupAttributes", + "elasticloadbalancing:DescribeTargetHealth", + "elasticloadbalancing:DescribeTags", + "elasticloadbalancing:DescribeTrustStores", + "elasticloadbalancing:DescribeListenerAttributes", + "elasticloadbalancing:DescribeCapacityReservation" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "cognito-idp:DescribeUserPoolClient", + "acm:ListCertificates", + "acm:DescribeCertificate", + "iam:ListServerCertificates", + "iam:GetServerCertificate", + "waf-regional:GetWebACL", + "waf-regional:GetWebACLForResource", + "waf-regional:AssociateWebACL", + "waf-regional:DisassociateWebACL", + "wafv2:GetWebACL", + "wafv2:GetWebACLForResource", + "wafv2:AssociateWebACL", + "wafv2:DisassociateWebACL", + "shield:GetSubscriptionState", + "shield:DescribeProtection", + "shield:CreateProtection", + "shield:DeleteProtection" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "ec2:AuthorizeSecurityGroupIngress", + "ec2:RevokeSecurityGroupIngress" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "ec2:CreateSecurityGroup" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "ec2:CreateTags" + ], + "Resource": "arn:aws:ec2:*:*:security-group/*", + "Condition": { + "StringEquals": { + "ec2:CreateAction": "CreateSecurityGroup" + }, + "Null": { + "aws:RequestTag/elbv2.k8s.aws/cluster": "false" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "ec2:CreateTags", + "ec2:DeleteTags" + ], + "Resource": "arn:aws:ec2:*:*:security-group/*", + "Condition": { + "Null": { + "aws:RequestTag/elbv2.k8s.aws/cluster": "true", + "aws:ResourceTag/elbv2.k8s.aws/cluster": "false" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "ec2:AuthorizeSecurityGroupIngress", + "ec2:RevokeSecurityGroupIngress", + "ec2:DeleteSecurityGroup" + ], + "Resource": "*", + "Condition": { + "Null": { + "aws:ResourceTag/elbv2.k8s.aws/cluster": "false" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:CreateLoadBalancer", + "elasticloadbalancing:CreateTargetGroup" + ], + "Resource": "*", + "Condition": { + "Null": { + "aws:RequestTag/elbv2.k8s.aws/cluster": "false" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:CreateListener", + "elasticloadbalancing:DeleteListener", + "elasticloadbalancing:CreateRule", + "elasticloadbalancing:DeleteRule" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:AddTags", + "elasticloadbalancing:RemoveTags" + ], + "Resource": [ + "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*", + "arn:aws:elasticloadbalancing:*:*:loadbalancer/net/*/*", + "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*" + ], + "Condition": { + "Null": { + "aws:RequestTag/elbv2.k8s.aws/cluster": "true", + "aws:ResourceTag/elbv2.k8s.aws/cluster": "false" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:AddTags", + "elasticloadbalancing:RemoveTags" + ], + "Resource": [ + "arn:aws:elasticloadbalancing:*:*:listener/net/*/*/*", + "arn:aws:elasticloadbalancing:*:*:listener/app/*/*/*", + "arn:aws:elasticloadbalancing:*:*:listener-rule/net/*/*/*", + "arn:aws:elasticloadbalancing:*:*:listener-rule/app/*/*/*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:ModifyLoadBalancerAttributes", + "elasticloadbalancing:SetIpAddressType", + "elasticloadbalancing:SetSecurityGroups", + "elasticloadbalancing:SetSubnets", + "elasticloadbalancing:DeleteLoadBalancer", + "elasticloadbalancing:ModifyTargetGroup", + "elasticloadbalancing:ModifyTargetGroupAttributes", + "elasticloadbalancing:DeleteTargetGroup", + "elasticloadbalancing:ModifyListenerAttributes", + "elasticloadbalancing:ModifyCapacityReservation" + ], + "Resource": "*", + "Condition": { + "Null": { + "aws:ResourceTag/elbv2.k8s.aws/cluster": "false" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:AddTags" + ], + "Resource": [ + "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*", + "arn:aws:elasticloadbalancing:*:*:loadbalancer/net/*/*", + "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*" + ], + "Condition": { + "StringEquals": { + "elasticloadbalancing:CreateAction": [ + "CreateTargetGroup", + "CreateLoadBalancer" + ] + }, + "Null": { + "aws:RequestTag/elbv2.k8s.aws/cluster": "false" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:RegisterTargets", + "elasticloadbalancing:DeregisterTargets" + ], + "Resource": "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*" + }, + { + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:SetWebAcl", + "elasticloadbalancing:ModifyListener", + "elasticloadbalancing:AddListenerCertificates", + "elasticloadbalancing:RemoveListenerCertificates", + "elasticloadbalancing:ModifyRule" + ], + "Resource": "*" + } + ] +} diff --git a/infra/terraform/modules/eks/variables.tf b/infra/terraform/modules/eks/variables.tf new file mode 100644 index 0000000..6cf7ce2 --- /dev/null +++ b/infra/terraform/modules/eks/variables.tf @@ -0,0 +1,42 @@ +variable "name" { + description = "Name prefix for all resources" + type = string +} + +variable "cluster_version" { + description = "Kubernetes version for EKS cluster" + type = string +} + +variable "vpc_id" { + description = "VPC ID" + type = string +} + +variable "private_subnet_ids" { + description = "Private subnet IDs for EKS nodes" + type = list(string) +} + +variable "node_instance_type" { + description = "EC2 instance type for node group" + type = string +} + +variable "node_desired_size" { + type = number +} + +variable "node_min_size" { + type = number +} + +variable "node_max_size" { + type = number +} + +variable "tags" { + description = "Tags to apply to all resources" + type = map(string) + default = {} +} diff --git a/infra/terraform/modules/vpc/main.tf b/infra/terraform/modules/vpc/main.tf new file mode 100644 index 0000000..0c518f4 --- /dev/null +++ b/infra/terraform/modules/vpc/main.tf @@ -0,0 +1,136 @@ +data "aws_region" "current" {} + +locals { + az_count = length(var.availability_zones) + + # vpc: 10.0.0.0/16 + # public: 10.0.0.0/24, 10.0.1.0/24 + # private: 10.0.10.0/24, 10.0.11.0/24 + public_cidrs = [for i in range(local.az_count) : cidrsubnet(var.vpc_cidr, 8, i)] + private_cidrs = [for i in range(local.az_count) : cidrsubnet(var.vpc_cidr, 8, i + 10)] +} + +# VPC +resource "aws_vpc" "this" { + cidr_block = var.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true # EKS 노드가 DNS로 통신하려면 필요 + + tags = merge(var.tags, { + Name = "${var.name}-vpc" + }) +} + +# Internet Gateway +resource "aws_internet_gateway" "this" { + vpc_id = aws_vpc.this.id + + tags = merge(var.tags, { + Name = "${var.name}-igw" + }) +} + +# Public Subnets +resource "aws_subnet" "public" { + count = local.az_count + + vpc_id = aws_vpc.this.id + cidr_block = local.public_cidrs[count.index] + availability_zone = var.availability_zones[count.index] + map_public_ip_on_launch = true # Public 서브넷: 인스턴스에 자동으로 퍼블릭 IP 부여 + + tags = merge(var.tags, { + Name = "${var.name}-public-${var.availability_zones[count.index]}" + "kubernetes.io/role/elb" = "1" # ALB가 이 서브넷을 찾는 태그 + }) +} + +# Private Subnets +resource "aws_subnet" "private" { + count = local.az_count + + vpc_id = aws_vpc.this.id + cidr_block = local.private_cidrs[count.index] + availability_zone = var.availability_zones[count.index] + + tags = merge(var.tags, { + Name = "${var.name}-private-${var.availability_zones[count.index]}" + "kubernetes.io/role/internal-elb" = "1" # Internal ALB용 태그 + }) +} + +# Elastic IP for NAT Gateway +resource "aws_eip" "nat" { + domain = "vpc" + + tags = merge(var.tags, { + Name = "${var.name}-nat-eip" + }) +} + +# NAT Gateway — Private 서브넷의 아웃바운드 인터넷 출구 (1개로 비용 절감) +resource "aws_nat_gateway" "this" { + allocation_id = aws_eip.nat.id + subnet_id = aws_subnet.public[0].id # Public 서브넷에 위치 + + tags = merge(var.tags, { + Name = "${var.name}-nat" + }) + + depends_on = [aws_internet_gateway.this] +} + +# Public Route Table — IGW로 라우팅 +resource "aws_route_table" "public" { + vpc_id = aws_vpc.this.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.this.id + } + + tags = merge(var.tags, { + Name = "${var.name}-public-rt" + }) +} + +# Private Route Table — NAT Gateway로 라우팅 +resource "aws_route_table" "private" { + vpc_id = aws_vpc.this.id + + route { + cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.this.id + } + + tags = merge(var.tags, { + Name = "${var.name}-private-rt" + }) +} + +# Route Table 연결 +resource "aws_route_table_association" "public" { + count = local.az_count + + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public.id +} + +resource "aws_route_table_association" "private" { + count = local.az_count + + subnet_id = aws_subnet.private[count.index].id + route_table_id = aws_route_table.private.id +} + +# S3 Gateway Endpoint — 무료, ECR 이미지 레이어 pull 시 NAT 비용 절감 +resource "aws_vpc_endpoint" "s3" { + vpc_id = aws_vpc.this.id + service_name = "com.amazonaws.${data.aws_region.current.id}.s3" + vpc_endpoint_type = "Gateway" + route_table_ids = [aws_route_table.private.id] + + tags = merge(var.tags, { + Name = "${var.name}-s3-endpoint" + }) +} diff --git a/infra/terraform/modules/vpc/outputs.tf b/infra/terraform/modules/vpc/outputs.tf new file mode 100644 index 0000000..de101fc --- /dev/null +++ b/infra/terraform/modules/vpc/outputs.tf @@ -0,0 +1,14 @@ +output "vpc_id" { + description = "VPC ID" + value = aws_vpc.this.id +} + +output "public_subnet_ids" { + description = "Public subnet IDs" + value = aws_subnet.public[*].id +} + +output "private_subnet_ids" { + description = "Private subnet IDs" + value = aws_subnet.private[*].id +} diff --git a/infra/terraform/modules/vpc/variables.tf b/infra/terraform/modules/vpc/variables.tf new file mode 100644 index 0000000..641d9ae --- /dev/null +++ b/infra/terraform/modules/vpc/variables.tf @@ -0,0 +1,20 @@ +variable "name" { + description = "Name prefix for all resources" + type = string +} + +variable "vpc_cidr" { + description = "CIDR block for VPC" + type = string +} + +variable "availability_zones" { + description = "List of availability zones" + type = list(string) +} + +variable "tags" { + description = "Tags to apply to all resources" + type = map(string) + default = {} +} diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf new file mode 100644 index 0000000..770c36b --- /dev/null +++ b/infra/terraform/outputs.tf @@ -0,0 +1,44 @@ +output "vpc_id" { + description = "VPC ID" + value = module.vpc.vpc_id +} + +output "private_subnet_ids" { + description = "Private subnet IDs" + value = module.vpc.private_subnet_ids +} + +output "public_subnet_ids" { + description = "Public subnet IDs" + value = module.vpc.public_subnet_ids +} + +output "eks_cluster_name" { + description = "EKS cluster name" + value = module.eks.cluster_name +} + +output "eks_cluster_endpoint" { + description = "EKS cluster endpoint" + value = module.eks.cluster_endpoint +} + +output "eks_kubeconfig_command" { + description = "Command to update kubeconfig" + value = "aws eks update-kubeconfig --region ${var.region} --name ${module.eks.cluster_name} --profile ${var.aws_profile}" +} + +output "ecr_repository_urls" { + description = "ECR repository URLs" + value = module.ecr.repository_urls +} + +output "alb_controller_role_arn" { + description = "ALB Controller IAM Role ARN (for Helm install)" + value = module.eks.alb_controller_role_arn +} + +output "external_secrets_role_arn" { + description = "External Secrets IAM Role ARN (for Helm install)" + value = module.eks.external_secrets_role_arn +} diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf new file mode 100644 index 0000000..c96bd60 --- /dev/null +++ b/infra/terraform/variables.tf @@ -0,0 +1,67 @@ +variable "region" { + description = "AWS region" + type = string + default = "us-east-2" +} + +variable "aws_profile" { + description = "AWS CLI profile" + type = string + default = "devopsim" +} + +variable "project" { + description = "Project name used as prefix for all resources" + type = string + default = "devopsim" +} + +variable "environment" { + description = "Deployment environment" + type = string + default = "prod" +} + +# VPC +variable "vpc_cidr" { + description = "CIDR block for VPC" + type = string + default = "10.0.0.0/16" +} + +variable "availability_zones" { + description = "List of availability zones" + type = list(string) + default = ["us-east-2a", "us-east-2b"] +} + +# EKS +variable "eks_cluster_version" { + description = "Kubernetes version for EKS cluster" + type = string + default = "1.35" +} + +variable "eks_node_instance_type" { + description = "EC2 instance type for EKS node group" + type = string + default = "t3.medium" +} + +variable "eks_node_desired_size" { + description = "Desired number of nodes in EKS node group" + type = number + default = 2 +} + +variable "eks_node_min_size" { + description = "Minimum number of nodes in EKS node group" + type = number + default = 1 +} + +variable "eks_node_max_size" { + description = "Maximum number of nodes in EKS node group" + type = number + default = 3 +} diff --git a/infra/terraform/versions.tf b/infra/terraform/versions.tf new file mode 100644 index 0000000..ea0b986 --- /dev/null +++ b/infra/terraform/versions.tf @@ -0,0 +1,15 @@ +terraform { + required_version = ">= 1.11.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} + +provider "aws" { + region = var.region + profile = var.aws_profile +}