Spring Boot 应用上云:JAR 配置热替换、systemd 托管与三个隐藏故障
背景
把一套 Spring Boot 单体应用(fat JAR,约 200 MB)部署到一台 1.7 GB 内存的云服务器上。数据库和 Redis 都在同一台机器。
看起来是最简单的部署场景:传 JAR、改配置、启动。实际卡了四次,每次原因都不一样。记一笔。
坑一:改一行配置,要重传 200 MB
Spring Boot 的 fat JAR 把 application-dev.yml 打包进了 BOOT-INF/classes/。数据库地址写错了,改完本地 yml 之后------
第一反应是 mvn clean package 重新打包,然后重新上传。但这里有两个问题:
- 重新打包依赖本地环境 :这台机器的
JAVA_HOME指向 JDK 8,项目要 JDK 17,mvn直接报无效的目标发行版: 17 - 重传 200 MB:家用上行带宽,一次十几分钟
更好的做法:JAR 就是 ZIP,直接原地替换里面的文件。
JDK 自带的 jar 命令支持增量更新:
bash
# 把新的 yml 放到 /tmp/application-dev.yml
# 然后原地更新 JAR 内的对应条目
jar uf /opt/app/myapp.jar -C /tmp application-dev.yml
jar uf 会在 JAR 里找到同名条目替换掉,不存在就新增。整个操作在服务器本地完成,网络传输量 = 一个 yml 文件的大小(几 KB)。
如果需要精确控制 JAR 内路径,用 Python 的 zipfile 重写也可以:
python
import zipfile
with zipfile.ZipFile("app.jar", "r") as zin, \
zipfile.ZipFile("app_new.jar", "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename.endswith("application-dev.yml"):
zout.writestr(item, new_yml_bytes)
else:
zout.writestr(item, zin.read(item.filename))
注意:这个技巧适合改配置文件这种「不影响编译产物」的场景。改 Java 代码当然还是要重新编译。
坑二:nohup 启动的进程,SSH 一断就没了
第一版启动命令:
bash
ssh server "cd /opt/app && nohup java -jar app.jar > app.log 2>&1 &"
进程起来了,ss -tln 能看到端口。但下一次 SSH 上去 pkill 再重启,进程就再也起不来了。
试过各种组合:
bash
setsid nohup java -jar app.jar > app.log 2>&1 < /dev/null & disown
disown 在非交互 shell 里不生效,setsid 也没能完全解决。根本原因是通过 SSH 的非交互 shell 启动的后台进程,生命周期管理很不确定------尤其是 paramiko 这类库执行完命令就关 channel,进程可能收到 SIGHUP。
正确做法:交给 systemd。
ini
# /etc/systemd/system/myapp.service
[Unit]
Description=My Spring Boot App
After=network.target mysqld.service redis.service
[Service]
Type=simple
WorkingDirectory=/opt/app
ExecStart=/usr/bin/java -Xmx256m -jar /opt/app/app.jar --spring.profiles.active=prod
StandardOutput=append:/opt/app/app.log
StandardError=append:/opt/app/app.log
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
bash
systemctl daemon-reload
systemctl enable myapp # 开机自启
systemctl start myapp
systemctl restart myapp # 以后重启就这一条
顺手拿到三个好处:进程脱离 SSH 会话、崩溃自动重启、开机自启。调试也方便:
bash
systemctl status myapp --no-pager
journalctl -u myapp -n 50 --no-pager
坑三:Client sent AUTH, but no password is set
应用起来了,端口在监听,日志里定时任务在正常轮询数据库。但一访问接口就 500:
org.springframework.data.redis.RedisConnectionFailureException: Cannot get Jedis connection
Caused by: redis.clients.jedis.exceptions.JedisDataException:
ERR Client sent AUTH, but no password is set
这个报错方向是反的------不是「密码错了」,是「你发了密码但我根本没设密码」。
应用配置里写了 spring.redis.password: xxx,但服务器上的 Redis 是 yum 装完直接启动的,/etc/redis.conf 里 requirepass 还是注释状态。
bash
# 确认当前状态
grep -n "^requirepass\|^# requirepass" /etc/redis.conf
# 480:# requirepass foobared ← 注释掉的
# 打开并设置
sed -i 's/^# *requirepass .*/requirepass yourpassword/' /etc/redis.conf
systemctl restart redis
# 验证
redis-cli -a yourpassword ping # 应返回 PONG
教训:应用配置和服务端配置是两头,改了一头要记得对齐另一头。部署清单里应该把「Redis 是否设密码」列成显式检查项,而不是假设默认行为。
坑四:Fontconfig head is null
登录页的验证码接口返回 500:
java.lang.RuntimeException: Fontconfig head is null, check your fonts or fonts configuration
验证码是服务端用 Java AWT 画出来的图片,需要系统字体。最小化安装的 Linux 镜像通常不带字体库------桌面环境才需要,服务器版本默认省掉了。
bash
yum install -y fontconfig dejavu-sans-fonts
# 验证
fc-list | wc -l # 装之前是 0,装之后有值
装完重启应用即可。
同类问题 :任何用到 java.awt 的功能(图片处理、图表生成、PDF 渲染、二维码带文字)在最小化服务器镜像上都可能踩这个。如果只是缺字体不想装完整字体包,也可以给 JVM 加 -Djava.awt.headless=true,但那只解决「没有显示设备」的问题,不解决「没有字体」。
内存 1.7 GB 怎么跑
顺带提一下资源限制。这台机器要同时跑 MySQL、Redis、Nginx 和 Spring Boot:
bash
java -Xmx256m -jar app.jar
-Xmx256m 是硬约束,不加的话 JVM 默认堆上限是物理内存的 1/4,看起来够,但加上 metaspace、线程栈、堆外内存,很容易把机器挤爆。实测这个应用在 256 MB 堆下稳定运行,RSS 约 580 MB。
MySQL 那边也要调:
ini
# /etc/my.cnf
innodb_buffer_pool_size = 128M
排错顺序总结
四个故障的共同点:日志里都有明确答案,只是要往下翻 。Caused by 链条的最后一层才是真正的根因:
RedisConnectionFailureException ← 表象
Caused by: JedisConnectionException ← 中间层
Caused by: JedisDataException:
ERR Client sent AUTH, but no password is set ← 真正原因
所以查日志的过滤条件应该是:
bash
grep -A2 "Caused by" app.log | tail -30
而不是只看第一行异常类型。
| 现象 | 真正原因 | 解法 |
|---|---|---|
| 改配置要重传大 JAR | fat JAR 打包了配置 | jar uf 原地替换 |
| 重启后进程起不来 | SSH 非交互 shell 的后台进程生命周期不可控 | 改用 systemd |
| 接口 500,Redis 连接失败 | 应用配了密码,服务端没设 | 对齐 requirepass |
| 验证码接口 500 | 最小化镜像无字体库 | 装 fontconfig + 字体包 |