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
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.service | start/stop/status |
.socket | 监听套接字,按连接拉起服务 | docker.socket | list-sockets |
.target | 一组 unit 的集合 | multi-user.target | isolate、get-default |
.timer | 定时触发另一个 unit | backup.timer | list-timers |
.mount | 挂载点 | data.mount | findmnt、mount |
.path | 文件/目录变化触发 | cups.path | status |
.slice | cgroup 分组 | system.slice | systemd-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 |
| 按内容 grep | journalctl -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 的优势是日志、依赖、随机延迟、错过后补跑。两者不是宗教选择,按任务性质选:
| 维度 | cron | systemd timer |
|---|---|---|
| 配置 | crontab -e、/etc/cron.d | .timer + .service |
| 日志 | 邮件/重定向,默认没有 | journalctl -u 自动收 |
| 依赖 | 无 | After/Requires/RequiresMountsFor |
| 错过补跑 | 无(anacron 另装) | Persistent=true |
| 随机延迟 | 自己 sleep $RANDOM | RandomizedDelaySec= |
| 环境 | 极简,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 device | dmesg、lsblk | initramfs 缺驱动、根 UUID 错 |
| 进 emergency/rescue | systemctl --failed、journalctl -b -1 | 哪个 unit 失败 |
| 服务没起来 | systemctl status foo、journalctl -u foo -b | Result、退出码 |
| 服务启动慢 | systemd-analyze critical-chain foo.service | 关键路径上的依赖 |
| 依赖成环 | `journalctl -b | grep -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 能看到反向依赖。
- 「
systemctl enable会立刻启动服务」——它只创建开机符号链接;要立刻启动用enable --now或start。 - 「
After=表示依赖」——它只排序,不保证对方成功;要依赖用Requires=/Wants=再加After=。 - 「改了 unit 文件不用做什么」——手动改
/etc/systemd/system/*.service后必须daemon-reload,否则 systemd 仍用旧配置。 - 「
Type=oneshot也能Restart=always」——不允许;oneshot 只能用Restart=on-failure,而且定时任务的主要重试应该是下一次 timer。 - 「cron 和 systemd timer 的环境一样」——cron 环境极简、不读
.bashrc;timer 可以用Environment=/EnvironmentFile=,但也不会自动继承登录 shell 的环境。 - 「
journalctl默认永久保存」——Ubuntu 默认Storage=auto,/var/log/journal不存在时日志只在内存,重启就没了;要持久化得显式配置。
0.12.8 依赖语义:排序、需求与传播
0.12.2 区分了 After 与 Requires,这一节把剩下的依赖关键字补齐。它们只做三件事:排序(谁先启动)、拉取(是否把对方拉起来)、传播(对方停止/失败时我怎么办)。
| 关键字 | 排序 | 拉取 | 失败/停止传播 | 典型用法 |
|---|---|---|---|---|
After= | 是 | 否 | 无 | 仅表达「等我之前」 |
Before= | 是 | 否 | 无 | 反向排序 |
Wants= | 否 | 是(弱) | 无 | 可选依赖 |
Requires= | 否 | 是(强) | 对方启动失败→我不启动;对方停止→我停止 | 强依赖 |
Requisite= | 否 | 否 | 对方未启动→我失败 | 只检查不拉起 |
BindsTo= | 否 | 是 | 对方消失/失败→我立即停 | 绑定设备/网络 |
PartOf= | 否 | 否 | 对方重启/停止→我也重启/停止 | 一组服务的联动 |
Conflicts= | 否 | 否 | 启动我→停止对方 | 互斥服务 |
四条最容易记错的规则:
Requires=不带排序。Requires=db.service只保证 db 被拉起,不保证它先于你启动;要顺序就再写After=db.service。两者都写才是通常想要的「先起 db,再起我」。Requires=的「停止传播」只在同一事务里可靠。对方后来被手动停掉,依赖方是否停,取决于事务与BindsTo=;需要「对方一没我就停」用BindsTo=。Wants=失败不算我失败。它是「尽力而为」,适合网络、监控、缓存这类可缺的依赖。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 立即返回,而服务其实还没准备好;或者相反,一直等到超时。
| 类型 | 启动完成的判定 | 适用 | 陷阱 |
|---|---|---|---|
simple | fork+exec 后立即算启动 | 大多数前台进程 | 二进制不存在也显示成功 |
exec | execve 成功后才算 | 推荐替代 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 | 日志正文 | 应用/内核 |
PRIORITY | 0 emerg … 7 debug | 应用/syslog |
SYSLOG_IDENTIFIER | 传统 tag,如 sshd | 应用 |
_PID / _UID / _GID | 发送者进程/用户 | journald(可信) |
_COMM / _EXE / _CMDLINE | 进程名/可执行文件/命令行 | journald(可信) |
_SYSTEMD_UNIT | 所属 unit | journald |
_SYSTEMD_CGROUP | cgroup 路径 | journald |
_BOOT_ID | 本次启动 ID | journald |
_TRANSPORT | kernel、stdout、syslog、journal | journald |
__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 都能「按时跑」,但时间模型、环境、失败可见性完全不同:
| 维度 | cron | systemd timer |
|---|---|---|
| 时间模型 | 墙钟,机器关机就错过 | OnCalendar= 墙钟;OnBootSec=/OnUnitActiveSec= 单调时钟 |
| 错过补跑 | 不补(anacron 例外) | Persistent=true 补一次 |
| 环境 | 极简 PATH、/bin/sh、无登录环境 | 明确的 Environment=/EnvironmentFile= |
| 日志 | 邮件/stdout,常丢 | journal 自动收集,按 unit 查 |
| 并发控制 | 自己 flock | 同一 unit 不会并发启动 |
| 随机延迟 | 自己 sleep $RANDOM | RandomizedDelaySec= |
| 依赖 | 无 | After=/Requires=/RequiresMountsFor= |
| 精度 | 分钟 | 默认 1 分钟,可调到秒/微秒 |
cron 的三个坑每次都有人踩:
- PATH 不是你的 PATH。cron 默认
PATH=/usr/bin:/bin,/usr/local/bin里的命令找不到。在 crontab 顶部写PATH=/usr/local/bin:/usr/bin:/bin,或全部用绝对路径。 %是特殊字符。crontab 里date +%F的%会被当成「命令输入换行」,必须写成\%,或者把命令放进脚本。/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 / delaycompress | gzip / 延迟一轮 | 配合 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 激活/并行会让它偏离真实因果 |
verify | unit 文件语法、依赖、可执行文件是否成立 | Unknown key、not executable、ordering cycle | 不检查运行时权限、目录、沙箱、SELinux |
calendar | OnCalendar= 下一次/下几次触发 | 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 show | systemd 已加载的 unit 对象 | 合并后的属性、FragmentPath、DropInPaths、Result | 注释、被忽略的未知键 |
systemctl status | show + 最近日志 | 状态、PID、退出码 | 完整依赖图 |
journalctl -u | journal | 启动/停止/应用输出 | 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 err | priority ≤ 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 与定时任务陷阱
| # | 现象 | 原因 | 修复/判断 |
|---|---|---|---|
| 1 | After= 写了但顺序仍不对 | 只排序、没拉取;对方没启动 | 加 Requires=/Wants= |
| 2 | 服务启动即 203/EXEC | 路径/执行位/shebang 错 | systemctl cat、ls -l、file |
| 3 | 服务无限重启拖垮机器 | Restart=always 无 StartLimit | StartLimitBurst=、RestartSec= |
| 4 | StartLimit 放在 [Service] 无效 | 它属于 [Unit] | 移到 [Unit] |
| 5 | ProtectSystem=strict 后写不了状态 | 没给 StateDirectory=/ReadWritePaths= | 加目录或放宽 |
| 6 | Type=simple 显示启动成功但端口没开 | 只保证 fork/exec | 改 Type=exec/notify + 健康检查 |
| 7 | journal 重启后丢失 | Storage=auto 且无 /var/log/journal | 建目录、设 persistent |
| 8 | 日志突然变少 | journald 限流 | 查 Suppressed,调 RateLimitBurst= |
| 9 | timer 到点没跑 | 未 enable、日历表达式错、service 失败 | list-timers、systemd-analyze calendar、journalctl |
| 10 | 错过的时间没补 | 没写 Persistent=true | 加上;只补一次 |
| 11 | cron 命令找不到 | PATH 太短 | crontab 顶部设 PATH 或用绝对路径 |
| 12 | cron 的 date +%F 截断命令 | % 未转义 | 写 \% 或放进脚本 |
| 13 | logrotate 没切日志 | 时间/大小条件未满足、状态文件未更新 | logrotate -d 看判断 |
| 14 | blame 第一名就是启动瓶颈 | blame 只算 activating,且不显示 Type=simple;并行单位不挡 target | 用 critical-chain 看关键路径,plot 看并行 |
| 15 | critical-chain 没列出某个慢服务 | 只沿一条依赖链;socket 激活/并行分支不在链上 | 结合 plot、systemd-analyze dump |
| 16 | verify 通过,服务仍启动失败 | 只做静态检查,不验证运行权限/目录/沙箱/SELinux | 真启动,journalctl -u 看 Permission denied/Result |
| 17 | calendar 算出的时间和预期差一小时 | 表达式按本地时区/DST 解释;跳过的时刻不存在 | 用 --base-time 在切换日验证,或显式写 UTC |
| 18 | journalctl -b -1 为空 | 未持久化、旧启动被 vacuum、MaxRetentionSec 到期 | --list-boots、/var/log/journal、--disk-usage |
| 19 | 时钟跳变后 --since 查不到日志 | 过滤用墙钟,NTP 回拨导致时间倒退 | 用 -b + _BOOT_ID,-o short-monotonic |
| 20 | systemctl cat 的内容和运行行为不一致 | 显示磁盘文件;改完未 daemon-reload 或生成器/模板展开不同 | systemctl show 看 FragmentPath/DropInPaths/ExecStart |
| 21 | drop-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 service | User=、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-run | logrotate -d 输出符合预期 | 0.12.6 |
| 进 rescue 修一次 fstab/unit | 能说出 remount,rw 与 daemon-reload | 0.12.7 |
用 critical-chain 找关键路径 | 能读出 @ 与 +,不被 blame 误导 | 0.12.15 |
用 -b -1 对比上次启动 | 能解释空结果与持久化条件 | 0.12.16 |
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–885 | 0.12.1–0.12.3 | 完整(服务管理,systemd 改写) |
| 鸟哥 | 第 18 章 认识与分析登录文件 | p.886–920 | 0.12.4 | 完整(日志查询与轮转) |
| 鸟哥 | 第 19 章 开机流程、模块管理与 Loader | p.921–970 | 0.12.1、0.12.7 | 完整(启动链路与救援) |
| 鸟哥 | 第 15 章 例行性工作调度(crontab) | p.753–775 | 0.12.5 | 精选(cron 与 timer 对照) |
| shell-book | 第 16 章 脚本控制 | p.368–398 | 0.12.5 | 背景(at/cron 调度) |
| 鸟哥 | 第 17 章 17.2–17.4 systemctl/timer | p.848–877 | 0.12.15 | 完整(systemctl cat/show 对照;systemd-analyze 为补充) |
| 鸟哥 | 第 18 章 18.4–18.5 journald/journalctl | p.910–917 | 0.12.16 | 完整(启动 ID 与跨启动取证为补充) |
| 鸟哥 | 第 19 章 19.4 开机问题解决 | p.963–966 | 0.12.15–0.12.17 | 背景(rescue/rd.break) |
systemd 文档 | systemd.unit(5)、systemd.timer(5)、journald.conf(5) | man | 0.12.2–0.12.4、0.12.8–0.12.12 | 完整(unit 语义、timer、journald 限额) |
下一站:0.13 网络配置与排障从「服务有没有起来」走到「端口通不通」:
ip、ss、DNS、抓包和防火墙,最后给一棵从链路到应用的故障树。