本文来自一轮 Spring Boot 3 服务滚动发布。类名、服务名、路径和业务配置均已泛化。系列导航:Kubernetes 重建迁移教程系列。
统一 Deployment 发布后,启动日志同时出现了五类信息:
UnsatisfiedDependencyException ... Factory method threw: mkdir failed
failed to create cache dir: /root/nacos/naming/.../failover
server.use-forward-headers is no longer supported
please remove commons-logging.jar from classpath
Could not initialize Logback Nacos logging from classpath:nacos-logback.xml
java.lang.ClassNotFoundException:
at java.lang.Class.forName(...)
把它们都归因于“Deployment 改坏了”并不准确;把它们都当成无害的 warning 也危险。真实情况跨越四层:
| 层 | 典型问题 | 是否可能阻止启动 |
|---|---|---|
| 文件系统 | SDK 无法创建缓存或断点文件 | 是 |
| 配置迁移 | 旧属性由 migrator 临时映射 | 通常否 |
| classpath | 日志桥重复、Logback 版本不兼容 | 视组件而定 |
| 业务代码 | 将空字符串传给 Class.forName | 是或形成隐患 |
正确的排查顺序
flowchart TD
A["Spring Boot 启动失败"] --> B["从异常最底部向上读"]
B --> C{"最深 Cause 是什么"}
C --> D["IOException / mkdir"]
C --> E["NoClassDefFound / ClassNotFound"]
C --> F["Properties migration"]
C --> G["Logging warning"]
D --> H["检查 UID、HOME、挂载与权限"]
E --> I["检查有效配置和依赖树"]
F --> J["替换配置 key"]
G --> K["清理日志桥或增加适配器"]
UnsatisfiedDependencyException 往往只是最外层包装。Controller 依赖 Service,Service 依赖 FileManager,FileManager 构造时创建目录失败,最终才让 Controller 无法注入。
不要一看到堆栈第一屏出现哪个 Bean,就先改哪个 Bean。
事故一:UnsatisfiedDependencyException 的根因是 mkdir
外层日志:
Error creating bean with name 'voucherController'
Unsatisfied dependency expressed through constructor parameter 0
继续向下:
Error creating bean with name 'fileManager'
Factory method 'fileManager' threw exception with message: mkdir failed
最底部:
Caused by: java.io.IOException: mkdir failed
at ...FileRecorder.<init>(...)
这说明:
- Spring DI 本身没有坏。
- Controller 和 Service 构造器不是首要问题。
- 第三方文件 SDK 在 Bean 初始化阶段创建断点记录目录失败。
先基于相同镜像和安全上下文进入容器检查:
id
printf 'HOME=%s\n' "$HOME"
java -XshowSettings:properties -version 2>&1 | grep 'user.home'
mount
ls -ld /root /tmp /data/java/logs
touch /tmp/write-test
容器即使能以 root UID 运行,也不代表任何路径都可写;read-only root filesystem、卷权限、挂载覆盖和 SDK 选择的默认路径都会影响最终结果。
事故二:Nacos 为什么突然写 /root/nacos
Nacos Client 会保存 naming failover、snapshot 或日志。路径计算可能依赖:
HOME。- Java
user.home。 - Nacos 的
JM.SNAPSHOT.PATH。 - Nacos 的
JM.LOG.PATH。
旧 Deployment 恰好允许写 /root,所以问题一直被隐藏。安全基线收紧或镜像用户变更后,启动线程开始报告:
failed to create cache dir: /root/nacos/naming/<namespace>/failover
修复方式是给客户端一个明确、受控、可写的目录:
volumeMounts:
- name: tmp-volume
mountPath: /tmp
env:
- name: HOME
value: /tmp
- name: JDK_JAVA_OPTIONS
value: >-
-Duser.home=/tmp
-DJM.SNAPSHOT.PATH=/tmp
-DJM.LOG.PATH=/data/java/logs/nacos
volumes:
- name: tmp-volume
emptyDir:
sizeLimit: 2Gi
启动脚本提前创建目录并在失败时退出:
mkdir -p /tmp/nacos /data/java/logs/nacos
test -w /tmp
test -w /data/java/logs/nacos
不要通过恢复所有 capability 或让整个根文件系统可写来解决一个缓存目录问题。
还要区分严重程度:
- Nacos failover 备份失败,主注册流程有时仍能工作,但容灾能力下降。
- 文件 SDK 在 Bean 构造期间
mkdir failed,会直接导致 Spring Context 启动失败。
“日志级别都是 ERROR”不代表影响相同,必须结合异常发生的生命周期阶段判断。
事故三:server.use-forward-headers 是迁移提示
日志:
The use of configuration keys that are no longer supported was found:
server.use-forward-headers
Reason: Replaced to support additional strategies.
升级期间如果引入 spring-boot-properties-migrator,应用可能仍能启动,因为 migrator 会临时映射旧 key。但应该尽快替换:
# 删除
server.use-forward-headers=true
# 根据代理链选择
server.forward-headers-strategy=framework
FRAMEWORK、NATIVE 和 NONE 的差异见 Spring Boot 反向代理指南。
Spring Boot 升级指南明确说明,properties migrator 只用于发现和临时迁移重命名属性,完成升级后应删除该依赖。
所以处理顺序是:
- 在 Nacos 中搜索旧 key 的所有 namespace、group 和 dataId。
- 用新 key 替换。
- 滚动服务并确认不再出现迁移报告。
- 从 parent 或应用中移除 properties migrator。
不要通过关闭迁移日志来“解决”配置问题。
事故四:commons-logging 与 spring-jcl 同时存在
警告:
Standard Commons Logging discovery in action with spring-jcl:
please remove commons-logging.jar from classpath
Spring Framework 使用 spring-jcl 作为 Commons Logging bridge。classpath 中又出现传统的:
commons-logging:commons-logging
就可能发生日志实现发现顺序冲突。
先定位引入链:
mvn dependency:tree \
-Dverbose \
-Dincludes=commons-logging:commons-logging
不要在几十个业务仓库逐个排除。如果依赖来自统一的 Nacos config/discovery starter,应在其直接依赖边界排除:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
discovery starter 若有独立依赖,也要在对应边界排除。Maven exclusion 只作用于指定依赖路径;另一个 SDK 仍可能再次引入同一个 JAR。
治理后重新检查:
mvn dependency:tree \
-Dincludes=commons-logging:commons-logging
jar tf application.jar \
| grep 'BOOT-INF/lib/commons-logging'
如果组织内要求长期禁止该依赖,可以在 parent 中增加 Maven Enforcer 的 bannedDependencies 规则;但应先处理已知传递链,避免一次升级导致所有仓库同时被阻断。
事故五:Nacos 无法加载 nacos-logback.xml
警告:
Load Logback Configuration of Nacos fail,
message: Could not initialize Logback Nacos logging
from classpath:nacos-logback.xml
这不是“缺少一个 XML 文件”这么简单。Spring Boot 3 使用 Logback 1.4,而部分 Nacos Client 2.2.x 的日志初始化方式与其不兼容。
Nacos 官方 FAQ给出的适配关系是:
| Nacos Client | logback-adapter |
|---|---|
| 2.2.1–2.3.x | 1.0.x |
| 2.4.0 及以后 | 1.1.x |
对于 Nacos Client 2.2.1,可以在统一依赖管理中锁定:
<properties>
<nacos-logback-adapter.version>1.0.1</nacos-logback-adapter.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.nacos</groupId>
<artifactId>logback-adapter</artifactId>
<version>${nacos-logback-adapter.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
但 dependencyManagement 只管理版本,不会自动把 JAR 放进应用。真正使用 Nacos 的公共 starter 还要声明:
<dependency>
<groupId>com.alibaba.nacos</groupId>
<artifactId>logback-adapter</artifactId>
</dependency>
最终用可执行 JAR 验证,而不是只看 parent:
jar tf application.jar \
| grep -E 'nacos-client|logback-adapter|logback-classic'
不要自己复制一份旧 nacos-logback.xml 到每个应用。这样会把版本兼容责任分散到各业务仓库,下次 Nacos 升级时还会再次漂移。
事故六:没有 message 的 ClassNotFoundException
另一个服务启动时出现:
java.lang.ClassNotFoundException:
at java.lang.Class.forName0(...)
at java.lang.Class.forName(...)
at ...JDBCUtil.initialize(...)
异常信息冒号后没有类名,这是重要线索。代码实际执行了:
Class.forName(driver);
但可选配置:
sales.driver=
sales.url=
为空字符串。Class.forName("") 会抛出几乎没有业务信息的 ClassNotFoundException。
如果该同步数据源是可选功能,初始化逻辑应先判断“功能是否启用”:
@PostConstruct
void initialize() {
if (!StringUtils.hasText(url)) {
log.info("未配置 sales.url,同步功能已禁用");
return;
}
if (!StringUtils.hasText(driver)) {
throw new IllegalStateException(
"已配置 sales.url,但缺少 sales.driver"
);
}
try {
Class.forName(driver);
} catch (ClassNotFoundException ex) {
throw new IllegalStateException(
"无法加载同步数据库驱动: " + driver,
ex
);
}
}
更理想的做法是使用条件装配:
@ConditionalOnProperty(
prefix = "sales",
name = "url"
)
让未配置的可选功能根本不创建 Bean。不要用空字符串表示“启用但暂时不知道驱动”。
另外,Class.forName 所在的方法曾被命名为 getServletPort,但它实际加载的是 JDBC 驱动。错误命名会让堆栈误导排障,应一并修正。
如何判断应该改 Deployment、Nacos、parent 还是业务代码
| 证据 | 修改位置 |
|---|---|
| 同一镜像在旧安全上下文下可写,新上下文下不可写 | Deployment 与挂载 |
| key 被 properties migrator 映射 | Nacos / application 配置 |
| 多个应用都由同一 starter 引入旧 JAR | parent 或公共 starter |
| 只有一个可选模块将空值传给反射 API | 业务代码 |
| Adapter 与客户端版本不匹配 | dependency management + starter |
不要因为日志出现在 Kubernetes 里,就默认所有问题都应通过 YAML 修复。
滚动验证清单
- 构建后检查最终 JAR 的依赖,不只检查 IDE dependency view。
- 渲染 Deployment,确认
HOME、user.home、挂载和 UID。 - 先滚动一个副本。
- 等新 ReplicaSet Ready,确认旧 Pod 仍能兜底。
- 检查启动日志中的五类关键字。
- 触发 Nacos 注册、Redis、文件上传和可选 JDBC 的真实调用路径。
- 再滚动剩余副本。
kubectl -n <namespace> logs <new-pod> --since=20m \
| grep -Ei \
'mkdir failed|/root/nacos|use-forward-headers|commons-logging|nacos-logback|ClassNotFound'
完成定义
- Spring 异常已追到最底层 Cause,而不是停在 Bean 名。
- 所有需要写入的客户端目录都有受控挂载和大小限制。
- Nacos 不再尝试写
/root/nacos。 - Nacos、上传 SDK 和日志目录都完成真实写入验证。
server.use-forward-headers已从所有 Nacos dataId 删除。- 最终 JAR 不包含传统
commons-logging.jar。 - Nacos Client 与 logback-adapter 版本匹配。
- 可选 JDBC 功能在未配置时不加载驱动。
- 新旧 ReplicaSet 切换完成,没有旧 Pod 长期兜底。
最终结论
这轮启动事故并没有一个统一的“神奇配置”。它真正提供的是一条稳定的判断路径:
先读最深 Cause
→ 判断文件系统、配置、classpath 还是业务代码
→ 在最接近根因的公共边界修复
→ 检查最终 JAR 和最终 Pod,而不是源码与模板
安全基线暴露了以前依赖 /root 可写的隐式假设;Spring Boot 3 暴露了旧属性和日志栈的版本边界;空 Class.forName 则暴露了业务代码没有正确表达“可选功能”。
把这些问题分别修复在 Deployment、Nacos、公共 starter 和业务代码中,才不会为了消除一条日志,又给整个集群放宽权限或引入新的 classpath 冲突。
DISCUSSION
讨论与反馈