Skip to content

Linux:部署受限维护任务与定时器 ​

本篇以Linux 维护任务为例;前置是服务账户、固定状态目录、flock 与 timer。源码与输入与下文配置共用同一份文件,示例未执行。

前置是权限、服务与互斥。示例未执行,需在目标环境核对权限与影响,包括脚本、systemctl 和所有准备/清理命令。下面提供读者以后在独立测试主机自跑的完整材料;本次只做文本和模板检查,正常与错误输出均为设计预期。

任务契约与依赖 ​

Linux 维护任务固定在 /var/lib/showdocs-maint 保存自己的文件系统容量报告。脚本不接受路径参数,只处理 maintenance.log、maintenance.log.1、maintenance.log.2、maintenance.log.3 四个精确名称;.lock 仅作互斥且不被轮转。MAX_BYTES 是触发阈值而非容量上限:检查旧日志达到阈值才轮转,单次报告仍可能使当前文件超过阈值。

完整文件在 examples/m5-linux:maintain.sh、showdocs-maint.service、showdocs-maint.timer、maint.env.example 及说明。依赖 Bash 4.4+、GNU coreutils 的 stat/readlink/df/date/mv/rm、util-linux 的 flock、systemd 249+。没有应用服务器、网络调用、安装脚本或额外语言包。参考 Ubuntu 22.04/24.04、Debian 12 的常见系统布局;系统服务账户管理命令及执行路径仍需按目标发行版确认。所有文件以 LF 保存,避免 Windows CRLF 让解释器或配置读入额外字符。

管理员拥有 /usr/local/libexec/showdocs-maint.sh 与 /etc/showdocs-maint.env,任务账户无权改它们。专用账户 showdocs-maint 使用同名主组、无交互登录,只拥有固定状态目录;/var、/var/lib 等父目录须由 root 管理且普通用户不可写。目录 0700,锁与日志须为任务账户拥有的 0600 普通单硬链接文件。脚本每次检查这些条件,并拒绝符号链接;同 UID 进程属于可信协作者,不能把这些 pathname 检查宣称为针对同 UID 恶意进程的无竞态隔离。

唯一维护脚本 ​

Linux 日志轮转先取得 flock 排他锁,再检查日志对象、采集 df 报告、按 .3/.2/.1 逆序移动,最后打开当前日志追加。没有长期持有日志描述符,也没有其他写者;因此不需要复制截断或通知外部进程重新打开。代码和正文从同一仓库文件 include,避免两份源码漂移。

bash
#!/bin/bash
# Teaching source only: not executed during documentation work.
set -euo pipefail
export PATH=/usr/bin:/bin LC_ALL=C
umask 077
root=/var/lib/showdocs-maint
fail() { printf 'maintenance: %s\n' "$*" >&2; exit 1; }
[[ $# == 0 ]] || fail 'no arguments accepted'
[[ ${MAX_BYTES:-} =~ ^[1-9][0-9]{0,6}$ ]] || fail 'MAX_BYTES must be 1..9999999'
[[ -d "$root" && ! -L "$root" ]] || fail 'private directory missing or symlink'
[[ $(readlink -e -- "$root") == "$root" ]] || fail 'non-canonical directory'
[[ $(stat -c '%u:%a' -- "$root") == "$EUID:700" ]] || fail 'directory needs owner=current user, mode=700'

# Parent directories must be root-managed; same UID is a trusted writer.
# Never remove the lock file: every invocation must lock the same inode.
check_file() {
    local path=$1
    [[ ! -L "$path" ]] || fail "symlink refused: $path"
    if [[ -e "$path" ]]; then
        [[ -f "$path" ]] || fail "not a regular file: $path"
        [[ $(stat -c '%u:%a:%h' -- "$path") == "$EUID:600:1" ]] || fail "file needs owner=current user, mode=600, links=1: $path"
    fi
}
check_file "$root/.lock"
[[ -f "$root/.lock" ]] || fail 'administrator must provision .lock'
exec 9<>"$root/.lock"
flock -n -E 75 9 || exit $?
for name in maintenance.log maintenance.log.1 maintenance.log.2 maintenance.log.3; do
    check_file "$root/$name"
done

# Read the report BEFORE rotation; an observation failure leaves logs alone.
report=$(df -P -- "$root")
stamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
if [[ -f "$root/maintenance.log" ]] && (( $(stat -c '%s' -- "$root/maintenance.log") >= 10#$MAX_BYTES )); then
    # Exactly three archives; no recursive search or wildcard deletion.
    [[ ! -e "$root/maintenance.log.3" ]] || rm -- "$root/maintenance.log.3"
    [[ ! -e "$root/maintenance.log.2" ]] || mv -T -- "$root/maintenance.log.2" "$root/maintenance.log.3"
    [[ ! -e "$root/maintenance.log.1" ]] || mv -T -- "$root/maintenance.log.1" "$root/maintenance.log.2"
    mv -T -- "$root/maintenance.log" "$root/maintenance.log.1"
fi
# There is no open log descriptor while renaming. All cooperating writers hold fd 9.
printf '%s maintenance report\n%s\n' "$stamp" "$report" >>"$root/maintenance.log"
printf 'maintenance: report appended; archives<=3\n'

set -euo pipefail 使意外失败停止后续步骤,但不提供事务。若在多次 rename 中途断电,归档序号可能出现缺口;不能保证三份归档完整保留。单次同文件系统 rename 的原子性不等于整个轮转流程原子,也不等于持久化落盘。状态目录必须位于本地常规文件系统,不以网络文件系统的锁语义为教学保证。

服务、环境与计时器 ​

Linux systemd 配置把身份、权限与调度独立表达。service 的 Type=oneshot 表示有限任务;ProtectSystem=strict 建立只读文件系统视图,ReadWritePaths 将状态目录列为可写例外,仍不能代替 Unix 权限。PrivateTmp 另外提供可写的私有临时目录,API文件系统也有特殊边界,不能把这些设置称为“系统只有一个可写路径”。EnvironmentFile 不是执行 Shell:不能把 export、命令替换或 source 写入 env。MAX_BYTES 缺失或不合法会明确失败,配置文件不承载秘密。

ini
[Unit]
Description=Bounded teaching maintenance report

[Service]
Type=oneshot
User=showdocs-maint
Group=showdocs-maint
EnvironmentFile=/etc/showdocs-maint.env
ExecStart=/bin/bash /usr/local/libexec/showdocs-maint.sh
WorkingDirectory=/var/lib/showdocs-maint
UMask=0077
TimeoutStartSec=30s
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/showdocs-maint
ini
# systemd EnvironmentFile syntax, not a sourced shell program.
MAX_BYTES=1048576
ini
[Unit]
Description=Daily teaching maintenance schedule

[Timer]
OnCalendar=*-*-* 03:00:00 UTC
Persistent=true
AccuracySec=1min
RandomizedDelaySec=5min
Unit=showdocs-maint.service

[Install]
WantedBy=timers.target

timer 使用 UTC 日历时间,Persistent=true 只补一次漏过的激活;随机延迟和精度窗口使实际开始时间可晚于 03:00,主机负载也会继续影响启动。service 没有安装段,避免与 timer 之外再安排开机执行。TimeoutStartSec 允许 systemd 对卡住的任务施加超时;超时造成失败,需要保留日志判断已完成到哪一步。

日后自跑顺序与预期 ​

Linux 自跑验证只能由读者在另行授权的独立测试主机进行,以下命令会修改系统账户和配置。先确认前述依赖已存在,不为本课程自动安装。下面的 useradd 示例使用 Debian/Ubuntu 常见参数;若账户或目录已存在,先核对身份与归属,不能重复覆盖。仓库工作目录需是 examples/m5-linux,系统不存在同名业务文件。

text
# 管理员准备;仅供日后测试,本轮未执行。
sudo useradd --system --user-group --no-create-home --shell /usr/sbin/nologin showdocs-maint
sudo install -d -o showdocs-maint -g showdocs-maint -m 0700 /var/lib/showdocs-maint
sudo install -o showdocs-maint -g showdocs-maint -m 0600 /dev/null /var/lib/showdocs-maint/.lock
sudo install -d -o root -g root -m 0755 /usr/local/libexec
sudo install -o root -g root -m 0644 maintain.sh /usr/local/libexec/showdocs-maint.sh
sudo install -o root -g root -m 0644 maint.env.example /etc/showdocs-maint.env
sudo install -o root -g root -m 0644 showdocs-maint.service /etc/systemd/system/showdocs-maint.service
sudo install -o root -g root -m 0644 showdocs-maint.timer /etc/systemd/system/showdocs-maint.timer
sudo systemctl daemon-reload
sudo systemctl start showdocs-maint.service
sudo systemctl show showdocs-maint.service -p Result -p ExecMainStatus
sudo journalctl -u showdocs-maint.service --no-pager -n 20
sudo systemctl enable --now showdocs-maint.timer
sudo systemctl list-timers showdocs-maint.timer

预期第一次服务退出为 0,journal 出现 maintenance: report appended; archives<=3,当前日志内有 UTC 时间和 df -P 报告。oneshot 完成后 inactive 可以正常;timer 等待下次触发。报告设备名、容量和具体时间取决于主机,不应硬编码预期字节值。

为了日后验证轮转,先停 timer 并等待当前任务结束,再把 env 中 MAX_BYTES 改为 1;依次手动启动 service 五次,预期最多三个归档且当前日志仍存在。修改 EnvironmentFile 内容由下次启动重新读取,不靠 daemon-reload 读取变量;恢复 MAX_BYTES=1048576 后再恢复 timer。这个检验只观察任务自己创建的文件,不能拿真实业务日志代替。

失败输入与恢复推演 ​

Linux 失败预期包括 MAX_BYTES=0、缺失 .lock、目录 mode=777、任意日志名为符号链接或多硬链接,脚本都应拒绝并退出 1;已有文件不被自动修权限。两个遵守约定的脚本同时进入时,一个持锁,另一个返回 75;systemd 将其记作失败,避免把未做工作伪装为成功。竞争条件应在独立测试环境验证。

若返回 No space left on device 或 Read-only file system,不能一味重试。先暂停调度并区分容量、inode、配额和挂载状态,再复制可读日志到独立位置保存;当前文件可能只写入部分报告。若轮转中断,检查四个精确文件的内容和时间,空缺序号不必人为补齐,恢复后下一次写入可继续;已删除的最老归档无法由本脚本找回。若 .lock 遭外部删除,先停止所有实例再重建,不能在仍有人持锁时重建。

综合练习与参考解答 ​

题目:能否把 root 改为命令行传入的路径,就作为通用日志清理器?能否删掉 flock,因为 timer 不并发启动同一 service?

参考解答:两者都不能直接做。任意目录引入路径穿越、符号链接、非私有父目录和误删范围,需要重新定义信任模型;手动脚本、第二种调度器和其他写者仍会产生竞争。当前最小实现只承诺固定私有目录、可信同账户协作和三个归档;扩大用途不是本例目标。维护与清理给出完整撤回顺序。

继续阅读