diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..818b8a4 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# docker-compose.prod.yml 용 환경변수 템플릿 +# 실제 값은 .env 로 복사해서 채우고, .env 는 절대 커밋하지 말 것 + +# Docker Hub 사용자명 (앱 이미지: /docker-springboot:latest) +DOCKER_USERNAME=your-dockerhub-username + +# MySQL +MYSQL_ROOT_PASSWORD=change-me-root-password +DB_NAME=course_register +DB_USERNAME=course +DB_PASSWORD=change-me-app-password diff --git a/.github/workflows/cd-test.yml b/.github/workflows/cd-test.yml index 58a17f6..3d17b58 100644 --- a/.github/workflows/cd-test.yml +++ b/.github/workflows/cd-test.yml @@ -1,12 +1,14 @@ -name: CD Test Pipeline +name: CD Test Pipeline (build & push image) +# test/** 브랜치 push 시 커밋 SHA 태그로 이미지를 push 한다. +# 서버 배포는 하지 않는다 (Terraform + user-data 담당). on: push: branches: - 'test/**' jobs: - build-and-deploy-test: + build-and-push-test: runs-on: ubuntu-latest steps: @@ -22,7 +24,7 @@ jobs: - name: Grant permission for gradlew run: chmod +x gradlew - - name: Clean and Build JAR + - name: Build JAR run: | ./gradlew clean ./gradlew bootJar @@ -33,37 +35,7 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - name: Build Docker image (tag = commit SHA) - run: docker build -t ${{ secrets.DOCKER_USERNAME }}/docker-springboot:${{ github.sha }} . - - - name: Push Docker image (tag = commit SHA) - run: docker push ${{ secrets.DOCKER_USERNAME }}/docker-springboot:${{ github.sha }} - - - name: Deploy to EC2 (test) - uses: appleboy/ssh-action@v0.1.7 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_PRIVATE_KEY }} - script: | - set -e - - export DOCKER_USERNAME=${{ secrets.DOCKER_USERNAME }} - export IMAGE_TAG=${{ github.sha }} - - # DB 환경변수 (현재 prod compose를 재사용한다는 전제) - export EC2_PRIVATE_IP=${{ secrets.EC2_PRIVATE_IP }} - export SPRING_PROFILES_ACTIVE=prod - export DB_HOST=${{ secrets.DB_HOST }} - export DB_PORT=${{ secrets.DB_PORT }} - export DB_NAME=${{ secrets.DB_NAME }} - export DB_USERNAME=${{ secrets.DB_USERNAME }} - export DB_PASSWORD=${{ secrets.DB_PASSWORD }} - - echo "[INFO] Deploying test build with IMAGE_TAG=$IMAGE_TAG" - - # app만 최신 SHA 태그로 pull & restart - sudo -E docker compose -f docker-compose.prod.yml pull app - sudo -E docker compose -f docker-compose.prod.yml up -d --no-deps app - - sudo docker image prune -f + - name: Build & push image (tag = commit SHA) + run: | + docker build -t ${{ secrets.DOCKER_USERNAME }}/docker-springboot:${{ github.sha }} . + docker push ${{ secrets.DOCKER_USERNAME }}/docker-springboot:${{ github.sha }} diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 4e53cf7..9872745 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -1,11 +1,14 @@ -name: CD pipeline +name: CD pipeline (build & push image) + +# develop에 머지되면 최신 이미지를 Docker Hub에 push 한다. +# 서버 배포는 Terraform(user-data가 :latest 를 pull)이 담당하므로 여기서는 하지 않는다. on: push: branches: - develop jobs: - build-and-deploy: + build-and-push: runs-on: ubuntu-latest steps: @@ -16,12 +19,12 @@ jobs: with: java-version: '17' distribution: 'temurin' + cache: gradle - name: Grant permission for gradlew run: chmod +x gradlew - - - name: Clean and Build JAR + - name: Build JAR run: | ./gradlew clean ./gradlew bootJar @@ -32,31 +35,8 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - name: Build Docker image - run: docker build -t ${{ secrets.DOCKER_USERNAME }}/docker-springboot . + - name: Build Docker image (:latest) + run: docker build -t ${{ secrets.DOCKER_USERNAME }}/docker-springboot:latest . - name: Push to Docker Hub - run: docker push ${{ secrets.DOCKER_USERNAME }}/docker-springboot - - - name: Deploy to EC2 - uses: appleboy/ssh-action@v0.1.7 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_PRIVATE_KEY }} - script: | - export DOCKER_USERNAME=${{ secrets.DOCKER_USERNAME }} - export EC2_PRIVATE_IP=${{ secrets.EC2_PRIVATE_IP }} - export SPRING_PROFILES_ACTIVE=prod - export DB_HOST=${{ secrets.DB_HOST }} - export DB_PORT=${{ secrets.DB_PORT }} - export DB_NAME=${{ secrets.DB_NAME }} - export DB_USERNAME=${{ secrets.DB_USERNAME }} - export DB_PASSWORD=${{ secrets.DB_PASSWORD }} - - # spring boot 이미지 최신화 및 업데이트 - sudo -E docker-compose -f docker-compose.prod.yml pull app - sudo -E docker-compose -f docker-compose.prod.yml up -d --no-deps app - - # 필요 없는 이미지 정리 - sudo docker image prune -f + run: docker push ${{ secrets.DOCKER_USERNAME }}/docker-springboot:latest diff --git a/.gitignore b/.gitignore index 680f4c8..abf3587 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,18 @@ out/ src/main/resources/application.yml src/main/resources/application-test.yml -docker-compose.yml \ No newline at end of file +docker-compose.yml + +### secrets / env ### +.env +!.env.example + +### Terraform ### +**/.terraform/* +*.tfstate +*.tfstate.* +*.tfvars +!*.tfvars.example +crash.log +override.tf +override.tf.json \ No newline at end of file diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..7533ab8 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,102 @@ +# 🚀 배포 가이드 (단일 EC2 + Docker + Terraform) + +> 리전: **ap-northeast-2 (서울)** / 구조: 단일 EC2 안에 `app` + `mysql` + `redis` 컨테이너 +> 운영 방식: **테스트할 때만 `terraform apply`로 올리고, 끝나면 `terraform destroy`로 내림** (임시 환경) +> +> 최종 목표: 단일 서버로 배포하되 scale-out(다중 서버) 성능 차이를 측정할 수 있게 만드는 것. +> 이 문서는 첫 배포 기준 체크리스트다. + +--- + +## 아키텍처 + +``` + EC2 (t3.medium, Ubuntu, ap-northeast-2) + ┌──────────────────────────────────────────┐ + :80 app (Spring Boot) + ├── mysql (컨테이너, EBS 볼륨) + └── redis (컨테이너) + └──────────────────────────────────────────┘ + Terraform로 프로비저닝 · user-data로 부트스트랩 +``` + +--- + +## Phase 0 — 사전 준비 (코드 + 계정) + +배포 3단계 전에 반드시 끝내야 하는 준비. + +### 코드 +- [ ] `docker-compose.prod.yml`에 **MySQL 컨테이너 추가** + `app`이 `DB_HOST=mysql`로 연결 +- [ ] **앱 이미지 재빌드 & Docker Hub push** — ⚠️ 최신 리팩터링 반영본으로 (기존 이미지는 옛 코드) + ```bash + ./gradlew clean bootJar + docker build -t /docker-springboot:latest . + docker push /docker-springboot:latest + ``` +- [ ] **시드 데이터** — ⚠️ 과목/회원이 없으면 부하테스트에서 신청할 대상이 없음 + → `DataInitializer`(CommandLineRunner)로 과목 + 테스트 회원 N명 생성 (BCrypt 해시 처리) +- [ ] `.env` 작성 (DB 비번 등) + `.gitignore` 확인 (커밋 금지) + +### 계정 / 도구 +- [ ] AWS 계정, **IAM 사용자 + 액세스키** 발급 (Terraform용, 최소권한) +- [ ] 로컬에 **AWS CLI** 설치 + `aws configure` (region: `ap-northeast-2`) +- [ ] 로컬에 **Terraform** 설치 +- [ ] **SSH 키페어** 생성 (`ssh-keygen -t ed25519`) + +--- + +## Phase 1 — 프로비저닝 (Terraform) + +코드로 AWS 리소스를 정의하고 `apply`. + +- [ ] Terraform 프로젝트 구조 + - `provider.tf` — AWS provider, `region = "ap-northeast-2"` + - `main.tf` — 리소스 정의 + - `variables.tf` / `terraform.tfvars`(**gitignore**) — 변수·비밀값 + - `outputs.tf` — 완료 후 퍼블릭 IP 출력 +- [ ] 리소스 + - 기본 VPC/서브넷 (`data`로 참조, 새로 안 만듦) + - **Security Group**: 22(SSH, 내 IP만), 80(HTTP, 전체) + - **Key Pair** (Phase 0 공개키 등록) + - **EC2**: `t3.medium`, Ubuntu 22.04 (AMI는 `data`로 최신 조회) + - (선택) **Elastic IP** — 재시작 시 IP 고정 (안 쓰면 생략) +- [ ] `terraform init` → `plan` → `apply` → 퍼블릭 IP 획득 + +--- + +## Phase 2 — 부트스트랩 (user-data) + +EC2가 부팅하며 자동 실행하는 스크립트. + +- [ ] `user-data.sh` + - Docker + docker-compose plugin 설치 + - 앱 파일 배치: `docker-compose.prod.yml`, `.env` (git clone 또는 scp) + - `.env` 비밀값 주입 (학습 단계는 scp, 이후 SSM Parameter Store 권장) +- [ ] lua 등 리소스는 jar(classpath)에 포함되는지 확인 → 포함되면 별도 전송 불필요 + +--- + +## Phase 3 — 배포 & 검증 + +- [ ] `docker compose -f docker-compose.prod.yml up -d` +- [ ] MySQL 스키마: `ddl-auto: update`가 테이블 자동 생성 → 시드로 과목 데이터 확인 +- [ ] **스모크 테스트**: `http://` 접속 → 로그인 → 수강신청 플로우 1회 (리팩터링 후 첫 실동작 검증) +- [ ] 로그 확인: `docker compose logs -f` + +--- + +## Phase 4 — 운영 (올리기/내리기) + +- [ ] 내릴 때: `terraform destroy` → 과금 리소스 0 +- [ ] ⚠️ destroy 시 MySQL 컨테이너 데이터도 삭제됨 → 부하테스트는 매번 깨끗한 상태가 좋고, 시더가 다시 채움 +- [ ] 다시 올릴 때: `terraform apply` + +--- + +## 나중에 (scale-out 실험 단계) + +- [ ] 세션 외부화: `spring-session-data-redis` (다중 인스턴스 로그인 유지) +- [ ] nginx + app replica 다중화, 이후 EC2 다중 + ALB +- [ ] 스케줄러 중복 실행 문제 발견 → `ShedLock`으로 단일 인스턴스 실행 보장 +- [ ] 단일 vs 다중 서버 부하테스트(k6) 비교 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 322747c..4ee7077 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,4 +1,25 @@ services: + mysql: + image: mysql:8.0 + container_name: mysql + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${DB_NAME} + MYSQL_USER: ${DB_USERNAME} + MYSQL_PASSWORD: ${DB_PASSWORD} + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + volumes: + - mysql-data:/var/lib/mysql + healthcheck: + test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${MYSQL_ROOT_PASSWORD}" ] + interval: 5s + timeout: 5s + retries: 20 + networks: + - app-network + redis: image: redis:7-alpine container_name: redis @@ -22,12 +43,14 @@ services: - "80:8080" environment: SPRING_PROFILES_ACTIVE: prod - DB_HOST: ${DB_HOST} - DB_PORT: ${DB_PORT} + DB_HOST: mysql + DB_PORT: 3306 DB_NAME: ${DB_NAME} DB_USERNAME: ${DB_USERNAME} DB_PASSWORD: ${DB_PASSWORD} depends_on: + mysql: + condition: service_healthy redis: condition: service_healthy restart: unless-stopped @@ -35,6 +58,7 @@ services: - app-network volumes: + mysql-data: redis-data: networks: diff --git a/src/main/java/com/practice/course_registration/global/init/DataInitializer.java b/src/main/java/com/practice/course_registration/global/init/DataInitializer.java new file mode 100644 index 0000000..15bf16a --- /dev/null +++ b/src/main/java/com/practice/course_registration/global/init/DataInitializer.java @@ -0,0 +1,87 @@ +package com.practice.course_registration.global.init; + +import com.practice.course_registration.domain.member.domain.Member; +import com.practice.course_registration.domain.member.repository.MemberRepository; +import com.practice.course_registration.domain.subject.domain.Subject; +import com.practice.course_registration.domain.subject.repository.SubjectRepository; +import com.practice.course_registration.global.enums.SubjectDay; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; + +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 부하테스트용 시드 데이터 생성기. + * app.seed.enabled=true 일 때만 동작하며, 데이터가 비어있을 때만 생성한다(재실행 안전). + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "app.seed.enabled", havingValue = "true") +public class DataInitializer implements CommandLineRunner { + + private final SubjectRepository subjectRepository; + private final MemberRepository memberRepository; + private final BCryptPasswordEncoder passwordEncoder; + + @Value("${app.seed.member-count:100}") + private int memberCount; + + @Value("${app.seed.member-password:test1234}") + private String memberPassword; + + @Override + public void run(String... args) { + if (subjectRepository.count() == 0) { + seedSubjects(); + } + if (memberRepository.count() == 0) { + seedMembers(); + } + } + + private void seedSubjects() { + SubjectDay[] days = SubjectDay.values(); + List subjects = new ArrayList<>(); + for (int i = 1; i <= 20; i++) { + subjects.add(Subject.builder() + .subjectName("테스트과목" + i) + .professorName("교수" + i) + .code(String.format("SUBJ%03d", i)) + .limitedNum(30) // 정원을 작게 두어 동시성 경합 유도 + .registeredNum(0) + .score(3) + .subjectDay(days[i % days.length]) + .startTime(LocalTime.of(9, 0)) + .endTime(LocalTime.of(10, 30)) + .build()); + } + subjectRepository.saveAll(subjects); + log.info("[seed] 과목 {}개 생성", subjects.size()); + } + + private void seedMembers() { + // bcrypt는 비용이 커서 동일 비밀번호는 한 번만 인코딩하여 재사용 + String encoded = passwordEncoder.encode(memberPassword); + List members = new ArrayList<>(); + for (int i = 1; i <= memberCount; i++) { + members.add(new Member( + "user" + i, // memberName (unique) + String.format("2024%06d", i), // memberNumber (unique) + (i % 4) + 1, // grade 1~4 + "user" + i, // loginId (unique) + encoded // password (bcrypt) + )); + } + memberRepository.saveAll(members); + log.info("[seed] 테스트 회원 {}명 생성 (loginId: user1~user{}, pw: {})", + members.size(), memberCount, memberPassword); + } +} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 57ceef1..4130b4b 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -8,7 +8,7 @@ spring: name: course-registration datasource: - url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME} + url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul&characterEncoding=UTF-8 username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver @@ -37,4 +37,11 @@ spring: server: servlet: session: - timeout: 30m # 마지막 활동으로부터 30분 지나면 로그아웃 \ No newline at end of file + timeout: 30m # 마지막 활동으로부터 30분 지나면 로그아웃 + +# 부하테스트용 시드 데이터 (DataInitializer) +app: + seed: + enabled: true + member-count: 100 + member-password: test1234 \ No newline at end of file diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..54b348b --- /dev/null +++ b/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.0" + hashes = [ + "h1:H3mU/7URhP0uCRGK8jeQRKxx2XFzEqLiOq/L2Bbiaxs=", + "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/http" { + version = "3.6.2" + constraints = "~> 3.4" + hashes = [ + "h1:N/wta5L2f9Gdn7r43i/R82WDbVJscE4C9CxSVk8jav0=", + "zh:1de57c0f889a6a35e018e8f83b1a1fcaafccda31b1c300fbbb29d29c3a1ce038", + "zh:502925fe2ebd90715922db22588727c4c688e56939a3e87568ad1ae32ace0da3", + "zh:5dc16040d1f59e1fadef2247ab4026b661f0e6e06fee8f44fad6bcbce7c1cb73", + "zh:699d2f4834b3a8b35ab3bfff07e3cf9e0b829b4a7afc0cc5441ee693f7be2495", + "zh:7898ac80ea83cf234500bf9ec06eca339261381964889cc4a0c79ef7f75fe5d3", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:a305c06a862e6313ffee934ed77a530c4a85d165841f08b625ec272d59ad422c", + "zh:a843dc181dec46190db292547405c74a989349999219211a1fe8381daa158e8f", + "zh:cd8b6ff9622f3e200584da91ba28f6636318c526fb0110fec91a460c1fb81a9d", + "zh:d1443f92590cf368f6b125c667be8f1d65e18f844b365d4226ec5765b92daac5", + "zh:e9a70abe3333e9d038d99857a3275c8d448611b5fd351c15400d221ae4beaf1b", + "zh:ea7fd748143074a7ab6a21c3e37f9e4823f352a7f9f4172803820b92ec989d7b", + "zh:ef91118b14dc6acdd7f9693b2cb83c5a6e74623fb91b6f6b63c441b90d7734ee", + ] +} diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..926e928 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,104 @@ +# 현재 공인 IP 자동 감지 (SSH 접근을 내 IP로만 제한하기 위함) +data "http" "myip" { + url = "https://checkip.amazonaws.com" +} + +locals { + my_cidr = var.ssh_ingress_cidr != "" ? var.ssh_ingress_cidr : "${chomp(data.http.myip.response_body)}/32" + + # EC2에 전달할 .env 내용 + env_content = <<-EOT + DOCKER_USERNAME=${var.docker_username} + MYSQL_ROOT_PASSWORD=${var.mysql_root_password} + DB_NAME=${var.db_name} + DB_USERNAME=${var.db_username} + DB_PASSWORD=${var.db_password} + EOT +} + +# ---- 기본 VPC / 서브넷 (새로 만들지 않고 조회) ---- +data "aws_vpc" "default" { + default = true +} + +data "aws_subnets" "default" { + filter { + name = "vpc-id" + values = [data.aws_vpc.default.id] + } +} + +# ---- Ubuntu 22.04 최신 AMI (Canonical) ---- +data "aws_ami" "ubuntu" { + most_recent = true + owners = ["099720109477"] + + filter { + name = "name" + values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] + } + filter { + name = "virtualization-type" + values = ["hvm"] + } +} + +# ---- SSH 키페어 등록 (기존 id_ed25519.pub 재사용) ---- +resource "aws_key_pair" "this" { + key_name = var.key_name + public_key = file(pathexpand(var.public_key_path)) +} + +# ---- 보안 그룹: 22(내 IP만), 80(전체) ---- +resource "aws_security_group" "app" { + name = "${var.project_name}-sg" + description = "course-registration app security group" + vpc_id = data.aws_vpc.default.id + + ingress { + description = "SSH (my ip only)" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = [local.my_cidr] + } + + ingress { + description = "HTTP" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${var.project_name}-sg" } +} + +# ---- EC2 인스턴스 ---- +resource "aws_instance" "app" { + ami = data.aws_ami.ubuntu.id + instance_type = var.instance_type + key_name = aws_key_pair.this.key_name + subnet_id = data.aws_subnets.default.ids[0] + vpc_security_group_ids = [aws_security_group.app.id] + associate_public_ip_address = true + + user_data = templatefile("${path.module}/user_data.sh.tftpl", { + compose_b64 = base64encode(file("${path.module}/../docker-compose.prod.yml")) + env_b64 = base64encode(local.env_content) + }) + + root_block_device { + volume_size = 30 + volume_type = "gp3" + } + + tags = { Name = "${var.project_name}-app" } +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..266bdcd --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,15 @@ +# 결과 출력 +output "public_ip" { + description = "EC2 퍼블릭 IP" + value = aws_instance.app.public_ip +} + +output "app_url" { + description = "앱 접속 주소 (기동까지 수 분 소요)" + value = "http://${aws_instance.app.public_ip}" +} + +output "ssh_command" { + description = "EC2 SSH 접속 명령" + value = "ssh -i ~/.ssh/id_ed25519 ubuntu@${aws_instance.app.public_ip}" +} diff --git a/terraform/provider.tf b/terraform/provider.tf new file mode 100644 index 0000000..5a8df95 --- /dev/null +++ b/terraform/provider.tf @@ -0,0 +1,18 @@ +# 어떤 프로바이더를 쓸지 선언함 (aws, http, 리전 등), 자격증명은 자동으로 읽음 +terraform { + required_version = ">= 1.5" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + http = { + source = "hashicorp/http" + version = "~> 3.4" + } + } +} + +provider "aws" { + region = var.aws_region +} diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example new file mode 100644 index 0000000..2ae0621 --- /dev/null +++ b/terraform/terraform.tfvars.example @@ -0,0 +1,11 @@ +# 이 파일을 terraform.tfvars 로 복사해서 값을 채우세요. +# terraform.tfvars 는 .gitignore 로 커밋되지 않습니다 (비밀값 보호). + +docker_username = "kanghana1" +db_name = "course_register" +db_username = "course" +mysql_root_password = "change-me-root-password" +db_password = "change-me-app-password" + +# SSH 허용 IP를 직접 지정하려면 주석 해제 (비우면 apply 시 현재 공인 IP 자동 감지) +# ssh_ingress_cidr = "1.2.3.4/32" diff --git a/terraform/user_data.sh.tftpl b/terraform/user_data.sh.tftpl new file mode 100644 index 0000000..9983dfc --- /dev/null +++ b/terraform/user_data.sh.tftpl @@ -0,0 +1,24 @@ +#!/bin/bash +set -euxo pipefail +# EC2 부팅 스크립트 +# ---- Docker 설치 (Ubuntu 22.04) ---- +export DEBIAN_FRONTEND=noninteractive +apt-get update -y +apt-get install -y ca-certificates curl gnupg +install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc +chmod a+r /etc/apt/keyrings/docker.asc +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list +apt-get update -y +apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin +systemctl enable --now docker + +# ---- 앱 파일 배치 ---- +mkdir -p /opt/app +echo "${compose_b64}" | base64 -d > /opt/app/docker-compose.prod.yml +echo "${env_b64}" | base64 -d > /opt/app/.env + +# ---- 컨테이너 기동 ---- +cd /opt/app +docker compose -f docker-compose.prod.yml --env-file .env pull +docker compose -f docker-compose.prod.yml --env-file .env up -d diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..a6ad36a --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,67 @@ +# 입력값 정의 (변수로 분리) + +variable "aws_region" { + description = "AWS 리전" + type = string + default = "ap-northeast-2" +} + +variable "project_name" { + description = "리소스 이름 접두사" + type = string + default = "course-registration" +} + +variable "instance_type" { + description = "EC2 인스턴스 타입" + type = string + default = "t3.medium" +} + +variable "key_name" { + description = "EC2 키페어 이름" + type = string + default = "course-registration-key" +} + +variable "public_key_path" { + description = "EC2에 등록할 SSH 공개키 경로" + type = string + default = "~/.ssh/id_ed25519.pub" +} + +variable "ssh_ingress_cidr" { + description = "SSH(22) 허용 CIDR. 비워두면 현재 공인 IP를 자동 감지해 /32로 제한" + type = string + default = "" +} + +# ---- 앱/DB 주입값 (.env 로 EC2에 전달) ---- +variable "docker_username" { + description = "Docker Hub 사용자명 (이미지: /docker-springboot:latest)" + type = string +} + +variable "db_name" { + description = "MySQL 데이터베이스명" + type = string + default = "course_register" +} + +variable "db_username" { + description = "MySQL 앱 사용자명" + type = string + default = "course" +} + +variable "mysql_root_password" { + description = "MySQL root 비밀번호" + type = string + sensitive = true +} + +variable "db_password" { + description = "MySQL 앱 사용자 비밀번호" + type = string + sensitive = true +}