Spring Boot Actuator
1. Giới Thiệu
1.1 Actuator là gì?
Spring Boot Actuator là một sub-project của Spring Boot cung cấp production-ready features cho ứng dụng của bạn: - Monitor và quản lý ứng dụng trong runtime - Expose các endpoint để thu thập metrics, health checks, application info - Hỗ trợ tích hợp với các monitoring systems như Prometheus, Grafana - Cung cấp insights về performance và behavior của ứng dụng
1.2 Cấu hình Dependencies
<!-- Maven -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Prometheus support -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- Security (optional) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
// Gradle
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
implementation 'org.springframework.boot:spring-boot-starter-security'
1.3 Cấu hình cơ bản
# application.yml
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,loggers,httptrace
exclude: shutdown
base-path: /actuator
cors:
allowed-origins: "*"
allowed-methods: GET,POST
jmx:
exposure:
include: "*"
endpoint:
health:
show-details: when-authorized
show-components: always
probes:
enabled: true
info:
enabled: true
metrics:
enabled: true
metrics:
tags:
application: ${spring.application.name}
environment: ${spring.profiles.active:default}
server:
port: 8081
address: 127.0.0.1
2. Built-in Endpoints
2.1 Health Endpoint
@Component
public class DatabaseHealthIndicator implements HealthIndicator {
private final DataSource dataSource;
private final Logger logger = LoggerFactory.getLogger(DatabaseHealthIndicator.class);
public DatabaseHealthIndicator(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public Health health() {
try (Connection connection = dataSource.getConnection()) {
if (connection.isValid(1)) {
return Health.up()
.withDetail("database", "Available")
.withDetail("validationQuery", "SELECT 1")
.withDetail("connectionPoolSize", getConnectionPoolSize())
.build();
} else {
return Health.down()
.withDetail("database", "Connection validation failed")
.build();
}
} catch (SQLException e) {
logger.error("Database health check failed", e);
return Health.down()
.withDetail("database", "Unavailable")
.withDetail("error", e.getMessage())
.withException(e)
.build();
}
}
private int getConnectionPoolSize() {
if (dataSource instanceof HikariDataSource) {
return ((HikariDataSource) dataSource).getHikariPoolMXBean().getActiveConnections();
}
return -1;
}
}
// Reactive Health Indicator
@Component
public class ReactiveRedisHealthIndicator implements ReactiveHealthIndicator {
private final ReactiveRedisTemplate<String, String> redisTemplate;
public ReactiveRedisHealthIndicator(ReactiveRedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public Mono<Health> health() {
return redisTemplate.execute(connection -> connection.ping())
.map(result -> Health.up()
.withDetail("redis", "Available")
.withDetail("ping", result)
.build())
.onErrorReturn(Health.down()
.withDetail("redis", "Unavailable")
.build());
}
}
2.2 Custom Health Groups
management:
endpoint:
health:
group:
readiness:
include: readinessState,database,redis
show-details: always
liveness:
include: livenessState,diskSpace
show-details: always
@Component
public class CustomReadinessIndicator implements HealthIndicator {
private final ApplicationEventPublisher eventPublisher;
private volatile boolean ready = false;
@Override
public Health health() {
if (ready) {
return Health.up()
.withDetail("status", "Application is ready to serve traffic")
.build();
}
return Health.down()
.withDetail("status", "Application is not ready")
.build();
}
@EventListener
public void onApplicationReady(ApplicationReadyEvent event) {
ready = true;
}
}
2.3 Info Endpoint với Git Information
@Component
public class CustomInfoContributor implements InfoContributor {
private final Environment environment;
public CustomInfoContributor(Environment environment) {
this.environment = environment;
}
@Override
public void contribute(Info.Builder builder) {
Map<String, Object> appInfo = new HashMap<>();
appInfo.put("name", environment.getProperty("spring.application.name"));
appInfo.put("version", getClass().getPackage().getImplementationVersion());
appInfo.put("profiles", environment.getActiveProfiles());
appInfo.put("jvm", Map.of(
"version", System.getProperty("java.version"),
"vendor", System.getProperty("java.vendor")
));
builder.withDetail("application", appInfo);
builder.withDetail("build", getBuildInfo());
}
private Map<String, Object> getBuildInfo() {
return Map.of(
"timestamp", Instant.now(),
"user", System.getProperty("user.name"),
"machine", getHostname()
);
}
private String getHostname() {
try {
return InetAddress.getLocalHost().getHostName();
} catch (UnknownHostException e) {
return "unknown";
}
}
}
// Git Info Configuration
@ConfigurationProperties("info.git")
@Data
public class GitProperties {
private String branch;
private String commit;
private String time;
private String remote;
}
3. Advanced Custom Endpoints
3.1 Feature Toggle Endpoint
@Component
@WebEndpoint(id = "features")
public class FeatureToggleEndpoint {
private final Map<String, FeatureToggle> features = new ConcurrentHashMap<>();
private final FeatureToggleService featureService;
public FeatureToggleEndpoint(FeatureToggleService featureService) {
this.featureService = featureService;
initializeFeatures();
}
@ReadOperation
public Map<String, Object> getAllFeatures() {
return Map.of(
"features", features,
"lastUpdated", Instant.now(),
"total", features.size()
);
}
@ReadOperation
public FeatureToggle getFeature(@Selector String featureName) {
return features.get(featureName);
}
@WriteOperation
public void updateFeature(@Selector String featureName,
@Nullable Boolean enabled,
@Nullable String description) {
FeatureToggle feature = features.get(featureName);
if (feature != null) {
if (enabled != null) feature.setEnabled(enabled);
if (description != null) feature.setDescription(description);
featureService.updateFeature(feature);
}
}
@DeleteOperation
public void removeFeature(@Selector String featureName) {
features.remove(featureName);
featureService.removeFeature(featureName);
}
private void initializeFeatures() {
features.putAll(featureService.getAllFeatures());
}
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public class FeatureToggle {
private String name;
private boolean enabled;
private String description;
private Instant lastModified;
private String modifiedBy;
}
3.2 Cache Management Endpoint
@Component
@Endpoint(id = "caches")
public class CacheManagementEndpoint {
private final CacheManager cacheManager;
private final List<CacheManager> cacheManagers;
public CacheManagementEndpoint(List<CacheManager> cacheManagers) {
this.cacheManagers = cacheManagers;
this.cacheManager = cacheManagers.isEmpty() ? null : cacheManagers.get(0);
}
@ReadOperation
public Map<String, Object> getCaches() {
Map<String, Object> result = new HashMap<>();
for (CacheManager cm : cacheManagers) {
Map<String, Object> cacheInfo = new HashMap<>();
for (String cacheName : cm.getCacheNames()) {
Cache cache = cm.getCache(cacheName);
if (cache != null) {
cacheInfo.put(cacheName, getCacheStatistics(cache));
}
}
result.put(cm.getClass().getSimpleName(), cacheInfo);
}
return result;
}
@WriteOperation
public void evictCache(@Selector String cacheName) {
if (cacheManager != null) {
Cache cache = cacheManager.getCache(cacheName);
if (cache != null) {
cache.clear();
}
}
}
@WriteOperation
public void evictCacheKey(@Selector String cacheName, @Selector String key) {
if (cacheManager != null) {
Cache cache = cacheManager.getCache(cacheName);
if (cache != null) {
cache.evict(key);
}
}
}
private Map<String, Object> getCacheStatistics(Cache cache) {
if (cache.getNativeCache() instanceof com.github.benmanes.caffeine.cache.Cache) {
@SuppressWarnings("unchecked")
com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache =
(com.github.benmanes.caffeine.cache.Cache<Object, Object>) cache.getNativeCache();
CacheStats stats = caffeineCache.stats();
return Map.of(
"size", caffeineCache.estimatedSize(),
"hitCount", stats.hitCount(),
"missCount", stats.missCount(),
"hitRate", stats.hitRate(),
"evictionCount", stats.evictionCount()
);
}
return Map.of("type", cache.getNativeCache().getClass().getSimpleName());
}
}
4. Security và Access Control
4.1 Endpoint Security Configuration
@Configuration
@EnableWebSecurity
public class ActuatorSecurityConfig {
@Bean
public SecurityFilterChain actuatorFilterChain(HttpSecurity http) throws Exception {
http.securityMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests(authz -> authz
.requestMatchers(EndpointRequest.to("health", "info")).permitAll()
.requestMatchers(EndpointRequest.to("metrics", "prometheus")).hasRole("METRICS_READER")
.requestMatchers(EndpointRequest.toAnyEndpoint()).hasRole("ACTUATOR_ADMIN")
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
public UserDetailsService actuatorUserDetailsService() {
UserDetails metricsUser = User.builder()
.username("metrics")
.password("{bcrypt}$2a$10$...")
.roles("METRICS_READER")
.build();
UserDetails adminUser = User.builder()
.username("admin")
.password("{bcrypt}$2a$10$...")
.roles("ACTUATOR_ADMIN", "METRICS_READER")
.build();
return new InMemoryUserDetailsManager(metricsUser, adminUser);
}
}
4.2 Custom Authentication Provider
@Component
public class ActuatorAuthenticationProvider implements AuthenticationProvider {
private final ActuatorUserService userService;
private final PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
ActuatorUser user = userService.findByUsername(username);
if (user != null && passwordEncoder.matches(password, user.getPassword())) {
List<GrantedAuthority> authorities = user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());
return new UsernamePasswordAuthenticationToken(username, password, authorities);
}
throw new BadCredentialsException("Invalid credentials");
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
5. Advanced Metrics và Monitoring
5.1 Custom Metrics với Micrometer
@Service
public class OrderMetricsService {
private final MeterRegistry meterRegistry;
private final Counter orderCreatedCounter;
private final Counter orderProcessedCounter;
private final Timer orderProcessingTimer;
private final Gauge orderQueueSize;
private final DistributionSummary orderValueSummary;
private final AtomicInteger queueSize = new AtomicInteger(0);
public OrderMetricsService(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.orderCreatedCounter = Counter.builder("orders.created")
.description("Number of orders created")
.tag("type", "business")
.register(meterRegistry);
this.orderProcessedCounter = Counter.builder("orders.processed")
.description("Number of orders processed")
.register(meterRegistry);
this.orderProcessingTimer = Timer.builder("orders.processing.duration")
.description("Order processing duration")
.register(meterRegistry);
this.orderQueueSize = Gauge.builder("orders.queue.size")
.description("Current order queue size")
.register(meterRegistry, queueSize, AtomicInteger::get);
this.orderValueSummary = DistributionSummary.builder("orders.value")
.description("Order value distribution")
.baseUnit("currency")
.register(meterRegistry);
}
@EventListener
public void onOrderCreated(OrderCreatedEvent event) {
orderCreatedCounter.increment(
Tags.of(
"customer_type", event.getCustomerType(),
"product_category", event.getProductCategory()
)
);
orderValueSummary.record(event.getOrderValue().doubleValue());
queueSize.incrementAndGet();
}
@EventListener
public void onOrderProcessed(OrderProcessedEvent event) {
orderProcessedCounter.increment(
Tags.of(
"status", event.getStatus().name(),
"processing_type", event.getProcessingType()
)
);
queueSize.decrementAndGet();
}
@Timed(value = "orders.processing.duration", description = "Time taken to process order")
public void processOrder(Order order) {
// Order processing logic
}
}
5.2 Database Metrics
@Configuration
public class DatabaseMetricsConfig {
@Bean
public MeterBinder dataSourceMetrics(DataSource dataSource) {
return new DataSourcePoolMetrics(dataSource, "hikari", Tags.empty());
}
@Bean
public MeterBinder jpaMetrics() {
return new HibernateMetrics();
}
}
@Component
public class CustomDatabaseMetrics {
private final JdbcTemplate jdbcTemplate;
private final MeterRegistry meterRegistry;
public CustomDatabaseMetrics(JdbcTemplate jdbcTemplate, MeterRegistry meterRegistry) {
this.jdbcTemplate = jdbcTemplate;
this.meterRegistry = meterRegistry;
Gauge.builder("database.connections.active")
.register(meterRegistry, this, CustomDatabaseMetrics::getActiveConnections);
Gauge.builder("database.size.total")
.register(meterRegistry, this, CustomDatabaseMetrics::getDatabaseSize);
}
public double getActiveConnections() {
try {
return jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM information_schema.processlist WHERE db IS NOT NULL",
Double.class);
} catch (Exception e) {
return 0;
}
}
public double getDatabaseSize() {
try {
return jdbcTemplate.queryForObject(
"SELECT SUM(data_length + index_length) / 1024 / 1024 " +
"FROM information_schema.tables WHERE table_schema = DATABASE()",
Double.class);
} catch (Exception e) {
return 0;
}
}
}
5.3 Application Performance Metrics
@Component
public class ApplicationPerformanceMetrics {
private final MeterRegistry meterRegistry;
public ApplicationPerformanceMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
registerCustomMetrics();
}
private void registerCustomMetrics() {
// JVM Metrics
Gauge.builder("jvm.memory.heap.utilization")
.register(meterRegistry, this, ApplicationPerformanceMetrics::getHeapUtilization);
Gauge.builder("jvm.gc.pause.total")
.register(meterRegistry, this, ApplicationPerformanceMetrics::getTotalGcPause);
// Application Metrics
Timer.builder("application.startup.duration")
.register(meterRegistry);
Counter.builder("application.errors.total")
.register(meterRegistry);
}
public double getHeapUtilization() {
MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();
MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage();
return (double) heapUsage.getUsed() / heapUsage.getMax();
}
public double getTotalGcPause() {
return ManagementFactory.getGarbageCollectorMXBeans().stream()
.mapToLong(GarbageCollectorMXBean::getCollectionTime)
.sum();
}
}
6. Production Monitoring Integration
6.1 Prometheus Configuration
management:
metrics:
export:
prometheus:
enabled: true
step: 1m
descriptions: true
distribution:
percentiles-histogram:
http.server.requests: true
orders.processing.duration: true
percentiles:
http.server.requests: 0.5, 0.95, 0.99
orders.processing.duration: 0.5, 0.95, 0.99
slo:
http.server.requests: 100ms,200ms,400ms
6.2 Grafana Dashboard Configuration
@Configuration
public class GrafanaMetricsConfig {
@Bean
public MeterRegistryCustomizer<PrometheusMeterRegistry> configureMetricsRegistry() {
return registry -> {
registry.config()
.commonTags(
"application", "my-spring-boot-app",
"instance", getInstanceId(),
"environment", getEnvironment()
)
.meterFilter(MeterFilter.deny(id -> {
String name = id.getName();
return name.startsWith("jvm.gc") && name.contains("memory.promoted");
}))
.meterFilter(MeterFilter.denyNameStartsWith("tomcat.sessions"));
};
}
private String getInstanceId() {
try {
return InetAddress.getLocalHost().getHostName();
} catch (UnknownHostException e) {
return "unknown";
}
}
private String getEnvironment() {
return System.getProperty("spring.profiles.active", "default");
}
}
6.3 Alerting Integration
@Component
public class AlertingMetrics {
private final MeterRegistry meterRegistry;
private final NotificationService notificationService;
public AlertingMetrics(MeterRegistry meterRegistry,
NotificationService notificationService) {
this.meterRegistry = meterRegistry;
this.notificationService = notificationService;
setupAlerts();
}
private void setupAlerts() {
// High error rate alert
meterRegistry.more().counter("application.errors.total", Tags.of("severity", "high"))
.threshold(10)
.whenExceeded(this::sendHighErrorRateAlert);
// Memory usage alert
Gauge.builder("jvm.memory.heap.utilization")
.register(meterRegistry, this, AlertingMetrics::getHeapUtilization)
.threshold(0.85)
.whenExceeded(this::sendMemoryAlert);
}
private void sendHighErrorRateAlert() {
notificationService.sendAlert(
"High Error Rate",
"Application error rate exceeded threshold"
);
}
private void sendMemoryAlert() {
notificationService.sendAlert(
"High Memory Usage",
"JVM heap utilization exceeded 85%"
);
}
public double getHeapUtilization() {
MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();
MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage();
return (double) heapUsage.getUsed() / heapUsage.getMax();
}
}
7. Performance Optimization
7.1 Endpoint Performance Tuning
@Configuration
public class ActuatorPerformanceConfig {
@Bean
@ConditionalOnProperty(name = "management.endpoints.web.cache.enabled", havingValue = "true")
public CacheManager actuatorCacheManager() {
return CacheManagerBuilder.newCacheManagerBuilder()
.withCache("health-cache",
CacheConfigurationBuilder.newCacheConfigurationBuilder(
String.class, Health.class,
ResourcePoolsBuilder.heap(100))
.withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofSeconds(30))))
.withCache("metrics-cache",
CacheConfigurationBuilder.newCacheConfigurationBuilder(
String.class, Object.class,
ResourcePoolsBuilder.heap(1000))
.withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofSeconds(10))))
.build(true);
}
@Bean
public WebMvcConfigurer actuatorCorsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/actuator/**")
.allowedOrigins("*")
.allowedMethods("GET", "POST")
.maxAge(3600);
}
};
}
}
@Component
public class CachedHealthIndicator implements HealthIndicator {
private final ExternalService externalService;
@Cacheable(value = "health-cache", key = "'external-service'")
@Override
public Health health() {
try {
externalService.healthCheck();
return Health.up()
.withDetail("external-service", "Available")
.build();
} catch (Exception e) {
return Health.down()
.withDetail("external-service", "Unavailable")
.withException(e)
.build();
}
}
}
7.2 Async Metrics Collection
@Configuration
@EnableAsync
public class AsyncMetricsConfig {
@Bean
public TaskExecutor metricsTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("metrics-");
executor.initialize();
return executor;
}
}
@Service
public class AsyncMetricsCollector {
private final MeterRegistry meterRegistry;
@Async("metricsTaskExecutor")
public CompletableFuture<Void> collectBusinessMetrics() {
// Expensive metrics collection
Timer.Sample sample = Timer.start(meterRegistry);
try {
// Collect metrics from external sources
collectExternalMetrics();
} finally {
sample.stop(Timer.builder("metrics.collection.duration")
.tag("type", "business")
.register(meterRegistry));
}
return CompletableFuture.completedFuture(null);
}
private void collectExternalMetrics() {
// Implementation for collecting external metrics
}
}
8. Testing Actuator Endpoints
8.1 Integration Testing
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(properties = {
"management.endpoints.web.exposure.include=health,info,metrics",
"management.endpoint.health.show-details=always"
})
class ActuatorIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@LocalServerPort
private int port;
@Test
void healthEndpointShouldReturnUp() {
ResponseEntity<Map> response = restTemplate.getForEntity(
"http://localhost:" + port + "/actuator/health",
Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody().get("status")).isEqualTo("UP");
}
@Test
void metricsEndpointShouldReturnMetrics() {
ResponseEntity<Map> response = restTemplate.getForEntity(
"http://localhost:" + port + "/actuator/metrics",
Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsKey("names");
}
@Test
void customEndpointShouldBeAvailable() {
ResponseEntity<Map> response = restTemplate.getForEntity(
"http://localhost:" + port + "/actuator/features",
Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
8.2 Security Testing
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestDatabase
class ActuatorSecurityTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void healthEndpointShouldBePublic() {
ResponseEntity<String> response = restTemplate.getForEntity(
"/actuator/health", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void metricsEndpointShouldRequireAuthentication() {
ResponseEntity<String> response = restTemplate.getForEntity(
"/actuator/metrics", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void metricsEndpointShouldAllowAuthenticatedAccess() {
ResponseEntity<String> response = restTemplate
.withBasicAuth("metrics", "password")
.getForEntity("/actuator/metrics", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
9. Best Practices
9.1 Production Checklist
# Production configuration
management:
endpoints:
web:
exposure:
# Only expose necessary endpoints
include: health,info,metrics,prometheus
exclude: shutdown,env,configprops
base-path: /actuator
endpoint:
health:
# Don't show sensitive details in production
show-details: when-authorized
info:
enabled: true
# Use different port for management endpoints
server:
port: 8081
address: 127.0.0.1
# Security
security:
enabled: true
roles: ACTUATOR_ADMIN
9.2 Monitoring Strategy
@Configuration
public class MonitoringStrategy {
@Bean
public MeterRegistryCustomizer<MeterRegistry> monitoringCustomizer() {
return registry -> {
// Common tags for all metrics
registry.config()
.commonTags(
"application", "my-app",
"environment", getEnvironment(),
"region", getRegion()
)
// Filter out noisy metrics
.meterFilter(MeterFilter.deny(id ->
id.getName().startsWith("tomcat.sessions")))
// Rename metrics for consistency
.meterFilter(MeterFilter.renameTag("jvm.memory.used", "area", "heap", "heap"))
// Set distribution summaries
.meterFilter(MeterFilter.maximumExpectedValue("http.server.requests",
Duration.ofSeconds(10)));
};
}
private String getEnvironment() {
return System.getProperty("spring.profiles.active", "default");
}
private String getRegion() {
return System.getProperty("aws.region", "us-east-1");
}
}