diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml new file mode 100644 index 0000000..071c993 --- /dev/null +++ b/.github/workflows/CICD.yml @@ -0,0 +1,145 @@ +name: Build and Deploy + +on: + workflow_dispatch: + push: + branches: ["main", "develop"] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + # 코드 체크아웃 (서브모듈 포함) + - name: Checkout With Submodule + uses: actions/checkout@v4 + with: + submodules: recursive + token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + fetch-depth: 0 + + # JDK 21 설치 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + # Gradle wrapper 실행 권한 부여 + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + # Gradle 빌드 (테스트 제외) + - name: Build with Gradle + run: ./gradlew clean build -x test + + # Docker Hub 로그인 + - name: DockerHub Login + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_ID }} + password: ${{ secrets.DOCKER_PASSWORD }} + + # Docker 이미지 빌드 & 푸시 + - name: Docker build & Push + run: | + docker build -t ${{ secrets.DOCKER_ID }}/${{ secrets.DOCKER_REPOSITORY }}:${{ github.ref_name }}-${{ github.sha }} . + docker push ${{ secrets.DOCKER_ID }}/${{ secrets.DOCKER_REPOSITORY }}:${{ github.ref_name }}-${{ github.sha }} + + deploy: + needs: build + runs-on: self-hosted + steps: + # 배포 환경 변수 설정 + - name: Set deployment target + id: deploy-env + run: | + if [[ "${{ github.ref_name }}" == "main" ]]; then + echo "EC2_HOST=${{ secrets.EC2_PROD_HOST }}" >> $GITHUB_OUTPUT + echo "PROFILE=prod" >> $GITHUB_OUTPUT + echo "DB_URL=${{ secrets.DB_PROD_URL }}" >> $GITHUB_OUTPUT + echo "DB_USERNAME=${{ secrets.DB_PROD_USERNAME }}" >> $GITHUB_OUTPUT + echo "DB_PASSWORD=${{ secrets.DB_PROD_PASSWORD }}" >> $GITHUB_OUTPUT + echo "REDIS_PASSWORD=${{ secrets.REDIS_PROD_PASSWORD }}" >> $GITHUB_OUTPUT + else + echo "EC2_HOST=${{ secrets.EC2_DEV_HOST }}" >> $GITHUB_OUTPUT + echo "PROFILE=dev" >> $GITHUB_OUTPUT + echo "DB_URL=${{ secrets.DB_DEV_URL }}" >> $GITHUB_OUTPUT + echo "DB_USERNAME=${{ secrets.DB_DEV_USERNAME }}" >> $GITHUB_OUTPUT + echo "DB_PASSWORD=${{ secrets.DB_DEV_PASSWORD }}" >> $GITHUB_OUTPUT + echo "REDIS_PASSWORD=${{ secrets.REDIS_DEV_PASSWORD }}" >> $GITHUB_OUTPUT + fi + + # docker-compose.server.yml 체크아웃 + - name: Checkout + uses: actions/checkout@v4 + with: + sparse-checkout: | + deploy/docker-compose.server.yml + deploy/docker-compose.infra.yml + deploy/server-deploy.sh + sparse-checkout-cone-mode: false + + # SSH Key 설정 + - name: Setup SSH Key + run: | + mkdir -p ~/.ssh + chmod 700 ~/.ssh + if [[ "${{ github.ref_name }}" == "main" ]]; then + echo "${{ secrets.EC2_PROD_PEM_KEY }}" > ~/.ssh/deploy_key.pem + else + echo "${{ secrets.EC2_DEV_PEM_KEY }}" > ~/.ssh/deploy_key.pem + fi + chmod 600 ~/.ssh/deploy_key.pem + + # EC2로 docker-compose.server.yml, server-deploy.sh 복사 + - name: Prepare remote directories and copy files + run: | + ssh -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key.pem ubuntu@${{ steps.deploy-env.outputs.EC2_HOST }} \ + "mkdir -p /home/ubuntu/deploy" + + scp -o StrictHostKeyChecking=no -i ~/.ssh/deploy_key.pem -r deploy/* ubuntu@${{ steps.deploy-env.outputs.EC2_HOST }}:/home/ubuntu/deploy/ + + # EC2에 배포 + - name: Deploy to EC2 + run: | + ssh -o StrictHostKeyChecking=no \ + -i ~/.ssh/deploy_key.pem \ + ubuntu@${{ steps.deploy-env.outputs.EC2_HOST }} << ENDSSH + + set -e + cd /home/ubuntu + + # 환경변수 설정 + export DOCKER_IMAGE="${{ secrets.DOCKER_ID }}/${{ secrets.DOCKER_REPOSITORY }}:${{ github.ref_name }}-${{ github.sha }}" + export SPRING_PROFILE="${{ steps.deploy-env.outputs.PROFILE }}" + export DB_URL="${{ steps.deploy-env.outputs.DB_URL }}" + export DB_USERNAME="${{ steps.deploy-env.outputs.DB_USERNAME }}" + export DB_PASSWORD="${{ steps.deploy-env.outputs.DB_PASSWORD }}" + export REDIS_PASSWORD="${{ steps.deploy-env.outputs.REDIS_PASSWORD }}" + + # Blue-Green 배포 스크립트 실행 + chmod +x /home/ubuntu/deploy/server-deploy.sh + /home/ubuntu/deploy/server-deploy.sh + ENDSSH + + # SSH Key 정리 + - name: Cleanup SSH Key + if: always() + run: | + rm -f ~/.ssh/deploy_key.pem + + # 결과 알림 + - name: Notify Discord + if: always() + uses: Ilshidur/action-discord@master + env: + DISCORD_WEBHOOK: ${{ github.ref_name == 'main' && secrets.DISCORD_MAIN_WEBHOOK || secrets.DISCORD_DEV_WEBHOOK }} + with: + args: | + 배포 결과: ${{ job.status == 'success' && '✅ 서버 배포 성공(Blue-Green 전환 완료)' || '❌ 서버 배포 실패 (기존 컨테이너 유지)' }} + - 브랜치: ${{ github.ref_name }} + - 서버: ${{ steps.deploy-env.outputs.EC2_HOST }} + - 로그: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} \ No newline at end of file diff --git a/.github/workflows/TEST.yml b/.github/workflows/TEST.yml new file mode 100644 index 0000000..7e14059 --- /dev/null +++ b/.github/workflows/TEST.yml @@ -0,0 +1,75 @@ +name: Test + +on: + workflow_dispatch: + push: + branches-ignore: + - main + - develop + pull_request: + branches: ["main", "develop"] + +permissions: + contents: read + checks: write + pull-requests: write + +jobs: + build: + runs-on: ubuntu-latest + + steps: + # 코드 체크아웃 (서브모듈 포함) + - name: Checkout With Submodule + uses: actions/checkout@v4 + with: + submodules: recursive + token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + fetch-depth: 0 + + # JDK 21 설치 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + # Redis 서비스 시작 + - name: Start Redis + uses: supercharge/redis-github-action@1.7.0 + with: + redis-version: 7 + + # Gradle 캐시 설정 + - name: Cache Gradle + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + # Gradle wrapper 실행 권한 부여 + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + # 빌드 및 테스트 실행 + - name: Build and Test + run: ./gradlew clean build -Dspring.profiles.active=test + continue-on-error: true + + # 테스트 결과 보고 + - name: Report Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + files: '**/build/test-results/test/TEST-*.xml' + + # PR에 테스트 결과 코멘트 추가 + - name: Comment Test Summary on PR + uses: mikepenz/action-junit-report@v3 + if: always() + with: + report_paths: '**/build/test-results/test/TEST-*.xml' \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2c18924..335e579 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,9 @@ out/ ### VS Code ### .vscode/ + +# 설정 파일 +src/main/resources/*.properties + +# Docker 로컬 볼륨 데이터 +db/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..693d933 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "config"] + path = config + url = https://github.com/JS-DevOps-Study/docker-compose-deploy-config.git diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4ebbd93 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM eclipse-temurin:21-jre-jammy + +WORKDIR /app + +COPY build/libs/*.jar app.jar + +RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser +RUN chown -R appuser:appgroup /app +USER appuser + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/README.md b/README.md index 81044dc..3727f8a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Spring Boot 기반 프로젝트 템플릿 - 형식: `타입/ISSUE번호-설명` - 타입: feature, bugfix, refactor, chore, test, hotfix, docs - ex) - - feature/#114-auth-login + - feature/114-auth-login - release/v1.2.0 (예외) --- diff --git a/build.gradle b/build.gradle index 6d703f1..1c8660f 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-actuator' // Spring Security // implementation 'org.springframework.boot:spring-boot-starter-security' @@ -53,3 +54,10 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +processResources.dependsOn('copyPrivateProperties') +tasks.register("copyPrivateProperties", Copy) { + from './config' + include "*.properties" + into 'src/main/resources' +} \ No newline at end of file diff --git a/config b/config new file mode 160000 index 0000000..76aff07 --- /dev/null +++ b/config @@ -0,0 +1 @@ +Subproject commit 76aff0739811d5b67363dfcb8600103055a61297 diff --git a/deploy/docker-compose.infra.yml b/deploy/docker-compose.infra.yml new file mode 100644 index 0000000..d5bd810 --- /dev/null +++ b/deploy/docker-compose.infra.yml @@ -0,0 +1,31 @@ +services: + redis: + image: redis:7.0 + container_name: redis_container + restart: unless-stopped + command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes + ports: + - "6379:6379" + volumes: + - redis-data:/data + networks: + - devops-study + + nginx: + image: nginx:alpine + container_name: nginx_container + restart: unless-stopped + volumes: + - /home/ubuntu/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - /home/ubuntu/nginx/logs:/var/log/nginx + ports: + - "80:80" + networks: + - devops-study + +networks: + devops-study: + external: true + +volumes: + redis-data: \ No newline at end of file diff --git a/deploy/docker-compose.server.yml b/deploy/docker-compose.server.yml new file mode 100644 index 0000000..de06560 --- /dev/null +++ b/deploy/docker-compose.server.yml @@ -0,0 +1,40 @@ +services: + app_blue: + image: ${DOCKER_IMAGE} + container_name: springboot_blue_container + restart: unless-stopped + environment: + - SPRING_PROFILES_ACTIVE=${SPRING_PROFILE} + - TZ=Asia/Seoul + - DB_URL=${DB_URL} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + - REDIS_HOST=redis_container + - REDIS_PORT=6379 + - REDIS_PASSWORD=${REDIS_PASSWORD} + ports: + - "8081:8080" + networks: + - devops-study + + app_green: + image: ${DOCKER_IMAGE} + container_name: springboot_green_container + restart: unless-stopped + environment: + - SPRING_PROFILES_ACTIVE=${SPRING_PROFILE} + - TZ=Asia/Seoul + - DB_URL=${DB_URL} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + - REDIS_HOST=redis_container + - REDIS_PORT=6379 + - REDIS_PASSWORD=${REDIS_PASSWORD} + ports: + - "8082:8080" + networks: + - devops-study + +networks: + devops-study: + external: true \ No newline at end of file diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..d7f1325 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,34 @@ +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + # 활성 서버 설정 + upstream backend { + server springboot_blue_container:8080; + } + + server { + listen 80; + + # 메인 트래픽 + location / { + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 로그 설정 + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log warn; + + # 타임아웃 설정 + proxy_connect_timeout 5s; + proxy_read_timeout 30s; + proxy_send_timeout 30s; + } +} diff --git a/deploy/server-deploy.sh b/deploy/server-deploy.sh new file mode 100644 index 0000000..9f48e07 --- /dev/null +++ b/deploy/server-deploy.sh @@ -0,0 +1,77 @@ +#!/bin/bash +set -e + +# 0. devops-study 네트워크 없으면 생성 +if ! docker network ls | grep -q devops-study; then + echo "📦 Docker 네트워크 생성" + docker network create devops-study +fi + +# 1. Redis, Nginx 미실행 시 실행 +for SERVICE in redis nginx; do + CONTAINER=$(docker ps --filter "name=${SERVICE}_container" --format "{{.Names}}") + if [ -z "$CONTAINER" ]; then + echo "📦 $SERVICE 실행" + docker compose -f /home/ubuntu/deploy/docker-compose.infra.yml up -d $SERVICE + fi +done + +# 2. 실행 중인 컨테이너 확인 +SPRINGBOOT_CONTAINER=$(docker ps --filter "name=springboot_" --format "{{.Names}}") + +if [ "$SPRINGBOOT_CONTAINER" == "springboot_blue_container" ]; then + NEW_PORT=8082 + NEW_SERVICE="app_green" + NEW_CONTAINER="springboot_green_container" + OLD_SERVICE="app_blue" + OLD_CONTAINER="springboot_blue_container" + echo "🟢 Green(8082) 배포 시작" +else + NEW_PORT=8081 + NEW_SERVICE="app_blue" + NEW_CONTAINER="springboot_blue_container" + OLD_SERVICE="app_green" + OLD_CONTAINER="springboot_green_container" + echo "🔵 Blue(8081) 배포 시작" +fi + +# 3. 이미지 Pull +docker pull ${DOCKER_IMAGE} + +# 4. 새 컨테이너 시작 +docker compose -f /home/ubuntu/deploy/docker-compose.server.yml up -d --no-deps ${NEW_SERVICE} + +# 헬스체크 (최대 45초) +for i in {1..9}; do + if curl -f -m 5 http://localhost:${NEW_PORT}/actuator/health > /dev/null 2>&1; then + echo "✅ $NEW_SERVICE 헬스체크 성공" + break + fi + + if [ $i -eq 9 ]; then + echo "❌ $NEW_SERVICE 헬스체크 실패" + docker compose -f /home/ubuntu/deploy/docker-compose.server.yml stop ${NEW_SERVICE} + exit 1 + fi + + echo "재시도 중... ($i/9)" + sleep 5 +done + + +# 새로운 컨테이너 헬스체크 성공한 경우만 이후 단계 진행 +# 5. Nginx 설정 변경 +if [ -n "$SPRINGBOOT_CONTAINER" ]; then + sed "s/${OLD_CONTAINER}:8080/${NEW_CONTAINER}:8080/" /home/ubuntu/nginx/nginx.conf > /tmp/nginx_temp.conf + cat /tmp/nginx_temp.conf > /home/ubuntu/nginx/nginx.conf + rm /tmp/nginx_temp.conf + + # 6. Nginx 리로드 + docker exec nginx_container nginx -s reload + + # 7. 이전 컨테이너 중지 + docker compose -f /home/ubuntu/deploy/docker-compose.server.yml stop ${OLD_SERVICE} +fi + +# 8. 리소스 정리 +docker system prune -f > /dev/null 2>&1 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d7ef454 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +services: + mysql: + image: mysql:8.0 + container_name: mysql_container + restart: always + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: study + MYSQL_USER: student + MYSQL_PASSWORD: studentpw + TZ: Asia/Seoul + ports: + - "3306:3306" + volumes: + - ./db/mysql/data:/var/lib/mysql + - ./db/mysql/init:/docker-entrypoint-initdb.d + networks: + - devops-study + + redis: + image: redis:7.0 + container_name: redis_container + restart: always + command: redis-server --requirepass studentpw + ports: + - "6379:6379" + volumes: + - ./db/redis/data:/data + networks: + - devops-study + +networks: + devops-study: + driver: bridge \ No newline at end of file diff --git a/src/main/java/devops/study/base/DevopsBaseApplication.java b/src/main/java/devops/study/base/DevopsBaseApplication.java index 4ce12d3..e3b1ae0 100644 --- a/src/main/java/devops/study/base/DevopsBaseApplication.java +++ b/src/main/java/devops/study/base/DevopsBaseApplication.java @@ -9,5 +9,4 @@ public class DevopsBaseApplication { public static void main(String[] args) { SpringApplication.run(DevopsBaseApplication.class, args); } - } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index eecd1f0..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=devops-base