diff --git a/.gitignore b/.gitignore index d78b2be..a7a811f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *.tfstate.backup *.tfvars .terraform.lock.hcl +modules/shared_resources/dist/*.zip # --- Secrets (보안상 절대 커밋 금지) --- *.pem diff --git a/README.md b/README.md index 6d5ae6d..a4229e0 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,23 @@ solid-connection-infra/ │ └── secrets/ # 민감한 data 관리 │ └── ... ├── modules/ -│ └── app_stack/ # [Prod/Stage 환경의 공통 모듈] -│ ├── security_groups.tf -│ ├── ec2.tf -│ ├── rds.tf +│ ├── app_stack/ # [Prod/Stage 환경의 공통 모듈] +│ │ ├── security_groups.tf +│ │ ├── ec2.tf +│ │ ├── rds.tf +│ │ ├── variables.tf +│ │ └── outputs.tf +│ └── shared_resources/ # [global 환경의 공유 자원 모듈] +│ ├── src/ +│ │ ├── img_resizing/ +│ │ │ └── index.js +│ │ └── thumbnail/ +│ │ └── index.js +│ ├── cloudfront.tf +│ ├── lambda.tf +│ ├── provider.tf │ ├── s3.tf -│ ├── variables.tf -│ └── outputs.tf +│ └── variables.tf └── environments/ ├── prod/ # [Prod 환경] │ ├── main.tf @@ -29,7 +39,11 @@ solid-connection-infra/ │ ├── main.tf │ ├── provider.tf │ └── variables.tf - └── monitoring/ # [Monitoring 환경] + ├── monitoring/ # [부하테스트 환경] + │ ├── main.tf + │ ├── provider.tf + │ └── variables.tf + └── global/ # [global 공유 환경] ├── main.tf ├── provider.tf └── variables.tf diff --git a/config/secrets b/config/secrets index c1cf69a..29cb906 160000 --- a/config/secrets +++ b/config/secrets @@ -1 +1 @@ -Subproject commit c1cf69a9de6f6b766750395875cd5bdcb16a0e96 +Subproject commit 29cb906df3e18f0a3ecc5272cc1a869478fe2542 diff --git a/environment/global/main.tf b/environment/global/main.tf new file mode 100644 index 0000000..ea785f0 --- /dev/null +++ b/environment/global/main.tf @@ -0,0 +1,25 @@ +module "shared_resources" { + source = "../../modules/shared_resources" + + providers = { + aws = aws + } + + s3_default_bucket_name = var.s3_default_bucket_name + s3_upload_bucket_name = var.s3_upload_bucket_name + + resizing_img_func_name = var.resizing_img_func_name + resizing_img_func_role = var.resizing_img_func_role + resizing_img_func_handler = var.resizing_img_func_handler + resizing_img_func_runtime = var.resizing_img_func_runtime + resizing_img_func_layers = var.resizing_img_func_layers + + thumbnail_generating_func_name = var.thumbnail_generating_func_name + thumbnail_generating_func_role = var.thumbnail_generating_func_role + thumbnail_generating_func_handler = var.thumbnail_generating_func_handler + thumbnail_generating_func_runtime = var.thumbnail_generating_func_runtime + thumbnail_generating_func_layers = var.thumbnail_generating_func_layers + + default_cdn_web_acl_id = var.default_cdn_web_acl_id + upload_cdn_web_acl_id = var.upload_cdn_web_acl_id +} \ No newline at end of file diff --git a/environment/global/provider.tf b/environment/global/provider.tf new file mode 100644 index 0000000..38fdf1e --- /dev/null +++ b/environment/global/provider.tf @@ -0,0 +1,21 @@ +terraform { + required_version = ">= 1.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = "ap-northeast-2" + + default_tags { + tags = { + Project = "solid-connection" + Environment = "global" + } + } +} \ No newline at end of file diff --git a/environment/global/variables.tf b/environment/global/variables.tf new file mode 100644 index 0000000..58d89e5 --- /dev/null +++ b/environment/global/variables.tf @@ -0,0 +1,71 @@ +# [S3 버킷 관련 변수] +variable "s3_default_bucket_name" { + description = "Name of the default S3 bucket" + type = string +} + +variable "s3_upload_bucket_name" { + description = "Name of the upload S3 bucket" + type = string +} + +# [Lambda 관련 변수] +variable "resizing_img_func_name" { + description = "Image Resizing function name for uploaded s3 file" + type = string +} + +variable "resizing_img_func_role" { + description = "Image Resizing function role for uploaded s3 file" + type = string +} + +variable "resizing_img_func_handler" { + description = "Image Resizing function handler for uploaded s3 file" + type = string +} + +variable "resizing_img_func_runtime" { + description = "Image Resizing function runtime for uploaded s3 file" + type = string +} + +variable "thumbnail_generating_func_name" { + description = "Thumbnail generating function name for uploaded s3 file" + type = string +} + +variable "thumbnail_generating_func_role" { + description = "Thumbnail generating function role for uploaded s3 file" + type = string +} + +variable "thumbnail_generating_func_handler" { + description = "Thumbnail generating function handler for uploaded s3 file" + type = string +} + +variable "thumbnail_generating_func_runtime" { + description = "Thumbnail generating function runtime for uploaded s3 file" + type = string +} + +variable "resizing_img_func_layers" { + description = "Layers For Image Resizing func" + type = list(string) +} + +variable "thumbnail_generating_func_layers" { + description = "Layers For Image Resizing func" + type = list(string) +} + +variable "default_cdn_web_acl_id" { + description = "WAF Web ACL Id for Default Cloudfront CDN" + type = string +} + +variable "upload_cdn_web_acl_id" { + description = "WAF Web ACL Id for Upload Cloudfront CDN" + type = string +} diff --git a/environment/prod/main.tf b/environment/prod/main.tf index 49adda0..626b9bd 100644 --- a/environment/prod/main.tf +++ b/environment/prod/main.tf @@ -41,8 +41,4 @@ module "prod_stack" { domain_name = var.domain_name cert_email = var.cert_email nginx_conf_name = var.nginx_conf_name - - # S3 버킷 이름 전달 - s3_default_bucket_name = var.s3_default_bucket_name - s3_upload_bucket_name = var.s3_upload_bucket_name } diff --git a/environment/prod/variables.tf b/environment/prod/variables.tf index dfcf981..824c661 100644 --- a/environment/prod/variables.tf +++ b/environment/prod/variables.tf @@ -93,13 +93,3 @@ variable "nginx_conf_name" { description = "Nginx conf name for the prod environment" type = string } - -variable "s3_default_bucket_name" { - description = "Name of the default S3 bucket" - type = string -} - -variable "s3_upload_bucket_name" { - description = "Name of the upload S3 bucket" - type = string -} diff --git a/environment/stage/main.tf b/environment/stage/main.tf index 5fd2308..1421683 100644 --- a/environment/stage/main.tf +++ b/environment/stage/main.tf @@ -41,8 +41,4 @@ module "stage_stack" { domain_name = var.domain_name cert_email = var.cert_email nginx_conf_name = var.nginx_conf_name - - # S3 버킷 이름 전달 - s3_default_bucket_name = var.s3_default_bucket_name - s3_upload_bucket_name = var.s3_upload_bucket_name } diff --git a/environment/stage/variables.tf b/environment/stage/variables.tf index 2e18f2a..e6baa8c 100644 --- a/environment/stage/variables.tf +++ b/environment/stage/variables.tf @@ -93,13 +93,3 @@ variable "nginx_conf_name" { description = "Nginx conf name for the stage environment" type = string } - -variable "s3_default_bucket_name" { - description = "Name of the default S3 bucket" - type = string -} - -variable "s3_upload_bucket_name" { - description = "Name of the upload S3 bucket" - type = string -} diff --git a/modules/app_stack/variables.tf b/modules/app_stack/variables.tf index 878b0fe..de7497f 100644 --- a/modules/app_stack/variables.tf +++ b/modules/app_stack/variables.tf @@ -103,14 +103,3 @@ variable "nginx_conf_name" { description = "Nginx config filename" type = string } - -# [S3 버킷 관련 변수] -variable "s3_default_bucket_name" { - description = "Name of the default S3 bucket" - type = string -} - -variable "s3_upload_bucket_name" { - description = "Name of the upload S3 bucket" - type = string -} diff --git a/modules/shared_resources/cloudfront.tf b/modules/shared_resources/cloudfront.tf new file mode 100644 index 0000000..d9c689b --- /dev/null +++ b/modules/shared_resources/cloudfront.tf @@ -0,0 +1,97 @@ +# 1. CDN for Default Bucket +resource "aws_cloudfront_distribution" "default_cdn" { + enabled = true + is_ipv6_enabled = true + comment = "solid-connection s3 default cloudfront" + price_class = "PriceClass_All" + http_version = "http2" + + web_acl_id = var.default_cdn_web_acl_id + + tags = { + "Name" = "solid-connection s3 default cloudfront" + } + + origin { + domain_name = "${var.s3_default_bucket_name}.s3.ap-northeast-2.amazonaws.com" + origin_id = "${var.s3_default_bucket_name}.s3.ap-northeast-2.amazonaws.com-mjo1g7tk2w8" # 기존 ID 유지 + origin_access_control_id = "E14M8OP55A3YO7" + + connection_attempts = 3 + connection_timeout = 10 + } + + default_cache_behavior { + target_origin_id = "${var.s3_default_bucket_name}.s3.ap-northeast-2.amazonaws.com-mjo1g7tk2w8" # 위 origin_id와 같아야 함 + viewer_protocol_policy = "redirect-to-https" + compress = true + + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + + cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" + + smooth_streaming = false + } + + restrictions { + geo_restriction { + restriction_type = "none" + locations = [] + } + } + + viewer_certificate { + cloudfront_default_certificate = true + minimum_protocol_version = "TLSv1" + } +} + +# 2. CDN for Upload Bucket +resource "aws_cloudfront_distribution" "upload_cdn" { + enabled = true + is_ipv6_enabled = true + comment = "solid-connection s3 upload cloudfront" + price_class = "PriceClass_All" + http_version = "http2" + + web_acl_id = var.upload_cdn_web_acl_id + + tags = { + "Name" = "solid-connection s3 upload cloudfront" + } + + origin { + domain_name = "${var.s3_upload_bucket_name}.s3.ap-northeast-2.amazonaws.com" + origin_id = "${var.s3_upload_bucket_name}.s3.ap-northeast-2.amazonaws.com-mjo1jpx6rvc" + origin_access_control_id = "E1ZBB5RMSBZQ4I" + + connection_attempts = 3 + connection_timeout = 10 + } + + default_cache_behavior { + target_origin_id = "${var.s3_upload_bucket_name}.s3.ap-northeast-2.amazonaws.com-mjo1jpx6rvc" + viewer_protocol_policy = "redirect-to-https" + compress = true + + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + + cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" + + smooth_streaming = false + } + + restrictions { + geo_restriction { + restriction_type = "none" + locations = [] + } + } + + viewer_certificate { + cloudfront_default_certificate = true + minimum_protocol_version = "TLSv1" + } +} \ No newline at end of file diff --git a/modules/shared_resources/lambda.tf b/modules/shared_resources/lambda.tf new file mode 100644 index 0000000..2865d2f --- /dev/null +++ b/modules/shared_resources/lambda.tf @@ -0,0 +1,78 @@ +# 1. Lambda 소스 코드 압축 +data "archive_file" "resizing_zip" { + type = "zip" + source_dir = "${path.module}/src/img_resizing" + output_path = "${path.module}/dist/img_resizing.zip" +} + +data "archive_file" "thumbnail_zip" { + type = "zip" + source_dir = "${path.module}/src/thumbnail" + output_path = "${path.module}/dist/thumbnail.zip" +} + +# 2. Lambda Resource Definition +resource "aws_lambda_function" "resizing_img_func" { + function_name = var.resizing_img_func_name + role = var.resizing_img_func_role + handler = var.resizing_img_func_handler + runtime = var.resizing_img_func_runtime + + filename = data.archive_file.resizing_zip.output_path + source_code_hash = data.archive_file.resizing_zip.output_base64sha256 + + layers = var.resizing_img_func_layers + timeout = 15 +} + +resource "aws_lambda_function" "thumbnail_generating_func" { + function_name = var.thumbnail_generating_func_name + role = var.thumbnail_generating_func_role + handler = var.thumbnail_generating_func_handler + runtime = var.thumbnail_generating_func_runtime + + filename = data.archive_file.thumbnail_zip.output_path + source_code_hash = data.archive_file.thumbnail_zip.output_base64sha256 + + layers = var.thumbnail_generating_func_layers + timeout = 15 +} + +# 3. Lambda Privileges Definition +resource "aws_lambda_permission" "allow_s3_resizing" { + statement_id = "AllowExecutionFromS3Bucket-Resizing" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.resizing_img_func.function_name + principal = "s3.amazonaws.com" + source_arn = aws_s3_bucket.default.arn +} + +resource "aws_lambda_permission" "allow_s3_thumbnail" { + statement_id = "AllowExecutionFromS3Bucket-Thumbnail" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.thumbnail_generating_func.function_name + principal = "s3.amazonaws.com" + source_arn = aws_s3_bucket.default.arn +} + +# 4. S3 Trigger Setting +resource "aws_s3_bucket_notification" "bucket_notification" { + bucket = aws_s3_bucket.default.id + + lambda_function { + lambda_function_arn = aws_lambda_function.resizing_img_func.arn + events = ["s3:ObjectCreated:Put", "s3:ObjectCreated:Post"] + filter_prefix = "original/" + } + + lambda_function { + lambda_function_arn = aws_lambda_function.thumbnail_generating_func.arn + events = ["s3:ObjectCreated:Put"] + filter_prefix = "chat/images/" + } + + depends_on = [ + aws_lambda_permission.allow_s3_resizing, + aws_lambda_permission.allow_s3_thumbnail + ] +} \ No newline at end of file diff --git a/modules/shared_resources/provider.tf b/modules/shared_resources/provider.tf new file mode 100644 index 0000000..e9e41f6 --- /dev/null +++ b/modules/shared_resources/provider.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} \ No newline at end of file diff --git a/modules/app_stack/s3.tf b/modules/shared_resources/s3.tf similarity index 86% rename from modules/app_stack/s3.tf rename to modules/shared_resources/s3.tf index 29838cd..9a158ab 100644 --- a/modules/app_stack/s3.tf +++ b/modules/shared_resources/s3.tf @@ -2,6 +2,8 @@ resource "aws_s3_bucket" "default" { bucket = var.s3_default_bucket_name + force_destroy = false + lifecycle { prevent_destroy = true ignore_changes = [tags_all] @@ -11,6 +13,8 @@ resource "aws_s3_bucket" "default" { resource "aws_s3_bucket" "upload" { bucket = var.s3_upload_bucket_name + force_destroy = false + lifecycle { prevent_destroy = true ignore_changes = [tags_all] diff --git a/modules/shared_resources/src/img_resizing/index.js b/modules/shared_resources/src/img_resizing/index.js new file mode 100644 index 0000000..6baaf61 --- /dev/null +++ b/modules/shared_resources/src/img_resizing/index.js @@ -0,0 +1,81 @@ +// dependencies +import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; +import { Readable } from "stream"; +import sharp from "sharp"; +import util from "util"; + +// create S3 client +const s3 = new S3Client({ region: "ap-northeast-2" }); + +// define the handler function +export const handler = async (event, context) => { + // Read options from the event parameter and get the source bucket + console.log("Reading options from event:\n", util.inspect(event, { depth: 5 })); + const srcBucket = event.Records[0].s3.bucket.name; + + // Object key may have spaces or unicode non-ASCII characters + const srcKey = decodeURIComponent(event.Records[0].s3.object.key.replace(/\+/g, " ")); + const dstBucket = srcBucket; // Destination bucket is the same as source bucket + const dstKey = srcKey.replace("original", "resize").replace(/\.\w+$/, ".webp"); // Change directory and file extension + + // Infer the image type from the file suffix + const typeMatch = srcKey.match(/\.([^.]*)$/); + if (!typeMatch) { + console.log("Could not determine the image type."); + return; + } + + // Supported image types for Sharp + const imageType = typeMatch[1].toLowerCase(); + if (imageType != "jpg" && imageType != "png") { + console.log(`Unsupported image type: ${imageType}`); + return; + } + + try { + const params = { + Bucket: srcBucket, + Key: srcKey, + }; + var response = await s3.send(new GetObjectCommand(params)); + var stream = response.Body; + + if (stream instanceof Readable) { + var content_buffer = Buffer.concat(await stream.toArray()); + } else { + throw new Error("Unknown object stream type"); + } + } catch (error) { + console.log(error); + return; + } + + const width = 600; // set thumbnail width + + try { + var output_buffer = await sharp(content_buffer) + .resize(width) + .webp() // Convert to webp + .toBuffer(); + } catch (error) { + console.log(error); + return; + } + + // Upload the resized .webp image to the destination bucket + try { + const destparams = { + Bucket: dstBucket, + Key: dstKey, + Body: output_buffer, + ContentType: "image/webp", // Set the correct Content-Type + }; + + await s3.send(new PutObjectCommand(destparams)); + } catch (error) { + console.log(error); + return; + } + + console.log("Successfully resized " + srcBucket + "/" + srcKey + " and uploaded to " + dstBucket + "/" + dstKey); +}; diff --git a/modules/shared_resources/src/thumbnail/index.js b/modules/shared_resources/src/thumbnail/index.js new file mode 100644 index 0000000..b5cfc5b --- /dev/null +++ b/modules/shared_resources/src/thumbnail/index.js @@ -0,0 +1,108 @@ +import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import sharp from 'sharp'; + +const s3Client = new S3Client({ region: 'ap-northeast-2' }); + +export const handler = async (event) => { + console.log('Event received:', JSON.stringify(event, null, 2)); + + try { + // S3 이벤트에서 버킷과 객체 정보 추출 + const record = event.Records[0]; + const bucket = record.s3.bucket.name; + const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' ')); + + console.log(`Processing file: ${key} from bucket: ${bucket}`); + + // chat/images/ 폴더의 이미지 파일만 처리 + if (!key.startsWith('chat/images/')) { + console.log('Not a chat image, skipping'); + return { statusCode: 200, body: 'Not a chat image' }; + } + + // 이미지 파일 확장자 확인 + const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp']; + if (!imageExtensions.some(ext => key.toLowerCase().endsWith(ext))) { + console.log('Not an image file, skipping'); + return { statusCode: 200, body: 'Not an image file' }; + } + + // 이미 썸네일 파일인 경우 처리하지 않음 (무한 루프 방지) + if (key.includes('_thumb')) { + console.log('Already a thumbnail, skipping'); + return { statusCode: 200, body: 'Already a thumbnail' }; + } + + // 원본 이미지 다운로드 (AWS SDK v3 방식) + console.log('Downloading original image...'); + const getCommand = new GetObjectCommand({ Bucket: bucket, Key: key }); + const response = await s3Client.send(getCommand); + + // Body를 Buffer로 변환 + const imageBuffer = await streamToBuffer(response.Body); + + // Sharp를 사용해 썸네일 생성 + console.log('Creating thumbnail...'); + const thumbnailBuffer = await sharp(imageBuffer) + .resize(200, 200, { + fit: 'inside', // 비율 유지하면서 200x200 안에 맞춤 + withoutEnlargement: true // 원본보다 크게 만들지 않음 + }) + .jpeg({ quality: 85 }) // JPEG 품질 85% + .toBuffer(); + + // 썸네일 파일명 생성 + const fileName = key.split('/').pop(); // 파일명만 추출 + const nameWithoutExt = fileName.substring(0, fileName.lastIndexOf('.')); + const extension = fileName.substring(fileName.lastIndexOf('.')); + const thumbnailKey = `chat/thumbnails/${nameWithoutExt}_thumb${extension}`; + + console.log(`Uploading thumbnail to: ${thumbnailKey}`); + + // 썸네일을 S3에 업로드 (AWS SDK v3 방식) + const putCommand = new PutObjectCommand({ + Bucket: bucket, + Key: thumbnailKey, + Body: thumbnailBuffer, + ContentType: 'image/jpeg', + Metadata: { + 'original-key': key, + 'generated-by': 'thumbnail-lambda' + } + }); + + await s3Client.send(putCommand); + + console.log(`Thumbnail created successfully: ${thumbnailKey}`); + + return { + statusCode: 200, + body: JSON.stringify({ + message: 'Thumbnail created successfully', + original: key, + thumbnail: thumbnailKey, + thumbnailSize: thumbnailBuffer.length + }) + }; + + } catch (error) { + console.error('Error creating thumbnail:', error); + + return { + statusCode: 500, + body: JSON.stringify({ + message: 'Error creating thumbnail', + error: error.message + }) + }; + } +}; + +// Stream을 Buffer로 변환하는 헬퍼 함수 +async function streamToBuffer(stream) { + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + return Buffer.concat(chunks); +} diff --git a/modules/shared_resources/variables.tf b/modules/shared_resources/variables.tf new file mode 100644 index 0000000..58d89e5 --- /dev/null +++ b/modules/shared_resources/variables.tf @@ -0,0 +1,71 @@ +# [S3 버킷 관련 변수] +variable "s3_default_bucket_name" { + description = "Name of the default S3 bucket" + type = string +} + +variable "s3_upload_bucket_name" { + description = "Name of the upload S3 bucket" + type = string +} + +# [Lambda 관련 변수] +variable "resizing_img_func_name" { + description = "Image Resizing function name for uploaded s3 file" + type = string +} + +variable "resizing_img_func_role" { + description = "Image Resizing function role for uploaded s3 file" + type = string +} + +variable "resizing_img_func_handler" { + description = "Image Resizing function handler for uploaded s3 file" + type = string +} + +variable "resizing_img_func_runtime" { + description = "Image Resizing function runtime for uploaded s3 file" + type = string +} + +variable "thumbnail_generating_func_name" { + description = "Thumbnail generating function name for uploaded s3 file" + type = string +} + +variable "thumbnail_generating_func_role" { + description = "Thumbnail generating function role for uploaded s3 file" + type = string +} + +variable "thumbnail_generating_func_handler" { + description = "Thumbnail generating function handler for uploaded s3 file" + type = string +} + +variable "thumbnail_generating_func_runtime" { + description = "Thumbnail generating function runtime for uploaded s3 file" + type = string +} + +variable "resizing_img_func_layers" { + description = "Layers For Image Resizing func" + type = list(string) +} + +variable "thumbnail_generating_func_layers" { + description = "Layers For Image Resizing func" + type = list(string) +} + +variable "default_cdn_web_acl_id" { + description = "WAF Web ACL Id for Default Cloudfront CDN" + type = string +} + +variable "upload_cdn_web_acl_id" { + description = "WAF Web ACL Id for Upload Cloudfront CDN" + type = string +}