Type=oneshot 服务表达一段有限工作,启动命令成功结束后,本次启动才算完成。因此,它适合初始化步骤,以及依赖方需要等待任务结束的定时工作。oneshot 描述的是一次激活的进程模型,不会建立永久记录来阻止任务在重启、再次激活或机器重新开机后重跑。名字中的“一次”,不能直接推导为业务意义上的“永远只做一次”。
坏掉的样子
成功的 oneshot 通常在命令完成后变为非活动状态,这是任务已经结束的正常表现,不是守护进程崩溃的证据。设置 RemainAfterExit=yes 后,管理器会在命令退出后仍把单元记为活动。因此,active (exited) 可以完全没有主进程;如果监控假定任何活动服务都必须有一个存活 PID,就会错误判断这种单元类型。
oneshot 允许多条 ExecStart,并按顺序执行。采用普通成功规则时,某条命令失败会阻止后续启动命令运行,并让本次激活失败。但前面的命令可能已经修改文件或数据库,systemd 不会回滚这些副作用。重新尝试整个激活,就可能重复之前已经成功的部分,不能把管理器的失败状态误当作业务事务已经全部撤销。
RemainAfterExit 保存的是管理器状态,不是持久的恰好一次执行保证。再次启动已经活动的单元不会重跑,重启却会。定时器指向一个仍然活动的 oneshot 时,也不会把每个到达的时间点都变成另一次执行。只有它确实代表一个持续成立的状态时,才适合保留活动标记;需要反复触发的定时工作,应在各次执行之间回到非活动状态。
快速验证
在由 systemd 管理的临时 Linux 环境中,以管理员身份运行下列 Python 脚本。它创建一个运行时单元和临时步骤程序。实测环境为 Debian 12、systemd 252.39,执行前确认示例单元文件名尚未使用。每个步骤先记录自身名字,第二步随后故意失败:
from pathlib import Path
from tempfile import TemporaryDirectory
import subprocess
unit='xbcatm-oneshot-lab.service';path=Path('/run/systemd/system')/unit
assert not path.exists()
def ctl(*args,success=True):
result=subprocess.run(['systemctl',*args],text=True,capture_output=True,timeout=10)
assert (result.returncode==0)==success,(args,result.stderr)
return result.stdout.strip()
def value(key):return ctl('show',unit,'-p',key,'--value')
with TemporaryDirectory(prefix='xbcatm-oneshot-',dir='/run') as directory:
root=Path(directory);worker=root/'step.py';calls=root/'calls'
worker.write_text("""from pathlib import Path
import sys
root=Path(__file__).parent
with (root/'calls').open('a') as output: output.write(sys.argv[1]+'\\n')
if sys.argv[1]=='second' and not (root/'healthy').exists(): sys.exit(7)
""")
definition=f'''[Service]
Type=oneshot
RemainAfterExit=yes
TimeoutStartSec=3s
ExecStart=/usr/bin/python3 {worker} first
ExecStart=/usr/bin/python3 {worker} second
ExecStart=/usr/bin/python3 {worker} third
'''
path.write_text(definition)
try:
subprocess.run(['systemd-analyze','verify',str(path)],check=True)
ctl('daemon-reload');ctl('start',unit,success=False)
assert calls.read_text().splitlines()==['first','second']
assert value('Result')=='exit-code' and value('ExecMainStatus')=='7'
print('second command fails; third command does not run')
(root/'healthy').touch();ctl('reset-failed',unit);ctl('start',unit)
assert calls.read_text().splitlines()==['first','second','first','second','third']
assert (value('ActiveState'),value('SubState'),value('MainPID'))==('active','exited','0')
before=calls.read_text();ctl('start',unit);assert calls.read_text()==before
print('active/exited with PID 0; repeated start does not rerun')
ctl('restart',unit)
assert calls.read_text()==before+'first\nsecond\nthird\n'
print('restart executes all commands again')
ctl('stop',unit);path.write_text(definition.replace('RemainAfterExit=yes','RemainAfterExit=no'))
ctl('daemon-reload');before=calls.read_text()
for _ in range(2):
ctl('start',unit)
assert value('ActiveState')=='inactive' and value('Result')=='success'
assert calls.read_text()==before+'first\nsecond\nthird\n'*2
print('without RemainAfterExit: two starts, two successful executions')
finally:
ctl('stop',unit)
if value('ActiveState')=='failed':ctl('reset-failed',unit)
path.unlink();ctl('daemon-reload')
第一次激活只留下 first 和 second 两项记录,启动命令返回失败,退出状态为七;第三条命令没有运行。此前的记录仍然存在,让我们直接看见部分副作用,而不是假设激活失败意味着什么都没做过。这对于会修改真实数据的任务尤其关键:管理器状态并不能描述全部业务影响。
创建健康标记后再重试,三个步骤都被记录。完整文件此时是上次留下的两项,加上新的一组三项。单元状态变为活动、已退出,主进程编号为零。这组状态说明管理器保留了成功激活的记录,不是说工作程序仍在后台运行,也不能用来判断某个隐藏子进程是否继续提供服务。
单元仍活动时,再执行一次启动,记录不会变化;重启同一单元则追加一整组步骤。这直接反驳了“把迁移写成 oneshot,就能保证机器生命周期内只执行一次”的理解。管理器只是按照新的激活请求,再次执行已经声明的命令,并没有负责判断业务变更以前是否完成过。
接着脚本停止单元,把 RemainAfterExit 改成否,重新加载管理器配置,再启动两次。两次均成功结束并回到非活动状态,日志增加两组完整步骤。这适合每次受到触发就执行、完成后等待下一次触发的任务。是否再次运行,由调度与当前激活状态决定,而不是由类型名称永久封存。
示例显式设置三秒启动超时。在所核验的版本中,oneshot 默认没有启动超时,因此卡住的命令可能无限期占着激活任务。实际工作应设置符合业务时长的边界,不要把这个小实验里的三秒直接复制到耗时很长的数据库迁移,否则会把正常工作中途终止,并留下需要判断的部分结果。
任务自身仍要保证正确性
每条启动命令应是有限、前台运行的工作。如果任务需要启动长期工作者,就应给那个工作者合适的服务生命周期,而不是根据父进程成功退出来推断它会持续运行。单元类型、命令完成与业务结果,应当描述同一件事;管理器只能按声明的进程协议工作,不能猜测业务实际上希望保持什么状态。
数据库迁移是否已经执行,应依靠迁移记录、事务语义和并发控制判断。一个管理器保留的活动状态,无法协调多台主机,也无法跨越所有操作变化持续保证只做一次。应在副作用真正发生的层面让重试安全,并保留足够结果来诊断部分完成,特别是前一条命令已成功、后一条失败的情形。
检查服务记录的结果和应用输出,不要只检查定时器或依赖是否触发过它。定时器可以保持活动,同时最近一次工作已经失败。安装、调度与执行是不同的保证,应在开机流程或发布流程依赖这项任务之前,同时验证成功路径与失败路径,而不是只看到某一次启动返回零就结束验证。
延伸阅读
来源
- systemd 252 服务类型,oneshot 命令顺序、保留状态与启动超时,已核验 2026-09-12。
- systemd 252 systemctl,启动、重启与管理器重载行为,已核验 2026-09-12。
- systemd 252 定时器,目标服务已经活动时的激活行为,已核验 2026-09-12。