Linux-14 systemd 与定时任务:写一个生产级 unit、Type 怎么选、cron 的经典坑
上一篇讲了进程,这一篇讲怎么把进程变成服务。systemd 的设计之争(为什么它赢了、为什么它被骂)留给第 37 篇,这里只讲实操。
先看五个问题:
Type=simple、forking、notify该选哪个?选错会发生什么?systemctl enable和systemctl start有什么区别?- 为什么改了 unit 文件必须
daemon-reload? - 脚本手动跑正常,放进 cron 就失败,为什么?
- systemd timer 相比 cron 有什么优势?
1. 基本概念
1.1 unit:systemd 管理的一切
systemd 把所有被管理的东西统一抽象成 unit,用后缀区分类型:
| 后缀 | 类型 | 管什么 |
|---|---|---|
.service |
服务 | 进程(最常用) |
.timer |
定时器 | 定时触发另一个 unit(cron 的替代) |
.socket |
套接字 | 监听端口/socket,有连接时才启动服务(socket 激活) |
.mount |
挂载点 | 文件系统挂载(由 /etc/fstab 自动生成) |
.automount |
自动挂载 | 首次访问时才挂载 |
.swap |
交换区 | swap 设备/文件 |
.target |
目标 | 一组 unit 的集合(替代传统的 runlevel) |
.path |
路径监视 | 文件/目录变化时触发 |
.device |
设备 | udev 识别的设备 |
.slice |
切片 | cgroup 层级,用于资源分配 |
.scope |
作用域 | 外部创建的进程组(如用户会话、容器) |
systemctl list-units # 当前【已加载】的 unit
systemctl list-units --type=service # 只看服务
systemctl list-units --state=failed # ✅ 失败的(排查第一步)
systemctl list-unit-files # 所有【已安装】的 unit 文件及其启用状态
systemctl list-unit-files --type=timer # 所有定时器
systemctl --failed # 等价 --state=failed
1.2 unit 文件的位置与优先级
优先级从高到低(同名文件,高优先级的完全覆盖低优先级的):
/etc/systemd/system/ ← 【管理员写的】,你应该放在这里
/run/systemd/system/ ← 运行时动态生成的(重启消失)
/usr/lib/systemd/system/ ← 【软件包安装的】,不要手改!
/lib/systemd/system/ ← 同上(多数发行版是软链接到 /usr/lib)
systemctl cat nginx # ✅ 看实际生效的完整配置(含所有 drop-in)
systemctl show nginx # 看所有属性的最终值(很长)
systemctl show nginx -p ExecStart -p Type -p Restart # 只看关心的
systemctl status nginx # 运行状态 + 最近日志
关键规则:不要直接编辑 /usr/lib/systemd/system/ 下的文件 —— 软件包升级会覆盖你的修改。正确做法是 drop-in(见第 5 章)。
1.3 两组状态
「是否开机自启」(list-unit-files 的 STATE 列):
| 状态 | 含义 |
|---|---|
enabled |
已启用开机自启(在某个 target 的 .wants/ 目录里有软链接) |
disabled |
未启用 |
static |
没有 [Install] 段,无法 enable(通常是被别的 unit 依赖才启动) |
masked |
被彻底屏蔽(软链接到 /dev/null),任何方式都无法启动 |
indirect |
通过别名间接启用 |
generated |
由 generator 自动生成(如 fstab 转换来的 .mount) |
「当前是否在运行」(status 的 Active 行):
| 状态 | 含义 |
|---|---|
active (running) |
正在运行(有常驻进程) |
active (exited) |
执行完退出了但被视为成功(Type=oneshot + RemainAfterExit=yes) |
active (waiting) |
在等触发(timer、socket) |
inactive (dead) |
未运行 |
failed |
启动失败或异常退出 |
activating / deactivating |
正在启动/停止中 |
1.4 依赖关系
这是 systemd 相对 SysV 脚本的核心改进:依赖是声明式的,而不是靠脚本编号顺序。
| 指令 | 语义 |
|---|---|
Wants= |
弱依赖:尽量一起启动,对方失败不影响自己(推荐默认用这个) |
Requires= |
强依赖:对方启动失败则自己也失败;对方停止则自己也停止 |
Requisite= |
要求对方已经启动,否则立即失败(不主动拉起) |
BindsTo= |
比 Requires 更强:对方意外退出也会导致自己停止 |
PartOf= |
单向绑定:对方 restart/stop 时自己也跟着,反之不影响 |
After= |
启动顺序:在对方之后启动(只管顺序,不管依赖) |
Before= |
在对方之前启动 |
Conflicts= |
互斥:启动自己会停止对方 |
OnFailure= |
自己失败时启动指定的 unit(用于告警) |
OnSuccess= |
成功时启动指定 unit(systemd 249+) |
最容易搞错的一点:Requires 与 After 是两个正交的概念。
# ❌ 只写 Requires 不写 After:两者会【同时】启动,可能数据库还没就绪
[Unit]
Requires=postgresql.service
# ✅ 依赖 + 顺序都要写
[Unit]
Requires=postgresql.service
After=postgresql.service
# ✅ 更常见的生产写法:用 Wants(数据库暂时不可用时服务仍启动,靠应用自己重试)
[Unit]
Wants=postgresql.service
After=postgresql.service network-online.target
systemctl list-dependencies nginx # 看它依赖谁(树形)
systemctl list-dependencies --reverse nginx # ✅ 看谁依赖它(改动前必查)
systemctl list-dependencies --all nginx
systemctl list-dependencies multi-user.target # 看这个 target 拉起哪些服务
1.5 target:替代 runlevel
systemctl get-default # multi-user.target(服务器默认)
systemctl set-default multi-user.target # 设置默认启动目标
systemctl list-units --type=target # 当前激活的 target
| target | 对应 runlevel | 含义 |
|---|---|---|
poweroff.target |
0 | 关机 |
rescue.target |
1 | 单用户救援模式 |
multi-user.target |
3 | 多用户命令行(服务器标准) |
graphical.target |
5 | 图形界面 |
reboot.target |
6 | 重启 |
emergency.target |
— | 紧急模式(只挂载根分区、只启动一个 shell) |
network-online.target |
— | 网络真正可用(不只是网卡起来了) |
basic.target |
— | 基础系统就绪 |
sysinit.target |
— | 系统初始化完成 |
# ⚠️ network.target 与 network-online.target 的区别(常见错误来源)
# network.target = 网络【栈】已初始化(不保证有 IP、不保证能连外网)
# network-online.target = 网络【真正可用】(有 IP、路由就绪)
# 需要联网的服务必须用后者,且要启用对应的等待服务
[Unit]
Wants=network-online.target
After=network-online.target
# 并确保 systemd-networkd-wait-online 或 NetworkManager-wait-online 已启用
systemctl is-enabled systemd-networkd-wait-online.service
2. systemctl 完整用法
2.1 开篇第二问:start 与 enable
sudo systemctl start nginx # 【立即】启动(重启后不会自动启动)
sudo systemctl enable nginx # 设置【开机自启】(现在不启动)
sudo systemctl enable --now nginx # ✅ 两件事一起做(最常用)
enable 的本质是创建一个软链接:
sudo systemctl enable nginx
# Created symlink /etc/systemd/system/multi-user.target.wants/nginx.service
# → /usr/lib/systemd/system/nginx.service
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 就是往 target 的 .wants 目录里放个链接
ls -l /etc/systemd/system/multi-user.target.wants/
# 这个链接放在哪个 target 的 .wants 里,由 unit 的 [Install] 段决定:
# [Install]
# WantedBy=multi-user.target
所以 static 状态的 unit 无法 enable —— 它没有 [Install] 段,systemd 不知道该把链接放进哪个 target。
2.2 生命周期操作
sudo systemctl start myapp
sudo systemctl stop myapp
sudo systemctl restart myapp # 停止再启动(有服务中断)
sudo systemctl reload myapp # 重载配置(需 unit 定义了 ExecReload)
sudo systemctl reload-or-restart myapp # ✅ 能 reload 就 reload,否则 restart
sudo systemctl try-restart myapp # 只在【正在运行】时才重启
sudo systemctl kill myapp # 发信号(默认 SIGTERM)
sudo systemctl kill -s SIGQUIT myapp # 指定信号(Go 服务打 goroutine 栈)
sudo systemctl kill --kill-whom=main -s HUP myapp # 只发给主进程
# reload vs restart 的区别(生产上很重要)
# reload = 不中断服务,通常是给进程发 SIGHUP 让它重读配置
# restart = 进程真的退出再启动,会有中断窗口
systemctl show nginx -p ExecReload # 看它支持什么 reload 方式
2.3 开机自启与屏蔽
sudo systemctl enable myapp
sudo systemctl disable myapp
sudo systemctl disable --now myapp # 禁用并立即停止
sudo systemctl is-enabled myapp # 查询(脚本用,看退出码)
sudo systemctl reenable myapp # 先 disable 再 enable(修复错乱的链接)
# mask:比 disable 更彻底
sudo systemctl mask myapp # 软链接到 /dev/null,【任何方式都无法启动】
sudo systemctl unmask myapp
# 用途:彻底禁止某个服务(如禁掉发行版自带的 apache 防止它抢 80 端口)
# disable 只是不自动启动,但依赖它的服务仍可能拉起它;mask 能彻底阻止
2.4 查询与诊断
systemctl status myapp # ✅ 状态 + 最近 10 行日志
systemctl status myapp -l --no-pager # 不截断、不分页
systemctl status myapp -n 50 # 显示 50 行日志
systemctl is-active myapp # active / inactive / failed(脚本用)
systemctl is-failed myapp
systemctl cat myapp # ✅ 看完整配置(含 drop-in,标注来源文件)
systemctl show myapp # 所有属性的最终值
systemctl show myapp -p MainPID -p Type -p Restart -p LimitNOFILE
systemctl help myapp # 打开该服务的 man 页
# 找出所有失败的服务(登录机器后的例行检查)
systemctl --failed
systemctl list-units --state=failed
sudo systemctl reset-failed # 清除失败计数(被 StartLimit 挡住时需要)
sudo systemctl reset-failed myapp
2.5 启动性能分析
systemd-analyze # 总启动时间
# Startup finished in 3.2s (kernel) + 8.5s (userspace) = 11.7s
systemd-analyze blame # ✅ 按耗时排序,找出拖慢启动的服务
# 4.521s NetworkManager-wait-online.service
# 2.103s snapd.service
# 1.234s docker.service
systemd-analyze critical-chain # 关键路径(真正影响启动时间的链条)
systemd-analyze critical-chain myapp.service
systemd-analyze plot > boot.svg # 生成可视化时序图
systemd-analyze verify /etc/systemd/system/myapp.service # ✅ 语法检查
systemd-analyze security myapp.service # ✅ 安全加固评分(0~10,越低越好)
systemd-analyze calendar 'Mon *-*-* 02:30' # ✅ 验证 OnCalendar 表达式
systemd-analyze timespan '2h 30min' # 验证时间跨度表达式
2.6 开篇第三问:为什么要 daemon-reload
sudo vim /etc/systemd/system/myapp.service
sudo systemctl restart myapp
# Warning: The unit file, source configuration file or drop-ins of myapp.service changed
# on disk. Run 'systemctl daemon-reload' to reload units.
sudo systemctl daemon-reload # ✅ 让 systemd 重新读取所有 unit 文件
sudo systemctl restart myapp
原因:systemd 在启动时把所有 unit 文件解析成内存里的对象,之后一直用内存里的版本。你改了磁盘上的文件,systemd 并不知道。daemon-reload 让它重新扫描并解析。
# 记忆规则
# 改了 unit 【文件】 -> daemon-reload + restart
# 改了服务自己的【配置文件】 -> 只需 reload(如 nginx.conf → systemctl reload nginx)
# 升级了 systemd 本身 -> daemon-reexec(重新执行 systemd 自己,不重启系统)
sudo systemctl daemon-reload # 重新加载 unit 定义
sudo systemctl daemon-reexec # 重新执行 systemd 进程本身
2.7 系统控制
sudo systemctl reboot # 重启
sudo systemctl poweroff # 关机
sudo systemctl suspend / hibernate # 挂起/休眠
sudo systemctl rescue # 切到救援模式
sudo systemctl isolate multi-user.target # 切换到指定 target(停掉不属于它的服务)
sudo systemctl list-jobs # 正在进行的启动/停止任务(卡住时看它)
3. 写一个生产级 unit
3.1 [Unit] 段
[Unit]
Description=My Go API Service # 一句话描述(systemctl status 会显示)
Documentation=https://wiki.internal/myapp # 文档链接(systemctl help 会用)
After=network-online.target postgresql.service
Wants=network-online.target
Requires= # 强依赖(慎用)
PartOf= # 被谁的 restart 带动
Conflicts= # 与谁互斥
OnFailure=notify-failure@%n.service # ✅ 失败时触发告警服务(%n = 本 unit 名)
StartLimitIntervalSec=300 # 在 300 秒窗口内
StartLimitBurst=5 # 最多允许启动 5 次(超了就拒绝再启动)
ConditionPathExists=/etc/myapp/config.yaml # 条件:文件不存在则跳过(不算失败)
AssertPathExists=/var/lib/myapp # 断言:不满足则【失败】
StartLimitBurst 是个容易踩的坑:
# 服务反复崩溃 -> 达到启动次数限制 -> systemd 拒绝再启动
systemctl status myapp
# Active: failed (Result: start-limit-hit)
# ^^^^^^^^^^^^^^^ 撞上了启动频率限制
sudo systemctl reset-failed myapp # ✅ 清除计数后才能再启动
sudo systemctl start myapp
3.2 [Service] 段核心字段
[Service]
Type=notify # 见第 4 章
User=myapp # 以哪个用户运行(10 篇讲的专用账号)
Group=myapp
WorkingDirectory=/var/lib/myapp # cwd
ExecStartPre=/usr/local/bin/myapp check # 启动前执行(失败则不启动)
ExecStart=/usr/local/bin/myapp --config /etc/myapp/config.yaml
ExecStartPost=/bin/sleep 1 # 启动后执行
ExecReload=/bin/kill -HUP $MAINPID # ✅ 定义 reload 行为
ExecStop= # 自定义停止命令(默认发 SIGTERM)
ExecStopPost= # 停止后清理
Restart=on-failure # 重启策略,见 3.3
RestartSec=5 # 重启前等 5 秒
TimeoutStartSec=30 # 启动超时(Type=notify 时超时算失败)
TimeoutStopSec=30 # ✅ 停止超时(超了发 SIGKILL)
KillSignal=SIGTERM # 停止时先发什么信号
KillMode=mixed # 主进程发 TERM,其余发 KILL
SendSIGKILL=yes # 超时后是否强杀(默认 yes)
Environment=GOMAXPROCS=4 # 环境变量
Environment="LOG_LEVEL=info" "TZ=Asia/Shanghai"
EnvironmentFile=-/etc/default/myapp # 从文件读(- 表示文件不存在也不报错)
StandardOutput=journal # stdout 去哪(journal/null/file:/path/append:/path)
StandardError=journal
SyslogIdentifier=myapp # 日志里的标识名
# 目录:systemd 自动创建 + 设好属主,停止时按需清理
RuntimeDirectory=myapp # /run/myapp(停止时删除)
RuntimeDirectoryMode=0750
StateDirectory=myapp # /var/lib/myapp(保留)
CacheDirectory=myapp # /var/cache/myapp
LogsDirectory=myapp # /var/log/myapp
ConfigurationDirectory=myapp # /etc/myapp
# 资源限制(底层是 cgroup)
LimitNOFILE=65535 # ✅ fd 上限(limits.conf 对服务无效!见 13 篇)
LimitNPROC=4096
LimitCORE=infinity
CPUQuota=200% # 最多用 2 核
MemoryMax=2G # 内存硬限制
MemoryHigh=1.5G # 软限制(超了激进回收)
TasksMax=4096
OOMScoreAdjust=-500 # 降低被 OOM 杀的概率
Nice=0
IOWeight=100
[Install]
WantedBy=multi-user.target # enable 时链接到哪个 target
3.3 重启策略
Restart= |
何时重启 |
|---|---|
no |
从不(默认) |
on-failure |
非零退出码、被信号杀死、超时、看门狗触发(最常用) |
always |
总是重启(包括正常退出) |
on-success |
只在正常退出(exit 0)时 |
on-abnormal |
被信号杀死、超时、看门狗(不含非零退出码) |
on-abort |
只在收到未捕获的信号时 |
on-watchdog |
只在看门狗超时时 |
# 长驻服务的典型配置
Restart=always
RestartSec=5
StartLimitIntervalSec=300
StartLimitBurst=5
# 含义:崩了就重启,间隔 5 秒;但 5 分钟内超过 5 次就停止尝试(避免无意义的疯狂重启)
# 一次性任务(数据库迁移、初始化)
Type=oneshot
RemainAfterExit=yes # 执行完仍显示 active(表示"已完成")
Restart=no
# 排除特定退出码不触发重启
RestartPreventExitStatus=42 # 退出码 42 时不重启(应用用它表达"配置错误,别重试")
SuccessExitStatus=143 # 把 143(SIGTERM)也视为成功
3.4 完整的 Go 服务模板
# /etc/systemd/system/myapp.service
[Unit]
Description=MyApp Go API Service
Documentation=https://wiki.internal/myapp
Wants=network-online.target
After=network-online.target postgresql.service
OnFailure=alert@%n.service
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=notify # 用 sd_notify 精确上报就绪状态(见第 4 章)
NotifyAccess=main
WatchdogSec=30 # 应用需每 <30s 发一次心跳,否则被判定为卡死并重启
User=myapp
Group=myapp
WorkingDirectory=/var/lib/myapp
ExecStartPre=/usr/local/bin/myapp validate --config /etc/myapp/config.yaml
ExecStart=/usr/local/bin/myapp serve --config /etc/myapp/config.yaml
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=5
TimeoutStopSec=45 # 要大于代码里 srv.Shutdown 的超时
KillMode=mixed
KillSignal=SIGTERM
EnvironmentFile=-/etc/default/myapp
Environment=GOMAXPROCS=4
Environment=GOMEMLIMIT=1800MiB # ✅ 略小于 MemoryMax,让 GC 提前工作
Environment=TZ=Asia/Shanghai
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp
RuntimeDirectory=myapp
StateDirectory=myapp
LogsDirectory=myapp
ConfigurationDirectory=myapp
LimitNOFILE=65535
MemoryMax=2G
MemoryHigh=1800M
CPUQuota=400%
TasksMax=4096
OOMScoreAdjust=-500
# 安全加固(10 篇 8 章讲过,用 systemd-analyze security 检查评分)
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
RestrictRealtime=yes
LockPersonality=yes
MemoryDenyWriteExecute=no # ⚠️ Go 不需要 JIT,可以设 yes;但 cgo/某些库会崩
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
ReadWritePaths=/var/lib/myapp /var/log/myapp
CapabilityBoundingSet=CAP_NET_BIND_SERVICE # 只保留绑定低端口的能力
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target
# 部署流程
sudo systemd-analyze verify /etc/systemd/system/myapp.service # ① 语法检查
sudo systemctl daemon-reload # ② 重新加载
sudo systemctl enable --now myapp # ③ 启用并启动
systemctl status myapp # ④ 确认
sudo systemd-analyze security myapp # ⑤ 看加固评分
journalctl -u myapp -f # ⑥ 跟随日志
4. 开篇第一问:Type 怎么选
Type= 决定 systemd 如何判断「服务已经启动成功」,选错会导致依赖顺序错乱或状态显示错误。
| Type | systemd 认为「启动完成」的时机 | 适用 |
|---|---|---|
simple |
ExecStart 的进程一被 fork 出来(不等它就绪) |
默认值;前台运行的程序 |
exec |
进程被 execve() 成功执行后 |
比 simple 略准确(能捕获「二进制不存在」) |
forking |
父进程退出后(传统守护进程的 double-fork) | 老式 daemon(自己 fork 到后台) |
notify |
进程主动发送 READY=1 通知 |
最准确,需要应用支持 sd_notify |
notify-reload |
同上,且支持 reload 时上报(systemd 253+) | — |
oneshot |
进程执行完毕退出后 | 一次性任务(迁移、初始化、timer 触发的任务) |
dbus |
进程在 D-Bus 上注册了指定名字后 | 桌面/D-Bus 服务 |
idle |
延迟到其他任务都完成后才启动 | 避免输出与启动信息交错 |
4.1 选错的后果
# ❌ 错误一:程序自己 fork 到后台,但写了 Type=simple
Type=simple
ExecStart=/usr/sbin/mydaemon # 这个程序会 fork 然后父进程退出
# 后果:systemd 看到"主进程"退出了,认为服务挂了 -> 立即报 failed 并(按策略)重启
# 形成无限重启循环
# ✅ 修法一:改成 forking
Type=forking
PIDFile=/run/mydaemon.pid # 告诉 systemd 去哪找真正的主进程 PID
# ✅ 修法二(更好):让程序【不要 fork】,以前台方式运行
Type=simple
ExecStart=/usr/sbin/mydaemon --foreground
# 现代最佳实践:程序前台运行,进程管理交给 systemd
# nginx: daemon off; / php-fpm: --nodaemonize / Go 服务天然前台
# ❌ 错误二:服务需要 10 秒才能就绪,但用了 Type=simple
Type=simple
ExecStart=/usr/local/bin/myapp # 启动后要 10 秒才能接受请求
After=myapp.service # 依赖它的服务
# 后果:systemd 一 fork 出进程就认为"启动完成",
# 依赖它的服务立即启动 -> 连不上 -> 失败
# ✅ 用 Type=notify,让应用自己上报就绪
Type=notify
4.2 Go 服务实现 sd_notify
import "github.com/coreos/go-systemd/v22/daemon"
func main() {
srv := &http.Server{Addr: ":8080", Handler: router}
// 先完成所有初始化:连数据库、加载配置、预热缓存
if err := initEverything(); err != nil {
log.Fatalf("初始化失败: %v", err)
}
ln, err := net.Listen("tcp", ":8080") // 端口已经在监听
if err != nil { log.Fatal(err) }
// ✅ 到这里才算真正就绪,通知 systemd
if sent, err := daemon.SdNotify(false, daemon.SdNotifyReady); err != nil {
log.Printf("sd_notify 失败: %v", err)
} else if sent {
log.Println("已上报 READY=1")
}
// 看门狗心跳(配合 WatchdogSec=30)
if interval, err := daemon.SdWatchdogEnabled(false); err == nil && interval > 0 {
go func() {
tick := time.NewTicker(interval / 2) // 按间隔的一半发送
defer tick.Stop()
for range tick.C {
// ✅ 这里应该做真实的健康检查,而不是无脑发心跳
if healthy() {
daemon.SdNotify(false, daemon.SdNotifyWatchdog)
}
}
}()
}
go srv.Serve(ln)
// 优雅退出(13 篇 5.3)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
<-quit
daemon.SdNotify(false, daemon.SdNotifyStopping) // 上报「正在停止」
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx)
}
不想引入依赖时,sd_notify 的协议其实很简单(往 $NOTIFY_SOCKET 这个 unix datagram socket 写一行):
func sdNotify(state string) error {
addr := os.Getenv("NOTIFY_SOCKET")
if addr == "" { return nil } // 不在 systemd 下运行时静默跳过
if addr[0] == '@' { addr = "\x00" + addr[1:] } // 抽象命名空间
conn, err := net.DialUnix("unixgram", nil, &net.UnixAddr{Name: addr, Net: "unixgram"})
if err != nil { return err }
defer conn.Close()
_, err = conn.Write([]byte(state))
return err
}
// sdNotify("READY=1")
// sdNotify("STATUS=处理中 1234 个请求") // 会显示在 systemctl status 里
// sdNotify("WATCHDOG=1")
// sdNotify("STOPPING=1")
5. drop-in:正确的覆盖方式
不要直接改发行版提供的 unit 文件(升级会覆盖)。用 drop-in 片段来追加或覆盖字段:
sudo systemctl edit nginx # ✅ 自动创建 drop-in 并打开编辑器
# 它会创建 /etc/systemd/system/nginx.service.d/override.conf
sudo systemctl edit --full nginx # 完整复制原文件到 /etc/(整体覆盖)
sudo systemctl revert nginx # ✅ 撤销所有 drop-in 与覆盖,回到原始状态
# 手动创建 drop-in
sudo mkdir -p /etc/systemd/system/nginx.service.d/
sudo tee /etc/systemd/system/nginx.service.d/override.conf > /dev/null <<'EOF'
[Service]
LimitNOFILE=65535
Environment=MY_VAR=value
EOF
sudo systemctl daemon-reload
systemctl cat nginx # ✅ 能看到原文件 + drop-in,并标注来源
一个关键规则:列表型字段需要先清空再赋值。
# ❌ 直接写会【追加】而不是替换(ExecStart 会变成两条,systemd 直接报错)
[Service]
ExecStart=/usr/local/bin/mynginx
# ✅ 先用空值清空,再赋新值
[Service]
ExecStart=
ExecStart=/usr/local/bin/mynginx
# 同样需要这样处理的字段:ExecStartPre、ExecStartPost、ExecReload、
# Environment、After、Wants、ReadWritePaths 等
6. journalctl
6.1 基本查询
journalctl # 全部日志(从最早开始,按 q 退出)
journalctl -e # ✅ 跳到【末尾】
journalctl -r # 倒序(最新在前)
journalctl -n 100 # 最近 100 行
journalctl -f # ✅ 跟随(等价 tail -f)
journalctl --no-pager # 不分页(脚本/管道用)
# ── 按服务 ──
journalctl -u myapp # ✅ 最常用
journalctl -u myapp -f # 跟随某个服务
journalctl -u myapp -n 50 --no-pager
journalctl -u nginx -u php-fpm # 多个服务
journalctl -u 'myapp*' # 通配
# ── 按时间 ──
journalctl --since today
journalctl --since yesterday --until today
journalctl --since '1 hour ago'
journalctl --since '2026-08-12 14:00' --until '2026-08-12 15:00'
journalctl --since '-30min' # 30 分钟内
journalctl -u myapp --since '10 min ago'
# ── 按优先级 ──
journalctl -p err # ✅ 只看 error 及以上
journalctl -p warning..err # 区间
# 级别:emerg(0) alert(1) crit(2) err(3) warning(4) notice(5) info(6) debug(7)
# ── 按其他维度 ──
journalctl -k # ✅ 只看内核消息(等价 dmesg)
journalctl -k -p err --since today # 今天的内核错误
journalctl _PID=1234 # 按 PID
journalctl _UID=1000 # 按用户
journalctl _COMM=sshd # 按命令名
journalctl -t myapp # 按 SyslogIdentifier
journalctl /usr/local/bin/myapp # 按可执行文件路径
journalctl -b # 本次启动以来
journalctl -b -1 # ✅ 【上一次】启动的日志(排查崩溃重启必用)
journalctl --list-boots # 列出所有启动记录
journalctl -u myapp --grep 'timeout' # ✅ 内置正则过滤(比 | grep 高效)
journalctl -u myapp --grep 'error' -p err
# ── 输出格式 ──
journalctl -u myapp -o short # 默认
journalctl -u myapp -o cat # ✅ 只输出消息本身(**给 jq 用**)
journalctl -u myapp -o json # JSON(一行一条)
journalctl -u myapp -o json-pretty # 格式化 JSON
journalctl -u myapp -o verbose # 显示所有字段(看有哪些字段可过滤)
journalctl -u myapp -o short-iso # ISO 时间格式
journalctl -u myapp -o short-precise # 微秒精度
# ── 结构化日志:Go 服务输出 JSON 时的组合 ──
journalctl -u myapp -o cat -f | jq -r 'select(.level=="error") | "\(.time) \(.msg)"'
journalctl -u myapp -o cat --since '1h ago' | jq -s 'group_by(.level) | map({level: .[0].level, n: length})'
6.2 磁盘占用与持久化
journalctl --disk-usage # 当前占用
# Archived and active journals take up 1.2G in the file system.
sudo journalctl --vacuum-size=500M # ✅ 压到 500MB 以内
sudo journalctl --vacuum-time=7d # 只保留 7 天
sudo journalctl --vacuum-files=5 # 只保留 5 个归档文件
journalctl --verify # 校验日志完整性
# 持久化配置(默认可能只存在内存里,重启就没了!)
sudo mkdir -p /var/log/journal # ✅ 这个目录存在 = 持久化
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journald
sudo vim /etc/systemd/journald.conf
# [Journal]
# Storage=persistent # volatile(仅内存) / persistent(落盘) / auto(有目录就落盘) / none
# SystemMaxUse=2G # 最多用多少磁盘
# SystemKeepFree=1G # 至少保留多少空闲
# SystemMaxFileSize=128M # 单个文件最大
# MaxRetentionSec=1month # 最长保留时间
# MaxFileSec=1week # 单文件最长时间跨度
# RateLimitIntervalSec=30s # ⚠️ 限流窗口
# RateLimitBurst=10000 # ⚠️ 窗口内最多多少条(超了会【丢日志】并提示 "Suppressed N messages")
# ForwardToSyslog=no # 是否转发给 rsyslog(不需要就关,省一半 IO)
sudo systemctl restart systemd-journald
一个容易忽略的坑:journald 有限流,日志量大时会静默丢弃并打印 Suppressed 1234 messages。高日志量的服务要调 RateLimitBurst 或直接写文件。
7. systemd timer
7.1 timer + service 配对
一个定时任务需要两个文件:.service 定义「做什么」,.timer 定义「什么时候做」。
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup
OnFailure=alert@%n.service # ✅ 失败时告警(cron 做不到)
[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/backup.sh
Nice=19 # 降低优先级,别影响线上
IOSchedulingClass=idle # IO 也降到最低
TimeoutStartSec=2h # 超过 2 小时算失败
# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup nightly
[Timer]
OnCalendar=*-*-* 02:30:00 # 每天 02:30
Persistent=true # ✅ 关机期间错过的任务,开机后补跑
RandomizedDelaySec=600 # ✅ 随机延迟 0~600 秒(避免集群同时打爆存储)
AccuracySec=1m # 精度(默认 1min,越大越省电)
Unit=backup.service # 触发哪个 service(默认同名)
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer # ✅ enable 的是 .timer,不是 .service
systemctl list-timers # ✅ 看所有定时器与下次触发时间
# NEXT LEFT LAST PASSED UNIT ACTIVATES
# Wed 2026-08-13 02:30:00 CST 9h left Tue 2026-08-12 02:30:12 CST 14h ago backup.timer backup.service
systemctl list-timers --all
sudo systemctl start backup.service # ✅ 手动触发一次(测试用)
journalctl -u backup.service -n 50 # 看执行日志
7.2 OnCalendar 语法
格式:DayOfWeek Year-Month-Day Hour:Minute:Second
OnCalendar=*-*-* 02:30:00 # 每天 02:30
OnCalendar=*-*-* *:00:00 # 每小时整点
OnCalendar=*-*-* *:*:00 # 每分钟
OnCalendar=*-*-* *:0/15:00 # 每 15 分钟(0,15,30,45)
OnCalendar=Mon-Fri 09:00 # 工作日 09:00
OnCalendar=Mon *-*-* 02:00:00 # 每周一 02:00
OnCalendar=Sat,Sun 03:00 # 周末
OnCalendar=*-*-01 04:00:00 # 每月 1 号
OnCalendar=*-01,04,07,10-01 05:00:00 # 每季度第一天
OnCalendar=2026-12-25 00:00:00 # 一次性
OnCalendar=daily # 简写:每天 00:00
OnCalendar=weekly # 每周一 00:00
OnCalendar=monthly / yearly / hourly / minutely
OnCalendar=*-*-* 02:30:00 Asia/Shanghai # ✅ 指定时区(systemd 249+)
# ✅ 写完一定要验证
systemd-analyze calendar '*-*-* 02:30:00'
# Normalized form: *-*-* 02:30:00
# Next elapse: Wed 2026-08-13 02:30:00 CST
# From now: 9h left
systemd-analyze calendar 'Mon-Fri 09:00' --iterations=5 # 看接下来 5 次触发时间
其他触发方式(相对时间,cron 完全做不到):
[Timer]
OnBootSec=5min # 开机后 5 分钟
OnStartupSec=10min # systemd 启动后 10 分钟
OnUnitActiveSec=1h # ✅ 上次【运行完】之后 1 小时(避免任务重叠)
OnUnitInactiveSec=30min # 上次停止后 30 分钟
OnActiveSec=1h # timer 激活后 1 小时
OnUnitActiveSec 解决了 cron 的一个经典问题:cron 是「每小时的固定时刻执行」,如果任务跑了 70 分钟,下一次会在上一次还没结束时就启动(任务重叠)。而 OnUnitActiveSec=1h 是「上次跑完后再等 1 小时」,天然不会重叠。
7.3 开篇第五问:timer 相比 cron 的优势
| cron | systemd timer | |
|---|---|---|
| 日志 | 需自己重定向,或靠邮件 | ✅ 自动进 journald,journalctl -u x 直接看 |
| 失败告警 | ❌ 无(除了发邮件) | ✅ OnFailure= 触发告警 unit |
| 执行环境 | ⚠️ 极简 PATH、无用户环境(坑的根源) | ✅ 显式声明 Environment/EnvironmentFile |
| 依赖管理 | ❌ 无 | ✅ After=/Requires= |
| 资源限制 | ❌ 无(要自己 nice/ionice) | ✅ MemoryMax/CPUQuota/Nice/IOSchedulingClass |
| 补跑错过的任务 | ❌(anacron 部分支持) | ✅ Persistent=true |
| 随机延迟 | 需自己 sleep $RANDOM |
✅ RandomizedDelaySec |
| 防重叠 | 需自己加 flock |
✅ OnUnitActiveSec 或 unit 天然不会并发 |
| 超时控制 | 需自己 timeout |
✅ TimeoutStartSec |
| 查看下次执行 | 要自己算 | ✅ systemctl list-timers |
| 权限/沙箱 | 只能 su/sudo |
✅ User= + 全套沙箱选项 |
| 语法验证 | ❌ 写错了要等到执行时才发现 | ✅ systemd-analyze calendar |
| 配置复杂度 | ✅ 一行搞定 | ❌ 要两个文件 |
| 可移植性 | ✅ 所有 Unix | ❌ 仅 systemd 系统 |
结论:生产环境的定时任务应该用 timer(可观测性和可靠性是决定性的),临时的、简单的用 cron。
8. cron
8.1 语法
crontab -e # ✅ 编辑当前用户的 crontab(会做语法检查)
crontab -l # 列出
crontab -r # ⚠️ 删除【全部】(没有确认提示,容易误操作)
crontab -l > backup.cron # ✅ 改之前先备份
crontab -u app -l # 查看其他用户的(需 root)
crontab file # 从文件导入(会覆盖现有的!)
# ┌───────────── 分钟 (0-59)
# │ ┌─────────── 小时 (0-23)
# │ │ ┌───────── 日 (1-31)
# │ │ │ ┌─────── 月 (1-12)
# │ │ │ │ ┌───── 星期 (0-7,0 和 7 都是周日)
# │ │ │ │ │
* * * * * command
30 2 * * * /usr/local/bin/backup.sh # 每天 02:30
0 * * * * /usr/local/bin/hourly.sh # 每小时整点
*/15 * * * * /usr/local/bin/check.sh # 每 15 分钟
0 9 * * 1-5 /usr/local/bin/workday.sh # 工作日 09:00
0 0 1 * * /usr/local/bin/monthly.sh # 每月 1 号
0 3 * * 0 /usr/local/bin/weekly.sh # 每周日 03:00
0 2,14 * * * /usr/local/bin/twice.sh # 每天 02:00 和 14:00
0 0-6 * * * /usr/local/bin/night.sh # 0~6 点每小时
# 特殊字符串
@reboot /usr/local/bin/on-boot.sh # ✅ 开机时执行一次
@daily / @midnight = 0 0 * * *
@hourly = 0 * * * *
@weekly = 0 0 * * 0
@monthly = 0 0 1 * *
@yearly / @annually = 0 0 1 1 *
# ⚠️ 「日」和「星期」同时指定时是【或】关系,不是与!
0 0 13 * 5 # 每月 13 号【或】每个周五都执行(不是"13 号且是周五")
8.2 配置文件的层次
crontab -e # 用户级:存在 /var/spool/cron/crontabs/<user>
sudo vim /etc/crontab # 系统级:⚠️ 多一个【用户名】字段
# 30 2 * * * root /usr/local/bin/backup.sh
# ^^^^ 系统级 crontab 要指定运行用户
sudo vim /etc/cron.d/myapp # ✅ 推荐:一个任务一个文件(便于自动化投放)
# 格式同 /etc/crontab(有用户名字段)
# 30 2 * * * app /usr/local/bin/backup.sh
ls /etc/cron.{hourly,daily,weekly,monthly}/ # 放【可执行脚本】(不是 crontab 格式)
# 由 run-parts 执行;文件名不能有点(.)否则被忽略!
sudo run-parts --test /etc/cron.daily # 测试哪些脚本会被执行
8.3 开篇第四问:为什么 cron 里就失败
cron 提供的执行环境极其精简,这是 90% 的 cron 问题的根源:
# 亲自看看 cron 里的环境有多简陋
* * * * * env > /tmp/cron-env.txt
# 一分钟后
cat /tmp/cron-env.txt
# HOME=/home/lhx
# LOGNAME=lhx
# PATH=/usr/bin:/bin <- ⚠️ 只有这两个!
# SHELL=/bin/sh <- ⚠️ 是 sh 不是 bash!
# PWD=/home/lhx
# 就这么几个,没有 LANG、没有你 .bashrc 里的一切
五个具体原因与对策:
# ① PATH 太短 -> command not found
30 2 * * * /usr/local/bin/mytool # ✅ 用绝对路径
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
30 2 * * * mytool # ✅ 或在 crontab 顶部设 PATH
# ② SHELL 是 sh 不是 bash -> bash 特有语法失效
# ([[ ]]、数组、进程替换 <()、$'...' 全都不能用)
SHELL=/bin/bash # ✅ 在 crontab 顶部声明
30 2 * * * /bin/bash /path/script.sh # ✅ 或显式用 bash 执行
# 脚本里也要写 #!/bin/bash 而不是 #!/bin/sh
# ③ 不读 .bashrc/.profile -> 你配的环境变量全都没有
30 2 * * * . /etc/profile; . ~/.bashrc; /path/script.sh # 显式 source
30 2 * * * bash -lc '/path/script.sh' # ✅ 或用登录 shell
# 更好的做法:让脚本自己 source 需要的配置,不依赖交互式环境
# ④ 没有 LANG/LC_ALL -> 中文乱码、日期格式不同、sort 顺序不同
LANG=en_US.UTF-8
LC_ALL=en_US.UTF-8
# ⑤ cwd 是家目录 -> 相对路径全错
30 2 * * * cd /opt/myapp && ./run.sh # ✅ 显式 cd
调试 cron 任务的标准流程:
# ① 让任务把输出和错误都记下来(cron 的默认行为是发邮件,而机器通常没配邮件)
30 2 * * * /path/script.sh >> /var/log/myjob.log 2>&1
30 2 * * * /path/script.sh 2>&1 | logger -t myjob # ✅ 或者送进 syslog/journal
# ② 用最小环境模拟 cron 来测试
env -i SHELL=/bin/sh PATH=/usr/bin:/bin HOME="$HOME" /bin/sh -c '/path/script.sh'
# ^^ -i 清空所有环境变量,最接近 cron 的真实环境
# ③ 看 cron 自己的日志(确认任务有没有被触发)
sudo journalctl -u cron -f # Debian
sudo journalctl -u crond -f # RHEL
sudo grep CRON /var/log/syslog | tail
# Aug 12 02:30:01 host CRON[12345]: (lhx) CMD (/path/script.sh)
# ^^^ 有这行说明触发了,问题在脚本内部
# ④ 检查任务有没有被邮件"吞掉"
MAILTO=ops@example.com # 有输出时发到这
MAILTO="" # 不发邮件(丢弃输出)
sudo cat /var/mail/$USER # 看本地邮件里的错误信息
8.4 cron 的其他坑
# ① % 必须转义(cron 里 % 是换行的意思)
0 2 * * * echo $(date +%Y-%m-%d) # ❌ 会被截断
0 2 * * * echo $(date +\%Y-\%m-\%d) # ✅ 转义
0 2 * * * /path/script.sh # ✅ 更好:复杂逻辑放脚本里
# ② 任务重叠(上一次没跑完,下一次又启动了)
*/5 * * * * flock -n /tmp/myjob.lock /path/script.sh # ✅ 用 flock 加锁
# ^^ -n 表示拿不到锁就立即退出(不等待)
*/5 * * * * flock -w 10 /tmp/myjob.lock /path/script.sh # 或等 10 秒
# ③ 超时失控(任务卡死后永远占着资源)
0 2 * * * timeout 3600 /path/script.sh # ✅ 最多跑 1 小时
# ④ 时区与 DST
timedatectl # 确认系统时区
CRON_TZ=Asia/Shanghai # ✅ 在 crontab 里指定时区
0 2 * * * /path/script.sh
# ⚠️ 夏令时切换那天,凌晨 2:00~3:00 的任务可能【不执行或执行两次】
# 关键任务避开 0:00~4:00,或用 UTC
# ⑤ crontab -r 手滑删掉全部
crontab -l > ~/crontab.bak # ✅ 改前必备份
# 更好的做法:crontab 内容进 git,用 /etc/cron.d/ 投放
# ⑥ 用户被删/被锁后 crontab 仍然存在(但执行失败)
sudo ls -l /var/spool/cron/crontabs/
# ⑦ 脚本没有执行权限、或 shebang 写错
chmod +x /path/script.sh
head -1 /path/script.sh # #!/bin/bash 且【无 CRLF】(05 篇)
8.5 anacron 与 at
# anacron:为「不常开机的机器」设计 —— 错过的任务开机后补跑
cat /etc/anacrontab
# period delay job-identifier command
# 1 5 cron.daily run-parts --report /etc/cron.daily
# ^ 天数 ^ 开机后延迟几分钟
# 服务器 7×24 开机通常用不到;systemd timer 的 Persistent=true 是它的现代替代
# at:一次性延迟任务
echo "/path/script.sh" | at 02:30 # 今天 02:30 执行一次
echo "reboot" | at now + 2 hours
at -l # 列出待执行的(等价 atq)
at -c 5 # 查看任务 5 的内容
atrm 5 # 删除任务 5
sudo systemctl enable --now atd # 需要 atd 服务在跑
# systemd 的等价物(不需要 atd)
sudo systemd-run --on-active=2h /path/script.sh # 2 小时后执行一次
sudo systemd-run --on-calendar='02:30' /path/script.sh # 指定时刻
sudo systemd-run --unit=myjob --on-active=30min /path/x # 命名,便于取消
9. socket 激活(简介)
systemd 的一个特色能力:由 systemd 持有监听端口,有连接进来时才启动服务。
# /etc/systemd/system/myapp.socket
[Unit]
Description=MyApp socket
[Socket]
ListenStream=8080
Accept=no # no = 把监听 fd 传给服务(服务自己 accept)
[Install]
WantedBy=sockets.target
sudo systemctl enable --now myapp.socket # 只启用 socket,service 暂不运行
curl localhost:8080 # 第一次访问时 systemd 才启动 myapp.service
三个价值:
- 消除启动顺序依赖 —— 端口一直在监听(由 systemd 持有),依赖它的服务可以随时连接,连接会被缓冲直到服务就绪
- 零停机重启 —— 重启服务时端口不关闭,客户端连接只是稍等而不是被拒绝
- 按需启动 —— 不常用的服务不必常驻内存
Go 里通过 go-systemd/activation 拿到传入的 fd:
import "github.com/coreos/go-systemd/v22/activation"
listeners, err := activation.Listeners() // 从 systemd 继承的监听 fd
if err != nil || len(listeners) == 0 {
// 没有的话自己监听(便于本地开发)
ln, _ = net.Listen("tcp", ":8080")
} else {
ln = listeners[0]
}
http.Serve(ln, handler)
10. 知识点扩展
10.1 systemctl 速查
| 操作 | 命令 |
|---|---|
| 启动/停止/重启 | systemctl start/stop/restart myapp |
| 重载配置(不中断) | systemctl reload myapp / reload-or-restart |
| 开机自启 + 立即启动 | systemctl enable --now myapp |
| 禁用 + 立即停止 | systemctl disable --now myapp |
| 彻底屏蔽 | systemctl mask myapp / unmask |
| 状态 + 最近日志 | systemctl status myapp -n 50 -l |
| 看生效的完整配置 | systemctl cat myapp |
| 看某个属性 | systemctl show myapp -p Type -p Restart |
| 改配置(drop-in) | systemctl edit myapp / edit --full / revert |
| 改完 unit 文件 | systemctl daemon-reload |
| 依赖关系 | systemctl list-dependencies [--reverse] myapp |
| 失败的服务 | systemctl --failed |
| 清除失败计数 | systemctl reset-failed myapp |
| 所有定时器 | systemctl list-timers --all |
| 发信号 | systemctl kill -s SIGQUIT myapp |
| 启动耗时排名 | systemd-analyze blame |
| 语法检查 | systemd-analyze verify /path/x.service |
| 安全评分 | systemd-analyze security myapp |
| 验证 OnCalendar | systemd-analyze calendar '*-*-* 02:30' |
| 按 cgroup 看资源 | systemd-cgtop / systemctl status |
| 临时带限制跑命令 | systemd-run --scope -p MemoryMax=1G cmd |
| 一次性定时任务 | systemd-run --on-active=2h cmd |
10.2 journalctl 速查
journalctl -u myapp -f # 跟随某服务
journalctl -u myapp -n 100 --no-pager # 最近 100 行
journalctl -u myapp --since '1h ago' -p err # 一小时内的错误
journalctl -u myapp --grep 'timeout' # 内置正则过滤
journalctl -b -1 -u myapp # 【上次启动】的日志(查崩溃原因)
journalctl -k -p err # 内核错误
journalctl -u myapp -o cat | jq . # 结构化日志给 jq
journalctl --disk-usage # 占用
sudo journalctl --vacuum-time=7d # 清理
10.3 unit 字段速查(按用途)
| 用途 | 字段 |
|---|---|
| 依赖 | Wants=(弱,推荐)Requires=(强)After=/Before=(顺序) |
| 身份 | User= Group= WorkingDirectory= |
| 启动 | Type= ExecStartPre= ExecStart= ExecStartPost= |
| 重载/停止 | ExecReload= ExecStop= KillSignal= KillMode= TimeoutStopSec= |
| 重启 | Restart=on-failure RestartSec= StartLimitBurst= RestartPreventExitStatus= |
| 环境 | Environment= EnvironmentFile=-/path |
| 目录 | RuntimeDirectory= StateDirectory= CacheDirectory= LogsDirectory= ConfigurationDirectory= |
| 日志 | StandardOutput=journal StandardError= SyslogIdentifier= |
| 限制 | LimitNOFILE= MemoryMax= MemoryHigh= CPUQuota= TasksMax= OOMScoreAdjust= |
| 加固 | NoNewPrivileges= ProtectSystem=strict PrivateTmp= SystemCallFilter= ReadWritePaths= CapabilityBoundingSet= |
| 就绪/健康 | Type=notify NotifyAccess= WatchdogSec= |
| 告警 | OnFailure= |
10.4 常用占位符
%n 完整 unit 名(myapp.service)
%N unit 名(不含后缀,myapp)
%p 实例化 unit 的前缀部分
%i 实例化 unit 的参数部分(myapp@prod.service 里的 prod)
%I 同 %i 但反转转义
%H 主机名
%u User= 指定的用户名
%U 对应的 uid
%h 用户家目录
%t 运行时目录(/run 或 /run/user/UID)
%S 状态目录(/var/lib)
%C 缓存目录(/var/cache)
%L 日志目录(/var/log)
%E 配置目录(/etc)
%% 字面的百分号
模板 unit(一份配置跑多个实例):
# /etc/systemd/system/myapp@.service <- 注意文件名里的 @
[Service]
ExecStart=/usr/local/bin/myapp --env %i --port 80%i
EnvironmentFile=/etc/myapp/%i.env
StateDirectory=myapp-%i
sudo systemctl enable --now myapp@prod.service # %i = prod
sudo systemctl enable --now myapp@staging.service # %i = staging
systemctl status 'myapp@*'
11. 面试题
Q:Type=simple、forking、notify 有什么区别?选错会怎样?
Type= 决定 systemd 如何判断「服务已启动成功」:
simple(默认):ExecStart的进程一被 fork 出来就认为成功,不等它真正就绪forking:等父进程退出(传统 daemon 的 double-fork 模式),通常配PIDFile=notify:等进程主动通过sd_notify发送READY=1,最准确
选错的两种典型后果:
- 程序自己 fork 到后台却写了
Type=simple—— systemd 看到「主进程」退出,判定服务挂了,立即报 failed 并按策略重启,形成无限重启循环。修法是改Type=forking+PIDFile=,但更好的做法是让程序前台运行(nginx: daemon off、php-fpm --nodaemonize,Go 服务天然前台),把进程管理完全交给 systemd - 服务需要 10 秒预热却用
simple—— systemd 一 fork 就认为就绪,After=依赖它的服务立即启动然后连不上。要用Type=notify让应用在真正 ready(数据库连上、端口开始监听)后才上报
Q:systemctl enable 和 start 有什么区别?static 状态是什么意思?
start 是立即启动(重启后不会自启),enable 是设置开机自启(现在不启动)。合起来是 enable --now。
enable 的本质是创建一个软链接:把 unit 链接到 [Install] 段里 WantedBy= 指定的那个 target 的 .wants/ 目录下。所以:
static= unit 没有[Install]段,systemd 不知道该把链接放哪,因此无法 enable。这类 unit 通常是被别的 unit 依赖时才启动masked= 被软链接到/dev/null,任何方式都无法启动。它比disable更彻底 ——disable只是不自动启动,但依赖它的服务仍可能拉起它
Q:为什么改了 unit 文件必须 daemon-reload?
因为 systemd 在启动时把所有 unit 文件解析成内存中的对象,之后一直使用内存里的版本,不会去检查磁盘文件是否变化。daemon-reload 让它重新扫描并解析。
记忆规则:改 unit 文件 → daemon-reload + restart;改服务自己的配置文件(如 nginx.conf)→ 只需 systemctl reload;升级了 systemd 本身 → daemon-reexec。
顺带一个相关的最佳实践:不要直接编辑 /usr/lib/systemd/system/ 下的文件(软件包升级会覆盖)。用 systemctl edit myapp 创建 drop-in 片段,它会生成 /etc/systemd/system/myapp.service.d/override.conf。注意列表型字段要先用空值清空再赋值(ExecStart= 然后 ExecStart=/new/path),否则会变成追加而报错。
Q:Requires= 和 After= 有什么区别?
它们是两个正交的概念:Requires= 表达依赖关系(对方失败我也失败、对方停止我也停止),After= 表达启动顺序(在对方之后启动)。
只写 Requires=postgresql.service 不写 After= 的话,两者会同时启动,你的服务可能在数据库就绪前就开始连接。所以依赖和顺序通常要一起写。
生产上更推荐 Wants= + After= 的组合:Wants 是弱依赖,数据库暂时不可用时服务仍会启动,靠应用自己重试 —— 这比整个服务栈连锁失败更健壮。
另一个高频错误是混淆 network.target 和 network-online.target:前者只表示网络栈已初始化(不保证有 IP),需要联网的服务必须用后者,并确保对应的 *-wait-online.service 已启用。
Q:脚本手动跑正常,放进 cron 就失败,为什么?
因为 cron 提供的执行环境极其精简。实测 * * * * * env > /tmp/e.txt 会发现只有几个变量:
PATH=/usr/bin:/bin—— 只有两个目录,/usr/local/bin里的命令全部command not foundSHELL=/bin/sh—— 不是 bash!所以[[ ]]、数组、进程替换<()、$'...'全部失效- 不读
.bashrc/.profile—— 你配的环境变量、代理、语言设置全都没有 - 没有
LANG/LC_ALL—— 中文乱码、日期格式和sort顺序都可能变 - cwd 是家目录 —— 相对路径全错
对策:命令用绝对路径、在 crontab 顶部声明 SHELL=/bin/bash 和 PATH=、显式 cd、把复杂逻辑放进脚本而不是写在 crontab 一行里。
调试的关键是先让输出可见(>> /var/log/x.log 2>&1 或 | logger -t myjob),因为 cron 默认把输出发邮件而机器通常没配邮件系统。用 env -i SHELL=/bin/sh PATH=/usr/bin:/bin /bin/sh -c '脚本' 可以在本地模拟 cron 的最小环境。还要看 journalctl -u cron 确认任务到底有没有被触发。
Q:systemd timer 相比 cron 有什么优势?
最决定性的三点是可观测性:
- 日志自动进 journald ——
journalctl -u backup.service直接看,不用自己重定向 - 失败可告警 ——
OnFailure=alert@%n.service,cron 只能发邮件 systemctl list-timers直接看到下次执行时间,不用自己算 cron 表达式
其他优势:执行环境明确(Environment=/EnvironmentFile=,不会有 cron 的 PATH 坑)、Persistent=true 补跑关机期间错过的任务、RandomizedDelaySec 避免集群同时打爆存储、OnUnitActiveSec 天然防任务重叠(cron 需要自己加 flock)、资源限制与沙箱(MemoryMax/Nice/IOSchedulingClass/ProtectSystem)、systemd-analyze calendar 能验证表达式(cron 写错要等执行时才知道)。
代价是要写两个文件(.timer + .service)且只能在 systemd 系统上用。结论:生产任务用 timer,临时/简单的用 cron。
Q:cron 里 */5 * * * * 的任务可能会重叠执行,怎么防?
cron 只负责「到点触发」,不检查上一次是否还在运行。如果任务偶尔跑超过 5 分钟,就会出现多个实例并发,可能导致数据竞争、资源耗尽或重复处理。
三种防护:
*/5 * * * * flock -n /tmp/myjob.lock /path/script.sh # ✅ 拿不到锁就跳过
*/5 * * * * timeout 240 flock -w 10 /tmp/x.lock /path/script.sh # 锁 + 超时
systemd timer 用 OnUnitActiveSec=5min 从根本上避免 —— 它的语义是「上次跑完之后再等 5 分钟」,而且同一个 unit 本身不会并发启动。
cron 的其他经典坑:% 必须转义(cron 里 % 表示换行,date +%Y 会被截断)、crontab -r 无确认地删除全部(改前先 crontab -l > backup)、夏令时切换那天 0:00~4:00 的任务可能不执行或执行两次。
Q:怎么让 systemd 准确知道 Go 服务已经就绪、以及是否卡死?
用 Type=notify + sd_notify:应用在完成所有初始化(连数据库、加载配置、开始监听端口)之后,发送 READY=1。这样 systemd 的 After= 依赖才是真实可靠的,systemctl start 也会阻塞到真正就绪。
卡死检测用 WatchdogSec=30 + 应用定期发送 WATCHDOG=1(建议按间隔的一半发)。超时未收到心跳,systemd 判定服务卡死并按 Restart= 策略重启。关键是心跳里要做真实的健康检查(能否访问数据库、队列是否积压),而不是无脑发送 —— 否则死锁的服务照样能发心跳。
协议本身很简单(往 $NOTIFY_SOCKET 这个 unix datagram socket 写一行文本),不想引依赖可以手写十几行。还能发 STATUS=当前处理 1234 个请求,这段文字会显示在 systemctl status 里,对运维很友好。
小结
- unit 是统一抽象(service/timer/socket/mount/target/slice),
/etc/systemd/system/优先级最高,不要改/usr/lib/下的文件 enable只是创建软链接到 target 的.wants/;static是没有[Install]段,mask是链到/dev/nullRequires管依赖、After管顺序,两个正交概念;生产上推荐Wants+After的弱依赖组合- 改 unit 文件必须
daemon-reload(systemd 用的是内存里解析好的版本) Type选错会导致无限重启或依赖失效:程序自己 fork 要用forking,最好的做法是让程序前台运行 +Type=notify- drop-in(
systemctl edit)是唯一正确的覆盖方式,列表型字段要先清空 journalctl -b -1 -u x看上次启动的日志是查崩溃原因的关键;journald 有限流会静默丢日志- timer 相比 cron 的核心优势是可观测性:日志进 journald、
OnFailure=告警、list-timers看下次执行 - cron 失败的头号原因是环境(PATH 只有两个目录、SHELL 是 sh、不读 profile),调试第一步是让输出可见
LimitNOFILE要写在 unit 里(limits.conf对 systemd 服务无效)
下一篇讲 网络操作与排查:ip 命令替代 ifconfig 之后各子命令怎么用、ss 的状态过滤、curl -w 精确定位耗时在哪一段、DNS 解析的完整链路(为什么容器里解析行为不同)、以及 tcpdump 抓包的基本套路。
xingliuhua