Spring Boot Actuator는 애플리케이션의 상태와 메트릭을 쉽게 확인할 수 있는 강력한 도구이지만 잘못 설정하면 내부 정보가 외부에 유출되는 보안 위협이 될 수 있다. 따라서 운영 환경에서는 필요한 엔드포인트만 선택적으로 노출하고 인증 및 인가 체계를 적용하여 접근을 제한하는 것이 필수적이다. 이번 포스팅에서는 Actuator 보안에 도움이 되는 내용들에 대해서 정리하고자 한다.
Spring Boot Actuator 포트 분리
애플리케이션 API와 같은 포트에서 Actuator 엔드포인트를 바인딩하면 외부 사용자도 /actuator/** 경로에 접근할 수 있다. 별도 포트에 두면 네트워크 차원에서 API와 Actuator 접근을 분리할 수 있는 이점이 있다.
설정은 다음과 같다.
management:
server:
port: 8081
actuator 포트는 8081로 적용된다.
Spring Boot Actuator base-path 변경
Spring Boot Actuator의 기본 경로는 디폴트로 /actuator로 시작하므로 잘 알려져 있다. 이를 통해 외부에서 무작위 스캐닝을 통해 쉽게 접근 시도를 할 수 있다.
설정은 다음과 같다.
management:
endpoints:
web:
base-path: /my-actuator
actuator 기본 경로는 /actuator -> /my-actuator로 적용된다.
Spring Boot Actuator Opt-in 정책 적용
Opt-out은 기본값으로 허용하고 민감하거나 불필요한 엔드포인트를 개별적으로 차단하는 방식을 의미한다.
반대로 Opt-in은 기본값은 차단이고 꼭 필요한 엔드포인트만 허용하는 방식을 의미한다.
테스트나 개발 환경에서는 Opt-out을 사용하는 것이 편의상 사용할 수 있지만 운영 환경에서는 Opt-in 방식이 강력히 권장된다.
management.endpoints.access.default는 deprecated 된 management.endpoints.enabled-by-default 설정을 대체한다.
Opt-out
management:
endpoints:
access:
default: unrestricted # 기본적으로 모든 엔드포인트 접근 허용
management:
endpoint:
shutdown:
access: none # 단, shutdown만 차단
모든 엔드포인트를 허용하되 shutdown 엔드포인트만 차단한다.
Opt-in
management:
endpoints:
access:
default: none #모든 엔드포인트 비활성
web:
exposure:
include: health, info, metrics
endpoint:
health:
access: read_only
info:
access: read_only
metrics:
access: read_only
모든 엔드포인트를 차단하되 health, info, metrics를 허용한다. 물론 management.endpoints.web.exposure.include에 엔드포인트가 지정되지 않으면 노출되지 않는다.
management.endpoints.access.default
엔드포인트 자체의 사용 가능 여부를 설정한다.
엔드포인트 빈을 등록할지, 어떤 오퍼레이션을 사용할 수 있을지를 제어하는 내부 레벨 설정이다.
management.endpoints.web[jmx].exposure.include
HTTP(JMX)로 어떤 엔드포인트를 노출할지를 제어한다.
엔드포인트가 살아있더라도 여기에 지정되지 않은 엔드포인트는 노출되지 않는다.
설정 충돌 시 access 설정이 우선 적용된다.
예를 들어 management.endpoints.access.default: none이고 management.endpoints.web.exposure.include: info라고 했을 때 info를 web에 노출하도록 설정하였으나 모든 엔드포인트에 대한 access는 차단이므로 info는 노출되지 않을 것이다. management.endpoint.info.access: read_only라면 노출될 것이다.
Spring Boot Actuator 불필요한 채널 노출 제거
Spring Boot Actuator는 노출 채널로 WEB과 JMX를 지원한다. 지원하지 않는 채널에 대해서는 제외해 주는 것이 좋다.
management:
endpoints:
jmx:
exposure:
excludes: "*"
Spring Boot Actuator 인증 / 인가 사용자에게만 상세 정보 보여주기
이번 포스팅의 메인 주제라고 하겠다. spring-boot-security를 이용하여 인증이 필요한 엔드포인트를 정의하고 인증 사용자에게 역할을 부여하여 해당 역할에 대해서 상세 정보를 노출하도록 하는 것이 좋다. spring-boot-security에 대해서는 추후에 자세히 다뤄보기로 하고 여기서는 인증이 필요한 엔드포인트를 설정하고 인증을 처리하는 방법에 대해서 알아보겠다.
보통 운영환경에서 actuator는 모니터링 시스템과 연동하여 health check 및 metric 정보 제공을 하게 되는데 적절한 인증 처리가 없으면 외부에도 내부 정보가 노출될 수 있기 때문에 모니터링 시스템과 협의된 인증 정보를 가지고 연동을 한다.
다음은 간단히 basic auth를 활용하여 인증 처리를 한 경우에만 health에 대한 상세 정보와 metrics를 노출한다. 또한 env 엔드포인트의 경우 인증을 받은 경우에만 값을 노출하도록 한다. 기본적으로 health, info 엔드포인트를 노출한다.
application.yml
우선 샘플에 정의된 application.yml 설정은 다음과 같다.
management:
server:
port: 8081
endpoints:
access:
default: none
web:
exposure:
include: health, info, env, metrics
base-path: /security-actuator
jmx:
exposure:
exclude: "*"
endpoint:
health:
show-details: when_authorized
access: read_only
roles: "ACTUATOR"
env:
show-values: when_authorized
access: read_only
metrics:
access: read_only
info:
access: read_only
health:
redis:
enabled: true
mongo:
enabled: true
info:
java:
enabled: true
os:
enabled: true
env:
enabled: true
process:
enabled: true
spring:
data:
redis:
host: localhost
port: 6379
mongodb:
host: localhost
port: 27019
info:
app:
name: demo-app
version: "1.0.0"
description: Spring Boot Actuator demo application
password: test-password
app:
security-password: ${SECURITY_PASSWORD}
우선 애플리케이션 서비스 포트와 actuator 포트를 분리하였다. (8081)
actuator base-path 설정을 /actuator -> /security-actuator로 변경하였다.
디폴트로 actuator 엔드포인트를 차단으로 하고 필요한 엔드포인트에 대해서만 활성화하였다. (Opt-in)
JMX 채널에 대한 엔드포인트 노출을 제외하였다.
health 엔드포인트에서 인증 및 ACTUATOR 역할을 받은 경우에만 상세 정보를 제공하도록 하였다.
health 정보에서 redis, mongodb 정보도 출력을 허용하도록 하였다. (인증을 받은 경우에만 정보가 노출된다)
인증 처리를 위한 비밀번호를 SECURITY_PASSWD 환경 변수에 등록하여 처리하였다.
일반적으로 DB에 저장된 값 혹은 k8s 환경에서는 secret에 등록된 값을 사용하지만 편의상 환경 변수로 등록하여 사용하였다.
Configuration
우선 configuration 정의를 위해서 spring-boot-starter-security 의존성을 추가해야 한다.
implementation 'org.springframework.boot:spring-boot-starter-security'
다음 클래스는 actuator endpoint에 대한 접근 설정과 인증 및 인가가 필요한 엔드포인트를 정의하고 인증 처리를 위한 빈을 정의한다.
@Configuration
public class ActuatorSecurityConfig {
@Value( "${app.security-password}" )
private String actuatorPassword;
/*
/metrics, metrics/**, /env, /env/** 에 대한 인증 및 인가 적용
/health, /info 엔드포인트는 기본 허용
그 외 나머지는 연결 거부
*/
@Bean
public SecurityFilterChain securityFilterChain( HttpSecurity http ) throws Exception {
http.securityMatcher( "/security-actuator/**" )
.authorizeHttpRequests( authorize -> authorize
.requestMatchers( "/security-actuator/health", "/security-actuator/info" ).permitAll()
.requestMatchers( "/security-actuator/metrics", "/security-actuator/metrics/**",
"/security-actuator/env", "/security-actuator/env/**" )
.hasRole( "ACTUATOR" )
.anyRequest().denyAll() )
.httpBasic( withDefaults() ) //basic 인증 처리
.csrf( csrf -> csrf.disable() );
return http.build();
}
/*
인증 처리에 필요한 비밀번호 Encoder 정의. 여기서는 bcrypt를 사용.
빈을 생성하지 않은 경우 UserDetailsService 빈 생성시 password 에 prefix 지정을 해줘야 함.
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder( 10 );
}
/*
UserDetailsService 빈이 spring security 처리 과정에서 적용된다.
내부적으로 AuthenticationManager -> AuthenticationProvider를 통해서 인증이 일어난다.
보통은 DB에 저장된 값 혹은 k8s 환경의 경우 secret에 저장된 값을 적용하지만 편의상 환경변수에 정의된 값을 사용하겠다.
*/
@Bean
public UserDetailsService userDetailsService() {
return username -> User.withUsername( username )
.password( passwordEncoder().encode( actuatorPassword ) )
.roles( "ACTUATOR" )
.build();
}
}
securityFilterChain 빈에 /security-actuator/** 에 대해서 보안 설정이 적용된다.
/security-actuator/health, /security-actuator/info 엔드포인트는 기본적으로 허용한다.
/security-actuator/metrics, /security-actuator/metrics/**, /security-actuator/env, /security-actuator/env/** 엔드포인트에 대해서는 인증과 함께 ACTUATOR 역할이 주어진 경우에만 허용한다.
그 외 나머지에 대해서는 모두 거부한다.
비밀번호는 bcrypt를 인코딩하여 적용한다. PasswordEncoder 빈에 BcryptPasswordEncdoer 인스턴스를 생성하였다.
UserDetailsService는 basic auth로 전달된 username(id) 값을 기반으로 비밀번호 정보(bcrypt)와 함께 "ACTUATOR" 역할을 넘겨주면 security 프레임워크 내에서 basic auth로 전달된 비밀번호를 UserDetailsService에 있는 비밀번호와 비교하여 인증처리를 한다.
편의상 username 역시 basic 인증 정보에 있는 값을 그대로 설정하도록 하였고 역할 정보도 "ACTUATOR"로 하드코딩 하였다.
보통은 UserDetailsService에 세팅하는 값은 username을 키로 하여 DB 혹은 다른 저장소에서 값을 가져와 저장된 정보를 설정할 것이다.
다음과 같이 인증에 사용할 비밀번호를 환경변수로 등록하여 테스트해 보겠다.
export SECURITY_PASSWORD=actuator-password
우선 엔드포인트 접속 제어가 잘 적용되었는지 확인해 보면 결과는 다음과 같다.
~/ curl http://localhost:8081/security-actuator/env | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 113 0 113 0 0 15526 0 --:--:-- --:--:-- --:--:-- 16142
{
"timestamp": "2025-09-23T11:48:09.182+00:00",
"status": 401,
"error": "Unauthorized",
"path": "/security-actuator/env"
}
=================================================
~/ curl http://localhost:8081/security-actuator/metrics | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 117 0 117 0 0 15600 0 --:--:-- --:--:-- --:--:-- 16714
{
"timestamp": "2025-09-23T11:48:27.387+00:00",
"status": 401,
"error": "Unauthorized",
"path": "/security-actuator/metrics"
}
================================================
~/ curl http://localhost:8081/security-actuator/health | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 15 0 15 0 0 1197 0 --:--:-- --:--:-- --:--:-- 1250
{
"status": "UP"
}
================================================
~/ curl http://localhost:8081/security-actuator/info | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 806 0 806 0 0 27523 0 --:--:-- --:--:-- --:--:-- 27793
{
"app": {
"name": "demo-app",
"version": "1.0.0",
"description": "Spring Boot Actuator demo application",
"password": "test-password"
},
"java": {
"version": "17.0.14",
"vendor": {
"name": "Eclipse Adoptium",
"version": "Temurin-17.0.14+7"
},
"runtime": {
"name": "OpenJDK Runtime Environment",
"version": "17.0.14+7"
},
"jvm": {
"name": "OpenJDK 64-Bit Server VM",
"vendor": "Eclipse Adoptium",
"version": "17.0.14+7"
}
},
"os": {
"name": "Mac OS X",
"version": "15.6.1",
"arch": "aarch64"
},
"process": {
...
}
}
/security-actuator/env : 401 Unauthorized
/security-actuator/metrics : 401 Unauthorized
/security-actuator/health : 간단한 health 정보 출력
/security-actuator/info : 정상 출력
와 같이 엔드포인트 접속 제한이 잘 적용되었음을 확인할 수 있다.
이제 basic 인증을 통해서 원하는 결과가 출력되는지 확인해 보자.
1. 인증을 통해서 /security-actuator/health 호출 시 상세 정보가 출력되는지 확인
~/ curl -v -u admin:actuator-password http://localhost:8081/security-actuator/health
* Host localhost:8081 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
* Trying [::1]:8081...
* Connected to localhost (::1) port 8081
* Server auth using Basic with user 'admin'
> GET /security-actuator/health HTTP/1.1
> Host: localhost:8081
> Authorization: Basic YWRtaW46YWN0dWF0b3ItcGFzc3dvcmQ=
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 0
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Content-Type: application/vnd.spring-boot.actuator.v3+json
< Transfer-Encoding: chunked
< Date: Tue, 23 Sep 2025 11:27:44 GMT
<
* Connection #0 to host localhost left intact
{
"status": "UP",
"components": {
"diskSpace": {
"status": "UP",
"details": {
...
...
}
},
"mongo": {
"status": "UP",
"details": {
"maxWireVersion": 17
}
},
"ping": {
"status": "UP"
},
"redis": {
"status": "UP",
"details": {
"version": "7.2.4"
}
},
"ssl": {
"status": "UP",
"details": {
"validChains": [],
"invalidChains": []
}
}
}
}
호출 헤더를 보면 Authorization: Basic YWRtaW46YWN0dWF0b3ItcGFzc3dvcmQ= (base64(admin:actuator-password))로 인증정보를 전달함을 알 수 있다.
결과는 상세한 health 정보가 출력된다.
2. 인증을 통해서 /security-actuator/env 가 출력되는지 확인
~/ curl -v -u admin:actuator-password http://localhost:8081/security-actuator/env | jq .
* Host localhost:8081 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0* Trying [::1]:8081...
* Connected to localhost (::1) port 8081
* Server auth using Basic with user 'admin'
> GET /security-actuator/env HTTP/1.1
> Host: localhost:8081
> Authorization: Basic YWRtaW46YWN0dWF0b3ItcGFzc3dvcmQ=
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 0
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Content-Type: application/vnd.spring-boot.actuator.v3+json
< Transfer-Encoding: chunked
< Date: Tue, 23 Sep 2025 11:32:32 GMT
<
{ [7880 bytes data]
100 24580 0 24580 0 0 106k 0 --:--:-- --:--:-- --:--:-- 107k
* Connection #0 to host localhost left intact
{
"activeProfiles": [],
"defaultProfiles": [
"default"
],
"propertySources": [
{
"name": "server.ports",
"properties": {
"local.server.port": {
"value": 8080
},
"local.management.port": {
"value": 8081
}
}
},
{
"name": "servletContextInitParams",
"properties": {}
},
{
"name": "systemProperties",
"properties": {
"java.specification.version": {
"value": "17"
},
"sun.jnu.encoding": {
"value": "UTF-8"
},
...
}
},
{
"name": "systemEnvironment",
"properties": {
...
"SECURITY_PASSWORD": {
"value": "actuator-password",
"origin": "System Environment Property \"SECURITY_PASSWORD\""
},
...
}
},
{
"name": "Config resource 'class path resource [application.yml]' via location 'optional:classpath:/'",
"properties": {
"management.server.port": {
"value": 8081,
"origin": "class path resource [application.yml] - 3:11"
},
...
"info.app.password": {
"value": "******",
"origin": "class path resource [application.yml] - 54:15"
},
"app.security-password": {
"value": "******",
"origin": "class path resource [application.yml] - 57:22"
}
}
},
{
"name": "applicationInfo",
"properties": {
"spring.application.pid": {
"value": 22880
}
}
}
]
}
3. 인증을 통해서 /security-actuator/metrics 가 출력되는지 확인
~/ curl -v -u admin:actuator-password http://localhost:8081/security-actuator/metrics | jq .
* Host localhost:8081 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0* Trying [::1]:8081...
* Connected to localhost (::1) port 8081
* Server auth using Basic with user 'admin'
> GET /security-actuator/metrics HTTP/1.1
> Host: localhost:8081
> Authorization: Basic YWRtaW46YWN0dWF0b3ItcGFzc3dvcmQ=
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0< HTTP/1.1 200
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 0
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Content-Type: application/vnd.spring-boot.actuator.v3+json
< Transfer-Encoding: chunked
< Date: Tue, 23 Sep 2025 11:41:01 GMT
<
{ [2766 bytes data]
100 2759 0 2759 0 0 12743 0 --:--:-- --:--:-- --:--:-- 12714
* Connection #0 to host localhost left intact
{
"names": [
"application.ready.time",
"application.started.time",
"disk.free",
"disk.total",
"executor.active",
"executor.completed",
"executor.pool.core",
"executor.pool.max",
"executor.pool.size",
"executor.queue.remaining",
"executor.queued",
...
...
"tomcat.sessions.active.current",
"tomcat.sessions.active.max",
"tomcat.sessions.alive.max",
"tomcat.sessions.created",
"tomcat.sessions.expired",
"tomcat.sessions.rejected"
]
}
/security-actuator/metrics/<name> 호출 확인
~/ curl -v -u admin:actuator-password http://localhost:8081/security-actuator/metrics/jvm.memory.used | jq .
* Host localhost:8081 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0* Trying [::1]:8081...
* Connected to localhost (::1) port 8081
* Server auth using Basic with user 'admin'
> GET /security-actuator/metrics/jvm.memory.used HTTP/1.1
> Host: localhost:8081
> Authorization: Basic YWRtaW46YWN0dWF0b3ItcGFzc3dvcmQ=
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200
< Content-Disposition: inline;filename=f.txt
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 0
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Content-Type: application/vnd.spring-boot.actuator.v3+json
< Transfer-Encoding: chunked
< Date: Tue, 23 Sep 2025 11:43:14 GMT
<
{ [341 bytes data]
100 329 0 329 0 0 1460 0 --:--:-- --:--:-- --:--:-- 1462
* Connection #0 to host localhost left intact
{
"name": "jvm.memory.used",
"description": "The amount of used memory",
"baseUnit": "bytes",
"measurements": [
{
"statistic": "VALUE",
"value": 1.1875176E+8
}
],
"availableTags": [
{
"tag": "area",
"values": [
"heap",
"nonheap"
]
},
{
"tag": "id",
"values": [
"G1 Survivor Space",
"Compressed Class Space",
"Metaspace",
"CodeCache",
"G1 Old Gen",
"G1 Eden Space"
]
}
]
}
정상적으로 출력됨을 확인할 수 있다.
끝.
'스프링부트' 카테고리의 다른 글
| Spring Boot Actuator - 5. 사용자 정의 Endpoint 만들기 (0) | 2025.09.27 |
|---|---|
| Spring Boot Actuator - 4. Endpoint 커스텀 (0) | 2025.09.24 |
| Spring Boot Actuator - 2. 주요 Endpoint (0) | 2025.09.21 |
| Spring Boot Actuator - 1. 시작하기 (0) | 2025.09.17 |
| Spring Boot JSON 처리를 위한 자동 구성 및 설정 (0) | 2025.01.04 |
댓글