All articles

// Knowledge.log — 技術記事

Spring Boot Actuator in production: what is worth exposing

Configure Spring Boot Actuator endpoints, health checks, readiness, security, and metrics without exposing sensitive production data.

A UP response from /actuator/health proves only that the indicators in that group are healthy. It does not prove that the application can consume the queue, serve requests through its main connector, or call an external dependency.

That detail matters because the default health check can remain green while the orders queue accumulates messages with no consumer. The answer is not to publish every endpoint and search for an explanation in a panic. It is to separate access, exposure, liveness, readiness, and metrics, each with a verifiable responsibility.

The baseline here is Java 25 LTS with Spring Boot 4.1.0. This combination is within the official Spring Boot requirements matrix: version 4.1.0 requires Java 17 or later and supports up to Java 26. Spring Boot 3.5.16, compatible with Java 17 through 25, appears only as migration context; the examples use the Boot 4 API.

Access and exposure solve different problems

In Actuator, an endpoint must pass through two decisions:

  • Access determines whether it can exist as unrestricted, read-only, or none, through management.endpoint.<id>.access. With none, the endpoint is removed from the context.
  • Exposure determines whether an existing endpoint will be published over HTTP or JMX, through management.endpoints.web.exposure and management.endpoints.jmx.exposure. If an id is in both include and exclude, exclude wins.

By default, only health is exposed over HTTP and JMX. Over HTTP, that makes /actuator/health available; /actuator serves as the discovery route for exposed endpoints.

So granting access does not automatically publish an endpoint. And adding an endpoint to include does not override none access. The wildcard is still a configuration strategy; it just is not usually a production strategy.

A conservative selection for production is:

EndpointDecisionReason
healthExposeFeeds probes and allows health groups to be checked.
infoExposeProvides operational information selected by the application.
metricsExpose with authenticationLets you explore metrics as JSON; it is not the scrape route.
prometheusExpose to the monitoring networkProvides the scrape format and requires the Prometheus registry.
loggersOptional and read-onlyHelps with diagnosis without allowing changes via POST.
heapdump, env, configprops, threaddumpDo not exposeThey can reveal the heap, configuration, values, or stack traces.
shutdown, beans, mappings, conditions, httpexchanges, logfile, sessionsDo not exposeThey unnecessarily expand the operational attack surface.

heapdump already has restricted access by default in Boot 4.1, as does shutdown. It is still worth setting it to none to record the intent. For env and configprops, keep show-values=never; default masking is not an invitation to publish the endpoint.

A production configuration with defense in depth

The dependencies below assume a project managed by the Spring Boot 4.1.0 parent or BOM:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>
</dependencies>

Separate management traffic onto a private port and allow only the required endpoints:

management:
  server:
    port: 8081
  endpoints:
    access:
      max-permitted: read-only
    web:
      exposure:
        include: health,info,metrics,prometheus
  endpoint:
    health:
      show-details: when-authorized
      roles: ENDPOINT_ADMIN
      probes:
        add-additional-paths: true
      group:
        readiness:
          include: readinessState,rabbit,ordersQueue
    env:
      show-values: never
    heapdump:
      access: none

The max-permitted: read-only ceiling takes precedence over broader permissions. It also prevents a future addition of loggers from accidentally enabling writes. If the team truly needs to change loggers at runtime, it must handle authentication and CSRF deliberately; disabling CSRF globally because a POST returned 403 merely trades one diagnosis for another problem.

With Spring Security on the classpath and no custom SecurityFilterChain, Boot protects every Actuator endpoint except health. When the application declares a chain, that auto-configuration backs off. In that case, create two chains: one for the application and another for the selected endpoints using EndpointRequest.toAnyEndpoint(), HTTP Basic, and the ENDPOINT_ADMIN authority. In Boot 4, the matcher is in org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest.

Allow unauthenticated access only to the probe routes the kubelet needs. Port 8081 should remain reachable only from the operational network or the cluster, not from the application's public ingress.

A green RabbitMQ health check does not measure the queue

In Boot 4, custom indicators implement org.springframework.boot.health.contributor.HealthIndicator. That package change matters for teams coming from Boot 3.5, where the interface lived under org.springframework.boot.actuate.health.

The automatic RabbitMQ indicator also deserves a precise reading. The Spring Boot 4.1.0 RabbitHealthIndicator opens an operation through RabbitTemplate and reads the broker's version property. If the broker responds, the component is UP.

It does not check the number of messages or consumers. So a reachable broker, zero consumers, and a queue stalled for 20 minutes still produce a green rabbit. It is a polite shade of green, but not especially useful to the order that is still waiting.

To check the operational condition of the orders queue, add a specific indicator:

package academy.devdojo.orders;

import org.springframework.amqp.core.AmqpAdmin;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.boot.health.contributor.Health;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.stereotype.Component;

@Component
public class OrdersQueueHealthIndicator implements HealthIndicator {

    private static final int MAX_PENDING_MESSAGES = 1_000;

    private final AmqpAdmin amqpAdmin;

    public OrdersQueueHealthIndicator(AmqpAdmin amqpAdmin) {
        this.amqpAdmin = amqpAdmin;
    }

    @Override
    public Health health() {
        var properties = this.amqpAdmin.getQueueProperties("orders");

        if (properties == null) {
            return Health.down()
                    .withDetail("queue", "orders")
                    .withDetail("reason", "missing")
                    .build();
        }

        int messages = (Integer) properties.get(RabbitAdmin.QUEUE_MESSAGE_COUNT);
        int consumers = (Integer) properties.get(RabbitAdmin.QUEUE_CONSUMER_COUNT);

        var health = Health.up()
                .withDetail("messages", messages)
                .withDetail("consumers", consumers);

        return (consumers == 0 || messages > MAX_PENDING_MESSAGES)
                ? health.down().build()
                : health.build();
    }
}

The class name without the HealthIndicator suffix becomes the ordersQueue id used in the YAML readiness group. The threshold of one thousand messages is an explicit choice for the example, not a universal recommendation; adjust it to the acceptable behavior of the real queue.

The concrete remediation when this indicator reports zero consumers is to restore the responsible listener and then drain the queue. If the queue does not exist, fix its declaration or configured name. During diagnosis, query /actuator/health/readiness with an authorized credential and monitor the messages and consumers fields; then confirm that the group has returned to UP. This observes the problem through the private port without publishing health details on the internet.

For a downstream API, create another HealthIndicator, make a short GET with RestClient, use a timeout in the 200 to 300 ms range, and return Health.down(ex) on an I/O failure. Only then add that indicator's id—and, if a DataSource exists, the JDBC starter's automatic db indicator—to the readiness include. The YAML above lists only what the snippet actually registers because an unknown group member prevents startup. Actuator does not automatically create a health indicator for HTTP clients.

Liveness is not readiness by another name

In Boot 4, the liveness and readiness groups are enabled by default. They are available at:

  • /actuator/health/liveness
  • /actuator/health/readiness

The default groups do not include a database, RabbitMQ, or custom indicators. The separation is intentional:

  • Liveness answers whether the local process needs to be restarted. Do not include a database, external API, cache, or queue. An external failure in liveness makes the kubelet restart healthy replicas and can turn a shared outage into a restart storm.
  • Readiness answers whether the replica should receive traffic. This is where rabbit, ordersQueue, and—once they exist in the context—db and an HTTP indicator can belong. Accept the consequence: if a shared dependency fails, every replica may become unready and remove the entire service from load balancing.

DOWN and OUT_OF_SERVICE indicators result in HTTP 503; UP and UNKNOWN result in HTTP 200. For the kubelet, responses from 200 through 399 are successful.

A separate management port introduces another subtlety: it may respond while the main connector is broken. With add-additional-paths: true, Boot publishes /livez and /readyz on the main port. The probes can test the path the application actually uses:

startupProbe:
  httpGet:
    path: /livez
    port: 8080

livenessProbe:
  httpGet:
    path: /livez
    port: 8080

readinessProbe:
  httpGet:
    path: /readyz
    port: 8080

The startupProbe prevents a long startup from being mistaken for a dead process. Meanwhile, readiness holds back traffic. Kubernetes and OpenShift use the same probe model; there is no need to invent a special route for OpenShift.

If an indicator becomes slow, Actuator logs a warning after 10 seconds by default. The threshold can be adjusted with management.endpoint.health.logging.slow-indicator-threshold, but a downstream health check should remain short. A probe is not the place to run a complete audit of the distributed chain.

Metrics answer questions that health checks do not

Health is a discrete state for automation. Trends, volume, and latency belong to metrics and observability with Micrometer Observation.

With micrometer-registry-prometheus in the project and prometheus included in exposure, the scrape endpoint is /actuator/prometheus. Both conditions are required; the endpoint is not exposed by default. /actuator/metrics, meanwhile, is for exploring measurements in JSON, not for Prometheus scraping.

A minimal Prometheus configuration points to the private management port:

scrape_configs:
  - job_name: devdojo-orders
    metrics_path: /actuator/prometheus
    static_configs:
      - targets: ["orders-service:8081"]

Available metrics include http.server.requests, http.client.requests, the jvm.* families, process, system, disk, uptime, application.started.time, application.ready.time, logger events, and executor pools. For JVM virtual-thread statistics, add micrometer-java21.

Prometheus-compatible histograms can be enabled with management.metrics.distribution.percentiles-histogram; percentiles calculated in-process use .percentiles, and application-defined bucket boundaries use .slo. The documentation does not provide a ready-made p95, p99, or SLO for the service. Those values only make sense after observing the actual load and dependencies, so no number is assumed here.

How to verify before release

With the application running, perform the checks from the network that can reach the management port:

curl -i http://localhost:8081/actuator
curl -i http://localhost:8081/actuator/health
curl -i http://localhost:8081/actuator/health/liveness
curl -i http://localhost:8081/actuator/health/readiness
curl -i -u actuator-admin:SENHA http://localhost:8081/actuator/health
curl -i -u actuator-admin:SENHA http://localhost:8081/actuator/prometheus
curl -i http://localhost:8080/actuator/env

Discovery should list only what was exposed. The unauthenticated health response should not reveal components; the authorized call can show details according to the configured role. Prometheus should return text format after authentication. The public attempt to reach /actuator/env on port 8080 should return 404.

To reproduce the queue's false green without simulating load, keep only the automatic rabbit indicator, stop the listener, and publish messages. The broker will remain reachable and the rabbit component will stay UP. Then register OrdersQueueHealthIndicator, include ordersQueue in readiness, and repeat: zero consumers or more than one thousand messages should take the group to DOWN and return 503.

Final recommendation

In production, expose health, info, metrics, and prometheus only on a private port; add loggers only when there is an operational need, authentication, and read-only access. Keep dumps, configuration, and administrative endpoints out of exposure, and limit access as a second barrier.

Use liveness only for unrecoverable local failures, and move database, queue, and downstream HTTP checks into readiness when removing the replica from traffic is truly the right response. If a shared outage would leave every replica unready, do not add the dependency to the group by reflex: handle degradation higher up with timeouts and circuit breaking, and observe it through metrics.

For the queue scenario, do not accept RabbitHealthIndicator as proof that messages are being consumed. Adopt a queue indicator in readiness when backlog or the absence of consumers means the replica cannot perform its function; otherwise, keep the signal in metrics and alerts without turning every operational fluctuation into a container restart.

javaspring-bootactuator

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles