systemd 实战:把服务托管做对
一、先说现象:那些"手动起的"服务
凌晨两点告警响了,登录机器一看,业务进程没了。翻历史命令,是上个月同事用 nohup ./app & 起的;日志在 nohup.out 里,早被别的输出冲得看不出上下文;机器一重启,服务再也不会自己回来。交接时新人只知道"要手动起",却不知道它依赖哪些目录和环境变量。
根因往往不是程序写得差,而是进程没有被可靠地托管。主流发行版早就不用 SysV init 了,PID 1 是 systemd,但很多人只把它当成 systemctl start/stop 的语法糖,重启策略、日志归集、资源限额、依赖顺序这些真正值钱的能力一个都没用上。下面按"现象 → 原理 → 操作 → 坑"的顺序,把裸进程迁到 systemd 托管。
二、原理:systemd 到底替你做了什么
进程归属:每个 service 独立 cgroup,主进程退出后按 KillMode 清理整个进程组,不留孤儿进程占端口,systemctl stop 因此比 kill 干净。
状态机:inactive → activating → active → deactivating → failed,systemctl status 显示的是这个状态,而不是简单的"进程在不在"。
重启策略:Restart= 决定什么情况下拉起,RestartSec= 决定隔多久,StartLimitBurst 决定连续失败几次后放弃,避免无限重启耗 CPU。
日志:stdout/stderr 默认进 journal,journalctl -u 按 unit 过滤,自带时间戳和优先级,不用自己重定向再配轮转。
依赖与顺序:Wants=/Requires= 决定要不要一起启动,After=/Before= 决定谁先谁后;两者正交,只写 After 不会拉起对方。
沙箱与限额:User=、ProtectSystem=、MemoryMax=、CPUQuota= 让普通服务获得接近容器的隔离度,不用引入容器运行时。
Unit 搜索路径优先级是 /etc/systemd/system > /run/systemd/system > /usr/lib/systemd/system,永远改 /etc 下那份。
三、Type 选错,后面全错
Type= 决定 systemd 如何判断"启动成功"。
simple:ExecStart一 fork 就算成功,默认值,适合前台进程。exec:等到execve()成功才算启动,能更早发现"命令不存在"。forking:父进程退出、子进程继续,传统守护进程写法,必须配PIDFile=。notify:程序主动sd_notify(READY=1)才算就绪,适合启动慢、需要预热的应用。oneshot:跑完就退出,适合初始化脚本,常配RemainAfterExit=yes。
判断很简单:一直占着前台用 simple,自己 daemonize 了用 forking,支持 sd_notify 就优先 notify,最准确。
四、动手:把一个脚本变成服务
4.1 准备一个会说话的示例程序
sudo install -d -m 0755 /opt/demo
sudo tee /opt/demo/app.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
trap 'echo "[demo] bye"; exit 0' TERM INT
while true; do echo "[demo] tick $(date -Is)"; sleep 10; done
EOF
sudo chmod 0755 /opt/demo/app.sh
4.2 写 unit 文件
sudo tee /etc/systemd/system/demo.service >/dev/null <<'EOF'
[Unit]
Description=Demo background worker
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=demo
Group=demo
WorkingDirectory=/opt/demo
Environment="APP_ENV=prod" "TZ=Asia/Shanghai"
EnvironmentFile=-/etc/demo/app.env
ExecStart=/opt/demo/app.sh
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=3s
TimeoutStopSec=20s
KillMode=mixed
StandardOutput=journal
StandardError=journal
SyslogIdentifier=demo
[Install]
WantedBy=multi-user.target
EOF
细节:EnvironmentFile 前的 - 表示文件不存在也不报错;ExecStart 必须是绝对路径;WantedBy 决定 enable 时软链接建到哪个 target 下。
4.3 建运行用户和目录
sudo useradd --system --no-create-home --shell /usr/sbin/nologin demo
sudo install -d -o demo -g demo -m 0750 /var/lib/demo /var/log/demo
sudo install -d -m 0755 /etc/demo
printf 'APP_PORT=8080\n' | sudo tee /etc/demo/app.env
sudo chmod 0640 /etc/demo/app.env
用专用系统用户跑服务是成本最低的安全加固,nologin 让账号泄露后也登不进来。
4.4 加载、启动、看状态
sudo systemctl daemon-reload
sudo systemctl enable --now demo.service
systemctl status demo.service --no-pager -l
enable --now 一步完成"开机自启 + 立即启动"。
4.5 验证自愈能力
systemctl show demo.service -p MainPID -p NRestarts -p ActiveState
sudo kill -9 "$(systemctl show -p MainPID --value demo.service)"
sleep 6
systemctl status demo.service --no-pager | head -6
kill -9 之后如果 NRestarts 加一、状态回到 active,说明重启策略真的生效了。这比看配置文件可靠得多。
4.6 日志怎么查
journalctl -u demo.service -n 50 --no-pager
journalctl -u demo.service -f
journalctl -u demo.service --since "1 hour ago" -p warning
journalctl -u demo.service -o json-pretty -n 1
journalctl --disk-usage
-p warning 只看警告以上;-o json-pretty 能看到 _PID、_SYSTEMD_UNIT 这些结构化字段,写告警脚本时很有用。
4.7 加资源上限和沙箱
sudo systemctl edit demo.service
在打开的覆盖文件里填:
[Service]
MemoryMax=512M
CPUQuota=50%
LimitNOFILE=65535
TasksMax=256
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/demo /var/log/demo
sudo systemctl daemon-reload
sudo systemctl restart demo.service
systemd-cgtop -1 --no-pager | head -5
systemctl show demo.service -p MemoryMax -p CPUQuotaPerSecUSec
systemctl edit 生成的是 drop-in 覆盖文件,放在 /etc/systemd/system/demo.service.d/ 下,比直接改原文件更好维护。ProtectSystem=strict 会把整个文件系统挂成只读,只留 ReadWritePaths 可写——这是最常导致"服务起不来又没有明显报错"的一条。
4.8 依赖和顺序
systemctl list-dependencies --reverse demo.service
systemd-analyze verify /etc/systemd/system/demo.service
systemd-analyze critical-chain demo.service
verify 能在上线前发现 ExecStart 路径不存在、unit 名拼错、指令拼写错误;critical-chain 展示启动链上每一步花了多久。
4.9 用 timer 替代 cron
sudo tee /etc/systemd/system/demo-report.service >/dev/null <<'EOF'
[Unit]
Description=Demo report job
[Service]
Type=oneshot
User=demo
ExecStart=/opt/demo/report.sh
EOF
sudo tee /etc/systemd/system/demo-report.timer >/dev/null <<'EOF'
[Unit]
Description=Demo report every 10 min
[Timer]
OnCalendar=*:0/10
Persistent=true
RandomizedDelaySec=30
Unit=demo-report.service
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now demo-report.timer
systemctl list-timers demo-report.timer --no-pager
Persistent=true 补跑关机期间错过的任务,RandomizedDelaySec 打散触发时间,避免几十台机器同时打后端。
4.10 排障四板斧
systemctl --failed --no-pager
systemctl cat demo.service
systemctl show demo.service -p ExecMainStatus -p ExecMainCode -p Result
journalctl -u demo.service -b -p err --no-pager
ExecMainStatus 是退出码,Result 是 systemd 的结论(exit-code、signal、timeout),这两个字段能定位八成启动失败。
五、常见坑,一条条对
改完 unit 不 daemon-reload。 文件改了但 systemd 用的是内存里的旧配置,表现为"怎么改都没效果"。任何 /etc/systemd/system 下的改动都要跟一次 daemon-reload。
Type=forking 不写 PIDFile。 systemd 找不到主进程,状态会卡在 activating,最后超时失败。
ExecStart 用相对路径或依赖 shell。 systemd 不经过 shell,ExecStart=./app、ExecStart=app > log 2>&1 都不成立。重定向要写成 StandardOutput=,管道要显式 /bin/sh -c。
环境变量不继承。 shell 里 export 的变量服务里一个都没有,PATH 也只有很窄的默认值,得写 Environment= 或 EnvironmentFile=。
Restart=always 配一个必然失败的命令。 结果是无限重启,journalctl 被刷屏,CPU 也被吃掉。加上 StartLimitIntervalSec=60 和 StartLimitBurst=5,失败到次数就停下来。
User= 之后写不了日志。 服务以普通用户跑,/var/log 下没有权限,程序静默失败。提前 install -d -o demo -g demo 建好目录。
服务里再 fork 出后台进程。 systemd 认为主进程退出,cgroup 被清理,子进程一起没了。要么前台运行,要么 Type=forking 加 PIDFile。
六、小结
一份能上生产的 unit 至少要回答六个问题:用什么身份跑(User)、怎么算启动成功(Type)、失败怎么办(Restart 与限流)、日志去哪(journal)、能占多少资源(MemoryMax/CPUQuota)、什么时候自启(WantedBy)。
上线前的检查顺序可以固定成:systemd-analyze verify → daemon-reload → enable --now → kill -9 验证自愈 → reboot 验证开机自启 → journalctl -u 确认日志可读。这六步走完,才算真正"托管"住,而不是换了个地方手动起进程。
- 点赞
- 收藏
- 关注作者
评论(0)