服务发现与配置中心:实例目录、配置导入和动态刷新
一个 catalog 服务可以同时运行两个实例,共用服务名,各有自己的地址。同样一份远程配置发布以后,两个实例也可能暂时使用不同的值:实例 a 已经刷新,实例 b 仍在使用先前创建的配置对象。
服务发现解决“到哪里调用”,配置中心提供“应用从哪里取得参数”。理解它们需要同时观察服务器保存的内容,以及客户端当前正在使用的内容。
实例目录和配置源分别保存什么
服务名、实例标识与可达地址
注册目录通常按逻辑服务名组织实例。同名实例提供兼容的接口,调用方从候选集合中选择一个目标:
catalog 逻辑服务名
├── catalog:a 实例标识
│ ├── host: catalog-a 通告给调用方的主机名
│ ├── port: 8080 通告端口
│ ├── status: UP 注册状态
│ └── metadata: zone / version 等附加信息
└── catalog:b
├── host: catalog-b
├── port: 8080
└── status: UP实例标识应在注册范围内唯一。两个进程使用相同标识,可能相互覆盖注册信息;一个进程每次心跳使用新标识,则会不断制造额外条目。
通告地址必须由调用方所在网络访问。容器监听 8080,宿主机映射 18141,其他容器调用它时通常使用容器网络中的主机名与 8080。把 127.0.0.1 或宿主映射端口误登记进去,会出现控制台看得到实例、调用却连接失败的情况。监听地址、通告地址、管理端点地址分别检查。
Spring 的 DiscoveryClient 提供查询服务和实例的公共接口;返回的 ServiceInstance 包含主机、端口、是否安全连接和 metadata。metadata 只是附加数据,选择器必须理解相应字段才会改变路由行为。Spring Cloud 公共抽象
从目录查询到网络连接
常用的客户端发现过程涉及四个对象:
| 对象 | 保存或执行的内容 | 排查时读取什么 |
|---|---|---|
| 注册服务器 | 服务和实例目录,接收注册、续约、注销 | 服务端 API 中的实例条目与状态 |
| 客户端目录缓存 | 最近拉取或订阅得到的实例集合 | 调用进程实际看到的地址集合 |
| 负载均衡器 | 过滤候选、为某次请求选择实例 | 本次选中的 instanceId 和目标 URI |
| HTTP 客户端与连接池 | 建立、复用、释放到目标地址的连接 | 连接错误、池等待、实际远端地址 |
服务端删除一个实例后,客户端可能还没更新;客户端更新以后,先前发出的请求仍可能在执行。目录收敛、停止选址和排空在途请求需要分别安排。具体选择与重试规则见负载均衡、超时与重试。
Eureka 采用应用客户端注册和拉取的方式;Nacos 同时提供发现与配置能力,客户端通过相应 SDK 接入。在 Kubernetes 中,Service 提供稳定访问入口,EndpointSlice 描述后端端点,应用也可以使用平台 DNS 和服务网络,未必还需要在应用内引入 Eureka。选择取决于部署平台、跨集群需求和已有调用方式,不宜让同一服务同时维护两套互不一致的实例来源。Kubernetes Service
远程配置如何定位
Spring Cloud Config 通过 application、profile、label 定位配置:
application = catalog 通常来自 spring.application.name
profile = default / test 对应活动配置环境
label = 某个分支或版本 由所选配置后端解释
Config Server
└── Environment
├── name / profiles / label / version
└── propertySources
├── 来源名称
└── 属性键和值Git 后端可以用分支、标签或提交定位配置;文件系统 native 后端读取指定目录。下面的实验使用文件系统,返回的 version 为 null,不存在 Git 提交版本。应用名和 profile 用于选择内容,生产环境的访问权限仍应由认证、授权及网络策略限制。Config 文件系统后端
远程配置进入客户端以后,与本地文件、环境变量、命令行参数等组成 Environment。某个属性的最终值取决于实际属性源优先级及导入方式。看到远程文件中有一个值,还应查看应用加载了哪份配置、活动 profile 是什么、是否存在更高优先级覆盖。Boot 的属性源顺序与配置文件加载规则见外部化配置。
地址变化与配置变化怎样到达应用
注册、续约、注销和缓存更新
Eureka 实例启动后注册,并周期性发送心跳续约。正常停机时,客户端可以主动注销;进程被强制杀死、网络中断时,服务器只能根据租约及清理规则判断。客户端还要等待自己的目录更新,才会停止使用旧地址。
Eureka 的自我保护用于应对大量心跳丢失:网络分区时,过度剔除可能删掉仍然运行的实例。启用后,不能承诺一过租约时间就必定删除。即便关闭自我保护,租约判断、清理任务和客户端缓存更新也构成多个时间阶段。Eureka 生命周期与健康检查
下面为了在本机观察,缩短了心跳、租约和缓存周期,并关闭服务端自我保护。它们是实验参数:
| 参数 | 实验值 | 作用 |
|---|---|---|
| lease-renewal-interval-in-seconds | 2 | 实例心跳间隔 |
| lease-expiration-duration-in-seconds | 6 | 服务端判断租约过期的时长 |
| eviction-interval-timer-in-ms | 1000 | 服务端执行剔除检查的间隔 |
| response-cache-update-interval-ms | 1000 | 服务端响应缓存更新间隔 |
| registry-fetch-interval-seconds | 2 | 客户端拉取目录的间隔 |
| enable-self-preservation | false | 关闭本次实验的自我保护 |
不要直接把这组值复制到生产。更短的周期增加控制平面流量,也可能把短暂网络抖动放大为实例上下线。生产需要结合规模、网络、发布排空时间和客户端重试共同确定。
启动导入与运行时刷新
客户端采用 Config Data 导入:
spring.application.name=catalog
spring.config.import=configserver:http://control:8761/config没有 optional: 前缀时,远程配置不可用会导致启动失败。改成 optional:configserver:... 后,导入失败可以继续加载其他配置,但应用必需的属性、数据库凭据或校验条件仍要满足。使用可选导入,应事先准备完整且安全的本地默认值;付款开关、生产数据库地址等不适合随意兜底。
Config Data 可能先查询默认 profile,再查询活动 profile,启动日志中的多次获取不一定来自故障重试。连接超时、读取超时、重试和多地址策略也有各自配置。Config Client 导入与失败处理
运行中修改远程文件只改变配置源。客户端是否重新获取、哪个对象重新创建,还取决于刷新触发方式。实验使用 POST /actuator/refresh 重新加载配置,并让 @RefreshScope 的对象在下一次访问时重建。CatalogPolicy 的关键方法如下,完整源码见实验工程:
public CatalogPolicy(
@Value("${catalog.banner}") String banner,
@Value("${catalog.limit}") int limit) {
if (limit < 1 || limit > 50) {
throw new IllegalArgumentException("catalog.limit must be between 1 and 50");
}
this.banner = banner;
this.limit = limit;
}
public Snapshot snapshot() {
return new Snapshot(banner, limit);
}代理保持注入关系,真实目标对象按 refresh scope 的规则缓存。刷新清除目标后,下一次调用重新创建它。因此,刷新请求返回成功时,某些对象的构造校验还没执行。新配置导致构造失败,随后的业务访问可能报错。
@ConfigurationProperties 的重新绑定和 refresh scope 的目标重建也要区分。前者通常在现有绑定对象上更新属性;后者涉及代理及目标生命周期。跨多个 Bean 的一致切换需要应用自己设计,不能从单个注解推导出来。Spring Cloud 刷新机制与限制
示例通过一个 snapshot() 调用取得 banner 和 limit,避免两次代理调用之间发生目标切换。这只保证这次方法返回同一目标的字段组合。更复杂的配置可以先解析完整候选、校验依赖关系,再通过应用维护的不可变快照切换;“校验失败继续保留旧值”需要显式实现。
健康状态和刷新范围
Eureka 默认心跳维护注册状态,不会自动采用全部 Actuator 健康结果。启用 eureka.client.healthcheck.enabled 或编写健康处理器后,才能把相应状态传递给注册机制。数据库探测、业务就绪以及接入流量之间如何关联,还要看平台和调用方具体消费哪种状态。
Eureka Client 默认也可刷新;刷新时可能注销并重建客户端,短暂影响目录。实验设置:
eureka.client.refresh.enable=false这样,后面的配置刷新只观察业务参数对象,保持注册客户端不被一起重建。需要修改注册地址或注册客户端参数时,应按该配置安排重启,而不要期待业务 refresh 同时完成。
监听端口、JVM 参数和启动时建立的资源通常采用重启生效。连接池尤其不能当成普通阈值;例如 HikariDataSource 默认列在 Spring Cloud 的不可刷新对象中。切换数据库还涉及旧连接、在途事务及凭据过渡,宜用受控重启或专门设计的资源替换流程。
运行 Eureka、Config Server 和两个服务实例
准备环境与源码
使用 Linux、Bash、Docker Engine 与 Compose 插件,宿主机安装 curl、jq、unzip。以普通用户执行,用户应有 Docker 使用权限;加入 Docker 用户组本身具有较高主机权限,只在受信任的实验机操作。
下载完整实验 ZIP。源码包括两个 Maven 模块、远程配置文件、Compose 和复验脚本;解压目录中的 README.md 列出各入口。
test "$(id -u)" -ne 0 || exit 1
set -o pipefail
docker version
docker compose version
curl --version
jq --version
unzip microservice-discovery-config-lab.zip
cd microservice-discovery-config-lab
export COMPOSE_PROJECT_NAME=ms-discovery-lab
mkdir -p .m2
export LAB_DIR="$PWD"
export LAB_UID="$(id -u)"
export LAB_GID="$(id -g)"实验固定 Boot 4.1.1、Cloud 版本 2025.1.3、Maven 3.9.12;Java 编译目标为 17,运行镜像为 Temurin 25.0.4_7。Cloud 2025.1.x 自版本 2025.1.2 起支持 Boot 4.1.x,应通过 BOM 管理组件版本。Spring Cloud 兼容关系
control 模块同时启动 Eureka Server 与 Config Server,便于本机观察。生产通常根据各自容量、可用性和权限拆开部署。端口 18761、18141、18142 必须空闲;所有映射仅绑定宿主回环,管理接口未配置认证,禁止放到共享或公网环境。
构建容器使用宿主 UID/GID,Maven 缓存和临时主目录均可写:
docker run --rm --user "$LAB_UID:$LAB_GID" \
-e MAVEN_CONFIG=/m2 -e MAVEN_OPTS=-Duser.home=/tmp \
-v "$LAB_DIR:/work" -v "$LAB_DIR/.m2:/m2" -w /work \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp -Dmaven.repo.local=/m2 clean verify
test -s control/target/control.jar
test -s catalog/target/catalog.jar应看到两个模块各 2 个测试、总计 4 个测试通过,以及 reactor 的 BUILD SUCCESS。构建失败先看具体模块的首个错误;不要继续用上一次残留 JAR 启动。
Compose 中三个 Java 进程使用 10001:10001,只读挂载 JAR;容器根文件系统只读,/tmp 使用 tmpfs。它与构建身份分别配置。Compose 服务配置
先读取配置源,再启动应用
配置文件初始内容:
catalog.banner=green
catalog.limit=20启动 control,最多等待 60 次:
docker compose up -d control
ready=0
for attempt in $(seq 1 60); do
if curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 1 --max-time 2 \
http://127.0.0.1:18761/config/catalog/default > config-response.json; then
ready=1
break
fi
sleep 1
done
test "$ready" -eq 1 || { docker compose logs --tail=80 control; exit 1; }
jq '{name,profiles,version,propertySources}' config-response.json属性源中应出现 file:/config/catalog.properties、catalog.banner=green 和 catalog.limit=20。native 后端返回的属性值是字符串;应用构造参数再把 limit 转成整数。version 应为 null。
curl 的 -q 必须放在第一个选项位置,用于忽略默认配置文件;--noproxy '*' 让回环验证不经过代理。成功请求使用 --fail-with-body,HTTP 错误和传输错误都不能继续当成功处理。curl 参数手册
接着启动两个业务实例:
docker compose up -d catalog-a catalog-b
for port in 18141 18142; do
ready=0
for attempt in $(seq 1 60); do
if curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 1 --max-time 2 \
"http://127.0.0.1:$port/settings" > "settings-$port.json"; then
ready=1
break
fi
sleep 1
done
test "$ready" -eq 1 || { docker compose logs --tail=80; exit 1; }
jq -e '.banner == "green" and .limit == 20' "settings-$port.json"
done实例 a 的响应是 {"node":"a","banner":"green","limit":20},JSON 字段顺序可以不同。继续查看 a 的真实 DiscoveryClient 查询结果,等候两个实例都进入其本地目录:
ready=0
for attempt in $(seq 1 60); do
if curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 1 --max-time 2 \
http://127.0.0.1:18141/instances > instances.json &&
jq -e 'length == 2' instances.json >/dev/null; then
ready=1
break
fi
sleep 1
done
test "$ready" -eq 1 || { docker compose logs --tail=80; exit 1; }
jq . instances.json应出现 catalog:a → catalog-a:8080 与 catalog:b → catalog-b:8080。这验证了注册及客户端目录获取,尚未通过负载均衡发起跨服务业务调用。
发布配置与逐实例刷新
在挂载目录中用替换文件的方式发布新内容,避免直接截断正在被读取的原文件:
printf 'catalog.banner=blue\ncatalog.limit=35\n' > config/catalog.properties.next
mv config/catalog.properties.next config/catalog.properties
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 5 \
http://127.0.0.1:18761/config/catalog/default |
jq '.propertySources[0].source'
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 5 http://127.0.0.1:18141/settings配置服务器已返回 blue、35,但 a 仍返回 green、20。现在只刷新 a:
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 8 -X POST \
http://127.0.0.1:18141/actuator/refresh
for port in 18141 18142; do
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 5 "http://127.0.0.1:$port/settings"
donerefresh 返回变化的键,例如 ["catalog.limit","catalog.banner"],顺序无要求。随后 a 为 blue、35,b 仍为 green、20。向 b 的 refresh 端点发送同样请求后,它才进入新配置。
多实例配置发布需要观察应用当前生效版本。Config Server 文件修改完成、刷新消息送达、业务对象使用新值是三个可以分别失败的步骤。可在业务配置中加入发布标识,并通过受保护的管理查询核实每个实例已应用哪个版本。
校验失败发生在什么时候
发布一个超出允许范围的 limit,然后刷新 a:
printf 'catalog.banner=blue\ncatalog.limit=200\n' > config/catalog.properties.next
mv config/catalog.properties.next config/catalog.properties
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 8 -X POST \
http://127.0.0.1:18141/actuator/refresh
status=$(curl -q --noproxy '*' --silent --show-error \
--connect-timeout 2 --max-time 5 -o invalid.json -w '%{http_code}' \
http://127.0.0.1:18141/settings)
transport=$?
test "$transport" -eq 0 || exit 1
test "$status" = 500 || { cat invalid.json; exit 1; }
jq '{status,error,path}' invalid.jsonrefresh 自身仍是 HTTP 200;下一次 /settings 创建新目标时失败,返回 HTTP 500。日志中可以找到 catalog.limit must be between 1 and 50。不能只检查管理请求成功就结束配置发布。
继续分别查看健康与注册状态:
status=$(curl -q --noproxy '*' --silent --show-error \
--connect-timeout 2 --max-time 5 -o health.json -w '%{http_code}' \
http://127.0.0.1:18141/actuator/health)
transport=$?
test "$transport" -eq 0 && test "$status" = 503 || exit 1
jq -e '.status == "DOWN"' health.json
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 5 -H 'Accept: application/json' \
http://127.0.0.1:18761/eureka/apps/CATALOG |
jq '.application.instance[] | {instanceId,status}'这组配置下,健康端点为 DOWN,而 Eureka 仍把 a、b 列为 UP:注册客户端未参与刷新,也未启用健康状态传播。具体业务故障是否摘流,必须由健康策略及其消费者连接起来。
恢复文件并刷新两个实例:
printf 'catalog.banner=green\ncatalog.limit=20\n' > config/catalog.properties.next
mv config/catalog.properties.next config/catalog.properties
for port in 18141 18142; do
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 8 -X POST \
"http://127.0.0.1:$port/actuator/refresh"
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 5 "http://127.0.0.1:$port/settings" |
jq -e '.banner == "green" and .limit == 20'
done对照正常退出、突然终止和控制平面停机
正常退出先执行 docker compose stop catalog-b,随后查询 a 的 /instances;再 docker compose start catalog-b,等待目录恢复两个实例。强制终止使用下面的命令,它仅针对可丢弃实验进程:
docker compose kill -s KILL catalog-b
ready=0
for attempt in $(seq 1 30); do
if curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 1 --max-time 2 \
http://127.0.0.1:18141/instances > instances.json &&
jq -e 'length == 1 and .[0].instanceId == "catalog:a"' \
instances.json >/dev/null; then
ready=1
break
fi
sleep 1
done
test "$ready" -eq 1 || { docker compose logs --tail=80 control; exit 1; }强制终止后通常还能短暂读到 b;本次配置下的一次运行约十余秒后收敛。不要用 6 秒租约值直接断言 6 秒内所有调用方完成更新。若超时,应分别查看服务端目录、客户端目录和自我保护配置。
保留 b 停止状态,再停 control。a 的 /settings 仍可使用已加载的配置:
docker compose stop control
curl -q --noproxy '*' --silent --show-error --fail-with-body \
--connect-timeout 2 --max-time 5 http://127.0.0.1:18141/settings |
jq -e '.limit == 20'
docker compose run --rm --no-deps catalog-b \
java -Xmx192m -jar /app/app.jar \
--spring.cloud.config.request-connect-timeout=1000 \
--spring.cloud.config.request-read-timeout=1000 > startup-failure.log 2>&1
result=$?
test "$result" -ne 0 || exit 1
grep -E 'ConfigClientFailFastException|Could not locate PropertySource' startup-failure.log新启动的进程因必需配置源不可达而非零退出。现有实例能读旧配置,并不意味着此时可以安全发布、扩容或重建所有实例。
恢复 control 后,先重做“先读取配置源”中的就绪检查,再启动 b,并等 /instances 恢复两个条目。全部实验结束,停止并删除这个 Compose 项目的容器和网络:
docker compose down源码、配置文件、构建产物与 Maven 缓存保留在宿主目录。后续在相同初始配置下,可运行 bash verify.sh 复验配置刷新与恢复;它不替代前面的注册过程和故障观察。
注册正常却调用失败,配置发布却没有生效
按当前读取的对象定位
| 现象 | 优先检查 | 下一步 |
|---|---|---|
| 服务端没有实例 | 应用启动是否完成、注册 URL、认证与注册日志 | 修复注册通道,再检查客户端目录 |
| 服务端有实例,客户端没有 | 查询服务名、拉取失败、客户端缓存、状态过滤 | 从调用进程读取 DiscoveryClient 结果 |
| 两边有地址,连接失败 | 通告主机/端口、容器 DNS、网络策略、TLS | 在调用方网络直接连接该地址 |
| 注册 UP,业务 500 | 业务日志、配置目标创建、依赖健康、健康状态传播 | 修复业务故障,并验证摘流策略是否执行 |
| Config Server 新值,应用旧值 | 导入来源、profile、属性覆盖、刷新对象和目标实例 | 分别查询配置源与应用有效值 |
| refresh 成功,下一请求失败 | 延迟初始化、构造校验、资源重建 | 回退配置并重新刷新,验证业务访问 |
| 控制平面停机后重建失败 | 必需导入、默认值完整性、控制平面地址解析 | 先恢复配置服务,再恢复业务副本 |
排查注册问题时,不必一开始就清空所有缓存或重启所有实例。先保存调用方当前的目标地址,能直接区分配置错误与目录陈旧。排查配置问题时,应同时保留发布标识、属性来源与业务错误;不要把密码、令牌或完整 Environment 打进普通日志。
可用性与权限怎样安排
控制平面需要独立的容量和恢复方案。Eureka 多节点复制、Config Server 多副本及其配置存储,各自仍有连接和一致性条件;两台服务器同时读取同一个不可用磁盘,无法解决存储故障。客户端应配置明确的超时,避免控制平面访问占住大量启动线程。
如果 Config Server 地址也依赖注册发现,冷启动必须先取得注册服务器地址;如果注册地址又从 Config Server 读取,就会形成引导依赖。最初的访问地址和认证材料通常由本地启动配置、平台服务地址或密钥注入提供。
生产中的配置发布宜分批执行:先校验候选值,再让少量实例应用,确认有效值与业务指标,最后扩展。回退也应是一份明确配置,不应依赖读者记得上一个文件内容。存在状态迁移时,还需确认旧配置能够处理新值已经产生的数据。
管理端点需要认证、授权、审计和网络限制。读取配置、写入配置、触发刷新、管理注册条目应分配不同权限;profile、label 和 namespace 的命名不会自动提供这些限制。敏感值宜使用专门密钥存储或受控解密机制,访问日志和错误响应同样要脱敏。
Nacos 的服务端口、鉴权、配置订阅及 Alibaba 接入见 Spring Cloud Alibaba 体系;配置变化涉及环境数据和真实外部副作用时,结合开发、测试、预发与生产环境边界安排。
