Spring

SSR 기반 Spring Server 모니터링 생성기 (Prometheus, Grafana, Spring Security)

js1024 2025. 3. 27. 22:50

 

모니터링 서버 자체를 구축하는 방법은 크게 어렵지 않았지만 그 과정에서 서버에 적용된 Security에 대해 다시 공부해보는 계기가 되었습니다. 

 

총 두가지 방법으로 모니터링 페이지를 구축하였습니다.

  1. Spring Security의 별도 SecurityFilterChain 생성 + 모니터링 전용 Id와 role을 생성
  2. Spring Security의 별도 SecurityFilterChain 생성 + 도커 내부망 통신
Spring Security의 별도 SecurityFilterChain 생성 + 모니터링 전용 Id와 role을 생성

 

  1. 필터 체인의 적용 순서
    • 기존 SecurityFilterChain이 먼저 선언되어 있으면, 기존 로그인 방식(LoginForm)을 사용하지 않는 문제가 발생합니다.
    • 이를 해결하기 위해 별도의 SecurityFilterChain을 생성하고,@Order를 이용해 우선 적용되도록 설정하였습니다.
  2. 로그인 처리 방식
    • SSR 기반 프로젝트이므로 Base Path 방식을 사용했습니다.
    • 사용자가 로그인할 때, 해당 Role에 따라 모니터링 URI로 리디렉션하는 별도의 LoginHandler를 구현하였습니다.
    • Prometheus에서는 모니터링 전용 아이디와 비밀번호를 사용하여 데이터를 가져가도록 설정했습니다
첫번째 방식의 문제점

 

로그인 방식의 문제점은 지속적인 로그인 시도와 외부망 오픈으로 인한 취약점

 

  1. 모니터링의 특성상 실시간에 가까운 데이터들이 필요한데 Prometheus에서는 이러한 데이터 정보를 얻기 위해 지속적인 로그인 시도하게 됩니다. 이러한 경우 필연적으로 서버 리소스를 지속적으로 사용하게 됩니다. 시스템 상에서도 불필요한 로그들이 지속적으로 발생하기에 모니터링 데이터 좋지 않은 영향을 끼치게 됩니다. 물론 해결방법으로 별도의 로그인 핸들링 코드를 작성하는 방법과 불필요한 로그 데이터를 모니터링 환경에서 제외하는 등의 방법이 존재하긴 합니다.

  2. 제가 생각하는 외부망 오픈되어 있는 환경은 불필요한 보안 취약점을 남긴다 생각해 두번째 방법을 선택하게 되었습니다. 외부망이 오픈되어 있는 경우 uri를 알게 되고 지속적인 로그인 시도를 통해 비밀번호와 아이디를 해킹할 가능성을 남긴다 생각하여 해당 방법을 포기하게 되었습니다.

 

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@Slf4j
public class SecurityConfig {

    private final RedisTemplate<String, Object> redisTemplate;
    private final CustomLoginSuccessHandler customLoginSuccessHandler;
    private final CustomLogoutSuccessHandler customLogoutSuccessHandler;
    private final CustomLoginFailHandler customLoginFailHandler;
    private final CustomMemberDetailService customMemberDetailService;
    private final CustomAccessDeniedHandler customAccessDeniedHandler;

    @Bean
    @Order(1)
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http.authorizeHttpRequests((auth) -> auth
                        .requestMatchers("/", "/login", "/signup", "/css/**", "/js/**").permitAll()
                        .requestMatchers("/automessage/admin").hasRole("ADMIN")
                        .requestMatchers("/automessage/**").hasAnyRole("USER", "ADMIN")
                        .anyRequest().authenticated());

        http.exceptionHandling((except) -> except
                .accessDeniedPage("/login"));

        http.exceptionHandling((e) -> e
                .accessDeniedHandler(customAccessDeniedHandler));

        http.formLogin((auth) -> auth
                .loginPage("/login")
                .usernameParameter("memberId")
                .passwordParameter("memberPassword")
                .loginProcessingUrl("/login")
                .successHandler(customLoginSuccessHandler)
                .failureHandler(customLoginFailHandler)
                .permitAll());

        http.logout(logout -> logout
                .logoutUrl("/logout")
                .logoutSuccessHandler(customLogoutSuccessHandler)
                .invalidateHttpSession(true)
                .deleteCookies("JSESSIONID")
                .permitAll());

        http.rememberMe((remember) -> remember
                .rememberMeParameter("remember")
                .tokenValiditySeconds(3 * 24 * 60 * 60) // 3일 동안 유효한 쿠키
                .tokenRepository(persistentTokenRepository())
                .userDetailsService(customMemberDetailService));

        return http.build();
    }

    @Bean
    @Order(0) // Actuator 관련 보안 필터 체인을 먼저 실행
    public SecurityFilterChain actuatorSecurity(HttpSecurity http) throws Exception {
        log.info("actuatorSecurity");

        http.securityMatcher("/metrics/**")
            .httpBasic(Customizer.withDefaults())
            .csrf(AbstractHttpConfigurer::disable)
            .cors(cors -> {})  // 기본 CORS 설정 적용
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/metrics/**").hasRole("MONITOR")
                    .anyRequest().denyAll()
            );

        return http.build();
    }
}

 

# my global config
global:
  scrape_interval: 15s 
  evaluation_interval: 15s

# Alertmanager configuration
alerting:
  alertmanagers:
    - static_configs:
        - targets:

rule_files:
scrape_configs:
  - job_name: 'prometheus'
    metrics_path: '/metrics/prometheus'
    static_configs:
      - targets: ["host ip:port"]
  	basic_auth:
      username: 'id'
      password: 'pw'

 

 

Spring Security의 별도 SecurityFilterChain 생성 + 도커 내부망 통신

 

기존 Spring Server의 경우 Docker 환경에 배포되어 있고 Prometheus와 Grafana도 Docker 환경에 배포할 예정이기에 docker network를 이용해 내부망 통신을 이용해 외부 환경에서는 접속이 불가능한 환경으로 변경하였습니다.

 

총 3개의 도커 컨테이너를 배포하게 되는데 이를 하나의 네트워크로 묶어준다 Network Name: Bridge (사용자 별로 상이)

 

하단 이미지는 실제 사용하고 있는 Prometheus 컨테이너 설정 정보이다. 배포 후 알았는데 Prometheus의 경우 환경 변수로 한국 시간을 별도로 변경할 수 없다 항상 UK 시간이 고정이고 Grafana 환경 설정을 통해 KR 시간으로 변경할 수 있다.

 

Prometheus Config

 

Prometheus.yml 

# my global config
global:
  scrape_interval: 15s 
  evaluation_interval: 15s

# Alertmanager configuration
alerting:
  alertmanagers:
    - static_configs:
        - targets:

rule_files:
scrape_configs:
  - job_name: 'prometheus'
    metrics_path: '/metrics/prometheus'
    static_configs:
      - targets: ["Spring 컨테이너 이름:8002"]

  - job_name: 'prometheus-health'
    metrics_path: '/metrics/health'
    static_configs:
      - targets: ["Spring 컨테이너 이름:8002"]

 

Prometheus의 경우 내부망 통신만 사용하기에 기존 포트포워딩으로 열어줬던 포트(9090)를 해지한다.

Grafana의 경우 외부망과 연결되어야 하기 때문에 별도의 포트포워딩이 필요하다.

 

SpringSecurity FilterChain 변경 사항

 

securityMatcher -> 해당 클라이언트 uri 요청만 필터링한다.

requestMatchers ->  클라이언트의 모든 요청 경로에 대해 보안 작동을 진행하고 그 중에 해당 uri 경로로 오는 것에 대해 추가 보안 검사를 실행한다.

 

InternaNetworkFilter()를 통해 ip 추출 후 내부망 여부 판단

@Bean
@Order(0) // 내부망 모니터링
public SecurityFilterChain actuatorSecurity(HttpSecurity http) throws Exception {

    http.securityMatcher("/metrics/**");

    http.
            authorizeHttpRequests((auth) -> auth
                    .requestMatchers("/metrics/**").permitAll()
                    .anyRequest().denyAll());

    http.
            addFilterBefore(new InternalNetworkFilter(), BasicAuthenticationFilter.class);

    http
            .csrf(AbstractHttpConfigurer::disable)
            .cors(cors -> {})
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

    return http.build();
}

@Slf4j
public class InternalNetworkFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String remoteAddr = request.getRemoteAddr();
        String requestURI = request.getRequestURI();

        log.info("접속 ip {}", remoteAddr);

        // 로그인 URL 제외
        if (requestURI.contains("/login")) {
            log.info("로그인 URL 제외");
            filterChain.doFilter(request, response);
            return;
        }

        if (!isInternalNetwork(remoteAddr)) {
            log.info("외부 ip 주소 입니다");
            response.sendRedirect("/login");
            return;
        }

        filterChain.doFilter(request, response);
    }

    private boolean isInternalNetwork(String ip) {
        return ip.startsWith("허용할 내부 ip");
    }

}

application.yml

server:
	forward-headers-strategy: native #추가 작성
    ....

 

 

위에 설정들을 Spring Server, Prometheus, Grafana 3개의 컨테이너를 하나의 네트워크로 묶어줬다면 동일한 네트워크가 아닌 이상 직접적인 uri 입력으로는 Spring Server의 Prometheus uri에 접근할 수 없게 됩니다.

 

활성화 된 대시보드 화면

 

 

참고 자료

 

Docker network로 모니터링 시스템 내부망 사용

안녕하세요, 셀럽잇의 로이스입니다.이전 글들을 통해 모니터링을 적절히 구성하였는데요.이번엔 보안적인 부분에 대해 신경쓰며 개선해보도록 하겠습니다.메트릭은 어플리케이션에 대한 다

velog.io