El mòdul 6 va acabar amb una afirmació i una mancança. L'afirmació: CicloUrbana és correcta, i ./mvnw verify ho demostra. La mancança: no és operable. Ningú no li pot preguntar si està sana abans d'enviar-li trànsit, ni si la base de dades respon, ni quina versió hi ha desplegada al servidor de l'ajuntament de Ribalta. Quan l'aplicació es queda penjada a les tres de la matinada, l'única eina disponible és reiniciar-la i esperar.

Aquesta lliçó tapa aquesta mancança amb Spring Boot Actuator, el mòdul que obre l'aplicació per dins i exposa el seu estat a través d'HTTP i JMX. Veurem quins endpoints porta, quins convé exposar i quins no, com assegurar-los amb la cadena de seguretat que ja vam escriure a 05-02, com funciona de debò /actuator/health —incloses les sondes de disponibilitat que Kubernetes necessitarà a 08-04—, com escriure indicadors de salut i endpoints propis per a la xarxa de Ribalta, i com saber, amb una sola petició, quin commit exacte s'està executant en producció.

Contingut

  1. Què significa que una aplicació sigui operable
  2. Afegir Actuator i què apareix immediatament
  3. El catàleg complet d'endpoints
  4. Exposició i descobriment
  5. Port de gestió separat
  6. Assegurar Actuator amb Spring Security
  7. /actuator/health a fons
  8. Grups de salut i sondes de disponibilitat
  9. Un HealthIndicator propi: SalutXarxaEstacions
  10. Canviar la disponibilitat amb AvailabilityChangeEvent
  11. /actuator/info: quina versió hi ha desplegada
  12. /actuator/loggers: canviar el nivell de log en calent
  13. mappings, configprops, env i els secrets
  14. Un endpoint propi amb @Endpoint
  15. Actuator sobre JMX
  16. Errors Comuns i Consells
  17. Exercicis

  1. Què significa que una aplicació sigui operable

Una aplicació correcta fa el que promet. Una aplicació operable, a més, pot ser gestionada per algú que no n'és l'autor, a mitja nit, sense llegir el codi. Són dues propietats independents: un sistema pot ser impecable per dins i un forat negre per fora. La diferència es veu en preguntes concretes que l'equip d'operacions de l'ajuntament de Ribalta farà tard o d'hora:

Pregunta operativa Sense Actuator Amb Actuator
Ja li puc enviar trànsit? Provar un endpoint real i veure si contesta GET /actuator/health/readiness
És viva o cal reiniciar-la? Mirar si el procés existeix (que existeix encara que estigui penjada) GET /actuator/health/liveness
Respon la base de dades? Llegir el log i buscar excepcions GET /actuator/health amb detalls
Quina versió hi ha desplegada? Preguntar a qui va desplegar GET /actuator/info
Necessito logs DEBUG d'un paquet, ja Canviar el YAML, reconstruir i reiniciar POST /actuator/loggers/com.ciclourbana
S'està executant la tasca nocturna? Buscar al log GET /actuator/scheduledtasks

Totes aquestes respostes ja existeixen dins del procés: Spring coneix els seus beans, la seva configuració, les seves rutes i l'estat del seu pool de connexions. Actuator no les calcula, les publica. Aquesta és tota la seva feina, i per això afegir-lo costa una dependència.

  1. Afegir Actuator i què apareix immediatament

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

Sense versió, perquè la governa el spring-boot-starter-parent (01-04). En arrencar, el log mostra una línia nova:

Exposing 1 endpoint(s) beneath base path '/actuator'

Un endpoint, no pas quinze. Actuator registra internament tots els seus, però només exposa health per HTTP de manera predeterminada. La raó és de seguretat i és una lliçó apresa a cops: a Spring Boot 1.x s'exposava gairebé tot, i hi va haver una època en què buscar /env a Internet retornava credencials de bases de dades d'empreses reals. Des de Boot 2 la política és la inversa: no surt res tret que ho demanis pel seu nom.

L'arrel /actuator retorna un índex HAL amb enllaços al que està exposat, útil per descobrir què hi ha disponible sense conèixer els noms de memòria. I la salut respon escarransida a propòsit:

curl -s http://localhost:8080/actuator/health
# {"status":"UP"}

L'estat agregat és informació pública tolerable; el desglossament no ho és, i per això està amagat per defecte. A l'apartat 7 l'obrim.

  1. El catàleg complet d'endpoints

Aquesta és la taula de referència de la lliçó. La columna de risc és la que decideix què s'exposa a Ribalta.

Endpoint Què exposa Risc si es filtra Exposar?
health Estat agregat i, opcionalment, el de cada component Baix sense detalls; mitjà amb ells (revela motor de BD, rutes de disc) Sí, sense detalls en públic
info Dades arbitràries: versió, commit, data de compilació Baix, si no hi poses res sensible Sí
metrics Mètriques de Micrometer (memòria, fils, peticions); s'explota a 09-03 Mitjà: revela volum de trànsit i capacitat Només autenticat
env Tot l'entorn: propietats, variables, arguments Molt alt: contrasenyes, secrets JWT, claus d'API Només ADMIN, amb valors sanititzats
beans Tots els beans del context amb els seus tipus i dependències Mitjà: mapa complet de l'arquitectura interna Només ADMIN
configprops Els @ConfigurationProperties de 02-05 amb els seus valors Alt: mateix problema que env Només ADMIN, sanititzat
mappings Totes les rutes registrades i a quin mètode van Mitjà: inventari de l'API, incloses rutes internes Només ADMIN
loggers Llegeix i modifica nivells de log en calent Alt: escriptura; un atacant pot omplir el disc Només ADMIN
threaddump Bolcat de tots els fils amb les seves piles Alt: noms de classes, i de vegades dades en variables Només ADMIN
heapdump Descarrega un .hprof amb tota la memòria Crític: conté tokens, contrasenyes en clar, dades personals Gairebé mai; puntualment i per túnel
scheduledtasks Tasques @Scheduled registrades i la seva periodicitat (07-03) Baix Només ADMIN
flyway Migracions aplicades i versió de l'esquema (04-08) Baix-mitjà Només ADMIN
caches Memòries cau registrades; permet buidar-les (09-02) Mitjà: escriptura Només ADMIN
shutdown Atura l'aplicació Crític Gairebé mai; desactivat per defecte
httpexchanges Últimes peticions i respostes HTTP en memòria Alt: capçaleres amb tokens d'altres usuaris Només ADMIN, i requereix un bean explícit

Tres observacions sobre la taula. La primera: shutdown és l'únic endpoint desactivat per defecte, no només no exposat —cal habilitar-lo amb management.endpoint.shutdown.enabled: true— perquè el seu efecte és irreversible. La segona: heapdump és el més perillós de tots i el que menys ho sembla; un bolcat de memòria de CicloUrbana conté els JWT de les sessions vives, les dades personals dels ciutadans i el secret de signatura. La tercera: httpexchanges no funciona només exposant-lo; necessita a més un bean HttpExchangeRepository, precisament perquè ningú no l'activi sense saber què està desant.

  1. Exposició i descobriment

Tres propietats governen què es veu i on:

management:
  endpoints:
    web:
      base-path: /actuator          # prefix de totes les rutes de gestió
      exposure:
        include: health,info,metrics,loggers,mappings,configprops,flyway,scheduledtasks
        exclude: env,heapdump,threaddump
Propietat Efecte
exposure.include Llista blanca d'endpoints publicats per HTTP; * els publica tots
exposure.exclude Llista negra que guanya sempre sobre include, fins i tot sobre *
base-path Canvia el prefix de totes les rutes (/gestio, /intern)
management.endpoint.<id>.enabled Activa o desactiva l'endpoint del tot, també a JMX

Distingir enabled d'exposure és important. Un endpoint deshabilitat no existeix: ni per HTTP, ni per JMX, ni com a bean. Un d'habilitat però no exposat existeix i funciona, però no té ruta HTTP. La combinació habitual en producció és habilitat + exposat + protegit, no pas deshabilitat, perquè els indicadors de salut se segueixen necessitant internament.

Sobre include: "*": les cometes són obligatòries a YAML —l'asterisc nu és un àlies— i, sobretot, és una decisió acceptable només en desenvolupament. Escriure la llista explícita costa trenta segons i elimina la mena d'accident que consisteix que una actualització de Spring Boot exposi un endpoint que no existia quan vas escriure la configuració. A 07-02 aquesta llista serà diferent per perfil: generosa a dev, mínima a prod.

  1. Port de gestió separat

management:
  server:
    port: 8081
    base-path: /actuator

Amb això, l'API de Ribalta continua al 8080 i tota la gestió passa a http://localhost:8081/actuator/.... És una pràctica recomanada per una raó que no és criptogràfica sinó topològica: permet que el tallafocs, i no l'aplicació, decideixi qui parla amb la gestió.

graph LR
    C[Ciutadans<br/>Internet] -->|:8080| LB[Balancejador]
    LB -->|:8080 /api/v1| APP[CicloUrbana]
    OPS[Xarxa interna<br/>operacions] -->|:8081 /actuator| APP
    K8S[Sondes de<br/>l'orquestrador] -->|:8081 /actuator/health| APP
Aspecte Port únic (8080) Port de gestió (8081)
Aïllament a la xarxa Cap: la gestió viatja pel mateix port públic El port es tanca a l'exterior al tallafocs
Risc d'error de configuració Una fallada a la cadena de seguretat exposa Actuator a Internet Encara que la cadena falli, el port no és accessible
Sondes de l'orquestrador Comparteixen port —i pool de fils— amb el trànsit Connector independent: continuen responent sota saturació

Aquest últim punt és més útil del que sembla: si el pool de fils que atén el 8080 està exhaurit, la sonda de salut que viatja pel 8080 també es queda esperant i l'orquestrador conclou que l'aplicació és morta. Advertiment: en canviar management.server.port, la cadena de seguretat de l'API deixa d'aplicar-se a Actuator, perquè són ports diferents. Continua calent autenticació —l'apartat següent— i cal fer servir EndpointRequest, que sap resoldre el port de gestió, en lloc d'escriure la ruta a mà.

  1. Assegurar Actuator amb Spring Security

A 05-02 vam deixar escrita una cadena a part amb @Order(1) i securityMatcher("/actuator/**"). Aquella versió funcionava, però té dos defectes: la ruta està escrita a mà —si algú canvia base-path, la seguretat deixa d'aplicar-se en silenci— i no contempla el port de gestió. Actuator porta un RequestMatcher propi que resol tots dos:

package com.ciclourbana.seguretat;

import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.actuate.info.InfoEndpoint;   // + anotacions de Spring Security

@Configuration
public class ConfiguracioSeguretatActuator {

    @Bean
    @Order(1)
    SecurityFilterChain cadenaActuator(HttpSecurity http) throws Exception {
        http.securityMatcher(EndpointRequest.toAnyEndpoint())
            .authorizeHttpRequests(auth -> auth
                // Salut i info: públics, els consulten el balancejador i l'orquestrador
                .requestMatchers(EndpointRequest.to(HealthEndpoint.class, InfoEndpoint.class))
                    .permitAll()
                // Tota la resta: només ADMIN
                .anyRequest().hasRole("ADMIN"))
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}

Les decisions, una a una:

  • EndpointRequest.toAnyEndpoint() coincideix amb tots els endpoints exposats, sigui quin sigui el base-path i estigui al port que estigui. És la diferència entre una regla que es manté sola i una que caduca en silenci.
  • EndpointRequest.to(HealthEndpoint.class, InfoEndpoint.class) identifica endpoints per la seva classe, no per la seva ruta. Existeix la variant per identificador, EndpointRequest.to("health", "info"), útil per a endpoints propis com el xarxa de l'apartat 14.
  • anyRequest().hasRole("ADMIN") tanca la resta. Dins d'aquesta cadena anyRequest() significa «qualsevol endpoint d'Actuator», perquè el securityMatcher ja ha acotat l'àmbit.
  • httpBasic en lloc de JWT, deliberadament: les eines d'operacions —Prometheus a 09-04, un curl de guàrdia, el sistema d'alertes— no saben demanar un token a /api/v1/auth/login ni renovar-lo. Un usuari tècnic amb ADMIN i contrasenya llarga, per un port que no surt de la xarxa interna i sobre TLS, és més operable i no menys segur. L'alternativa madura és mTLS entre el sistema de monitoratge i l'aplicació.
  • STATELESS i CSRF desactivat, per coherència amb la resta del projecte (05-02): no hi ha sessió ni formularis.

El RoleHierarchy de 05-03 continua vigent, així que un ADMIN cobreix també allò exigible a OPERARI. I el permitAll sobre salut és revisable: si la xarxa ho permet, tancar també health i deixar públiques només les sondes per grup (apartat 8) encara és millor.

  1. /actuator/health a fons

health no és una comprovació, és una agregació: Spring recull tots els beans que implementen HealthIndicator, pregunta a cadascun i combina els resultats.

management:
  endpoint:
    health:
      show-details: when-authorized      # never | when-authorized | always
      show-components: when-authorized
      roles: ADMIN
  health:
    diskspace:
      enabled: false                     # sorollós en contenidors amb disc efímer
Valor de show-details Qui veu el desglossament
never (per defecte) Ningú: només {"status":"UP"}
when-authorized Només usuaris autenticats amb algun dels rols de roles
always Tothom, inclosos els anònims

Amb when-authorized i credencials d'ADMIN, la resposta canvia:

{
  "status": "UP",
  "components": {
    "db":        { "status": "UP", "details": { "database": "PostgreSQL", "validationQuery": "isValid()" } },
    "diskSpace": { "status": "UP", "details": { "total": 494384795648, "free": 91234567890 } },
    "xarxaEstacions": { "status": "UP", "details": { "estacions": 4, "bicicletesDisponibles": 37 } }
  }
}

Els indicadors automàtics apareixen sols segons el que hi hagi al classpath: db amb qualsevol DataSource (demana una connexió i executa isValid()), diskSpace sempre (espai lliure per damunt d'un llindar de 10 MB), ping sempre (respon UP si l'aplicació atén), ssl des de Boot 3.4 (certificats a punt de caducar) i un per cada starter d'infraestructura present: mail, redis, rabbit, kafka, elasticsearch. Cadascun s'apaga amb management.health.<nom>.enabled: false.

Les regles d'agregació són la part que dona més sorpreses: UP produeix 200, DOWN i OUT_OF_SERVICE produeixen 503, i UNKNOWN produeix 200. L'estat global és el pitjor de tots segons un ordre configurable, i qualsevol DOWN tomba el conjunt. D'aquí surt el parany més comú d'aquesta lliçó: un indicador d'un sistema no crític pot treure l'aplicació de producció. Si CicloUrbana declara un indicador per a la passarel·la de pagaments de Ribalta i la passarel·la cau, /actuator/health retorna 503, el balancejador retira la instància i la xarxa sencera deixa de llogar bicicletes per un problema que només afectava el cobrament. La solució no és treure l'indicador, sinó treure'l del grup que mira el balancejador.

  1. Grups de salut i sondes de disponibilitat

Un grup és un subconjunt d'indicadors amb la seva pròpia URL i la seva pròpia política:

management:
  endpoint:
    health:
      probes:
        enabled: true
      group:
        readiness:
          include: db
          show-details: always
        liveness:
          include: livenessState

Ara existeixen /actuator/health/readiness i /actuator/health/liveness, cadascuna agregant només el que li pertoca. La passarel·la de pagaments de l'exemple anterior pot continuar apareixent a /actuator/health complet —on la veu l'equip— sense estar a readiness, que és el que mira el balancejador.

Les sondes de disponibilitat són la peça que caldrà a 07-04 i 08-04. Amb probes.enabled: true —i automàticament quan Spring detecta que s'executa a Kubernetes— el framework gestiona dos estats propis:

Sonda Pregunta Si falla, l'orquestrador... Causes típiques
Liveness El procés està sa o és irrecuperable? Mata i reinicia el contenidor Estat intern corrupte, interbloqueig total, OutOfMemoryError
Readiness Pot atendre peticions ara? Deixa d'enviar-li trànsit, sense matar-lo Arrencant, escalfant memòries cau, dependència crítica caiguda, manteniment
sequenceDiagram
    participant K as Orquestrador
    participant A as CicloUrbana
    K->>A: GET /actuator/health/liveness
    A-->>K: 503 (encara no hi ha servidor)
    Note over A: Context llest · ApplicationReadyEvent
    K->>A: GET /actuator/health/liveness
    A-->>K: 200 UP (procés sa)
    K->>A: GET /actuator/health/readiness
    A-->>K: 503 (Flyway encara migrant)
    K->>A: GET /actuator/health/readiness
    A-->>K: 200 UP
    K->>K: Afegeix la instància al balancejador

La distinció és crítica i s'erra constantment. Si poses la base de dades a liveness, una caiguda de PostgreSQL de trenta segons farà que l'orquestrador mati totes les instàncies de CicloUrbana, que en reiniciar tampoc no trobaran la base de dades, i entraran en un bucle de reinicis que converteix una incidència de la base de dades en una caiguda total. La regla és senzilla: a liveness només hi va allò que s'arregla reiniciant; a readiness hi va tot allò que impedeix atendre bé. Una dependència externa caiguda no s'arregla mai reiniciant.

  1. Un HealthIndicator propi: SalutXarxaEstacions

La salut de CicloUrbana no és només tècnica. Si les quatre estacions de Ribalta estan sense bicicletes disponibles, l'aplicació respon perfectament i el servei, a la pràctica, no existeix. Això és un indicador de negoci:

package com.ciclourbana.estacions;

import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;

@Component("xarxaEstacions")
public class SalutXarxaEstacions implements HealthIndicator {

    private final EstacioRepositori estacioRepositori;
    private final BicicletaRepositori bicicletaRepositori;

    // constructor omès

    @Override
    public Health health() {
        try {
            long estacions   = estacioRepositori.count();
            long disponibles = bicicletaRepositori.comptarPerEstat(EstatBicicleta.DISPONIBLE);
            long ambBicis    = estacioRepositori.comptarAmbBicicletesDisponibles();

            Health.Builder salut = disponibles == 0
                    ? Health.down().withDetail("motiu", "cap bicicleta disponible a la xarxa")
                    : Health.up();

            return salut.withDetail("estacions", estacions)
                        .withDetail("estacionsAmbBicicletes", ambBicis)
                        .withDetail("bicicletesDisponibles", disponibles)
                        .build();

        } catch (Exception e) {
            return Health.down(e).build();
        }
    }
}

Quatre detalls que separen un indicador útil d'un de nociu:

  • El nom del bean és el nom del component. @Component("xarxaEstacions") produeix aquesta clau al JSON. Sense ell, Spring el deriva de la classe llevant el sufix HealthIndicator; anomenar-lo evita que un canvi de nom trenqui la configuració del grup readiness.
  • No deixis escapar mai una excepció. Un indicador que llança produeix un DOWN amb el missatge de l'excepció dins del JSON, i aquest missatge pot contenir la URL de la base de dades amb l'usuari.
  • Ha de ser ràpid. S'executa a cada sonda, cada tres o cinc segons, a cada instància. Una consulta que recorri l'històric de lloguers converteix la sonda en un problema de rendiment; si el càlcul és car, posa'l a la memòria cau (09-02) o llegeix-lo del RegistreLloguersActius en memòria.
  • Pensa a quin grup pertany. xarxaEstacions a readiness significa: si Ribalta es queda sense bicicletes, el balancejador retira la instància. Gairebé segur que no és el que vols —el ciutadà ha de poder consultar estacions encara que estiguin buides—, així que encaixa millor a /actuator/health general i en una alerta. És l'exemple perfecte de per què la pregunta «a quin grup va?» importa més que el codi mateix.

Variants: AbstractHealthIndicator estalvia el try/catch sobreescrivint doHealthCheck(Health.Builder); CompositeHealthContributor agrupa diversos subindicadors sota una clau comuna —útil per a una salut per estació—; i en una aplicació WebFlux s'implementa ReactiveHealthIndicator, el health() del qual retorna un Mono<Health> per no bloquejar el bucle d'esdeveniments. CicloUrbana és servlet, així que fem servir la variant bloquejant.

  1. Canviar la disponibilitat amb AvailabilityChangeEvent

De vegades l'estat no es dedueix, es decideix. Quan l'equip de Ribalta ha d'aplicar una migració pesada, vol que la instància continuï viva però deixi de rebre trànsit. Spring ho modela amb dos enumerats, LivenessState i ReadinessState, que es canvien publicant un esdeveniment:

@Service
public class ModeManteniment {

    private final ApplicationEventPublisher esdeveniments;   // constructor omès

    public void activar() {
        AvailabilityChangeEvent.publish(esdeveniments, this, ReadinessState.REFUSING_TRAFFIC);
    }

    public void desactivar() {
        AvailabilityChangeEvent.publish(esdeveniments, this, ReadinessState.ACCEPTING_TRAFFIC);
    }

    @EventListener
    public void alCanviar(AvailabilityChangeEvent<ReadinessState> esdeveniment) {
        log.warn("Disponibilitat de la xarxa de Ribalta: {}", esdeveniment.getState());
    }
}

A partir del publish, /actuator/health/readiness retorna 503 i el balancejador retira la instància sense matar-la, mentre liveness continua en UP; les peticions en curs acaben gràcies a l'aturada ordenada de 01-05. Publicar LivenessState.BROKEN té l'efecte contrari i molt més dràstic: li diu a l'orquestrador «soc irrecuperable, mata'm». L'@EventListener registra al log qui va deixar d'acceptar trànsit i quan, que és justament el que es busca després en una investigació.

  1. /actuator/info: quina versió hi ha desplegada

De totes les preguntes d'operacions, «quina versió hi ha desplegada?» és la que fa perdre més temps quan no té resposta automàtica. info la respon, però ve buit per defecte i cal omplir-lo des de tres fonts: propietats estàtiques, les dades de compilació i el commit de Git.

management:
  info:
    env:   { enabled: true }        # desactivat per defecte des de Boot 2.6
    build: { enabled: true }
    git:   { enabled: true, mode: full }
    java:  { enabled: true }
info:
  aplicacio: { nom: CicloUrbana, ciutat: Ribalta }

Els dos plugins que generen META-INF/build-info.properties i git.properties durant l'empaquetatge:

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <executions>
        <execution><id>build-info</id><goals><goal>build-info</goal></goals></execution>
    </executions>
</plugin>
<plugin>
    <groupId>io.github.git-commit-id</groupId>
    <artifactId>git-commit-id-maven-plugin</artifactId>
    <configuration><generateGitPropertiesFile>true</generateGitPropertiesFile></configuration>
</plugin>

El resultat combinat:

{
  "aplicacio": { "nom": "CicloUrbana", "ciutat": "Ribalta" },
  "build": { "artifact": "ciclourbana", "version": "2.4.0", "time": "2026-09-01T07:12:44.118Z" },
  "git":   { "branch": "main", "commit": { "id": "9f3a2b1", "time": "2026-09-01T07:10:02Z" } }
}

Amb aquesta resposta, «està desplegat l'arreglament del càlcul de tarifa?» es contesta comparant un hash de set caràcters, sense entrar a la màquina. A 08-05 això es converteix en la base de la verificació posterior al desplegament. Un InfoContributor propi hi afegeix a més dades calculades en calent:

@Component
public class InfoXarxaRibalta implements InfoContributor {

    private final EstacioRepositori estacioRepositori;
    private final XarxaProperties xarxa;                  // constructor omès

    @Override
    public void contribute(Info.Builder builder) {
        builder.withDetail("xarxa", Map.of(
                "ciutat", xarxa.ciutat(),
                "estacions", estacioRepositori.count(),
                "duradaMaximaLloguer", xarxa.duradaMaximaLloguer().toString()));
    }
}

Advertiment: info sol quedar públic, així que no hi posis res que no posaries en un cartell. La URL de la base de dades, el nom del servidor o la llista de dependències amb les seves versions —un inventari perfecte de vulnerabilitats conegudes— no hi han d'aparèixer.

  1. /actuator/loggers: canviar el nivell de log en calent

És l'endpoint que salva més vegades una guàrdia. Permet apujar el detall del log d'un paquet concret sense reiniciar i sense desplegar, i tornar a abaixar-lo quan acabis.

# Consultar el nivell efectiu
curl -s -u admin:*** http://localhost:8081/actuator/loggers/com.ciclourbana.lloguers
# {"configuredLevel":null,"effectiveLevel":"INFO","members":[...]}

# Apujar només aquest paquet a DEBUG
curl -X POST -u admin:*** -H 'Content-Type: application/json' \
     -d '{"configuredLevel":"DEBUG"}' \
     http://localhost:8081/actuator/loggers/com.ciclourbana.lloguers

# Tornar-lo al seu valor configurat
curl -X POST -u admin:*** -H 'Content-Type: application/json' \
     -d '{"configuredLevel":null}' \
     http://localhost:8081/actuator/loggers/com.ciclourbana.lloguers

Tres consells operatius. Sigues quirúrgic: posar org.hibernate.SQL en DEBUG en una instància amb trànsit real pot generar gigabytes en minuts i omplir el disc, cosa que sí que és una caiguda. Recorda desfer-ho, perquè el canvi viu en memòria i sobreviu fins al pròxim reinici, que pot trigar setmanes. I recorda que és un endpoint d'escriptura: aquesta és exactament la raó per la qual exigeix ADMIN. El tractament sistemàtic del registre —formats, JSON estructurat, agregació— és matèria de 09-05; aquí només ens interessa la palanca.

  1. mappings, configprops, env i els secrets

/actuator/mappings retorna l'inventari complet de rutes: el patró, els mètodes HTTP, el produces/consumes i el mètode Java que l'atén. Respon en segons a «existeix aquesta ruta?», «per què aquesta petició cau al controlador equivocat?» i «queda alguna ruta de depuració publicada?».

/actuator/configprops mostra els @ConfigurationProperties de 02-05 amb els seus valors efectius —XarxaProperties, TarifesProperties, PassarelaProperties— i, juntament amb /actuator/env, respon la pregunta més frustrant de la configuració d'Spring: d'on surt aquest valor? env llista les fonts en ordre de precedència, de manera que es veu que la contrasenya ve d'una variable d'entorn i no del YAML.

I aquí hi ha el risc: tots dos endpoints veuen tot, inclòs ciclourbana.jwt.secret i spring.datasource.password. Per això Spring sanititza:

management:
  endpoint:
    env:         { show-values: when-authorized }   # never (per defecte) | when-authorized | always
    configprops: { show-values: when-authorized }

Amb never, qualsevol valor apareix com a "******". Amb when-authorized es mostren només a qui tingui els rols configurats; i tot i així Spring emmascara les claus el nom de les quals coincideix amb patrons sensibles (password, secret, key, token, credentials), emmascarament afinable amb un bean SanitizingFunction.

La regla de Ribalta: env i configprops no s'exposen en producció. El diagnòstic que aporten és real, però es pot obtenir a pre amb la mateixa configuració, i cap quantitat de sanitització no compensa el dia que algú afegeixi una propietat anomenada ciclourbana.passarela.credencial-mestra —que no coincideix amb cap patró— i la publiqui en JSON. L'emmascarament és una segona línia de defensa, no la primera.

  1. Un endpoint propi amb @Endpoint

Quan la informació que necessita operacions no encaixa ni a salut ni a info, s'escriu un endpoint. A CicloUrbana, el resum de l'estat de la xarxa de Ribalta:

package com.ciclourbana.estacions;

import org.springframework.boot.actuate.endpoint.annotation.*;   // Endpoint, ReadOperation...

@Component
@Endpoint(id = "xarxa")
public class EndpointXarxa {

    private final EstacioService estacioService;
    private final ModeManteniment manteniment;            // constructor omès

    /** GET /actuator/xarxa */
    @ReadOperation
    public Map<String, Object> resum() {
        List<Estacio> estacions = estacioService.llistarTotes();
        return Map.of(
                "instant", Instant.now().toString(),
                "estacions", estacions.size(),
                "ancoratgesTotals", estacions.stream().mapToInt(Estacio::capacitat).sum(),
                "detall", estacions.stream().map(e -> Map.of(
                        "nom", e.nom(),
                        "disponibles", estacioService.comptarDisponibles(e.id()))).toList());
    }

    /** GET /actuator/xarxa/{nom} */
    @ReadOperation
    public Map<String, Object> detallEstacio(@Selector String nom) {
        return estacioService.resumPerNom(nom);
    }

    /** POST /actuator/xarxa  amb cos {"manteniment": true} */
    @WriteOperation
    public Map<String, Object> canviarManteniment(boolean manteniment) {
        if (manteniment) this.manteniment.activar(); else this.manteniment.desactivar();
        return Map.of("manteniment", manteniment);
    }
}
Anotació Verb HTTP Notes
@ReadOperation GET N'hi pot haver diverses si es distingeixen per @Selector
@WriteOperation POST Els paràmetres es llegeixen del cos JSON per nom
@DeleteOperation DELETE Per a operacions d'invalidació o neteja
@Selector — Converteix un paràmetre en segment de ruta: /actuator/xarxa/{nom}

Quatre punts a tenir en compte. L'id ha de ser alfanumèric en minúscules i no xocar amb un altre endpoint. Cal exposar-lo explícitament a exposure.include, igual que els integrats. Un endpoint no és un controlador: no passa pel GestorGlobalExcepcions de 03-06 ni per la validació de 03-04, així que valida tu els paràmetres i retorna estructures simples. I @Endpoint publica alhora per HTTP i per JMX; per restringir-lo a un existeixen @WebEndpoint i @JmxEndpoint. Que aquest endpoint permeti activar el manteniment amb un POST explica per què la cadena de l'apartat 6 exigeix ADMIN per a tot allò que no sigui salut ni info.

  1. Actuator sobre JMX

A més d'HTTP, Actuator publica els seus endpoints com a MBeans sota el domini org.springframework.boot, encara que per defecte està desactivat des d'Spring Boot 2.2:

management:
  endpoints:
    jmx:
      exposure: { include: health,info,xarxa }
spring:
  jmx: { enabled: true }

Amb això, JConsole, VisualVM o JMC connectades al procés veuen els endpoints com a operacions invocables. Continua sent útil en desplegaments tradicionals sobre màquines amb accés per SSH, però en contenidors el camí HTTP és l'habitual: no requereix obrir el port RMI ni barallar-se amb la negociació de ports aleatoris de JMX, notòriament hostil amb NAT i amb Docker.

  1. L'advertiment que tanca la lliçó

Actuator és, literalment, una porta de servei a l'aplicació. Amb env es llegeixen secrets, amb heapdump es descarrega la memòria completa amb els JWT dels ciutadans de Ribalta a dins, amb loggers s'omple el disc i amb shutdown s'atura el servei. Un Actuator exposat sense autenticar a Internet no és una mala pràctica: és una bretxa. Els cercadors especialitzats en dispositius exposats indexen rutes /actuator/env de manera sistemàtica, i troben resultats cada dia.

La llista mínima de verificació abans de cada desplegament a Ribalta:

  1. exposure.include és una llista explícita, mai *, al perfil prod.
  2. env, heapdump, threaddump, beans i configprops estan exclosos o restringits a ADMIN, i shutdown continua deshabilitat.
  3. Existeix una cadena amb EndpointRequest.toAnyEndpoint() i anyRequest() no queda sense regla.
  4. management.server.port és un port tancat al tallafocs al trànsit extern, i show-details i show-values no valen always.
  5. Tot viatja per TLS, perquè l'autenticació bàsica envia la contrasenya codificada, no xifrada.

Errors Comuns i Consells

Exposar-ho tot amb include: "*" i oblidar-ho. Funciona el primer dia, es copia al perfil de producció i sobreviu anys. Escriu la llista explícita des del principi.

Posar la base de dades a liveness. Converteix una caiguda de PostgreSQL en un bucle de reinicis de totes les instàncies. A liveness només hi va allò que s'arregla reiniciant.

Un HealthIndicator lent. S'executa a cada sonda de cada instància: una consulta de dos segons converteix la comprovació de salut en un problema de rendiment i provoca falsos DOWN per temps d'espera.

Un indicador no crític que tomba el health global. L'estat agregat és el pitjor de tots: si la passarel·la de pagaments no ha de retirar la instància del balancejador, treu-la del grup readiness.

Confondre enabled amb exposure. Deshabilitar health «perquè no es vegi» trenca també les sondes internes. El que vols és deixar-lo habilitat i no exposar-lo, o exposar-lo protegit.

Consell: posa Actuator al seu propi port des del primer dia, perquè canviar-ho després obliga a tocar balancejadors, tallafocs, alertes i manifestos. I fes servir EndpointRequest, mai la ruta literal: "/actuator/**" deixa de coincidir el dia que algú canvia base-path, i la seguretat desapareix sense ni un sol error al log.

Consell: documenta els teus grups de salut —què mira el balancejador, què mira l'orquestrador, què dispara una alerta— i escriu una prova (06-04) que demani /actuator/env sense credencials i esperi 401: així la política d'exposició passa a estar defensada per la construcció.

Exercicis

Exercici 1: configuració operativa completa

Configura Actuator per a CicloUrbana segons aquests requisits: port de gestió 8081; exposició de health, info, metrics, loggers, flyway i l'endpoint propi xarxa; detalls de salut només per a usuaris autoritzats amb rol ADMIN; sondes de disponibilitat actives; un grup readiness que inclogui només la base de dades; l'indicador d'espai en disc desactivat; i info amb dades de compilació i de Git. Escriu el YAML i la cadena de seguretat, i explica què respon cada URL a un client anònim.

Exercici 2: indicador de salut del pool de connexions

Escriu un HealthIndicator anomenat poolConnexions que consulti el HikariDataSource de 04-02 a través del seu HikariPoolMXBean i retorni DOWN si hi ha més de tres fils esperant connexió o si les connexions actives superen el 90 % del màxim, i UP en cas contrari, amb el desglossament com a detall. Decideix raonadament a quin grup de salut ha d'anar.

Exercici 3: revisió d'una configuració perillosa

Un company ha pujat aquesta configuració de producció. Troba tots els problemes i proposa'n la correcció.

management:
  endpoints:
    web:
      exposure: { include: "*" }
  endpoint:
    health:
      show-details: always
      group:
        liveness: { include: db, passarelaPagaments }
    shutdown: { enabled: true }
    env: { show-values: always }
@Bean
@Order(1)
SecurityFilterChain cadenaActuator(HttpSecurity http) throws Exception {
    http.securityMatcher("/actuator/**")
        .authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
    return http.build();
}

Solucions

Solució 1

management:
  server:
    port: 8081
  endpoints:
    web:
      base-path: /actuator
      exposure:
        include: health,info,metrics,loggers,flyway,xarxa
  endpoint:
    health:
      show-details: when-authorized
      show-components: when-authorized
      roles: ADMIN
      probes: { enabled: true }
      group:
        readiness: { include: db }
        liveness:  { include: livenessState }
  health:
    diskspace: { enabled: false }
  info:
    build: { enabled: true }
    git:   { enabled: true, mode: full }
    env:   { enabled: true }

info:
  aplicacio: { nom: CicloUrbana, ciutat: Ribalta }

Amb la cadena de seguretat de l'apartat 6, el que veu un client anònim al port 8081 és:

URL Resposta anònima Motiu
/actuator/health 200 amb {"status":"UP"} Públic, però show-details: when-authorized amaga el desglossament
/actuator/health/readiness 200 o 503 segons l'estat de db Pertany a health, també públic
/actuator/info 200 amb versió, commit i dades de la xarxa Públic per decisió explícita
/actuator/metrics 401 Exposat però exigeix ADMIN
/actuator/loggers 401 Igual, i a més és d'escriptura
/actuator/xarxa 401 Endpoint propi, cobert per toAnyEndpoint()
/actuator/env 404 No és a include: la ruta no existeix
/actuator/beans 404 Tampoc no està exposat

La diferència entre 401 i 404 és informativa: 404 significa «no exposat» i 401, «exposat i protegit». I convé recordar que aquestes URL només són accessibles des de la xarxa interna, perquè el port 8081 no es publica a l'exterior.

Solució 2

@Component("poolConnexions")
public class SalutPoolConnexions implements HealthIndicator {

    private static final int ESPERA_MAXIMA = 3;
    private static final double LLINDAR_OCUPACIO = 0.90;

    private final HikariDataSource dataSource;            // constructor omès

    @Override
    public Health health() {
        HikariPoolMXBean pool = dataSource.getHikariPoolMXBean();
        if (pool == null) {
            return Health.unknown().withDetail("motiu", "pool encara no inicialitzat").build();
        }

        int actives  = pool.getActiveConnections();
        int esperant = pool.getThreadsAwaitingConnection();
        int maxim    = dataSource.getMaximumPoolSize();

        boolean saturat = esperant > ESPERA_MAXIMA
                || (double) actives / maxim > LLINDAR_OCUPACIO;

        return (saturat ? Health.down() : Health.up())
                .withDetail("actives", actives)
                .withDetail("inactives", pool.getIdleConnections())
                .withDetail("esperant", esperant)
                .withDetail("maxim", maxim)
                .withDetail("ocupacio", Math.round(100.0 * actives / maxim) + "%")
                .build();
    }
}

Decisions. S'injecta HikariDataSource i no DataSource, perquè l'MXBean és específic d'Hikari; el null inicial es contempla perquè el pool es crea mandrosament i una sonda molt primerenca hi pot arribar abans. En aquest cas es retorna UNKNOWN i no DOWN, perquè UNKNOWN mapa a 200 i una instància tot just arrencada no s'ha de declarar malalta. Els llindars són constants per brevetat; al projecte real serien propietats de @ConfigurationProperties (02-05) ajustables per entorn. L'indicador és barat: només llegeix comptadors en memòria, sense tocar la base de dades.

A quin grup va. Aquest sí que és bon candidat per a readiness, i és el contrast exacte amb xarxaEstacions. Un pool saturat significa que la instància no pot atendre bé més peticions, així que retirar-la del balancejador és el correcte: el trànsit va a instàncies sanes i aquesta es recupera drenant la seva cua. A liveness seria un error greu —un pic de càrrega mataria les instàncies que estan aguantant—. Cautela final: si totes les instàncies saturen el pool alhora, totes es declaren REFUSING_TRAFFIC i la xarxa de Ribalta es queda sense cap de disponible; per això el llindar ha de ser alt (90 %, no 60 %) i ha d'existir una alerta que avisi molt abans que la sonda actuï.

Solució 3

Set problemes, en ordre de gravetat:

# Problema Conseqüència Correcció
1 permitAll() sobre tot Actuator Qualsevol a Internet llegeix env, descarrega heapdump i atura l'aplicació EndpointRequest.to(HealthEndpoint.class, InfoEndpoint.class).permitAll() i anyRequest().hasRole("ADMIN")
2 shutdown.enabled: true Un POST anònim atura CicloUrbana Treure-ho: deixar-lo deshabilitat
3 include: "*" Publica env, beans, heapdump, threaddump i qualsevol endpoint futur Llista explícita
4 env.show-values: always El secret JWT i la contrasenya de PostgreSQL surten en clar never, o directament no exposar env
5 passarelaPagaments dins de liveness Una caiguda de la passarel·la mata i reinicia totes les instàncies en bucle Treure-ho de liveness; ni tan sols hauria de ser a readiness
6 db dins de liveness Mateix bucle de reinicis davant d'una incidència de base de dades Moure-ho a readiness
7 show-details: always El desglossament (motor, versió, rutes de disc, estat del pool) és públic when-authorized amb roles: ADMIN

Hi ha un vuitè problema, de forma: la cadena fa servir la ruta literal "/actuator/**", així que si algú canvia base-path o activa management.server.port, la regla deixa de coincidir sense cap avís.

La configuració corregida és la de la solució 1 més la cadena de l'apartat 6. I una lliçó de fons: els problemes 1 i 2 junts permeten a qualsevol aturar la xarxa de bicicletes d'una ciutat amb un curl d'una línia. No és una fallada d'Spring Boot —tots els seus valors per defecte són segurs—, és el resultat d'haver-los canviat un a un buscant comoditat durant el desenvolupament i haver promocionat el fitxer a producció. D'aquí surt el mòdul següent.

Conclusió

CicloUrbana ja es deixa preguntar. Saps què significa que una aplicació sigui operable i per què és una propietat diferent de ser correcta. Coneixes el catàleg complet d'endpoints d'Actuator amb el risc de cadascun, entens per què només health surt per HTTP de fàbrica i per què aquesta política neix d'una lliçó apresa a cops. Saps exposar-los amb include/exclude, moure'ls a un base-path diferent i, sobretot, separar-los al seu propi port perquè sigui el tallafocs —i no una regla de codi— qui decideixi qui parla amb la gestió. Has tancat la cadena de seguretat amb EndpointRequest.toAnyEndpoint(), que no caduca quan canvia la ruta, deixant públics només salut i info i exigint ADMIN per a la resta.

Domines /actuator/health per dins: l'agregació al pitjor estat, show-details, els indicadors automàtics i com apagar-los, i la peça que caldrà a Docker i a Kubernetes —els grups de salut i les sondes liveness i readiness—, amb la regla que evita l'error més car: a liveness només hi va allò que s'arregla reiniciant. Has escrit SalutXarxaEstacions, que mesura la salut del servei i no només la del procés, amb les quatre cauteles que separen un indicador útil d'un de nociu, i saps canviar la disponibilitat a voluntat amb AvailabilityChangeEvent per buidar de trànsit una instància sense matar-la. /actuator/info respon per fi quina versió i quin commit estan desplegats a Ribalta, /actuator/loggers apuja el detall del log sense reiniciar, mappings i configprops serveixen d'eina de diagnòstic, i tens gravat l'advertiment sobre env i els secrets. I has escrit el teu propi @Endpoint(id = "xarxa") amb operacions de lectura, escriptura i esborrat.

Queda un endpoint deliberadament sense obrir: /actuator/metrics. Actuator el publica, però explotar-lo —comptadors i temporitzadors de negoci, @Timed, percentils, MeterRegistry— és la feina de 09-03, i la seva recol·lecció per Prometheus i la seva representació a Grafana, la de 09-04. Aquí hem muntat la instrumentació; allà es llegirà.

I ara apareix el problema que l'exercici 3 ha deixat al descobert. Tota la configuració d'aquesta lliçó —què s'exposa, quants detalls es mostren, si shutdown està habilitat— ha de ser diferent al portàtil d'un desenvolupador i al servidor de l'ajuntament. El mateix passa amb la base de dades (H2 davant de PostgreSQL), amb el nivell de log, amb Swagger, amb ddl-auto, amb CORS i amb la caducitat del JWT. Fins ara els hem anat canviant a mà en un únic application.yml, que és exactament com una configuració de desenvolupament acaba desplegada en producció. La lliçó següent, Perfils d'Spring Boot, ho resol amb el principi de construir una vegada i desplegar en molts llocs: un sol artefacte, diversos entorns, cap recompilació.

Curs de Spring Boot

Mòdul 1: Introducció a Spring Boot

Mòdul 2: Conceptes bàsics de Spring Boot

Mòdul 3: Construint serveis web RESTful

Mòdul 4: Accés a dades amb Spring Boot

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

Mòdul 7: Funcions avançades de Spring Boot

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats