
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策略足够细可以区分正常退出和异常退出日志走 journaldjournalctl一条命令就能看而且它对资源限制、启动依赖、启动顺序的支持都很完整等于一个方案覆盖了好几件事。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这一步新手最容易忘改了配置不 reloadsystemd 读的还是旧内容然后你就会对着一个明明改了却没生效的配置怀疑人生。2.2 三个 Section 到底各自管什么一个 service 单元文件基本由[Unit]、[Service]、[Install]三段组成我见过太多人把字段塞错段然后服务起不来。[Unit]段管的是这个单元和别的单元的关系Description是描述journalctl列表里显示的就是它After和Before定义启动顺序Wants定义弱依赖对方失败我也照常启Requires定义强依赖对方挂了我也被停。这里有个高频误区Afternetwork.target只表示在网络目标之后启动不代表网络真的已经可用。如果你的 jar 启动时要连数据库、要注册到某个注册中心光写network.target很可能启动太快导致连接失败。后面我会给更稳的写法。[Service]段是核心管进程怎么起、怎么死、怎么重启、什么身份跑。[Install]段一般是WantedBymulti-user.target这个字段决定了systemctl enable的时候往哪个 target 的wants目录里建软链接。multi-user.target对应传统的多用户命令行运行级别服务器上基本都是它。写了这段enable才会真正生效不写[Install]enable命令会报没有安装信息。2.3 启动命令怎么写才不出幺蛾子ExecStart是最关键的一行写错了服务直接status203/EXEC。几个必须注意的点必须用绝对路径。ExecStartjava -jar app.jar这种写法在 systemd 里会失败因为 systemd 不走你的 shell 环境变量PATH里的 java 它是找不到的。要么写/usr/bin/java要么先which java确认真实路径注意有些系统上/usr/bin/java只是个软链接指向具体的 JDK 目录反正是哪个你用哪个。命令里的空格、引号要小心。systemd 的解析规则和 shell 不一样不会做变量展开、不会做通配符展开。所以-Dspring.profiles.activeprod这类 JVM 参数要一个一个写清楚不要指望它替你展开${JAVA_HOME}。如果确实需要变量用Environment或者EnvironmentFile显式声明。不要在命令里加、nohup、重定向。systemd 自己就负责把进程放到后台、负责接日志你再加这些反而会破坏它的进程跟踪。Typesimple模式下systemd 认为ExecStart启动的那个进程就是主进程如果被提前 fork 掉systemd 会以为进程退出了然后触发重启形成启动-退出-重启死循环。这个坑我踩过现象是systemctl status里 process 不断变化日志里全是启动记录。一个我常用的启动行长这样ExecStart/usr/bin/java -Xms512m -Xmx1024m -Dfile.encodingUTF-8 -Dspring.profiles.activeprod -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 的配合这部分是自动重启的核心。先看几个关键值Restartno不重启默认值。Restartalways不管怎么退出都重启包括被systemctl stop停掉这个不太符合直觉一般不推荐。Restarton-failure只有非零退出码、被信号杀死、超时才算失败才重启。这个是我最推荐的它能把人为正常停服和进程崩溃区分开。Restarton-abnormal只有被信号终止和超时才重启退出码非零不算。用途窄一些。RestartSec是重启前等待多少秒默认 100ms。这个值太短会有问题如果应用启动就崩比如配置写错、端口被占systemd 会疯狂重启日志刷屏CPU 也会被拖起来。我一般设成 5 到 10 秒给运维一点反应时间也给外部依赖比如数据库一点恢复时间。还有一对容易被忽略的参数StartLimitIntervalSec和StartLimitBurst它们在[Unit]段里。含义是在StartLimitIntervalSec这段时间内如果启动次数超过StartLimitBurstsystemd 就不再尝试了把服务标记为 failed避免无限重启把机器拖垮。默认值大致是 10 秒内 5 次。我在生产环境会放宽一些比如 60 秒内允许 10 次因为有些偶发的依赖抖动重启几次就好了没必要放弃治疗。[Unit] StartLimitIntervalSec60 StartLimitBurst10注意这两个参数在较老的 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因为这两个目录的权限模型和系统服务运行身份经常冲突用Userappuser跑起来之后读不到 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] DescriptionWarehouse API Service Documentationhttps://example.invalid/warehouse Afternetwork-online.target Wantsnetwork-online.target StartLimitIntervalSec60 StartLimitBurst10 [Service] Typesimple Userappuser Groupappuser WorkingDirectory/opt/warehouse EnvironmentJAVA_HOME/usr/lib/jvm/java-17-openjdk EnvironmentSPRING_PROFILES_ACTIVEprod EnvironmentFile-/opt/warehouse/env.conf ExecStart/usr/bin/java -Xms512m -Xmx1024m \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/var/log/warehouse/ \ -Dfile.encodingUTF-8 \ -jar /opt/warehouse/warehouse-api.jar SuccessExitStatus143 Restarton-failure RestartSec8 StandardOutputjournal StandardErrorjournal SyslogIdentifierwarehouse-api LimitNOFILE65536 TimeoutStopSec30 KillModecontrol-group [Install] WantedBymulti-user.target这里有几个点值得单独说。Afternetwork-online.target配合Wantsnetwork-online.target是我推荐的网络等待组合它比network.target更严格表示网络接口已经配置好并且有地址。不过要注意network-online.target本身的就绪判定依赖NetworkManager-wait-online或systemd-networkd-wait-online服务是否启用如果这两个没启用这个 target 可能立刻就被认为满足等于没等。如果你确实需要严格等网络先去确认这两个服务是 enabled 状态。EnvironmentFile-/opt/warehouse/env.conf前面的那个减号表示文件不存在也不报错这是个很实用的小技巧方便把敏感配置数据库密码单独放一个文件、从版本控制里排除掉。env.conf 的格式是KEYVALUE一行一个。SuccessExitStatus143这行是专门给 Java 加的。143 是 12815也就是进程收到 SIGTERM 正常退出的退出码。有些 Java 应用在收到停止信号后是以这个码退出的如果不声明成成功状态systemd 会认为它异常退出从而触发重启结果就是你systemctl stop之后它自己又起来了非常迷惑。加上这行就能避免。KillModecontrol-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]段写成了WantedBymulti-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 高频故障速查表先把最常见的几类问题整理成表照着对号入座基本能解决八成故障现象可能原因排查方向status203/EXECExecStart 路径不对或无执行权限ls -l看 java 路径确认绝对路径status200/CHDIRWorkingDirectory 不存在或无权限检查目录是否创建、属主是否匹配反复重启日志全是启动记录应用启动即崩或命令带了 提前 fork去掉后台符号手工执行同一命令验证停止后自己又起来未声明 SuccessExitStatus 或 Restartalways加 SuccessExitStatus143改用 on-failure启动时报找不到配置文件相对路径问题、WorkingDirectory 不对改绝对路径或显式指定 config.location日志里中文乱码未指定 file.encoding加-Dfile.encodingUTF-8enable 成功但重启不生效WantedBy 写错或被其他单元屏蔽检查软链接、systemctl list-dependencies数据库连接失败启动太早网络未就绪用 network-online.target或加重试逻辑4.2 关于启动太早连不上数据库这件事这个坑我印象太深了。那个仓储系统用的 MySQL 和 jar 在同一台机器上我写了Afternetwork-online.target和Aftermysql.service以为万无一失。结果偶发性地服务起来之后日志里报连接池初始化失败重启一下又好了。后来才想明白Aftermysql.service只保证 MySQL 的 systemd 单元启动动作完成了但 MySQL 单元本身是Typenotify还是simple、它什么时候真正能接受连接是两回事。MySQL 初始化 InnoDB 可能还要几秒。我的解决方案是双保险一是在 systemd 层面加Aftermysql.service二是在应用层面配置连接池的初始化重试比如 HikariCP 的initializationFailTimeout设为正值让它启动时容忍一段时间的连接失败。因为 systemd 层面再怎么调顺序也无法百分之百保证应用层依赖的中间件真的 ready把重试逻辑放到应用里才是最可靠的。4.3 内存溢出的处理思路如果 jar 挂掉的原因是 OOM光靠自动重启是治标不治本。我在配置里加了-XX:HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath这样每次 OOM 都会落一个堆转储文件出来方便事后用 MAT 之类的工具分析到底是哪个对象在膨胀。配合-Xmx限制堆上限同时用 systemd 的MemoryMax限制整个服务的物理内存防止它把机器拖垮[Service] MemoryMax1.5G MemoryHigh1.2GMemoryHigh是软限制超过会触发内存回收并节流MemoryMax是硬限制超过会被 OOM killer 干掉。有了这两个即使你的 jar 有内存泄漏崩的也只是它自己不会把整台服务器一起带走。这也是把内存限制交给 cgroupsystemd 底层就是 cgroup而不是只靠 JVM 参数的意义所在。4.4 日志去哪了journald 的取舍systemd 服务的标准输出默认进 journald用journalctl -u xxx看。好处是集中、有时间戳、可以按时间过滤。坏处是默认策略下日志存在/run/log/journal内存盘重启就没了。如果你的合规需求或者排障习惯需要日志持久化得去改 journald 配置# 编辑 /etc/systemd/journald.conf设置 Storagepersistent sudo mkdir -p /var/log/journal sudo systemd-tmpfiles --create --prefix /var/log/journal sudo systemctl restart systemd-journald改了Storagepersistent并且建了/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] DescriptionWarehouse API Instance %i Afternetwork-online.target [Service] Typesimple Userappuser ExecStart/usr/bin/java -jar /opt/warehouse/warehouse-api.jar --server.port80%i Restarton-failure RestartSec8 [Install] WantedBymulti-user.target然后启动的时候用systemctl start warehouse81、warehouse82后面的就是%i的值。enable也是一样systemctl enable warehouse81 warehouse82。这个写法省去了维护多份几乎相同的配置文件的麻烦改一处全生效。模板实例的日志可以用SyslogIdentifierwarehouse-%i区分开。5.2 平滑重启不想让用户掉线怎么办systemctl restart是暴力重启先停后起中间有服务不可用窗口。对于要求不高的内部系统可以接受但对于对外接口几十秒的窗口也是事故。我的做法是让应用支持优雅停机Spring Boot 2.3 之后有server.shutdowngraceful配置配合spring.lifecycle.timeout-per-shutdown-phase30s收到 SIGTERM 之后先停止接新请求、等已有请求处理完再退出。systemd 侧配TimeoutStopSec30与之呼应给足退出时间。再进一步如果有多个实例在负载均衡后面可以做滚动重启逐个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 服务真的值得花半天时间把这套模板固化下来回报率比想象中高得多。