1. 为什么要在 Linux 上给 jar 包做自启动与守护
1.1 一个真实运维场景引发的思考
我第一次遇到这个问题,是在给一家做仓储管理的小公司做部署的时候。服务器上跑着一个 Spring Boot 打包出来的 jar,白天业务在用,晚上我回家睡觉,结果凌晨两点机房那台 Ubuntu 22.04 因为内核更新后自动重启了一遍,第二天早上业务方打电话过来说系统打不开。上去一看,进程没了,服务端口 8080 没有任何监听。那一刻我才意识到,nohup java -jar xxx.jar &这种临时起意的启动方式,在生产环境里基本等于埋雷。
这个问题的本质其实分成两层:第一层是进程级的存活保障,也就是进程意外退出(OOM、代码抛异常导致主线程终止、被误 kill)之后能不能自己拉起来;第二层是系统级的启动保障,即机器重启之后,服务能不能不依赖人工登录就自动跑起来。很多人只做了第一层,或者只做了第二层,结果就是要么重启后服务没了,要么服务挂了没人管。这两件事必须一起做,才算是一个完整的方案。
我把这套东西后来又在好几个项目里复用打磨,包括单体 jar、多实例 jar、带外部配置文件目录的 jar,慢慢总结出一套自己比较顺手的做法。下面这些内容,是我在不同年限的机器、不同发行版上踩过坑之后沉淀下来的,不是官方文档的翻译,而是实际干活时的取舍。
1.2 适合什么基础的人看
这篇内容我尽量写得对新手友好,但确实需要你满足几个前提:会基本的 Linux 命令操作,知道systemctl、ps、ss这类工具怎么用;知道自己那个 jar 包启动时依赖哪些环境变量、哪些外部文件;服务器上你有 root 或者 sudo 权限,因为注册服务这件事离不开它。
如果你现在的状态是"jar 能手动跑起来,但不知道怎么让它常驻",或者"配了 systemd 但 service 一直 failed 找不到原因",那这篇基本能覆盖你的诉求。如果你用的是 Docker、Kubernetes 那一套编排,那 jar 的自愈其实交给编排层更合适,不过 systemd 这套逻辑理解了,对你排查容器内进程信号问题也有帮助。
1.3 几条主流路线先摆出来对比
在动手之前,先把可选方案摆清楚,免得你走了弯路又回头。Linux 下让 jar 常驻,常见的有这么几种:
| 方案 | 存活保障 | 开机自启 | 适用场景 | 主要缺点 |
|---|---|---|---|---|
| nohup + & | 无 | 无 | 临时测试 | 关掉终端就没了,重启必丢 |
| screen / tmux 会话 | 弱 | 无 | 手工调试 | 依赖会话存活,不适合无人值守 |
| crontab 定时拉起 | 有(轮询式) | 可以(@reboot) | 简易场景 | 检测粒度粗,日志和状态管理乱 |
| supervisor | 有 | 有 | 多进程托管 | 需额外装组件,部分发行版源里版本旧 |
| systemd service | 有 | 有 | 生产标配 | 配置项多,出错了排查要经验 |
我给绝大多数人的建议是直接上systemd。理由很实在:主流的发行版(Ubuntu、Debian、CentOS、Rocky、openEuler、麒麟等)全都自带了,不用额外安装任何东西;它的Restart策略足够细,可以区分正常退出和异常退出;日志走 journald,journalctl一条命令就能看;而且它对资源限制、启动依赖、启动顺序的支持都很完整,等于一个方案覆盖了好几件事。supervisor 我更倾向于在需要托管几十个异构进程、或者团队已经有一套统一脚本体系的时候才用,单体 jar 真没必要引入这个额外依赖。
2. 把 jar 写成 systemd 服务:核心字段逐个拆透
2.1 单元文件放哪里,命名有什么讲究
systemd 读的单元文件,按优先级主要分三个位置:/etc/systemd/system/、/run/systemd/system/、/usr/lib/systemd/system/(部分发行版是/lib/systemd/system/)。自己手写的服务一律放/etc/systemd/system/,这是管理员手动管理的位置,优先级比发行版自带的目录高,升级软件包时也不会被覆盖。
命名上我用的是应用名.service,比如warehouse-api.service。注意文件名里的短横线和下划线在 systemd 里是有语义差异的,一般用短横线更稳妥,别用空格和中文。改完单元文件之后必须执行systemctl daemon-reload,这一步新手最容易忘,改了配置不 reload,systemd 读的还是旧内容,然后你就会对着一个"明明改了却没生效"的配置怀疑人生。
2.2 三个 Section 到底各自管什么
一个 service 单元文件基本由[Unit]、[Service]、[Install]三段组成,我见过太多人把字段塞错段,然后服务起不来。
[Unit]段管的是这个单元和别的单元的关系:Description是描述,journalctl列表里显示的就是它;After和Before定义启动顺序;Wants定义弱依赖(对方失败我也照常启);Requires定义强依赖(对方挂了我也被停)。这里有个高频误区:After=network.target只表示"在网络目标之后启动",不代表网络真的已经可用。如果你的 jar 启动时要连数据库、要注册到某个注册中心,光写network.target很可能启动太快导致连接失败。后面我会给更稳的写法。
[Service]段是核心,管进程怎么起、怎么死、怎么重启、什么身份跑。
[Install]段一般是WantedBy=multi-user.target,这个字段决定了systemctl enable的时候,往哪个 target 的wants目录里建软链接。multi-user.target对应传统的多用户命令行运行级别,服务器上基本都是它。写了这段,enable才会真正生效;不写[Install],enable命令会报"没有安装信息"。
2.3 启动命令怎么写才不出幺蛾子
ExecStart是最关键的一行,写错了服务直接status=203/EXEC。几个必须注意的点:
- 必须用绝对路径。
ExecStart=java -jar app.jar这种写法在 systemd 里会失败,因为 systemd 不走你的 shell 环境变量,PATH里的 java 它是找不到的。要么写/usr/bin/java,要么先which java确认真实路径,注意有些系统上/usr/bin/java只是个软链接,指向具体的 JDK 目录,反正是哪个你用哪个。 - 命令里的空格、引号要小心。systemd 的解析规则和 shell 不一样,不会做变量展开、不会做通配符展开。所以
-Dspring.profiles.active=prod这类 JVM 参数要一个一个写清楚,不要指望它替你展开${JAVA_HOME}。如果确实需要变量,用Environment=或者EnvironmentFile=显式声明。 - 不要在命令里加
&、nohup、重定向。systemd 自己就负责把进程放到后台、负责接日志,你再加这些反而会破坏它的进程跟踪。Type=simple模式下,systemd 认为ExecStart启动的那个进程就是主进程,如果被&提前 fork 掉,systemd 会以为进程退出了,然后触发重启,形成"启动-退出-重启"死循环。这个坑我踩过,现象是systemctl status里 process 不断变化,日志里全是启动记录。
一个我常用的启动行长这样:
ExecStart=/usr/bin/java -Xms512m -Xmx1024m -Dfile.encoding=UTF-8 -Dspring.profiles.active=prod -jar /opt/warehouse/warehouse-api.jar --spring.config.location=/opt/warehouse/config/注意我把-jar放在 JVM 参数之后、应用参数之前,这个顺序不能乱,-jar后面第一个参数是 jar 路径,再往后的--spring.config.location才会作为应用参数传进去。应用参数如果包含空格或特殊字符,要用双引号整体包起来。
2.4 Type 选 simple 还是 forking,这是个真实的分叉
Type决定 systemd 怎么判断"服务已经启动完成"。常见值:
simple(默认):ExecStart一执行就算启动成功。绝大多数java -jar都该用这个。forking:进程自己 fork 出子进程后父进程退出,systemd 认为才算启动完成。典型的比如 nginx 的 daemon 模式。java 进程一般不要用这个,除非你的启动脚本自身做了 daemon 化。notify:进程通过 sd_notify 主动通知 systemd 启动完成。Spring Boot 默认不会,除非你引入了相关依赖或者写了对应的通知逻辑。oneshot:执行一次就结束的任务,比如初始化脚本。
我见过有人为了"等应用真正 ready 再算启动完成"用了notify,结果应用没发通知,systemd 一直挂在那里等,最后超时失败。除非你明确知道自己在做什么,simple就是最优解。
2.5 重启策略:Restart 和 RestartSec 的配合
这部分是"自动重启"的核心。先看几个关键值:
Restart=no:不重启,默认值。Restart=always:不管怎么退出都重启,包括被systemctl stop停掉,这个不太符合直觉,一般不推荐。Restart=on-failure:只有非零退出码、被信号杀死、超时才算失败,才重启。这个是我最推荐的,它能把"人为正常停服"和"进程崩溃"区分开。Restart=on-abnormal:只有被信号终止和超时才重启,退出码非零不算。用途窄一些。
RestartSec是重启前等待多少秒,默认 100ms。这个值太短会有问题:如果应用启动就崩(比如配置写错、端口被占),systemd 会疯狂重启,日志刷屏,CPU 也会被拖起来。我一般设成 5 到 10 秒,给运维一点反应时间,也给外部依赖(比如数据库)一点恢复时间。
还有一对容易被忽略的参数:StartLimitIntervalSec和StartLimitBurst,它们在[Unit]段里。含义是在StartLimitIntervalSec这段时间内,如果启动次数超过StartLimitBurst,systemd 就不再尝试了,把服务标记为 failed,避免无限重启把机器拖垮。默认值大致是 10 秒内 5 次。我在生产环境会放宽一些,比如 60 秒内允许 10 次,因为有些偶发的依赖抖动,重启几次就好了,没必要放弃治疗。
[Unit] StartLimitIntervalSec=60 StartLimitBurst=10注意这两个参数在较老的 systemd 版本里放在[Service]段,如果你的系统提示未知选项,就挪到[Unit]段试试,这是版本差异。
3. 一份可以直接抄的完整配置与落地步骤
3.1 目录规划:把东西放整齐,后面少受罪
在写配置之前,先把目录定下来。我习惯的布局是:
- 程序本体:
/opt/warehouse/warehouse-api.jar - 外部配置:
/opt/warehouse/config/(放application-prod.yml之类) - 日志输出:
/var/log/warehouse/(如果要自己写文件日志) - 运行用户:专门建一个
appuser,不给它登录 shell
为什么坚持程序不放/root或者/home/xxx?因为这两个目录的权限模型和系统服务运行身份经常冲突,用User=appuser跑起来之后,读不到 root 家目录里的文件是常态。放/opt是 Linux 惯例,权限好控。建用户的命令:
sudo useradd -r -s /sbin/nologin appuser sudo mkdir -p /opt/warehouse/config /var/log/warehouse sudo chown -R appuser:appuser /opt/warehouse /var/log/warehouse-r表示建系统用户,-s /sbin/nologin表示不允许登录,纯服务账号。这一步别偷懒,直接用 root 跑服务是很多安全事故的起点,也是不规范运维的典型特征。
3.2 完整单元文件示例
下面这份是我实际在用的模板,字段都做了注释:
[Unit] Description=Warehouse API Service Documentation=https://example.invalid/warehouse After=network-online.target Wants=network-online.target StartLimitIntervalSec=60 StartLimitBurst=10 [Service] Type=simple User=appuser Group=appuser WorkingDirectory=/opt/warehouse Environment="JAVA_HOME=/usr/lib/jvm/java-17-openjdk" Environment="SPRING_PROFILES_ACTIVE=prod" EnvironmentFile=-/opt/warehouse/env.conf ExecStart=/usr/bin/java -Xms512m -Xmx1024m \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/var/log/warehouse/ \ -Dfile.encoding=UTF-8 \ -jar /opt/warehouse/warehouse-api.jar SuccessExitStatus=143 Restart=on-failure RestartSec=8 StandardOutput=journal StandardError=journal SyslogIdentifier=warehouse-api LimitNOFILE=65536 TimeoutStopSec=30 KillMode=control-group [Install] WantedBy=multi-user.target这里有几个点值得单独说。After=network-online.target配合Wants=network-online.target是我推荐的网络等待组合,它比network.target更严格,表示"网络接口已经配置好并且有地址"。不过要注意,network-online.target本身的就绪判定依赖NetworkManager-wait-online或systemd-networkd-wait-online服务是否启用,如果这两个没启用,这个 target 可能立刻就被认为满足,等于没等。如果你确实需要严格等网络,先去确认这两个服务是 enabled 状态。
EnvironmentFile=-/opt/warehouse/env.conf前面的那个减号表示"文件不存在也不报错",这是个很实用的小技巧,方便把敏感配置(数据库密码)单独放一个文件、从版本控制里排除掉。env.conf 的格式是KEY=VALUE一行一个。
SuccessExitStatus=143这行是专门给 Java 加的。143 是 128+15,也就是进程收到 SIGTERM 正常退出的退出码。有些 Java 应用在收到停止信号后是以这个码退出的,如果不声明成成功状态,systemd 会认为它"异常退出"从而触发重启,结果就是你systemctl stop之后它自己又起来了,非常迷惑。加上这行就能避免。
KillMode=control-group保证systemctl stop时把整个进程组都干掉,包括 Java 线程里 fork 出来的子进程。默认值在新版 systemd 里就是 control-group,但显式写出来更清楚。
3.3 从零开始的落地命令序列
配置写好后,按这个顺序操作,一步都别跳:
# 1. 写入单元文件 sudo vim /etc/systemd/system/warehouse-api.service # 2. 重载 systemd 配置,让它认识新文件 sudo systemctl daemon-reload # 3. 先启动,观察状态 sudo systemctl start warehouse-api sudo systemctl status warehouse-api # 4. 跟着看实时日志,确认应用真的起来了 sudo journalctl -u warehouse-api -f # 5. 测试无误后设置开机自启 sudo systemctl enable warehouse-api # 6. 双向确认:既看 enable 状态,又检查软链接有没有建出来 sudo systemctl is-enabled warehouse-api ls -l /etc/systemd/system/multi-user.target.wants/ | grep warehouse第 6 步为什么要额外看软链接?因为我遇到过一次is-enabled显示 enabled,但实际重启后没起来的情况。原因是[Install]段写成了WantedBy=multi-user.target之外的值,或者别的单元把它冲突屏蔽了。看一眼软链接是最直接的验证方式。
enable的时候还可以用--now参数,等于enable加start一气呵成:sudo systemctl enable --now warehouse-api。
3.4 验证自动重启真的生效了
配完不能就这么算完,得实测。我的验证方法是手工模拟崩溃:
# 找到主进程 PID sudo systemctl status warehouse-api | grep "Main PID" # 模拟被信号杀死(等同 OOM killer 干掉进程的场景) sudo kill -9 <PID> # 立刻 watch 状态变化,应该能看到它自己起来 watch -n 1 'systemctl status warehouse-api --no-pager | head -15'正常情况下,8 秒左右(你设的 RestartSec)服务会重新变成 active。如果一直没起来,先看journalctl -u warehouse-api -n 50的报错,八成是启动命令路径或者权限问题。
第二个验证是重启机器:sudo reboot,等机器起来后登录,systemctl status warehouse-api应该已经是 active。这一步一定要在正式上线前做,别指望"应该没问题"。我见过太多"以为配好了"然后在真重启时翻车的案例。
4. 排查与避坑:那些只有踩过才知道的细节
4.1 高频故障速查表
先把最常见的几类问题整理成表,照着对号入座基本能解决八成故障:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| status=203/EXEC | ExecStart 路径不对或无执行权限 | ls -l看 java 路径,确认绝对路径 |
| status=200/CHDIR | WorkingDirectory 不存在或无权限 | 检查目录是否创建、属主是否匹配 |
| 反复重启,日志全是启动记录 | 应用启动即崩,或命令带了 & 提前 fork | 去掉后台符号,手工执行同一命令验证 |
| 停止后自己又起来 | 未声明 SuccessExitStatus 或 Restart=always | 加 SuccessExitStatus=143,改用 on-failure |
| 启动时报找不到配置文件 | 相对路径问题、WorkingDirectory 不对 | 改绝对路径,或显式指定 config.location |
| 日志里中文乱码 | 未指定 file.encoding | 加-Dfile.encoding=UTF-8 |
| enable 成功但重启不生效 | WantedBy 写错或被其他单元屏蔽 | 检查软链接、systemctl list-dependencies |
| 数据库连接失败 | 启动太早,网络未就绪 | 用 network-online.target,或加重试逻辑 |
4.2 关于"启动太早连不上数据库"这件事
这个坑我印象太深了。那个仓储系统用的 MySQL 和 jar 在同一台机器上,我写了After=network-online.target和After=mysql.service,以为万无一失。结果偶发性地,服务起来之后日志里报连接池初始化失败,重启一下又好了。后来才想明白:After=mysql.service只保证 MySQL 的 systemd 单元"启动动作完成了",但 MySQL 单元本身是Type=notify还是simple、它什么时候真正能接受连接,是两回事。MySQL 初始化 InnoDB 可能还要几秒。
我的解决方案是双保险:一是在 systemd 层面加After=mysql.service,二是在应用层面配置连接池的初始化重试(比如 HikariCP 的initializationFailTimeout设为正值,让它启动时容忍一段时间的连接失败)。因为 systemd 层面再怎么调顺序,也无法百分之百保证应用层依赖的中间件真的 ready,把重试逻辑放到应用里才是最可靠的。
4.3 内存溢出的处理思路
如果 jar 挂掉的原因是 OOM,光靠自动重启是治标不治本。我在配置里加了-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath,这样每次 OOM 都会落一个堆转储文件出来,方便事后用 MAT 之类的工具分析到底是哪个对象在膨胀。配合-Xmx限制堆上限,同时用 systemd 的MemoryMax限制整个服务的物理内存,防止它把机器拖垮:
[Service] MemoryMax=1.5G MemoryHigh=1.2GMemoryHigh是软限制,超过会触发内存回收并节流;MemoryMax是硬限制,超过会被 OOM killer 干掉。有了这两个,即使你的 jar 有内存泄漏,崩的也只是它自己,不会把整台服务器一起带走。这也是把内存限制交给 cgroup(systemd 底层就是 cgroup)而不是只靠 JVM 参数的意义所在。
4.4 日志去哪了:journald 的取舍
systemd 服务的标准输出默认进 journald,用journalctl -u xxx看。好处是集中、有时间戳、可以按时间过滤。坏处是默认策略下日志存在/run/log/journal,内存盘,重启就没了。如果你的合规需求或者排障习惯需要日志持久化,得去改 journald 配置:
# 编辑 /etc/systemd/journald.conf,设置 Storage=persistent sudo mkdir -p /var/log/journal sudo systemd-tmpfiles --create --prefix /var/log/journal sudo systemctl restart systemd-journald改了Storage=persistent并且建了/var/log/journal目录之后,日志才会真正落盘到/var/log/journal/。同时记得配SystemMaxUse=限制一下总占用,不然日积月累会把磁盘写满,这个也是常见事故。
另外,如果应用自身用 logback 写了文件日志,比如输出到/var/log/warehouse/app.log,那 journald 里就只有启动阶段的东西,运行日志要去文件里看。两种方式各有场景,我的习惯是:启动阶段看 journalctl,运行日志看应用自己的文件,System.out打出来的东西尽量少,因为那些最终都会进 journal。
4.5 一个容易被忽略的点:jar 里读不到外部配置
有人把配置文件放在 jar 同级目录,然后用相对路径./config/去读,手工java -jar跑没问题,配成 systemd 服务就读不到了。原因就是 systemd 服务的WorkingDirectory默认是/(或者没设置),相对路径解析的基准变了。两个解法:设WorkingDirectory=/opt/warehouse,或者干脆在ExecStart里用--spring.config.location指定绝对路径。我通常两个都做,双保险,因为线上出问题的时候你不想再去猜路径。
5. 进阶:多实例、平滑重启与替代方案
5.1 同一台机器跑多个 jar 实例
有时候一台机器要跑同一个应用的两个实例,绑不同端口。最干净的做法是写模板单元文件,而不是复制两份。在/etc/systemd/system/下建warehouse@.service,里面的端口、配置路径用%i占位:
[Unit] Description=Warehouse API Instance %i After=network-online.target [Service] Type=simple User=appuser ExecStart=/usr/bin/java -jar /opt/warehouse/warehouse-api.jar --server.port=80%i Restart=on-failure RestartSec=8 [Install] WantedBy=multi-user.target然后启动的时候用systemctl start warehouse@81、warehouse@82,@后面的就是%i的值。enable也是一样systemctl enable warehouse@81 warehouse@82。这个写法省去了维护多份几乎相同的配置文件的麻烦,改一处全生效。模板实例的日志可以用SyslogIdentifier=warehouse-%i区分开。
5.2 平滑重启:不想让用户掉线怎么办
systemctl restart是暴力重启,先停后起,中间有服务不可用窗口。对于要求不高的内部系统可以接受,但对于对外接口,几十秒的窗口也是事故。我的做法是让应用支持优雅停机:Spring Boot 2.3 之后有server.shutdown=graceful配置,配合spring.lifecycle.timeout-per-shutdown-phase=30s,收到 SIGTERM 之后先停止接新请求、等已有请求处理完再退出。systemd 侧配TimeoutStopSec=30与之呼应,给足退出时间。
再进一步,如果有多个实例在负载均衡后面,可以做滚动重启:逐个restart,每次等健康检查通过再动下一个。这个通常要写一点脚本或者交给上层编排,但原理就这么简单,理解了这个你就能自己搭。
另外提醒一句,systemctl reload能不能用,取决于服务是否实现了ExecReload。Java 应用一般没有实现这个,你写了也会报"没有 ExecReload 命令"。想要 reload 语义,得应用自己监听信号或者提供管理接口,不能指望 systemd 凭空变出来。
5.3 什么情况下我会放弃 systemd 改用别的
systemd 不是万能的,有两种情况我会考虑别的方案。一是需要跨机器统一调度、需要健康检查、需要自动扩缩容,那 Kubernetes 的探针和重启策略更专业,systemd 只能管单机。二是团队有强制的进程管理平台,比如统一的 supervisor 体系或者自研的守护进程工具,那遵循团队规范比个人偏好更重要。
还有一类特殊情况:应用需要按非常复杂的条件判断是否重启(比如检查某个外部健康接口的返回内容再决定),这时候 systemd 的Restart策略表达不了,可以用一个外部的守护脚本或者专门的健康检查工具,通过 systemd 的 timer 定期执行来判断。但这种设计复杂度和收益要权衡,多数时候还是把健康判断做进应用自己的探针接口更简单。
5.4 关于安全的一点实践经验
最后说点运维习惯上的东西。服务账号不用 root、配置文件权限收紧到640并且属主是appuser、数据库密码走EnvironmentFile而不写死在单元文件里(单元文件本身权限设成644就够了,因为里面不该有明文密码)、jar 包和配置的目录不允许其他用户写。这几条做到位,已经能挡掉相当一部分常见风险。我自己吃过亏的一次是偷懒把配置放在/tmp下调试,权限开得比较宽,虽然没造成实际损失,但事后复盘时后背发凉。
还有一点值得强调的是daemon-reload之外的变更纪律:任何对单元文件的修改,都应该走"改文件 → daemon-reload → 重启服务 → 验证"这个固定流程,并且做好记录。线上出现"配置改了但行为和预期不一致"的问题,十有八九是有人改了之后忘了 reload,或者改了 A 文件实际生效的是 B 文件。这类问题查起来非常耗时间,不如一开始就把流程固定下来。
我后来把上面这套东西做成了一个内部的部署脚本:检查目录、建用户、渲染 service 模板、daemon-reload、enable、启动、跑一次自检。执行完输出一份检查报告,包含服务状态、监听端口、软链接、最近 20 行日志。这样接手的人不需要理解每个字段的含义,照着跑就行,出错的时候报告里也直接能看出卡在哪一步。这套东西用了两年多,后面新上的几个 jar 服务都是照着改改名字就上,基本没再为"服务起不来"这类问题浪费过整个上午。如果你也在维护多个类似的 Java 服务,真的值得花半天时间把这套模板固化下来,回报率比想象中高得多。