ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用systemd托管Java jar进程:替代nohup,实现自动重启与开机自启

用systemd托管Java jar进程:替代nohup,实现自动重启与开机自启 1. 为什么要把 jar 进程交给 systemd而不是继续用 nohup先说个场景。很多人第一次在 Ubuntu 服务器上部署 Java 应用时拿到一个 Spring Boot 的 jar 包第一反应就是java -jar app.jar或者nohup java -jar app.jar 。这两种方式在本地开发、临时验证时完全没问题但一旦进入需要长期稳定运行的阶段问题就接踵而至终端一关进程就没了、服务器重启后不能自动拉起、进程崩溃了没人管、想看日志还得自己重定向到文件再手动 tail。我见过不少团队线上服务挂了半天没人发现原因就是部署方式还停留在 nohup 阶段。systemd 是 Linux 系统默认的服务管理守护进程Ubuntu 从 15.04 开始就用它替代了原来的 SysV init 脚本体系。通过 systemd 的 service 单元文件可以管理任意类型的进程——包括 Java 的 jar 包。本质上你写一个.service文件告诉 systemd这个进程怎么启动、怎么停止、崩溃了要不要自动重启、开机要不要自动拉起剩下的交给系统即可。这里必须说清楚 nohup 和 systemd 的本质差异。nohup 做的只是忽略挂断信号 后台运行它不负责监控进程状态而 systemd 是完整的服务生命周期管理器。用一个生活化的类比nohup 相当于你把孩子送到学校门口就走了孩子进没进教室、上课有没有睡觉你完全不知道systemd 相当于托管班几点到校、几点放学、中途有没有跑出去它全盯着出了问题还会主动联系你。这种差异在线上环境里是决定性的。所以如果你在 Ubuntu 上部署的是需要长期运行的 jar 服务而且不满足于能跑就行那用 systemd service 来管理几乎是最优解。下面我一步一步拆解从环境准备到配置编写再到排错和升级把整个流程完整过一遍。2. 写 service 文件之前必须确认好的三件事很多教程上来就贴 service 文件结果读者照着抄完发现起不来然后开始怀疑人生。其实问题往往不出在 service 文件本身而是前置条件没准备好。我这里先说三个最容易翻车的检查项。2.1 确认 JDK 安装路径ExecStart 里写错路径是最常见的启动失败原因service 文件里的ExecStart字段需要指定 java 命令的绝对路径。很多人直接写java -jar /opt/app/app.jar这在 shell 里能执行因为 shell 会去 PATH 环境变量里找 java但 systemd 启动服务时使用的环境极其精简PATH 通常只有/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin这几个目录如果 java 安装在/usr/lib/jvm/或者你自己解压的目录systemd 根本找不到它。所以在写配置之前先执行下面的命令确认 java 路径which java # 或者 readlink -f $(which java)比如输出是/usr/lib/jvm/java-17-openjdk-amd64/bin/java那 ExecStart 里就要写这个完整路径。还有一种更稳妥的做法在 service 文件里显式设置 PATH 环境变量[Service] EnvironmentPATH/usr/lib/jvm/java-17-openjdk-amd64/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin这样即使 systemd 的默认环境比较精简也能保证 java 命令可用。不过我个人还是建议直接用绝对路径少一层间接就少一个踩坑点。2.2 确认运行用户别用 root 跑业务进程这是运维底线service 文件里通常要指定User和Group。我见过很多刚入门的朋友图省事直接不写这两个字段默认就是 root 运行。这样带来的问题是一旦应用被注入攻击或者存在漏洞攻击者拿到的是 root 权限而且日志文件、数据文件的属主也全是 root后续排查问题会很麻烦。推荐的做法是创建一个专用的系统用户sudo useradd --system --no-create-home -s /usr/sbin/nologin appuser这个用户不能登录 shell只用来运行服务权限最小化。之后 jar 包所在目录的属主也改成这个用户sudo chown -R appuser:appuser /opt/app这样不管是日志写入还是临时文件生成都在应用用户自己的权限范围内干净又安全。2.3 确认 jar 包的工作目录WorkingDirectory 不设置相对路径文件全白搭WorkingDirectory 是另一个容易被忽略的字段。举个例子你的 Spring Boot 应用配置了file.upload-dir./uploads这里的./是相对于进程的工作目录而言的。如果不设置 WorkingDirectorysystemd 默认使用/作为工作目录那上传的文件就会跑到根目录的 uploads 里绝对不是你想要的。正确的做法是指定 jar 包所在目录[Service] WorkingDirectory/opt/app ExecStart/usr/lib/jvm/java-17-openjdk-amd64/bin/java -jar /opt/app/app.jar这样应用里的相对路径都基于/opt/app解析行为跟你在命令行手动 cd 过去执行完全一致。3. 手把手写一个能用在生产环境的 service 文件前置条件确认好之后写 service 文件就是顺理成章的事了。我直接给出一个我在生产环境经常用的模板然后逐行解释关键字段为什么要这么写。3.1 一个完整的 service 单元文件示例sudo vim /etc/systemd/system/myapp.service内容如下[Unit] DescriptionMy Spring Boot Application Afternetwork.target mysql.service Wantsnetwork.target [Service] Typesimple Userappuser Groupappuser WorkingDirectory/opt/app EnvironmentFile/etc/myapp.conf EnvironmentJAVA_HOME/usr/lib/jvm/java-17-openjdk-amd64 ExecStart/usr/lib/jvm/java-17-openjdk-amd64/bin/java -Xms512m -Xmx1024m -jar /opt/app/app.jar --spring.profiles.activeprod ExecReload/bin/kill -s HUP $MAINPID ExecStop/bin/kill -s TERM $MAINPID Restarton-failure RestartSec10 StartLimitIntervalSec60 StartLimitBurst3 TimeoutStopSec30 KillModemixed StandardOutputjournal StandardErrorjournal SyslogIdentifiermyapp [Install] WantedBymulti-user.target3.2 每个关键字段的设计逻辑先看Afternetwork.target mysql.service。这一行的意思是本服务要在网络就绪、以及 mysql 服务启动之后再启动。如果你的应用依赖数据库而数据库和它同在宿主机上且也是 systemd 管理一定要把依赖写清楚。否则可能出现 jar 先启动、尝试连数据库失败然后退出的情况——虽然 Restart 会把它拉起来但白白增加一次无谓的启动失败。Typesimple表示 ExecStart 启动的进程就是主进程。systemd 不需要额外等待什么只要进程 fork 成功就算启动完成。对于 java -jar 这种方式这是最合适的 Type不需要用Typeforking那种意在处理启动进程会 fork 出子进程然后父进程退出的场景Java 应用本身不会这样运行。ExecReload和ExecStop里使用了$MAINPID这是 systemd 提供的内置变量指向该服务的主进程 PID。发送HUP信号给 Java 进程Spring Boot 的 Actuator 或者自定义信号处理可以做一些刷新操作发送TERM信号则是安全终止让 Spring Boot 有机会执行优雅停机逻辑等存量请求处理完再退出。注意如果你没有特殊需求ExecStop其实可以省略systemd 默认就会向主进程发送TERM信号。我写上只是为了让它更明确方便后人阅读。Restarton-failure是核心中的核心。它表示只有进程异常退出时才自动重启。正常退出的场景比如systemctl stop不会触发重启。而RestartSec10是重启前等待 10 秒防止进程陷入崩溃-立刻重启-再崩溃的死循环里疯狂消耗 CPU。StartLimitIntervalSec60和StartLimitBurst3则进一步限制60 秒内如果启动失败超过 3 次systemd 会放弃重启并把这个服务标记为 failed。这两个参数组合起来就是最多让你试 3 次每次都间隔 10 秒超过上限就停止然后告警给你这套机制能有效避免故障时的无限自愈假象。KillModemixed是关乎优雅停机的关键设置。Java 进程会创建子进程比如使用 ProcessBuilder 调用外部命令。默认的KillModecontrol-group是向整个 cgroup 的所有进程发 SIGKILL这会粗暴地干掉所有子进程而KillModemixed会先向主进程发 SIGTERM等待一段时间由TimeoutStopSec控制等不到了再发 SIGKILL子进程则直接 SIGKILL。这样既能保证主进程优雅退出又不会遗留孤儿子进程。StandardOutputjournal的意思是把 stdout 和 stderr 都交给 systemd 的 journal 日志系统管理。这样你就不需要手动维护日志文件直接用journalctl -u myapp就能查看全部输出。不过如果你有专门的日志采集需求比如 ELK也可以改成StandardOutputfile:/var/log/myapp.log但 journal 方式对单机排障来说已经足够好用。SyslogIdentifiermyapp是给日志打标签方便用journalctl -t myapp过滤。3.3 为什么使用 EnvironmentFile 而不是直接在 service 里写环境变量模板里有一行EnvironmentFile/etc/myapp.conf。这是把环境变量单独放到一个文件里管理。为什么这么做因为应用配置数据库密码、Redis 地址、第三方密钥经常需要修改如果写在 service 文件里每改一次都要systemctl daemon-reload才能生效如果路径写错了还可能直接导致服务起不来。而 EnvironmentFile 的内容是启动时读取的改完文件内容后只需要systemctl restart myapp就能生效不用重新加载守护进程。这个文件的内容格式是简单的KEYVALUESPRING_DATASOURCE_PASSWORDYourPassword SERVER_PORT8080文件权限要收紧sudo chown root:appuser /etc/myapp.conf sudo chmod 640 /etc/myapp.conf这样只有 root 和应用用户能读避免明文密码泄露。需要注意EnvironmentFile 的文件如果不存在systemd 默认不会报错服务照样启动只是环境变量缺失所以一定要确保文件真实存在。4. 服务启动、开机自启与日志排查的完整操作链配置文件写完接下来的操作顺序是有讲究的很多人直接在写完文件后立刻systemctl start myapp然后报错这是正常的因为你还没告诉 systemd我新增了一个服务单元。4.1 让 systemd 识别新配置并启动服务顺序应该是这样的# 重新加载 systemd 配置让新写的 service 文件被识别 sudo systemctl daemon-reload # 设置开机自启 sudo systemctl enable myapp # 启动服务 sudo systemctl start myapp # 查看运行状态 sudo systemctl status myappstatus输出会告诉你服务的当前状态、主进程 PID、最近日志片段。看到绿色的active (running)就说明起来了。这里强调一下每次修改/etc/systemd/system/下的.service文件后都要重新执行daemon-reload否则 systemd 还会按旧的配置来管理服务这是个非常容易忽略的细节。4.2 用 journalctl 高效定位启动失败原因如果服务没起来第一反应不应该是瞎猜而是看日志。我自己排障的命令优先级是# 查看该服务的全部日志从最早开始 sudo journalctl -u myapp # 查看最近的 100 行 sudo journalctl -u myapp -n 100 # 实时跟踪日志输出类似 tail -f sudo journalctl -u myapp -f # 查看今天从某个时间点之后的日志 sudo journalctl -u myapp --since 2024-01-01 00:00:00日志能告诉你绝大多数问题的答案。比如 Spring Boot 启动失败的堆栈会直接打印出来端口被占用、数据库连不上、配置文件缺失这些错误都能在 journal 里看清楚。如果日志显示的是main线程异常退出那多半是应用自身的问题如果日志都没有那就要检查 service 文件本身的问题了。4.3 一张表看明白 systemctl 日常命令systemctl start myapp # 启动服务 systemctl stop myapp # 停止服务 systemctl restart myapp # 重启服务 systemctl reload myapp # 触发 ExecReload 信号 systemctl enable myapp # 设置开机自启 systemctl disable myapp # 取消开机自启 systemctl status myapp # 查看状态 systemctl is-enabled myapp # 查看是否已设置自启 systemctl list-units | grep myapp # 确认服务是否已加载这里多说一句reload和restart的区别。reload只是发送 HUP 信号进程本身的 PID 不变适合配置热加载的场景restart是完整停止再启动进程 PID 会变。如果你的 Java 应用没有实现 HUP 信号处理逻辑reload 跟没执行一样这时候还是老老实实 restart。4.4 服务起不来的一个完整排查案例我遇到过一次比较典型的情况值得拿出来当案例讲。同事部署一个 Spring Boot 应用执行systemctl start myapp后status显示active (running)但等了两分钟服务端口始终连不上。排查过程是这样的先看进程是否存在ps aux | grep app.jar结果显示有 java 进程而且 CPU 在跑说明 JVM 确实启动了。再看 journal 日志journalctl -u myapp --since 2 minutes ago日志停在Starting XNIO server...就不再动了。再往下看端口监听ss -tlnp | grep java发现 8080 端口没监听反而是所有网卡的随机端口都在建立连接。这时候我就怀疑是启动了但没完成——典型的进程活着但端口没监听。最后发现是数据库连接池连接超时设置太长应用在启动阶段阻塞在数据库连接上端口自然一直没起来。Typesimple只知道进程活着就算启动成功并不会检查端口是否就绪。这个案例给我们的教训是systemd 认为服务启动成功不代表应用真的 ready 了。如果业务上对就绪有严格要求可以考虑在启动脚本里做健康检查或者使用ExecStartPost配合等待检查但这些都是进阶话题暂不展开。基础阶段记住状态不代表可用要用日志和端口双重确认即可。5. 升级部署和线上运维中最容易翻车的两个细节service 模式下日常维护频率最高的操作就是发版升级。这个过程看似简单实际操作中却有两个高频翻车点必须单独拿出来说。5.1 覆盖 jar 包时的文件占用问题很多人升级的流程是先把新 jar 包直接覆盖旧文件再执行systemctl restart myapp。如果此时服务还在运行旧进程的代码已经被映射进内存文件本身被删掉或者覆盖是不影响运行中的进程的——在 Linux 上进程持有的文件描述符依然有效。但问题在于如果先停掉旧进程再覆盖文件覆盖操作会有一个时间窗口此时服务处于停止状态而如果先覆盖再重启覆盖动作因为文件正在被进程占用在特殊情况下会报Text file busy错误。规范的操作顺序应该是# 1. 停止服务释放文件占用 sudo systemctl stop myapp # 2. 备份旧版本回滚就靠它了 sudo mv /opt/app/app.jar /opt/app/app.jar.bak # 3. 上传新版本到临时位置然后移动到位 sudo mv /tmp/app.jar /opt/app/app.jar # 4. 启动服务 sudo systemctl start myapp # 5. 确认状态 sudo systemctl status myapp备份这一步很多人会省但我要劝你千万别省。系统运维的第一原则就是可回滚只要有一次线上发布出问题而你没有备份你就会理解这条原则的分量。5.2 多 jar 包、多实例的管理思路如果一个服务器上有多个 jar 服务或者同一个 jar 要拉起多份实例比如按端口区分我建议每个服务单独写一个 service 文件命名区分开比如myapp-8080.service、myapp-8081.service。不要尝试在一个 service 里用ExecStart执行多条命令systemd 本来就只支持一个主进程语法上也不允许这样写。你的项目目录结构可以是/opt/app/ ├── myapp-8080/ │ └── app.jar ├── myapp-8081/ │ └── app.jar └── logs/每个 service 文件里WorkingDirectory指向各自目录端口通过 EnvironmentFile 或者启动参数传入互不干扰。这样做的额外好处是某一份实例崩了systemd 只会拉起它自己不会影响其他实例。这里补充一个虚拟环境级别的技巧。如果你的 jar 里需要读取环境变量的地方很多可以考虑在[Service]段里用EnvironmentJAVA_OPTS-Xms512m -Xmx1024m然后把$JAVA_OPTS拼接到 ExecStart 里。但注意ExecStart 的值在 systemd 里不会做 shell 展开你必须写成下面这样ExecStart/usr/lib/jvm/java-17-openjdk-amd64/bin/java $JAVA_OPTS -jar /opt/app/app.jarsystemd 会自己做简单的变量展开但用的是 systemd 的语法$VAR或${VAR}不是 shell 的语法。这个细节很容易踩坑在 shell 里$JAVA_OPTS不带上引号会被切分成多个参数而在 systemd 里这个行为类似——它会按空白切分这在大多数场景下正是我们想要的。5.3 关于开机自启的一个验证技巧设置完enable之后最好执行一次systemctl is-enabled myapp确认输出是enabled。但这只代表 systemd 层面注册了自启不能代表系统重启后服务真的能正常拉起来。因为如果服务依赖了没准备好的资源比如挂载点、网络存储开机时可能启动失败。虽然Restarton-failure会自动尝试但如果在StartLimitIntervalSec内连续失败还是会放弃。所以更可靠的验证方式是直接重启服务器然后立刻看服务状态sudo reboot # 重启后用另一个终端登录 journalctl -u myapp -b-b参数表示只看本次开机以来的日志可以快速确认服务是否在开机阶段成功拉起了。这个方法虽然费点时间但比设置完 enable 就放心的走人要踏实得多。6. 我在实战中总结的一些小技巧最后说几个零散但实战价值很高的点。首先是 PIDFile很多人写 service 文件会想配这个字段希望 systemd 通过 PID 文件管理进程。但 Java 进程默认不会写 PID 文件你需要在启动命令里加-Dpidfile.path/var/run/myapp.pid这样的参数Spring Boot 支持但需要引入依赖或者干脆不用 PIDFile直接让 systemd 自己管理主进程的 PID。Typesimple模式下systemd 本来就清楚主进程是谁不写 PIDFile 反而更简单。其次是内存管理。如果服务器资源紧张建议在 ExecStart 里显式指定 JVM 堆大小参数如-Xmx512m不要放任 JVM 自动吃满机器内存。另外如果多个 jar 实例在同一台机器上我习惯同时在 service 文件的[Service]段加上MemoryMax1G这是 systemd 级别的 cgroup 内存限制超过阈值内核会 OOM kill 进程。有了它和Restarton-failure配合即便发生内存泄漏服务也能重启自救而不是拖垮整台机器。这个组合是我在线上环境里最依赖的保护网之一强烈建议用上。还有一个小习惯就是给每个 service 文件写清楚 Description。看起来不起眼但当你机器上有二三十个服务时systemctl list-units输出里能看到每个服务是干什么的比看到一堆名字全靠猜要舒服得多。把 jar 包交给 systemd 管理前期多花十分钟写配置后面省下的是无数个半夜爬起来手动拉进程的夜晚。这套方案在 Ubuntu 16.04 到 22.04 上我都实际部署过行为稳定值得信任。
返回列表