本文来自一轮 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 只用于发现和临时迁移重命名属性,完成升级后应删除该依赖。

所以处理顺序是:

  1. 在 Nacos 中搜索旧 key 的所有 namespace、group 和 dataId。
  2. 用新 key 替换。
  3. 滚动服务并确认不再出现迁移报告。
  4. 从 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 Clientlogback-adapter
2.2.1–2.3.x1.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 引入旧 JARparent 或公共 starter
只有一个可选模块将空值传给反射 API业务代码
Adapter 与客户端版本不匹配dependency management + starter

不要因为日志出现在 Kubernetes 里,就默认所有问题都应通过 YAML 修复。

滚动验证清单

  1. 构建后检查最终 JAR 的依赖,不只检查 IDE dependency view。
  2. 渲染 Deployment,确认 HOME、user.home、挂载和 UID。
  3. 先滚动一个副本。
  4. 等新 ReplicaSet Ready,确认旧 Pod 仍能兜底。
  5. 检查启动日志中的五类关键字。
  6. 触发 Nacos 注册、Redis、文件上传和可选 JDBC 的真实调用路径。
  7. 再滚动剩余副本。
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 冲突。