이전에 Spring Boot 프로젝트 모니터링 글을 작성하면서 Spring Boot Admin에 대해서 간단하게 소개했다. 이번에는 이를 적용하고, 그 과정에서 겪었던 여러 시행착오들의 원인과 해결방법 등을 써보았다.
Spring Boot Admin은 Spring Boot Actuator에서 제공하는 정보를 보기 좋게 접근 가능한 방식으로 시각화하는 것을 목표로 하는 모니터링 도구이다. security 설정을 통해 모니터링 정보에 아무나 접근할 수 없도록 할 수 있다.
Spring Boot Admin은 두 가지 주요 부분으로 구성된다.
그러니까 이 모니터링 시스템을 구축하기 위해서는 최소한 두 개의 스프링 프로젝트가 필요하다. 한 개 이상의 클라이언트로부터 actuator 정보를 받아와 UI에 보여주는 서버와, actuator 엔드포인트 액세스를 제공하는 클라이언트. 이렇게 말이다. 처음에는 이 부분이 이해가 제대로 되지 않아 한참을 헤맸었다.
나 같은 경우에는 기존에 진행하던 프로젝트의 로그를 원격으로 확인하고 싶으니 이 프로젝트가 Admin의 클라이언트 역할이 될 것이고, 서버 역할의 프로젝트는 새로 생성해야 한다. 간단하게 구현해보자.
Admin Server 역할을 수행할 스프링 프로젝트를 새로 생성해주었다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'de.codecentric:spring-boot-admin-starter-server:3.1.0'
...
}
Admin 프로젝트의 README에서는 Spring Boot와 Spring Boot Admin의 메이저와 마이너 버전이 일치하는 것이 좋다고 하지만, 마이너 버전은 조금 달라도 괜찮은 것 같다. 나는 Spring Boot 버전이 3.0.8인데 문제 없이 동작한다.
server:
port: 8090
spring:
application:
name: spring-boot-admin-server
security:
user:
name: ${ADMIN_SERVER_USERNAME}
password: ${ADMIN_SERVER_PASSWORD}
boot:
admin:
ui:
poll-timer:
logfile: 30000 # logfile 갱신 주기. 30초로 설정함
admin 페이지 접속할 때 필요한 로그인 정보는 환경변수를 사용하였다.
spring.boot.admin.ui.poll-timer.logfile
옵션은 로그파일 데이터 갱신 주기(ms)인데, 디폴트값이 1000이다. 로그파일 페이지에 들어가면 1초마다 /actuator/logfile
을 요청해서 로그를 갱신해준다. 그런데 이렇게 써보니 매초마다 위의 요청이 로그에 찍히니 쓸모없는 데이터가 너무 많아져서 널널하게 30초로 설정해주었다. 참고로 admin 페이지에서 로그파일 탭에 들어가 있을 때에만 해당 주기마다 갱신 요청을 한다.
@SpringBootApplication
@EnableAdminServer
public class SpringBootAdminServerApplication {
public static void main(String[] args) {
SpringApplication.run(SpringBootAdminServerApplication.class, args);
}
}
Admin Server로 사용하기 위해서 @EnableAdminServer
어노테이션을 추가해준다.
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity(debug = true)
public class SecurityConfig {
private final AdminServerProperties adminServer;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 로그인 성공 시 메인페이지로 redirect
final SavedRequestAwareAuthenticationSuccessHandler loginSuccessHandler = new SavedRequestAwareAuthenticationSuccessHandler();
loginSuccessHandler.setTargetUrlParameter("redirectTo");
loginSuccessHandler.setDefaultTargetUrl(this.adminServer.path("/"));
http
.authorizeHttpRequests()
// 로그인 페이지와 assets 리소스는 누구나 접근할 수 있도록 허용
.requestMatchers(
"/login",
"/assets/*"
).permitAll()
// 그 외에는 접근 권한이 필요함
.anyRequest().authenticated()
.and()
// 로그인 URL 및 success handler 설정
.formLogin().loginPage(this.adminServer.path("/login")).successHandler(loginSuccessHandler)
// 로그아웃 URL 설정
.and().logout().logoutUrl("/logout")
.and()
// Client 등록을 위한 HTTP-Basic 지원 사용
.httpBasic()
.and().csrf()
// 쿠키를 사용하여 CSRF 보호
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
// CSRF 비활성화 URL
.ignoringRequestMatchers(
this.adminServer.path("/instances"),
this.adminServer.path("/actuator/**")
);
return http.build();
}
}
Spring Security를 위한 관련 설정을 추가해준다.
헤더에 정보를 추가해서 http basic auth를 사용하거나 JWT를 적용할 수도 있다.
http://docs.spring-boot-admin.com/current/customize_http-headers.html
@Bean
public HttpHeadersProvider customHttpHeadersProvider() {
return (instance) -> {
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add("X-CUSTOM", "My Custom Value");
return httpHeaders;
};
}
IntelliJ에서 환경변수 설정까지 해주고 /login
링크에 접속하면 다음과 같은 로그인 화면을 볼 수 있다. 이 상태에서는 로그인을 해도 등록된 client가 없기 때문에 인스턴스가 없다고 뜰 것이다.
만약 아래 화면이 보이지 않는다면 구현 중 놓친 부분이 있는지, 에러가 발생하지는 않는지 확인해보자.
dependencies {
implementation 'de.codecentric:spring-boot-admin-starter-client:3.1.0'
...
}
server 버전과 동일하게 맞춰주자.
server:
port: 8080
spring:
# spring boot admin client 관련 설정
boot:
admin:
client:
instance:
# Client display name
name: Client Server
# 현재 돌아가고 있는 서버, 즉 spring boot admin client의 주소
service-url: "http://localhost:8080"
# spring boot admin server의 주소
url: "http://localhost:8090"
# true로 설정하면 애플리케이션을 등록하는 주기적인 작업이 애플리케이션이 준비된 후 자동으로 예약됨
auto-registration: true
username: ${ADMIN_SERVER_USERNAME}
password: ${ADMIN_SERVER_PASSWORD}
management:
endpoints:
web:
exposure:
include: refresh, health, metrics, logfile, env
endpoint:
health:
show-details: always
logging:
file:
name: ./logs/application.log
service-url
과 url
을 헷갈리지 않도록 주의하자! service-url
은 현재 프로젝트, 즉 client-side 프로젝트의 주소이다. url
은 반드시 기입해줘야 하는 설정으로, 등록할 Spring Boot Admin Server의 URL 목록이다. 서버가 여러 개라면 쉼표로 구분할 수 있다. (공식문서) 그래서 아까 server-side의 포트인 8090을 사용한 것을 확인할 수 있다.
include: refresh, health, metrics, logfile, env
대신에 include: *
을 사용해도 된다. yml을 사용한다면 큰따옴표로 감싸주어야 한다.
YAML에서 asterisk(*)을 와일드카드가 아니라 반복되는 노드를 제거하는 용도로 사용해서 파싱할 때 에러가 발생하는 것 같다.
그러나 나는 이렇게 큰따옴표로 감싸주어도 에러가 발생했었다. Github의 Actions secrets
에 yml 내용을 넣어주고 workflow로 배포해주고 있는데, 배포서버의 application-aws.yml에는 큰따옴표가 벗겨져있었다. 이 때문에 asterisk 대신에 일일이 명시해줬다. 원인은 아직 찾지 못했다...
spring boot에서는 기본적으로 로그파일을 생성하지 않으므로, 로그 파일 설정은 따로 해주어야 한다. 자세한 내용은 내가 이전에 썼던 포스트를 참고 바란다.
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
@Value("${ip.local.address}")
private String myLocalIpAddress;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
...
.requestMatchers("/actuator/**").access(hasIpAddress(myLocalIpAddress))
.anyRequest().authenticated()
...
}
private static AuthorizationManager<RequestAuthorizationContext> hasIpAddress(String ipAddress) {
IpAddressMatcher ipAddressMatcher = new IpAddressMatcher(ipAddress);
return (authentication, context) -> {
HttpServletRequest request = context.getRequest();
return new AuthorizationDecision(ipAddressMatcher.matches(request));
};
}
특정 IP 주소에서만 /actuator 엔드포인트에 액세스할 수 있도록 설정해주었다. 3.x 버전의 Spring Boot Security에는 보안상의 이유로 hasIpAddress
메소드가 구현되어 있지 않다. 때문에 비슷한 역할을 수행할 메소드를 구현해주었다.
Matching IP address with authorizeHttpRequests
위의 설정이 완료되었다면 Admin 페이지에 로그인 했을 때 아래와 같은 화면들을 볼 수 있을 것이다.
로컬에서는 잘 돌아가는데, 이상하게 AWS EC2에 배포하니 등록된 IP 주소인 내 컴퓨터에서 /actuator에 접근할 수 없었다. 로그를 확인해보니 내 IP 주소가 x-forwarded-for
에 들어가있었다.
보안관련해서 방화벽이나 클라우드로 운영하는 경우, 클라이언트가 요청을 하면 Web Server에서 프록시나 로드 밸런서를 통해 WAS에 요청하기 때문에 프록시나 로드 밸런서의 IP 주소만을 담고 있다. 때문에 원래 IP 주소를 가져오지 못하는 현상이 발생한다고 한다. 이를 위해 사용하는 것이 X-Forwarded-For 헤더이다.
X-Forwarded-For (XFF) 헤더는 HTTP 프록시나 로드 밸런서를 통해 웹 서버에 접속하는 클라이언트의 원 IP 주소를 식별하는 사실상의 표준 헤더다. 클라이언트와 서버 중간에서 트래픽이 프록시나 로드 밸런서를 거치면, 서버 접근 로그에는 프록시나 로드 밸런서의 IP 주소만을 담고 있다. 클라이언트의 원 IP 주소를 보기위해 X-Forwarded-For 요청 헤더가 사용된다.
보통 HttpServletRequest 객체 내 함수로 클라이언트 IP를 가져올 때 getRemoteAddr()
메소드를 사용한다. 위에서 사용한 IpAddressMatcher 클래스의 match 함수도 내부적으로 getRemoteAddr()
를 사용한다.
IpAddressMatcher.java
public final class IpAddressMatcher implements RequestMatcher {
...
@Override
public boolean matches(HttpServletRequest request) {
return matches(request.getRemoteAddr());
}
...
}
그래서 로컬에서는 잘 동작했어도 EC2에 배포한 후에는 IP 주소를 받아올 수 없어서 엔드포인트에 접근할 수 없었던 것이다. matches
메소드는 String도 지원하므로 이에 맞게 코드를 수정해보자.
@Slf4j
public class Utils {
public static String getClientIP(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
log.info("> X-FORWARDED-FOR : " + ip);
if (ip == null) {
ip = request.getHeader("Proxy-Client-IP");
log.info("> Proxy-Client-IP : " + ip);
}
if (ip == null) {
ip = request.getHeader("WL-Proxy-Client-IP");
log.info("> WL-Proxy-Client-IP : " + ip);
}
if (ip == null) {
ip = request.getHeader("HTTP_CLIENT_IP");
log.info("> HTTP_CLIENT_IP : " + ip);
}
if (ip == null) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
log.info("> HTTP_X_FORWARDED_FOR : " + ip);
}
if (ip == null) {
ip = request.getRemoteAddr();
log.info("> getRemoteAddr : "+ip);
}
log.info("> Result : IP Address : "+ip);
return ip;
}
}
로컬에서 사용할 때에는 XFF 헤더가 null이었다. 환경에 따라 원래 IP가 들어있는 위치나 헤더가 다른 것 같으니 그에 맞는 util 메소드를 추가해주었다.
private static AuthorizationManager<RequestAuthorizationContext> hasIpAddress(String ipAddress) {
IpAddressMatcher ipAddressMatcher = new IpAddressMatcher(ipAddress);
return (authentication, context) -> {
HttpServletRequest request = context.getRequest();
return new AuthorizationDecision(ipAddressMatcher.matches(getClientIP(request)));
};
}
이렇게 바꿔주니 정상적으로 동작하는 것을 확인할 수 있었다!
Spring Boot Admin Docs
Spring Boot Admin 구축 및 보안 설정
Document special handling of * with YAML
What is the use of star(*) in yaml file?
[Java] 1. 클라이언트 실제 접속 IP 가져오기
Mozilla Web docs - X-Forwarded-For