diff --git a/.github/workflows/terraform-apply.yml b/.github/workflows/terraform-apply.yml index c1026b3..de7e1fd 100644 --- a/.github/workflows/terraform-apply.yml +++ b/.github/workflows/terraform-apply.yml @@ -199,6 +199,7 @@ jobs: run: | terraform apply -auto-approve \ -var-file="../../config/secrets/prod.tfvars" \ + -var-file="../../config/secrets/prod_db.tfvars" \ -var-file="../../config/secrets/app_stack.tfvars" - name: Stop SSM Tunnel if: always() diff --git a/.github/workflows/terraform-plan.yml b/.github/workflows/terraform-plan.yml index 40a2a7a..a200494 100644 --- a/.github/workflows/terraform-plan.yml +++ b/.github/workflows/terraform-plan.yml @@ -267,6 +267,7 @@ jobs: run: | terraform plan -no-color \ -var-file="../../config/secrets/prod.tfvars" \ + -var-file="../../config/secrets/prod_db.tfvars" \ -var-file="../../config/secrets/app_stack.tfvars" \ 2>&1 | tee plan_output.txt echo "exitcode=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT diff --git a/.gitignore b/.gitignore index a7a811f..d95d421 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ modules/shared_resources/dist/*.zip # --- IDE & OS --- .idea/ .DS_Store + +# --- AGENTS --- +AGENTS.local.md \ No newline at end of file diff --git a/config/secrets b/config/secrets index c1eaa90..c9f90e3 160000 --- a/config/secrets +++ b/config/secrets @@ -1 +1 @@ -Subproject commit c1eaa90de823b64108732407523b1c78a9873c40 +Subproject commit c9f90e38261e50b7ffab749a37afc25dade1721a diff --git a/environment/prod/main.tf b/environment/prod/main.tf index be2d166..ab7a423 100644 --- a/environment/prod/main.tf +++ b/environment/prod/main.tf @@ -29,8 +29,9 @@ module "prod_stack" { db_data_volume_size = var.db_data_volume_size # 보안 그룹 규칙 - api_ingress_rules = var.api_ingress_rules - db_ingress_rules = var.db_ingress_rules + api_ingress_rules = var.api_ingress_rules + rds_ingress_rules = var.rds_ingress_rules + db_ec2_ingress_rules = var.db_ec2_ingress_rules # RDS 식별자 설정 rds_identifier = var.rds_identifier diff --git a/environment/prod/mysql_backup.tf b/environment/prod/mysql_backup.tf new file mode 100644 index 0000000..2bb5c55 --- /dev/null +++ b/environment/prod/mysql_backup.tf @@ -0,0 +1,173 @@ +resource "aws_s3_bucket" "mysql_backup" { + bucket = var.mysql_backup_bucket_name + force_destroy = false + object_lock_enabled = true + + tags = { + Name = var.mysql_backup_bucket_name + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_ownership_controls" "mysql_backup" { + bucket = aws_s3_bucket.mysql_backup.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_public_access_block" "mysql_backup" { + bucket = aws_s3_bucket.mysql_backup.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_policy" "mysql_backup" { + bucket = aws_s3_bucket.mysql_backup.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "DenyInsecureTransport" + Effect = "Deny" + Principal = "*" + Action = "s3:*" + Resource = [ + aws_s3_bucket.mysql_backup.arn, + "${aws_s3_bucket.mysql_backup.arn}/*", + ] + Condition = { + Bool = { + "aws:SecureTransport" = "false" + } + } + }, + ] + }) +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "mysql_backup" { + bucket = aws_s3_bucket.mysql_backup.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_versioning" "mysql_backup" { + bucket = aws_s3_bucket.mysql_backup.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_object_lock_configuration" "mysql_backup" { + bucket = aws_s3_bucket.mysql_backup.id + + depends_on = [aws_s3_bucket_versioning.mysql_backup] + + rule { + default_retention { + mode = "GOVERNANCE" + days = 14 + } + } +} + +resource "aws_s3_bucket_lifecycle_configuration" "mysql_backup" { + bucket = aws_s3_bucket.mysql_backup.id + + depends_on = [ + aws_s3_bucket_versioning.mysql_backup, + aws_s3_bucket_object_lock_configuration.mysql_backup, + ] + + rule { + id = "expire-mysql-backups-after-14-days" + status = "Enabled" + + filter {} + + expiration { + days = 14 + } + + noncurrent_version_expiration { + noncurrent_days = 1 + } + + abort_incomplete_multipart_upload { + days_after_initiation = 1 + } + } + + rule { + id = "remove-expired-delete-markers" + status = "Enabled" + + filter {} + + expiration { + expired_object_delete_marker = true + } + } +} + +# S3 Gateway Endpoint에는 별도 시간당 또는 데이터 처리 비용이 없습니다. +# 접근 권한은 Terraform에 선언하지 않고 수동으로 관리하는 IAM 정책에서 제한합니다. +data "aws_route_table" "db_ec2" { + subnet_id = var.db_ec2_subnet_id +} + +resource "aws_vpc_endpoint" "mysql_backup_s3" { + vpc_id = data.aws_vpc.default.id + service_name = "com.amazonaws.ap-northeast-2.s3" + vpc_endpoint_type = "Gateway" + route_table_ids = [data.aws_route_table.db_ec2.id] + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowBackupBucketMetadataAccess" + Effect = "Allow" + Principal = "*" + Action = [ + "s3:GetBucketLocation", + "s3:ListBucket", + "s3:ListBucketMultipartUploads", + "s3:ListBucketVersions", + ] + Resource = aws_s3_bucket.mysql_backup.arn + }, + { + Sid = "AllowBackupObjectAccess" + Effect = "Allow" + Principal = "*" + Action = [ + "s3:AbortMultipartUpload", + "s3:GetObject", + "s3:GetObjectVersion", + "s3:ListMultipartUploadParts", + "s3:PutObject", + ] + Resource = "${aws_s3_bucket.mysql_backup.arn}/*" + }, + ] + }) + + tags = { + Name = "solid-connection-prod-mysql-backup-s3" + } +} diff --git a/environment/prod/outputs.tf b/environment/prod/outputs.tf new file mode 100644 index 0000000..056ee67 --- /dev/null +++ b/environment/prod/outputs.tf @@ -0,0 +1,9 @@ +output "mysql_backup_bucket_name" { + description = "Prod MySQL 백업 S3 버킷 이름" + value = aws_s3_bucket.mysql_backup.bucket +} + +output "mysql_backup_s3_vpc_endpoint_id" { + description = "Prod MySQL 백업용 S3 Gateway VPC Endpoint ID" + value = aws_vpc_endpoint.mysql_backup_s3.id +} diff --git a/environment/prod/variables.tf b/environment/prod/variables.tf index af55f8c..9236616 100644 --- a/environment/prod/variables.tf +++ b/environment/prod/variables.tf @@ -38,6 +38,11 @@ variable "db_data_volume_size" { type = number } +variable "mysql_backup_bucket_name" { + description = "Prod MySQL dump와 binlog를 보관할 S3 버킷 이름" + type = string +} + variable "api_ingress_rules" { description = "List of ingress rules for API Server" type = list(object({ @@ -49,8 +54,18 @@ variable "api_ingress_rules" { })) } -variable "db_ingress_rules" { - description = "List of ingress rules for DB Server" +variable "rds_ingress_rules" { + description = "API Server Security Group에서 RDS로 허용할 ingress 규칙" + type = list(object({ + from_port = number + to_port = number + protocol = string + description = string + })) +} + +variable "db_ec2_ingress_rules" { + description = "API Server Security Group에서 DB EC2로 허용할 ingress 규칙" type = list(object({ from_port = number to_port = number diff --git a/modules/app_stack/security_groups.tf b/modules/app_stack/security_groups.tf index 7474ed7..0de5d32 100644 --- a/modules/app_stack/security_groups.tf +++ b/modules/app_stack/security_groups.tf @@ -35,12 +35,15 @@ resource "aws_security_group" "db_ec2_sg" { description = "Security Group for DB EC2" vpc_id = var.vpc_id - ingress { - description = "MySQL from API server" - from_port = 3306 - to_port = 3306 - protocol = "tcp" - security_groups = [aws_security_group.api_sg.id] + dynamic "ingress" { + for_each = var.db_ec2_ingress_rules + content { + description = ingress.value.description + from_port = ingress.value.from_port + to_port = ingress.value.to_port + protocol = ingress.value.protocol + security_groups = [aws_security_group.api_sg.id] + } } egress { @@ -63,7 +66,7 @@ resource "aws_security_group" "db_sg" { vpc_id = var.vpc_id dynamic "ingress" { - for_each = var.db_ingress_rules + for_each = var.rds_ingress_rules content { description = ingress.value.description from_port = ingress.value.from_port diff --git a/modules/app_stack/variables.tf b/modules/app_stack/variables.tf index e7a2e2b..c4bb113 100644 --- a/modules/app_stack/variables.tf +++ b/modules/app_stack/variables.tf @@ -74,8 +74,19 @@ variable "api_ingress_rules" { })) } -variable "db_ingress_rules" { - description = "List of ingress rules for DB Server" +variable "rds_ingress_rules" { + description = "API Server Security Group에서 RDS로 허용할 ingress 규칙" + type = list(object({ + from_port = number + to_port = number + protocol = string + description = string + })) + default = [] +} + +variable "db_ec2_ingress_rules" { + description = "API Server Security Group에서 DB EC2로 허용할 ingress 규칙" type = list(object({ from_port = number to_port = number