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
调用过程大致是:
- Spring Boot 重新绑定日志配置。
- Binder 读取
logging.group。 - 读取操作进入 Jasypt 包装过的
PropertySource。 - Jasypt 的延迟属性解析器开始查找
jasyptStringEncryptor。 - Spring 容器仍处于
BeanFactoryPostProcessor等较早的启动阶段。 Sm4Encryptor被提前创建,常规的注解构造函数解析流程此时可能还没有就绪。- 创建逻辑尝试调用无参构造函数,最终抛出
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 不会在一个平台上拥有无参构造函数,换到另一个平台后又突然消失。
如果不同平台上的表现不一致,可以优先检查:
- 两边运行的是否为同一个 JAR,可以比较文件散列值。
- Docker 构建是否重新解析了不同的依赖版本。
- 是否使用了动态版本、SNAPSHOT 或没有锁定的 dependency。
- Windows 与容器中的 active profile 是否相同。
- JVM 参数、环境变量和启动命令是否一致。
- Dockerfile 是否复制了预期的构建产物。
latest基础镜像是否在两次构建之间发生了变化。- 容器是否使用了不同的 CPU 架构或 classpath。
可以先在同一台机器上使用完全相同的 JAR 进行对比:
java -jar errDemo.jar
docker run --rm \
-e JASYPT_PASSWORD=passwordForTest \
your-image
如果问题只在容器中出现,需要保留完整的异常链,不能只看 Spring Boot 的 Failure Analyzer 摘要。完整堆栈可以确认究竟是哪个类调用了无参构造函数。
目前还没有稳定的复现用例,因此无法据此认定“OpenJDK 25 或 distroless 存在跨平台一致性缺陷”。现有日志更像是 Jasypt 提前初始化自定义加密器,或者应用构建产物、依赖图不同导致的差异。
备注:内容仅供参考。
