别再用 nohup 了:把你的程序交给 systemd——开机自启、崩溃重启、日志与安全加固

很多自己写的小服务,第一次部署到 Linux 上时大概都是这样:

./myapp

发现 SSH 一断程序也没了,于是升级成:

nohup ./myapp > app.log 2>&1 &

再熟练一点,可能还会顺手记下 PID:

echo $! > app.pid

这当然能用。

但只要这个程序真的开始承担服务职责,问题很快就会出现:

  • 服务器重启以后谁把它拉起来?
  • 程序崩了谁负责重启?
  • 到底跑了几个实例?
  • PID 文件失效怎么办?
  • 日志越来越大怎么办?
  • 环境变量放在哪里?
  • 服务为什么要用 root 跑?
  • SSH 退出以后程序还在,但到底是不是健康的?
  • 想停服务时,应该 kill 哪个进程?

这时候继续往 nohup 外面套 Shell 脚本,通常只是在自己重新实现一套更差的服务管理器。

如果你的 Linux 使用 systemd,更直接的办法是:

把进程交给 systemd。

本文从一个普通的可执行程序开始,把它改造成一个具备开机启动、崩溃重启、统一日志、独立用户、配置隔离和基础安全加固的系统服务。

本文适用于 Ubuntu、Debian、Rocky Linux、CentOS Stream、openEuler、Alibaba Cloud Linux 等使用 systemd 的发行版。不同发行版的 systemd 版本可能不同,个别安全参数如果版本过旧不支持,删掉对应参数即可。


一、先确认你的机器是不是 systemd

先执行:

ps -p 1 -o comm=

如果输出:

systemd

基本就可以继续了。

再看一下版本:

systemctl --version

例如:

systemd 252

这里不要求一定是某个特定版本。本文最核心的 systemctljournalctl.service 都已经是现代 Linux 上非常成熟的能力。


二、为什么不建议长期用 nohup

先说清楚:nohup 本身没有错。

它非常适合:

  • 临时跑一个脚本;
  • 临时进行数据处理;
  • 跑一次性任务;
  • 开发测试时让进程在退出 SSH 后继续工作。

问题是,它并不是服务管理器。

假设我们这样启动:

nohup ./myapp > app.log 2>&1 &

实际上只是完成了两件事:

  1. 忽略挂断信号;
  2. 把输出重定向到文件。

它并不会自动帮我们解决:

生命周期管理
    ├── 开机启动
    ├── 崩溃重启
    ├── 优雅停止
    └── 启动顺序

运行身份
    ├── 使用哪个用户
    └── 拥有哪些系统权限

可观测性
    ├── 当前状态
    ├── 退出码
    ├── 启动次数
    └── 统一日志

资源与安全
    ├── 文件系统访问
    ├── Home 目录访问
    ├── 提权限制
    └── 内核资源访问

这些才是一个长期运行服务真正需要解决的问题。


三、我们要部署什么

为了让教程可以直接套用,假设现在有一个程序:

myapp

它可以是:

  • Go 编译出来的单文件二进制;
  • Rust 程序;
  • C/C++ 程序;
  • Java 的启动脚本;
  • Python 虚拟环境中的入口程序;
  • Node.js 服务。

为了简单,我们先假设它本身可以直接运行:

./myapp

监听:

127.0.0.1:8080

并通过环境变量读取配置。

最终目录结构规划成:

/opt/myapp/
└── myapp                  # 程序本体,root 拥有

/etc/myapp/
└── myapp.env              # 配置文件

/var/lib/myapp/            # 程序需要写入的数据

/etc/systemd/system/
└── myapp.service          # systemd 服务定义

这个结构有一个很重要的思想:

程序、配置和运行数据分开。

不要把数据库文件、上传文件、日志、密钥和程序二进制全部扔进同一个目录。


四、第一步:不要让服务直接以 root 身份运行

很多人为了省事会直接:

sudo ./myapp

然后 service 文件里也不指定用户。

这样 systemd 系统服务默认就可能以 root 身份执行。

如果你的 Web API 只是监听 8080、读数据库、访问自己的数据目录,它通常根本不需要 root。

创建一个专用系统用户:

NOLOGIN="$(command -v nologin || echo /sbin/nologin)"
sudo useradd --system --no-create-home --shell "$NOLOGIN" myapp

如果提示用户已经存在,不需要重复创建。

检查:

getent passwd myapp

这个账号的作用不是拿来 SSH 登录,而是专门承担服务进程身份。

这样即使程序未来出现漏洞,攻击者拿到的首先也只是 myapp 用户的权限,而不是整台服务器的 root。


五、第二步:安装程序和配置

创建目录:

sudo install -d -o root -g root -m 0755 /opt/myapp
sudo install -d -o root -g myapp -m 0750 /etc/myapp
sudo install -d -o myapp -g myapp -m 0750 /var/lib/myapp

把程序安装进去:

sudo install -o root -g root -m 0755 ./myapp /opt/myapp/myapp

注意这里故意让程序文件归:

root:root

而不是:

myapp:myapp

原因很简单:

运行服务的账号最好不能修改自己的程序本体。

否则一旦服务进程被利用,攻击者可能直接覆盖可执行文件,形成持久化。


配置环境变量

新建:

sudo nano /etc/myapp/myapp.env

例如:

MYAPP_ADDR=127.0.0.1:8080
MYAPP_DATA_DIR=/var/lib/myapp
DATABASE_URL=postgres://myapp:change-me@127.0.0.1:5432/myapp

设置权限:

sudo chown root:myapp /etc/myapp/myapp.env
sudo chmod 0640 /etc/myapp/myapp.env

检查:

ls -l /etc/myapp/myapp.env

应该类似:

-rw-r----- 1 root myapp ... /etc/myapp/myapp.env

这至少比把数据库密码直接写进 .service 文件好维护得多。

如果涉及真正高价值的生产密钥,最好进一步接 Secret Manager、systemd credentials 或其他密钥管理方案,而不是长期把所有密钥作为普通环境变量保存。


六、第三步:创建第一个 systemd Service

新建:

sudo nano /etc/systemd/system/myapp.service

先写一个基础版:

[Unit]
Description=MyApp Service
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/var/lib/myapp
EnvironmentFile=/etc/myapp/myapp.env
ExecStart=/opt/myapp/myapp

Restart=on-failure
RestartSec=3s
TimeoutStopSec=30s
KillSignal=SIGTERM

StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp

[Install]
WantedBy=multi-user.target

一份真实的 systemd 服务文件大概就是这种结构。下面这张图是国内技术社区中常见的 Service 配置示例,可以对照理解:

systemd Service 配置示例

配图来源:CSDN 国内图床;示例原文可见博客园《Centos7 的 Systemctl 与创建系统服务》。本文配置以本文给出的内容为准。

接下来逐块解释。


七、[Unit]:这个服务是谁,它依赖什么

[Unit]
Description=MyApp Service
Wants=network-online.target
After=network-online.target

Description 就是描述。

以后执行:

systemctl status myapp

会看到这个名字。

After=network-online.target 表示启动顺序上希望放在网络在线目标之后。

Wants=network-online.target 则把这个目标作为弱依赖拉进来。

但要注意:

network-online.target 并不等于“互联网一定能访问”。

它只是 systemd 与网络管理组件之间的启动同步机制。

如果你的服务必须等待某个远端数据库真正可以访问,仍然应该在程序里实现连接重试,而不是把全部希望寄托在启动顺序上。


八、[Service]:真正决定程序怎么跑

1. Type=simple

Type=simple

对于 Go、Rust、Python、Node.js 这类直接以前台进程运行的现代服务,大多数时候 simple 就够了。

程序不要自己 fork 到后台。

例如你的 Go 服务就正常:

/opt/myapp/myapp

然后一直占着前台运行。

后台管理交给 systemd。

这也是一个非常重要的思路:

应用程序负责业务,systemd 负责守护。


2. User 和 Group

User=myapp
Group=myapp

明确告诉 systemd:

不要用 root 跑。

这是整份配置里最值得养成习惯的一项。


3. WorkingDirectory

WorkingDirectory=/var/lib/myapp

不要依赖“我从哪个目录启动程序”。

systemd 启动服务时和你在 SSH 终端手工执行的环境并不一样。

程序如果使用相对路径:

./data
./config.json

是否设置 WorkingDirectory 会直接影响它最终访问哪里。

更好的应用本身应该优先使用明确路径,但服务层把工作目录固定下来仍然很有价值。


4. EnvironmentFile

EnvironmentFile=/etc/myapp/myapp.env

把运行配置和 service 定义分离。

以后换数据库地址,只需要修改:

sudo nano /etc/myapp/myapp.env
sudo systemctl restart myapp

不需要每次都改 unit 文件。


5. ExecStart

ExecStart=/opt/myapp/myapp

这是服务真正执行的程序。

这里有一个新手非常容易踩的坑:

不要把它当成一整行 Bash。

比如你写:

ExecStart=/opt/myapp/myapp > /tmp/myapp.log 2>&1

通常不会像你在 Shell 里想象的那样工作。

>2>&1| 这些是 Shell 语法,而 systemd 默认不会先偷偷给你套一层 Bash。

需要日志就交给 journald;真的需要 Shell,再显式写:

ExecStart=/bin/bash -lc '...'

但普通服务尽量不要这么做。


九、程序崩了以后自动拉起来

核心是:

Restart=on-failure
RestartSec=3s

on-failure 非常适合作为普通长期服务的默认策略之一。

当程序异常退出时,systemd 会尝试重新启动。

比如我们故意杀掉进程:

sudo systemctl kill -s SIGSEGV myapp

稍等几秒,再看:

systemctl status myapp

如果一切正常,服务应该重新起来。

但生产环境还要防另一种情况:

启动
→ 立即崩溃
→ 重启
→ 立即崩溃
→ 无限循环

可以在 [Unit] 增加启动频率限制:

StartLimitIntervalSec=60
StartLimitBurst=5

完整变成:

[Unit]
Description=MyApp Service
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5

意思可以粗略理解为:

在一段时间内失败得太频繁,就先别疯狂重启了。

如果修复问题以后发现服务处于 start-limit-hit,可以:

sudo systemctl reset-failed myapp
sudo systemctl start myapp

十、启动、开机自启和配置重载

写完 service 后,先让 systemd 重新扫描配置:

sudo systemctl daemon-reload

然后启动:

sudo systemctl start myapp

看状态:

systemctl status myapp

确认没有问题以后设置开机启动:

sudo systemctl enable myapp

也可以一步完成:

sudo systemctl enable --now myapp

以后最常用的命令基本就是:

sudo systemctl start myapp
sudo systemctl stop myapp
sudo systemctl restart myapp
sudo systemctl status myapp

查看是否开机启用:

systemctl is-enabled myapp

查看当前是否真的在运行:

systemctl is-active myapp

systemctl status 的输出除了告诉你 running/failed,还会带上 PID、最近日志和退出状态。下面是一个典型的 systemctl status 终端示例:

systemctl status 状态示例

配图使用 CSDN 国内图床,图中具体服务仅用于展示 systemctl status 的输出形式。


十一、终于可以不用自己管理 app.log 了

如果程序把日志正常输出到:

stdout
stderr

我们配置了:

StandardOutput=journal
StandardError=journal

那么就可以直接使用 journald。

查看全部日志:

journalctl -u myapp

只看最近 100 行:

journalctl -u myapp -n 100

实时跟踪:

journalctl -u myapp -f

只看今天:

journalctl -u myapp --since today

看最近 30 分钟:

journalctl -u myapp --since "30 min ago"

看某一个时间段:

journalctl -u myapp \
  --since "2026-08-10 02:00:00" \
  --until "2026-08-10 03:00:00"

只看 warning 以上:

journalctl -u myapp -p warning

腾讯云开发者社区的 systemd/journalctl 资料中也给出了 journalctl -u <service>-f--since--disk-usage 等常用操作。对于服务器上的长期服务,这种按 Unit 聚合日志的方式比散落一堆 nohup.out 更容易追踪。


十二、日志会不会把磁盘写满?

先看当前 journal 占用:

journalctl --disk-usage

临时清理到指定大小:

sudo journalctl --vacuum-size=1G

或者只保留一定时间:

sudo journalctl --vacuum-time=14d

更长期的限制应该放到 journald 配置里,而不是每天手工清理。

例如:

sudo nano /etc/systemd/journald.conf

可以根据服务器磁盘大小设置类似:

[Journal]
SystemMaxUse=1G
RuntimeMaxUse=256M

然后:

sudo systemctl restart systemd-journald

具体数值不要照抄,要根据你的磁盘、日志量和审计保留需求决定。


十三、让服务真正“少一点权限”

到目前为止,我们已经解决了:

  • 不用 root;
  • 自动重启;
  • 开机自启;
  • 统一日志;
  • 独立配置目录。

但 systemd 还能做更多。

[Service] 中追加一组相对保守的安全参数:

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ReadWritePaths=/var/lib/myapp
UMask=0027

分别看一下。


NoNewPrivileges=true

NoNewPrivileges=true

限制服务及其子进程通过某些机制获得新的额外权限。

对于普通业务服务,通常值得开启。


PrivateTmp=true

PrivateTmp=true

给服务一个隔离的临时目录视图,降低它和其他服务通过 /tmp 相互影响的机会。

如果你的程序依赖和其他进程通过 /tmp 共享文件,就不要盲开。


ProtectSystem=strict

ProtectSystem=strict

把大部分文件系统对这个服务变成只读。

但我们的程序需要写:

/var/lib/myapp

所以显式放行:

ReadWritePaths=/var/lib/myapp

最终效果就是:

系统大部分位置:不能写
/var/lib/myapp:允许写

这比“进程理论上可以到处写,只是希望它不要乱写”要可靠得多。


ProtectHome=true

ProtectHome=true

如果一个后端 API 根本不需要读取:

/root
/home
/run/user

那就不要让它读。

假设服务未来被利用,也可以少暴露一部分 SSH Key、开发者配置和用户文件。


ProtectKernel*

ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true

一个普通 Web 服务一般没有理由去修改内核参数、内核模块或者 cgroup 层级。

不需要的能力就关掉。

这其实就是最小权限原则。


十四、一份更完整的最终配置

于是我们的 /etc/systemd/system/myapp.service 可以变成:

[Unit]
Description=MyApp Service
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/var/lib/myapp
EnvironmentFile=/etc/myapp/myapp.env
ExecStart=/opt/myapp/myapp

Restart=on-failure
RestartSec=3s
TimeoutStopSec=30s
KillSignal=SIGTERM

StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ReadWritePaths=/var/lib/myapp
UMask=0027

[Install]
WantedBy=multi-user.target

修改后不要忘记:

sudo systemctl daemon-reload
sudo systemctl restart myapp

然后:

systemctl status myapp
journalctl -u myapp -n 100 --no-pager

确认程序确实正常工作。

安全选项不要一次性复制到所有程序。比如程序需要访问用户 Home、挂载 FUSE、调用某些内核接口时,对应限制就可能导致它无法运行。正确方法是逐步收紧,然后测试。


十五、先验证 Unit 文件,再重启

生产服务器上最尴尬的事情之一是:

配置改完
→ 直接 restart
→ 服务起不来了

systemd 提供了验证工具。

可以先执行:

sudo systemd-analyze verify /etc/systemd/system/myapp.service

没有输出通常意味着没有发现明显问题。

再让 systemd 重载:

sudo systemctl daemon-reload

如果系统支持,还可以查看服务的安全暴露情况:

systemd-analyze security myapp.service

它会根据各种 namespace、capability、文件系统保护等配置给出分析。

不要为了追求一个“满分”盲目把所有选项全开。

目标应该是:

在不破坏业务的前提下,把服务不需要的权限尽量拿掉。


十六、程序更新应该怎么做

最简单的更新流程:

sudo systemctl stop myapp
sudo install -o root -g root -m 0755 ./myapp /opt/myapp/myapp
sudo systemctl start myapp

然后检查:

systemctl is-active myapp
journalctl -u myapp -n 50 --no-pager

如果程序支持优雅退出,systemd 在停止时会向主进程发送停止信号。你的程序应该正确处理 SIGTERM:

收到 SIGTERM
→ 停止接收新请求
→ 等待正在执行的请求结束
→ 关闭数据库连接
→ 刷新必要数据
→ 正常退出

而不是一收到信号就直接丢掉所有正在处理的任务。

对于 Go HTTP 服务,可以结合 http.Server.Shutdown;其他语言和框架也通常有对应的 graceful shutdown 能力。


十七、不要在 ExecStart 里偷偷启动后台进程

这是另一个很常见的问题。

错误思路:

ExecStart=/opt/myapp/start.sh

然后 start.sh

nohup /opt/myapp/myapp > app.log 2>&1 &
exit 0

这等于:

systemd 管 start.sh
start.sh 又用 nohup 管 myapp

systemd 很快看到脚本退出,就会认为主进程结束了。

你又重新回到了 PID 跟踪和后台进程的问题里。

正确方式通常就是:

ExecStart=/opt/myapp/myapp

让真正的服务进程保持前台运行。


十八、Python、Node.js 和 Java 怎么写?

原理完全一样,只是 ExecStart 不同。

Python 虚拟环境

ExecStart=/opt/myapp/venv/bin/python /opt/myapp/app.py

或者:

ExecStart=/opt/myapp/venv/bin/gunicorn -b 127.0.0.1:8000 app:app

不要指望 systemd 自动执行:

source venv/bin/activate

直接使用虚拟环境里的解释器绝对路径即可。


Node.js

先找 Node 的绝对路径:

command -v node

例如:

ExecStart=/usr/bin/node /opt/myapp/server.js

如果 Node 是通过 nvm 装在个人 Home 下,就会马上碰到一个部署设计问题:

生产服务是否应该依赖某个登录用户的 nvm 环境?

一般来说不建议。

服务运行环境最好有明确、稳定、可重复的运行时路径。


Java

ExecStart=/usr/bin/java -Xms256m -Xmx512m -jar /opt/myapp/app.jar

Java 也不需要自己在命令末尾加 &


十九、最常见的十个坑

1. 改完 service 忘了 daemon-reload

sudo systemctl daemon-reload

这是第一反应。


2. ExecStart 写相对路径

不要:

ExecStart=./myapp

用:

ExecStart=/opt/myapp/myapp

3. 依赖 SSH 登录环境里的 PATH

你 SSH 登录时:

node
python
java

都能找到,不代表 systemd 的运行环境也一样。

尽量写绝对路径:

command -v python3
command -v node
command -v java

4. 用 root 只是因为“权限问题少”

权限问题少了,安全问题会变大。

正确做法是明确:

程序到底需要读什么
程序到底需要写什么

然后给对应权限。


5. 把密码直接写进 unit

比如:

Environment=DATABASE_PASSWORD=123456

service 文件经常会被复制、备份、查看。

至少单独使用权限受控的 EnvironmentFile;更敏感的场景再上专门的密钥管理。


6. 程序自己 daemonize

systemd 管理现代应用时,大多数情况下让程序保持前台运行更简单。


7. Restart=always 无脑套

有些一次性任务正常执行完就应该结束。

你却写:

Restart=always

它就会重新跑。

先搞清楚服务的生命周期。


8. 程序崩溃后无限重启刷屏

加入:

StartLimitIntervalSec=60
StartLimitBurst=5

同时真正去修崩溃原因。


9. 配置了 ProtectSystem 却忘记放行数据目录

表现通常就是:

Permission denied
Read-only file system

检查:

ReadWritePaths=/var/lib/myapp

10. 只看 status,不看 journal

systemctl status 只会展示有限的最近内容。

真正排查时直接:

journalctl -u myapp -b

或者:

journalctl -u myapp --since "10 min ago"

二十、顺手再学一个:临时命令也能交给 systemd

有时候你只是想临时把某条命令交给 systemd 管,而不想马上写 unit 文件。

可以试试:

systemd-run --unit=my-task /usr/local/bin/my-task

然后:

systemctl status my-task
journalctl -u my-task

这对于临时后台任务非常方便。

它和:

nohup command &

相比最大的区别之一,就是这个临时进程已经进入 systemd 的生命周期和日志管理体系。


二十一、什么时候仍然可以用 nohup?

写到这里也不要走到另一个极端。

如果只是:

今晚跑一个数据转换脚本

或者:

临时下载一个大文件

那:

nohup command > task.log 2>&1 &

完全没问题。

甚至用:

screen
tmux

可能更方便。

真正的分界线不是“nohup 落后,systemd 高级”。

而是:

这个进程是不是已经变成了一项需要长期维护的服务。

如果答案是“是”,那它就应该拥有明确的:

启动方式
运行身份
配置来源
日志入口
停止方式
失败策略
权限边界

而不是依靠某个人记得:

cd /home/xxx/project
nohup ./app > app.log 2>&1 &

最后

一台服务器真正开始变乱,往往不是因为上面跑了太多程序。

而是因为每个程序都有自己的一套“民间启动方式”:

这个要去 /root/a 里 nohup
那个要先 source 一个脚本
另一个 PID 写在 /tmp
还有一个挂了以后手动重启
日志有人写 app.log
有人写 nohup.out
还有人直接不知道写哪去了

当程序数量从 2 个变成 10 个,再从一个人维护变成多人维护,这种方式很快就会失控。

systemd 的价值不是让启动命令变得更复杂。

恰恰相反,它是在把这些原本散落在人脑、Shell 历史和 README 里的约定,变成机器可以执行的配置:

谁来运行
怎么启动
什么时候启动
挂了怎么办
日志去哪里
能访问什么
怎么停止

所以,如果你的程序已经准备长期留在一台 Linux 服务器上:

别只让它“在后台跑着”。

把它真正变成一个服务。


参考资料