目录

Linux-14 systemd 与定时任务:写一个生产级 unit、Type 怎么选、cron 的经典坑

上一篇讲了进程,这一篇讲怎么把进程变成服务。systemd 的设计之争(为什么它赢了、为什么它被骂)留给第 37 篇,这里只讲实操。

先看五个问题:

  1. Type=simpleforkingnotify 该选哪个?选错会发生什么?
  2. systemctl enablesystemctl start 有什么区别?
  3. 为什么改了 unit 文件必须 daemon-reload
  4. 脚本手动跑正常,放进 cron 就失败,为什么?
  5. 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+)

最容易搞错的一点:RequiresAfter 是两个正交的概念。

# ❌ 只写 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
日志 需自己重定向,或靠邮件 自动进 journaldjournalctl -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

三个价值

  1. 消除启动顺序依赖 —— 端口一直在监听(由 systemd 持有),依赖它的服务可以随时连接,连接会被缓冲直到服务就绪
  2. 零停机重启 —— 重启服务时端口不关闭,客户端连接只是稍等而不是被拒绝
  3. 按需启动 —— 不常用的服务不必常驻内存

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=simpleforkingnotify 有什么区别?选错会怎样?

Type= 决定 systemd 如何判断「服务已启动成功」

  • simple(默认):ExecStart 的进程一被 fork 出来就认为成功,不等它真正就绪
  • forking:等父进程退出(传统 daemon 的 double-fork 模式),通常配 PIDFile=
  • notify:等进程主动通过 sd_notify 发送 READY=1最准确

选错的两种典型后果:

  1. 程序自己 fork 到后台却写了 Type=simple —— systemd 看到「主进程」退出,判定服务挂了,立即报 failed 并按策略重启,形成无限重启循环。修法是改 Type=forking + PIDFile=,但更好的做法是让程序前台运行nginx: daemon offphp-fpm --nodaemonize,Go 服务天然前台),把进程管理完全交给 systemd
  2. 服务需要 10 秒预热却用 simple —— systemd 一 fork 就认为就绪,After= 依赖它的服务立即启动然后连不上。要用 Type=notify 让应用在真正 ready(数据库连上、端口开始监听)后才上报

Q:systemctl enablestart 有什么区别?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.targetnetwork-online.target:前者只表示网络栈已初始化(不保证有 IP),需要联网的服务必须用后者,并确保对应的 *-wait-online.service 已启用。

Q:脚本手动跑正常,放进 cron 就失败,为什么?

因为 cron 提供的执行环境极其精简。实测 * * * * * env > /tmp/e.txt 会发现只有几个变量:

  • PATH=/usr/bin:/bin —— 只有两个目录,/usr/local/bin 里的命令全部 command not found
  • SHELL=/bin/sh —— 不是 bash!所以 [[ ]]、数组、进程替换 <()$'...' 全部失效
  • 不读 .bashrc/.profile —— 你配的环境变量、代理、语言设置全都没有
  • 没有 LANG/LC_ALL —— 中文乱码、日期格式和 sort 顺序都可能变
  • cwd 是家目录 —— 相对路径全错

对策:命令用绝对路径、在 crontab 顶部声明 SHELL=/bin/bashPATH=、显式 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 有什么优势?

最决定性的三点是可观测性

  1. 日志自动进 journald —— journalctl -u backup.service 直接看,不用自己重定向
  2. 失败可告警 —— OnFailure=alert@%n.service,cron 只能发邮件
  3. 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/null
  • Requires 管依赖、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 抓包的基本套路。