diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6b2532f..b0a82a4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -55,13 +55,12 @@ jobs: envs: IMAGE_TAG script: | cd ~/BE - - # 1. [핵심] 깃허브의 모든 최신 파일(promtail-config 등) 강제 동기화 + + # 1. 깃허브 최신 파일 동기화 git fetch --all git reset --hard origin/deploy - # 2. .env 파일 생성 (보안상 필요한 변수들만 주입) - # 기존에 작성하신 echo 방식을 유지하되, 덮어쓰기(>)와 추가(>>)를 구분합니다. + # 2. .env 파일 생성 echo "MYSQL_ROOT_PASSWORD=${{ secrets.MYSQL_ROOT_PASSWORD }}" > .env echo "MYSQL_DATABASE=${{ secrets.MYSQL_DATABASE }}" >> .env echo "MYSQL_USER=${{ secrets.MYSQL_USER }}" >> .env @@ -88,4 +87,31 @@ jobs: sudo IMAGE_TAG=$IMAGE_TAG docker-compose -f docker-compose-prod.yml up -d --force-recreate # 5. 미사용 이미지 정리 - sudo docker image prune -f \ No newline at end of file + sudo docker image prune -f + + # 6. 디스코드 알림 (성공 시) - SSH 액션 밖으로 분리 + - name: Discord Notification - Success + if: success() + uses: sarisia/actions-status-discord@v1 + with: + webhook: ${{ secrets.DISCORD_WEBHOOK_URL }} + status: success + title: "🚀 배포 완료 (cuk-compasser)" + description: | + **브랜치:** `${{ github.ref_name }}` + **커밋 메시지:** ${{ github.event.head_commit.message }} + 성공적으로 EC2에 배포되었습니다! + color: 0x00ff00 + + # 7. 디스코드 알림 (실패 시) - SSH 액션 밖으로 분리 + - name: Discord Notification - Failure + if: failure() + uses: sarisia/actions-status-discord@v1 + with: + webhook: ${{ secrets.DISCORD_WEBHOOK_URL }} + status: failure + title: "❌ 배포 실패 (cuk-compasser)" + description: | + **브랜치:** `${{ github.ref_name }}` + 배포 중 에러가 발생했습니다. Actions 탭에서 로그를 확인해주세요. + color: 0xff0000 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 4455f9f..8c3c18e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,12 +12,13 @@ RUN chmod +x ./gradlew RUN ./gradlew clean bootJar -x test # 2. 실행 스테이지 -FROM eclipse-temurin:21-jre-jammy +FROM eclipse-temurin:21-jre-alpine WORKDIR /app -COPY --from=build /app/build/libs/*.jar app.jar - +RUN apk add --no-cache tzdata ENV TZ=Asia/Seoul +COPY --from=build /app/build/libs/*SNAPSHOT.jar app.jar + # 실행 시 'prod' 프로파일 활성화 ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=prod", "app.jar"] \ No newline at end of file diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index adc4e85..8821331 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -24,6 +24,7 @@ services: - KAKAO_LOGIN_REDIRECT_URI=${KAKAO_LOGIN_REDIRECT_URI} - KAKAO_LOCAL_BASE_URL=https://dapi.kakao.com - KAKAO_REST_API_KEY=${KAKAO_REST_API_KEY} + - JAVA_TOOL_OPTIONS=-Xms256m -Xmx256m depends_on: mysql-db: condition: service_healthy @@ -35,9 +36,18 @@ services: options: max-size: "10m" max-file: "3" + + volumes: + - ./logs:/app/logs + networks: - cuk-compasser-net + deploy: + resources: + limits: + memory: 400M + redis: image: redis:latest container_name: redis @@ -64,11 +74,18 @@ services: networks: - cuk-compasser-net healthcheck: - test: ["CMD-SHELL", "mysqladmin ping -h localhost -u $$MYSQL_USER -p$$MYSQL_PASSWORD"] + test: [ "CMD-SHELL", "mysqladmin ping -h localhost -u $$MYSQL_USER -p$$MYSQL_PASSWORD" ] interval: 10s timeout: 5s retries: 5 start_period: 30s + command: + - --innodb_buffer_pool_size=128M # 데이터를 램에 올려두는 캐시 공간을 128MB로 제한 + - --max_connections=50 # 동시 접속자(커넥션) 수 50개로 제한 + deploy: + resources: + limits: + memory: 300M # 컨테이너 전체 메모리 상한선 prometheus: image: prom/prometheus:latest @@ -91,7 +108,8 @@ services: environment: - GF_SECURITY_ADMIN_PASSWORD=${GF_SECURITY_ADMIN_PASSWORD} - GF_SERVER_DOMAIN=compasser.site - - GF_SERVER_ROOT_URL=%(protocol)s://%(domain)s:%(http_port)s/grafana/ + - GF_SERVER_PROTOCOL=http + - GF_SERVER_ROOT_URL=https://compasser.site/grafana/ - GF_SERVER_SERVE_FROM_SUB_PATH=true volumes: - grafana_data:/var/lib/grafana @@ -118,9 +136,9 @@ services: restart: always volumes: - ./promtail/promtail-config.yml:/etc/promtail/config.yml - - /var/run/docker.sock:/var/run/docker.sock + - ./logs:/var/log/spring-app:ro - /var/lib/docker/containers:/var/lib/docker/containers:ro - - /var/log:/var/log + - /var/run/docker.sock:/var/run/docker.sock command: -config.file=/etc/promtail/config.yml networks: - cuk-compasser-net diff --git a/promtail/promtail-config.yml b/promtail/promtail-config.yml index 28ca9d8..826a161 100644 --- a/promtail/promtail-config.yml +++ b/promtail/promtail-config.yml @@ -9,29 +9,32 @@ clients: - url: http://loki:3100/loki/api/v1/push scrape_configs: - # 1. 기존 시스템 로그 (EC2 자체 로그) - - job_name: system - static_configs: - - targets: - - localhost - labels: - job: varlogs - application: compasser-api - __path__: /var/log/*.log - - # 2. 도커 컨테이너 로그 (스프링 부트 로그 포함) - - job_name: docker_logs + # 1. 도커 컨테이너 로그 직접 수집 (강력 추천 방식) + - job_name: docker_containers docker_sd_configs: - host: unix:///var/run/docker.sock refresh_interval: 5s relabel_configs: - - source_labels: [__meta_docker_container_id] - target_label: __path__ - replacement: /var/lib/docker/containers/$1/*-json.log + # 컨테이너 이름을 추출해서 'container_name' 라벨로 저장 - source_labels: ['__meta_docker_container_name'] regex: '/(.*)' target_label: 'container_name' - - source_labels: ['__meta_docker_container_name'] - regex: '/cuk-compasser-service' + # 로그 파일 경로 설정 + - source_labels: [__meta_docker_container_id] + target_label: __path__ + replacement: /var/lib/docker/containers/$1/*-json.log + # 특정 컨테이너(cuk-compasser-service)를 'spring-boot' job으로 식별 + - source_labels: ['container_name'] + regex: 'cuk-compasser-service' target_label: 'job' - replacement: 'spring-boot' \ No newline at end of file + replacement: 'spring-boot' + + # 2. 백업: 파일 직접 수집 (컨테이너 내부 logs 폴더) + - job_name: spring-boot-file + static_configs: + - targets: + - localhost + labels: + job: spring-boot + container_name: cuk-compasser-service + __path__: /var/log/spring-app/*.log \ No newline at end of file diff --git a/src/main/java/Comprehensive_Design_Project/CUK_Compasser/global/security/config/SecurityConfig.java b/src/main/java/Comprehensive_Design_Project/CUK_Compasser/global/security/config/SecurityConfig.java index 284649a..2f51b13 100644 --- a/src/main/java/Comprehensive_Design_Project/CUK_Compasser/global/security/config/SecurityConfig.java +++ b/src/main/java/Comprehensive_Design_Project/CUK_Compasser/global/security/config/SecurityConfig.java @@ -1,11 +1,12 @@ package Comprehensive_Design_Project.CUK_Compasser.global.security.config; import Comprehensive_Design_Project.CUK_Compasser.global.security.filter.JWTAuthenticationFilter; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; +import Comprehensive_Design_Project.CUK_Compasser.global.security.jwt.JWTProvider; +import Comprehensive_Design_Project.CUK_Compasser.global.security.userDetails.CustomUserDetailsService; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; @@ -16,16 +17,19 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; @Configuration @RequiredArgsConstructor public class SecurityConfig { - private final JWTAuthenticationFilter jwtAuthenticationFilter; + @Bean + public JWTAuthenticationFilter jwtAuthenticationFilter(JWTProvider jwtProvider, RedisTemplate redisTemplate, CustomUserDetailsService customUserDetailsService) { + return new JWTAuthenticationFilter(jwtProvider, redisTemplate, customUserDetailsService); + } @Bean - SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + SecurityFilterChain securityFilterChain(HttpSecurity http, JWTAuthenticationFilter jwtAuthenticationFilter) throws Exception { + http .csrf(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) @@ -55,6 +59,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http.build(); } + @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); diff --git a/src/main/java/Comprehensive_Design_Project/CUK_Compasser/global/security/filter/JWTAuthenticationFilter.java b/src/main/java/Comprehensive_Design_Project/CUK_Compasser/global/security/filter/JWTAuthenticationFilter.java index 405d704..7b18645 100644 --- a/src/main/java/Comprehensive_Design_Project/CUK_Compasser/global/security/filter/JWTAuthenticationFilter.java +++ b/src/main/java/Comprehensive_Design_Project/CUK_Compasser/global/security/filter/JWTAuthenticationFilter.java @@ -1,7 +1,6 @@ package Comprehensive_Design_Project.CUK_Compasser.global.security.filter; import Comprehensive_Design_Project.CUK_Compasser.global.common.apiPayload.code.generalStatus.GeneralErrorCode; -import Comprehensive_Design_Project.CUK_Compasser.global.common.apiPayload.exception.GeneralException; import Comprehensive_Design_Project.CUK_Compasser.global.security.jwt.JWT; import Comprehensive_Design_Project.CUK_Compasser.global.security.jwt.JWTProvider; import Comprehensive_Design_Project.CUK_Compasser.global.security.userDetails.CustomUserDetailsService; @@ -22,7 +21,6 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @@ -30,7 +28,6 @@ @Slf4j @RequiredArgsConstructor -@Component public class JWTAuthenticationFilter extends OncePerRequestFilter { private final JWTProvider jwtProvider; @@ -41,52 +38,44 @@ public class JWTAuthenticationFilter extends OncePerRequestFilter { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String accessToken = resolveAccessToken(request); - /// jwtProvider 에서 인증 조회 + 토큰 검증이 필요! - try{ - if(accessToken != null && jwtProvider.validateToken(accessToken)){ // 비었거나, 올바르지 않거나 - + try { + if (accessToken != null && jwtProvider.validateToken(accessToken)) { String isBlackListed = (String) redisTemplate.opsForValue().get("blacklist:" + accessToken); - if (isBlackListed != null){ + if (isBlackListed != null) { log.warn("[JWTAuthenticationFilter] - Using BlackListed Token!"); - throw new GeneralException(GeneralErrorCode.BLACKLIST_TOKEN); + // 직접 응답을 보내고 필터 체인을 종료합니다. + sendErrorResponse(response, GeneralErrorCode.BLACKLIST_TOKEN); + return; } Authentication authentication = jwtProvider.getAuthentication(accessToken); if (authentication != null) { SecurityContextHolder.getContext().setAuthentication(authentication); - } else { - log.info("[JWTAuthenticationFilter] - Invalid Token, Dont save authentication!"); } } -/* else { - log.warn("[JWTAuthenticationFilter] - This is request with Empty or Invalid Token"); -// throw new GeneralException(ErrorCode.AUTH_UNAUTHORIZED); - }*/ - } - catch (ExpiredJwtException e){ + } catch (ExpiredJwtException e) { log.warn("[JWTAuthenticationFilter] - AT expired, attempting to refresh token"); handleExpiredAccessToken(request, response, e); - } - catch(JwtException | IllegalArgumentException e){ - log.info("[JWTAuthenticationFilter] - Invalid Refresh Token! "); + return; // 갱신 로직에서 응답을 이미 보냈으므로 종료 + } catch (JwtException | IllegalArgumentException e) { + log.info("[JWTAuthenticationFilter] - Invalid Token! "); + // 필요 시 여기서도 에러 응답을 보낼 수 있습니다. } filterChain.doFilter(request, response); } - private String resolveAccessToken(HttpServletRequest request){ + private String resolveAccessToken(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (bearerToken != null && bearerToken.startsWith("Bearer ")) { - return bearerToken.substring(7).trim(); // 앞 뒤 공백 제거, "Bearer ~~~" 형식으로 통일 + return bearerToken.substring(7).trim(); } return null; } private String resolveRefreshToken(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); - if (cookies == null) { - return null; - } + if (cookies == null) return null; return Arrays.stream(cookies) .filter(cookie -> "refreshToken".equals(cookie.getName())) @@ -95,55 +84,61 @@ private String resolveRefreshToken(HttpServletRequest request) { .orElse(null); } - private void handleExpiredAccessToken (HttpServletRequest request, HttpServletResponse response, ExpiredJwtException e){ - + private void handleExpiredAccessToken(HttpServletRequest request, HttpServletResponse response, ExpiredJwtException e) throws IOException { Claims claims = e.getClaims(); String username = claims.getSubject(); - // String authorities = claims.get("auth").toString(); 어차피 userDetails에서 검색하니까 필요 X String requestRT = resolveRefreshToken(request); - String savedRT = (String) redisTemplate.opsForValue().get("refresh:"+username); // 이메일 + String savedRT = (String) redisTemplate.opsForValue().get("refresh:" + username); - if (savedRT == null){ - log.warn("[JWTAuthenticationFilter] - savedRT is null!"); - throw new GeneralException(GeneralErrorCode.RT_NOT_FOUND); + if (savedRT == null || requestRT == null || !requestRT.equals(savedRT)) { + log.warn("[JWTAuthenticationFilter] - RT invalid or missing!"); + sendErrorResponse(response, GeneralErrorCode.RT_NOT_FOUND); + return; } - if (requestRT != null && requestRT.equals(savedRT)){ - UserDetails userDetails = customUserDetailsService.loadUserByUsername(username); - Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); - JWT newJWT = jwtProvider.generateToken(authentication); - - // AT 덮어쓰기 - response.setHeader("Authorization", "Bearer " + newJWT.getAccessToken()); - - // RT 덮어쓰기 - ResponseCookie cookie = ResponseCookie.from("refreshToken", newJWT.getRefreshToken()) - .httpOnly(true) - .secure(true) - .path("/") - .maxAge(7 * 24 * 60 * 60) // 7일 - .sameSite("Strict") - .build(); - response.setHeader(HttpHeaders.SET_COOKIE, cookie.toString()); - redisTemplate.opsForValue().set("refresh:"+username, newJWT.getRefreshToken()); - - SecurityContextHolder.getContext().setAuthentication(authentication); - log.info("Successfully refreshed token and set security context for user: {}", username); - } - else{ - log.warn("[JWTAuthenticationFilter] - requestRT is null! || requestRT is not equal to savedRT!"); - throw new GeneralException(GeneralErrorCode.RT_NOT_FOUND); - } + // RT가 유효한 경우 새로운 토큰 발급 + UserDetails userDetails = customUserDetailsService.loadUserByUsername(username); + Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + JWT newJWT = jwtProvider.generateToken(authentication); + + response.setHeader("Authorization", "Bearer " + newJWT.getAccessToken()); + ResponseCookie cookie = ResponseCookie.from("refreshToken", newJWT.getRefreshToken()) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(7 * 24 * 60 * 60) + .sameSite("Strict") + .build(); + response.setHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + redisTemplate.opsForValue().set("refresh:" + username, newJWT.getRefreshToken()); + + SecurityContextHolder.getContext().setAuthentication(authentication); + log.info("Successfully refreshed token for user: {}", username); + + // 토큰 갱신 후에도 요청을 계속 진행하려면 filterChain.doFilter를 호출해야 하지만, + // 보통은 헤더만 실어서 401 대신 200으로 처리하거나 클라이언트에게 다시 요청하라고 안내합니다. + } + + private void sendErrorResponse(HttpServletResponse response, GeneralErrorCode errorCode) throws IOException { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + + String body = String.format( + "{\"isSuccess\": false, \"code\": \"%s\", \"message\": \"%s\"}", + errorCode.getCode(), + errorCode.getMessage() + ); + + response.getWriter().write(body); } @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { String path = request.getRequestURI(); - // 이 경로들은 필터 로직을 아예 실행하지 않음 return path.startsWith("/actuator") || path.startsWith("/swagger-ui") || path.startsWith("/v3/api-docs") || path.startsWith("/favicon.ico"); } -} +} \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 5510ff1..1c6cf1f 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -89,7 +89,7 @@ management: logging: file: - name: /var/log/compasser-api.log + name: /app/log/compasser-api.log logback: rollingpolicy: max-history: 7