diff --git a/.github/workflows/backend-cd-dev.yml b/.github/workflows/backend-cd-dev.yml deleted file mode 100644 index e859b70..0000000 --- a/.github/workflows/backend-cd-dev.yml +++ /dev/null @@ -1,115 +0,0 @@ -name: Backend CD Dev Server - -on: - push: - branches: [ backend-dev ] - paths: [ 'backend/**' ] - workflow_dispatch: - -jobs: - build-and-deploy: - runs-on: [self-hosted, dev-runner] - - steps: - - name: Initialize workspace permissions - run: | - sudo chown -R ubuntu:ubuntu /home/ubuntu/actions-runner/_work - - - name: Checkout project repository - uses: actions/checkout@v4 - - - name: Create application-secret.yml file - run: | - mkdir -p backend/src/main/resources - echo "${{ secrets.DEV_SECRET_YML }}" > backend/src/main/resources/application-secret.yml - - - name: Create firebase-adminsdk-account.json file - run: | - mkdir -p backend/src/main/resources/firebase - echo "${{ secrets.FIREBASE_ADMINSDK_ACCOUNT_KEY }}" > backend/src/main/resources/firebase/firebase-adminsdk-account.json - - - name: Gradle build - run: | - chmod +x ./gradlew - sudo ./gradlew clean build - working-directory: ./backend - - - name: Get build jar file info - id: get_build_jar - run: | - BUILD_JAR_PATH=$(find ./backend/build/libs -name "*.jar" ! -name "*plain.jar" | head -n 1) - echo "BUILD_JAR_PATH=${BUILD_JAR_PATH}" >> $GITHUB_OUTPUT - echo "BUILD_JAR_NAME=$(basename ${BUILD_JAR_PATH})" >> $GITHUB_OUTPUT - - - name: Deploy to EC2 - env: - DEV_BLUE_WAS_PORT: ${{ secrets.DEV_BLUE_WAS_PORT }} - run: | - # 0. WAS 포트 - WAS_PORT=${DEV_BLUE_WAS_PORT} - - # 1. 이전 WAS 종료 (Graceful Shutdown) - echo "Stopping old Spring WAS..." - PID=$(sudo lsof -t -i:${WAS_PORT} || true) - if [ -n "$PID" ]; then - echo "Found running process with PID: $PID. Sending SIGTERM." - sudo kill -SIGTERM $PID - # 프로세스 종료 대기 - for i in $(seq 1 20); do - if ! sudo kill -0 $PID 2>/dev/null; then - echo "Process $PID has terminated." - break - fi - echo "Waiting for process $PID to terminate... ($i/20)" - sleep 1 - done - if sudo kill -0 $PID 2>/dev/null; then - echo "Process $PID did not terminate gracefully. Forcing shutdown with SIGKILL." - sudo kill -9 $PID - sleep 3 - fi - else - echo "No process found on port ${WAS_PORT}." - fi - - # 2. 이전 JAR 파일 삭제 및 빌드 JAR 파일 복사 - echo "Deleting old JAR files..." - DEPLOY_DIRECTORY="/home/ubuntu/${{ github.event.repository.name }}/backend" - echo "DEPLOY_DIRECTORY=${DEPLOY_DIRECTORY}" - mkdir -p "${DEPLOY_DIRECTORY}" - sudo find "${DEPLOY_DIRECTORY}" -maxdepth 1 -name "*.jar" -delete - echo "Old JAR files deleted." - - BUILD_JAR_PATH="${{ steps.get_build_jar.outputs.BUILD_JAR_PATH }}" - BUILD_JAR_NAME="${{ steps.get_build_jar.outputs.BUILD_JAR_NAME }}" - DEPLOY_JAR_PATH="${DEPLOY_DIRECTORY}/${BUILD_JAR_NAME}" - echo "BUILD_JAR_PATH=${BUILD_JAR_PATH}" - echo "BUILD_JAR_NAME=${BUILD_JAR_NAME}" - echo "DEPLOY_JAR_PATH=${DEPLOY_JAR_PATH}" - sudo cp "${BUILD_JAR_PATH}" "${DEPLOY_JAR_PATH}" - echo "New JAR file copied." - - # 3. 새로운 WAS 실행 - echo "Starting new Spring WAS..." - LOG_PATH="${DEPLOY_DIRECTORY}/application.log" - echo "LOG_PATH=${LOG_PATH}" - - sudo nohup java -jar -Duser.timezone=Asia/Seoul "${DEPLOY_JAR_PATH}" --spring.profiles.active=dev --server.port=${WAS_PORT} & - for i in $(seq 1 30); do - if sudo lsof -t -i:${WAS_PORT} > /dev/null; then - echo "WAS has started and is listening on port ${WAS_PORT}." - break - fi - echo "Waiting for port ${WAS_PORT} to be occupied... ($i/30)" - sleep 1 - done - - NEW_WAS_PID=$(sudo lsof -t -i:${WAS_PORT} || true) - if [ -n "$NEW_WAS_PID" ]; then - echo "Application started with PID: $NEW_WAS_PID." - else - echo "Error: Application failed to start." - # git action failed 처리 - exit 1 - fi - echo "Deployment complete." diff --git a/.github/workflows/ci-cd-dev.yml b/.github/workflows/ci-cd-dev.yml new file mode 100644 index 0000000..5276995 --- /dev/null +++ b/.github/workflows/ci-cd-dev.yml @@ -0,0 +1,84 @@ +name: CI/CD Build, Upload, Deploy (Dev) + +on: + push: + branches: [ dev ] + workflow_dispatch: + +jobs: + build-and-upload: + runs-on: ubuntu-latest + + permissions: + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: gradle + + - name: Create application-secret.yml + run: | + mkdir -p src/main/resources + echo "${{ secrets.DEV_SECRET_YML }}" > src/main/resources/application-secret.yml + + - name: Create firebase-adminsdk-account.json + run: | + mkdir -p src/main/resources/firebase + echo '${{ secrets.FIREBASE_ADMINSDK_ACCOUNT_KEY }}' \ + > src/main/resources/firebase/firebase-adminsdk-account.json + + - name: Gradle build + run: | + chmod +x ./gradlew + ./gradlew clean build + + - name: Find executable jar + id: jar + run: | + JAR_PATH=$(find build/libs -name "*.jar" ! -name "*plain.jar" | head -n 1) + if [ -z "$JAR_PATH" ]; then + echo "No executable jar found"; exit 1; + fi + echo "jar_path=$JAR_PATH" >> "$GITHUB_OUTPUT" + echo "jar_name=$(basename "$JAR_PATH")" >> "$GITHUB_OUTPUT" + + - name: Create deploy bundle + id: bundle + run: | + mkdir -p deploy + cp "${{ steps.jar.outputs.jar_path }}" deploy/ + + cp infra/appspec.yml deploy/ 2>/dev/null || true + cp infra/*.sh deploy/ 2>/dev/null || true + + cd deploy + ZIP_NAME="festabook-$(date +'%Y%m%d%H%M%S')-${GITHUB_SHA::7}.zip" + zip -r "$ZIP_NAME" . + echo "zip_name=$ZIP_NAME" >> "$GITHUB_OUTPUT" + echo "zip_path=$(pwd)/$ZIP_NAME" >> "$GITHUB_OUTPUT" + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-region: ${{ secrets.AWS_DEPLOY_REGION }} + role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }} + role-session-name: festabook-ci-cd + + - name: Upload artifact to S3 + run: | + aws s3 cp "${{ steps.bundle.outputs.zip_path }}" \ + "s3://${{ secrets.S3_ARTIFACT_BUCKET }}/dev/builds/${{ steps.bundle.outputs.zip_name }}" + + - name: Trigger CodeDeploy deployment + run: | + aws deploy create-deployment \ + --application-name "${{ secrets.CODEDEPLOY_APP_NAME }}" \ + --deployment-group-name "${{ secrets.CODEDEPLOY_DEPLOYMENT_GROUP_DEV }}" \ + --s3-location bucket=${{ secrets.S3_ARTIFACT_BUCKET }},bundleType=zip,key=dev/builds/${{ steps.bundle.outputs.zip_name }} diff --git a/.github/workflows/ci-cd-prod.yml b/.github/workflows/ci-cd-prod.yml new file mode 100644 index 0000000..d5bef7f --- /dev/null +++ b/.github/workflows/ci-cd-prod.yml @@ -0,0 +1,84 @@ +name: CI/CD Build, Upload, Deploy (Prod) + +on: + push: + branches: [ prod ] + workflow_dispatch: + +jobs: + build-and-upload: + runs-on: ubuntu-latest + + permissions: + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: gradle + + - name: Create application-secret.yml + run: | + mkdir -p src/main/resources + echo "${{ secrets.PROD_SECRET_YML }}" > src/main/resources/application-secret.yml + + - name: Create firebase-adminsdk-account.json + run: | + mkdir -p src/main/resources/firebase + echo '${{ secrets.FIREBASE_ADMINSDK_ACCOUNT_KEY }}' \ + > src/main/resources/firebase/firebase-adminsdk-account.json + + - name: Gradle build + run: | + chmod +x ./gradlew + ./gradlew clean build + + - name: Find executable jar + id: jar + run: | + JAR_PATH=$(find build/libs -name "*.jar" ! -name "*plain.jar" | head -n 1) + if [ -z "$JAR_PATH" ]; then + echo "No executable jar found"; exit 1; + fi + echo "jar_path=$JAR_PATH" >> "$GITHUB_OUTPUT" + echo "jar_name=$(basename "$JAR_PATH")" >> "$GITHUB_OUTPUT" + + - name: Create deploy bundle + id: bundle + run: | + mkdir -p deploy + cp "${{ steps.jar.outputs.jar_path }}" deploy/ + + cp infra/appspec.yml deploy/ 2>/dev/null || true + cp infra/*.sh deploy/ 2>/dev/null || true + + cd deploy + ZIP_NAME="festabook-$(date +'%Y%m%d%H%M%S')-${GITHUB_SHA::7}.zip" + zip -r "$ZIP_NAME" . + echo "zip_name=$ZIP_NAME" >> "$GITHUB_OUTPUT" + echo "zip_path=$(pwd)/$ZIP_NAME" >> "$GITHUB_OUTPUT" + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-region: ${{ secrets.AWS_DEPLOY_REGION }} + role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }} + role-session-name: festabook-ci-cd + + - name: Upload artifact to S3 + run: | + aws s3 cp "${{ steps.bundle.outputs.zip_path }}" \ + "s3://${{ secrets.S3_ARTIFACT_BUCKET }}/prod/builds/${{ steps.bundle.outputs.zip_name }}" + + - name: Trigger CodeDeploy deployment + run: | + aws deploy create-deployment \ + --application-name "${{ secrets.CODEDEPLOY_APP_NAME }}" \ + --deployment-group-name "${{ secrets.CODEDEPLOY_DEPLOYMENT_GROUP_PROD }}" \ + --s3-location bucket=${{ secrets.S3_ARTIFACT_BUCKET }},bundleType=zip,key=prod/builds/${{ steps.bundle.outputs.zip_name }} diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/ci.yml similarity index 71% rename from .github/workflows/backend-ci.yml rename to .github/workflows/ci.yml index e1aab5a..e9a1708 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,10 @@ -name: Backend CI Test +name: CI Test on: pull_request: branches: - - backend-prod - - backend-dev + - prod + - dev jobs: Run-PR-Test: @@ -30,27 +30,26 @@ jobs: path: | ~/.gradle/caches ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('backend/**/*.gradle*', 'backend/**/gradle-wrapper.properties') }} + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle- - name: Grant execute permission for gradlew - run: chmod +x backend/gradlew + run: chmod +x ./gradlew - name: Create firebase-adminsdk-account.json run: | - mkdir -p backend/src/main/resources/firebase - echo "${{ secrets.FIREBASE_ADMINSDK_ACCOUNT_KEY }}" > backend/src/main/resources/firebase/firebase-adminsdk-account.json + mkdir -p src/main/resources/firebase + echo "${{ secrets.FIREBASE_ADMINSDK_ACCOUNT_KEY }}" > src/main/resources/firebase/firebase-adminsdk-account.json - name: Run Gradle Test run: ./gradlew clean test - working-directory: backend - name: Publish Unit Test Results if: always() uses: EnricoMi/publish-unit-test-result-action@v2 with: - files: backend/build/test-results/test/TEST-*.xml + files: build/test-results/test/TEST-*.xml check_name: '테스트 결과 🛠️' check_run_annotations: 'none' comment_mode: 'off' diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 6e2d19d..3411f58 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -4,7 +4,7 @@ on: push: branches: - release/* - - main + - prod permissions: contents: write @@ -26,7 +26,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Publish Release - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/prod' id: drafter uses: release-drafter/release-drafter@v6 with: diff --git a/.github/workflows/common-slack-notify-opened.yml b/.github/workflows/slack-notify-opened.yml similarity index 100% rename from .github/workflows/common-slack-notify-opened.yml rename to .github/workflows/slack-notify-opened.yml diff --git a/.github/workflows/common-slack-notify-rerequested.yml b/.github/workflows/slack-notify-rerequested.yml similarity index 100% rename from .github/workflows/common-slack-notify-rerequested.yml rename to .github/workflows/slack-notify-rerequested.yml diff --git a/.github/workflows/common-slack-notify-submitted.yml b/.github/workflows/slack-notify-submitted.yml similarity index 100% rename from .github/workflows/common-slack-notify-submitted.yml rename to .github/workflows/slack-notify-submitted.yml diff --git a/infra/appspec.yml b/infra/appspec.yml index 23b17fd..c398436 100644 --- a/infra/appspec.yml +++ b/infra/appspec.yml @@ -1,11 +1,26 @@ version: 0.0 os: linux + files: - - source: backend/infra/output/ + - source: / destination: /home/ubuntu/app hooks: + ApplicationStop: + - location: stop.sh + timeout: 60 + runas: ubuntu + + BeforeInstall: + - location: clean.sh + timeout: 60 + ApplicationStart: - - location: backend/infra/output/start.sh + - location: start.sh timeout: 60 runas: ubuntu + + ValidateService: + - location: validate.sh + timeout: 90 + runas: ubuntu diff --git a/infra/clean.sh b/infra/clean.sh new file mode 100644 index 0000000..2ce36c5 --- /dev/null +++ b/infra/clean.sh @@ -0,0 +1,3 @@ +#!/bin/bash +echo "> Cleaning old jar files" +rm -rf /home/ubuntu/app/* diff --git a/infra/start.sh b/infra/start.sh index f4478ee..19f25a5 100644 --- a/infra/start.sh +++ b/infra/start.sh @@ -1,16 +1,28 @@ #!/bin/bash APP_HOME="/home/ubuntu/app" LOG_FILE="/tmp/app.log" -JAR_NAME=$(find $APP_HOME -name "*.jar" | head -n 1) +JAR_NAME=$(find "$APP_HOME" -maxdepth 1 -name "*.jar" | head -n 1) -echo "🚀========== ApplicationStart ==========" +PROFILE=prod + +if [[ "$DEPLOYMENT_GROUP_NAME" == *"dev"* ]]; then + PROFILE=dev +fi +echo "🚀========== ApplicationStart ==========" echo "▶️ Spring WAS 실행 중..." -if [ -f "$JAR_NAME" ]; then - sudo nohup java -jar -Duser.timezone=Asia/Seoul "$JAR_NAME" \ - --spring.profiles.active=prod > $LOG_FILE 2>&1 & - echo "📦 실행 파일: $JAR_NAME" -else +echo "▶️ DEPLOYMENT_GROUP_NAME = $DEPLOYMENT_GROUP_NAME" +echo "▶️ Active Spring profile = $PROFILE" + +if [ -z "$JAR_NAME" ]; then echo "❌ 오류: $APP_HOME 경로에서 JAR 파일을 찾을 수 없습니다." exit 1 fi + +echo "📦 실행 파일: $JAR_NAME" + +sudo nohup java -jar -Duser.timezone=Asia/Seoul "$JAR_NAME" \ + --spring.profiles.active=$PROFILE > $LOG_FILE 2>&1 & + +echo "✅ Spring WAS 실행 명령 전송 완료" +exit 0 diff --git a/infra/stop.sh b/infra/stop.sh new file mode 100644 index 0000000..46af485 --- /dev/null +++ b/infra/stop.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +PID=$(pgrep -f "festabook.*\.jar") + +if [ -z "$PID" ]; then + echo "> No running application found" +else + echo "> Killing process $PID" + + sudo kill -15 $PID + + TIMEOUT=30 + COUNT=0 + + while [ $COUNT -lt $TIMEOUT ]; do + if ps -p $PID > /dev/null 2>&1; then + echo "> 종료 대기 중... ($COUNT초 경과)" + sleep 1 + COUNT=$((COUNT + 1)) + else + echo "> 프로세스($PID)가 정상적으로 종료되었습니다." + break + fi + done + + if ps -p $PID > /dev/null 2>&1; then + echo "> 경고: $TIMEOUT초 안에 프로세스($PID)가 종료되지 않았습니다." + echo "> 강제 종료(SIGKILL -9)를 진행합니다." + sudo kill -9 $PID + else + echo "> 이전 애플리케이션 정리 완료." + fi +fi diff --git a/infra/validate.sh b/infra/validate.sh new file mode 100644 index 0000000..546f12d --- /dev/null +++ b/infra/validate.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +HEALTH_URL="http://localhost/api/actuator/health" + +echo "🔍========== ValidateService ==========" +echo "▶️ 서버 헬스 체크 시작: $HEALTH_URL" + +for i in {1..90} +do + STATUS_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_URL" || echo "000") + echo "⏱ 헬스 체크 시도 #$i → $STATUS_CODE" + + if [ "$STATUS_CODE" = "200" ]; then + echo "✅ 헬스 체크 통과" + exit 0 + fi + + sleep 1 +done + +echo "❌ 헬스 체크 실패: $HEALTH_URL" +exit 1 diff --git a/src/main/java/com/daedan/festabook/global/config/CloudWatchMetricsConfig.java b/src/main/java/com/daedan/festabook/global/config/CloudWatchMetricsConfig.java deleted file mode 100644 index 2dee54c..0000000 --- a/src/main/java/com/daedan/festabook/global/config/CloudWatchMetricsConfig.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.daedan.festabook.global.config; - -import io.micrometer.cloudwatch2.CloudWatchConfig; -import io.micrometer.cloudwatch2.CloudWatchMeterRegistry; -import io.micrometer.core.instrument.Clock; -import java.time.Duration; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; -import software.amazon.awssdk.services.cloudwatch.CloudWatchAsyncClient; - -@Profile({"prod", "dev"}) -@Configuration -public class CloudWatchMetricsConfig { - - @Value("${cloudwatch.namespace}") - private String cloudWatchNamespace; - - private static final Duration CLOUDWATCH_STEP = Duration.ofMinutes(1); - - @Bean - public CloudWatchConfig cloudWatchConfig() { - return new CloudWatchConfig() { - - @Override - public String get(String key) { - return null; - } - - @Override - public String namespace() { - return cloudWatchNamespace; - } - - @Override - public Duration step() { - return CLOUDWATCH_STEP; - } - }; - } - - @Bean - public CloudWatchAsyncClient cloudWatchAsyncClient() { - return CloudWatchAsyncClient.create(); - } - - @Bean - public CloudWatchMeterRegistry cloudWatchMeterRegistry(CloudWatchConfig config, - CloudWatchAsyncClient client) { - return new CloudWatchMeterRegistry(config, Clock.SYSTEM, client); - } -} diff --git a/src/main/java/com/daedan/festabook/global/config/S3ClientConfig.java b/src/main/java/com/daedan/festabook/global/config/S3ClientConfig.java index e52cdda..4755bdd 100644 --- a/src/main/java/com/daedan/festabook/global/config/S3ClientConfig.java +++ b/src/main/java/com/daedan/festabook/global/config/S3ClientConfig.java @@ -8,7 +8,7 @@ import software.amazon.awssdk.services.s3.S3Client; @Configuration -@Profile("prod") +@Profile("prod | dev") public class S3ClientConfig { @Value("${cloud.aws.region.static}") diff --git a/src/main/java/com/daedan/festabook/storage/infrastructure/LocalStorageManager.java b/src/main/java/com/daedan/festabook/storage/infrastructure/LocalStorageManager.java index 25b8171..6b95e44 100644 --- a/src/main/java/com/daedan/festabook/storage/infrastructure/LocalStorageManager.java +++ b/src/main/java/com/daedan/festabook/storage/infrastructure/LocalStorageManager.java @@ -11,14 +11,16 @@ import java.nio.file.Paths; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Component; @Slf4j @Loggable -@Component -@Profile("dev") +@Deprecated(since = "2025/11/27") +/** + * @Deprecated 날짜 : 2025/11/27 + * Dev 환경 로컬 저장 방식에서 S3 방식으로 변경되어 사용하지 않게 되었습니다. + * 하지만, 추후 로컬에서 사용할 수 있는 가능성이 있어 남겨둡니다. + */ public class LocalStorageManager implements StorageManager { @Value("${local.storage.base-path}") diff --git a/src/main/java/com/daedan/festabook/storage/infrastructure/S3StorageManager.java b/src/main/java/com/daedan/festabook/storage/infrastructure/S3StorageManager.java index 8a73842..96b7ac8 100644 --- a/src/main/java/com/daedan/festabook/storage/infrastructure/S3StorageManager.java +++ b/src/main/java/com/daedan/festabook/storage/infrastructure/S3StorageManager.java @@ -17,7 +17,7 @@ @Loggable @Component -@Profile("prod") +@Profile("prod | dev") public class S3StorageManager implements StorageManager { private final S3Client s3Client;