先给结论,后面再解释为什么。

一、Type=oneshot 的单元,ExecStart 里的每一条命令都必须能安全地跑第二遍。判据很硬:第二次执行的副作用和第一次相同,退出码仍然是 0。

二、不要靠 ConditionPathExists 之类的条件来"实现"幂等,条件只能减少执行次数,不能保证命令本身可重入。

三、Type=oneshot 配 Restart=on-failure 基本是个陷阱,多数情况下你想要的是 Restart=no,让失败暴露出来。

一次真实的对照

mkdir 是最直观的反例:目录已存在时它返回非 0。用 systemd-run --user 起两个瞬时单元,各跑两遍,不需要往 /etc/systemd/system/ 里写任何东西。

rm -rf /tmp/exp-oneshot && mkdir -p /tmp/exp-oneshot
for i in 1 2; do
  systemd-run --user --unit=bad-$i --property=Type=oneshot \
    /bin/mkdir /tmp/exp-oneshot/workdir
  sleep 0.4
  printf 'run %s -> ExecMainStatus=%s state=%s\n' "$i" \
    "$(systemctl --user show -p ExecMainStatus --value bad-$i.service)" \
    "$(systemctl --user is-active bad-$i.service)"
done

在一台 Ubuntu 24.04、systemd 255 的机器上,输出是:

run 1 -> ExecMainStatus=0 state=inactive
run 2 -> ExecMainStatus=1 state=failed

第二遍变成 failed。journal 里的原因写得很直白:

journalctl --user -u bad-2.service --no-pager -o cat
Starting bad-2.service - /bin/mkdir /tmp/exp-oneshot/workdir...
/bin/mkdir: 无法创建目录 "/tmp/exp-oneshot/workdir": 文件已存在
bad-2.service: Main process exited, code=exited, status=1/FAILURE
bad-2.service: Failed with result 'exit-code'.
Failed to start bad-2.service - /bin/mkdir /tmp/exp-oneshot/workdir.

把命令换成 mkdir -p,同样的循环跑两遍,两次 ExecMainStatus 都是 0。差别只在这一个 -p。

真实场景里没人会手写 mkdir,但这个模式到处都是:脚本前半段做了一半失败,systemctl restart 重跑,前半段那些"只做一次"的命令集体炸掉。单元本身没错,错在命令不可重入。

常见命令的幂等改造

下面是我改别人单元文件时最常替换的一批。左边是常见写法,右边是可重入写法。

# 建目录
mkdir /opt/app/data            ->  mkdir -p /opt/app/data
# 放配置文件(顺带管权限和属主)
cp app.conf /etc/app/app.conf  ->  install -D -m 0644 -o root -g root app.conf /etc/app/app.conf
# 建软链
ln -s /opt/app/bin/x /usr/local/bin/x  ->  ln -sfn /opt/app/bin/x /usr/local/bin/x
# 建用户
useradd -r app                 ->  id -u app >/dev/null 2>&1 || useradd -r app
# 加内核参数
echo 'vm.swappiness=10' > /etc/sysctl.d/99-app.conf && sysctl -p  ->  sysctl --system
# 加载模块
modprobe br_netfilter          ->  modprobe br_netfilter   # 本身幂等,已加载时返回 0

install -D 是我最愿意推的一个:一条命令同时解决"文件不存在"“权限不对"“属主不对"三种状态,而且第二次跑不会报"文件已存在”。cp 做不到这点,cp 还得配 chmod 和 chown,三条命令里任何一条被打断都会留下半成品。

useradd 那行要注意 || 的短路写法。useradd 在用户已存在时返回非 0,直接写会让单元失败;但也不能写成 useradd -r app || true,那样连"磁盘满了建不出用户"这种真失败也一起吞掉。id -u 先探一次,语义清楚得多。

RemainAfterExit 和 Restart 的取舍

看一眼系统里现成的 oneshot 单元是怎么配的:

systemctl show -p Type,RemainAfterExit,Restart,RestartUSec apparmor.service
Type=oneshot
Restart=no
RestartUSec=100ms
RemainAfterExit=yes

RemainAfterExit=yes 的作用是:主进程退出后单元继续显示 active (exited),而不是 inactive (dead)。对"加载 AppArmor 策略"“恢复 ALSA 音量"这类做完就完、但状态需要被别的单元依赖的事情,这个开关是必须的——否则 After=/Requires= 它的单元会认为它没起来。

反过来,纯粹"跑一遍就走"的迁移脚本、一次性初始化,我倾向不写 RemainAfterExit,让它老实回到 inactive,这样 systemctl start 每次都能真的再跑一遍。

Restart 这块我的做法是:oneshot 一律 Restart=no。理由是 oneshot 的语义就是"执行一次”,配 Restart=on-failure 之后,一个不可重入的 ExecStart 会被 systemd 反复重启,每次都在同一个地方失败,日志刷满,而真正的原因被埋在几十条重复记录下面。想重试的话,把重试逻辑写进 ExecStart 指向的脚本里,比交给 systemd 好排查。

RestartUSec=100ms 是 Restart=no 时的残留默认值,没有生效,不用管它。

还有一层:条件指令的边界

ConditionPathExists=、ConditionDirectoryNotEmpty=、ExecStartPre 里加判断,这些都能减少无谓执行,但它们和幂等不是一回事。条件检查发生在单元启动的那一刻,检查通过之后到命令真正执行之间有时间窗。如果同一台机器上有别的东西(另一个单元、一个 cron、一个人手敲的命令)也在动同一个路径,条件就是过期的。

我一般把条件当优化,不当正确性保证。正确性只来自命令本身可重入。

不确定的一点:ConditionPathExists=!/path 这种取反写法在并发场景下到底有多脆,我没有构造出可复现的失败案例,只是从语义上判断它不该被当成锁用。真要互斥,用 flock 或者把状态落到一个原子重命名的临时文件上。

一份可以直接抄的骨架

[Unit]
Description=Provision app runtime directories
# 明确声明顺序,别依赖默认排序
After=local-fs.target
RequiresMountsFor=/opt/app

[Service]
Type=oneshot
RemainAfterExit=yes
Restart=no
# 每一条都能安全重跑
ExecStart=/usr/bin/install -d -m 0750 -o app -g app /opt/app/data
ExecStart=/usr/bin/install -d -m 0750 -o app -g app /opt/app/run
ExecStart=/usr/bin/install -D -m 0640 -o root -g app /opt/app/conf/app.conf /etc/app/app.conf
# 失败就停下,不要留下半成品
ExecStartPost=/usr/bin/systemd-notify --status=provisioned

[Install]
WantedBy=multi-user.target

写完先用 systemd-analyze verify 过一遍,它能在不启动单元的情况下报出依赖和语法问题:

systemd-analyze verify ./app-provision.service

这个命令不会往系统目录写文件,unit 放在当前目录就行。它报的东西不一定都是错——比如在我这台机器上它会顺带抱怨一句 Binding to IPv6 address not available since kernel does not support IPv6.,那是内核启动参数里关了 IPv6 导致的,和单元本身无关,属于要自己分辨的噪声。