Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

0.12 systemd、启动、日志与定时任务

本章回答

开机从按下电源到登录提示符,中间经过哪几段?After/Requires/Wants 到底谁保证什么?journalctl 怎么在几百条日志里定位一次失败?cron 和 systemd timer 该怎么选?

读完你会

用 systemd-analyze 找出启动慢在哪一段;把一个脚本写成带沙箱、超时、自动重启的 service;用 timer 替代 crontab 并保留日志;在 fstab/unit 写坏之后进 rescue 模式修回来。

0.11 把程序装进了文件系统,但装完还不会开机自启、失败重启、留下可查的日志。这些是 init 系统的职责。Ubuntu 24.04 用 systemd 255:PID 1 负责启动服务、挂载文件系统、收集日志、管理定时器,还顺带接管了 /etc/fstab 和 cron 的一部分工作。

systemd 的批评通常集中在「管得太宽」。但从排障角度,它带来的好处是统一的观测面:每个 unit 有状态、有依赖、有日志、有启动耗时,可以用同一组命令查。代价是要理解 unit 的依赖模型——After 只管顺序,Requires 才管「它失败我也失败」,两者经常被混为一谈。

这一章从开机链路开始,落到一个完整的 backup.service + backup.timer,再讲日志和定时任务的取舍。读完你应该能把 0.8 的备份脚本变成开机后可调度、失败可追踪、日志可轮转的服务,而不是在 crontab 里塞一行了事。

0.12.1 开机链路:从固件到 target

Linux 启动流程:固件、引导、内核、initramfs、systemd 七个阶段与两个关键分界:内核交棒给用户态、systemd 达到 target。 从按下电源到登录提示符UEFI/BIOSPOST → 找 ESPbootloaderGRUB / systemd-bootkernel解压 / 初始化 / initcallinitramfs加载驱动 / 挂根initsystemd PID 1targetmulti-user / graphicallogingetty / sshd内核阶段的关键节点start_kernel → rest_init → kernel_init → run_init_process用户态阶段的关键节点systemd → 并行启动 unit → 达到 default.target观测:dmesg -T(内核)、journalctl -b(systemd)、systemd-analyze blame / critical-chain故障:initramfs 缺驱动 → 卡在「Waiting for root device」;unit 依赖环 → 启动超时
读图:从左到右读。前两段是固件与引导器,第三段内核初始化,第四段 initramfs 负责「挂上真正的根」,第五段起是用户态。排启动问题先确定卡在哪一段:dmesg 有输出说明已进内核;journalctl -b 有记录说明 systemd 已接管。

读图:从左到右七段,两个分界最关键。第一个是 kernel 把控制权交给用户态:在这之前出问题,dmesg 有输出、journalctl 没有;在这之后出问题,journalctl -b 才有记录。第二个是 initramfs 挂上真正的根:卡在 Waiting for root device 说明 initramfs 缺驱动或根设备 UUID 不对。排启动问题先确定卡在哪一段,再选工具,不要一上来就翻 journalctl。

Ubuntu 的启动时间可以拆开看:

$ systemd-analyze
Startup finished in 4.312s (firmware) + 2.104s (loader) + 3.821s (kernel) + 8.442s (userspace) = 18.679s
graphical.target reached after 8.401s in userspace.
$ systemd-analyze blame | head -4
5.212s apt-daily.service
2.104s NetworkManager-wait-online.service
1.842s snapd.seeded.service
 921ms systemd-udev-settle.service
$ systemctl get-default
graphical.target
$ systemctl list-units --type=target --state=active --no-pager | head -5
UNIT                   LOAD   ACTIVE SUB     DESCRIPTION
basic.target           loaded active active Basic System
multi-user.target      loaded active active Multi-User System
graphical.target       loaded active active Graphical Interface

systemd-analyze 的四段是固件、引导器、内核、用户态。前三段由硬件和内核决定,能优化的主要是用户态。blame 按耗时倒序列出 unit,但它是串行视角:一个 5 秒的服务如果和其他服务并行,不一定拖慢启动。看关键路径用 systemd-analyze critical-chain,它从 target 反推依赖链,只显示真正阻塞的那条。

systemctl get-default 返回默认 target。multi-user.target 对应旧 runlevel 3(多用户无图形),graphical.target 对应 runlevel 5。服务器通常设成 multi-user.target:

sudo systemctl set-default multi-user.target
# 临时切换,不重启
sudo systemctl isolate multi-user.target

isolate 会停掉不属于目标 target 的 unit,不要在远程生产机上随手执行:它可能停掉你的 SSH 会话依赖的服务。切 target 前先 systemctl list-dependencies multi-user.target 看清楚会启停什么。

0.12.2 unit 类型与依赖模型

systemd 把「要管理的东西」统一成 unit。日常接触最多的是这几种:

类型管什么例子常用命令
.service进程ssh.service、backup.servicestart/stop/status
.socket监听套接字,按连接拉起服务docker.socketlist-sockets
.target一组 unit 的集合multi-user.targetisolate、get-default
.timer定时触发另一个 unitbackup.timerlist-timers
.mount挂载点data.mountfindmnt、mount
.path文件/目录变化触发cups.pathstatus
.slicecgroup 分组system.slicesystemd-cgls

unit 文件放在三个地方,优先级从高到低:/etc/systemd/system/(管理员)、/run/systemd/system/(运行时)、/usr/lib/systemd/system/(包安装)。不要直接改 /usr/lib 下的文件,包升级会覆盖;用 systemctl edit foo.service 生成 drop-in,写到 /etc/systemd/system/foo.service.d/override.conf。

依赖关系分两类,必须分开记:

  • 顺序:After=/Before= 只影响「谁先启动」,不保证对方存在。After=network.target 表示「网络 target 启动后再启动我」,网络失败我也照启动。
  • 需求:Requires= 表示「对方启动失败,我也失败」;Wants= 表示「尽量拉起对方,失败不影响我」;BindsTo= 更紧,对方停止我也停止;PartOf= 表示「对方重启/停止时带上我」。

一个常见错误是只写 After=postgresql.service 就以为数据库一定可用。正确写法通常是 Requires=postgresql.service 加 After=postgresql.service,或者在应用里做重试。After 不创建依赖,只排序。

systemctl cat ssh.service              # 看 unit 内容与 drop-in
systemctl show ssh.service -p After -p Requires -p Wants
systemctl list-dependencies ssh.service --reverse
systemctl edit --full backup.service   # 复制一份到 /etc 后编辑
systemctl daemon-reload                # 手改 unit 后必须执行

systemctl edit 默认创建 drop-in 并自动 daemon-reload;直接改 /etc/systemd/system/*.service 之后要手动 daemon-reload,否则 systemd 仍用旧配置。daemon-reload 不会重启已经运行的服务,改 ExecStart 后还要 restart。

0.12.3 把脚本变成 service:完整 unit

以 0.8 的 backup.sh 为例。目标:每天 02:30 跑一次,以专用用户运行,限制它能写的目录,超时 2 小时,失败留日志。先建用户和目录:

sudo useradd --system --home /var/lib/backup --shell /usr/sbin/nologin backup
sudo install -d -o backup -g backup -m 0750 /var/lib/backup /srv/backup
sudo install -m 0755 backup.sh /usr/local/libexec/backup.sh

service unit 全文:

[Unit]
Description=Nightly backup of /srv/data
ConditionPathIsDirectory=/srv/data
RequiresMountsFor=/srv/data /srv/backup
After=local-fs.target

[Service]
Type=oneshot
User=backup
Group=backup
StateDirectory=backup
Environment=STATE_DIR=/var/lib/backup
EnvironmentFile=-/etc/backup.env
ExecStart=/usr/local/libexec/backup.sh -s /srv/data -d /srv/backup -k 14
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
TimeoutStartSec=2h
Restart=on-failure
RestartSec=5min
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/backup
NoNewPrivileges=true
LimitNOFILE=65536

逐段解释:

  • Type=oneshot:进程跑完就退出,没有常驻 PID。备份、迁移、清理这类任务用它;常驻服务用 Type=simple(默认)或 Type=notify。
  • ConditionPathIsDirectory=/srv/data:目录不存在时 unit 直接跳过,记为 condition failed,不算失败。适合「盘没挂上就别备份」。
  • RequiresMountsFor=:把 /srv/data、/srv/backup 所在的挂载 unit 拉成依赖,挂载失败就不启动。比手写 After=local-fs.target 精确。
  • StateDirectory=backup:自动创建 /var/lib/backup 并 chown 给 User=,重启后保留。脚本的 STATE_DIR 指向它,锁和日志都放这里。
  • EnvironmentFile=-/etc/backup.env:前面的 - 表示文件不存在也不报错;文件里写 KEY=VALUE,不要 export。
  • Restart=on-failure + RestartSec=5min:失败后 5 分钟重试。Type=oneshot 不允许 Restart=always,on-failure 是允许的;但定时任务的主要重试机制应该是下一次 timer,不是无限重启。
  • TimeoutStartSec=2h:超过 2 小时 systemd 发 SIGTERM,宽限期后 SIGKILL。备份大目录时按实际耗时设,不要用默认 90 秒。
  • ProtectSystem=strict:整个文件系统只读,只有 ReadWritePaths= 列出的路径可写;/srv/data 只读访问没问题,/srv/backup 显式放开。
  • ProtectHome=true:隐藏 /home、/root、/run/user,减少误读用户数据。
  • PrivateTmp=true:给服务一个私有 /tmp,防止和其他服务抢名字。
  • NoNewPrivileges=true:禁止 setuid/setcap 提权,配合 User= 降低逃逸后的影响。
  • LimitNOFILE=65536:备份大量文件时 fd 可能不够,按需调。

timer unit 全文:

[Unit]
Description=Daily backup timer

[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=15m
AccuracySec=1m
Unit=backup.service

[Install]
WantedBy=timers.target

OnCalendar 的格式接近 cron 但更严格:*-*-* 02:30:00 表示每天 02:30。systemd-analyze calendar '*-*-* 02:30:00' 能验证表达式并给出下一次触发时间。Persistent=true 表示「关机期间错过的任务,开机后补跑一次」;没有它,停机就跳过。RandomizedDelaySec=15m 把触发时间打散,避免所有机器同一秒打爆备份存储。AccuracySec=1m 允许 systemd 合并唤醒,省电。

安装与验证:

sudo install -m 0644 backup.service /etc/systemd/system/backup.service
sudo install -m 0644 backup.timer /etc/systemd/system/backup.timer
sudo systemd-analyze verify /etc/systemd/system/backup.service
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
systemctl list-timers backup.timer --no-pager
sudo systemctl start backup.service
systemctl status backup.service --no-pager
journalctl -u backup.service -n 20 --no-pager
$ systemctl list-timers backup.timer --no-pager
NEXT                        LEFT     LAST  PASSED UNIT         ACTIVATES
Mon 2025-03-03 02:30:00 UTC 16h left n/a   n/a    backup.timer backup.service
$ sudo systemctl start backup.service
$ systemctl status backup.service --no-pager
* backup.service - Nightly backup of /srv/data
     Loaded: loaded (/etc/systemd/system/backup.service; static)
     Active: inactive (dead) since Sun 2025-03-02 10:41:12 UTC; 2s ago
TriggeredBy: * backup.timer
    Process: 2411 ExecStart=/usr/local/libexec/backup.sh -s /srv/data -d /srv/backup -k 14 (code=exited, status=0/SUCCESS)
   Main PID: 2411 (code=exited, status=0/SUCCESS)
$ journalctl -u backup.service -n 3 --no-pager
Mar 02 10:41:10 lab systemd[1]: Starting Nightly backup of /srv/data...
Mar 02 10:41:12 lab backup.sh[2411]: 2025-03-02 10:41:10 INFO  start src=/srv/data dest=/srv/backup keep=14d dry_run=0
Mar 02 10:41:12 lab systemd[1]: Finished Nightly backup of /srv/data.

status 里 Active: inactive (dead) 对 Type=oneshot 是正常的:跑完就退出,没有常驻进程。Process 行的 status=0/SUCCESS 才是结果。TriggeredBy 说明它由 timer 拉起。journalctl -u 把 systemd 自己的启动/结束日志和脚本输出放在同一条时间线上,这是比 crontab 邮件更好的观测方式。

如果 status 显示 failed,先看 journalctl -u backup.service -b,再看 systemctl show backup.service -p Result -p ExecMainStatus。Result=exit-code 表示脚本返回非零;Result=timeout 表示超过 TimeoutStartSec;Result=oom-kill 表示被 cgroup/OOM killer 杀掉;Result=resources 不是 systemd 的 OOM 结果。

0.12.4 journalctl:查日志的顺序

日志的采集者是 systemd-journald.service(systemctl status systemd-journald 可看它的状态与内存/磁盘占用);journalctl 只是读它的客户端。journald 把日志写到 /run/log/journal(易失)或 /var/log/journal(持久,需 Storage=persistent)。

systemd-journald 收集内核日志、服务 stdout/stderr、syslog 兼容消息,默认写到 /run/log/journal(内存,重启丢失)。Ubuntu 默认 Storage=auto:如果 /var/log/journal 存在就持久化,不存在就只留内存。要看历史日志,先确认目录存在:

$ journalctl --disk-usage
Archived and active journals take up 128.0M in the file system.
$ journalctl -b -p err --no-pager | tail -3
Mar 02 09:14:02 lab kernel: pcieport 0000:00:1c.0: AER: Corrected error received
Mar 02 09:20:11 lab systemd[1]: Failed to start backup.service.
$ journalctl -u backup.service -b --no-pager | tail -4
Mar 02 02:30:01 lab systemd[1]: Starting Nightly backup of /srv/data...
Mar 02 02:30:03 lab backup.sh[1988]: 2025-03-02 02:30:01 ERROR cannot create /srv/backup
Mar 02 02:30:03 lab systemd[1]: backup.service: Main process exited, code=exited, status=1/FAILURE
Mar 02 02:30:03 lab systemd[1]: Failed to start Nightly backup of /srv/data.

常用组合:

目的命令
本次启动的全部日志journalctl -b
上一次启动journalctl -b -1
某个服务journalctl -u backup.service
错误及以上journalctl -p err -b
某时间段journalctl --since '2025-03-02 02:00' --until '03:00'
实时跟踪journalctl -f -u backup.service
内核日志journalctl -k -b
按 PID/命令过滤journalctl _PID=2411、journalctl _COMM=backup.sh
按内容 grepjournalctl -g 'cannot create' -b

优先级从 0 到 7:emerg、alert、crit、err、warning、notice、info、debug。-p err 表示 err 及以上。-o json-pretty 输出结构化字段,适合配合 jq 做统计;-o short-precise 带微秒,适合对齐多服务时间线。

日志占满磁盘时,先看 --disk-usage,再按时间或大小清理:

sudo journalctl --vacuum-time=2weeks
sudo journalctl --vacuum-size=500M

持久化日志的配置在 /etc/systemd/journald.conf:

[Journal]
Storage=persistent
SystemMaxUse=2G
SystemMaxFileSize=200M

改完 sudo systemctl restart systemd-journald。Storage=persistent 会创建 /var/log/journal,重启后日志还在。注意 rsyslog 可能同时存在,/var/log/syslog 和 journal 是两份数据,查的时候别只看一处。

0.12.4.1 谁登录过:last、who 与 utmp 的现状

journalctl 能查服务日志,但「谁在什么时候登录、从哪个 IP、登录了多久」不在 journal 的主路径里。传统做法是三个文件:

文件内容查看命令
/var/run/utmp当前登录会话who、w
/var/log/wtmp登录/注销历史last
/var/log/btmp失败登录lastb(需 root)
/var/log/lastlog每个用户最后一次登录lastlog

记录结构由 struct utmp/utmpx 定义;编程接口是 getutent()/getutxent() 系列(<utmpx.h>),按时间顺序读 wtmp。写侧由 login、sshd、agetty 调用 updwtmp()/pututxline() 完成。

今天仍然有用,但要知道边界:

  • 容器里通常没有 utmp/wtmp,who 为空;ssh 进容器也不一定写。
  • systemd 系统上 utmp 仍由 systemd-update-utmp.service 维护,但很多发行版在讨论移除;loginctl/journalctl 是更可靠的会话来源。
  • last 的数据可被 root 篡改,不能当审计证据;合规审计用 auditd 或集中日志。
last -n 5                 # 最近 5 次登录
lastb -n 5                # 最近失败登录
who -u                    # 当前会话 + 空闲时间
loginctl list-sessions    # systemd 的会话视图

如果只需要「谁登录过」,优先 last/loginctl;要长期审计,转发到集中日志,别依赖本地 wtmp。

0.12.5 cron 与 systemd timer

cron 仍然可用,语法短、到处都有;systemd timer 的优势是日志、依赖、随机延迟、错过后补跑。两者不是宗教选择,按任务性质选:

维度cronsystemd timer
配置crontab -e、/etc/cron.d.timer + .service
日志邮件/重定向,默认没有journalctl -u 自动收
依赖无After/Requires/RequiresMountsFor
错过补跑无(anacron 另装)Persistent=true
随机延迟自己 sleep $RANDOMRandomizedDelaySec=
环境极简,PATH 常出问题Environment=/EnvironmentFile=
资源限制无cgroup、Nice、LimitNOFILE
精度分钟秒/微秒(受 AccuracySec 影响)

crontab 的五个时间字段是「分 时 日 月 周」,后面跟命令。它有几个坑:

SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=ops@example.com
30 2 * * * /usr/local/libexec/backup.sh -s /srv/data -d /srv/backup >>/var/log/backup.log 2>&1
  • 环境极简:cron 不读 .bashrc,PATH 通常只有 /usr/bin:/bin,脚本里用相对命令会找不到。显式设 PATH,或用绝对路径。
  • % 有特殊含义:crontab 里未转义的 % 表示换行,date +\%F 要写成 \%;这也是为什么日志文件名里的日期在 crontab 里经常出错。
  • /etc/cron.d 多一个用户字段:30 2 * * * backup /path/script;crontab -e 没有这个字段,别混。
  • run-parts 忽略带点的文件:/etc/cron.daily/backup.sh 不会执行,改成 backup 并加可执行位。
  • 邮件是默认输出通道:没有 MTA 时输出丢失;显式重定向到文件或 logger。

systemd timer 的写法在 0.12.3 已经给全。临时跑一个定时任务可以用 systemd-run,不必写 unit:

sudo systemd-run --on-calendar='*-*-* 03:00:00' --unit=oneoff-backup /usr/local/libexec/backup.sh -s /srv/data -d /srv/backup
systemctl list-timers oneoff-backup.timer

at 仍然适合「一小时后跑一次」的一次性任务(echo cmd | at now + 1 hour),但它的日志和依赖管理比 timer 弱;batch 在系统负载低时跑,适合不紧急的批处理。anacron 解决的是「笔记本关机错过 cron」的问题,服务器上用 Persistent=true 更直接。

0.12.6 logrotate:日志轮转

journal 有自己的 vacuum,普通文本日志(脚本重定向、nginx、应用日志)交给 logrotate。配置放在 /etc/logrotate.d/:

/var/log/backup.log {
    weekly
    rotate 8
    compress
    delaycompress
    missingok
    notifempty
    create 0640 backup adm
}

weekly/rotate 8 保留 8 周;compress 压缩旧文件,delaycompress 让最近一份不压缩(有些程序还在写);missingok 文件不存在不报错;notifempty 空文件不轮转;create 用指定权限和属主重建。轮转后文件名变成 backup.log.1、backup.log.2.gz。

如果服务一直持有日志 fd,轮转后它仍写旧文件。两种处理:

  • copytruncate:先复制再清空原文件,服务不用重开;缺点是复制和清空之间可能丢日志。
  • postrotate + 重开:让服务重新打开日志文件,nginx 用 kill -USR1,rsyslog 用 HUP。
/var/log/nginx/access.log {
    daily
    rotate 14
    compress
    missingok
    notifempty
    sharedscripts
    postrotate
        systemctl reload nginx >/dev/null 2>&1 || true
    endscript
}

sharedscripts 让多个日志文件只跑一次 postrotate。改完配置先 dry-run:

sudo logrotate -d /etc/logrotate.conf
sudo logrotate -f /etc/logrotate.d/backup

-d 只打印会做什么,不改文件;-f 强制立即轮转,适合测试。Ubuntu 用 logrotate.timer 每天触发,systemctl list-timers logrotate.timer 可以确认。

0.12.7 启动故障排查的顺序

启动失败分三类:进不了系统、进了系统但服务没起来、服务起来了但行为不对。三类查法不同:

现象第一条命令证据
卡在固件/引导看屏幕、efibootmgr -v引导项、ESP 分区
卡在 Waiting for root devicedmesg、lsblkinitramfs 缺驱动、根 UUID 错
进 emergency/rescuesystemctl --failed、journalctl -b -1哪个 unit 失败
服务没起来systemctl status foo、journalctl -u foo -bResult、退出码
服务启动慢systemd-analyze critical-chain foo.service关键路径上的依赖
依赖成环`journalctl -bgrep -i ‘ordering cycle’`

常用救援动作:

systemctl --failed                       # 列出失败的 unit
systemctl status backup.service          # 状态、退出码、最近日志
journalctl -b -1 -u backup.service       # 上一次启动的日志
systemctl list-jobs                      # 有没有卡住的 job
sudo systemctl rescue                    # 切到单用户救援
sudo systemctl emergency                 # 更小的 emergency

内核参数 systemd.unit=rescue.target 可以在启动时直接进救援;rd.break 在 initramfs 里断住,适合修根文件系统或 fstab。改完 fstab 或 unit 后,systemctl daemon-reload 是必须的;如果是 /etc/fstab,systemd 会重新生成 .mount unit,findmnt --verify 先验证再重启。

systemctl disable 只是去掉开机符号链接,unit 仍可被依赖拉起;systemctl mask 把 unit 链接到 /dev/null,任何方式都启动不了。排查「谁把服务拉起来了」时,systemctl list-dependencies --reverse foo.service 能看到反向依赖。

systemd 与定时的六个误解
  1. 「systemctl enable 会立刻启动服务」——它只创建开机符号链接;要立刻启动用 enable --now 或 start。
  2. 「After= 表示依赖」——它只排序,不保证对方成功;要依赖用 Requires=/Wants= 再加 After=。
  3. 「改了 unit 文件不用做什么」——手动改 /etc/systemd/system/*.service 后必须 daemon-reload,否则 systemd 仍用旧配置。
  4. 「Type=oneshot 也能 Restart=always」——不允许;oneshot 只能用 Restart=on-failure,而且定时任务的主要重试应该是下一次 timer。
  5. 「cron 和 systemd timer 的环境一样」——cron 环境极简、不读 .bashrc;timer 可以用 Environment=/EnvironmentFile=,但也不会自动继承登录 shell 的环境。
  6. 「journalctl 默认永久保存」——Ubuntu 默认 Storage=auto,/var/log/journal 不存在时日志只在内存,重启就没了;要持久化得显式配置。

0.12.8 依赖语义:排序、需求与传播

0.12.2 区分了 After 与 Requires,这一节把剩下的依赖关键字补齐。它们只做三件事:排序(谁先启动)、拉取(是否把对方拉起来)、传播(对方停止/失败时我怎么办)。

关键字排序拉取失败/停止传播典型用法
After=是否无仅表达「等我之前」
Before=是否无反向排序
Wants=否是(弱)无可选依赖
Requires=否是(强)对方启动失败→我不启动;对方停止→我停止强依赖
Requisite=否否对方未启动→我失败只检查不拉起
BindsTo=否是对方消失/失败→我立即停绑定设备/网络
PartOf=否否对方重启/停止→我也重启/停止一组服务的联动
Conflicts=否否启动我→停止对方互斥服务

四条最容易记错的规则:

  1. Requires= 不带排序。Requires=db.service 只保证 db 被拉起,不保证它先于你启动;要顺序就再写 After=db.service。两者都写才是通常想要的「先起 db,再起我」。
  2. Requires= 的「停止传播」只在同一事务里可靠。对方后来被手动停掉,依赖方是否停,取决于事务与 BindsTo=;需要「对方一没我就停」用 BindsTo=。
  3. Wants= 失败不算我失败。它是「尽力而为」,适合网络、监控、缓存这类可缺的依赖。
  4. PartOf= 不拉取、不传播启动。它只把「停止/重启」沿着一个方向传下去,适合 nginx.service 与 nginx.socket 这类同生共死的组合。

系统给每个 service 自动加了默认依赖,这就是为什么你没写 After=basic.target 也不会在挂载完成前启动:

默认依赖作用
Requires=sysinit.target + After=sysinit.target等早期系统初始化完成
After=basic.target等基础 target
Conflicts=shutdown.target + Before=shutdown.target关机时先停我
After=network.target(仅部分 unit)不保证网络可用

DefaultDependencies=no 会关掉这一整套,只有 sysinit.target、shutdown.target 这类早期/晚期 unit 才需要。写普通服务时不要关,除非你明确知道自己在和关机顺序打交道。

RequiresMountsFor=/srv/data 会自动生成对 srv-data.mount 的 Requires= 与 After=,比手写 local-fs.target 精确。挂载点没挂上时,服务直接不启动,而不是在空目录上写数据。

查看实际生效的依赖,不要只看 unit 文件:

systemctl show -p Requires -p Wants -p After -p Before -p BindsTo nginx.service
systemctl list-dependencies --after nginx.service
systemctl list-dependencies --before nginx.service
systemctl list-dependencies --reverse nginx.service

systemctl show 输出的是合并 drop-in、生成器、默认依赖之后的最终值,一行可能很长;list-dependencies 画的是树,--reverse 看「谁依赖我」。排查「为什么它先启动了」时,--after 比读 unit 文件可靠。

drop-in 是覆盖而非替换:

sudo systemctl edit nginx.service        # 创建 /etc/systemd/system/nginx.service.d/override.conf
systemctl cat nginx.service              # 看主文件 + 所有 drop-in 的拼接结果
systemctl daemon-reload                  # 改完必须重新加载

systemctl edit 生成的文件只写你要改的键,未写的继承主文件。空的赋值(Environment=)可以清空列表型字段;ExecStart= 清空后必须重新给一个,否则 unit 无效。systemctl edit --full 则是复制整个 unit 到 /etc,升级时不会跟随发行版更新,除非必要不要用。

0.12.9 服务类型、重启策略与沙箱

Type= 决定 systemd 什么时候认为服务「启动完成」。选错会让 systemctl start 立即返回,而服务其实还没准备好;或者相反,一直等到超时。

类型启动完成的判定适用陷阱
simplefork+exec 后立即算启动大多数前台进程二进制不存在也显示成功
execexecve 成功后才算推荐替代 simple仍需应用自己就绪
forking父进程退出、子进程留下传统 daemon要配 PIDFile=
oneshot进程退出且退出码为 0一次性任务默认不允许 Restart=always
notify应用调用 sd_notify(READY=1)支持 systemd 的服务必须 NotifyAccess=
dbus总线名出现D-Bus 服务需要 BusName=
idle其他 job 派发完才启动控制台输出启动被延迟

Type=exec 比 simple 多等一步 execve,能捕获「文件不存在/没有执行权限」,是现在推荐默认。Type=notify 要求程序主动发 readiness,Nginx、PostgreSQL 等主流服务都支持;配 WatchdogSec= 还能让 systemd 在应用卡死时重启它。

Restart= 的取值与触发条件:

值何时重启典型场景
no从不一次性任务、调试
on-success仅退出码 0很少用
on-failure非零退出、被信号杀、超时常驻服务默认选择
on-abnormal被信号杀、超时、watchdog不想因普通退出码重启
on-abort仅未捕获信号崩溃重启
on-watchdog仅 watchdog 超时配合 WatchdogSec=
always任何退出必须常驻的守护进程

RestartSec= 控制间隔,StartLimitIntervalSec=/StartLimitBurst= 控制「多短时间内失败多少次就放弃」。这两个键在 [Unit] 段,不在 [Service];放错段会被忽略,表现为「无限重启把机器拖垮」。达到上限后 unit 进入 failed,systemctl reset-failed 才能重新计数。

停止过程由 KillMode= 决定:

KillMode=发信号给谁适用
control-group(默认)cgroup 内所有进程大多数服务,能清掉子进程
mixed主进程 SIGTERM,随后整组 SIGKILL需要主进程优雅退出的服务
process只给主进程自己管理子进程的服务
none不发信号只用于调试/特殊场景

TimeoutStopSec= 是「SIGTERM 后等多久发 SIGKILL」;SendSIGKILL=no 会禁用最后的强杀,通常只会让停止卡住。ExecStop= 存在时,systemd 先执行它,再按 KillMode= 清理剩余进程;ExecStopPost= 无论成功失败都会执行,适合清理临时文件。

资源限制直接落在 cgroup 上,比 ulimit 更可靠:

[Service]
MemoryMax=2G
MemoryHigh=1800M
CPUQuota=200%
TasksMax=512
IOWeight=100
OOMPolicy=stop

MemoryMax 是硬上限,超过会触发 cgroup OOM;MemoryHigh 是软上限,先节流再回收。CPUQuota=200% 表示最多两个核。TasksMax 限制线程/进程数,防 fork bomb。systemctl status 会显示 Memory:、Tasks:、CPU: 当前值,systemd-cgtop 看全局。

沙箱选项按「先能跑,再收紧」的顺序加:

选项效果常见破坏
NoNewPrivileges=true禁止 setuid/setcap 提权需要 setuid 的辅助程序
PrivateTmp=true私有 /tmp、/var/tmp与其他服务通过 /tmp 交换文件
ProtectSystem=strict除指定路径外全只读服务要写 /var/lib 却没用 StateDirectory=
ProtectHome=true隐藏 /home、/root、/run/user服务要读用户家目录
PrivateDevices=true隐藏物理设备需要直接访问 /dev/sd*
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX限制 socket 类型需要 netlink/raw socket
SystemCallFilter=@system-service限制 syscall需要特殊 syscall 的程序
MemoryDenyWriteExecute=true禁止 W+X 映射JIT、部分解释器
DynamicUser=true临时 UID,退出即回收需要固定 UID 的文件属主

一次只加一个,systemctl restart 后跑功能测试;失败看 journalctl -u 的 Permission denied、Operation not permitted。systemd-analyze security UNIT 会给一个 0–10 的暴露评分,分数越低越收紧,但它只是启发式,不是安全证明。

0.12.10 journald 的字段、持久化与限额

journalctl 不是「按文本 grep 日志」,而是按结构化字段过滤。每条日志是一组键值对,MESSAGE 只是其中一项。

字段含义谁设置
MESSAGE日志正文应用/内核
PRIORITY0 emerg … 7 debug应用/syslog
SYSLOG_IDENTIFIER传统 tag,如 sshd应用
_PID / _UID / _GID发送者进程/用户journald(可信)
_COMM / _EXE / _CMDLINE进程名/可执行文件/命令行journald(可信)
_SYSTEMD_UNIT所属 unitjournald
_SYSTEMD_CGROUPcgroup 路径journald
_BOOT_ID本次启动 IDjournald
_TRANSPORTkernel、stdout、syslog、journaljournald
__REALTIME_TIMESTAMP微秒时间戳journald

带 _ 前缀的是「可信字段」,由 journald 填写,客户端不能伪造;不带前缀的(MESSAGE、PRIORITY、SYSLOG_IDENTIFIER)由发送方提供,可以被伪造。审计时用带 _ 的字段定位进程,不要用应用自己在正文里打印的 PID 或自报的 SYSLOG_IDENTIFIER 代替 _PID/_EXE。

常用过滤组合:

journalctl -b -p warning --since "1 hour ago"       # 本次启动、warning 及以上、最近一小时
journalctl -u nginx.service -n 50 --no-pager        # 指定 unit 最后 50 行
journalctl _PID=1234 -o verbose                     # 按 PID,看全部字段
journalctl /usr/sbin/sshd                           # 按可执行文件
journalctl -k -b -1                                 # 上一次启动的内核日志
journalctl -g 'timeout|refused' -b                  # 正则搜正文
journalctl -F _SYSTEMD_UNIT | head                  # 列出某字段出现过的值
journalctl --list-boots                            # 启动记录列表

-b -1 依赖持久化存储。Ubuntu 默认 Storage=auto:/var/log/journal 存在才持久化,不存在只写 /run/log/journal(tmpfs),重启即丢。要保留历史:

sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo sed -i 's/^#\?Storage=.*/Storage=persistent/' /etc/systemd/journald.conf
sudo systemctl restart systemd-journald
journalctl --flush

systemd-tmpfiles --create 会按 /usr/lib/tmpfiles.d/systemd.conf 建目录并设权限;只 mkdir 不设 ACL,journald 可能仍不写。--flush 把 /run 里已有的日志搬到 /var。

限额在 journald.conf:

配置作用默认/建议
SystemMaxUse=/var/log/journal 总上限默认 10% 文件系统
SystemKeepFree=至少留多少空间15%
SystemMaxFileSize=单文件上限默认 1/8 总上限
MaxRetentionSec=最长保留时间默认不按时间删
RateLimitIntervalSec= / RateLimitBurst=限流30s / 10000 条(systemd 默认)
ForwardToSyslog=同时送 rsyslog装了 rsyslog 时通常 yes
Compress=压缩yes

限流是「日志突然少了」的常见原因:某服务刷屏超过 RateLimitBurst,journald 会丢弃并记一条 Suppressed N messages。查 journalctl -u systemd-journald 能看到抑制记录;临时调大 RateLimitBurst= 或修应用,不要直接关限流。

清理旧日志用 journalctl --vacuum-size=500M、--vacuum-time=30d、--vacuum-files=10。不要 rm -rf /var/log/journal:正在写的文件被删除后,journald 仍持有 fd,空间不释放,还会破坏索引。journalctl --verify 能检查归档完整性,--rotate 强制切分当前文件。

0.12.11 cron 与 timer 的语义差异

cron 和 systemd timer 都能「按时跑」,但时间模型、环境、失败可见性完全不同:

维度cronsystemd timer
时间模型墙钟,机器关机就错过OnCalendar= 墙钟;OnBootSec=/OnUnitActiveSec= 单调时钟
错过补跑不补(anacron 例外)Persistent=true 补一次
环境极简 PATH、/bin/sh、无登录环境明确的 Environment=/EnvironmentFile=
日志邮件/stdout,常丢journal 自动收集,按 unit 查
并发控制自己 flock同一 unit 不会并发启动
随机延迟自己 sleep $RANDOMRandomizedDelaySec=
依赖无After=/Requires=/RequiresMountsFor=
精度分钟默认 1 分钟,可调到秒/微秒

cron 的三个坑每次都有人踩:

  1. PATH 不是你的 PATH。cron 默认 PATH=/usr/bin:/bin,/usr/local/bin 里的命令找不到。在 crontab 顶部写 PATH=/usr/local/bin:/usr/bin:/bin,或全部用绝对路径。
  2. % 是特殊字符。crontab 里 date +%F 的 % 会被当成「命令输入换行」,必须写成 \%,或者把命令放进脚本。
  3. /etc/crontab 与 /etc/cron.d/* 多一个用户字段。系统级 crontab 是「分 时 日 月 周 用户 命令」,用户级 crontab -e 没有用户字段;抄错就会把用户名当命令执行。

cron 的日志在 journalctl -u cron 或 /var/log/syslog 的 CRON 行;MAILTO="" 可以关掉邮件,但关掉后失败就静默了。至少把输出重定向到文件:

PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=""
30 2 * * * /usr/local/bin/backup.sh >>/var/log/backup.log 2>&1

timer 的日历表达式用 systemd-analyze calendar 验证,支持 Mon..Fri、*/15、02:30 等:

systemd-analyze calendar 'Mon..Fri *-*-* 09:00:00'
systemd-analyze calendar --iterations=5 '*-*-* 00/6:00:00'

OnCalendar= 是墙钟,OnUnitActiveSec= 是「上次激活后隔多久」,OnBootSec= 是「开机后隔多久」。想要「每 10 分钟,不管漂移」用 OnCalendar=*:0/10;想要「上次跑完 10 分钟后」用 OnUnitActiveSec=10min。两者不能混着理解。

Persistent=true 只对 OnCalendar= 有意义:关机期间错过的时间点,开机后补跑一次(不是每个错过的点都补)。AccuracySec= 默认 1 分钟,允许 systemd 合并唤醒;要精确到秒就设小,但更耗电。RandomizedDelaySec= 在触发时间上加随机延迟,适合大批机器同时备份。

0.12.12 logrotate:轮转、压缩与信号

logrotate 由 logrotate.timer 每天触发一次,读 /etc/logrotate.conf 与 /etc/logrotate.d/*。它的核心不是「按天切」,而是「按条件判断是否该切」:

指令含义注意
daily/weekly/monthly时间条件与 size 同时写时,满足任一即切
rotate N保留 N 份超出删除
size 100M超过大小就切与时间条件是「或」
maxsize 500M即使没到时间,超过也切常与 daily 搭配
minsize 10M到时间但太小则不切防止碎文件
missingok文件不存在不报错服务未装时有用
notifempty空文件不切默认切空文件
compress / delaycompressgzip / 延迟一轮配合 copytruncate 要注意
copytruncate复制后清空原文件有丢日志窗口
create 0640 user group切完建新文件权限/属主要对
dateext用日期后缀避免序号滚动
sharedscripts多个文件只跑一次脚本配合 postrotate
su user group降权执行目录属主不是 root 时必需

最常用的两种模式:

/var/log/myapp/*.log {
    daily
    rotate 14
    missingok
    notifempty
    compress
    delaycompress
    sharedscripts
    postrotate
        systemctl reload myapp.service >/dev/null 2>&1 || true
    endscript
}

postrotate 里让服务重新打开日志文件(reload/SIGUSR1),而不是 copytruncate。copytruncate 在「复制完、清空前」写入的日志会丢;对审计日志不可接受。若应用不支持重开,只能 copytruncate,并把 delaycompress 打开,避免压缩刚复制出来的文件时与应用竞争。

诊断用 -d(debug,不实际轮转)和 -f(强制):

sudo logrotate -d /etc/logrotate.conf
sudo logrotate -f /etc/logrotate.d/myapp
sudo logrotate -v -s /var/lib/logrotate/status /etc/logrotate.conf

-d 会打印「considering log …」「log needs rotating」以及每个条件的判断结果,是排查「为什么没切」的第一条命令。状态文件 /var/lib/logrotate/status 记录每个文件上次轮转的时间;删掉它会让所有文件重新计时,通常不是好主意。

0.12.13 三条 systemd 巡检脚本

0.12.13.1 unit 静态检查

#!/usr/bin/env bash
# unit-lint.sh /etc/systemd/system/*.service
set -euo pipefail
bad=0
for u in "$@"; do
    echo "== $u"
    if ! systemd-analyze verify "$u"; then bad=1; fi
    # ExecStart 的第一个词必须是可执行文件
    exec=$(awk -F= '/^ExecStart=/{sub(/^ExecStart=/,""); print; exit}' "$u")
    bin=${exec%% *}
    [[ -x $bin ]] || { echo "  MISSING/NOEXEC: $bin"; bad=1; }
    # 检查 User= 是否存在
    user=$(awk -F= '/^User=/{print $2; exit}' "$u")
    if [[ -n ${user:-} ]] && ! id "$user" >/dev/null 2>&1; then
        echo "  NO SUCH USER: $user"; bad=1
    fi
done
exit "$bad"

systemd-analyze verify 会检查语法、依赖循环、ExecStart 路径与 unit 名;它不检查运行时权限,也不启动服务。awk -F= 的写法假设 ExecStart= 没有前置空格,systemd 允许行首空白,严谨做法是先 sed 's/^[[:space:]]*//'。

0.12.13.2 失败服务取证

#!/usr/bin/env bash
# svc-forensics.sh —— 打印所有 failed unit 的关键证据
set -euo pipefail
mapfile -t failed < <(systemctl --failed --no-legend --plain | awk '{print $1}')
(( ${#failed[@]} == 0 )) && { echo "no failed units"; exit 0; }

for u in "${failed[@]}"; do
    echo "===== $u"
    systemctl show "$u" -p Result -p ExecMainStatus -p ExecMainCode -p ActiveState -p SubState
    systemctl status "$u" --no-pager -l | sed -n '1,12p'
    journalctl -u "$u" -b -n 30 --no-pager
    # cgroup OOM / 资源限制线索
    journalctl -b -g "oom|killed process|Failed with result" --no-pager | tail -5
done

Result=exit-code 看 ExecMainStatus,Result=signal 看 ExecMainCode(信号编号),Result=timeout 看 TimeoutStartSec/TimeoutStopSec,Result=oom-kill 看 cgroup/OOM 限额。journalctl -b -g 的 -g 是正则,不是 glob。

0.12.13.3 timer 到期核对

#!/usr/bin/env bash
# timer-report.sh —— 列出 timer 的下次触发与最近一次结果
set -euo pipefail
systemctl list-timers --all --no-pager
echo
while read -r unit; do
    [[ $unit == *.timer ]] || continue
    echo "== $unit"
    systemctl show "$unit" -p NextElapseUSecRealtime -p LastTriggerUSec -p Result -p Persistent
    systemctl cat "$unit" | awk '/^OnCalendar=|^OnBootSec=|^OnUnitActiveSec=|^Persistent=|^RandomizedDelaySec=/'
done < <(systemctl list-timers --all --no-legend --plain | awk '{print $(NF-1)}')

NextElapseUSecRealtime 是绝对时间(微秒),LastTriggerUSec 是上次触发;Result 为 success 不代表 service 成功,只代表 timer 自己成功触发,service 的结果要 systemctl show foo.service -p Result。systemctl list-timers 的 LEFT 列是相对时间,机器休眠后会失真,以 NEXT 为准。

list-timers 的列顺序是 NEXT LEFT LAST PASSED UNIT ACTIVATES,UNIT 是倒数第二列;read 只取第一列会拿到 NEXT 的星期缩写,循环看起来跑了却什么也没输出。脚本用 awk '{print $(NF-1)}' 取 UNIT,比按空格数猜列稳。

0.12.14 两个实验:203/EXEC 与错过补跑

实验一:制造一个 203/EXEC 并用 drop-in 修复

sudo tee /etc/systemd/system/broken.service >/dev/null <<'EOF'
[Unit]
Description=broken exec demo
[Service]
Type=exec
ExecStart=/usr/local/bin/does-not-exist
Restart=on-failure
RestartSec=2s
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl start broken.service; echo "start rc=$?"
systemctl status broken.service --no-pager
journalctl -u broken.service -n 10 --no-pager
systemctl show broken.service -p Result -p ExecMainStatus -p NRestarts

预期:start 返回非 0,status 显示 status=203/EXEC,Result=exit-code 或 resources,NRestarts 在几秒内增长。203/EXEC 是 systemd 的退出码约定:EXIT_EXEC 表示 execve 失败,常见于路径不存在、没有执行位、shebang 解释器缺失。

修复用 drop-in,不覆盖主文件:

sudo mkdir -p /etc/systemd/system/broken.service.d
sudo tee /etc/systemd/system/broken.service.d/fix.conf >/dev/null <<'EOF'
[Service]
ExecStart=
ExecStart=/usr/bin/sleep 300
EOF
sudo systemctl daemon-reload
sudo systemctl restart broken.service
systemctl is-active broken.service

ExecStart= 空赋值先清空原值,再给新值;只写第二行会变成「两个 ExecStart」,对 Type=exec 是错误。修好后 systemctl reset-failed broken.service 清掉失败计数,再 stop。

实验二:timer 的错过补跑

sudo tee /etc/systemd/system/miss.service >/dev/null <<'EOF'
[Unit]
Description=missed timer demo
[Service]
Type=oneshot
ExecStart=/usr/bin/date +%%F-%%T
EOF
sudo tee /etc/systemd/system/miss.timer >/dev/null <<'EOF'
[Unit]
Description=run every minute, persist missed
[Timer]
OnCalendar=*-*-* *:*:00
Persistent=true
AccuracySec=1s
Unit=miss.service
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now miss.timer
systemctl list-timers miss.timer --no-pager

等一分钟,journalctl -u miss.service -n 5 应看到一次 date 输出。然后模拟「关机错过」:

sudo systemctl stop miss.timer
sleep 70
sudo systemctl start miss.timer
sleep 5
journalctl -u miss.service -n 3 --no-pager

预期:启动 timer 后几秒内 service 立即跑一次,补上停机期间错过的时间点。若把 Persistent=true 去掉,同样的操作不会补跑。AccuracySec=1s 让触发更准时;默认 1 分钟可能让你以为「没跑」。

0.12.15 systemd-analyze:启动耗时、unit 校验与日历

systemd-analyze blame 只回答「哪个 unit 激活久」,不回答「它是否在关键路径上」。这一节用三个子命令把启动问题拆开:critical-chain 看关键路径,verify 看 unit 能不能被 systemd 接受,calendar 看 timer 下一次到底何时触发。

子命令回答的问题关键输出不能证明什么
time固件/引导器/内核/用户态各花多久firmware、loader、kernel、userspace(有 initrd 时多一段)只统计到服务被拉起,不保证初始化完成、磁盘空闲
blame哪些 unit 激活耗时最长ACTIVATING→ACTIVE 的墙钟串行视角;Type=simple 不显示;不含 job 在队列中等待的时间
critical-chain哪条依赖链决定 target 的到达时间UNIT @激活时刻 +自身耗时只沿一条链;socket 激活/并行会让它偏离真实因果
verifyunit 文件语法、依赖、可执行文件是否成立Unknown key、not executable、ordering cycle不检查运行时权限、目录、沙箱、SELinux
calendarOnCalendar= 下一次/下几次触发Normalized form、Next elapse、Iteration不验证 service 能否成功,只验证时间表达式
security服务暴露面评分Overall exposure level 0–10只看 systemd 自身的沙箱项,不是漏洞扫描
plot所有 unit 的并行时间线SVG需要浏览器;不替代 critical-chain 的因果链
cat-config配置文件合并后的内容文件路径 + 内容只对支持的配置文件;不显示 unit 运行时属性

critical-chain 的输出要按 @ 和 + 分开读:

$ systemd-analyze critical-chain
graphical.target @8.401s
└─multi-user.target @8.400s
  └─basic.target @8.398s
    └─sockets.target @8.397s
      └─cups.socket @8.396s
        └─cups.service @7.900s +496ms
          └─network.target @7.880s
            └─NetworkManager.service @7.120s +758ms

@8.401s 是 graphical.target 变为 active 的时刻;cups.service @7.900s +496ms 表示它在 7.900 s 开始激活、自身花了 496 ms,在 8.396 s 完成,随后 cups.socket 在 8.396 s 到达。链上每个带 + 的 unit 是它自身的启动耗时;沿链找 + 最大的那一环,才是「关键路径上的慢」。blame 把 apt-daily.service 排第一,并不代表它在关键路径上:它可能和其他 unit 并行,早就不挡 target 了。

blame 还有两个容易忽略的限制(systemd-analyze(1)):它只统计 unit 处于 activating 的时间,完全不显示 Type=simple 的服务——systemd 认为 simple 服务 fork/exec 后就算启动完成,没有可测的初始化窗口。看到「某个 simple 服务没出现在 blame 里」不是它没启动,而是它没有可测量的 activating 阶段。

verify 是静态检查,适合在 daemon-reload 之前拦住明显的 unit 错误:

cat >/tmp/lab.service <<'EOF'
[Unit]
Description=verify lab
[Service]
Type=exec
ExecStart=/usr/local/bin/nope --flag
EOF
systemd-analyze verify /tmp/lab.service; echo "rc=$?"
# 预期输出含:Command /usr/local/bin/nope is not executable: No such file or directory
# rc 通常非 0;若你的版本把这类问题降级为 warning,以输出为准

verify 会检查未知的 section/key、缺失的依赖、ExecStart= 指向的文件不存在或不可执行。它不会替你检查 User= 是否存在、ReadWritePaths= 是否可写、ProtectSystem=strict 下服务是否还能写状态目录;这些只有真正启动才暴露。systemd-analyze verify 也不执行 ExecStartPre=,所以「预创建目录」这类逻辑它看不到。

calendar 用来验证 timer 表达式,而不是靠猜:

systemd-analyze calendar --iterations=3 'Mon..Fri *-*-* 09:00:00'
systemd-analyze calendar '*-*-* 02:30:00 Europe/Berlin'
systemd-analyze calendar --base-time='2025-03-29 12:00:00' '*-*-* 02:30:00 Europe/Berlin'

输出里的 Normalized form 是 systemd 实际理解的表达式,Next elapse 是下一次触发,--iterations=3 再给后两次。跨时区/夏令时不要假设「每天都有 02:30」:用 --base-time 把基准时间放到切换日附近,验证跳过的本地时刻和重复的本地时刻,再决定备份窗口。

systemctl cat 和 systemctl show 经常被当成同义词,其实一个读文件、一个读已加载的 unit:

命令数据来源能看到看不到
systemctl cat磁盘上的 fragment + drop-in文件路径、注释、原始 ExecStart= 行生成器/模板展开、运行时属性;未 daemon-reload 时可能与内存中的配置不一致
systemctl showsystemd 已加载的 unit 对象合并后的属性、FragmentPath、DropInPaths、Result注释、被忽略的未知键
systemctl statusshow + 最近日志状态、PID、退出码完整依赖图
journalctl -ujournal启动/停止/应用输出unit 配置本身
systemctl cat ssh.service | grep -E '^# |^ExecStart=|^EnvironmentFile='
systemctl show ssh.service -p FragmentPath -p DropInPaths -p UnitFileState -p ExecStart

cat 输出里的 # /usr/lib/systemd/system/ssh.service 和 # /etc/systemd/system/ssh.service.d/override.conf 是文件边界;同一个键在后面的 drop-in 里可以覆盖前面的值,但列表型键(ExecStart=、Environment=)是追加,除非先用空赋值清空。show 的 ExecStart={ path=... ; argv[]=... } 才是 systemd 实际准备执行的 argv,变量展开、模板实例都在这里体现。

0.12.15.1 实验:用 verify + calendar + cat/show 定位一个 unit

# 1. 静态检查:先证明 unit 文件本身有问题
systemd-analyze verify /tmp/lab.service; echo "verify rc=$?"

# 2. 日历检查:确认 timer 不是「到点没跑」而是「根本没到点」
systemd-analyze calendar --iterations=3 '*-*-* 02:30:00'
systemctl list-timers backup.timer --no-pager

# 3. 配置来源:文件里写的 vs systemd 实际加载的
systemctl cat backup.service
systemctl show backup.service -p FragmentPath -p DropInPaths -p UnitFileState \
    -p ActiveState -p Result -p ExecMainStatus

# 4. 启动关键路径:不要只看 blame 的第一名
systemd-analyze critical-chain backup.service
systemd-analyze plot > /tmp/boot.svg

预期:第 1 步在 ExecStart 路径不存在时给出 not executable;第 2 步的 Next elapse 与 list-timers 的 NEXT 一致;第 3 步能看出 FragmentPath 是 /etc 还是 /usr/lib,以及有没有 drop-in;第 4 步把 backup.service 放回依赖链,而不是孤立地看它自己的耗时。plot 生成的 SVG 用浏览器打开,横轴是时间,能看出哪些 unit 并行、哪些在等。

0.12.15.2 boot-triage.sh:一次收集启动证据

#!/usr/bin/env bash
# boot-triage.sh — 启动耗时、失败 unit、配置来源,只读
set -Eeuo pipefail
export SYSTEMD_PAGER=cat LC_ALL=C
out=${1:-/var/tmp/boot-triage-$(date +%Y%m%dT%H%M%S)}
mkdir -p "$out"
exec > >(tee "$out/report.txt") 2>&1

printf '== %s ==\n' "$(date -Is)"
systemd-analyze time || true
printf '\n== critical chain ==\n'
systemd-analyze critical-chain || true

printf '\n== failed units ==\n'
mapfile -t failed < <(systemctl --failed --no-legend --plain | awk '{print $1}')
if ((${#failed[@]} == 0)); then
    echo none
else
    for u in "${failed[@]}"; do
        printf '\n-- %s\n' "$u"
        systemctl show "$u" -p FragmentPath -p DropInPaths -p UnitFileState \
            -p ActiveState -p SubState -p Result -p ExecMainStatus -p ExecMainCode
        systemctl cat "$u" 2>/dev/null | sed -n '1,40p' || true
        journalctl -u "$u" -b -n 20 --no-pager || true
    done
fi

printf '\n== boot id ==\n'
cat /proc/sys/kernel/random/boot_id
journalctl --list-boots --no-pager 2>/dev/null | tail -5 || true
printf '\nreport: %s\n' "$out"

脚本先固定 SYSTEMD_PAGER=cat,否则在交互终端里 critical-chain 会进 pager,重定向到文件时行为不一致。mapfile 读 systemctl --failed --plain 的第一列;--plain 去掉前面的圆点,--no-legend 去掉表头。systemctl show 的 DropInPaths 直接告诉你覆盖文件在哪,比在 /etc/systemd/system 里盲找快。最后打印 boot_id 和最近几次启动,方便把报告和 journalctl -b -1 对上。

0.12.16 journalctl -b:跨启动取证的证据链

journalctl -b 的实现不是「读本次开机的文件」,而是给查询加一个 _BOOT_ID= 匹配。每条 journal 记录都带 _BOOT_ID,值来自内核在每次启动时生成的 UUID(/proc/sys/kernel/random/boot_id)。理解这一点,才能解释三个常见现象:-b -1 为什么是空的、时钟跳变后 --since 为什么会漏记录、dmesg 和 journalctl -k 为什么对不上。

选择器含义前提/边界
-b当前启动,等价于 _BOOT_ID=$(cat /proc/sys/kernel/random/boot_id)总是可用
-b -1上一次启动需要持久化日志,且没被 vacuum 掉
-b <BOOT_ID>指定某次启动ID 从 --list-boots 或 -F _BOOT_ID 取
-b all所有启动输出量可能很大,配合 -u/-p/-g
--list-boots启动列表:IDX、BOOT ID、首末记录时间依赖持久化;只列 journal 里还有记录的启动
-k只看内核消息隐含 -b,并匹配 _TRANSPORT=kernel;不是 dmesg 的 ring buffer
-u UNIT某个 unit匹配 _SYSTEMD_UNIT,用户 unit 要加 --user
-p errpriority ≤ 3数值越小越严重
-g PATTERN正文正则不是字段匹配;字段用 _PID= 等
--since/--until墙钟时间过滤时钟跳变后会漏/重;跨启动优先用 -b
$ cat /proc/sys/kernel/random/boot_id
4f6c2d1e9a7b4c3d8e5f0a1b2c3d4e5f
$ journalctl --list-boots --no-pager
IDX BOOT ID                          FIRST ENTRY                 LAST ENTRY
 -1 3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d Sun 2025-03-02 09:00:01 UTC Sun 2025-03-02 10:12:44 UTC
  0 4f6c2d1e9a7b4c3d8e5f0a1b2c3d4e5f Sun 2025-03-02 10:30:12 UTC Sun 2025-03-02 11:02:03 UTC
$ journalctl -b -o verbose -n 1 --no-pager | grep -E '^ +(_BOOT_ID|__MONOTONIC_TIMESTAMP|__REALTIME_TIMESTAMP|_TRANSPORT)'
    _BOOT_ID=4f6c2d1e9a7b4c3d8e5f0a1b2c3d4e5f
    __MONOTONIC_TIMESTAMP=123456789
    __REALTIME_TIMESTAMP=1740913323000000
    _TRANSPORT=journal
$ journalctl -b -1 -u ssh -n 3 --no-pager
Mar 02 10:12:40 lab sshd[1888]: Accepted publickey for ops from 10.0.2.9 port 51234
...

--list-boots 的 IDX 是相对当前启动的序号:0 当前,-1 上一次,正数从 journal 里最早的一次开始数。-b -1 查不到时,先看 --list-boots 有没有 -1,再看 /var/log/journal 是否存在;不要直接怀疑 journalctl 坏了。

__REALTIME_TIMESTAMP 是墙钟,__MONOTONIC_TIMESTAMP 是自启动以来的单调时钟。-o short-monotonic 显示后者(记录顺序仍按 journal 序列),-o short-precise 用前者。NTP 把时钟向前/向后拨时,墙钟时间可能倒退,--since 会漏掉「看起来在开始之前」的记录;跨启动对比时用 _BOOT_ID 限定启动,再用 short-monotonic 看启动内顺序。

dmesg 读的是内核 ring buffer(/dev/kmsg),容量由 log_buf_len 决定,可能被后来的消息覆盖;journalctl -k 读 journald 收到的内核消息。两者不是同一份数据:容量、启动时间、是否持久化都不同;对不上时先看 ring buffer 是否已绕回,再查 journal 是否持久化。dmesg -T 把单调时间换算成墙钟,挂起/恢复后换算可能偏差;对时间敏感时用 dmesg --time-format=monotonic。

0.12.16.1 实验:同一份日志的三种时间视图

boot=$(cat /proc/sys/kernel/random/boot_id)
echo "boot=$boot"

# 1. -b 与显式 _BOOT_ID 应完全等价
journalctl -b -n 1 --no-pager
journalctl -b "_BOOT_ID=$boot" -n 1 --no-pager

# 2. 三种输出:默认、单调时钟、精确墙钟
journalctl -b -o short -n 5 --no-pager
journalctl -b -o short-monotonic -n 5 --no-pager
journalctl -b -o short-precise -n 5 --no-pager

# 3. 找时钟事件与启动边界
journalctl -b -g 'clock|time (has|was) changed|Time has been changed' -n 10 --no-pager
journalctl -b -o short-monotonic -n 3 --no-pager

预期:前两条输出同一条记录;short-monotonic 的时间戳从接近 0 开始增长,重启后重新计数;short-precise 带微秒且是墙钟。若 -g 找到 Time has been changed,把同一时间段的 --since 查询和 -b 查询对比,就能看到墙钟过滤漏掉了哪些记录。

跨启动实验需要一次重启:

# 重启前记录
cat /proc/sys/kernel/random/boot_id
journalctl --list-boots --no-pager | tail -3

# 重启后
journalctl --list-boots --no-pager | tail -3
journalctl -b -1 -u ssh -n 5 --no-pager
journalctl -b -1 -p warning -n 20 --no-pager
journalctl -b -1 -o short-monotonic -n 5 --no-pager

如果 -b -1 为空,按顺序查:Storage= 是否为 persistent、/var/log/journal 是否存在、journalctl --disk-usage 是否还有旧数据、MaxRetentionSec=/--vacuum-* 是否把旧启动删了。四条都正常才轮到「上次启动是否根本没写 journal」。

0.12.17 二十一个 systemd 与定时任务陷阱

#现象原因修复/判断
1After= 写了但顺序仍不对只排序、没拉取;对方没启动加 Requires=/Wants=
2服务启动即 203/EXEC路径/执行位/shebang 错systemctl cat、ls -l、file
3服务无限重启拖垮机器Restart=always 无 StartLimitStartLimitBurst=、RestartSec=
4StartLimit 放在 [Service] 无效它属于 [Unit]移到 [Unit]
5ProtectSystem=strict 后写不了状态没给 StateDirectory=/ReadWritePaths=加目录或放宽
6Type=simple 显示启动成功但端口没开只保证 fork/exec改 Type=exec/notify + 健康检查
7journal 重启后丢失Storage=auto 且无 /var/log/journal建目录、设 persistent
8日志突然变少journald 限流查 Suppressed,调 RateLimitBurst=
9timer 到点没跑未 enable、日历表达式错、service 失败list-timers、systemd-analyze calendar、journalctl
10错过的时间没补没写 Persistent=true加上;只补一次
11cron 命令找不到PATH 太短crontab 顶部设 PATH 或用绝对路径
12cron 的 date +%F 截断命令% 未转义写 \% 或放进脚本
13logrotate 没切日志时间/大小条件未满足、状态文件未更新logrotate -d 看判断
14blame 第一名就是启动瓶颈blame 只算 activating,且不显示 Type=simple;并行单位不挡 target用 critical-chain 看关键路径,plot 看并行
15critical-chain 没列出某个慢服务只沿一条依赖链;socket 激活/并行分支不在链上结合 plot、systemd-analyze dump
16verify 通过,服务仍启动失败只做静态检查,不验证运行权限/目录/沙箱/SELinux真启动,journalctl -u 看 Permission denied/Result
17calendar 算出的时间和预期差一小时表达式按本地时区/DST 解释;跳过的时刻不存在用 --base-time 在切换日验证,或显式写 UTC
18journalctl -b -1 为空未持久化、旧启动被 vacuum、MaxRetentionSec 到期--list-boots、/var/log/journal、--disk-usage
19时钟跳变后 --since 查不到日志过滤用墙钟,NTP 回拨导致时间倒退用 -b + _BOOT_ID,-o short-monotonic
20systemctl cat 的内容和运行行为不一致显示磁盘文件;改完未 daemon-reload 或生成器/模板展开不同systemctl show 看 FragmentPath/DropInPaths/ExecStart
21drop-in 里加 ExecStart= 后两条命令都跑了列表型键是追加,不是覆盖先写空 ExecStart= 再写新值;show -p ExecStart 确认 argv

第 9 条要按顺序查:systemctl is-enabled → systemctl is-active → systemctl list-timers --all → systemd-analyze calendar → journalctl -u foo.service。跳过前两步直接看日志,经常会发现 timer 根本没启用。

0.12.18 本章出口检查

检查项达标线没达标去哪
说出开机七段与两个分界能定位卡在内核还是用户态0.12.1
区分 After 与 Requires能举「只排序不依赖」的例子0.12.2
写一个带沙箱的 oneshot serviceUser=、ProtectSystem=、ReadWritePaths= 齐全0.12.3
写一个 timer 并验证表达式systemd-analyze calendar 通过0.12.3
用 journalctl 查一次失败能按 -b/-u/-p 缩小范围0.12.4
说出 cron 的三个坑PATH、%、/etc/cron.d 用户字段0.12.5
配一次 logrotate 并 dry-runlogrotate -d 输出符合预期0.12.6
进 rescue 修一次 fstab/unit能说出 remount,rw 与 daemon-reload0.12.7
用 critical-chain 找关键路径能读出 @ 与 +,不被 blame 误导0.12.15
用 -b -1 对比上次启动能解释空结果与持久化条件0.12.16
即时自测 · 0.12

Q1 你写了 After=postgresql.service,但应用启动时数据库还没 ready,报连接拒绝。After 为什么不够?应该加什么?

Q2 systemctl enable backup.timer 后 list-timers 有下次时间,但到点没跑。列出三条最可能的原因和对应检查命令。

Q3 服务日志显示 status=203/EXEC。这是什么意思?先查什么?

Q4 一台机器重启后 /var/log/journal 不存在,journalctl -b -1 什么都没有。为什么?怎么让日志跨重启保留?

答案

A1 After= 只保证「postgresql 的启动 job 在我之前完成」,不保证数据库接受连接,也不保证 postgresql 启动成功。加 Requires=postgresql.service(或 Wants=)建立需求关系,同时在应用里做连接重试;数据库 ready 的精确信号要靠 pg_isready 或应用自己的健康检查。

A2 ① timer 没启用/没启动:systemctl status backup.timer;② OnCalendar 写错:systemd-analyze calendar '...';③ service 启动失败:journalctl -u backup.service;④ 系统时间/时区不对:timedatectl;⑤ Persistent=true 但时钟跳变:journalctl -u systemd-timers。

A3 203/EXEC 表示 ExecStart 指向的文件无法执行:路径不存在、没有可执行位、shebang 解释器不存在、或者被 ProtectSystem/NoExec 限制。先 systemctl cat backup.service 看 ExecStart,再 ls -l 该路径、file 看解释器,最后 journalctl -u 看有没有 Permission denied。

A4 Ubuntu 默认 Storage=auto,/var/log/journal 不存在时 journald 只写 /run/log/journal(tmpfs),重启即丢。sudo mkdir -p /var/log/journal && sudo systemd-tmpfiles --create --prefix /var/log/journal,再在 journald.conf 设 Storage=persistent,systemctl restart systemd-journald。

母本对应

母本章节PDF 页本书落点程度
鸟哥第 17 章 认识系统服务(daemons)p.841–8850.12.1–0.12.3完整(服务管理,systemd 改写)
鸟哥第 18 章 认识与分析登录文件p.886–9200.12.4完整(日志查询与轮转)
鸟哥第 19 章 开机流程、模块管理与 Loaderp.921–9700.12.1、0.12.7完整(启动链路与救援)
鸟哥第 15 章 例行性工作调度(crontab)p.753–7750.12.5精选(cron 与 timer 对照)
shell-book第 16 章 脚本控制p.368–3980.12.5背景(at/cron 调度)
鸟哥第 17 章 17.2–17.4 systemctl/timerp.848–8770.12.15完整(systemctl cat/show 对照;systemd-analyze 为补充)
鸟哥第 18 章 18.4–18.5 journald/journalctlp.910–9170.12.16完整(启动 ID 与跨启动取证为补充)
鸟哥第 19 章 19.4 开机问题解决p.963–9660.12.15–0.12.17背景(rescue/rd.break)
systemd 文档systemd.unit(5)、systemd.timer(5)、journald.conf(5)man0.12.2–0.12.4、0.12.8–0.12.12完整(unit 语义、timer、journald 限额)

下一站:0.13 网络配置与排障从「服务有没有起来」走到「端口通不通」:ip、ss、DNS、抓包和防火墙,最后给一棵从链路到应用的故障树。