diff --git a/build.gradle b/build.gradle index 50850dc..865948b 100644 --- a/build.gradle +++ b/build.gradle @@ -76,13 +76,17 @@ dependencies { // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + + // monoitoring + implementation("org.springframework.boot:spring-boot-starter-actuator") + runtimeOnly("io.micrometer:micrometer-registry-prometheus") } tasks.named('test') { useJUnitPlatform() } -tasks.withType(Checkstyle){ +tasks.withType(Checkstyle) { reports { xml.required = true html.required = true @@ -91,7 +95,7 @@ tasks.withType(Checkstyle){ checkstyle { configFile = file("checkstyle/config/rules.xml") - configProperties = ["suppressionFile" : "checkstyle/config/suppressions.xml"] + configProperties = ["suppressionFile": "checkstyle/config/suppressions.xml"] maxWarnings = 0 } diff --git a/docker-compose-db.yml b/docker-compose-db.yml new file mode 100644 index 0000000..6254cb6 --- /dev/null +++ b/docker-compose-db.yml @@ -0,0 +1,68 @@ +version: '3.8' +services: + redis: + image: redis:alpine + container_name: rabbit-redis + hostname: redis + ports: + - "6379:6379" + networks: + - rabbit-db + + mysql_master: + container_name: rabbit-mysql-master + image: mysql:8.0 + environment: + MYSQL_DATABASE: rabbit + MYSQL_ROOT_HOST: '%' + MYSQL_ROOT_PASSWORD: 1234 + ports: + - "3306:3306" + volumes: + - ./mysql/master-data-source.cnf:/etc/mysql/conf.d/my.cnf + - ./mysql/init-master.sql:/docker-entrypoint-initdb.d/01-init-master.sql + networks: + - rabbit-db + healthcheck: + test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p1234" ] + timeout: 20s + retries: 10 + + mysql_replica: + container_name: rabbit-mysql-replica + image: mysql:8.0 + environment: + MYSQL_DATABASE: rabbit + MYSQL_ROOT_HOST: '%' + MYSQL_ROOT_PASSWORD: 1234 + ports: + - "3307:3306" + volumes: + - ./mysql/replica-data-source.cnf:/etc/mysql/conf.d/my.cnf + networks: + - rabbit-db + depends_on: + mysql_master: + condition: service_healthy + healthcheck: + test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p1234" ] + timeout: 20s + retries: 10 + + mysql_replication_setup: + image: mysql:8.0 + container_name: mysql_replication_setup + volumes: + - ./mysql/setup-replication.sh:/setup-replication.sh + command: [ "/bin/bash", "/setup-replication.sh" ] + networks: + - rabbit-db + depends_on: + mysql_master: + condition: service_healthy + mysql_replica: + condition: service_healthy + restart: "no" + +networks: + rabbit-db: diff --git a/docker-compose-monitoring.yml b/docker-compose-monitoring.yml new file mode 100644 index 0000000..5af2c2c --- /dev/null +++ b/docker-compose-monitoring.yml @@ -0,0 +1,30 @@ +version: '3.8' +services: + prometheus: + build: ./monitoring/prometheus + container_name: rabbit-prometheus + ports: + - 9090:9090 + environment: + ACTUATOR_METRICS_PATH: ${ACTUATOR_METRICS_PATH:-/actuator/prometheus} + ACTUATOR_USERNAME: ${ACTUATOR_USERNAME:-rabbit} + ACTUATOR_PASSWORD: ${ACTUATOR_PASSWORD:-rabbit1234} + restart: always + networks: + - rabbit-monitoring + + grafana: + image: grafana/grafana + container_name: rabbit-grafana + ports: + - 3000:3000 + volumes: + - ./monitoring/grafana/volume:/var/lib/grafana + restart: always + networks: + - rabbit-monitoring + depends_on: + - prometheus + +networks: + rabbit-monitoring: diff --git a/docker-compose.yml b/docker-compose.yml index 80999d4..f2acc1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.8' services: rabbitmq: image: rabbitmq:3-management - container_name: rabbitmq + container_name: rabbit-rabbitmq ports: - "5672:5672" - "15672:15672" @@ -13,75 +13,11 @@ services: RABBITMQ_DEFAULT_USER: guest RABBITMQ_DEFAULT_PASS: guest networks: - - my_network - - redis: - image: redis:alpine - container_name: redis - hostname: redis - ports: - - "6379:6379" - networks: - - my_network - - mysql_master: - container_name: rabbit-mysql-master - image: mysql:8.0 - environment: - MYSQL_DATABASE: rabbit - MYSQL_ROOT_HOST: '%' - MYSQL_ROOT_PASSWORD: 1234 - ports: - - "3306:3306" - volumes: - - ./mysql/master-data-source.cnf:/etc/mysql/conf.d/my.cnf - - ./mysql/init-master.sql:/docker-entrypoint-initdb.d/01-init-master.sql - networks: - - my_network - healthcheck: - test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p1234" ] - timeout: 20s - retries: 10 - - mysql_replica: - container_name: rabbit-mysql-replica - image: mysql:8.0 - environment: - MYSQL_DATABASE: rabbit - MYSQL_ROOT_HOST: '%' - MYSQL_ROOT_PASSWORD: 1234 - ports: - - "3307:3306" - volumes: - - ./mysql/replica-data-source.cnf:/etc/mysql/conf.d/my.cnf - networks: - - my_network - depends_on: - mysql_master: - condition: service_healthy - healthcheck: - test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p1234" ] - timeout: 20s - retries: 10 - - mysql_replication_setup: - image: mysql:8.0 - container_name: mysql_replication_setup - volumes: - - ./mysql/setup-replication.sh:/setup-replication.sh - command: [ "/bin/bash", "/setup-replication.sh" ] - networks: - - my_network - depends_on: - mysql_master: - condition: service_healthy - mysql_replica: - condition: service_healthy - restart: "no" + - rabbit-default nginx: image: nginx:latest - container_name: nginx + container_name: rabbit-nginx ports: - "80:80" - "443:443" @@ -89,7 +25,7 @@ services: - ./nginx/config/nginx.conf:/etc/nginx/conf.d/default.conf - ./nginx/ssl:/etc/nginx/ssl networks: - - my_network + - rabbit-default networks: - my_network: + rabbit-default: diff --git a/monitoring/prometheus/Dockerfile b/monitoring/prometheus/Dockerfile new file mode 100644 index 0000000..34f3ee1 --- /dev/null +++ b/monitoring/prometheus/Dockerfile @@ -0,0 +1,19 @@ +FROM alpine:latest + +# 2025.08.30 기준 최신 LTS 버전 +ENV PROMETHEUS_VERSION=3.5.0 + +# Prometheus 설치 +RUN wget https://github.com/prometheus/prometheus/releases/download/v${PROMETHEUS_VERSION}/prometheus-${PROMETHEUS_VERSION}.linux-amd64.tar.gz \ + && tar xvf prometheus-${PROMETHEUS_VERSION}.linux-amd64.tar.gz \ + && mv prometheus-${PROMETHEUS_VERSION}.linux-amd64/prometheus /bin/prometheus \ + && mv prometheus-${PROMETHEUS_VERSION}.linux-amd64/promtool /bin/promtool \ + && rm -rf prometheus-${PROMETHEUS_VERSION}.linux-amd64* + +# envsubst 설치 +RUN apk add --no-cache gettext + +# 설정 파일 복사 +COPY config/prometheus-env.yml /etc/prometheus/prometheus-env.yml + +ENTRYPOINT ["/bin/sh", "-c", "envsubst < /etc/prometheus/prometheus-env.yml > /etc/prometheus/prometheus.yml && exec prometheus --web.enable-lifecycle --enable-feature=expand-external-labels --config.file=/etc/prometheus/prometheus.yml"] diff --git a/monitoring/prometheus/config/prometheus-env.yml b/monitoring/prometheus/config/prometheus-env.yml new file mode 100644 index 0000000..d19c343 --- /dev/null +++ b/monitoring/prometheus/config/prometheus-env.yml @@ -0,0 +1,15 @@ +global: + scrape_interval: 15s # scrap target의 기본 interval을 15초로 변경 / default = 1m + scrape_timeout: 15s # scrap request 가 timeout wait/ default = 10s + + external_labels: + monitor: 'rabbit-prometheus' # 기본적으로 붙여줄 라벨 + +scrape_configs: + - job_name: prometheus + metrics_path: ${ACTUATOR_METRICS_PATH} + static_configs: + - targets: [ 'host.docker.internal:8080' ] + basic_auth: + username: ${ACTUATOR_USERNAME} + password: ${ACTUATOR_PASSWORD} diff --git a/src/main/java/com/rabbitmqprac/config/SecurityConfig.java b/src/main/java/com/rabbitmqprac/config/SecurityConfig.java index dde5309..1301cc2 100644 --- a/src/main/java/com/rabbitmqprac/config/SecurityConfig.java +++ b/src/main/java/com/rabbitmqprac/config/SecurityConfig.java @@ -4,19 +4,27 @@ import com.rabbitmqprac.infra.security.filter.JwtAuthenticationFilter; import com.rabbitmqprac.infra.security.filter.JwtExceptionFilter; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.security.ConditionalOnDefaultWebSecurity; -import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.boot.autoconfigure.security.servlet.PathRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.AbstractRequestMatcherRegistry; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.AccessDeniedHandler; @@ -27,6 +35,7 @@ import java.util.List; +@Slf4j @Configuration @EnableWebSecurity @ConditionalOnDefaultWebSecurity @@ -37,16 +46,55 @@ public class SecurityConfig { private final CorsConfigurationSource corsConfigurationSource; private final AccessDeniedHandler accessDeniedHandler; private final AuthenticationEntryPoint authenticationEntryPoint; + private final PasswordEncoder bCryptPasswordEncoder; + + @Value("${management.actuator.username}") + private String actuatorUsername; + @Value("${management.actuator.password}") + private String actuatorPassword; + @Value("${management.actuator.role}") + private String actuatorRole; + @Value("${management.endpoints.web.exposure.base-path}") + private String metricsPath; + + @Bean(name = "actuatorUserDetailsService") + public UserDetailsService actuatorUserDetailsService() { + String encodedPassword = bCryptPasswordEncoder.encode(actuatorPassword); + UserDetails actuatorUser = User.builder() + .username(actuatorUsername) + .password(encodedPassword) + .roles(actuatorRole) + .build(); + return new InMemoryUserDetailsManager(actuatorUser); + } + + @Bean + @Order(1) + public SecurityFilterChain actuatorFilterChain( + HttpSecurity http, + @Qualifier("actuatorUserDetailsService") UserDetailsService actuatorUserDetailsService + ) throws Exception { + http + .securityMatcher(metricsPath) + .authorizeHttpRequests(auth -> auth + .anyRequest().hasRole(actuatorRole) + ) + .userDetailsService(actuatorUserDetailsService) + .httpBasic(Customizer.withDefaults()) + .csrf(csrf -> csrf.disable()); + return http.build(); + } @Bean - @Order(SecurityProperties.BASIC_AUTH_ORDER) - public SecurityFilterChain filterChainDev(HttpSecurity http) throws Exception { + @Order(2) + public SecurityFilterChain jwtFilterChain(HttpSecurity http) throws Exception { return defaultSecurity(http) .cors((cors) -> cors.configurationSource(corsConfigurationSource())) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class) .authorizeHttpRequests( auth -> defaultAuthorizeHttpRequests(auth) + .requestMatchers(metricsPath).permitAll() .requestMatchers(WebSecurityUrls.SWAGGER_ENDPOINTS).permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/com/rabbitmqprac/domain/context/auth/service/UserDetailServiceImpl.java b/src/main/java/com/rabbitmqprac/domain/context/auth/service/UserDetailServiceImpl.java index 974c67a..a29b2f1 100644 --- a/src/main/java/com/rabbitmqprac/domain/context/auth/service/UserDetailServiceImpl.java +++ b/src/main/java/com/rabbitmqprac/domain/context/auth/service/UserDetailServiceImpl.java @@ -1,15 +1,17 @@ package com.rabbitmqprac.domain.context.auth.service; -import com.rabbitmqprac.infra.security.authentication.SecurityUserDetails; import com.rabbitmqprac.domain.context.user.service.UserService; import com.rabbitmqprac.domain.persistence.user.entity.User; +import com.rabbitmqprac.infra.security.authentication.SecurityUserDetails; import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Primary; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; @Service +@Primary @RequiredArgsConstructor public class UserDetailServiceImpl implements UserDetailsService { private final UserService userService; diff --git a/src/main/java/com/rabbitmqprac/infra/security/filter/JwtAuthenticationFilter.java b/src/main/java/com/rabbitmqprac/infra/security/filter/JwtAuthenticationFilter.java index 91568e4..bd70f64 100644 --- a/src/main/java/com/rabbitmqprac/infra/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/rabbitmqprac/infra/security/filter/JwtAuthenticationFilter.java @@ -11,6 +11,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.lang.NonNull; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -31,9 +32,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final UserDetailsService userDetailService; private final JwtProvider accessTokenProvider; + @Value("${management.endpoints.web.exposure.base-path}") + private String metricsPath; + @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { - if (isAnonymousRequest(request)) { + if (isMetricRequest(request) || isAnonymousRequest(request)) { filterChain.doFilter(request, response); return; } @@ -45,6 +49,10 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull Ht filterChain.doFilter(request, response); } + private boolean isMetricRequest(HttpServletRequest request) { + return request.getRequestURI().startsWith(metricsPath); + } + /** * AccessToken과 RefreshToken이 모두 없는 경우, 익명 사용자로 간주한다. */ diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 83e2b6f..161b22e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,6 +3,30 @@ rabbit: domain: local: ${RABBIT_SERVER_DOMAIN_LOCAL:http://localhost:8080} dev: ${RABBIT_SERVER_DOMAIN_LOCAL:http://localhost:8080} + +management: + endpoints: + web: + exposure: + include: "*" # 실제 운영 시 필요 엔드포인트만 명시하여 보안 강화 필요 + base-path: ${ACTUATOR_METRICS_PATH:/actuator/prometheus} + endpoint: + health: + show-details: always + prometheus: + metrics: + export: + enabled: true + actuator: + username: ${ACTUATOR_USERNAME:rabbit} + password: ${ACTUATOR_PASSWORD:rabbit1234} + role: ${ACTUATOR_ROLE:ACTUATOR} + +server: + tomcat: + mbeanregistry: # 톰캣 메트릭을 모두 사용하려면 다음 옵션을 켜야하며, 옵션을 켜지 않으면 tomcat.session. 관련정보만 노출 + enabled: true + spring: config: import: optional:file:.env[.properties] @@ -28,7 +52,6 @@ spring: properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect - data: mongodb: uri: mongodb://root:1234@localhost:27017/rabbitmq?authSource=admin&authMechanism=SCRAM-SHA-1