用 systemd 托管后台服务:Restart 策略、日志接管、资源限制与开机自启全踩一遍
你写了个 Python/Go/Node 的后台服务,部署到 Linux 上。第一版怎么跑?大概率是 nohup ./app &,或者塞进 screen / tmux 里。然后你会陆续踩到:进程崩了没人拉起来、服务器重启后服务没了、日志全糊在一个越来越大的 nohup.out 里、想看它有没有活着只能 ps aux | grep。
这些问题 systemd 全都替你解决了,而且它就是现代 Linux(CentOS 7+/Ubuntu 16.04+/Debian 8+)的标配,不用装任何东西。这篇文章从一个能跑但很糟的启动方式出发,一步步把服务交给 systemd 托管,把 Restart、日志、资源限制、开机自启这几件事讲透。
nohup 方式糟在哪
先看典型的「土办法」:
bash
nohup python3 /opt/myapp/server.py > /var/log/myapp.log 2>&1 &
它的问题:
- 崩了不会自动重启。 进程 OOM 或抛异常退出,服务就没了,你得盯着。
- 重启服务器就丢。 机器重启后没人再执行这条命令。
- 日志不切割。
myapp.log无限增长,迟早撑爆磁盘。 - 管理全靠手动。 停止要先
ps找 PID 再kill,查状态靠猜。 - 没有资源隔离。 一个服务内存泄漏能把整台机器拖死。
systemd 把服务定义成一个「unit 文件」,以上五点全内建支持。
写第一个 service 文件
systemd 的服务定义放在 /etc/systemd/system/ 下,文件名以 .service 结尾。建一个 /etc/systemd/system/myapp.service:
ini
[Unit]
Description=My App Server
# 等网络就绪后再启动,依赖网络的服务几乎都要这行
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# 用哪个用户跑,别用 root 跑业务进程
User=appuser
Group=appuser
WorkingDirectory=/opt/myapp
# ExecStart 必须写绝对路径,systemd 不吃 PATH 里的相对命令
ExecStart=/usr/bin/python3 /opt/myapp/server.py
# 崩了就拉起来
Restart=on-failure
RestartSec=3s
[Install]
# systemctl enable 时挂到这个 target,实现开机自启
WantedBy=multi-user.target
三个段落各管一摊:
[Unit]:描述和依赖关系(什么之前/之后启动)。[Service]:进程本身怎么跑、怎么重启。[Install]:enable时怎么挂载,决定开机自启。
写完让 systemd 重新读配置,然后启动:
bash
sudo systemctl daemon-reload # 改动 .service 文件后必须执行
sudo systemctl start myapp # 启动
sudo systemctl status myapp # 看状态(是否 active running、最近日志)
sudo systemctl enable myapp # 设置开机自启
enable 之后,服务器重启会自动拉起,前面第 2 个痛点解决。
Type 到底该填 simple 还是别的
Type= 决定 systemd 怎么判断「服务算启动成功了」,填错会导致依赖它的服务时序错乱。最常用的三个:
Type=simple(默认):ExecStart一旦 fork 出进程,立刻认为启动完成。适合前台运行、不 daemon 化 的程序(大多数现代服务:Go 二进制、python server.py、node app.js)。Type=forking:程序自己会 fork 到后台、父进程退出。传统守护进程(如某些 C 写的 daemon)用它,通常要配PIDFile=。Type=notify:程序启动完成后主动用sd_notify通知 systemd「我好了」。最精确,但要程序支持(如 nginx、systemd 原生集成的服务)。
判断口诀:你的程序是不是在前台一直跑、不自己 daemon 化? 是就 simple。绝大多数用脚本/二进制直接跑的服务都是 simple。如果你用了 --daemon 之类让程序自己退到后台的参数,反而要用 forking------但更推荐去掉那个参数、让 systemd 管前台进程。
Restart 策略:别无脑用 always
Restart= 控制进程退出后要不要重启,几个常用值:
no(默认):从不重启。on-failure:非正常退出 (退出码非 0、被信号杀死、超时)才重启。推荐默认选它。always:不管怎么退出都重启,包括正常退出(退出码 0)。on-abnormal:只在被信号杀死或超时时重启。
为什么推荐 on-failure 而不是 always?因为 always 会连「你主动正常关闭」也拉起来------比如一次性任务跑完正常退出(exit 0),always 会把它无限重启。业务常驻服务用 on-failure 最合适:崩了(异常)才救,主动停了就别捣乱。
光有 Restart 还不够,得防「崩溃-重启-再崩溃」的死循环把 CPU 打满。用启动限流:
ini
[Service]
Restart=on-failure
RestartSec=3s
# 10 秒窗口内最多重启 5 次,超了就放弃并标记 failed
StartLimitIntervalSec=10s
StartLimitBurst=5
超过阈值后 systemd 会停手并把服务标为 failed,而不是无限空转。这时 systemctl status 会显示 start-limit-hit,你就知道该去查根因而不是让它自己抽搐。
日志:直接交给 journald
systemd 服务的标准输出/标准错误默认被 journald 接管,不用你自己重定向文件、也不用配 logrotate。查日志:
bash
journalctl -u myapp # 该服务全部日志
journalctl -u myapp -f # 实时跟踪(类似 tail -f)
journalctl -u myapp --since "10 min ago" # 最近 10 分钟
journalctl -u myapp -p err # 只看 error 及以上级别
journalctl -u myapp --since today # 今天的
journald 自带按大小/时间滚动清理,不会像 nohup.out 那样无限增长(默认上限可在 /etc/systemd/journald.conf 里用 SystemMaxUse= 调,比如 SystemMaxUse=500M)。
一个实操细节:很多语言的运行时默认对 stdout 做缓冲,导致日志不实时刷进 journald。Python 尤其明显,要关掉缓冲:
ini
[Service]
# 让 Python 的 stdout/stderr 不缓冲,日志实时进 journald
Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/bin/python3 /opt/myapp/server.py
或者在命令行加 python3 -u。不加这个,你会疑惑「明明在打日志,journalctl 里却半天不出东西」。
资源限制:一个服务别拖垮整台机器
systemd 基于 cgroup,能直接给服务上内存/CPU 限制,不用 Docker 也能做资源隔离:
ini
[Service]
# 内存超过 512M 就被内核 OOM 掉这个服务(而不是随机杀别人)
MemoryMax=512M
# 高水位软限制,超了开始回收但不立刻杀
MemoryHigh=400M
# CPU 最多用 1.5 个核(150%)
CPUQuota=150%
# 最多开 100 个任务(线程/进程),防 fork 炸弹
TasksMax=100
MemoryMax 是硬上限:进程超了会被 OOM killer 精准干掉这个服务 ,而不是让内核在整机内存耗尽时随机挑一个进程杀。这就把前面第 5 个痛点(一个服务泄漏拖死全机)堵上了。改完记得 daemon-reload + restart。
想临时看某个服务实时吃了多少资源:
bash
systemctl status myapp # 状态里会显示 Memory、CPU、Tasks 当前用量
systemd-cgtop # 类似 top,按 cgroup 看各服务资源占用
一个更完整的生产级模板
把上面的点拼起来,加几个常用的安全加固项:
ini
[Unit]
Description=My App Server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/opt/myapp
Environment=PYTHONUNBUFFERED=1
Environment=APP_ENV=production
# 从文件读环境变量,密钥别写死在 unit 文件里
EnvironmentFile=/opt/myapp/.env
ExecStart=/usr/bin/python3 /opt/myapp/server.py
# 优雅停机:先发 SIGTERM,给 30 秒收尾再 SIGKILL
KillSignal=SIGTERM
TimeoutStopSec=30s
Restart=on-failure
RestartSec=3s
StartLimitIntervalSec=10s
StartLimitBurst=5
MemoryMax=512M
CPUQuota=150%
TasksMax=100
# 安全加固:只读系统目录、禁止提权、私有 /tmp
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
# strict 下若服务要写某目录,显式放开
ReadWritePaths=/opt/myapp/data
[Install]
WantedBy=multi-user.target
几个值得说的:
EnvironmentFile=把密钥放独立文件(权限设600),别硬编码进 unit。TimeoutStopSec=30s+KillSignal=SIGTERM:systemctl stop时先发 SIGTERM,你的程序捕获它做优雅关闭(处理完在途请求),30 秒还没退才强杀。这对有状态服务很重要。ProtectSystem=strict/NoNewPrivileges这类是「零成本」的安全加固,让服务即使被攻破也难以动系统文件,配ReadWritePaths放开确实需要写的目录即可。
排查:服务起不来先看这三样
bash
systemctl status myapp # 概览:active/failed、退出码、最近几行日志
journalctl -u myapp -n 50 --no-pager # 最近 50 行完整日志,看真实报错
systemctl cat myapp # 打印当前生效的 unit 内容,确认没写错/没忘 daemon-reload
最高频的三个低级错误:改了 .service 忘了 daemon-reload(改动没生效)、ExecStart 写了相对路径(找不到命令)、User 指定的用户对 WorkingDirectory 没权限(启动即失败)。status + journalctl 基本一眼定位。
小结
- systemd 是现代 Linux 自带的服务管理器,用一个
.service文件就替代了 nohup + 手动重启 + logrotate + cgroup 一整套,不用装任何东西。 - 核心四要素:
Restart=on-failure(崩了才救)配StartLimitBurst防重启风暴;stdout 交给 journald 用journalctl -u查;MemoryMax/CPUQuota做资源隔离;enable+WantedBy=multi-user.target实现开机自启。 Type=绝大多数前台服务填simple;Python 记得PYTHONUNBUFFERED=1否则日志不实时。- 改完
.service一定daemon-reload;起不来先看systemctl status+journalctl -u。
一句话记忆:别再 nohup &------写个二十行的 unit 文件,自动重启、开机自启、日志、限流、资源上限一次全有了。