三个相连方块,依次标有 Observe、Decide 和 Act。

systemd 管理进程的启动、失败与停止

服务管理器把进程启动、失败和停止变成可以观察的状态变化。使用 systemd 时,单元可以明确前台命令、重启策略与输出目的地,不必再靠一个只会反复拉起程序、却说不清为什么停止的 shell 循环。但这并不能证明运行中的应用已经能响应请求,也不能证明重新启动一定修好了原来的故障。管理生命周期与验证业务可用性,需要各自成立。 坏掉的样子 服务类型必须匹配程序的启动契约。Type=simple 很早就认为启动成功,此时可执行文件甚至还不一定已经成功执行。Type=exec 则等待执行程序成功,因此缺少可执行文件时,启动任务可以直接失败。不过,这两种类型都不会自动等待应用连接数据库、加载状态或完成业务初始化。看到启动命令返回成功,还不能据此开始向它发送必须成功的业务请求。 如果程序自行转入后台,就可能与预期前台主进程的单元不一致。应使用程序的前台模式,让管理器掌握生命周期,或者按照程序真实的守护化协议选择有文档依据的类型。不要靠进程名称搜索来猜主进程编号:名字可能重复,包装脚本也可能把错误的进程留作单元主进程,导致观察、停止和重启的对象都不符合预期。 重启策略也要有清楚边界。Restart=on-failure 覆盖的是规定的失败退出,并不是所有终止信号,更不是所有不健康状态。一个卡住却没有退出的进程,不会自动产生重启事件。应该结合有意义的应用健康观察与有限重试,而不是把单元状态为活动,直接等同于服务已经恢复对外工作。 快速验证 在 PID 1 为 systemd 的一次性 Linux 环境中,以管理员身份运行下面的 Python 脚本。它只写入明确命名的临时运行时单元和一个临时工作目录。记录中的环境是隔离容器内的 Debian 12、systemd 252.39。执行前确认两个单元文件名尚未被使用,避免与已有服务混淆: from pathlib import Path from tempfile import TemporaryDirectory import subprocess,time unit='xbcatm-supervise-lab.service';bad='xbcatm-exec-failure-lab.service' unit_path=Path('/run/systemd/system')/unit;bad_path=unit_path.with_name(bad) assert not unit_path.exists() and not bad_path.exists() def run(args,success=True): result=subprocess.run(args,text=True,capture_output=True,timeout=10) assert (result.returncode==0)==success,(args,result.returncode,result.stderr) return result.stdout.strip() def value(name,key):return run(['systemctl','show',name,'-p',key,'--value']) def wait_for(check): deadline=time.monotonic()+5 while not check(): assert time.monotonic()<deadline,'state transition timed out' time.sleep(0.02) with TemporaryDirectory(prefix='xbcatm-supervise-',dir='/run') as directory: root=Path(directory);worker=root/'worker.py';ready=root/'ready' worker.write_text("""from pathlib import Path import os,signal,sys signal.signal(signal.SIGTERM,lambda *_: sys.exit(0)) Path(__file__).with_name('ready').write_text(str(os.getpid())) print('worker ready',flush=True) while True: signal.pause() """) unit_path.write_text(f'''[Unit] Description=Disposable supervision exercise StartLimitIntervalSec=10s StartLimitBurst=3 [Service] Type=exec ExecStart=/usr/bin/python3 -u {worker} Restart=on-failure RestartSec=200ms TimeoutStopSec=2s StandardOutput=journal StandardError=journal ''') bad_path.write_text(f'[Service]\nType=exec\nExecStart={root}/missing-program\n') try: run(['systemd-analyze','verify',str(unit_path)]) run(['systemctl','daemon-reload']);run(['systemctl','start',unit]) wait_for(lambda: ready.exists() and ready.read_text()==value(unit,'MainPID')) first=value(unit,'MainPID');assert first!='0' first_invocation=value(unit,'InvocationID') run(['systemctl','kill','--kill-who=main','--signal=KILL',unit]) wait_for(lambda: value(unit,'MainPID') not in ('0',first) and ready.read_text()==value(unit,'MainPID')) assert int(value(unit,'NRestarts'))==1 print('SIGKILL: replacement PID confirmed; NRestarts=1') run(['journalctl','--sync']) for invocation in (first_invocation,value(unit,'InvocationID')): logs=run(['journalctl','_SYSTEMD_INVOCATION_ID='+invocation,'--no-pager','-o','cat']) assert 'worker ready' in logs print('journal contains both application startups') run(['systemctl','stop',unit]);time.sleep(0.3) assert value(unit,'ActiveState')=='inactive' and value(unit,'MainPID')=='0' print('manager stop leaves service inactive') run(['systemctl','daemon-reload']);run(['systemctl','start',bad],success=False) assert value(bad,'Result')=='exit-code' and value(bad,'ExecMainStatus')=='203' print('Type=exec reports missing executable: status 203/EXEC') finally: run(['systemctl','stop',unit,bad]) for name in (unit,bad): if value(name,'ActiveState')=='failed': run(['systemctl','reset-failed',name]) unit_path.unlink();bad_path.unlink(missing_ok=True) run(['systemctl','daemon-reload']) 工作进程始终留在前台,并写出自己的启动标记。脚本等待该标记与管理器报告的主进程编号一致,再只向受管理的主进程发送 SIGKILL。随后确认编号发生变化,且自动重启次数为一,从而证明管理器确实替换了被终止的那个进程,并非只是原进程仍然存活,或者恰巧找到了同名程序。 ...

十一月 1, 2026 · 2 分钟 · XB Field Notes
以观察、判断和行动三个面板呈现定时任务重试验证。

定时任务需要能够安全重跑

调度器启动的是一次尝试,业务关心的通常是某个逻辑任务是否完成。一笔每日额度可能因为报错、重启或人工补跑而尝试多次。如果每次尝试都生成新的身份,重复执行就无法与新工作区分。应使用能够代表预期任务的键,例如任务名称与名义计划时间,并在修改业务状态之前定义重复键的含义。尝试日志中的唯一编号可以保留,但不能代替业务层面的去重身份。 调度触发与业务执行是两件事 不能假设所有调度器都会补跑遗漏工作。普通 cron 的具体行为随实现变化,没有统一补跑承诺。启用 Persistent=true 的 systemd 日历定时器,在停用期间至少错过一次触发时,可以在重新启用后补触发;这不等于把所有错过的时间点逐个重放。Kubernetes 文档也明确说明,某些情况下 CronJob 可能重复创建 Job,或者没有创建对应任务。 因此要先确定业务规则:过时任务直接跳过、每个缺失周期都处理,还是对当前状态做一次校正。去重键应使用名义上的逻辑周期,并明确时区。实际启动时间会随重试变化,不能用于识别同一次计划工作。诊断需要的尝试编号与业务身份应分开记录,这样才能同时看见执行了几次尝试,以及到底完成了几个逻辑周期。 坏掉的样子 先查询有没有处理过,再单独修改数据,存在竞态。两个工作进程可能都看到键不存在,然后都应用业务效果。进程锁也许能阻止一台机器上的重叠运行,却不能让业务更新与完成标记文件自动形成原子操作。如果更新成功后、写标记之前失败,重试就会重复生效;先写标记再执行业务,则可能把尚未发生的工作误记为完成。调整两个步骤的先后顺序不能消除中间窗口。 当业务状态与完成记录位于同一个事务数据库时,应在唯一约束下共同提交。提交之前失败,两者都不应留下;提交之后重试,应识别已经完成的逻辑键。SQLite 的 BEGIN IMMEDIATE 会提前开始写事务,其他写入者可能等待,也可能因为繁忙而失败。超时限制等待长度,并不保证任何并发量下都不会冲突;调用方仍需正确区分重试、失败和重复完成。 这个原子性边界不覆盖外部邮件、支付或 HTTP 调用。本地回滚无法撤销远端已经接受的效果。此类流程需要远端提供合适的幂等契约,或采用持久投递设计并在接收方去重。同一个键如果可能携带不同负载,还需要明确拒绝这种冲突的规则。下面示例始终增加固定额度,因此只验证同一逻辑操作的重复尝试,没有把变更参数的重用键视为新请求。 快速验证 本轮以 Python 3.12.14 与 SQLite 3.53.1 完整运行下列示例,实际使用临时数据库与并发线程。 保存为 idempotent-jobs.py 后运行 python3 idempotent-jobs.py。脚本使用临时 SQLite 数据库和八个工作线程,每个线程建立独立数据库连接。业务效果是给余额增加十个单位;非空主键记录已经完成的每日逻辑额度发放,并与余额修改处于同一个事务中。实验不需要实际修改系统定时器,也不会访问外部服务。 from concurrent.futures import ThreadPoolExecutor from pathlib import Path from tempfile import TemporaryDirectory from threading import Barrier import json import sqlite3 with TemporaryDirectory() as directory: database = str(Path(directory) / 'jobs.db') with sqlite3.connect(database) as connection: connection.executescript(''' CREATE TABLE balance (id INTEGER PRIMARY KEY, units INTEGER NOT NULL); INSERT INTO balance VALUES (1, 0); CREATE TABLE completed (logical_key TEXT PRIMARY KEY NOT NULL); ''') def run(key, fail=False, gate=None): connection = sqlite3.connect(database, timeout=10, isolation_level=None) try: if gate: gate.wait(timeout=5) connection.execute('BEGIN IMMEDIATE') inserted = connection.execute( 'INSERT INTO completed VALUES (?) ON CONFLICT(logical_key) DO NOTHING', (key,)) if not inserted.rowcount: connection.execute('COMMIT') return 'duplicate' connection.execute('UPDATE balance SET units = units + 10 WHERE id = 1') if fail: raise RuntimeError('injected failure before commit') connection.execute('COMMIT') return 'applied' except Exception: if connection.in_transaction: connection.execute('ROLLBACK') raise finally: connection.close() key = 'daily-credit:2026-09-13T00:00:00Z' try: run(key, fail=True) except RuntimeError as error: assert str(error) == 'injected failure before commit' with sqlite3.connect(database) as connection: assert connection.execute('SELECT units FROM balance').fetchone() == (0,) assert connection.execute('SELECT count(*) FROM completed').fetchone() == (0,) gate = Barrier(8) with ThreadPoolExecutor(max_workers=8) as pool: results = list(pool.map(lambda _: run(key, gate=gate), range(8))) assert results.count('applied') == 1 and results.count('duplicate') == 7 assert run(key) == 'duplicate' assert run('daily-credit:2026-09-14T00:00:00Z') == 'applied' with sqlite3.connect(database) as connection: units = connection.execute('SELECT units FROM balance').fetchone()[0] completed = connection.execute('SELECT count(*) FROM completed').fetchone()[0] assert (units, completed) == (20, 2) print(json.dumps({'rolled_back_attempt_left_no_state': True, 'concurrent_applied': results.count('applied'), 'concurrent_duplicates': results.count('duplicate'), 'final_units': units, 'completed_logical_jobs': completed})) 第一次尝试在完成插入和余额更新之后、提交之前故意抛出异常。脚本验证回滚之后余额仍为零,也不存在完成键。随后八个工作线程同时处理同一个逻辑时间点,必须只有一个返回 applied,另七个返回 duplicate。唯一键判断与业务修改依靠真实数据库串行化,不是通过假的内存锁预先安排好结果。并发开始屏障让竞争条件实际存在。 ...

八月 15, 2026 · 2 分钟 · XB Field Notes
文章标题旁排列着由箭头相连的 OBSERVE、DECIDE、ACT 方框,下方有圆形指针和引导线。

最小权限是门槛,不是感觉

最小权限是把某项明确工作需要的操作,授予正确主体,并限定资源范围与有效时间。它是一份能够验证的政策,而不是令人安心的标签。应从服务实际启动身份和依赖开始梳理;闲置权限与缺失权限是不同发现,不能用同一种处理方式解决,也不能因为一个接口暂时调用成功就认为边界已经合适。 把最小值写清楚 列出服务读取的文件、写入的目录、需要访问的网络目标以及管理操作,同时覆盖启动、日志、轮转、恢复与退出。业务数据虽然只在一个目录,服务仍可能合理地需要临时空间和共享库。一份有用清单解释每项访问为什么存在,而不是笼统宣称数据目录以外的一切都不必要,更不应遗漏低频但关键的恢复路径。 对 systemd 单元,User= 与 Group= 决定运行身份,文件权限和沙箱设置进一步约束行为。ProtectSystem=strict 使单元视图中的大部分文件系统只读,但 API 文件系统有文档规定的例外,也可能另行开放写入路径。只读不等于不可读;若目的是保护秘密内容,需要独立的访问权限或可见性约束,例如适合当前服务的 ProtectHome= 政策,不能把写入失败当作读取也被隔离的证明。 检验具体边界 不同指令解决不同问题。PrivateDevices= 限制设备暴露,NoNewPrivileges= 防止通过执行程序取得新权限,RestrictAddressFamilies= 限制套接字地址族。最后这一项不是目标地址或端口防火墙。内核支持、系统或用户服务管理器、既有描述符和其他设置,都可能影响实际效果;列出一串指令名称,并不能证明已经拥有完整沙箱。 单项 capability 可能比不受限 root 更窄,却未必是一项很小的权限,其中有些覆盖广泛管理操作。授予之前,应查看它实际允许什么,以及文件所有权、ACL 或独立受限辅助程序能否更好地满足需求。测试时要在真实服务上下文中同时执行一个应允许的操作和一个相邻但应拒绝的操作,分别记录结果,避免管理员手动验证掩盖服务自身边界。 宽权限为什么总会留下来 事件期间的临时授权经常活得比事件更久。应记录负责人、原因和到期或复核时间,使恢复权限不会悄悄成为日常权限。变更后比较线上绑定与获准政策;源码仓库中存在配置文件,并不说明正在运行的工作负载真的使用了它。临时例外需要明确归宿,而不能只靠某个人记得事后撤销。 凭据寿命属于另一项控制。范围较窄的令牌在有效期内仍可被滥用,轮换权限过大的凭据也不会自动减少权限。应使用适当限定的凭据,避免把它们复制到日志或运维手册。复核既要看它能做什么,也要看工作或人员关系结束后如何撤销;权限范围、保存方式与有效时间共同决定暴露,不能互相替代。 坏掉的样子 在 Kubernetes 中,通配动词与资源会在规则的 API 组和绑定范围内授予广泛权限,但并不自动等于跨所有组和命名空间的集群管理员。即便如此,新出现的匹配资源或动词仍可能无需再次修改政策就得到授权。判断实际权限时,需要检查主体、命名空间、资源名和全部叠加授权,不能看到一个很窄的 Role 就认为总权限也一样窄。 一段时间没有使用,只能成为复核线索,不能直接证明权限应删除。恢复操作可能很重要,却不会出现在普通一周的记录里。反过来,某个角色使用过某项动作,也不能成为给所有角色都保留它的理由。使用记录应按身份和角色关联;例外需要业务依据,不能把一份全局动作集合误当作逐角色的授权证据。 过度收紧可能阻断合法发布,并诱使人绕过正规流程。删除权限之前,应提供且验证更窄的受支持路径。身份模拟本身也受权限控制,不是加一个参数就凭空变成管理员。紧急运维流程应保留责任与记录,而不是让工作流转入秘密共享账号,再把正规路径的零授权当成安全成功。 快速验证 保存为 privilege_audit.py,运行 python3 privilege_audit.py。标准库模型回放七次合成请求,另外按每个角色独立的合成复核窗口记录比较授权与使用。它不读取凭据、不查询生产身份,也不修改任何权限,因此可以安全地观察纯集合判定,同时明确实际授权验证仍然另有工作。 ...

八月 11, 2026 · 2 分钟 · XB Field Notes