前两天帮一个同事排查问题他负责的服务在 K8s 里每隔一段时间就被莫名其妙地重启。我看了下探针配置livenessProbe 和 readinessProbe 全部指向/actuator/health而那段日子的数据库连接池时不时被打满。连接池一满应用线程阻塞readiness 探针失败服务被从负载均衡摘掉紧接着 liveness 探针也失败Pod 直接被 K8s 杀掉重启。重启时流量重新灌入又制造一波新的连接高峰整个雪崩链路就这么串起来了。这其实是 Spring Boot Actuator 非常典型的误用场景。很多人把它理解成一个“加上依赖就能看的监控页面”真到生产环境才发现健康检查、指标暴露、端点安全这三件事任何一件没想清楚都可能让系统在诡异的时间点翻车。我基于最近项目里踩过的坑和验证过的配置把 Actuator 从原理到实战完整梳理一遍这篇内容适合正在用 Spring Boot 2.7 或 3.x 做微服务又不想在监控和安全上糊弄过去的同学。1. 从一次“假死”事故开始Actuator 的核心价值边界1.1 探针配置错在哪里同事那个服务其实是健康的进程活着、端口在监听只是因为数据库连接池满导致了一部分请求耗时变长。在 K8s 里readiness 探针判断的是“要不要把流量分给你”liveness 探针判断的是“你进程还能不能救”。两台探针共用同一个健康检查路径等于把“该摘流量”和“该杀进程”两件事混为一谈。Spring Boot Actuator 的/health端点是一个聚合状态只要任意一个健康组件返回 DOWN整个 health 状态就会变成 DOWN。数据库连接池满、Redis 抖动、磁盘空间不足这些都会让 health 变成 DOWN。K8s 的三种探针如果都指向它任何一项依赖抖动都会被放大成 Pod 重启。正确的做法是利用 Actuator 自带的 availability 能力拆出 liveness 和 readiness 两组状态后面第 2 章详细讲。1.2 Actuator 不是“装了就有的监控面板”Actuator 本质上是一组 HTTP/JMX 端点把应用运行时的信息以结构化数据的方式暴露出来。它不负责采集、存储和展示只负责把信息给你。你拿它接 Prometheus、Spring Cloud Admin、自定义监控平台或者只是给运维一条curl命令都行但前提是你得先理解它暴露了什么。我把它的能力拆成三块端点体系Endpoints把环境变量、Bean、日志等级、线程快照、堆转储、映射路径等信息暴露出来指标体系Metrics基于 Micrometer 生成 JVM、HTTP、连接池、业务自定义等指标安全与配置边界控制哪些端点能被谁访问这也是最容易翻车的地方。1.3 依赖引入与端点全景先说引入方式Maven 下加依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependencyGradle 对应implementation org.springframework.boot:spring-boot-starter-actuator引入之后默认只通过 Web 暴露health和info两个端点其他端点虽然存在但对 HTTP 访问是关闭的。这个设计是故意的Spring Boot 默认给你一个可用且不容易出安全事故的起点。但实际生产中大家都想尽快看到指标于是会打开更多端点风险也随之而来。我在项目里常用的端点清单如下端点默认暴露默认启用说明health是是健康检查聚合所有 HealthIndicatorinfo是是应用描述信息如版本、Git 提交metrics否是查看指标列表及单个指标详情prometheus否是引入 Prometheus 相关依赖后出现env否是全部环境变量与配置属性含敏感信息beans否是容器内所有 Bean 的信息loggers否是查看并动态修改日志级别threaddump否是获取线程快照heapdump否是下载堆转储文件mappings否是所有 URL 映射关系shutdown否否优雅关闭应用默认关闭我的经验是先把这个表格打印出来贴在工位上上线前逐项核对哪些端点开着、谁能访问比任何巡检脚本都好用。2. 健康检查的完整逻辑组件、聚合与探针分流2.1 HealthIndicator 自动装配体系Actuator 的健康检查不是一条简单的 ping 探测而是一个组件树。Spring Boot 根据项目里引入的依赖自动装配一批 HealthIndicator 并注册到 HealthContributorRegistry。你的工程里如果引入了 Redis、MongoDB、Kafka、Elasticsearch对应的健康检查组件会自动出现在/health响应里。常见的自动装配组件包括但不限于DiskSpaceHealthIndicator检查磁盘剩余空间是否低于阈值DataSourceHealthIndicator对数据源执行SELECT 1验证可用性RedisHealthIndicator执行PING检查 RedisMongoHealthIndicator执行 ping 命令检查 MongoDBRabbitHealthIndicator检查 RabbitMQ 连接ElasticsearchHealthIndicator检查 ES 集群状态PingHealthIndicator一个始终返回 UP 的兜底组件。所以/health的返回结果是一个聚合体默认格式类似{ status: UP, components: { diskSpace: { status: UP }, ping: { status: UP }, db: { status: UP } } }status是聚合后的总体状态components是每一个健康组件的独立状态。2.2 状态聚合规则与探针分组聚合规则一句话就能说清取所有组件中“最严重”的状态。状态优先级从高到低大致是DOWNOUT_OF_SERVICEUPUNKNOWN。任何一个组件 DOWN整体就是 DOWN哪怕其他一百个组件都是 UP。这个规则很合理但也会带来“牵连”问题。数据库连不上应用本身就不该接收新流量吗看情况。有时数据库只是短暂抖动应用进程完全正常我们希望把服务从负载均衡摘掉但不重启进程有时是 JVM 内存泄漏导致进程濒临崩溃这时候无论流量怎么摘进程都该重启。于是 Spring Boot 提供了 availability 分组能力。在配置里显式开启management: endpoint: health: probes: enabled: true endpoints: web: exposure: include: health,info,metrics,prometheus开启后Actuator 会自动注册两组健康检查/actuator/health/liveness只包含独立于外部依赖的 PingHealthIndicator进程还活着就返回 UP/actuator/health/readiness包含数据库、Redis、Kafka 等所有真实依赖依赖不可用就返回 DOWN。也可以手动定义分组更灵活management: endpoint: health: group: readiness: include: readinessState liveness: include: pingK8s 的探针就对应配置为readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 livenessProbe: httpGet: path: /actuator/health/liveness port: 8080这样数据库抖动时 readiness 先失败流量被摘除但进程不会被杀等依赖恢复readiness 重新 UP流量自动回来。liveness 只在进程真正不健康时参与决策。2.3 自定义 HealthIndicator以 MinIO 为例框架自带组件覆盖不了所有东西。比如我们项目里用 MinIO 做文件存储Actuator 默认不知道 MinIO 是否可用我一般会写一个自定义 HealthIndicator。写法非常干净注册成一个 Spring Bean 即可Component public class MinioHealthIndicator implements HealthIndicator { private final MinioClient minioClient; public MinioHealthIndicator(MinioClient minioClient) { this.minioClient minioClient; } Override public Health health() { try { boolean exists minioClient.bucketExists( BucketExistsArgs.builder() .bucket(default-bucket) .build() ); if (exists) { return Health.up() .withDetail(bucket, default-bucket) .withDetail(latency, ok) .build(); } return Health.down() .withDetail(reason, bucket not found) .build(); } catch (Exception e) { return Health.down(e) .withDetail(reason, minio unavailable) .build(); } } }HealthIndicator 的类名有个约定类名去掉HealthIndicator后缀后按驼峰转小写就是你在这个组件里的名字。MinioHealthIndicator对应minio所以在/health响应里能看到minio: {status: UP}。这里有个细节值得注意旧版 Actuator 里需要实现HealthContributor接口Spring Boot 2.2 开始推荐直接实现HealthIndicator后者接收HealthIndicatorRegistry自动注入更简单。如果你在网上看到一些老文章写HealthContributor或自定义HealthAggregator那大概率是针对 2.1 及更早版本的写法。2.4 健康详情与控制泄露/health端点有一个重要的配置项management.endpoint.health.show-details取值有三个never永远不显示详情默认值只返回整体状态always总是显示每个组件的详情when-authorized只有在请求已认证且授权的情况下显示详情。很多新手会让show-details保持never然后困惑为什么/health看不到 components。反过来也有人为了排查问题设置成always还把这个端点暴露在公网数据库类型、版本、连接状态全被外部看到了。我的建议是生产环境用when-authorized配合 Spring Security 让监控平台带凭证访问management: endpoint: health: show-details: when-authorized还有一点容易被忽略即使show-detailsnever/health的存在本身也在泄露信息。依赖正常时返回 UP依赖故障时返回 DOWN外部攻击者可以拿这个端点半盲探测你的依赖情况。所以健康检查虽然不像heapdump那样直接导致密钥泄露但也不应该裸奔在公网。3. 指标暴露实战Micrometer 与 Prometheus 对接3.1 先理解 Micrometer 的四个基础计量器Actuator 的指标体系建立在 Micrometer 之上。Micrometer 是一层门面抽象它定义了四种基础计量器理解这四种就够了计量器类型用途典型场景Counter单调递增计数器订单创建总数、请求错误总数Gauge可增可减的即时数值当前连接数、缓存大小、队列深度Timer观测耗时与调用次数接口响应时间、数据库查询耗时DistributionSummary事件分布统计报文大小、业务评分、编辑耗时官方指标命名规范是小写字母加点分隔比如jvm.memory.used、http.server.requests、hikaricp.connections。你在 Prometheus 里看到的下划线版本是 Micrometer 在导出时自动转换的规则。3.2 从 metrics 到 prometheus最简暴露配置默认暴露的metrics端点是一个查询入口适合人工调试。比如想看 JVM 堆内存curl http://localhost:8080/actuator/metrics/jvm.memory.used?tagarea:heap返回的是一个 JSON 结构包含测量值、可用标签等。想看 HTTP 请求指标时curl http://localhost:8080/actuator/metrics/http.server.requests?taguri:/api/orders但人工调试归人工调试真正的监控系统不会用 JSON 端点去采集而是对接 Prometheus 文本格式。引入依赖dependency groupIdio.micrometer/groupId artifactIdmicrometer-registry-prometheus/artifactId /dependency只要这个依赖在 classpath 里Actuator 会自动注册/actuator/prometheus端点输出 Prometheus 直接可抓取的文本格式。配合暴露策略一起配到application.ymlmanagement: endpoints: web: exposure: include: health,info,metrics,prometheusPrometheus 的抓取任务配置scrape_configs: - job_name: spring-boot-app metrics_path: /actuator/prometheus static_configs: - targets: [localhost:8080]抓下来你会看到类似这样的输出jvm_memory_used_bytes{areaheap,idPS Survivor Space,} 1.2E7 http_server_requests_seconds_count{exceptionnone,methodGET,status200,uri/actuator/prometheus,} 12.0生产环境我建议把 Prometheus 采集路径和业务接口完全分开最干净的方式就是第 4 章讲的独立管理端口。3.3 业务指标埋点计数器与耗时统计框架自带指标覆盖 JVM、HTTP、数据源等通用维度但业务指标必须自己埋。我用的最多的两个场景是订单创建量和支付耗时。计数器示例Component public class OrderMetrics { private final Counter orderCreatedCounter; public OrderMetrics(MeterRegistry registry) { this.orderCreatedCounter Counter.builder(order.created.total) .description(Total number of orders created) .tag(channel, default) .register(registry); } public void recordOrderCreated() { orderCreatedCounter.increment(); } }这里的tag用了channel后面可以根据渠道拆维度看。有一点要牢记tag 的值必须是低基数的枚举值比如渠道名、支付方式、地区代码。绝不能把订单 ID、用户 ID 放进去否则第 5 章会讲指标基数爆炸比漏一个监控指标麻烦得多。耗时统计用 TimerTimer paymentTimer Timer.builder(order.payment.duration) .publishPercentileHistogram() .register(registry); long start System.nanoTime(); try { doPay(); } finally { paymentTimer.record(System.nanoTime() - start, TimeUnit.NANOSECONDS); }想更进一步简化Micrometer 支持Timed注解配合TimedAspect使用Timed(name order.payment.duration, percentiles 0.95,0.99) public void doPay() { // ... }注解方式适合切面式埋点函数式方式适合业务代码里手工控制看团队习惯效果基本一致。3.4 生产监控面板上必看的几个指标接完 Prometheus 之后建议第一版监控面板先放这几类指标指标名关注点告警建议jvm.memory.used堆内存使用趋势超过堆最大值 85% 且持续不回落jvm.gc.pauseGC 停顿时间P99 超过 300ms 告警http.server.requests接口请求量、耗时、错误率错误率超过 5%P99 超过阈值hikaricp.connections连接池使用率连接数打满或接近最大连接数process.cpu.usage进程 CPU 使用率持续高于 80%logback.events各日志级别数量ERROR 级别短时间内剧增指标接进来不难难的是定告警阈值。我的建议是先用两周的基线数据再定阈值别一开始就拍脑袋写个数字不然后面全是告警噪音。4. 端点安全三层防线缺一不可4.1 暴露在公网的 Actuator 到底有多危险Actuator 端点一旦被公网直接访问风险等级不是“可能泄露信息”而是“几乎必然被利用”。逐个说/actuator/env直接返回环境变量和配置项数据库密码、连接串里的密钥、第三方服务凭证都在里面/actuator/heapdump下载整个堆转储文件。堆里有什么内存中的 token、密钥、业务数据一网打尽/actuator/beans暴露所有 Bean攻击者能反推系统结构和类库版本为漏洞利用铺路/actuator/mappings把所有 URL 映射关系暴露出来等于把攻击面清单送上门/actuator/loggers可以动态调整日志级别。把某个包调成 DEBUG生产日志瞬间刷爆磁盘/actuator/shutdown虽然默认 disabled一旦误开启外部直接发个 POST 就能关掉你的服务。我处理过一个真实案例某服务把heapdump暴露在公网攻击者下载堆转储后用工具一把梭直接把内存里的密钥提取出来。事后排查问题就出在团队为了排查内存泄漏临时打开了这个端点上线后忘关。所以端点安全的第一原则是默认全关按需打开打开后必须设访问控制。4.2 第一层防线网络与独立管理端口最有效的一层防线其实在应用之外。把管理端点和业务端口直接分开management: server: port: 9091 address: 127.0.0.1 endpoints: web: base-path: /actuator exposure: include: health,info,metrics,prometheusmanagement.server.port9091意味着所有/actuator/**请求都会跑到 9091 端口上业务端口 8080 不再响应任何端点。address这一项要特别注意如果绑定127.0.0.1那只有宿主机本机可以访问适合运维通过 SSH 或监控 Agent 来采集如果服务跑在 Docker/K8s 里配合 Ingress 或负载均衡的来源限制绑定内网网卡 IP 更实用。我常用的方式是绑定内网地址同时在防火墙层设置仅允许监控平台的 IP 段访问 9091 端口。独立管理端口的好处是监控系统、K8s 探针都走 9091业务流量完全走 8080两边互不干扰。不过要记住探针和 Prometheus 的抓取路径也要同步改成新的端口否则上线后会发现健康检查全挂。4.3 第二层防线Spring Security 与端点鉴权网络层挡得住外网挡不住已经进入内网的横向渗透。所以应用内的鉴权必须做。引入 Spring Securitydependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency然后用 Actuator 提供的EndpointRequest工具来匹配所有管理端点。以 Spring Boot 3.x 为例Configuration public class ActuatorSecurityConfig { Bean public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception { http .securityMatcher(/actuator/**) .authorizeHttpRequests(auth - auth .requestMatchers(EndpointRequest.to(HealthEndpoint.class)).permitAll() .requestMatchers(EndpointRequest.to(InfoEndpoint.class)).permitAll() .requestMatchers(EndpointRequest.toAnyEndpoint()).hasRole(ACTUATOR) ) .httpBasic(Customizer.withDefaults()); return http.build(); } }这里有几个坑要讲清楚。第一个坑Spring Boot 2.x 时代写authorizeRequests、antMatchersSpring Boot 3.x 必须改成authorizeHttpRequests和requestMatchers。Security 6 里antMatchers已经移除如果你从旧项目升级启动就直接失败。第二个坑EndpointRequest.to(HealthEndpoint.class)的写法要求你 import 的是org.springframework.boot.actuate.health.HealthEndpoint不是HealthIndicator。用错类名会导致匹配不到路径。第三个坑如果用了management.server.port独立管理端口安全配置里的securityMatcher(/actuator/**)依然有效但管理端口和业务端口的 filter chain 是分开的。我有一个习惯是把管理端口的访问限制写成独立 chain别让两个端口的授权规则纠缠在一起排查时能省很多时间。Configure 里同时要定义ACTUATOR角色的用户spring: security: user: name: actuator password: ${ACTUATOR_PASSWORD} roles: ACTUATOR密码当然不能用明文写在 yml 里用环境变量注入。4.4 第三层防线最小化端点开关网络层、鉴权层都做到位后最后一步是尽量缩小端点暴露面。我给出一个生产可落地的配置management: endpoints: web: exposure: include: health,info,metrics,prometheus endpoint: health: show-details: when-authorized probes: enabled: true env: enabled: false beans: enabled: false conditions: enabled: false configprops: enabled: false mappings: enabled: false loggers: enabled: true threaddump: enabled: false heapdump: enabled: falseloggers我建议保留打开但只允许有ACTUATOR角色的用户通过认证访问。生产环境经常需要在不重启的情况下临时调大某个包的日志级别这个端点是真的香。threaddump同理排障时很有用平时关着也可以需要时再临时打开。但heapdump无论是平时还是在排障时我都建议保持关闭宁可让运维通过jmap在宿主机上操作也别开放 HTTP 下载通道。5. 生产环境踩过的 3 个坑5.1 坑一readiness 和 liveness 复用同一个探针路径开头那个同事的案例就是最典型的坑。这里我把雪崩链路再完整捋一遍数据库连接池被打满DataSourceHealthIndicator 返回 DOWN/health整体变成 DOWNK8s 的 readinessProbe 和 livenessProbe 都指向/health于是两个探针同时失败readiness 失败服务从 Service 的 Endpoints 里摘除但这并没有缓解数据库压力liveness 失败K8s 杀掉 Pod 并重启重启瞬间 Service 又把流量全量打进来新 Pod 刚启动就要承受老 Pod 的流量数据库连接又被瞬间打满周而复始服务进入重启循环。解决方式前面已经说了readiness 指向/actuator/health/readinessliveness 指向/actuator/health/liveness两套状态互不干扰。这个坑的本质是滥用聚合状态。/health这个聚合值是给人看的给调度器用必须拆开。顺带说一句如果你是直接在一个大 Controller 里手写了一个/health返回ok那就更危险了因为它完全不反映依赖状态数据库挂了你都不知道K8s 还在继续往里打流。5.2 坑二自定义 HealthIndicator 里的远程调用超时我们有个服务在 HealthIndicator 里检查一个第三方短信平台代码写得很“直白”Override public Health health() { boolean reachable smsClient.ping(); // 内部 HTTP 调用默认超时 10 秒 return reachable ? Health.up().build() : Health.down().build(); }看起来没什么问题但生产环境里第三方平台偶尔会假死ping请求最长得等 10 秒才超时。Actuator 的 health 请求是同步处理的一个组件卡 10 秒整个/health端点就卡 10 秒。K8s 的探针一般只有几秒的 timeout探针先超时过去又判定失败Pod 又开始被重启。后来我在 HealthIndicator 里强制设置短超时HttpClient httpClient HttpClient.newBuilder() .connectTimeout(Duration.ofMillis(500)) .build(); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(https://sms.example.com/ping)) .timeout(Duration.ofSeconds(1)) .build();同时给 Actuator 的健康检查整体设置了超时management: endpoint: health: timeout: 1s原理很简单健康检查只回答“这个依赖现在能不能用”不需要像业务调用那样等待完整超时。1 秒内给不出答案就当成 DOWN流量摘掉总比堵在探针上强。5.3 坑三指标标签基数爆炸有一次线上 Prometheus 机器内存持续飙升查询响应越来越慢。排查后发现是http.server.requests这个指标导出的序列数量爆炸了。原因是我们网关层在访问日志埋点里把订单 ID 直接塞进了 tagregistry.counter(order.access.total, orderId, order.getId(), channel, channel);订单 ID 是无限增长的唯一值每个订单产生一条时间序列几个小时后序列数量就过了百万。Prometheus 对时序数量非常敏感基数一大内存和磁盘一起完蛋查询直接卡死。而且要命的是这个问题不像接口报错那样立刻暴露它是在慢慢拖垮监控系统的过程中被发现的。等到你看到 Prometheus 报警往往已经晚了。Micrometer 官方文档也特别强调了这一点tag 的值必须是低基数的。“低基数”没有一个绝对数字但经验上某个 tag 的值每天新增量应该稳定在个位数以内两位数都危险。正确的做法是把唯一 ID 放到指标 value 里承载而不是放到 tag 里。比如只统计“所有订单访问量”tag 用渠道、状态、机房这些有限枚举值。如果真需要按订单查明细那是日志系统的事不是指标系统的事。另外Spring Boot 3.x 的http.server.requests默认会对 URL 模板做自动归一化/api/orders/123会变成/api/orders/{id}所以指标里不会为每一个订单 ID 生成独立序列。这算是一个隐含的防护但如果你在自己的埋点里手动塞了唯一 ID框架帮不了你。最后说说我的落地习惯经历过上面几个坑之后现在每接一个 Spring Boot 服务我都会先做三件事管理端口独立出去探针路径只指向 readiness/liveness端点暴露清单交给安全评审。Actuator 本身是特别好的工具它能给你极高的可观测性但可观测性是有代价的代价就是你必须把它当成一个真实的生产组件来管理而不是一个装上就能忘的依赖。希望这篇文章能帮你少走几个弯路至少别再让 K8s 因为一个健康检查路径把服务杀掉。