ARTICLE DETAIL

资讯详情

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

Java服务JAR包自动启动脚本:从Shell到systemd完整指南

Java服务JAR包自动启动脚本:从Shell到systemd完整指南 做后端服务的同学应该都体会过这种场景服务上线之后每次服务器重启或者进程意外挂掉都得手动登录服务器执行一串java -jar命令。运气不好还得先用ps查到旧进程再kill一套操作下来少说折腾几分钟。遇到半夜告警困得眼睛都睁不开还得分毫不差地把命令敲对稍不留神窗口一关服务跟着没了。后来我把常用的 JAR 包部署流程整理成一套自动启动脚本启动、停止、重启、开机自启、日志归档这些日常操作全部一键搞定省下大量重复劳动。这篇文章就把这套脚本的核心设计、完整代码和踩过的坑一起分享出来适合正在手工部署 Spring Boot 项目、想用脚本一键管理服务的朋友参考。1. 为什么需要一个像样的JAR包自动启动脚本1.1 手动操作的那些坑新手阶段我部署 JAR 包基本靠三条命令java -jar app.jar启动CtrlC停掉窗口一关就完事。这在本地开发环境没啥问题但放到服务器上各种问题就全冒出来了。第一个坑是进程和终端会话绑定。直接在 SSH 终端里执行java -jar只要终端一断开服务大概率会被挂掉哪怕服务本身没崩溃。原因在于进程收到了 SIGHUP 信号默认行为就是终止。很多人以为用nohup加就万事大吉但实际上如果不把标准输出和错误输出重定向到文件日志会直接丢到 nohup.out 里越堆越大占满磁盘你都不知道。第二个坑是端口冲突。服务没停干净就重新启动报Port already in use只能先去ps -ef | grep java找旧进程再kill -9。如果服务器上部署了多个 Java 服务grep java出来的结果一大片稍不注意就把别人的服务给杀了真实生产事故就是这么来的。第三个坑是重启之后没人管。服务器因为补丁或者硬件原因重启了机器起来了但业务没起来一直等到用户投诉才发现。这种问题靠人肉盯着根本不现实必须有机制让服务跟着系统一起拉起。1.2 自动启动脚本到底解决什么问题把部署动作脚本化本质上是把人脑中的操作步骤固化成可重复执行的程序逻辑。它解决的问题不只是省一次敲命令的时间而是让部署行为变得确定、可追踪、可复用。确定性强是最重要的一点。人操作容易漏步骤比如忘了配环境变量、忘了切目录、启动参数少写一个。脚本一但写好每次执行都是同一套流程不会出现这次成功、下次失败这种玄学情况。可追踪体现在 PID 文件和日志上。脚本启动时把进程号记录到固定文件停止时直接按 PID 精准操作不再需要ps grep大海捞针。日志统一按日期归档排障时直接查对应时间段的文件效率高得多。可复用则是说这套脚本积累下来换项目、换服务器都能直接用。我后来所有 Java 服务的部署目录结构都一样app.jar、logs/、bin/换机器只需要改配置变量脚本基本不动。2. 脚本设计的核心思路与关键参数2.1 功能清单一个合格脚本必须具备的能力先别急着写代码把需求列清楚。我整理下来一个能扛住生产环境的 JAR 包自启动脚本至少要具备以下能力启动能设置 JVM 参数、指定配置文件、把日志写到固定位置并把启动后的 PID 记录下来。停止支持优雅停机给 Spring Boot 应用留出处理收尾逻辑的时间停不掉再考虑强制杀。重启等于先停后启但要保证端口的释放避免启动失败。状态检查能判断服务到底活着没有而不是盲目执行操作。开机自启交给 systemd 或系统计划任务接管保证重启后服务自动拉起。日志管理日志按日期或大小归档定期清理旧数据。这些能力不是一次写完的我是从一次生产事故里意识到必须补全的。当时服务磁盘被日志写满新的日志文件创建失败服务静默不可用。后来脚本里加上了日志切割和清理策略才彻底踏实。2.2 JVM参数与环境变量怎么给JVM 参数如果不写在脚本里每次启动都要现打既慢又容易错。把常用参数抽成脚本顶部的变量区域改动只动一处是最省心的做法。JAVA_HOME/usr/local/java/jdk17 JAR_FILE/opt/myapp/app.jar JAVA_OPTS-Xms512m -Xmx1024m -XX:MetaspaceSize128m -XX:MaxMetaspaceSize256m APP_ENVprod-Xms 和 -Xmx 建议设成同一个值避免运行期堆扩容造成性能抖动。-Xmx 具体设多大要看机器的物理内存和这台机器上的其他服务。我一般按机器内存的 40%-50% 来分配同时用free -m确认剩余内存足够防止一启动就把机器拖垮。Spring Boot 项目经常要用到不同环境的配置所以用--spring.profiles.active$APP_ENV指定运行环境很实用。如果配置文件放在 jar 包外面再加一句--spring.config.additional-location/opt/myapp/config/这样改配置不用重新打包。还可以根据自己的需求追加-Dfile.encodingUTF-8固定文件编码以及-Duser.timezoneAsia/Shanghai统一时区避免日志时间和业务时间对不上。2.3 PID文件与日志的约定PID 文件的作用是记录当前启动的进程号让脚本在停止、重启、状态检查时能快速定位目标进程。约定一个固定路径比如/opt/myapp/app.pid注意这个文件要放在有写权限的目录下/var/run这类目录经常需要 root 权限不建议直接用。日志方面我习惯建一个独立的logs/目录启动脚本把标准输出和标准错误都重定向到日志文件而不是把所有内容混到 Linux 原生的 nohup.out 里。命名规则用日期app-20240101.log这种方便按天排查。达到一定量后让 logrotate 或脚本自身清理后面我会给出具体做法。PID 和日志的约定是整个脚本体系的地基有了这套约定stop、restart、status 几个功能才有共同语言不然每个脚本各查各的还是一团乱麻。3. Linux环境完整实现从Shell脚本到systemd开机自启3.1 start.sh一键启动并记录PID先把最基础也最关键的 start.sh 写出来。我的做法是把配置部分和逻辑部分分开配置全放顶部逻辑尽量短小直白。#!/bin/bash # 配置区 APP_NAMEmyapp JAR_FILE/opt/myapp/app.jar PID_FILE/opt/myapp/app.pid LOG_DIR/opt/myapp/logs JAVA_OPTS-Xms512m -Xmx512m -XX:MetaspaceSize128m -XX:MaxMetaspaceSize256m APP_ENVprod EXTRA_CONFIG_DIR/opt/myapp/config # mkdir -p $LOG_DIR if [ -f $PID_FILE ]; then OLD_PID$(cat $PID_FILE) if kill -0 $OLD_PID 2/dev/null; then echo 服务已经在运行中, PID$OLD_PID exit 1 fi rm -f $PID_FILE fi cd /opt/myapp nohup $JAVA_OPTS -jar $JAR_FILE \ --spring.profiles.active$APP_ENV \ --spring.config.additional-location$EXTRA_CONFIG_DIR/ \ $LOG_DIR/${APP_NAME}-$(date %Y%m%d).log 21 echo $! $PID_FILE for i in $(seq 1 20); do sleep 1 if kill -0 $(cat $PID_FILE) 2/dev/null; then PID_VAL$(cat $PID_FILE) echo 启动成功, PID$PID_VAL exit 0 fi done echo 启动超时, 请查看日志 $LOG_DIR/${APP_NAME}-$(date %Y%m%d).log exit 1注意我例子里的$JAVA_OPTS如果被当成一个整体参数传给 java 会出错建议按下面这种更稳妥的方式写java $JAVA_OPTS -jar $JAR_FILE --spring.profiles.active$APP_ENV \ --spring.config.additional-location$EXTRA_CONFIG_DIR/ \ $LOG_DIR/${APP_NAME}-$(date %Y%m%d).log 21 kill -0这个命令很实用它不会真的发信号给进程只是探活。如果进程存在返回 0不存在返回非 0用这个做健康判断既轻量又不会有误杀风险。为了防止系统刚重启完马上执行导致误判我还用了循环等待每 1 秒检查一次最多等 20 秒。对于 Spring Boot 这种启动需要几秒到十几秒的应用这个等待时间合理够用。启动日志追加到当天日期命名的文件里用的是追加而不是覆盖避免重启时把上一次的日志清掉排查问题才能找到历史线索。3.2 stop.sh与restart.sh优雅停机与平滑重启stop.sh 的核心是优雅停机。Spring Boot 应用通过kill不带 -9会触发 JVM 的 Shutdown Hook让 Spring 容器走完销毁流程把数据库连接池关闭、正在处理的请求尽量收尾这比kill -9直接强杀安全得多。#!/bin/bash APP_NAMEmyapp PID_FILE/opt/myapp/app.pid if [ ! -f $PID_FILE ]; then echo PID 文件不存在, 服务可能未启动 exit 1 fi PID_VAL$(cat $PID_FILE) kill -0 $PID_VAL 2/dev/null if [ $? -ne 0 ]; then echo 进程 $PID_VAL 不存在, 清理 PID 文件 rm -f $PID_FILE exit 0 fi echo 正在停止服务, PID$PID_VAL kill $PID_VAL STILL_RUNNING1 for i in $(seq 1 30); do sleep 1 if ! kill -0 $PID_VAL 2/dev/null; then STILL_RUNNING0 break fi done if [ $STILL_RUNNING -eq 0 ]; then echo 服务已停止 rm -f $PID_FILE else echo 超过 30 秒仍未退出, 执行强制停止 kill -9 $PID_VAL rm -f $PID_FILE fi这里我留了 30 秒的宽限期。如果应用处理长事务比较多可以把等待时间调大比如 60 秒。强制杀是因为某些情况下 Spring Boot 的 Shutdown Hook 被卡住比如线程池任务一直不结束这时候不强制杀旧进程占着端口后面服务就起不来。但kill -9是最后手段不能一上来就用。restart.sh 不用单独写太多调用 stop 再调用 start 就行。唯一要注意的是 stop 的循环等待完成后端口可能还没完全释放需要小睡一下再启动保险起见加 2 到 3 秒#!/bin/bash bash /opt/myapp/bin/stop.sh sleep 3 bash /opt/myapp/bin/start.sh如果你有多实例部署的需求还可以把端口号、服务名作为参数传进脚本写成一个通用函数。我早期图省事写死过后来服务多了之后改成参数化一次改动到处受益。3.3 systemd服务让JAR包开机自动拉起脚本写好只是第一步还要解决开机自启的问题。早期我用过 crontab 的reboot来触发 start.sh简单但不可控日志、依赖顺序都不好管理。后来切到 systemd发现这才是 Linux 上管理 Java 服务最标准的姿势。在/etc/systemd/system/myapp.service创建服务文件内容如下[Unit] DescriptionMy Application Service Afternetwork.target mysql.service [Service] Typeforking Userappuser Groupappgroup PIDFile/opt/myapp/app.pid ExecStart/opt/myapp/bin/start.sh ExecStop/opt/myapp/bin/stop.sh ExecReload/opt/myapp/bin/restart.sh Restartalways RestartSec5 StartLimitIntervalSec0 StandardOutputappend:/opt/myapp/logs/systemd-out.log StandardErrorappend:/opt/myapp/logs/systemd-err.log [Install] WantedBymulti-user.targetTypeforking表示启动脚本会以子进程方式在后台运行systemd 会对 PID 文件进行跟踪比Typesimple更符合我们自己脚本的执行方式。Afternetwork.target确保网络就绪后再启动服务有 MySQL 依赖的话可以加上mysql.service或者直接在 After 里列出。Restartalways是核心配置。它保证了服务进程崩溃后systemd 会在 5 秒后自动重新拉起这比任何手动脚本守护都可靠。RestartSec5防止疯狂重启打满 CPU。StartLimitIntervalSec 设成 0 是避免短时间内重启次数过多被 systemd 直接放弃但这要根据实际情况权衡如果服务一直起不来无限重启也不合理。配好后依次执行systemctl daemon-reload systemctl enable myapp systemctl start myappenable是开机自启的关键会在/etc/systemd/system/multi-user.target.wants/下生成一个软链接。之后用systemctl status myapp查看运行状态journalctl -u myapp -f查看服务的标准日志输出。这里有个细节很多人忽略systemd 服务里指定了User之后所有启动动作都会以这个用户身份执行。如果/opt/myapp的属主不是该用户start.sh 里写 PID 文件、写日志都会失败。我第一次配这个就踩了权限坑排查了半天才发现目录属主不对。4. Windows Server环境实现批处理脚本与任务计划4.1 startup.bat与stop.batWindows 服务器上跑 JAR 包同样属于常见场景尤其是很多企业内网环境直接上了 Windows Server。Windows 下没有 systemd但可以用批处理脚本加任务计划达到类似效果。先看启动脚本 startup.batecho off setlocal set APP_NAMEmyapp set APP_HOMED:\apps\myapp set JAR_FILE%APP_HOME%\app.jar set LOG_FILE%APP_HOME%\logs\app-%date:~0,4%%date:~5,2%%date:~8,2%.log set JAVA_OPTS-Xms512m -Xmx512m if not exist %APP_HOME%\logs mkdir %APP_HOME%\logs set PID_FILE%APP_HOME%\app.pid if exist %PID_FILE% ( set /p OLD_PID%PID_FILE% tasklist /FI PID eq %OLD_PID% 2NUL | find /I %OLD_PID% NUL if not errorlevel 1 ( echo 服务已在运行中, PID%OLD_PID% exit /b 1 ) ) cd /d %APP_HOME% start JAR-Server-%APP_NAME% /B javaw %JAVA_OPTS% -jar %JAR_FILE% echo %errorlevel% %PID_FILE% echo 服务启动完成, 日志文件: %LOG_FILE% endlocalWindows 下我用start /B javaw让 Java 进程在后台运行不用像 Linux 一样加nohup。javaw相比java的好处是不会弹出控制台窗口服务器上看着干净一点。如果要看控制台输出可以改用java。日志文件名里的%date%拼出来是2024/01/01这种带斜杠的格式直接拼到文件名里会创建多层目录所以我在拼接时取了第 0-3 位做年份、第 5-6 位做月份、第 8-9 位做日期得到20240101这样的纯数字。停止脚本 stop.bat 也不复杂echo off setlocal set APP_HOMED:\apps\myapp set PID_FILE%APP_HOME%\app.pid if not exist %PID_FILE% ( echo PID 文件不存在 exit /b 1 ) set /p PID_VAL%PID_FILE% taskkill /PID %PID_VAL% /T if exist %PID_FILE% del %PID_FILE% echo 停止完成 endlocaltaskkill /T会连子进程一起干掉适合 Java 服务可能派生子进程的场景。Windows 上也存在强杀 vs 优雅停的问题taskkill不带/F参数时会发送温和的关闭消息对 Java 应用来说也能触发 Shutdown Hook建议先不加/F停不掉再加。4.2 利用任务计划程序实现开机自启Windows 下的开机自启优先用任务计划程序比把脚本扔到启动文件夹里更规范。用命令行创建计划任务的方式如下schtasks /Create /TN MyAppAutoStart /TR D:\apps\myapp\bin\startup.bat /SC ONSTART /RU SYSTEM /RL HIGHEST这条命令创建了一个名为 MyAppAutoStart 的任务开机时以 SYSTEM 身份执行 startup.bat属于最高权限级别。创建后可以用schtasks /Run /TN MyAppAutoStart手动测试。如果服务器开机后还依赖数据库、中间件等其他服务任务计划不像 systemd 那样有依赖排序功能需要自己在脚本里加等待逻辑。我一般会在 startup.bat 开头加一个轮询检查确保数据库端口通了之后才启动应用比如用ping命令加延时或者用 PowerShell 的 Test-NetConnection 来探测端口。schtasks的ONSTART任务是登录前执行的适合服务型应用。如果你的应用需要依赖登录会话可以考虑ONLOGON但服务器上推荐前者因为服务器不一定有人登录。5. 常见问题与排查技巧实录5.1 端口被占用与环境变量失效端口被占用是最常见的启动失败原因。Spring Boot 应用默认端口 8080一旦被其他服务占着启动日志里会出现Web server failed to start. Port 8080 was already in use.。排查方式先看端口谁在用netstat -tlnp | grep 8080 # 或者 ss -tlnp | grep 8080确认占用进程后要么停掉冲突服务要么给新服务换端口。我个人的习惯是给每个 Java 服务单独分配端口段比如 8001、8002并在脚本的配置区里用变量维护避免每次排查全靠猜。环境变量失效也踩过不少次。手工在终端里export JAVA_HOME...之后 start.sh 能跑但 systemd 启动时找不到那个环境变量。原因在于 systemd 的 service 环境是全新的不继承用户的 shell 环境。所以脚本里尽量使用绝对路径或者在 systemd 服务文件里用 Environment 字段显式声明EnvironmentJAVA_HOME/usr/local/java/jdk17 EnvironmentPATH/usr/local/java/jdk17/bin:/usr/bin:/bin5.2 脚本执行了但服务没起来怎么回事脚本打印了启动完成但服务实际没起来这类问题最迷惑人。常见原因有三个。第一个是脚本里cd失败。start.sh 里先cd /opt/myapp再执行java -jar如果目录不存在或者没有执行权限后面的命令基本不会成功。所以脚本开头最好判断一下if [ ! -d $APP_HOME ]; then exit 1; fi。第二个是 Java 命令找不到。用 systemd 托管时经常遇到解决方式前面说了在脚本里显式指定JAVA_HOME并用$JAVA_HOME/bin/java执行。第三个是 start.sh 的循环等待逻辑有问题。比如进程启动之后立刻崩溃但 PID 文件里记录的是旧的进程号导致探活误判。所以我在循环等待里不仅要kill -0最好再检查一下端口有没有监听。对 Spring Boot 来说可以轮询/actuator/health接口只有接口返回 200 才认为真正起来了。这个优化放到生产环境非常值得。5.3 进程杀掉后端口迟迟不释放服务明明停了重新启动却报端口占用这种问题经常出现在非优雅停机之后。原因可能是 TIME_WAIT 状态的 TCP 连接还在或者 JVM 的子线程/网络栈没有完全回收。Linux 下可以用以下命令查看端口状态netstat -an | grep 8080 | grep TIME_WAITTIME_WAIT 通常一分钟后会自然消失但不想等的话可以调内核参数sysctl -w net.ipv4.tcp_fin_timeout30缩短等待时间。这属于全局设置要谨慎修改我只在自己测试机上调过生产环境没动过。如果是子进程没有回收导致端口被占用ps -ef | grep 8080或fuser -v 8080/tcp找出具体进程手动处理。5.4 让脚本更健壮健康检查与守护脚本写得再好也只能保证动作执行正确无法保证服务本身永远健康。为了提前发现服务不可用我在生产环境给脚本体系加了一个健康检查脚本 health.sh用 Spring Boot Actuator 的接口做探活#!/bin/bash APP_URLhttp://127.0.0.1:8080/actuator/health HTTP_CODE$(curl -s -o /dev/null -w %{http_code} --connect-timeout 5 --max-time 10 $APP_URL) if [ $HTTP_CODE 200 ]; then echo 服务健康 exit 0 else echo 服务异常, HTTP状态码: $HTTP_CODE exit 1 fi配合 crontab 每 5 分钟执行一次异常时写告警日志或者调用通知接口能大幅降低问题发现时间。对没有暴露健康检查接口的应用退而求其次用curl -s -I检测首页响应也可以敏感度低一些但聊胜于无。crontab 示例*/5 * * * * /opt/myapp/bin/health.sh /opt/myapp/logs/health.log 216. 一点个人心得整套脚本从最初十来行的 start.sh 演进到现在这个规模前后用了大半年每一段代码都是踩过坑才补上的。回头看最值得的投资不是把某个脚本写得炫酷而是把部署目录和约定固定下来——jar 放哪里、PID 写哪里、日志存哪里、配置变量放哪一行都有明确规矩。后来新服务上线直接复制目录结构改四个变量就能跑新人照着文档也能操作不再依赖我来人肉部署。有两件事我建议你开始动手前就先考虑清楚。第一脚本里所有路径尽量用绝对路径别依赖相对路径和环境变量工作目录的问题坑过太多人。第二停机手段一定要分梯次先温和后强制别一上来就kill -9给程序一个收尾的机会很多数据问题都是强杀引出来的。如果你现在还在手动java -jar部署强烈建议尽快把这套脚本搭起来不用一步到位先从 start.sh 开始把 PID 记录和日志重定向做好再逐步加停止、系统托管、健康检查。整个过程边际成本很低但长期回报非常高尤其是服务器数量多起来之后省下的不仅是时间更是半夜爬起来处理问题的心情成本。
返回列表