Cloud 适配:Consul、Vault、Kubernetes、Eureka 与 Zookeeper
同一个 POST /actuator/serviceregistry 带 status=OUT_OF_SERVICE,在五个适配器里会得到五种不同的事实:Consul 给实例打上维护检查、Eureka 改 InstanceStatus 且引入 overriddenStatus、Zookeeper 改写实例元数据并依赖 LoadBalancer 过滤、Kubernetes 根本没有这条通道、Vault 不参与服务注册。“实例已注册”“健康检查通过”“消费者目录已更新”“业务请求成功”同样是四个可分离的事实。本页沿 Spring Cloud Commons 的 DiscoveryClient/ServiceRegistry 接口,逐适配器回答:注册动作落在哪个远端 API、健康由谁判定、配置坐标与刷新机制是什么、失败时默认是启动失败还是静默降级。
本页是 U35 / #72 的 L06 权威正文,覆盖 22 个冻结知识点(u35-* 前缀)。通用四段模型、supplier 链、实例缓存、健康 supplier、重试与摘流模板在 Cloud发现与负载均衡:实例、缓存与摘流(U32);本页只写“每个具体子项目如何填充模型的每一段”,不重讲 supplier 组合与 35s 缓存。配置读取坐标、Config Server 后端、刷新与 Bus 在 Config与Bus:配置读取、传播与版本回滚(U34);Nacos 运行时在 Nacos注册发现与配置一致性(U40);ZooKeeper 会话、watch 与 quorum 在 ZooKeeper协调与会话恢复(U39)。查询日期 2026-10-27。
版本、模块与维护状态
工程基线 JDK 21、Maven 3.9.9、Boot 4.0.9、Cloud 2025.1.4。Cloud 2025.1.4 BOM 管理五个子项目:Consul 5.0.4、Vault 5.0.3(底层 spring-vault-core 4.0.4)、Kubernetes 5.0.4、Netflix 5.0.3、Zookeeper 5.0.2。固定提交分别为 Consul 7a5ec9776e139a0e1a1470e30fb9337c7bed6738、Vault 7006bcf856038fd9b78199fa52750a375765c091、Kubernetes 0c2d1e201f7112a2c06f0e9c07e7c0bed9932e2f、Netflix 6da718fcea11608dc04a9d900c5636723a4febe0、Zookeeper 801a865c7ef323cc955e1c96d6c029439096e117。source-excerpt 的 tag 字段使用完整提交,SHA-256 对整文件计算,代码为连续原文;BOM 版本、最终依赖树与实际运行是三层不同证据。
| 子项目 | 版本 / 固定提交 | 主要模块与 starter | 本页角色 |
|---|---|---|---|
| Consul | 5.0.4 / 7a5ec977… | consul-core/discovery/config、spring-cloud-starter-consul-discovery、-config | 代表实现公开实验:注册、发现、KV 配置、健康(通过与否以证据 JSON 为准) |
| Vault | 5.0.3 / 7006bcf8… | vault-config(+aws/consul/databases/rabbitmq 扩展)、spring-cloud-starter-vault-config | 代表实现公开实验:KV v2 秘密读取与失败路径(同上) |
| Kubernetes | 5.0.4 / 0c2d1e20… | commons、fabric8-* 与 client-* 两套实现、spring-cloud-starter-kubernetes-fabric8-*/-client-*;另有独立的 -discoveryclient HTTP 实现 | 配置/源码推演,未接集群 |
| Netflix | 5.0.3 / 6da718fc… | eureka-client、eureka-server、对应 starter;仓库只剩 Eureka 两个功能模块 | 客户端语义推演,不搭 server |
| Zookeeper | 5.0.2 / 801a865c… | zookeeper-core/discovery/config、spring-cloud-starter-zookeeper-* | 适配层推演;内核归 U39 |
维护状态逐项核对(spring.io 项目页、Supported-Versions wiki、GitHub API,2026-10-27):五个项目均为 status: ACTIVE、supportPolicy: DOWNSTREAM,5.0.x 的 OSS 支持至 2027-07,仓库均未归档且 2026-10-26 仍有提交。Netflix 的维护模式声明只覆盖旧组件——v2.2.x/v3.0.0 README 的 “Modules In Maintenance Mode” 与 2018 Greenwich 公告列出 archaius、hystrix、ribbon、turbine、zuul 等,并明确 “This does not include the Eureka modules”;5.0.3 里这些旧模块已不在代码库。因此正确表述是“旧 Netflix 组件维护模式、Eureka 仍 ACTIVE”,不是“Netflix 全系停止维护”。Zookeeper 未检索到任何 maintenance/feature-complete/EOL 声明;5.0.2 发布节奏慢于同 train 只是版本事实。Kubernetes 5.0.4 README 明确“目前不支持 Spring Boot AOT 转换或 native image”,本页不承诺 Native。
Consul 5.x 的结构性变化必须按新版讲:5.0.4 移除了 com.ecwid.consul:consul-api 与 Gson,ConsulClient 改为 Spring RestClient + HTTP Interface,端点以 @GetExchange/@PutExchange 声明(spring-cloud-consul-core/.../ConsulClient.java:49-141:/v1/agent/service/register、/v1/health/service/{serviceName}、/v1/kv/{context}?recurse 等)。旧 ecwid 教程里的客户端类名、阻塞 API 与默认值都不能直接搬到 5.0.4。
- 输入:五个坐标(BOM 版本、固定 tag、starter 名、客户端实现族、平台检测)。
- 失败条件:starter 混用(fabric8 与 client 两套自动配置同时上 classpath)、按旧 ecwid API 写扩展、把 train 级支持窗口当成逐项目承诺。
- 反例:“Netflix 已 feature-complete 所以 Eureka 不能用”与“Consul 还依赖 ecwid”都与固定版本源码矛盾。
- 观测点:
mvn dependency:tree的最终版本、IDE 引用的 jar 路径、各项目页的 status/supportPolicy 原文与查询日期。
适配器语义对照矩阵
五个子项目都实现 Spring Cloud 的 DiscoveryClient/ServiceRegistry 接口,但接口相同不代表语义可互换。下表是本页的导航:每一列的具体证据在后续对应小节,Nacos 列只回指 U40。
| 维度 | Consul | Eureka | Kubernetes | Zookeeper(Cloud 适配) | Nacos(U40 回指) |
|---|---|---|---|---|---|
| 注册模型 | 客户端同步调 agent /v1/agent/service/register,失败默认上抛(failFast=true) | 客户端异步复制:先本地置 UP,再靠心跳/复制传到 server,最终一致 | 平台控制:Service/Endpoints 由 K8s 维护,客户端自动注册开关无效 | 客户端经 Curator ServiceDiscovery.registerService 同步写 znode,异常上抛 | 临时实例内存状态+事件、持久实例走一致性协议(U40) |
| 健康来源 | Consul check:默认 HTTP GET {scheme}://{hostname}:{port}/actuator/health 每 10s;可改 TTL 心跳(默认关) | 默认客户端心跳 30s/租约 90s;healthcheck.enabled=true 才传播 Actuator | Endpoint 的 ready 地址;include-not-ready-addresses=false 排除未就绪 | 无内建健康检查;instance_status 元数据 + LoadBalancer 侧过滤 | 客户端上报/服务端探测两模式(U40) |
| 配置坐标 | KV:config/<app>,<profile>/ → config/<app>/ → config/application,<profile>/ → config/application/ | 无配置中心 | ConfigMap/Secret 按 spring.application.name 与 profile 取多个源 | znode 树:config/<app>,<profile> 等四上下文 | dataId/group/namespace(U40) |
| 变更通知 | ConfigWatch 阻塞长轮询(wait 55s + delay 1s)→ RefreshEvent;目录 CatalogWatch(1s + 2s blocking)→ HeartbeatEvent | 客户端每 30s 拉注册表(registry-fetch-interval-seconds) | informer/watch 缓存;目录 CatalogWatch 30s 定时比较 → 仅变化时 HeartbeatEvent | Curator TreeCache 事件 → HeartbeatEvent,无轮询间隔属性 | 客户端 MD5 比对 + 服务端推送(U40) |
| setStatus 语义 | OUT_OF_SERVICE/UP → agent maintenance 开关,其余字符串抛 IllegalArgumentException | InstanceStatus.toEnum + setStatus;CANCEL_OVERRIDE 撤销覆盖状态 | 无对应 setStatus;注册状态由平台掌握 | 写入元数据 instance_status 并 updateService,过滤发生在 LB supplier | 实例健康状态接口(U40) |
| zone | instance-zone/instance-group 只写入注册 metadata,无 LB 自动接线 | EurekaLoadBalancerClientConfiguration 自动写 LoadBalancerZoneConfig | 无 zone 概念(namespace/node 维度另算) | 无 zone 自动接线 | 集群/权重语义(U40) |
| 缓存与刷新 | 消费者侧目录缓存归 U32;Consul 只提供目录与 index | 客户端本地注册表缓存 + 30s 拉取;server 端 read/write 缓存 | informer Lister 缓存,wait-cache-ready=true 默认等待同步 | Curator 缓存 + TreeCache 事件 | 本地容灾快照(U40) |
横向结论先给出:注册时机(同步/异步/平台)、失败默认(上抛/静默/平台兜底)、健康判定者(agent/server/平台/客户端过滤)、配置来源与通知机制四组语义,任意两组都不能互相套用。把 OUT_OF_SERVICE 当成跨注册中心统一语义、把 Consul 的 watch 当成 Nacos 的客户端比对、把 znode 事件一一映射成实例事件,是本页反复出现的反例。
- 输入:同一份业务代码,只替换 starter 与地址。
- 失败条件:跨适配器复制状态字符串、默认值或恢复时序。
- 反例:认为“都实现 DiscoveryClient,所以行为一致”。
- 观测点:逐列回到本页对应小节与 U32 的四段模型;Nacos 列回到 U40。
自动注册、注销、setStatus 与故障恢复差异
自动注册的通用骨架由 Commons 的 AbstractAutoServiceRegistration 承担:监听 WebServerInitializedEvent,拿到真实端口后调用适配器的 ServiceRegistry.register,停止时调用 deregister 并把状态推给适配器(U32 的“自动注册、状态端点与摘流状态链”一节已讲模板,本页只讲各适配器把这一步翻译成什么远端动作)。/actuator/serviceregistry 的 POST 只是把字符串交给 ServiceRegistry.setStatus,端点返回 200 不代表消费者已更新目录。
setStatus 各适配器的远端动作
三个代表性实现:
@Override
public void register(ConsulRegistration reg) {
log.info("Registering service with consul: " + reg.getService());
try {
ResponseEntity<Void> response = this.client.agentServiceRegister(this.properties.getAclToken(),
reg.getService());
NewService service = reg.getService();
if (this.heartbeatProperties.isEnabled() && this.ttlScheduler != null && service.getCheck() != null
&& service.getCheck().getTtl() != null) {
this.ttlScheduler.add(reg.getService());
}
}
catch (RuntimeException e) {
if (this.properties.isFailFast()) {
log.error("Error registering service with consul: " + reg.getService(), e);
ReflectionUtils.rethrowRuntimeException(e);
}
log.warn("Failfast is false. Error registering service with consul: " + reg.getService(), e);
}
}ConsulServiceRegistry.register 直接调 agentServiceRegister;失败时只有 failFast=true(默认,ConsulDiscoveryProperties.java:199)重新抛出,false 只 warn——应用会“带病启动”,目录里可能根本没有自己。开启 TTL 心跳时再由 TtlScheduler.add 进入心跳任务。
@Override
public void register(EurekaRegistration reg) {
if (eurekaInstanceConfigBean != null && eurekaInstanceConfigBean.isAsyncClientInitialization()) {
if (log.isDebugEnabled()) {
log.debug("Initializing client asynchronously...");
}
ExecutorService executorService = Executors.newSingleThreadExecutor();
executorService.submit(() -> {
maybeInitializeClient(reg);
if (log.isDebugEnabled()) {
log.debug("Asynchronous client initialization done.");
}
});
}
else {
maybeInitializeClient(reg);
}
if (log.isInfoEnabled()) {
log.info("Registering application " + reg.getApplicationInfoManager().getInfo().getAppName()
+ " with eureka with status " + reg.getInstanceConfig().getInitialStatus());
}
reg.getApplicationInfoManager().setInstanceStatus(reg.getInstanceConfig().getInitialStatus());
reg.getHealthCheckHandler()
.ifAvailable(healthCheckHandler -> reg.getEurekaClient().registerHealthCheck(healthCheckHandler));
}
private void maybeInitializeClient(EurekaRegistration reg) {
// force initialization of possibly scoped proxies
reg.getApplicationInfoManager().getInfo();
reg.getEurekaClient().getApplications();
}EurekaServiceRegistry.register 先强制初始化客户端(maybeInitializeClient),然后把实例状态设为 initialStatus(默认 UP),若存在 HealthCheckHandler 再注册;真正到达 server 靠后续心跳复制,默认每 30s 一次。deregister 不是删除调用,而是把状态置 DOWN 并依赖 EurekaRegistration.close() 关闭客户端——server 端还要经历租约与驱逐周期。
@Override
public void register(ZookeeperRegistration registration) {
try {
getServiceDiscovery().registerService(registration.getServiceInstance());
}
catch (Exception e) {
rethrowRuntimeException(e);
}
}
private ServiceDiscovery<ZookeeperInstance> getServiceDiscovery() {
return this.serviceDiscovery;
}
@Override
public void deregister(ZookeeperRegistration registration) {
try {
getServiceDiscovery().unregisterService(registration.getServiceInstance());
}
catch (Exception e) {
rethrowRuntimeException(e);
}
}
@Override
public void afterSingletonsInstantiated() {
try {
getServiceDiscovery().start();
}
catch (Exception e) {
rethrowRuntimeException(e);
}
}ZookeeperServiceRegistry 的 register/deregister 就是 Curator 的 registerService/unregisterService,异常一律 rethrowRuntimeException;afterSingletonsInstantiated 阶段才 serviceDiscovery.start()。注册失败与 Consul 类似是启动失败,不像 Eureka 那样后台补注册。
| 适配器 | 触发后远端动作 | 默认失败行为 | setStatus("OUT_OF_SERVICE") | 观测点 |
|---|---|---|---|---|
| Consul | PUT /v1/agent/service/register(含 check) | failFast=true 启动失败 | agentServiceSetMaintenance(id,true),等价新增维护检查 | /v1/agent/services、/v1/health/service/{name} |
| Eureka | 本地置 UP + 注册 HealthCheckHandler,心跳复制 | 异步;server 不可达不阻断启动 | InstanceStatus.toEnum + setStatus;CANCEL_OVERRIDE 撤销 override | getStatus 返回 status 与 overriddenStatus |
| Kubernetes | 无客户端注册(平台控制) | 不适用 | 无对应实现 | Service/Endpoints 由平台维护 |
| Zookeeper | Curator registerService(临时节点) | 异常上抛,启动失败 | 写 instance_status 元数据 + updateService | 直接 getInstances 仍能看到该实例,只有 LB supplier 过滤 |
| Vault | 不参与注册 | — | — | 只作为配置源 |
注册中心不可用时的恢复差异
故障恢复同样不能互相套用。Consul:agent 不可达时注册失败(默认启动失败);已注册实例的 HTTP check 由 agent 执行,agent 自己失联时目录与检查状态取决于 agent 本地状态与集群;ConfigWatch 首次失败且 config.fail-fast=true 才抛异常,之后只 warn(ConfigWatch.java:192-209)。Eureka:消费者拿的是本地缓存的注册表,server 全挂时旧目录仍可被读取;server 端 enable-self-preservation=true(续约阈值 0.85)在网络抖动时保住未续约实例,表现为“实例其实已死但目录还在”。Zookeeper:临时节点绑 session,session 过期节点消失、重连不等于旧节点仍在(机制归 U39),Curator 会重新注册;期间目录可能短暂为空。Kubernetes:informer watch 断线后自动重连并从缓存重建,wait-cache-ready 与 60s 超时决定启动是否被阻断。Vault:fail-fast=false(默认)意味着不可用时应用以“无秘密”启动,属于静默降级。
- 输入:
WebServerInitializedEvent、适配器属性、注册中心可达性。 - 失败条件:failFast=false 的无注册启动、Eureka server 不可达、ZK session 过期、K8s watch 中断、Vault 不可达。
- 反例:把“POST 返回 200”当全局摘流完成;把“注册中心挂了业务仍可调用”当普遍安全;把 Eureka 的 DOWN 当立即删除。
- 观测点:各注册中心自己的目录 API/端点、客户端本地缓存、
/actuator/serviceregistry的 GET 输出(适配器自定义结构)。
Consul 注册:agent API、实例身份与健康检查
Consul 注册是客户端同步调用本机/远端 agent:ConsulServiceRegistry.register → agentServiceRegister → PUT /v1/agent/service/register。默认连接 localhost:8500 且未配置 scheme 时回落 http(ConsulProperties.java:42,48,52;ConsulAutoConfiguration.java:101-111,缺省 scheme 回落 http 在 :108)。实例 ID 默认由 IdUtils.getDefaultInstanceId(env, includeHostnameInInstanceId=false) 生成(通常是 应用名:端口 形状),再经 normalizeForDns 规整:必须以字母开头、字母或数字结尾,内部只留字母数字与连字符(ConsulAutoRegistration.java:159-190);自定义 instance-id 也走同一规整,非法 ID 直接抛 IllegalArgumentException。
健康检查的关键默认值(ConsulDiscoveryProperties.java:79,88):register-health-check=true、health-check-path=/actuator/health、health-check-interval=10s。ConsulAutoRegistration.createCheck 的行为分叉是本节核心:
public static NewService.Check createCheck(Integer port, HeartbeatProperties ttlConfig,
ConsulDiscoveryProperties properties) {
NewService.Check check = new NewService.Check();
if (StringUtils.hasText(properties.getHealthCheckCriticalTimeout())) {
check.setDeregisterCriticalServiceAfter(properties.getHealthCheckCriticalTimeout());
}
if (ttlConfig.isEnabled()) {
// FIXME 3.0.0
// https://github.com/spring-cloud/spring-cloud-consul/issues/614
check.setTtl(ttlConfig.getTtl().getSeconds() + "s");
return check;
}
Assert.notNull(port, "createCheck port must not be null");
Assert.isTrue(port > 0, "createCheck port must be greater than 0");
if (properties.getHealthCheckUrl() != null) {
check.setHttp(properties.getHealthCheckUrl());
}
else {
check.setHttp(String.format("%s://%s:%s%s", properties.getScheme(), properties.getHostname(), port,
properties.getHealthCheckPath()));
}
check.setHeader(properties.getHealthCheckHeaders());
check.setInterval(properties.getHealthCheckInterval());
check.setTimeout(properties.getHealthCheckTimeout());
check.setTlsSkipVerify(properties.getHealthCheckTlsSkipVerify());
return check;
}health-check-critical-timeout非空时映射为 Consul 的deregisterCriticalServiceAfter——critical 持续该时长后 agent 自动注销;默认空,意味着检查失败只会让实例变 critical,不会自动消失。heartbeat.enabled=true时不做 HTTP check,改为 TTL check(ttl默认 30s),并提前返回;TTL 心跳由客户端发送,间隔 = ttl × interval-ratio(默认 2/3,即约 20s,且被夹逼在 1s 与 ttl-1s 之间;HeartbeatProperties.java:45,48,52)。默认关闭,源码 TODO 注释给出理由:漏心跳会把实例打成 critical。- 否则构造 HTTP check:URL 取
health-check-url,缺省拼{scheme}://{hostname}:{port}{health-check-path},并带 interval、timeout、headers、tls-skip-verify。
TTL 心跳不是“发出去就算活着”:任务按应用状态(默认取 Actuator health,use-actuator-health=true)选择 PASSING/WARNING/CRITICAL 对应的 agentCheckPass/Warn/Fail:
@Override
public void run() {
ConsulClient client = this.ttlScheduler.client;
CheckStatus status = statusSupplier.get();
switch (status) {
case PASSING:
possiblyReregisterIfFails(() -> client.agentCheckPass(checkId, null,
this.ttlScheduler.discoveryProperties.getAclToken()));
logHeartbeatSent(status);
break;
case WARNING:
possiblyReregisterIfFails(() -> client.agentCheckWarn(checkId, null,
this.ttlScheduler.discoveryProperties.getAclToken()));
logHeartbeatSent(status);
break;
case CRITICAL:
possiblyReregisterIfFails(() -> client.agentCheckFail(checkId, null,
this.ttlScheduler.discoveryProperties.getAclToken()));
logHeartbeatSent(status);
break;
default:
log.debug(String.format("Not sending consul heartbeat for %s (%s)", checkId, status));
}
}
private void logHeartbeatSent(CheckStatus status) {
log.debug(String.format("Sent consul heartbeat for %s (%s)", checkId, status));
}
private void possiblyReregisterIfFails(Runnable consulClientCall) {
try {
consulClientCall.run();
}
catch (HttpStatusCodeException e) {
if (this.ttlScheduler.heartbeatProperties.isReregisterServiceOnFailure()
&& this.ttlScheduler.reregistrationPredicate.isEligible(e)) {
log.warn(e.getMessage());
NewService registeredService = this.ttlScheduler.registeredServices.get(this.serviceId);
if (registeredService != null) {
if (log.isInfoEnabled()) {
log.info("Re-register " + registeredService);
}
this.ttlScheduler.client.agentServiceRegister(
this.ttlScheduler.discoveryProperties.getAclToken(), registeredService);
}
else {
log.warn("The service to re-register is not found.");
}
}
else {
throw e;
}
}
}reregister-service-on-failure 默认 false:心跳请求失败(404/5xx 且谓词匹配)时不重注册而是把异常上抛,注册信息可能已从 agent 丢失。排查“实例还在目录但永远 critical”时,要同时看 check 类型、URL 可达性与 ACL token。
管理端口可注册成独立服务:要求 spring.cloud.service-registry.auto-registration.register-management(Commons 属性,ConsulAutoRegistration 继承使用)、管理端口存在且与主端口不同(ConsulAutoRegistration.shouldRegisterManagement),服务名/ID 追加 management-suffix(默认 management);多出一个 -management 服务不是重复注册 bug。
- 输入:
spring.application.name、运行时端口、check 属性、ACL token(取值链consul.token>CONSUL_TOKEN>spring.cloud.consul.token>SPRING_CLOUD_CONSUL_TOKEN)。 - 失败条件:agent 不可达、ACL 403、
health-check-url指向错误端口/路径、TTL 开启但应用从未上报。 - 反例:认为“注册 200 = 消费者可调用”;认为“默认用 TTL 心跳”;认为 check 失败会自动摘除。
- 观测点:
/v1/agent/services看注册体与 check 定义、/v1/health/service/{name}看聚合状态、应用日志中的Registering service with consul与 TTL 心跳 debug 行。
Consul 发现:health/service、queryPassing 与 catalog watch
ConsulDiscoveryClient.getInstances 的查询入口是 /v1/health/service/{serviceName},参数包括 passing、ACL token、query tags 与 consistency:
@Override
public List<ServiceInstance> getInstances(final String serviceId) {
ConsulDiscoveryProperties.ConsistencyMode consistencyModeProp = this.properties.getConsistencyMode();
ConsulClient.ConsistencyMode consistencyMode = ConsulClient.ConsistencyMode.DEFAULT;
if (consistencyModeProp != null) {
consistencyMode = ConsulClient.ConsistencyMode.valueOf(consistencyModeProp.name().toUpperCase(Locale.ROOT));
}
return getInstances(serviceId, new QueryParams(consistencyMode));
}
public List<ServiceInstance> getInstances(final String serviceId, final QueryParams queryParams) {
List<ServiceInstance> instances = new ArrayList<>();
addInstancesToList(instances, serviceId, queryParams);
return instances;
}
private void addInstancesToList(List<ServiceInstance> instances, String serviceId, QueryParams queryParams) {
String[] queryTags = properties.getQueryTagsForService(serviceId);
List<String> tags = null;
if (queryTags != null) {
tags = Arrays.asList(queryTags);
}
ResponseEntity<List<HealthService>> healthServices = client.getHealthServices(serviceId,
properties.isQueryPassing(), properties.getAclToken(), tags, queryParams);
for (HealthService service : healthServices.getBody()) {
instances.add(new ConsulServiceInstance(service, serviceId));
}
}三个默认值决定行为边界:
query-passing默认 false(ConsulDiscoveryProperties.java:184)。passing=false时 Consul 返回该服务的全部实例(含 critical),Spring 客户端不会再按 Check 状态二次过滤。只想拿健康实例必须显式spring.cloud.consul.discovery.query-passing=true,且要理解这是服务端过滤:目录不变而消费者看到集合不同。- 一致性模式默认 DEFAULT(
ConsulDiscoveryProperties.java:147附近,consistencyMode=DEFAULT),可选STALE;跨 DC 查询用datacenters,查询标签用defaultQueryTag/service-query-tags——标签不匹配会得到空列表,不是错误。 - 实例地址解析顺序是
service.address → node.address → node 名称,IPv6 自动加方括号;secure从 Service.meta 读取(注册端把scheme=https?写入 meta)——HTTP/HTTPS 选择靠元数据而不是端口猜测。
CatalogWatch:blocking query 与 HeartbeatEvent
目录变更通知是 ConsulCatalogWatch:以 catalog-services-watch-timeout(默认 2s)的 blocking query 调 /v1/catalog/services,带上上次 Consul index,外层再以 catalog-services-watch-delay(默认 1000ms)固定延迟调度(ConsulCatalogWatch.java:97,120-138;属性见 ConsulDiscoveryProperties.java:122,125)。注意它与 ConfigWatch 的差别:CatalogWatch 每轮都发布 HeartbeatEvent(携带 index),由 U32 的目录缓存/健康 supplier 链决定是否重取;ConfigWatch 只在 index 变化时发布 RefreshEvent。把两者说成同一种“推送”都不准确——底层都是客户端发起的带 index 阻塞查询。
- 输入:serviceId、
query-passing、query tags、consistency、ACL token。 - 失败条件:agent 不可达(抛错或空目录)、过滤标签写错、跨 DC 未指定 datacenter、误以为客户端会滤掉 critical。
- 反例:
query-passing=false时把 critical 实例当可调用;把 CatalogWatch 的 1s 当成“变更在 1s 内必达消费者”。 - 观测点:直接
curl /v1/health/service/{name}?passing与客户端getInstances的结果对比;catalog index;U32 缓存时间线(35s TTL 与刷新事件)。
Consul KV 配置与 Config Watch
当前入口是 ConfigData:spring.config.import=consul:(ConsulConfigDataLocationResolver.PREFIX="consul:",:57),缺 import 时 KV 根本不会被读。import 里可以带 agent 地址(consul:127.0.0.1:18500),import 中的 host/port 覆盖 spring.cloud.consul.host/port(同文件 :202-206);带路径时按 ; 分隔自定义上下文(:123-136)。格式与键空间(ConsulConfigProperties.java:59,66):
| format | 读取内容 | 键映射 | 备注 |
|---|---|---|---|
KEY_VALUE(默认) | 前缀下所有 KV | 去上下文前缀后 / 换成 . | 最常用;每个键就是属性 |
PROPERTIES / YAML | 只读 data-key(默认 data)指向的整块内容 | 块内解析 | properties 按 ISO-8859-1、YAML 按 UTF-8;不写对 data-key 就“什么也读不到” |
FILES | .yml/.yaml/.properties 后缀的键 | profile 分隔符强制为 - | 面向 git2consul 类工具 |
自动上下文由 ConsulPropertySources.generateAutomaticContexts 生成并对每个 prefix 整体 reverse,使最具体者排最前(ConsulPropertySources.java:62-95;config.adoc 示例同序):config/<app>,<profile>/ → config/<app>/ → config/application,<profile>/ → config/application/。property source 初始化时按 format 分派:
public void init() {
if (!this.context.endsWith("/")) {
this.context = this.context + "/";
}
if (this.context.startsWith("/")) {
this.context = this.context.substring(1);
}
if (this.context.contains("//")) {
this.context = this.context.replace("//", "/");
}
ResponseEntity<List<GetValue>> response = this.source.getKVValues(this.context,
this.configProperties.getAclToken());
this.initialIndex = ConsulHeaders.getConsulIndex(response);
final List<GetValue> values = response.getBody();
ConsulConfigProperties.Format format = this.configProperties.getFormat();
switch (format) {
case KEY_VALUE:
parsePropertiesInKeyValueFormat(values);
break;
case PROPERTIES:
case YAML:
parsePropertiesWithNonKeyValueFormat(values, format);
}
}配置读取失败默认 fail-fast:spring.cloud.consul.config.fail-fast=true(ConsulConfigProperties.java:76)。注意它与 discovery 的 failFast 是两套键、两个组件。
Config Watch 是带 index 的阻塞长轮询,不是服务端推送——这是本页最高风险的结论之一:
ResponseEntity<List<GetValue>> response = this.consul.getKVValues(context, aclToken,
(long) properties.getWatch().getWaitTime(), currentIndex);
// if response.value == null, response was a 404, otherwise it was a
// 200, reducing churn if there wasn't anything
if (HttpStatus.OK.isSameCodeAs(response.getStatusCode()) && response.hasBody()
&& !response.getBody().isEmpty()) {
Long newIndex = ConsulHeaders.getConsulIndex(response);
if (newIndex != null && !newIndex.equals(currentIndex)) {
// don't publish the same index again, don't publish the first
// time (-1) so index can be primed
if (!this.consulIndexes.containsValue(newIndex) && !currentIndex.equals(-1L)) {
if (log.isTraceEnabled()) {
log.trace("Context " + context + " has new index " + newIndex);
}
RefreshEventData data = new RefreshEventData(context, currentIndex, newIndex);
this.publisher.publishEvent(new RefreshEvent(this, data, data.toString()));
}
else if (log.isTraceEnabled()) {
log.trace("Event for index already published for context " + context);
}
this.consulIndexes.put(context, newIndex);
}
else if (log.isTraceEnabled()) {
log.trace("Same index for context " + context);
}
}机制拆开看(ConfigWatch.java:97,159-209;ConsulConfigProperties.java:268,271,274):
start()用scheduleWithFixedDelay按spring.cloud.consul.config.watch.delay(默认 1000ms)调度watchConfigKeyValues;每轮对每个 context 调getKVValues(context, aclToken, waitTime, currentIndex)。waitTime默认 55s,必须小于 ConsulClient 默认 60s 超时;服务端在 index 变化或超时后返回,所以一轮的墙钟时间≈“阻塞到变化或 55s”,轮间再停 1s——变更传播上界由 wait+delay 决定,不是即时。- 仅当 HTTP 200、body 非空、index 非空且与上次不同、且不是首次(-1)时发布
RefreshEvent;首次只用于 prime index,重复 index 去重(consulIndexes.containsValue)。收到事件的RefreshEvent与 U34 的本地刷新链同源,但触发者是 Consul watch,不是 Config Server。 - 首次查询失败且
fail-fast=true才上抛,之后每轮失败只 warn(ConfigWatch.java:192-209)——注册中心故障后恢复不需要重启,但丢失的变更只能等下一轮 index 变化或显式/actuator/refresh。
spring.cloud.consul.config.watch.enabled=false 时 watch bean 不装配(ConsulConfigAutoConfiguration 的 matchIfMissing=true 条件反转),KV 变更只能靠重启或显式刷新——公开实验的第三条观察行 consulConfigNoAutoRefresh 专门验证这个边界。它与 Nacos 的客户端 MD5 比对(U40)、Config/Bus 的显式刷新(U34)是三种不同机制,不能因为“都能自动发现变更”合并结论。
discovery-first 定位 Config Server(U34 归属声明指向本页的适配侧内容):spring.cloud.config.discovery.enabled=true(默认 false)时,Config Client 不再直连 URL,而是通过 DiscoveryClient 查 serviceId(默认 configserver)再拼 /{app}/{profile}/{label};Eureka 场景可用实例 metadata user/password/configPath 传递连接信息(Config 5.0.6 client.adoc:33-74)。因此 Consul 侧的注册质量(query-passing、健康检查)会直接决定客户端能否找到 Config Server;启动多一次发现往返,失败模式也变成“发现得到但 Config Server 不可用”。U34 正文相应行已回改为本页链接(旧句曾声称该讲解在 U34 正文内,属归属不符,本次由 U35 补齐)。
- 输入:
spring.config.import、prefix(默认config)、application name、profiles、format/data-key、watch 三属性。 - 失败条件:缺 import、
optional:掩盖必需失败、format 与键空间不匹配、watch 关闭时期待自动刷新、wait-time ≥ 客户端超时。 - 反例:把 ConfigWatch 说成服务端 push;把
data-key当成所有 format 通用;认为 KV 变更会经 Bus 广播。 - 观测点:
/v1/kv/config/...?recurse原始树、/actuator/env(脱敏)中的属性源、RefreshEvent/watch debug 日志、证据 JSON 的三条 Consul 观察行。
Consul 与 LoadBalancer/zone 边界
Consul 只负责把实例目录交给 U32 的 supplier 链;spring-cloud-consul-discovery 模块内没有 ServiceInstanceListSupplier 或 LoadBalancerZoneConfig 相关自动配置(固定 commit 全模块检索无命中),这与 Eureka 的 zone 自动接线形成鲜明对比。Consul 提供的 zone 只是数据:
注册端:
instance-zone写入 metadata 的键名由default-zone-metadata-name决定(默认zone),instance-group写入group,secure按 scheme 写入(ConsulAutoRegistration.getMetadata)。消费端:metadata 原样出现在
ServiceInstance.getMetadata(),没有任何代码把它自动写进spring.cloud.loadbalancer.zone。想让同区优先,必须显式设置spring.cloud.loadbalancer.zone或自定义 supplier/配置;U32“zone 和 hint 的优先与宽松回退”一节的“Eureka 有专门自动集成,其他客户端不要假设能自动推断本区”在这里得到源码级确认(U32 原文是概括,本页给出分界)。输入:注册时的 zone/group 属性、消费者侧 LB 配置。
失败条件:以为 Consul 自动同区优先;只写 metadata 不配 LB zone;把
instance-group当 LB 分组(它只是 metadata)。反例:“Eureka 有的 zone 接线 Consul 应该也有”。
观测点:
/v1/agent/services的 Meta、客户端getInstances(...).getMetadata()、LoadBalancerZoneConfig实际值(默认空)。
Vault 客户端 KV v2:vault:// 与运行时 mount 探测
Spring Cloud Vault 客户端通过 spring.config.import=vault: 把秘密接进 Environment(VaultConfigDataLocationResolver:vault: 前缀,默认位置导入所有已启用 secret backend;vault:secret/my-app 是显式上下文路径;?prefix=foo. 给该位置所有键加前缀,:130-133)。缺 import 时没有 Vault 属性源。KV 默认值(VaultKeyValueBackendProperties.java:52,58,63,69,74):enabled=true、backend=secret、default-context=application、profile-separator=/、application-name=application。
@ConfigurationProperties(VaultKeyValueBackendProperties.PREFIX)
@Validated
public class VaultKeyValueBackendProperties implements EnvironmentAware, VaultKeyValueBackendPropertiesSupport {
/**
* Configuration prefix for config properties.
*/
public static final String PREFIX = "spring.cloud.vault.kv";
/**
* Enable the key-value backend.
*/
private boolean enabled = true;
/**
* Name of the default backend.
*/
@NotEmpty
private String backend = "secret";
/**
* Name of the default context.
*/
private String defaultContext = "application";
/**
* Profile-separator to combine application name and profile.
*/
@NotEmpty
private String profileSeparator = "/";
/**
* Application name to be used for the context.
*/
private String applicationName = "application";
/**
* List of active profiles.
* @since 3.0
*/
@Nullable
private List<String> profiles;
/**
* Key-Value backend version. Currently supported versions are:
* <ul>
* <li>Version 1 (unversioned key-value backend).</li>
* <li>Version 2 (versioned key-value backend).</li>
* </ul>
*/
private int backendVersion = 2;
public VaultKeyValueBackendProperties() {
}上下文组合与顺序由 buildContexts 决定:
public static List<String> buildContexts(VaultKeyValueBackendPropertiesSupport properties, List<String> profiles) {
String appName = properties.getApplicationName();
String defaultContext = properties.getDefaultContext();
Set<String> contexts = new LinkedHashSet<>(
buildContexts(defaultContext, profiles, properties.getProfileSeparator()));
for (String applicationName : StringUtils.commaDelimitedListToSet(appName)) {
contexts.addAll(buildContexts(applicationName, profiles, properties.getProfileSeparator()));
}
List<String> result = new ArrayList<>(contexts);
Collections.reverse(result);
return result;
}
/**
* Create a list of context names from a combination of application name and
* application name with profile name. Using an empty application name will return an
* empty list.
* @param applicationName the application name. May be empty.
* @param profiles active application profiles.
* @param profileSeparator profile separator character between application name and
* profile name.
* @return list of context names.
*/
public static List<String> buildContexts(String applicationName, List<String> profiles, String profileSeparator) {
if (!StringUtils.hasText(applicationName)) {
return Collections.emptyList();
}
List<String> contexts = new ArrayList<>(profiles.size() + 1);
contexts.add(applicationName);
for (String profile : profiles) {
if (!StringUtils.hasText(profile)) {
continue;
}
String contextName = applicationName + profileSeparator + profile.trim();
if (!contexts.contains(contextName)) {
contexts.add(contextName);
}
}
return contexts;
}默认生成四个上下文并 reverse,使最具体者最先:/secret/{app}/{profile} → /secret/{app} → /secret/application/{profile} → /secret/application。application-name 支持逗号分隔多应用名,各自展开;profile 为空时跳过 profile 组合。
KV v2 的 data/ 由客户端在读取时探测 mount 后自动插入,不要手工拼路径:
public Secrets read(SecretBackendMetadata secretBackendMetadata) {
Assert.notNull(secretBackendMetadata, "SecureBackendAccessor must not be null!");
log.info(String.format("Fetching config from Vault at: %s", secretBackendMetadata.getPath()));
try {
VaultResponse vaultResponse;
if (this.keyValueDelegate.isVersioned(secretBackendMetadata.getPath())) {
vaultResponse = this.keyValueDelegate.getSecret(secretBackendMetadata.getPath());
}
else {
vaultResponse = this.vaultOperations.read(secretBackendMetadata.getPath());
}
if (vaultResponse == null) {
log.info(String.format("Could not locate PropertySource: %s", "key not found"));
return null;
}
Map<String, Object> data = JsonMapFlattener.flatten(vaultResponse.getRequiredData());
PropertyTransformer propertyTransformer = secretBackendMetadata.getPropertyTransformer();
data = propertyTransformer.transformProperties(data);
return createSecrets(vaultResponse, data);
}
catch (VaultException e) {
if (this.properties.isFailFast()) {
throw new IllegalStateException(
"Could not locate PropertySource and the fail fast property is set, failing.", e);
}
else {
log.warn(String.format("Could not locate PropertySource: %s", e.getMessage()));
}
}
return null;
}VaultConfigTemplate.read 用 KeyValueDelegate.isVersioned(path) 判断:v2 走 keyValueDelegate.getSecret(path)(自动插 data/ 并解包 data),v1 走 vaultOperations.read。判定方式是运行时查询 mount 信息(spring-vault-core 4.0.4 的 KeyValueDelegate 调 sys/internal/ui/mounts/{path} 并读取 options.version;本地 jar 字节码中 v2 路径模板为 %sdata/%s,对应仓库 VaultKeyValueBackendProperties.java:144-149 的弃用说明“kv version is determined during secret retrieval”)。所以:
spring.cloud.vault.kv.backend-version默认 2 但已@Deprecated(:90,144-149),不应作为行为依据;把 v1 数据放进 v2 mount 或反过来,会得到 404 或形状不符。- 每个上下文的读取失败(如 404)默认跳过该上下文而不是整体失败(
config-data.adoc);vaultResponse == null只记录 “key not found”。 spring.cloud.vault.config.order(默认 0,Bootstrap 时代属性、已 Deprecated)决定 Vault 属性源相对优先级,PriorityOrdered;不要说“Vault 最高”,要列顺序。
默认属性源的属性落入 VaultPropertySource:
public void init() {
try {
this.secrets = this.source.read(this.secretBackendMetadata);
if (this.secrets != null) {
this.properties.putAll(this.secrets.getRequiredData());
}
}
catch (RuntimeException e) {
String message = String.format("Unable to read properties from Vault using %s for %s ", getName(),
this.secretBackendMetadata.getVariables());
if (this.failFast) {
throw e;
}
log.error(message, e);
}
}init 把读到的 secret 全量 putAll;failFast=false 时异常只 log.error 后继续,属性源为空——应用“无秘密启动”。
- 输入:import 位置、
spring.application.name、profiles、backend 名、mount 版本(v1/v2)。 - 失败条件:缺 import、v1/v2 混放、路径拼错(例如自己写了
data/又赶上客户端自动插入)、token 无 policy(403)。 - 反例:认为
backend-version=2决定实际版本;认为 404 会让启动失败(默认不会)。 - 观测点:
vault kv get -mount=secret <path>与客户端/actuator/env(脱敏)、Vault 审计日志中的实际请求路径(应含data/)。
Vault 认证与租约生命周期
默认认证是静态 TOKEN:spring.cloud.vault.authentication=TOKEN(VaultProperties.java:149),spring.cloud.vault.token 显式配置;缺失时回退读取 ~/.vault-token(ClientAuthenticationFactory.java:393-412,Paths.get(SystemProperties.get("user.home"), ".vault-token")),这既是便利也是风险——本机 CLI 登录过 Vault 的开发者可能“意外认证成功”。其他认证方式(AppRole、Kubernetes、AWS、Azure、GCP、GitHub、Cert、Cubbyhole、PCF)走同一工厂的其它分支,未运行。
连接与失败默认值(VaultProperties.java:63,68,73,100,105,110):https://localhost:8200、连接超时 5000ms、读超时 15000ms、fail-fast=false。取不到秘密只告警不中断启动(上一节源码);需要启动即失败时显式 spring.cloud.vault.fail-fast=true(advanced-topics.adoc)。
public static final String PREFIX = "spring.cloud.vault";
/**
* Enable Vault config server.
*/
private boolean enabled = true;
/**
* Vault server host.
*/
private String host = "localhost";
/**
* Vault server port.
*/
private int port = 8200;
/**
* Protocol scheme. Can be either "http" or "https".
*/
private String scheme = "https";
/**
* Vault URI. Can be set with scheme, host and port.
*/
@Nullable
private String uri;
/**
* Vault namespace (requires Vault Enterprise).
*/
@Nullable
private String namespace;
/**
* Reactive properties.
*/
private Reactive reactive = new Reactive();
/**
* Discovery properties.
*/
private Discovery discovery = new Discovery();
/**
* Connection timeout.
*/
private int connectionTimeout = 5000;
/**
* Read timeout.
*/
private int readTimeout = 15000;
/**
* Fail fast if data cannot be obtained from Vault.
*/
private boolean failFast = false;
/**
* Static vault token. Required if {@link #authentication} is {@code TOKEN}.
*/
@Nullable
private String token;租约、续租与静态 token
租约与会话生命周期默认开启(_configprops.adoc;VaultProperties.java:1306,1431):spring.cloud.vault.config.lifecycle.enabled=true 管理 secret lease 的续租/轮换,应用关闭时撤销可续租约;spring.cloud.vault.session.lifecycle.enabled=true 管理登录 token 的续期、重新登录与关闭撤销(expiry-threshold=7s、refresh-before-expiry=5s)。静态 token 不被续租也不被撤销(advanced-topics.adoc:“Static tokens are not renewed or revoked”),所以把静态 root token 放进生产配置,等于既无轮换也无回收。ConfigData 路径把租约接入 SecretLeaseContainer:
private ConfigData loadConfigData(VaultConfigLocation location, ConfigurableBootstrapContext bootstrap,
VaultProperties vaultProperties) {
if (location.getSecretBackendMetadata() instanceof ApplicationEventPublisherAware) {
bootstrap.addCloseListener(event -> {
((ApplicationEventPublisherAware) location.getSecretBackendMetadata())
.setApplicationEventPublisher(event.getApplicationContext());
});
}
if (vaultProperties.getConfig().getLifecycle().isEnabled()) {
RequestedSecret secret = getRequestedSecret(location.getSecretBackendMetadata());
if (vaultProperties.isFailFast()) {
return new ConfigData(Collections.singleton(createLeasingPropertySourceFailFast(
bootstrap.get(SecretLeaseContainer.class), secret, location.getSecretBackendMetadata())));
}
return createConfigData(() -> createLeasingPropertySource(bootstrap.get(SecretLeaseContainer.class), secret,
location.getSecretBackendMetadata()));
}
return createConfigData(() -> {
VaultConfigTemplate configTemplate = bootstrap.get(VaultConfigTemplate.class);
return createVaultPropertySource(configTemplate, vaultProperties.isFailFast(),
location.getSecretBackendMetadata());
});
}lifecycle 开启时用 RequestedSecret(KV 为 rotating)构造 leasing property source;failFast 分支先取首个值再返回。Vault 不可用时是否阻断启动、租约失败是否告警,取决于这一层与 fail-fast 的组合,不是单一开关。
- 输入:
authentication、token 或~/.vault-token、lifecycle 开关、fail-fast。 - 失败条件:token 过期/无 policy、误读本机 token 文件、静态 token 被当作会轮换、续租失败无告警。
- 反例:“默认 fail-fast 会挡住无秘密启动”(默认 false);“开了 lifecycle 静态 token 也会被撤销”。
- 观测点:
/v1/auth/token/lookup-self、Vault 审计日志、客户端关闭时的 revoke 请求、/actuator/health的 Vault indicator。
Vault 客户端与 Config Server Vault 后端不可互推
这是两个组件、两套键、两套默认值(C5):
| 维度 | Spring Cloud Vault 客户端 5.0.3 | Config Server 5.0.6 的 Vault 后端 |
|---|---|---|
| 入口依赖 | spring-cloud-starter-vault-config | spring-cloud-config-server + spring-vault-core |
| 启用方式 | spring.config.import=vault: | spring.profiles.active=vault + spring.cloud.config.server.vault.* |
| KV 版本 | 运行时按 mount 探测;spring.cloud.vault.kv.backend-version 默认 2 且 @Deprecated | spring.cloud.config.server.vault.kv-version 默认 1(VaultEnvironmentProperties.java:93) |
| 认证 | spring.cloud.vault.token 或 ~/.vault-token | X-Config-Token 请求头(每请求携带,不是客户端 token) |
| label | 不适用 | enable-label 默认 false,label 不参与选秘密 |
| 回滚边界 | 不涉及 | “Git revert 不会自动回退 Vault”(U34 已讲,仅对服务端后端成立) |
典型误用是把 U34 的 kv-version: 2 配置句搬给客户端,或把 X-Config-Token 当客户端认证头。客户端侧的正确写法是 spring.cloud.vault.kv.backend(默认 secret)加 import;服务端侧才是 spring.cloud.config.server.vault.kv-version。两套代码路径(VaultConfigDataLoader vs Config Server 的 VaultEnvironmentRepository)连异常处理都不同:客户端默认跳过 404 上下文并继续,服务端行为由 fail-fast 与 composite 决定(U34)。
- 输入:明确“谁是客户端、谁是服务端后端”。
- 失败条件:键名互换、默认值互推、把服务端 token 当客户端凭据。
- 反例:“Vault 的 KV 版本默认都是 2”;“
X-Config-Token配在客户端上”。 - 观测点:依赖树(starter vs config-server)、
/actuator/env的属性源名、Config Server 响应头与客户端 import 声明。
Kubernetes 发现:namespace、过滤与 readiness
Kubernetes 适配有三套客户端实现:fabric8(spring-cloud-starter-kubernetes-fabric8-*)、官方 Java client(-client-*)、以及依赖 Discovery Server 的 HTTP 实现(spring-cloud-starter-kubernetes-discoveryclient)。starter 与客户端库不能混用(fabric8 与 client-java 的自动配置类不同),教材示例必须按 starter 讲。发现默认 enabled=true,但还需 CloudPlatform=KUBERNETES(自动探测或 spring.main.cloud-platform)与 spring.cloud.discovery.enabled 同时成立(ConditionalOnKubernetesDiscoveryEnabled)。
发现客户端基于 informer 缓存,不是每次调用打 API Server:
@Override
public List<String> getServices() {
List<String> services = serviceListers.stream()
.flatMap(serviceLister -> serviceLister.list().stream())
.filter(predicate)
.map(s -> s.getMetadata().getName())
.distinct()
.toList();
LOG.debug(() -> "will return services : " + services);
return services;
}
@Override
public List<ServiceInstance> getInstances(String serviceId) {
Objects.requireNonNull(serviceId, "serviceId must be provided");
List<V1Service> allServices = serviceListers.stream()
.flatMap(x -> x.list().stream())
.filter(service -> service.getMetadata() != null)
.filter(service -> serviceId.equals(service.getMetadata().getName()))
.toList();
List<ServiceInstance> serviceInstances = allServices.stream()
.filter(predicate)
.flatMap(service -> serviceInstances(service).stream())
.collect(Collectors.toCollection(ArrayList::new));
if (properties.includeExternalNameServices()) {
LOG.debug(() -> "Searching for 'ExternalName' type of services with serviceId : " + serviceId);
List<V1Service> externalNameServices = allServices.stream()
.filter(s -> s.getSpec() != null)
.filter(s -> EXTERNAL_NAME.equals(s.getSpec().getType()))
.toList();
for (V1Service service : externalNameServices) {
ServiceMetadata serviceMetadata = serviceMetadata(service);
Map<String, String> serviceInstanceMetadata = serviceInstanceMetadata(Map.of(), serviceMetadata,
properties);
KubernetesClientInstanceIdHostPodNameSupplier supplierOne = KubernetesClientInstanceIdHostPodNameSupplier
.instanceIdHostPodNameSupplier(service);
ServiceInstance externalNameServiceInstance = externalNameServiceInstance(serviceMetadata, supplierOne,
serviceInstanceMetadata);
serviceInstances.add(externalNameServiceInstance);
}
}
return serviceInstances;
}默认值集中在 record 上:
// @formatter:off
@ConfigurationProperties(KubernetesDiscoveryProperties.PREFIX)
public record KubernetesDiscoveryProperties(
@DefaultValue("true") boolean enabled, boolean allNamespaces,
@DefaultValue Set<String> namespaces,
@DefaultValue("true") boolean waitCacheReady,
@DefaultValue("60") long cacheLoadingTimeoutSeconds,
boolean includeNotReadyAddresses, String filter,
@DefaultValue({"443", "8443"}) Set<Integer> knownSecurePorts,
@DefaultValue Map<String, String> serviceLabels, String primaryPortName,
@DefaultValue Metadata metadata,
@DefaultValue("" + DEFAULT_ORDER) int order,
boolean useEndpointSlices,
boolean includeExternalNameServices,
String discoveryServerUrl) {
// @formatter:on
/**
* Prefix of the properties.
*/
public static final String PREFIX = "spring.cloud.kubernetes.discovery";
@ConstructorBinding
public KubernetesDiscoveryProperties {
}
/**
* Default instance.
*/
public static final KubernetesDiscoveryProperties DEFAULT = new KubernetesDiscoveryProperties(true, false, Set.of(),
true, 60, false, null, Set.of(), Map.of(), null, KubernetesDiscoveryProperties.Metadata.DEFAULT, 0, false,
false, null);逐项含义:
- namespace 三策略:
all-namespaces=false+namespaces空 = 只看本 namespace(one);namespaces非空 = selective;all-namespaces=true= all。本 namespace 的解析顺序是spring.cloud.kubernetes.client.namespace→ 服务账号 namespace 文件(路径可用serviceAccountNamespacePath覆盖,默认/var/run/secrets/kubernetes.io/serviceaccount/namespace)→ 客户端方法(如 fabric8KubernetesClient::getNamespace),全失败抛异常(namespace-resolution.adoc)。 - 过滤:
service-labels精确匹配标签;filter是 SpEL 谓词,作用于 Service 对象。二者叠加。 - 端口:
primary-port-name指定优先端口名;没有匹配时取定义中的端口;known-secure-ports={443,8443}决定secure标记。 - readiness:
include-not-ready-addresses=false默认只取 Endpoint 的 ready 地址;未就绪 Pod 的地址被排除。use-endpoint-slices=false默认用 Endpoints,开启后用 EndpointSlice。include-external-name-services=false默认不把 ExternalName Service 当实例。 - 缓存:
wait-cache-ready=true时启动等待所有 informerhasSynced,超过cache-loading-timeout-seconds(默认 60)中止启动;RBAC 不足(无 list/watch 权限)会表现为缓存初始化失败或超时,而不是“服务列表为空”。
目录监听是定时比较而非 watch 事件:
@Scheduled(fixedDelayString = "${" + CATALOG_WATCH_PROPERTY_WITH_DEFAULT_VALUE + "}")
void catalogServicesWatch() {
try {
List<EndpointNameAndNamespace> currentState = stateGenerator.apply(context);
if (!currentState.equals(catalogEndpointsState)) {
LOG.debug(() -> "Received endpoints update from kubernetesClient: " + currentState);
publisher.publishEvent(new HeartbeatEvent(this, currentState));
}
catalogEndpointsState = currentState;
}
catch (Exception e) {
LOG.error(e, () -> "Error watching Kubernetes Services");
}
}属性名是 camelCase spring.cloud.kubernetes.discovery.catalogServicesWatchDelay(默认 30000ms,KubernetesDiscoveryConstants.java:88,98),写成 kebab-case 的 catalog-services-watch-delay 不会生效;且仅在 endpoint 状态列表变化时发布 HeartbeatEvent——与 Consul CatalogWatch 每轮都发不同。
- 输入:namespace 策略、service-labels/filter、primary-port-name、readiness/切片开关、RBAC。
- 失败条件:默认只看本 namespace 导致跨 namespace 查不到;Service 无端口;未就绪被排除;RBAC 不足;属性写成 kebab-case。
- 反例:“K8s 发现天然支持 ExternalName”;“EndpointSlice 默认开启”;“空列表一定是没有服务”(也可能是缓存没就绪)。
- 观测点:
kubectl get endpoints/endpointslices、Pod 内 ServiceAccount 的 list/watch 权限、启动日志的 informer 同步与超时、/actuator/health的 discovery 相关项。
Kubernetes 配置:kubernetes: 导入与 ConfigMap/Secret
配置入口是 spring.config.import=kubernetes:(YAML 里需引号),且只有在 KUBERNETES 平台被探测/强制时才解析(KubernetesConfigDataLocationResolver.isResolvable,:64-68)。默认值:ConfigMap 源开启、Secret 源默认关闭(SecretsConfigProperties.java:40 的 record @DefaultValue("false");secrets-propertysource.adoc:150 同口径),两者的 fail-fast 默认 false,读取重试默认开启(initial 1000ms、multiplier 1.1、max 6)。namespace 解析与发现共用同一顺序,ConfigData 路径还多一层 kubernetes.namespace 回退。
单条目与多条目的 ConfigMap 语义不同:
public static Map<String, Object> processAllEntries(Map<String, String> input, Environment environment,
boolean includeDefaultProfileData) {
Set<Map.Entry<String, String>> entrySet = input.entrySet();
if (entrySet.size() == 1) {
// we handle the case where the configmap contains a single "file"
// in this case we don't care what the name of the file is
Map.Entry<String, String> singleEntry = entrySet.iterator().next();
String propertyName = singleEntry.getKey();
String propertyValue = singleEntry.getValue();
if (propertyName.endsWith(".yml") || propertyName.endsWith(".yaml")) {
LOG.debug("The single property with name: [" + propertyName + "] will be treated as a yaml file");
return yamlParserGenerator(environment).apply(propertyValue);
}
else if (propertyName.endsWith(".properties")) {
LOG.debug("The single property with name: [" + propertyName + "] will be treated as a properties file");
return KEY_VALUE_TO_PROPERTIES.andThen(PROPERTIES_TO_MAP).apply(propertyValue);
}
}
return defaultProcessAllEntries(input, environment, includeDefaultProfileData);
}- 只有一个 entry 且文件名以
.yml/.yaml结尾:整体按 YAML 解析;.properties同理。适合“整份配置文件塞进一个键”。 - 多 entry:按既定顺序合并——
application[.yml/.properties]→application-<profile>→ 普通键;未知后缀文件条目跳过并 warn。键值型 ConfigMap 的属性名就是键本身。
PropertySource 的组装:
@Override
public PropertySource<?> locate(Environment environment) {
if (environment instanceof ConfigurableEnvironment env) {
List<NormalizedSource> sources = properties.determineSources(sourceType, environment);
Set<NormalizedSource> uniqueSources = new LinkedHashSet<>(sources);
LOG.debug(sourceType.name() + " normalized sources : " + uniqueSources);
CompositePropertySource composite = new CompositePropertySource(
"composite-" + sourceType.name().toLowerCase(Locale.ROOT));
uniqueSources.forEach(secretSource -> {
MapPropertySource propertySource = getPropertySource(env, secretSource, properties.readType());
if ("true".equals(propertySource.getProperty(Constants.ERROR_PROPERTY))) {
LOG.warn(() -> "Failed to load source: " + secretSource);
}
else {
LOG.debug("Adding " + sourceType.name().toLowerCase(Locale.ROOT) + " property source "
+ propertySource.getName());
composite.addFirstPropertySource(propertySource);
}
});
return composite;
}
return null;
}顺序规则:同键时 ConfigMap 赢 Secret(order_of_configMaps_and_secrets.adoc);spring.config.import 导入内容高于声明它的文档,多个 import 后声明者优先;CLI/系统属性/环境变量仍最高。legacy bootstrap 与 import 不能混用:bootstrap 下 ConfigMap 与 Config Server locator 都是 order 0、Secret locator 是 order 1,不应依赖二者先后(property-source-config.adoc)。
reload 要如实标注:spring.cloud.kubernetes.reload.* 自 2020.0 起已弃用,官方替代是 Configuration Watcher 控制器;reload.enabled 默认 false,策略 refresh/restart_context/shutdown(propertysource-reload.adoc)。这些只有文档与配置层核对,没有集群实跑,正文不把它当作可用热更新承诺;configtree: 的“加载协议不是热更新协议”边界仍以 Boot配置:外部来源、绑定与失败 为准。
- 输入:
kubernetes:import、应用名/profile、sources(name/namespace/labels/order)、Secret 开关。 - 失败条件:无 import 或不满足平台条件、Secret 未显式开启、namespace 解析失败、单文件误当多键。
- 反例:“Secret 默认也会加载”;“ConfigMap 与 Secret 同键时 Secret 赢”;“volume 挂载变化会自动重绑”。
- 观测点:
kubectl get configmap/secret -o yaml、启动日志的 normalized sources 与 “Failed to load source”、/actuator/env属性源名。
Kubernetes LoadBalancer 模式、pod health 与 AOT 边界
spring.cloud.kubernetes.loadbalancer.* 有两种模式(KubernetesLoadBalancerProperties.java:32,38,43,48):enabled=true、mode=POD(默认)、cluster-domain=cluster.local、port-name=http。
- POD 模式:走 DiscoveryClient,把 Service 的 Endpoint 地址作为实例列表,交给 U32 的 LB 链(RR/缓存/健康判定都不变)。
- SERVICE 模式:不取 Pod 地址,而是解析目标 Service,用
service-matching-strategy选择:NAME(默认,按metadata.name匹配请求主机名)或LABELS(忽略主机名,用spring.cloud.kubernetes.discovery.serviceLabels找服务)。端口选择:单端口直接用;多端口先按portName(默认http)匹配;匹配不到取列表第一个——官方文档自己写了 “This last option is non-deterministic”,生产要显式命名端口。 - 文档错字:
load-balancer.adoc:52把属性写成spring.cloud.kubernet.loadbalancer.service-matching-strategy(少一个e),源码与测试用的是spring.cloud.kubernetes.loadbalancer.service-matching-strategy。照抄文档错字 → 属性不生效。
pod health 与 Boot 探针是两个贡献者:management.health.kubernetes.enabled=false 关闭的是 K8s 适配器的 pod health indicator(暴露 pod 名/IP/namespace/service account/node、内部或外部标志,pod-health-indicator.adoc);Boot 4 的 readiness/liveness 探针默认开启且不依赖 K8s 环境(Actuator:端点权限、健康就绪与观测)。二者不是同一开关,不能互相替代。
AOT 与 native image 输入边界
AOT 输入边界:Kubernetes 5.0.4 README 明确当前不支持 Spring Boot AOT 转换与 native image(“Partial support might be added in future releases”),本页只回指 AOT、Native、CRaC 与语言空安全 的通用限制与 U32 的“需预知 serviceId”输入,不声称 Native 可运行。服务注册在 K8s 由平台控制,spring.cloud.service-registry.auto-registration.enabled 与 @EnableDiscoveryClient(autoRegister=false) 在此实现下无效(service-registry.adoc)。
- 输入:LB mode、service-matching-strategy、portName、cluster-domain、pod health 开关。
- 失败条件:照抄
kubernet错字、多端口未命名、把 SERVICE 模式当 DNS、把 pod indicator 当 readiness 探针。 - 反例:“SERVICE 模式就是让 kube-proxy 自己做 LB”;“K8s 适配支持 native”。
- 观测点:LB 日志中的目标 Service/端口、
/actuator/health的 kubernetes 贡献者、native/AOT 构建的失败信息(若尝试)。
Eureka 客户端与 zone 自动接线
Eureka 的核心默认值(EurekaClientConfigBean.java:57,62,79,85,91,98,281,353;EurekaInstanceConfigBean.java:104,117):service-url.defaultZone=http://localhost:8761/eureka/、register-with-eureka=true、fetch-registry=true、注册表拉取 30s、实例复制 30s、首次复制延迟 40s、服务地址轮询 300s、实例心跳 30s、租约 90s。defaultZone 是大小写敏感的 map key:eureka.client.service-url.default-zone 这种 kebab 写法不会绑定(spring-cloud-netflix.adoc:58 明确要求 camelCase)。
@ConfigurationProperties(EurekaClientConfigBean.PREFIX)
public class EurekaClientConfigBean implements EurekaClientConfig, Ordered {
/**
* Default prefix for Eureka client config properties.
*/
public static final String PREFIX = "eureka.client";
/**
* Default Eureka URL.
*/
public static final String DEFAULT_URL = "http://localhost:8761" + DEFAULT_PREFIX + "/";
/**
* Default availability zone if none is resolved based on region.
*/
public static final String DEFAULT_ZONE = "defaultZone";
@Autowired(required = false)
PropertyResolver propertyResolver;
/**
* Flag to indicate that the Eureka client is enabled.
*/
private boolean enabled = true;
@NestedConfigurationProperty
private EurekaTransportConfig transport = new CloudEurekaTransportConfig();
/**
* Indicates how often(in seconds) to fetch the registry information from the eureka
* server.
*/
private int registryFetchIntervalSeconds = 30;
/**
* Indicates how often(in seconds) to replicate instance changes to be replicated to
* the eureka server.
*/
private int instanceInfoReplicationIntervalSeconds = 30;
/**
* Indicates how long initially (in seconds) to replicate instance info to the eureka
* server.
*/
private int initialInstanceInfoReplicationIntervalSeconds = 40;
/**
* Indicates how often(in seconds) to poll for changes to eureka server information.
* Eureka servers could be added or removed and this setting controls how soon the
* eureka clients should know about it.
*/
private int eurekaServiceUrlPollIntervalSeconds = 300; // 5 * MINUTES注册是异步、最终一致的:注册信息经实例复制 → server 缓存 → 客户端拉取三层,官方文档直言“可能要 3 个心跳”才被其他客户端看见(spring-cloud-netflix.adoc:347-353)。把“启动成功”当“已被发现”,或把 90s 当“必然从注册表消失”,都是错的:租约过期后还有 server 驱逐周期,且默认 enable-self-preservation=true(阈值 0.85)会在续约不足时保住实例。
/**
* Indicates how often (in seconds) the eureka client needs to send heartbeats to
* eureka server to indicate that it is still alive. If the heartbeats are not
* received for the period specified in leaseExpirationDurationInSeconds, eureka
* server will remove the instance from its view, there by disallowing traffic to this
* instance.
*
* Note that the instance could still not take traffic if it implements
* HealthCheckCallback and then decides to make itself unavailable.
*/
private int leaseRenewalIntervalInSeconds = 30;
/**
* Indicates the time in seconds that the eureka server waits since it received the
* last heartbeat before it can remove this instance from its view and there by
* disallowing traffic to this instance.
*
* Setting this value too long could mean that the traffic could be routed to the
* instance even though the instance is not alive. Setting this value too small could
* mean, the instance may be taken out of traffic because of temporary network
* glitches.This value to be set to at least higher than the value specified in
* leaseRenewalIntervalInSeconds.
*/
private int leaseExpirationDurationInSeconds = 90;健康传播的默认行为与 metadata 冲突是本页必须点名的矛盾:源码默认不传播 Actuator 健康(EurekaDiscoveryClientConfiguration.java:61 的 @ConditionalOnProperty(..., matchIfMissing=false),只有显式 eureka.client.healthcheck.enabled=true 才装配 EurekaHealthCheckHandler),而入库的 additional metadata 写 defaultValue: true(spring-cloud-netflix-eureka-client/src/main/resources/META-INF/additional-spring-configuration-metadata.json:3-8)。以源码为准:默认 EurekaServiceRegistry.register 把状态置为 initialStatus(默认 UP),与真实业务健康无关。开启健康检查时必须写在 application.yml;写在 bootstrap.yml 会以 UNKNOWN 状态注册(spring-cloud-netflix.adoc:199)。
@Override
public List<ServiceInstance> getInstances(String serviceId) {
List<InstanceInfo> infos = this.eurekaClient.getInstancesByVipAddress(serviceId, false);
List<ServiceInstance> instances = new ArrayList<>();
for (InstanceInfo info : infos) {
instances.add(new EurekaServiceInstance(info));
}
return instances;
}
@Override
public List<String> getServices() {
Applications applications = this.eurekaClient.getApplications();
if (applications == null) {
return Collections.emptyList();
}
List<Application> registered = applications.getRegisteredApplications();
List<String> names = new ArrayList<>();
for (Application app : registered) {
if (app.getInstances().isEmpty()) {
continue;
}
names.add(app.getName().toLowerCase(Locale.ROOT));
}
return names;
}getInstances 按 VIP 地址查且 secure=false,getServices 过滤空 Application 并把名字转小写——serviceId 大小写与 Consul/K8s 的规则不同。
注销与状态:register 侧原码见前文“自动注册”一节的 EurekaServiceRegistry 摘录;注销侧在 EurekaServiceRegistry.deregister 把实例置 DOWN 并依赖 EurekaRegistration.close() 关客户端,没有立即删除调用。)setStatus 支持任意 InstanceStatus 字符串与特殊值 CANCEL_OVERRIDE(撤销 override 状态);getStatus 同时返回 status 与 overriddenStatus,排查“已置 OUT_OF_SERVICE 但仍被调用”要看 overridden 是否残留(EurekaServiceRegistry.java:100-140)。
zone 自动接线与回退
zone 自动接线只在 Eureka 存在:EurekaLoadBalancerClientConfiguration 在 LoadBalancerZoneConfig 与 EurekaLoadBalancerProperties 均存在时,于 @PostConstruct 阶段:若 zoneConfig.zone 为空,则 getZoneFromEureka() 取值:approximate-zone-from-hostname=true 时直接用主机名反推 zone;否则先取 metadataMap.zone,再回退 availabilityZones[region] 第一个,结果写入 LoadBalancerZoneConfig(EurekaLoadBalancerClientConfiguration.java:69-96 的 postprocess/getZoneFromEureka;EurekaLoadBalancerProperties.java:28,34;spring-cloud-netflix.adoc:393-400)。这是 U32 zone 一节“Eureka 专门集成”的源码出处;Consul/ZK 没有同款类。属性写错 zone/region 时回退是“全量候选”,表现为同区偏好失效而非报错。
- 输入:
eureka.client.*、eureka.instance.*、metadataMap.zone、availabilityZones。 - 失败条件:
default-zone写法、healthcheck 放 bootstrap、把 Eureka 当强一致、zone 拼错。 - 反例:认为注册后立即可见;认为 DOWN 立即删除;认为 healthcheck 默认开启。
- 观测点:Eureka server 的
/eureka/apps、客户端/actuator/env中的eureka.client.serviceUrl、LoadBalancerZoneConfig值、注册状态与 overriddenStatus。
Cloud Zookeeper 适配:发现、状态与配置
内核(session/watch/quorum/临时节点)归 U39,本页只写 Spring Cloud 适配层。连接与会话默认值集中在 spring.cloud.zookeeper.*(ZookeeperProperties.java:43,48,53,58,63,68,73,84,90):
/**
* Configuration prefix.
*/
public static final String PREFIX = "spring.cloud.zookeeper";
/**
* Connection string to the Zookeeper cluster.
*/
private String connectString = "localhost:2181";
/**
* Is Zookeeper enabled.
*/
private boolean enabled = true;
/**
* Initial amount of time to wait between retries.
*/
private Integer baseSleepTimeMs = 50;
/**
* Max number of times to retry.
*/
private Integer maxRetries = 10;
/**
* Max time in ms to sleep on each retry.
*/
private Integer maxSleepMs = 500;
/**
* Wait time to block on connection to Zookeeper.
*/
private Integer blockUntilConnectedWait = 10;
/**
* The unit of time related to blocking on connection to Zookeeper.
*/
private TimeUnit blockUntilConnectedUnit = TimeUnit.SECONDS;
/**
* The configured/negotiated session timeout in milliseconds. Please refer to
* <a href='https://cwiki.apache.org/confluence/display/CURATOR/TN14'>Curator's Tech
* Note 14</a> to understand how Curator implements connection sessions.
*
* @see <a href='https://cwiki.apache.org/confluence/display/CURATOR/TN14'>Curator's
* Tech Note 14</a>
*/
@DurationUnit(ChronoUnit.MILLIS)
private Duration sessionTimeout = Duration.of(60 * 1000, ChronoUnit.MILLIS);
/**
* The configured connection timeout in milliseconds.
*/
@DurationUnit(ChronoUnit.MILLIS)
private Duration connectionTimeout = Duration.of(15 * 1000, ChronoUnit.MILLIS);即 localhost:2181、session 60s、connection 15s、重试退避 50ms/最多 10 次/单次上限 500ms、阻塞等待连接 10s。发现默认 enabled=true,注册根 /services,URI 模板 {scheme}://{address}:{port},register=true、初始状态 UP(ZookeeperDiscoveryProperties.java:44,48,53,88,100)。
发现查询走 Curator ServiceDiscovery;服务不存在是空列表不是异常:
@Override
public List<org.springframework.cloud.client.ServiceInstance> getInstances(
final String serviceId) {
try {
if (getServiceDiscovery() == null) {
return Collections.EMPTY_LIST;
}
String serviceIdToQuery = getServiceIdToQuery(serviceId);
Collection<ServiceInstance<ZookeeperInstance>> zkInstances = getServiceDiscovery()
.queryForInstances(serviceIdToQuery);
List<org.springframework.cloud.client.ServiceInstance> instances = new ArrayList<>();
for (ServiceInstance<ZookeeperInstance> instance : zkInstances) {
instances.add(createServiceInstance(serviceIdToQuery, instance));
}
return instances;
}
catch (KeeperException.NoNodeException e) {
if (log.isDebugEnabled()) {
log.debug(
"Error getting instances from zookeeper. Possibly, no service has registered.",
e);
}
// this means that nothing has registered as a service yes
return Collections.emptyList();
}
catch (Exception exception) {
rethrowRuntimeException(exception);
}
return new ArrayList<>();
}注册/注销直接映射 Curator:register → registerService、deregister → unregisterService,异常一律上抛;原码见前文“自动注册、注销、setStatus 与故障恢复差异”一节的 ZookeeperServiceRegistry 摘录。
状态与过滤是 Zookeeper 适配最特殊的部分:Curator 本身不支持 OUT_OF_SERVICE。ZookeeperServiceRegistry.setStatus 把状态写入实例元数据键 instance_status(StatusConstants.INSTANCE_STATUS_KEY)并 updateService;ZookeeperServiceInstanceListSupplier 在 LoadBalancer 侧过滤:元数据缺失视为 UP(向后兼容),只保留等于 UP 的实例(ZookeeperServiceInstanceListSupplier.java:67-90)。因此“摘流”只对走 LB 的调用生效,直接 DiscoveryClient.getInstances 仍能看到该实例——这与 Consul(agent 维护检查)和 Eureka(server 侧状态)的可见性完全不同。
Zookeeper 配置导入与四上下文
配置入口 spring.config.import=optional:zookeeper:(ZookeeperConfigDataLocationResolver.PREFIX="zookeeper:",:53),import 中的连接串优先于 spring.cloud.zookeeper.connect-string(config.adoc)。默认四上下文与 Config 同构:config/<app>,<profile> → config/<app> → config/application,<profile> → config/application(root 默认 config、分隔符 ,、fail-fast=true)。ZK 特有能力的边界:spring.cloud.zookeeper.dependencies.* 是别名 → 服务定义的映射(path/version/contentType/stubs/required),不是通用 DiscoveryClient 用法;dependency watcher 监听依赖的 CONNECTED/DISCONNECTED,Presence Checker 在启动时检查:required=true 且缺失 → 抛异常关停,非 required → WARN(dependency-watcher.adoc)。目录变化由 ZookeeperServiceWatch 建 TreeCache,节点事件发布 HeartbeatEvent——无轮询间隔属性,与 Consul 的 1s、K8s 的 30s 不同。
- 输入:connect-string、root、状态元数据、config 四上下文、dependencies 配置。
- 失败条件:ZK 不可达(blockUntilConnected 后失败)、session 过期(U39)、
instance_status非 UP 被 LB 过滤、dependency required 缺失阻断启动。 - 反例:把临时节点存在当会话有效;把 getInstances 结果当 LB 可调用集合;把 znode 事件一一映射为实例事件。
- 观测点:
zkCli查看/services与config树、ZookeeperServiceInstanceListSupplier过滤前后列表、依赖 watcher 日志。
配置元数据与源码不一致的教学
入库的 additional-spring-configuration-metadata.json 是手工补充,会滞后于源码。固定 tag 逐条核对发现的不一致(完整 12 条见仓库外 config-metadata 报告):
| # | 项目 | 现象 | 证据(文件:行) | 教学口径 |
|---|---|---|---|---|
| 1 | Consul | spring.cloud.consul.ribbon.enabled 仍存在且默认 true,但 5.x 代码库已无 Ribbon | spring-cloud-consul-discovery/src/main/resources/META-INF/additional-spring-configuration-metadata.json:3-8 | Ribbon 已移除,该属性无效果 |
| 2 | Kubernetes | client.masterUrl metadata 写 https://kubernetes.default.svc,源码实际 Config.autoConfigure(null)(kubeconfig/in-cluster),仅显式设置才覆盖 | spring-cloud-kubernetes-commons/.../additional-spring-configuration-metadata.json:9-14;Fabric8AutoConfiguration.java:52-57 | 以源码为准,metadata 默认值不生效 |
| 3 | Kubernetes | client.namespace metadata 的 defaultValue 是布尔 true(类型错误),实际来自服务账号 namespace 文件 | 同文件 :21-26;KubernetesNamespaceProvider.java:94-128 | 类型都写错的 metadata 不能当文档 |
| 4 | Kubernetes | secrets.enabled metadata 写 true,源码 record @DefaultValue("false")、文档表格也写 false | spring-cloud-kubernetes-fabric8-config/.../additional-spring-configuration-metadata.json:9-14;SecretsConfigProperties.java:40;secrets-propertysource.adoc:150 | 默认关闭;API 读 Secret 出于安全需显式开启 |
| 5 | Netflix | eureka.client.healthcheck.enabled metadata 写 true,源码 matchIfMissing=false(默认不装配 HealthCheckHandler) | spring-cloud-netflix-eureka-client/.../additional-spring-configuration-metadata.json:3-8;EurekaDiscoveryClientConfiguration.java:61 | 默认关闭;开启必须写 application.yml |
| 6 | Vault | 仓库内没有任何入库 metadata(构建期生成),旧教程常写的 backend-version 已 @Deprecated | Vault 树 find 无 spring-configuration-metadata.json;VaultKeyValueBackendProperties.java:90,144-149 | 默认值只能读源码/构建产物 |
| 7 | Kubernetes | discovery 模块 metadata 引用的 KubernetesDiscoveryClientProperties 类在树中不存在(悬空条目) | spring-cloud-kubernetes-discovery/src/main/resources/META-INF/spring-configuration-metadata.json:5-20 | IDE 补全可能提示不存在的类 |
| 8 | Kubernetes | CatalogWatch 属性是 camelCase catalogServicesWatchDelay,不符合 kebab 惯例 | KubernetesDiscoveryConstants.java:88 | 写成 catalog-services-watch-delay 静默失效 |
方法论:metadata 只作发现,运行版本源码是唯一默认值依据;每个默认值断言都要能回到 文件:行;在应用侧用 /actuator/configprops(脱敏)与启动日志核对“实际绑定到了什么”。本表同时也是 K04(证据分层)与 K09(来源分离)的实证材料——BOM、metadata、文档、源码、运行是五层不同证据。
- 输入:一份属性名与期望默认值。
- 失败条件:只查 IDE 补全或 appendix 默认列。
- 反例:照 metadata 写
secrets.enabled=true以为在关闭;按文档错字kubernet配 LB。 - 观测点:固定 commit 的源码行、
_configprops.adoc、/actuator/configprops、运行日志。
实验读法与旧内容唯一归属
公开实验入口与边界见 Cloud 适配实验:公开实验的设计是用真实 Consul 1.22.7 与 Vault 2.2.1(dev 模式)官方二进制在 127.0.0.1 随机端口上运行(Consul 一次占 5 个端口、Vault dev 额外占 listen+1,夹具先断言端口空闲),使用合成 token(本实验未启用 Consul ACL),结束时有界清理进程与端口;不连接、也不修改本机已有的 Consul/Vault。本页不提前声明通过:实验只以六条固定观察行为准,实际结果以 labs/cloud-adapters/evidence/u35-2026-10-27.json 为准:
| 观察行 | 覆盖的知识点 | 说明(预期行为) |
|---|---|---|
U35 consulRegister=PASS | u35-consul-register | 真实 agent 注册:/v1/catalog/service、/v1/health/service?passing 与 /v1/agent/services 均可见实例与 check(注销路径由 Spring 关闭 Context 触发,未单独断言) |
U35 consulConfig=PASS | u35-consul-config | spring.config.import=consul: 读到四上下文中的 KV,业务端点返回合成值 |
U35 consulConfigNoAutoRefresh=PASS | u35-consul-config(watch 边界) | 显式关闭 ConfigWatch 后修改 KV,应用值不变;显式刷新或重启才更新 |
U35 vaultSecret=PASS | u35-vault-kv | 真实 Vault KV v2 mount,客户端运行时探测并自动插 data/ 读到秘密 |
U35 vaultFailure=PASS | u35-vault-auth(失败路径) | fail-fast=true 下错误 token 在 ConfigData 导入阶段以 403 失败(启动中止);错误 backend 在非 leasing 读取路径 404 跳过,秘密不出现(默认 fail-fast=false 与不可达分支未运行) |
U35 consulOutage=PASS | u35-adapter-failure-recovery | agent 停止后的注册/发现/配置读取错误路径与恢复行为 |
未运行清单(不得外推):Eureka server/client、真实 Kubernetes 集群(informer/EndpointSlice/ConfigMap/Secret/RBAC)、真实 ZooKeeper 集群(适配层与 U39 内核)、Consul 多节点 Raft/quorum 与跨 DC、Vault 非 token 认证与真实租约续期观察、K8s reload/Configuration Watcher。U32 交给 U35/U40 的“集群分区”并未被单节点 agent 关闭(C3);CloudFoundry 平台验签/重放不在 L06 范围,登记为无归属新项,不由本页静默吸收(C4);CloudFoundry 只在 Gateway 页出现三头存在性检查,本页不承接。
K01–K09 的定位方式:每个机制小节的“输入/失败条件/反例/观测点”四行分别对应 K01(参与者与输入输出)、K02(完整用法)、K04(证据分层与边界)、K05(反例)、K06(问题定位);源码摘录与 文件:行 对应 K03/K09;闭卷验收对应 K07;对照矩阵、故障恢复与实验边界对应 K08。
22 个知识点 ID 与本页 H2 的对应(供登记使用):
| 知识点 ID | H2 节 | 实跑/推演 |
|---|---|---|
| u35-adapter-catalog | 版本、模块与维护状态 | BOM/源码核对;维护状态来源核对 |
| u35-adapter-compare | 适配器语义对照矩阵 | 源码推演 + Consul 列公开实验 |
| u35-adapter-lifecycle | 自动注册、注销、setStatus 与故障恢复差异 | Consul 公开实验;其余源码 |
| u35-consul-register | Consul 注册:agent API、实例身份与健康检查 | 公开实验 |
| u35-consul-discovery | Consul 发现:health/service、queryPassing 与 catalog watch | 公开实验 |
| u35-consul-config | Consul KV 配置与 Config Watch | 公开实验(含 watch 关闭边界) |
| u35-consul-lb-zone | Consul 与 LoadBalancer/zone 边界 | 源码推演 + 元数据可见性 |
| u35-vault-kv | Vault 客户端 KV v2:vault:// 与运行时 mount 探测 | 公开实验 |
| u35-vault-auth | Vault 认证与租约生命周期(token 部分) | token 路径公开实验;其余推演 |
| u35-vault-lease | Vault 认证与租约生命周期(租约部分) | 推演(源码/文档) |
| u35-vault-vs-configserver | Vault 客户端与 Config Server Vault 后端不可互推 | 推演 + U34 引用 |
| u35-k8s-discovery | Kubernetes 发现:namespace、过滤与 readiness | 推演(无集群) |
| u35-k8s-config | Kubernetes 配置:kubernetes: 导入与 ConfigMap/Secret | 推演(无集群) |
| u35-k8s-lb-health | Kubernetes LoadBalancer 模式、pod health 与 AOT 边界(LB/health 部分) | 推演 |
| u35-k8s-aot-input | Kubernetes LoadBalancer 模式、pod health 与 AOT 边界(AOT 部分,回指 U28) | 推演 |
| u35-eureka-client | Eureka 客户端与 zone 自动接线(客户端部分) | 推演(不搭 server) |
| u35-eureka-zone | Eureka 客户端与 zone 自动接线(zone 部分) | 推演 |
| u35-zk-discovery | Cloud Zookeeper 适配:发现、状态与配置(发现部分) | 推演;内核回指 U39 |
| u35-zk-config | Cloud Zookeeper 适配:发现、状态与配置(配置部分) | 推演 |
| u35-adapter-status | 自动注册、注销、setStatus 与故障恢复差异 | 源码推演(公开实验不含 setStatus 用例) |
| u35-adapter-failure-recovery | 自动注册、注销、setStatus 与故障恢复差异 | Consul 公开实验(consulOutage)+ 其余推演 |
| u35-adapter-evidence | 实验读法与旧内容唯一归属 | 公开实验(以证据 JSON 为准) |
旧内容唯一归属(只回指,不重复):
| 主题 | 唯一权威 |
|---|---|
| 四段模型、supplier 链、实例缓存 35s、健康 supplier、重试、摘流模板 | Cloud发现与负载均衡:实例、缓存与摘流(U32) |
| ConfigData 排序、Config Server 后端、刷新/RefreshScope、Bus/Kafka、{cipher}、revert | Config与Bus:配置读取、传播与版本回滚(U34,尤其 Vault 服务端后端 563–608 行) |
| Nacos 注册路径、配置快照、混版 | Nacos注册发现与配置一致性(U40,本页只在矩阵回指) |
| ZK session/watch/quorum/fencing | ZooKeeper协调与会话恢复(U39,本页只写 Cloud 适配层) |
| 来源顺序、configtree、Binder/校验 | Boot配置:外部来源、绑定与失败 |
| 探针默认与管理链授权 | Actuator:端点权限、健康就绪与观测(U24) |
| AOT/Native/CRaC 通用限制 | AOT、Native、CRaC 与语言空安全(U28) |
| Feign/SCB/R4j | 远程客户端与容错组合:Feign 与 Resilience4j(U33) |
- 输入:实验观察行文本、证据 JSON、README 的限制清单、本页逐节断言。
- 失败条件:用配置推演冒充实跑、跨 tag 拼证据、把单节点 agent 当集群、把“未生成证据”当通过。
- 反例:只看观察行文本不看证据 JSON 的输入与限制;把 Pending 写成 PASS。
- 观测点:
labs/cloud-adapters/evidence/u35-2026-10-27.json的 ticket/status/观察行、README 的“未运行”清单、回归测试对新正文的解析结果。
闭卷验收
本页 34 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。