From 01fcfc6fb31dec89c3c443ff2878ca0e92c6e187 Mon Sep 17 00:00:00 2001 From: yoonseo Date: Fri, 4 Sep 2026 03:26:08 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=E2=99=BB=EF=B8=8Frefactor:=20Azure=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20legacy-azure=20=EB=94=94=EB=A0=89=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/{ => legacy-azure}/azure-portal-guide.md | 0 infra/{ => legacy-azure}/azure-setup.sh | 0 infra/{ => legacy-azure}/custom-domain.sh | 0 infra/{ => legacy-azure}/deploy-manual.sh | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename infra/{ => legacy-azure}/azure-portal-guide.md (100%) rename infra/{ => legacy-azure}/azure-setup.sh (100%) rename infra/{ => legacy-azure}/custom-domain.sh (100%) rename infra/{ => legacy-azure}/deploy-manual.sh (100%) diff --git a/infra/azure-portal-guide.md b/infra/legacy-azure/azure-portal-guide.md similarity index 100% rename from infra/azure-portal-guide.md rename to infra/legacy-azure/azure-portal-guide.md diff --git a/infra/azure-setup.sh b/infra/legacy-azure/azure-setup.sh similarity index 100% rename from infra/azure-setup.sh rename to infra/legacy-azure/azure-setup.sh diff --git a/infra/custom-domain.sh b/infra/legacy-azure/custom-domain.sh similarity index 100% rename from infra/custom-domain.sh rename to infra/legacy-azure/custom-domain.sh diff --git a/infra/deploy-manual.sh b/infra/legacy-azure/deploy-manual.sh similarity index 100% rename from infra/deploy-manual.sh rename to infra/legacy-azure/deploy-manual.sh From d90223b18a7c671e32a1ffb8ba6072fef1b6ef2a Mon Sep 17 00:00:00 2001 From: yoonseo Date: Fri, 4 Sep 2026 03:26:35 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=94=A7chore:=20Terraform=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=8C=8C=EC=9D=BC=20.gitignore=EC=97=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20CI=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 4 ++++ .gitignore | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 490ed6d..50c67e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,10 @@ name: CI on: pull_request: branches: [dev, main] + paths-ignore: + - "infra/**" + - "docs/**" + - "**.md" # 동일 PR에서 새 푸시 시 이전 실행 취소 concurrency: diff --git a/.gitignore b/.gitignore index 124f493..5d5b0d0 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,18 @@ docs/ ### Logs ### logs/ -*.log \ No newline at end of file +*.log + +### Terraform ### +**/.terraform/* +*.tfstate +*.tfstate.* +*.tfvars +*.tfvars.json +crash.log +crash.*.log +override.tf +override.tf.json +*_override.tf +.terraformrc +terraform.rc \ No newline at end of file From ba02a298a3aa2a06ee94a6fdde72bcc69586f330 Mon Sep 17 00:00:00 2001 From: yoonseo Date: Sat, 5 Sep 2026 15:14:33 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=94=A7chore:=20Terraform=20plan=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=8C=8C=EC=9D=BC=20.gitignore=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5d5b0d0..dbab077 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,7 @@ override.tf override.tf.json *_override.tf .terraformrc -terraform.rc \ No newline at end of file +terraform.rc +tfplan +*.tfplan +plan.out \ No newline at end of file From 8315a8e7a401ea3933a07e9a1b78e606c7314b7f Mon Sep 17 00:00:00 2001 From: yoonseo Date: Sat, 5 Sep 2026 15:15:45 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=94=A7chore:=20Terraform=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20AWS=20=EC=9D=B8=ED=94=84=EB=9D=BC=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/terraform/.terraform.lock.hcl | 46 +++ infra/terraform/compute.tf | 453 ++++++++++++++++++++++++++++ infra/terraform/edge.tf | 135 +++++++++ infra/terraform/main.tf | 55 ++++ infra/terraform/network.tf | 120 ++++++++ infra/terraform/outputs.tf | 52 ++++ infra/terraform/storage.tf | 123 ++++++++ infra/terraform/variables.tf | 67 ++++ 8 files changed, 1051 insertions(+) create mode 100644 infra/terraform/.terraform.lock.hcl create mode 100644 infra/terraform/compute.tf create mode 100644 infra/terraform/edge.tf create mode 100644 infra/terraform/main.tf create mode 100644 infra/terraform/network.tf create mode 100644 infra/terraform/outputs.tf create mode 100644 infra/terraform/storage.tf create mode 100644 infra/terraform/variables.tf diff --git a/infra/terraform/.terraform.lock.hcl b/infra/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..4a059b2 --- /dev/null +++ b/infra/terraform/.terraform.lock.hcl @@ -0,0 +1,46 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.60" + hashes = [ + "h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + constraints = "~> 3.6" + hashes = [ + "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", + "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", + "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", + "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", + "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", + "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", + "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", + "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", + "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", + "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", + "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", + "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", + ] +} diff --git a/infra/terraform/compute.tf b/infra/terraform/compute.tf new file mode 100644 index 0000000..f140da7 --- /dev/null +++ b/infra/terraform/compute.tf @@ -0,0 +1,453 @@ +# ══ IAM ══════════════════════════════════════════════════════ +data "aws_iam_policy_document" "ec2_assume" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["ec2.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "ecs_tasks_assume" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "instance" { + name = "${local.name_prefix}-instance-role" + assume_role_policy = data.aws_iam_policy_document.ec2_assume.json +} + +resource "aws_iam_role_policy_attachment" "instance_managed" { + for_each = toset([ + "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role", + "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore", # SSH 키 없이 접속 + ]) + + role = aws_iam_role.instance.name + policy_arn = each.value +} + +# 부팅 시 자기 자신에게 EIP 를 붙이기 위한 권한 +data "aws_iam_policy_document" "instance_eip" { + statement { + actions = ["ec2:AssociateAddress", "ec2:DescribeAddresses"] + resources = ["*"] + } +} + +resource "aws_iam_role_policy" "instance_eip" { + name = "associate-eip" + role = aws_iam_role.instance.id + policy = data.aws_iam_policy_document.instance_eip.json +} + +resource "aws_iam_instance_profile" "instance" { + name = "${local.name_prefix}-instance-profile" + role = aws_iam_role.instance.name +} + +resource "aws_iam_role" "task_execution" { + name = "${local.name_prefix}-task-execution-role" + assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json +} + +resource "aws_iam_role_policy_attachment" "task_execution_managed" { + role = aws_iam_role.task_execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +data "aws_iam_policy_document" "task_execution_ssm" { + statement { + actions = ["ssm:GetParameters"] + resources = concat( + [for p in aws_ssm_parameter.app : p.arn], + [aws_ssm_parameter.db_password_seed.arn], + ) + } +} + +resource "aws_iam_role_policy" "task_execution_ssm" { + name = "read-app-secrets" + role = aws_iam_role.task_execution.id + policy = data.aws_iam_policy_document.task_execution_ssm.json +} + +# 앱 코드가 쓰는 권한. 액세스 키 불필요. +resource "aws_iam_role" "task" { + name = "${local.name_prefix}-task-role" + assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json +} + +data "aws_iam_policy_document" "task_s3" { + statement { + actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] + resources = ["${aws_s3_bucket.images.arn}/*"] + } + statement { + actions = ["s3:ListBucket"] + resources = [aws_s3_bucket.images.arn] + } +} + +resource "aws_iam_role_policy" "task_s3" { + name = "images-bucket-access" + role = aws_iam_role.task.id + policy = data.aws_iam_policy_document.task_s3.json +} + +# ── GitHub Actions OIDC ────────────────────── +# GitHub OIDC 공급자는 URL 당 계정에 하나만 존재할 수 있다. +data "aws_iam_openid_connect_provider" "github" { + url = "https://token.actions.githubusercontent.com" +} + +data "aws_iam_policy_document" "github_assume" { + statement { + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [data.aws_iam_openid_connect_provider.github.arn] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + + condition { + test = "StringLike" + variable = "token.actions.githubusercontent.com:sub" + values = ["repo:${var.github_repository}:ref:refs/heads/main"] + } + } +} + +resource "aws_iam_role" "github_actions" { + name = "${local.name_prefix}-github-actions" + assume_role_policy = data.aws_iam_policy_document.github_assume.json +} + +data "aws_iam_policy_document" "github_deploy" { + statement { + actions = [ + "ecr:GetAuthorizationToken", + "ecr:BatchCheckLayerAvailability", + "ecr:InitiateLayerUpload", + "ecr:UploadLayerPart", + "ecr:CompleteLayerUpload", + "ecr:PutImage", + ] + resources = ["*"] + } + + statement { + actions = ["ecs:UpdateService", "ecs:DescribeServices", "ecs:RegisterTaskDefinition"] + resources = ["*"] + } + + statement { + actions = ["iam:PassRole"] + resources = [aws_iam_role.task_execution.arn, aws_iam_role.task.arn] + } +} + +resource "aws_iam_role_policy" "github_deploy" { + name = "deploy" + role = aws_iam_role.github_actions.id + policy = data.aws_iam_policy_document.github_deploy.json +} + +# ══ ECR ══════════════════════════════════════════════════════ +resource "aws_ecr_repository" "app" { + for_each = toset(["backend", "ai"]) + + name = "${local.name_prefix}-${each.key}" + image_tag_mutability = "IMMUTABLE" + + image_scanning_configuration { scan_on_push = true } +} + +resource "aws_ecr_lifecycle_policy" "app" { + for_each = aws_ecr_repository.app + repository = each.value.name + + policy = jsonencode({ + rules = [{ + rulePriority = 1 + description = "최근 10개만 보관" + selection = { + tagStatus = "any" + countType = "imageCountMoreThan" + countNumber = 10 + } + action = { type = "expire" } + }] + }) +} + +# ══ ECS 클러스터 + 캐패시티 ═══════════════════════════════════ +data "aws_ssm_parameter" "ecs_ami" { + name = "/aws/service/ecs/optimized-ami/amazon-linux-2023/arm64/recommended/image_id" +} + +resource "aws_ecs_cluster" "main" { + name = "${local.name_prefix}-cluster" + + setting { + name = "containerInsights" + value = "disabled" # 비용 절감 + } +} + +resource "aws_launch_template" "ecs" { + name_prefix = "${local.name_prefix}-lt-" + image_id = data.aws_ssm_parameter.ecs_ami.value + instance_type = var.instance_type + + iam_instance_profile { arn = aws_iam_instance_profile.instance.arn } + + vpc_security_group_ids = [aws_security_group.instance.id] + + block_device_mappings { + device_name = "/dev/xvda" + ebs { + # ECS 최적화 AL2023 ARM64 AMI 의 루트 스냅샷이 30GB 라 그 아래로는 못 줄인다 + volume_size = 30 + volume_type = "gp3" + encrypted = true + } + } + + user_data = base64encode(<<-EOT + #!/bin/bash + echo "ECS_CLUSTER=${aws_ecs_cluster.main.name}" >> /etc/ecs/ecs.config + + TOKEN=$(curl -sX PUT "http://169.254.169.254/latest/api/token" \ + -H "X-aws-ec2-metadata-token-ttl-seconds: 60") + IID=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \ + http://169.254.169.254/latest/meta-data/instance-id) + aws ec2 associate-address --region ${var.region} \ + --instance-id "$IID" \ + --allocation-id ${aws_eip.origin.id} \ + --allow-reassociation + EOT + ) + + lifecycle { create_before_destroy = true } +} + +resource "aws_autoscaling_group" "ecs" { + name = "${local.name_prefix}-asg" + vpc_zone_identifier = [for s in aws_subnet.public : s.id] + + min_size = 1 + max_size = 2 + desired_capacity = 1 + + launch_template { + id = aws_launch_template.ecs.id + version = "$Latest" + } + + protect_from_scale_in = true + + tag { + key = "AmazonECSManaged" + value = "true" + propagate_at_launch = true + } + + lifecycle { + ignore_changes = [desired_capacity] + } +} + +# 이 블록이 "런칭 때 Fargate 전환" 의 접점 +resource "aws_ecs_capacity_provider" "ec2" { + name = "${local.name_prefix}-cp-ec2" + + auto_scaling_group_provider { + auto_scaling_group_arn = aws_autoscaling_group.ecs.arn + managed_termination_protection = "ENABLED" + + managed_scaling { + status = "ENABLED" + target_capacity = 100 + } + } +} + +resource "aws_ecs_cluster_capacity_providers" "main" { + cluster_name = aws_ecs_cluster.main.name + capacity_providers = [aws_ecs_capacity_provider.ec2.name] + + default_capacity_provider_strategy { + capacity_provider = aws_ecs_capacity_provider.ec2.name + weight = 100 + } +} + +# ══ ECS 태스크 + 서비스 ══════════════════════════════════════ +resource "aws_cloudwatch_log_group" "app" { + for_each = toset(["backend", "ai"]) + + name = "/ecs/${local.name_prefix}/${each.key}" + retention_in_days = 7 # 기본값은 무기한 — 반드시 지정 +} + +resource "aws_ecs_task_definition" "app" { + family = "${local.name_prefix}-app" + + # host 모드: 두 컨테이너가 호스트 네트워크 공유 → backend 가 localhost:8000 으로 ai 호출. + # awsvpc 는 태스크 ENI 에 퍼블릭 IP 를 못 붙여 NAT($33/월)가 필요해진다. + # 트레이드오프: 한 인스턴스에 같은 태스크 2개 불가. Fargate 전환 시 awsvpc + NAT 필요. + network_mode = "host" + requires_compatibilities = ["EC2"] + cpu = "1536" + memory = "1408" + + execution_role_arn = aws_iam_role.task_execution.arn + task_role_arn = aws_iam_role.task.arn + + runtime_platform { + cpu_architecture = "ARM64" + operating_system_family = "LINUX" + } + + container_definitions = jsonencode([ + { + name = "ai" + image = "${aws_ecr_repository.app["ai"].repository_url}:latest" + essential = true + memory = local.ai_memory + + environment = [ + { name = "PORT", value = "8000" }, + ] + + secrets = [ + { name = "OPENAI_API_KEY", valueFrom = aws_ssm_parameter.app["openai_api_key"].arn }, + { name = "CLOVA_OCR_SECRET", valueFrom = aws_ssm_parameter.app["clova_ocr_secret"].arn }, + { name = "CLOVA_OCR_URL", valueFrom = aws_ssm_parameter.app["clova_ocr_url"].arn }, + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.app["ai"].name + "awslogs-region" = var.region + "awslogs-stream-prefix" = "ai" + } + } + + # ⚠ 이미지에 curl 이 설치되어 있어야 한다 (Dockerfile 확인) + healthCheck = { + command = ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"] + interval = 30 + timeout = 5 + retries = 3 + } + }, + { + name = "backend" + image = "${aws_ecr_repository.app["backend"].repository_url}:latest" + essential = true + memory = local.backend_memory + + dependsOn = [{ containerName = "ai", condition = "HEALTHY" }] + + environment = [ + { name = "SPRING_PROFILES_ACTIVE", value = "prod" }, + { name = "SERVER_PORT", value = "8080" }, + { name = "AI_SERVICE_BASE_URL", value = "http://localhost:8000" }, + { name = "S3_BUCKET", value = aws_s3_bucket.images.bucket }, + { name = "AWS_REGION", value = var.region }, + { name = "JAVA_TOOL_OPTIONS", value = "-XX:MaxRAMPercentage=60" }, + { name = "DB_HOST", value = aws_db_instance.main.address }, + { name = "CORS_ALLOWED_ORIGINS", value = "https://${var.domain_name}" }, + ] + + secrets = [ + { name = "DB_PASSWORD", valueFrom = aws_ssm_parameter.app["db_password"].arn }, + { name = "JWT_SECRET", valueFrom = aws_ssm_parameter.app["jwt_secret"].arn }, + { name = "GOOGLE_CLIENT_ID", valueFrom = aws_ssm_parameter.app["google_client_id"].arn }, + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.app["backend"].name + "awslogs-region" = var.region + "awslogs-stream-prefix" = "backend" + } + } + + healthCheck = { + command = ["CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1"] + interval = 30 + timeout = 5 + retries = 3 + startPeriod = 60 + } + }, + ]) +} + +resource "aws_ecs_service" "app" { + name = "${local.name_prefix}-app" + cluster = aws_ecs_cluster.main.id + task_definition = aws_ecs_task_definition.app.arn + desired_count = 1 + + # 롤링 업데이트 = 나중에 ALB 를 서비스 재생성 없이 붙일 수 있는 전제 + deployment_controller { type = "ECS" } + + # 인스턴스 1대 + host 모드 → 교체형 배포 + deployment_minimum_healthy_percent = 0 + deployment_maximum_percent = 100 + + capacity_provider_strategy { + capacity_provider = aws_ecs_capacity_provider.ec2.name + weight = 100 + } + + lifecycle { + ignore_changes = [task_definition] + } + + depends_on = [aws_ecs_cluster_capacity_providers.main] +} + +# ══ 예산 알림 ═════════════════════════════════════════════════ +resource "aws_budgets_budget" "monthly" { + name = "${local.name_prefix}-monthly" + budget_type = "COST" + limit_amount = tostring(var.monthly_budget_usd) + limit_unit = "USD" + time_unit = "MONTHLY" + + notification { + comparison_operator = "GREATER_THAN" + threshold = 80 + threshold_type = "PERCENTAGE" + notification_type = "ACTUAL" + subscriber_email_addresses = [var.alert_email] + } + + notification { + comparison_operator = "GREATER_THAN" + threshold = 100 + threshold_type = "PERCENTAGE" + notification_type = "FORECASTED" + subscriber_email_addresses = [var.alert_email] + } +} \ No newline at end of file diff --git a/infra/terraform/edge.tf b/infra/terraform/edge.tf new file mode 100644 index 0000000..4e6fc53 --- /dev/null +++ b/infra/terraform/edge.tf @@ -0,0 +1,135 @@ +# ── 오리진 고정 IP ─────────────────────────────────────────── +# 인스턴스가 교체돼도 오리진 주소가 안 깨지도록 EIP 를 쓴다. +# 실제 연결은 launch_template 의 user_data 가 부팅 시 수행한다. +resource "aws_eip" "origin" { + domain = "vpc" + tags = { Name = "${local.name_prefix}-eip-origin" } +} + +# ── ACM: CloudFront 용은 반드시 us-east-1 ──────────────────── +# 루트 도메인(han-spoon.site)은 Vercel 프론트엔드가 쓰므로 api 서브도메인만 발급. +resource "aws_acm_certificate" "cdn" { + provider = aws.us_east_1 + domain_name = local.api_fqdn + validation_method = "DNS" + + lifecycle { create_before_destroy = true } +} + +# DNS 권한이 Vercel 에 있어 검증 레코드는 수동 등록한다. +# → terraform output acm_validation_record 로 값을 확인해 Vercel DNS 에 넣을 것. +# 나중에 Route 53 으로 이관하면 aws_route53_record + aws_acm_certificate_validation 을 되살린다. + +# ── CloudFront ─────────────────────────────────────────────── +resource "random_password" "origin_secret" { + length = 40 + special = false +} + +# 하드코딩된 정책 ID 대신 이름으로 조회 +data "aws_cloudfront_cache_policy" "disabled" { + name = "Managed-CachingDisabled" +} + +data "aws_cloudfront_cache_policy" "optimized" { + name = "Managed-CachingOptimized" +} + +data "aws_cloudfront_origin_request_policy" "all_viewer" { + name = "Managed-AllViewer" +} + +resource "aws_cloudfront_origin_access_control" "s3" { + name = "${local.name_prefix}-oac-s3" + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" +} + +resource "aws_cloudfront_distribution" "main" { + enabled = true + aliases = [local.api_fqdn] + price_class = "PriceClass_200" # 아시아 포함, 남미/아프리카 제외 + + origin { + origin_id = "api" + + # Route 53 레코드 대신 EIP 의 퍼블릭 DNS 를 직접 쓴다. + # EIP 가 고정이므로 인스턴스가 교체돼도 이 이름은 유지된다. + domain_name = aws_eip.origin.public_dns + + custom_origin_config { + http_port = 8080 # backend 컨테이너가 host 모드로 바인딩하는 포트 + https_port = 443 + origin_protocol_policy = "http-only" # Caddy 로 오리진 TLS 붙이면 https-only + origin_ssl_protocols = ["TLSv1.2"] + origin_read_timeout = 60 # OCR 파이프라인이 길다 (기본 30s) + } + + custom_header { + name = "X-Origin-Secret" + value = random_password.origin_secret.result + } + } + + origin { + origin_id = "images" + domain_name = aws_s3_bucket.images.bucket_regional_domain_name + origin_access_control_id = aws_cloudfront_origin_access_control.s3.id + } + + default_cache_behavior { + target_origin_id = "api" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"] + cached_methods = ["GET", "HEAD"] + + cache_policy_id = data.aws_cloudfront_cache_policy.disabled.id + origin_request_policy_id = data.aws_cloudfront_origin_request_policy.all_viewer.id + } + + ordered_cache_behavior { + path_pattern = "/images/*" + target_origin_id = "images" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + + cache_policy_id = data.aws_cloudfront_cache_policy.optimized.id + } + + restrictions { + geo_restriction { restriction_type = "none" } + } + + viewer_certificate { + # 인증서가 ISSUED 상태여야 배포가 생성된다 → 2단계 apply 필요 + acm_certificate_arn = aws_acm_certificate.cdn.arn + ssl_support_method = "sni-only" + minimum_protocol_version = "TLSv1.2_2021" + } +} + +# S3 버킷은 이 CloudFront 배포만 읽을 수 있다 +data "aws_iam_policy_document" "images_cdn" { + statement { + actions = ["s3:GetObject"] + resources = ["${aws_s3_bucket.images.arn}/*"] + + principals { + type = "Service" + identifiers = ["cloudfront.amazonaws.com"] + } + + condition { + test = "StringEquals" + variable = "AWS:SourceArn" + values = [aws_cloudfront_distribution.main.arn] + } + } +} + +resource "aws_s3_bucket_policy" "images" { + bucket = aws_s3_bucket.images.id + policy = data.aws_iam_policy_document.images_cdn.json +} diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf new file mode 100644 index 0000000..c418109 --- /dev/null +++ b/infra/terraform/main.tf @@ -0,0 +1,55 @@ +terraform { + required_version = ">= 1.10" + + required_providers { + aws = { source = "hashicorp/aws", version = "~> 5.60" } + random = { source = "hashicorp/random", version = "~> 3.6" } + } + + backend "s3" { + bucket = "hanspoon-tfstate" + key = "prod/terraform.tfstate" + region = "ap-northeast-2" + encrypt = true + use_lockfile = true + } +} + +provider "aws" { + region = var.region + default_tags { tags = local.common_tags } +} + +provider "aws" { + alias = "us_east_1" + region = "us-east-1" + default_tags { tags = local.common_tags } +} + +data "aws_availability_zones" "available" { + state = "available" +} + +locals { + name_prefix = "${var.project}-${var.env}" + api_fqdn = "${var.api_subdomain}.${var.domain_name}" + + common_tags = { + Project = var.project + Env = var.env + ManagedBy = "terraform" + } + + azs = slice(data.aws_availability_zones.available.names, 0, 2) + + public_subnets = { + for idx, az in local.azs : az => { cidr = cidrsubnet(var.vpc_cidr, 8, idx), az = az } + } + + private_subnets = { + for idx, az in local.azs : az => { cidr = cidrsubnet(var.vpc_cidr, 8, idx + 10), az = az } + } + + backend_memory = 640 + ai_memory = 768 +} \ No newline at end of file diff --git a/infra/terraform/network.tf b/infra/terraform/network.tf new file mode 100644 index 0000000..0954ccd --- /dev/null +++ b/infra/terraform/network.tf @@ -0,0 +1,120 @@ +resource "aws_vpc" "main" { + cidr_block = var.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + + tags = { Name = "${local.name_prefix}-vpc" } +} + +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + tags = { Name = "${local.name_prefix}-igw" } +} + +# 퍼블릭: ECS 컨테이너 인스턴스. +resource "aws_subnet" "public" { + for_each = local.public_subnets + + vpc_id = aws_vpc.main.id + cidr_block = each.value.cidr + availability_zone = each.value.az + map_public_ip_on_launch = true + + tags = { Name = "${local.name_prefix}-public-${each.key}" } +} + +# 프라이빗: RDS 전용. +resource "aws_subnet" "private" { + for_each = local.private_subnets + + vpc_id = aws_vpc.main.id + cidr_block = each.value.cidr + availability_zone = each.value.az + + tags = { Name = "${local.name_prefix}-private-${each.key}" } +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.main.id + } + + tags = { Name = "${local.name_prefix}-rt-public" } +} + +resource "aws_route_table_association" "public" { + for_each = aws_subnet.public + + subnet_id = each.value.id + route_table_id = aws_route_table.public.id +} + +resource "aws_route_table" "private" { + vpc_id = aws_vpc.main.id + tags = { Name = "${local.name_prefix}-rt-private" } +} + +resource "aws_route_table_association" "private" { + for_each = aws_subnet.private + + subnet_id = each.value.id + route_table_id = aws_route_table.private.id +} + +resource "aws_vpc_endpoint" "s3" { + vpc_id = aws_vpc.main.id + service_name = "com.amazonaws.${var.region}.s3" + vpc_endpoint_type = "Gateway" + route_table_ids = [aws_route_table.public.id, aws_route_table.private.id] + + tags = { Name = "${local.name_prefix}-vpce-s3" } +} + +# ── 보안 그룹 ──────────────────────────────────────────────── +data "aws_ec2_managed_prefix_list" "cloudfront" { + name = "com.amazonaws.global.cloudfront.origin-facing" +} + +resource "aws_security_group" "instance" { + name = "${local.name_prefix}-sg-instance" + description = "ECS container instance" + vpc_id = aws_vpc.main.id + + tags = { Name = "${local.name_prefix}-sg-instance" } +} + +resource "aws_vpc_security_group_ingress_rule" "instance_from_cloudfront" { + security_group_id = aws_security_group.instance.id + description = "HTTP(8080) from CloudFront edges only" + prefix_list_id = data.aws_ec2_managed_prefix_list.cloudfront.id + ip_protocol = "tcp" + from_port = 8080 + to_port = 8080 +} + +# 아웃바운드: CLOVA OCR, LLM API, ECR, SSM 호출용 +resource "aws_vpc_security_group_egress_rule" "instance_all" { + security_group_id = aws_security_group.instance.id + ip_protocol = "-1" + cidr_ipv4 = "0.0.0.0/0" +} + +resource "aws_security_group" "rds" { + name = "${local.name_prefix}-sg-rds" + description = "PostgreSQL from ECS instances only" + vpc_id = aws_vpc.main.id + + tags = { Name = "${local.name_prefix}-sg-rds" } +} + +# SG 를 참조하게 함. +resource "aws_vpc_security_group_ingress_rule" "rds_from_instance" { + security_group_id = aws_security_group.rds.id + referenced_security_group_id = aws_security_group.instance.id + ip_protocol = "tcp" + from_port = 5432 + to_port = 5432 +} \ No newline at end of file diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf new file mode 100644 index 0000000..2ccf248 --- /dev/null +++ b/infra/terraform/outputs.tf @@ -0,0 +1,52 @@ +output "cloudfront_domain" { + description = "CloudFront 배포 도메인" + value = aws_cloudfront_distribution.main.domain_name +} + +output "origin_eip" { + description = "EC2 오리진 고정 IP" + value = aws_eip.origin.public_ip +} + +output "rds_endpoint" { + description = "RDS 엔드포인트 (application-prod.yml 의 DB_HOST)" + value = aws_db_instance.main.address +} + +output "ecr_repository_urls" { + description = "GitHub Actions 가 push 할 대상" + value = { for k, v in aws_ecr_repository.app : k => v.repository_url } +} + +output "github_actions_role_arn" { + description = "워크플로의 role-to-assume 에 넣을 값" + value = aws_iam_role.github_actions.arn +} + +output "s3_bucket_name" { + description = "이미지 버킷" + value = aws_s3_bucket.images.bucket +} + +output "origin_secret" { + description = "오리진이 검증할 X-Origin-Secret 헤더 값" + value = random_password.origin_secret.result + sensitive = true +} +# ── DNS 수동 등록용 (Vercel 대시보드에 입력) ────────────────── +output "acm_validation_record" { + description = "1단계: Vercel DNS 에 등록할 인증서 검증 CNAME" + value = { + for dvo in aws_acm_certificate.cdn.domain_validation_options : + dvo.domain_name => { + type = dvo.resource_record_type + name = dvo.resource_record_name + value = dvo.resource_record_value + } + } +} + +output "api_dns_record" { + description = "2단계: Vercel DNS 에 등록할 API CNAME" + value = "${local.api_fqdn} CNAME ${aws_cloudfront_distribution.main.domain_name}" +} diff --git a/infra/terraform/storage.tf b/infra/terraform/storage.tf new file mode 100644 index 0000000..744b3ba --- /dev/null +++ b/infra/terraform/storage.tf @@ -0,0 +1,123 @@ +# ── S3: 메뉴 이미지 ────────────────────────────────────────── +resource "random_id" "bucket_suffix" { + byte_length = 4 +} + +resource "aws_s3_bucket" "images" { + bucket = "${local.name_prefix}-images-${random_id.bucket_suffix.hex}" + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_versioning" "images" { + bucket = aws_s3_bucket.images.id + versioning_configuration { status = "Enabled" } +} + +resource "aws_s3_bucket_public_access_block" "images" { + bucket = aws_s3_bucket.images.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_lifecycle_configuration" "images" { + bucket = aws_s3_bucket.images.id + + # 스캔 원본은 90일 후 삭제. + rule { + id = "expire-scan-originals" + status = "Enabled" + filter { prefix = "scans/" } + expiration { days = 90 } + } + + # 대표 메뉴 이미지는 영구 보관, 옛 버전은 정리 + rule { + id = "cleanup-old-versions" + status = "Enabled" + filter { prefix = "menus/" } + noncurrent_version_expiration { noncurrent_days = 30 } + } +} + +# ── RDS: PostgreSQL 16 ─────────────────────────────────────── +resource "aws_db_subnet_group" "main" { + name = "${local.name_prefix}-db-subnet" + subnet_ids = [for s in aws_subnet.private : s.id] +} + +resource "random_password" "db" { + length = 32 + special = false +} + +resource "aws_db_instance" "main" { + identifier = "${local.name_prefix}-db" + engine = "postgres" + engine_version = "16" + instance_class = var.db_instance_class + + allocated_storage = 20 + max_allocated_storage = 50 + storage_type = "gp3" + storage_encrypted = true + + db_name = "hanspoon" + username = "hanspoon_app" + password = random_password.db.result + + db_subnet_group_name = aws_db_subnet_group.main.name + vpc_security_group_ids = [aws_security_group.rds.id] + publicly_accessible = false + multi_az = false # 런칭 시 true 로 + + backup_retention_period = 7 + backup_window = "17:00-18:00" + maintenance_window = "sun:18:00-sun:19:00" + + deletion_protection = true + skip_final_snapshot = false + final_snapshot_identifier = "${local.name_prefix}-db-final" + + performance_insights_enabled = false + + lifecycle { + prevent_destroy = true + } +} + +# ── SSM ─────────────────────────────────────── +locals { + app_secrets = [ + "db_password", + "jwt_secret", + "google_client_id", + "openai_api_key", + "clova_ocr_secret", + "clova_ocr_url", + ] +} + +resource "aws_ssm_parameter" "app" { + for_each = toset(local.app_secrets) + + name = "/${local.name_prefix}/${each.key}" + type = "SecureString" + value = "PLACEHOLDER" # 실제 값은 CLI 로 주입 + + lifecycle { + ignore_changes = [value] + } +} + +# Terraform 이 생성한 DB 비밀번호를 옮겨 담을 곳 (최초 1회 참고용) +resource "aws_ssm_parameter" "db_password_seed" { + name = "/${local.name_prefix}/db_password_seed" + type = "SecureString" + value = random_password.db.result +} \ No newline at end of file diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf new file mode 100644 index 0000000..27d76ae --- /dev/null +++ b/infra/terraform/variables.tf @@ -0,0 +1,67 @@ +variable "project" { + type = string + default = "hanspoon" +} + +variable "env" { + type = string + default = "prod" + validation { + condition = contains(["dev", "prod"], var.env) + error_message = "env 는 dev 또는 prod 여야 함." + } +} + +variable "region" { + type = string + default = "ap-northeast-2" +} + +variable "vpc_cidr" { + type = string + default = "10.0.0.0/16" + validation { + condition = can(cidrhost(var.vpc_cidr, 0)) + error_message = "유효한 CIDR 표기여야 함." + } +} + +variable "domain_name" { + type = string + default = "han-spoon.site" +} + +variable "instance_type" { + type = string + default = "t4g.small" +} + +variable "db_instance_class" { + type = string + default = "db.t4g.micro" +} + +variable "github_repository" { + type = string + default = "Han-Spoon/backend-api" +} + +variable "alert_email" { + type = string + default = "lys8167@gmail.com" +} + +variable "monthly_budget_usd" { + type = number + default = 60 + validation { + condition = var.monthly_budget_usd > 0 + error_message = "예산은 0보다 커야 함." + } +} + +variable "api_subdomain" { + description = "API 호스트. root 도메인은 Vercel 프론트엔드가 쓰고 있음." + type = string + default = "api" +} \ No newline at end of file