diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 3b967fd3..73e4b032 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -88,24 +88,63 @@ jobs: EOF shell: bash - # 10. Docker Compose로 배포 (PRODUCTION) - - name: Deploy with Docker Compose + # 10. Blue-Green 무중단 배포 + - name: Blue-Green Deployment run: | # MySQL과 Redis 시작 (이미 실행 중이면 스킵) docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d mysql-prod redis-prod - # Spring Boot 운영 앱만 재시작 - docker-compose -f docker-compose.prod.yml --env-file .env.prod stop app-prod || true - docker-compose -f docker-compose.prod.yml --env-file .env.prod rm -f app-prod || true - docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d app-prod + # Orphan 컨테이너 정리 (기존 ono-app-prod 등) + echo "Cleaning up orphan containers..." + docker stop ono-app-prod 2>/dev/null || true + docker rm ono-app-prod 2>/dev/null || true + docker stop ono-app 2>/dev/null || true + docker rm ono-app 2>/dev/null || true + + # 현재 활성화된 환경 확인 (Blue가 실행 중이면 Green으로 배포, 그 반대도 동일) + BLUE_RUNNING=$(docker ps --filter "name=ono-app-prod-blue" --filter "status=running" -q) + GREEN_RUNNING=$(docker ps --filter "name=ono-app-prod-green" --filter "status=running" -q) + + if [ -n "$BLUE_RUNNING" ]; then + # Blue가 실행 중이면 Green으로 배포 + CURRENT="blue" + TARGET="green" + CURRENT_PORT=8080 + TARGET_PORT=8081 + elif [ -n "$GREEN_RUNNING" ]; then + # Green이 실행 중이면 Blue로 배포 + CURRENT="green" + TARGET="blue" + CURRENT_PORT=8081 + TARGET_PORT=8080 + else + # 둘 다 실행 중이지 않으면 Blue로 초기 배포 + CURRENT="none" + TARGET="blue" + CURRENT_PORT=0 + TARGET_PORT=8080 + fi + + echo "Current active environment: $CURRENT (port $CURRENT_PORT)" + echo "Deploying to target environment: $TARGET (port $TARGET_PORT)" + + # Target 환경에 새 버전 배포 + if [ "$TARGET" = "green" ]; then + docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile green up -d app-prod-green + else + docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d app-prod-blue + fi - # 헬스체크 대기 (최대 1분) - echo "Waiting for production application to be healthy..." - timeout=60 + # 헬스체크 대기 (최대 2분) + echo "Waiting for $TARGET environment to be healthy..." + timeout=120 elapsed=0 + healthy=false + while [ $elapsed -lt $timeout ]; do - if docker-compose -f docker-compose.prod.yml ps app-prod | grep -q "healthy"; then - echo "Production application is healthy!" + if docker ps | grep "ono-app-prod-$TARGET" | grep -q "healthy"; then + echo "$TARGET environment is healthy!" + healthy=true break fi echo "Waiting... ($elapsed seconds)" @@ -113,12 +152,47 @@ jobs: elapsed=$((elapsed + 5)) done + if [ "$healthy" = false ]; then + echo "ERROR: $TARGET environment failed to become healthy within $timeout seconds" + docker-compose -f docker-compose.prod.yml logs --tail=100 app-prod-$TARGET + exit 1 + fi + + # Nginx 설정 업데이트 (Blue-Green 전환) + echo "Switching Nginx to $TARGET environment (port $TARGET_PORT)..." + sudo /opt/ono/scripts/switch-nginx.sh $TARGET_PORT + + # Nginx 설정 테스트 및 재로드 + if sudo /opt/homebrew/bin/nginx -t; then + sudo /opt/homebrew/bin/nginx -s reload + echo "Nginx successfully switched to $TARGET environment" + else + echo "ERROR: Nginx configuration test failed" + # 원래 환경으로 롤백 + sudo /opt/ono/scripts/switch-nginx.sh $CURRENT_PORT + exit 1 + fi + + # 10초 대기 후 이전 환경 종료 (안전을 위한 대기) + echo "Waiting 10 seconds before stopping old environment..." + sleep 10 + + # 이전 환경 종료 (초기 배포가 아닌 경우만) + if [ "$CURRENT" != "none" ]; then + echo "Stopping old $CURRENT environment..." + docker-compose -f docker-compose.prod.yml stop app-prod-$CURRENT || true + docker-compose -f docker-compose.prod.yml rm -f app-prod-$CURRENT || true + else + echo "Initial deployment - no old environment to stop" + fi + # 컨테이너 상태 확인 + echo "=== Current Container Status ===" docker-compose -f docker-compose.prod.yml ps # 로그 출력 (마지막 50줄) - echo "=== Production Application Logs ===" - docker-compose -f docker-compose.prod.yml logs --tail=50 app-prod + echo "=== $TARGET Environment Logs ===" + docker-compose -f docker-compose.prod.yml logs --tail=50 app-prod-$TARGET shell: bash # 12. 오래된 Docker 이미지 정리 @@ -149,4 +223,4 @@ jobs: }] }" shell: bash - continue-on-error: true \ No newline at end of file + continue-on-error: true diff --git a/build.gradle b/build.gradle index 003fddda..9eb3e7c4 100644 --- a/build.gradle +++ b/build.gradle @@ -53,6 +53,7 @@ dependencies { implementation group: 'com.squareup.okhttp3', name: 'okhttp', version: '4.2.2' implementation 'org.springframework.boot:spring-boot-starter-quartz' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-actuator' // Hibernate Core implementation 'org.hibernate:hibernate-core:6.5.2.Final' diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e67e8a77..01d0f555 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -44,13 +44,13 @@ services: retries: 5 command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru - # Spring Boot Application - PRODUCTION - app-prod: + # Spring Boot Application - PRODUCTION (Blue) + app-prod-blue: image: ${DOCKER_USERNAME}/ono:prod-latest - container_name: ono-app-prod + container_name: ono-app-prod-blue restart: unless-stopped ports: - - "8080:8080" # 운영 서버 포트 + - "8080:8080" # Blue 포트 environment: SPRING_PROFILES_ACTIVE: prod SPRING_DATASOURCE_URL: jdbc:mysql://mysql-prod:3306/ono_db_prod?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 @@ -88,6 +88,52 @@ services: retries: 3 start_period: 60s + # Spring Boot Application - PRODUCTION (Green) + app-prod-green: + image: ${DOCKER_USERNAME}/ono:prod-latest + container_name: ono-app-prod-green + restart: unless-stopped + ports: + - "8081:8080" # Green 포트 (외부 8081 -> 컨테이너 내부 8080) + environment: + SPRING_PROFILES_ACTIVE: prod + SPRING_DATASOURCE_URL: jdbc:mysql://mysql-prod:3306/ono_db_prod?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + SPRING_DATASOURCE_USERNAME: root + SPRING_DATASOURCE_PASSWORD: ${MYSQL_ROOT_PASSWORD} + SPRING_DATA_REDIS_HOST: redis-prod + SPRING_DATA_REDIS_PORT: 6379 + # 환경 변수 + SPRING_CRYPTO_SECRET_KEY: ${SPRING_CRYPTO_SECRET_KEY} + JWT_ACCESS_TOKEN_SECRET: ${JWT_ACCESS_TOKEN_SECRET} + JWT_ACCESS_TOKEN_EXPIRATION: ${JWT_ACCESS_TOKEN_EXPIRATION} + JWT_REFRESH_TOKEN_SECRET: ${JWT_REFRESH_TOKEN_SECRET} + JWT_REFRESH_TOKEN_EXPIRATION: ${JWT_REFRESH_TOKEN_EXPIRATION} + CLOUD_AWS_S3_BUCKET: ${CLOUD_AWS_S3_BUCKET} + CLOUD_AWS_CREDENTIALS_ACCESS_KEY: ${CLOUD_AWS_CREDENTIALS_ACCESS_KEY} + CLOUD_AWS_CREDENTIALS_SECRET_KEY: ${CLOUD_AWS_CREDENTIALS_SECRET_KEY} + DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL} + OPENAI_API_KEY: ${OPENAI_API_KEY} + SENTRY_DSN: ${SENTRY_DSN} + ADMIN_IDENTIFIER: ${ADMIN_IDENTIFIER} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} + volumes: + - app_prod_logs:/app/logs + depends_on: + mysql-prod: + condition: service_healthy + redis-prod: + condition: service_healthy + networks: + - ono-network + healthcheck: + test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://localhost:8080/actuator/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + profiles: + - green # 기본적으로는 비활성화, 배포 시에만 활성화 + networks: ono-network: driver: bridge diff --git a/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java b/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java index 09ba1ecc..6c76124f 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java +++ b/src/main/java/com/aisip/OnO/backend/auth/config/SecurityConfig.java @@ -61,7 +61,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http.authorizeHttpRequests(authorizeRequests -> authorizeRequests - .requestMatchers("/", "/robots.txt", "/home","/images/**", "/login", "/css/**", "/js/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/", "/robots.txt", "/home","/images/**", "/login", "/css/**", "/js/**", "/swagger-ui/**", "/v3/api-docs/**", "/actuator/**").permitAll() .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .requestMatchers("/api/fcm/**").hasAnyRole("GUEST", "MEMBER", "ADMIN") diff --git a/src/main/java/com/aisip/OnO/backend/common/auth/CustomAuthenticationEntryPoint.java b/src/main/java/com/aisip/OnO/backend/common/auth/CustomAuthenticationEntryPoint.java index 7c6c02fe..a9eacd95 100644 --- a/src/main/java/com/aisip/OnO/backend/common/auth/CustomAuthenticationEntryPoint.java +++ b/src/main/java/com/aisip/OnO/backend/common/auth/CustomAuthenticationEntryPoint.java @@ -13,6 +13,19 @@ public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + + String requestURI = request.getRequestURI(); + if(requestURI.startsWith("/actuator/") || + requestURI.startsWith("/api/auth") || + requestURI.equals("/") || + requestURI.equals("/robots.txt") || + requestURI.equals("/home") || + requestURI.startsWith("/login") || + requestURI.startsWith("/swagger-ui") || + requestURI.startsWith("/v3/api-docs") + ) { + return; + } String errorMessage = (String) request.getAttribute("errorMessage"); if(errorMessage == null) diff --git a/src/main/java/com/aisip/OnO/backend/common/auth/JwtTokenFilter.java b/src/main/java/com/aisip/OnO/backend/common/auth/JwtTokenFilter.java index 2808f361..13676825 100644 --- a/src/main/java/com/aisip/OnO/backend/common/auth/JwtTokenFilter.java +++ b/src/main/java/com/aisip/OnO/backend/common/auth/JwtTokenFilter.java @@ -31,6 +31,24 @@ public class JwtTokenFilter extends OncePerRequestFilter { private final JwtTokenizer jwtTokenizer; private final RedisTokenService redisTokenService; + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + + // JWT 필터를 건너뛸 경로들 + return path.startsWith("/actuator/") || + path.startsWith("/api/auth/") || + path.equals("/") || + path.equals("/robots.txt") || + path.equals("/home") || + path.startsWith("/images/") || + path.equals("/login") || + path.startsWith("/css/") || + path.startsWith("/js/") || + path.startsWith("/swagger-ui/") || + path.startsWith("/v3/api-docs/"); + } + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String bearerToken = request.getHeader(AUTHORIZATION_HEADER);