PAYATHON 2026

SpringBoot 使用环境变量配置 jasypt 密码时初始化失败

排障赵师傅

结论

@Value 多半没有解析失败,环境变量也不是直接原因。问题出在 Jasypt 提前请求了名为 jasyptStringEncryptor 的自定义加密器,此时 Spring 容器还没有完成常规 Bean 后处理器的注册。

在这个阶段,Sm4Encryptor 无法通过正常的 Spring 构造函数注入流程创建。框架或相关组件转而尝试用无参构造函数实例化它,但这个 Kotlin 类只有 Sm4Encryptor(String),没有 Sm4Encryptor(),因此抛出:

NoSuchMethodException: com.mt.errdemo.system.config.Sm4Encryptor.<init>()

这也能解释为什么把密码硬编码到 application.yaml 后仍然失败。异常出现在 Bean 创建阶段,@Value 还没有得到正常处理,所以密码来自环境变量还是 YAML,并不会改变结果。

为什么错误显示为 logging.group 绑定失败

日志最外层的异常是:

Failed to bind properties under 'logging.group'

这里通常只是异常向外传播的位置,不能据此判断 logging.group 配置有问题。

启动日志显示,Jasypt 正在替换 Spring 的 PropertySource:

Converting PropertySource systemEnvironment ...
Converting PropertySource Config resource 'application.yaml' ...
Re-initializing Logging System

调用过程大致是:

  1. Spring Boot 重新绑定日志配置。
  2. Binder 读取 logging.group。
  3. 读取操作进入 Jasypt 包装过的 PropertySource。
  4. Jasypt 的延迟属性解析器开始查找 jasyptStringEncryptor。
  5. Spring 容器仍处于 BeanFactoryPostProcessor 等较早的启动阶段。
  6. Sm4Encryptor 被提前创建,常规的注解构造函数解析流程此时可能还没有就绪。
  7. 创建逻辑尝试调用无参构造函数,最终抛出 NoSuchMethodException。

logging.group 只是触发属性读取的入口,后续排查应当沿着 Sm4Encryptor.<init>() 这条异常继续进行。

Kotlin 构造函数为什么会暴露这个问题

下面这个类编译后只有一个接收 String 的构造函数:

@Component("jasyptStringEncryptor")
class Sm4Encryptor(
    @param:Value("\${jasypt.encryptor.password}")
    private val password: String
) : StringEncryptor

对应的 Java 构造形式接近:

public Sm4Encryptor(String password)

Kotlin 不会为它自动生成无参构造函数。如果启动阶段的某段代码执行了类似操作:

Sm4Encryptor.class.getDeclaredConstructor().newInstance();

JVM 会查找:

Sm4Encryptor()

由于这个构造函数不存在,最终会看到:

Sm4Encryptor.<init>()

如果启动流程正常完成,Spring 可以识别单构造函数,并解析构造参数上的 @Value。当前问题是这个 Bean 被请求得太早。

另外,Kotlin 中属性占位符的正确写法是:

@Value("\${jasypt.encryptor.password}")

问题代码中的 $"${jasypt.encryptor.password}" 如果不是展示时产生的转义错误,就不是正确的 Kotlin 占位符写法。不过,这类语法问题通常会在编译阶段暴露,无法解释当前运行时出现的 NoSuchMethodException。

推荐解决方式

Jasypt 密码是解密其他配置所需的“启动凭据”。如果这个凭据还要依赖 Jasypt 包装后的 Spring Environment,就容易遇到启动顺序或循环依赖问题。

更稳妥的做法是让加密器在创建时直接从容器环境变量或 JVM system property 中读取启动凭据。

方案一:通过无参配置类创建 Bean

import org.jasypt.encryption.StringEncryptor
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration(proxyBeanMethods = false)
class EncryptorConfiguration {

    @Bean("jasyptStringEncryptor")
    fun jasyptStringEncryptor(): StringEncryptor {
        val password =
            System.getProperty("jasypt.encryptor.password")
                ?: System.getenv("JASYPT_PASSWORD")
                ?: error("JASYPT_PASSWORD is not set")

        return Sm4Encryptor(password)
    }
}

class Sm4Encryptor(
    private val password: String
) : StringEncryptor {

    override fun encrypt(message: String): String? {
        return EncryptUtils.sm4Encrypt(message, password)
    }

    override fun decrypt(encryptedMessage: String?): String? {
        return EncryptUtils.sm4Decrypt(encryptedMessage, password)
    }
}

这样既保留了构造函数注入,也不需要 Spring 在 Jasypt 初始化期间解析 @Value。

可以用 JVM system property 传入密码:

java -Djasypt.encryptor.password=passwordForTest -jar app.jar

也可以使用容器环境变量:

docker run \
  -e JASYPT_PASSWORD=passwordForTest \
  your-image

不要把密码写入日志。生产密码也不应写进镜像或提交到配置文件。

方案二:让加密器直接读取环境变量

如果想让实现更简单,可以给加密器使用无参构造函数:

import org.jasypt.encryption.StringEncryptor
import org.springframework.stereotype.Component

@Component("jasyptStringEncryptor")
class Sm4Encryptor : StringEncryptor {

    private val password: String by lazy {
        System.getProperty("jasypt.encryptor.password")
            ?: System.getenv("JASYPT_PASSWORD")
            ?: error("JASYPT_PASSWORD is not set")
    }

    override fun encrypt(message: String): String? {
        return EncryptUtils.sm4Encrypt(message, password)
    }

    override fun decrypt(encryptedMessage: String?): String? {
        return EncryptUtils.sm4Decrypt(encryptedMessage, password)
    }
}

这种写法绕过了启动早期的 @Value 处理,代价是加密器会更直接地依赖运行环境。用于读取启动凭据时,这种取舍通常可以接受。

不建议用默认参数伪造无参构造函数

例如:

class Sm4Encryptor(
    private val password: String = ""
) : StringEncryptor

这么做可能会消除 NoSuchMethodException,但程序会在密码为空的情况下继续运行,直到解密阶段才暴露问题,排查反而更麻烦。启动凭据缺失时,程序应立即失败并给出明确错误。

把 @Value 改成字段注入也不能可靠地解决问题:

@field:Value("\${jasypt.encryptor.password}")
lateinit var password: String

如果创建 Bean 时相关 BeanPostProcessor 还没有注册,字段仍有可能得不到注入。

依赖变化是否可能触发问题

有可能。依赖调整可能影响:

  • jasypt-spring-boot 或 starter 的版本;
  • Spring Boot、Spring Framework 的版本;
  • 自动配置和 BeanFactoryPostProcessor 的执行顺序;
  • Jasypt 通过代理包装 PropertySource 的方式;
  • Kotlin 插件、Spring 插件或编译产物中的构造函数;
  • classpath 上是否存在多个 Jasypt starter 或不一致的传递依赖。

依赖变化可能是触发条件,但当前日志还不足以证明某个具体版本存在缺陷。可以先查看完整的运行时依赖树:

./gradlew dependencyInsight \
  --dependency jasypt-spring-boot \
  --configuration runtimeClasspath
./gradlew dependencies --configuration runtimeClasspath

同时检查编译后的构造函数:

javap -p \
  -classpath build/classes/kotlin/main \
  com.mt.errdemo.system.config.Sm4Encryptor

如果输出中只有类似下面的构造函数,就可以确认这个类没有无参构造函数:

public Sm4Encryptor(java.lang.String);

还要对比故障发生前后的 lockfile、Gradle dependency report,以及实际打包进可执行 JAR 的依赖,不能只看声明版本。

Windows 与 distroless 表现不同怎么判断

NoSuchMethodException 反映的是 JVM 实际加载到的类结构,通常不是 Windows 和 Linux 处理环境变量名称的差异直接造成的。对于相同的字节码,Sm4Encryptor 不会在一个平台上拥有无参构造函数,换到另一个平台后又突然消失。

如果不同平台上的表现不一致,可以优先检查:

  1. 两边运行的是否为同一个 JAR,可以比较文件散列值。
  2. Docker 构建是否重新解析了不同的依赖版本。
  3. 是否使用了动态版本、SNAPSHOT 或没有锁定的 dependency。
  4. Windows 与容器中的 active profile 是否相同。
  5. JVM 参数、环境变量和启动命令是否一致。
  6. Dockerfile 是否复制了预期的构建产物。
  7. latest 基础镜像是否在两次构建之间发生了变化。
  8. 容器是否使用了不同的 CPU 架构或 classpath。

可以先在同一台机器上使用完全相同的 JAR 进行对比:

java -jar errDemo.jar
docker run --rm \
  -e JASYPT_PASSWORD=passwordForTest \
  your-image

如果问题只在容器中出现,需要保留完整的异常链,不能只看 Spring Boot 的 Failure Analyzer 摘要。完整堆栈可以确认究竟是哪个类调用了无参构造函数。

目前还没有稳定的复现用例,因此无法据此认定“OpenJDK 25 或 distroless 存在跨平台一致性缺陷”。现有日志更像是 Jasypt 提前初始化自定义加密器,或者应用构建产物、依赖图不同导致的差异。

备注:内容仅供参考。