# 磁盘压力下，代理审计日志必须故障关闭

如果代理网关在无法持久记录操作后仍继续执行，它就破坏了自己的安全边界。磁盘压力不是一个无关紧要的可观测性问题。它决定下一次 API 请求或 SSH 命令是否会留下操作员能够检查和验证的证据。

安全规则很明确：在审计写入器耗尽空间之前，拒绝新的、需要凭据的调用。更早发出告警。只有在网关仍能记录完成或失败结果的情况下，才允许已经越过准入边界的工作完成。当追加失败时，保留磁盘上已有的密文，不要把日志当成以后可以随手清理的普通文件。

## 审计日志必须属于授权流程

如果网关的作用是为代理操作设置一个由人控制的边界，就不能把审计日志称为“尽力而为”。一旦代理可以请求使用凭据的 HTTP 请求或 SSH 命令，这个请求的记录就属于授权决定的一部分。如果网关执行了操作却丢失记录，操作员之后就无法区分一次合法运行和一次无法追责的运行。

团队经常把两种不同的故障混在一起：

- 派生索引不可用，导致控制面板或活动视图无法更新。
- 加密源日志无法接受持久事件追加。

第一种故障令人不便。第二种故障会改变网关是否可以采取新的外部操作。不要给它们设置相同的严重级别，也不要使用相同的恢复路径。

Sallyport 的模型清楚体现了这种区别：Sessions 和 Activity 日志都从一份加密、哈希链式审计日志生成，而不是各自成为独立的事实来源。投影可能延迟，也可能需要重建。无法写入的加密链才是必须保持完整的记录。

这种安排也让准入规则更容易表述。分派操作前，网关必须确认存储容量和写入器状态足以至少记录这次尝试。分派之后，必须记录结果。如果操作本身会产生不可逆影响，例如修改远程生产环境设置，网关应在分派前记录持久的意图事件，分派后再记录结果事件。

不要承诺存储层无法支持的精确时间顺序。应承诺更强也更有用的保证：每个获准操作都有一条持久且有序的记录，其中包含请求身份、授权上下文和最后已知结果。如果网关无法维持这项保证，就不应再批准新的操作。

## 告警和拒绝是两种不同状态

在拒绝调用的同一刻才发出告警，必然导致糟糕的运营决策。操作员直到代理已经被阻止时才得知问题，然后必须在压力下决定是删除数据、停止工作，还是削弱审计规则。存储策略需要多个状态，并明确规定状态之间如何转换。

使用四种状态：

1. **正常**：可用容量超过运行预留空间，并且最近一次持久性检查成功。
2. **警告**：容量低于警告阈值，但网关仍保有完整预留空间。新调用继续执行，操作员收到一次告警。
3. **受限**：容量低于预留空间，或写入器出现了尚未达到硬故障边界的临时错误。不要批准会产生大型记录或多条事件的操作。只允许已经获准的工作继续，并持续测试写入器。
4. **拒绝**：网关无法持久追加审计事件，或剩余容量无法覆盖最小记录预算。拒绝所有新的、需要凭据的调用。

单独使用百分比无法定义这些状态。在 4 TB 卷上，剩余 5% 可能绰绰有余。在小容量卷上，剩余 5% 可能在一次代理运行、一次系统更新或一阵详细的远程命令输出中消失。如果存储策略直接套用监控模板中的数字，而不是根据网关实际的写入行为设置阈值，就会失效。

警告阈值为操作员争取时间。预留空间保护证据链。拒绝状态保护“每个获准操作都有记录”这一承诺。它们承担不同的职责，因此不应使用同一个数字。

良好的状态转换还应避免状态抖动。当可用容量低于阈值时进入警告状态，只有容量升到更高的恢复阈值以上，并且写入器完成一次持久测试追加后才离开警告状态。受限状态也应采用同样的滞回机制。否则，接近边界的代理运行可能会因为几个文件系统块的变化，在允许和拒绝调用之间来回切换，既令人困惑，也难以调查。

## 预留空间是记录预算，不是乐观的空闲空间

通过计算停止批准新工作后仍可能需要存在的记录来设置硬性预留空间。计算不需要虚假的精确性，但输入必须保守，并且能在事件期间解释清楚。

先回答这些问题：

- 最大的已批准操作记录有多大？应包括加密载荷、策略保留的请求头、结果元数据和哈希链字段。
- 网关进入受限状态时，最多有多少个操作正在执行？
- 一次操作是否可能产生单独的完成记录、超时记录或重试记录？
- 源日志旁边有哪些本地文件会增长，例如分段元数据、索引、临时文件或崩溃报告？
- 操作系统和应用需要多少空间才能正常报告该状况？

假设网关允许 12 个操作同时执行，每个操作都可能需要一条意图记录和一条结果记录，保守的加密记录预算为 128 KiB。单是操作记录就会占用 3 MiB。这还不是预留空间。还要加上预计最大的日志投影批次、文件轮换开销、故障标记，以及针对分配行为的充足安全余量。最后向上取整到一个便于监控的数字。

有用的结果应是一份书面策略，而不是一个神秘数字：

```text
warning threshold: 2 GiB free on the audit volume
hard reserve:      512 MiB reserved for audit completion and recovery records
admission budget:  256 KiB minimum available per new action
recovery threshold: 3 GiB free plus one durable test append
```

这些数值只是示例，不是所有 Mac 的默认值。只有短 API 调用的单台开发机，预留空间可能小于一台让自主代理执行长时间 SSH 任务的共享构建机。策略必须反映网关所记录的最坏可信输出，而不是安静一周里的平均请求大小。

Apple 当前的 APFS 文档还指出了一个容易被百分比告警忽略的问题：应检查某项操作所需的空间是否可用，而不是试图根据分区上的可用空间推导一个可靠的总量。APFS 还使用空间共享、克隆和稀疏文件，因此表面上剩余的空间与可立即使用的空间，不一定像旧式固定分区磁盘那样表现一致。

对审计写入器来说，这意味着准入检查应问：“现在是否能安全承担这份记录预算？”而不是问：“菜单栏是否仍显示非零的可用空间？”

## 追加失败后，网关必须立即改变行为

把追加失败视为状态转换，而不是写入调试日志后再试一次。临时中断时重试可能合理，但不能把重试当成继续发送未记录调用的许可。

设想一个合理的故障过程。由于本地开发工具创建了大型缓存，审计卷的可用空间已经低于预留值。代理要求网关执行一次 HTTP 部署操作。网关写入意图事件，分派请求，收到成功响应，随后文件系统返回空间不足错误，导致网关无法追加完成事件。

此时网关知道三件事：它批准了这个操作，外部系统可能已经发生变化，而正常的完成记录缺失。正确做法是保留持久的意图事件，如果仍有安全通道则记录故障标记，停止新的调用，并向操作员显示操作标识符和存储故障。不能悄悄重新运行请求，因为重试可能复制远程副作用。

再看一个更糟的过程。意图追加在分派之前就失败了。网关必须拒绝该操作。它没有持久依据声称该操作已被请求、批准或执行。构建正在等待时，用户可能会觉得这很烦，但另一种选择是产生任何人都无法有把握重建的审计缺口。

同步期间发现的故障也遵循同一规则。Apple 的 fsync 手册说明，fsync 会推动修改过的数据和属性进入永久存储，并且排队的 I/O 操作可能导致 fsync 返回与读写相关的错误。手册还警告，普通刷新本身并不能保证断电时物理介质上的顺序。因此，软件必须定义自己的持久性边界，不能把内存中的追加等同于已提交事件。

使用一个小型故障分类器：

```text
append or sync succeeds                 -> action may proceed or complete normally
append fails before dispatch             -> deny the action
append fails after dispatch              -> deny new actions, preserve intent, raise incident
sync reports I/O failure                 -> deny new actions, preserve files, investigate storage health
space check below admission budget       -> deny this new action before dispatch
```

不要为了让当前写入成功而删除旧分段。那会把容量事件变成证据销毁事件。

## 写入器需要明确的持久提交边界

仅仅把字节追加到打开的文件中，并不意味着审计问题已经解决。写入器需要一个边界，用来告诉网关的其他部分某个事件何时真正成为持久链的一部分。

让这个边界小而明确。一种可行模式是使用只追加的分段文件，每个事件携带前一事件的哈希，并配合一个小型分段清单。具体编码可以变化，但操作顺序不应变化。

```text
1. Serialize the next encrypted event with sequence number N and previous hash H(N-1).
2. Append the complete event frame to the active segment.
3. Sync the segment file and check the result.
4. Update the manifest with the new high-water sequence and segment hash.
5. Sync the manifest and check the result.
6. Only now mark event N as committed to the dispatcher.
```

清单很重要，因为恢复过程需要回答“哪些完整的事件帧算作有效？”崩溃或磁盘已满后留下的尾部不完整帧，并不会因为其中一部分字节到达分段文件就成为已提交事件。帧长度、认证数据、序列号，以及清单中已提交的最高水位，可以让恢复过程拒绝含糊状态，而不是靠猜测处理。

不要用重写 JSON 文件的方式解决这个问题。文件系统可能有空间创建新文件，却没有空间完成重命名；崩溃也可能让旧索引和新分段并存。只追加分段加上一个小型清单，更容易推理故障行为，也更容易离线验证。

这里还要区分两件事：加密保护事件内容，哈希链保护有序连续性。但这两种属性都不能证明追加已经持久化。写入器必须先建立持久性，验证器才能确认保留下来的序列没有被修改。

Sallyport 的 `sp audit verify` 可以在不使用保险库密钥的情况下，对密文离线验证加密哈希链。发生存储事件后，这项验证很有用，因为操作员可以在解锁保险库或重新启动代理工作前，先检查保留的证据。

## 在一次性审计卷上进行演练

磁盘压力演练应证明每次状态转换时的行为，而不只是证明写入最终会报错。使用磁盘映像或隔离的测试卷，只让非生产网关实例把审计数据存放在那里，并在演练结束后删除它。不要填满正常的 Mac 卷。测试应给一次性环境带来不便，而不是拿存放源代码和凭据的机器冒险。

在 macOS 上，为演练创建并挂载一个小型 APFS 磁盘映像：

```sh
hdiutil create -size 2g -fs APFS -volname AuditDrill /tmp/audit-drill.dmg
hdiutil attach /tmp/audit-drill.dmg
df -h /Volumes/AuditDrill
```

如果已经存在同名卷，实际挂载路径可能不同。修改测试配置前，使用 `df` 确认路径。预期输出应显示一个已挂载的文件系统，并显示大约 2 GiB 的容量：

```text
Filesystem        Size   Used  Avail Capacity  Mounted on
/dev/diskXsY      2.0G   ...   ...     ...%    /Volumes/AuditDrill
```

将测试实例的审计存储指向这个挂载路径。先生成几个已知正常的操作，记录它们的操作标识符，然后运行链验证器。这一步很重要，因为后续验证失败可能来自测试设置，而不是磁盘压力。

然后只在挂载的磁盘映像中消耗空间：

```sh
dd if=/dev/zero of=/Volumes/AuditDrill/fill.bin bs=1048576
```

当卷无法再分配块时，`dd` 会停止。这个命令故意很直接。不要使用稀疏文件命令进行测试，因为稀疏文件看起来很大，却可能没有实际消耗触发测试条件所需的块。

分阶段进行演练，不要直接冲到零空间：

1. 填充到触发警告阈值。确认新的小型操作仍能运行，并且操作员只收到一次告警。
2. 填充到触发硬性预留空间。确认网关根据准入预算拒绝新的操作。
3. 如果测试设计允许，在进入受限状态前立即批准一次受控操作，观察其最终事件能否成功记录。
4. 填充到追加或同步真正失败。确认网关进入拒绝状态，并显示故障原因。
5. 停止进程，必要时重新挂载映像，然后在删除填充文件前验证加密链。

重点不是欣赏 ENOSPC 错误，而是回答这些问题：网关是否在正确时刻失败，日志事后是否如实反映情况，操作员是否有足够证据在不猜测的情况下采取行动。

## 测试难处理的时间窗口，而不只是满盘

浅层演练会填满卷，观察拒绝，释放空间，然后宣布成功。这会错过那些真正造成审计缺口的时间窗口。

测试一个外部副作用很快完成、但审计完成记录被延迟的操作。受控目标可以是一个返回唯一请求标识符的测试 HTTP 端点。在仍有空间记录意图事件时启动操作，然后在网关提交结果事件之前耗尽测试卷的剩余空间。预期结果不一定是整齐的成功或失败状态。网关可能需要报告外部操作的最终状态有待协调。

这是一个诚实的结果。网关应显示持久的意图记录、外部系统返回的远程请求标识符，以及本地存储故障。它应拒绝后续调用。操作员可以检查测试目标，判断远程操作是否成功。绝不能把不确定性转化为第二次请求。

还要测试网关轮换审计分段时的故障。分段轮换会消耗元数据，可能涉及新文件、目录更新和清单更新。能处理普通追加，却在轮换期间失败的设计，并没有真正解决存储压力。

也要测试重启行为。追加失败后，只强制关闭一次性测试实例，然后在卷仍然已满的情况下重新启动。它应保持安全的拒绝状态，而不是因为进程重新启动就假定存储恢复正常。释放经过测量的空间，再次启动，并确认它会先验证最后一个已提交序列，然后才打开新分段。

最后，如果测试框架支持，注入权限错误或 I/O 错误。容量耗尽和 I/O 错误在阻止审计写入持久化时，都要求网关立即停止准入，但调查方式不同。释放空间可能解决 ENOSPC，却无法解决故障卷、文件系统错误或存储设备中断。

## 修复前先保留密文

审计写入失败时，人们常常急于清理，因为他们想让代理恢复运行。这种本能会把一个可恢复事件变成争议事件。先保留，再修复。

冻结受影响的审计目录。不要为了让活动视图看起来完整而压缩分段、重新生成清单、截断最后一个文件、轮换加密材料或重新运行操作。如果存储设备看起来不健康，应使用能够报告读取错误的方法复制目录，然后在副本上工作。原始目录可能是唯一能证明哪些记录真正到达存储设备的证据。

保留清单应回答五个问题：

- 首次写入失败时，哪个审计分段和清单处于活动状态？
- 验证器接受的最后一个序列是什么？
- 哪些已获准操作有意图记录，却没有结果记录？
- 哪些外部系统可以独立确认这些操作？
- 故障发生后，是否有进程修改过审计目录？

哈希链验证结果是证据，不是修复指令。如果验证在序列 8,412 处停止，就保留这一事实。除非书面恢复流程明确规定，并且原始文件已经保留，否则不要自动截断后续不完整帧。崩溃后出现部分加密帧可能是预期现象，也可能暴露写入器缺陷。只有保留原始字节，才能区分两者。

这也是源日志必须与面向用户的日志分开的另一个原因。投影恢复前，日志可能显示不完整的行。只要它告诉操作员正在追赶，这就可以接受。重建或隐藏派生视图，绝不能为了让屏幕看起来整齐而改写源证据。

## 告警必须告诉操作员网关做了什么决定

“磁盘快满了”对于操作网关来说是一个很弱的告警。它只说明机器状况，却没有说明代理活动是否仍被允许、审计证据是否面临风险，或者自上次通知以来发生了什么变化。

当网关进入警告、受限、拒绝和已验证恢复状态时发出告警。应包括能帮助操作员采取行动的字段：

```text
state: denied
reason: audit append failed with ENOSPC
audit path: /configured/audit/path
free bytes observed: 41,943,040
hard reserve: 536,870,912
last committed sequence: 8412
unresolved admitted actions: 1
new credentialed calls: denied
existing action handling: completion record could not be committed
first failure time: 2026-07-22T14:37:18Z
```

不要在告警中放入密钥、请求正文或凭据。告警需要的是能够关联到加密证据的标识符，而不是把敏感活动数据再复制一份，散落在各个通知系统中。

不要对每次低空间采样都触发通知。应在进入受限或拒绝状态时通知，然后只有在状况持续或状态恶化时才发送提醒。从正常到警告的转换可以生成工单或本地可见通知。转为拒绝状态则是一个运营事件，因为网关已经有意停止新的外部操作。

告警还应说明网关是否正在保护已经获准的记录。这个细节会改变响应方式。如果网关有足够空间完成正在执行的记录，操作员可以让这些运行结束，同时释放空间。如果分派后追加已经失败，操作员就必须在恢复自动化前调查未解决的操作。

## 恢复必须证明写入器再次健康

释放空间是必要条件，但不能证明审计写入器可以安全回到正常状态。恢复流程应让网关通过验证后才能完成状态转换。

首先保留事件材料，并记录是什么释放了空间。删除无关的构建缓存，与删除审计文件是两种不同的事件，事件记录应说明实际发生的是哪一种。然后离线验证保留的链。如果验证失败，就让网关保持拒绝状态，在允许新操作之前先调查证据。

接下来执行一次有界的恢复追加。网关应写入一条恢复事件，标明之前的拒绝状态，将它追加到新的或已有文档说明的恢复分段中，同步该分段，更新清单，并验证生成的序列。它不应在失败的分段中间悄悄恢复运行。

只有这次追加成功后，网关才应重建或追赶派生日志。日志工作可能独立失败。如果失败，应保留源证据，并报告视图不完整。不能仅仅因为活动行尚未出现，就否认某个操作已经发生。

然后应用恢复阈值。如果警告阈值设置为 2 GiB，硬性预留空间为 512 MiB，恢复阈值为 3 GiB，就不要因为写入器成功追加了一次而在 600 MiB 时重新开放调用。更高的阈值可以防止问题立即复发，也给操作员时间找出压力来源。

存储压力演练真正有价值，是因为它改变了一个具体决定：网关停止授权新工作的确切时刻。把这个时刻写下来，在一次性卷上进行测试，并把每次失败的追加当作需要保留的证据，而不是应该删除的杂物。
