---
title: Sandbox 异常退出监控
description: 说明 Sandbox 容器异常退出的实时监听、去重、补偿同步与落库/日志记录机制。
---

本文介绍 Pro 版 Sandbox 的一套异常退出监控机制。它解决的是一个很具体的问题：

- Docker sandbox 容器异常退出后，系统如何尽快发现。
- 如何区分“主动 stop/remove”与“真正异常退出”。
- 多实例共享同一个 Docker daemon 时，如何避免重复记录。
- `environment`、`project`、`user` 三类 sandbox 的记录行为为什么不同。

当前实现采用“两条链路并存”的方式：

- 实时主链路：监听 Docker `events`，第一时间捕获 `oom` / `die`。
- 补偿链路：保留现有 scheduler，在 listener 不可用、实例切换或容器已消失时继续兜底。

## 整体链路

可以先把这套机制理解成下面 4 步：

1. `SandboxContainerEventMonitorService` 竞争 Redis 锁，只有 leader 会连接 Docker `events` 长连接。
2. 监听到 `oom` 时，先只写 Redis 短期状态；监听到 `die` 时，再交给 `SandboxContainerExitRecorderService` 统一判定。
3. `SandboxContainerExitRecorderService` 结合 Docker `inspect`、labels、Redis 上下文、数据库记录，决定这次退出是不是异常。
4. 如果实时监听漏掉了，`SandboxEnvironmentContainerSyncScheduler` 在下一轮巡检时会用同一个 recorder 做补偿，因此 environment 仍能落库。

对应关系如下：

```text
Docker oom/die event
  -> SandboxContainerEventMonitorService
  -> SandboxContainerRuntimeStateService
     - expected-stop intent
     - oom mark
     - container context
     - exit dedupe signature
  -> SandboxContainerExitRecorderService
     - 判断是否异常
     - 抓 inspect / logs
     - environment 写 DB
     - 全部 sandbox 打结构化日志

Scheduler fallback
  -> SandboxEnvironmentContainerSyncScheduler
  -> SandboxContainerExitRecorderService
```

## 三个服务分别做什么

### SandboxContainerEventMonitorService

这个服务是实时监听入口。

它启动后不会让所有实例都去监听 Docker，而是先通过 Redis 锁选 leader。只有拿到锁的实例才会：

- 打开 Docker `events` 长连接。
- 只订阅 `container` 类型的 `oom` 和 `die` 事件。
- 连接断开后自动重连。

这样设计的目的是避免多实例部署时，每个实例都重复消费同一条 Docker 事件。

这里有一个关键细节：

- `oom` 事件到来时，它不会立刻记一条“异常退出”。
- 它只会在 Redis 里给这个 `containerId` 写一个短 TTL 的 OOM 标记。
- 真正的落库/日志记录发生在之后的 `die` 事件阶段。

原因是 OOM 通常只是异常的一部分信号，最终还是要以容器结束时的状态为准，否则同一次异常可能被记两次。

### SandboxContainerRuntimeStateService

这个服务不直接判定异常，它更像“短期运行时事实存储”。

目前它主要管理四类 Redis 状态：

- `expected-stop`
  - 表示这次停止是系统主动发起的。
  - 所有正常 stop/remove 路径都会在停容器前先写这个标记。
- `oom`
  - 收到 Docker `oom` 事件后写入，等待后续 `die` 合并判定。
- `container context`
  - 记录 `tenantId`、`workForType`、`workForId`、`environmentId`。
  - 用于容器已经退出、labels 不完整或 scheduler 补偿时继续归因。
- `exit signature`
  - 用 `containerId + finishedAt + exitCode + reason` 生成去重键。
  - listener 和 scheduler 共用它，避免重复写入。

它在这套方案里承担的是“把瞬时事件补成上下文”的作用。

### SandboxContainerExitRecorderService

这个服务是真正的统一记录器，也是整套机制的核心。

它接收来自两处的调用：

- Docker 实时事件监听器
- Environment scheduler 补偿流程

Recorder 会做这些事情：

- 尝试 `inspect` 容器，读取退出时状态。
- 合并 labels、Redis context、环境/运行记录，确认这个容器是否属于 sandbox。
- 检查有没有 `expected-stop` 标记，有则直接跳过，不记为异常。
- 根据 `oom`、`OOMKilled`、`exitCode`、scheduler 提示，计算异常原因。
- 抓取容器尾日志：最后 200 行，最多 32 KB。
- 写结构化 Nest logger 日志。
- 对 `environment` 同步更新数据库字段。
- 为本次退出写去重签名，防止重复记录。

它统一产出的异常原因目前固定为：

- `oom_killed`
- `non_zero_exit`
- `missing_container`

## 为什么不会把正常停止误判成异常

误报控制主要靠 `expected-stop intent`。

凡是系统主动发起的 stop/remove，都会先写 Redis 标记，然后再真正停容器。Recorder 发现这个标记后，就把这次退出视为“预期内停止”，不会写异常记录。

当前覆盖的主动停止路径包括：

- cleanup processor 清理 legacy `project/user` 容器
- environment cleanup processor 清理 environment 容器
- 手动 stop/remove environment 容器
- `DockerSandboxProvider` 替换旧容器
- environment orphan cleanup

这意味着：

- 用户超时过期后被系统清理，不算异常。
- 主动重建容器、替换容器，不算异常。
- scheduler 发现孤儿容器后回收，也不算异常。

## 异常判定规则

Recorder 的判定逻辑非常固定：

1. 有 `expected-stop` 标记，不是异常。
2. 如果命中过 `oom` 事件，或者 `inspect.State.OOMKilled = true`，记为 `oom_killed`。
3. 否则，如果 `exitCode != 0`，记为 `non_zero_exit`。
4. 如果 scheduler 发现环境记录里还引用着容器，但 Docker `inspect` 已经返回 `404`，记为 `missing_container`。
5. `exitCode = 0` 且没有其他异常信号，不记录。

这也是为什么 `oom` 不会在收到事件时立刻记一次，而是要等到 `die` 阶段统一判断。

## 为什么不会重复记录

这里有两层保护：

### 1. 只有一个实时 listener

`SandboxContainerEventMonitorService` 使用 Redis 锁选主，因此同一时刻只有一个实例消费 Docker 事件流。

### 2. recorder 自身再做幂等去重

无论是实时 listener 还是 scheduler 补偿，最终都调用同一个 `SandboxContainerExitRecorderService`，并写入同一套 exit signature：

```text
containerId + finishedAt + exitCode + reason
```

只要这个签名已经被记录过，后续同一异常就会被识别为重复事件并跳过。

所以多实例切换、Docker 事件流短暂断开、scheduler 补偿补到同一次异常时，都不会把同一个异常写两遍。

## 三类 sandbox 的记录差异

这套机制覆盖全部 Docker sandbox：

- `environment`
- `project`
- `user`

但三者的持久化策略不同。

### environment

`environment` 是本期重点记录对象，异常退出后会：

- 更新 `SandboxEnvironment.containerMeta.lastAbnormalExit`
- 更新关联 `SandboxRun.containerMeta.lastAbnormalExit`
- 同时维护已有的 `status`、`exitCode`、`finishedAt`、`containerId`
- 输出结构化应用日志

`lastAbnormalExit` 结构固定如下：

| 字段          | 含义                                                 |
| ------------- | ---------------------------------------------------- |
| `observedAt`  | 系统观察到异常的时间                                 |
| `source`      | 来源，通常是 `docker-event` 或 `scheduler`           |
| `reason`      | `oom_killed` / `non_zero_exit` / `missing_container` |
| `containerId` | 容器 ID                                              |
| `exitCode`    | 退出码，可为空                                       |
| `oomKilled`   | 是否 OOM                                             |
| `finishedAt`  | Docker 记录的结束时间，可为空                        |
| `logTail`     | 容器尾日志，最后 200 行且最多 32 KB                  |

### project / user

`project` 和 `user` sandbox 本期只要求“可监控、可定位问题”，不要求新增数据库历史查询能力。

因此它们异常退出时：

- 不新增事件表
- 不写数据库
- 只写结构化应用日志

日志上下文字段会尽量包含：

- `containerId`
- `tenantId`
- `workForType`
- `workForId`
- `reason`
- `exitCode`
- `oomKilled`

## labels 与 legacy fallback

为了让 recorder 能准确归属容器，新创建的 sandbox 容器会优先打 labels：

- `xpert.sandbox.provider`
- `xpert.sandbox.tenant_id`
- `xpert.sandbox.work_for_type`
- `xpert.sandbox.work_for_id`
- environment 额外带 `xpert.sandbox.environment_id`

这让 `environment`、`project`、`user` 三类容器都能直接从 Docker 侧拿到归属信息。

但系统里还存在一部分历史 legacy 容器，可能没有完整 labels。因此实现里保留了 fallback：

- 优先看 labels
- labels 不够时，继续尝试 Redis runtime context
- 再不够时，对 environment 仍可通过 `/workspace` 挂载路径解析旧目录结构

这也是为什么历史未打标的容器，至少仍有机会被识别为 sandbox 相关容器并写出异常日志。

## 为什么 scheduler 仍然保留

实时事件监听不是万能的，所以 environment 仍保留 scheduler 作为补偿路径。

保留它主要是为了覆盖这些情况：

- listener 进程刚切换 leader
- Docker 事件流短暂断开
- 容器在事件处理前已经消失
- 某些历史状态只剩数据库引用，没有 Docker 运行态信息

Scheduler 主要做两件事：

- 如果环境记录里的容器已经不在 Docker 中，按 `missing_container` 走 recorder。
- 如果容器已经停止且是异常退出，也按同一个 recorder 处理。

重点是：scheduler 不再自己发明另一套异常记录逻辑，而是复用 recorder，因此判定规则、日志抓取、落库结构、去重策略都保持一致。

## 日志抓取与日志量控制

异常发生后，recorder 会尽量读取容器日志尾部，但会严格限流：

- 按 Docker 日志抓最后 200 行
- 最多保留 32 KB 到 DB / 内存对象
- 输出到应用 logger 时，只保留最多 4 KB 预览

这样可以在保留排障线索的同时，避免异常日志把数据库和应用日志打爆。

本期没有接对象存储，也没有把异常日志写到 `logObjectKey`。异常排查入口仍然是：

- `SandboxEnvironment.containerMeta.lastAbnormalExit`
- `SandboxRun.containerMeta.lastAbnormalExit`
- 应用服务日志

## 排查建议

如果要排查某个 sandbox 为什么退出，可以按这个顺序看：

1. 先看 `SandboxEnvironment.containerMeta.lastAbnormalExit`。
2. 再看对应 `SandboxRun.containerMeta.lastAbnormalExit` 与 `status/exitCode/finishedAt`。
3. 如果是 `project/user` sandbox，直接查服务日志里的结构化异常日志。
4. 如果怀疑是 OOM，重点看 `reason=oom_killed`、`oomKilled=true` 和日志尾部。
5. 如果是 `missing_container`，优先排查容器是否被外部删除、Docker 重启或 metadata 与运行态脱节。

## 相关文档

- [Sandbox](/zh-hans/pro/sandbox/index)
- [Sandbox 文件卷架构](/zh-hans/pro/sandbox/volume-architecture)
