ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Codex在Windows沙箱连环故障复盘:从WSL2到Docker镜像层的排查思路

Codex在Windows沙箱连环故障复盘:从WSL2到Docker镜像层的排查思路

10月8号晚上,我手头一个Codex驱动的批处理任务卡在了Windows沙箱上,连续四次启动全部失败,前后折腾了四个多小时才把问题彻底解决。这不是一次简单的环境报错,而是容器沙箱的任务锚定机制、镜像层完整性、资源预算三个环节同时出了岔子,任何一个单点都很难直接看出全貌。这篇文章就是那次故障的完整复盘,从Codex在Windows下是怎么跑沙箱的、故障现场是什么样、我是怎么一层层定位到根的,到最终怎么修复、以后怎么预防,全部摊开讲。如果你也在Windows上用Codex或其他依赖容器化沙箱的编码智能体,这篇文章应该能帮你少走几趟弯路。

1. 先搞清楚Codex在Windows上到底是怎么跑沙箱的

1.1 为什么编码智能体非要一个沙箱不可

Codex这类编码智能体的工作模式是:理解你的需求,自己拆解任务,然后执行工具调用去读写文件、跑命令、构建项目。这个过程中,模型自己生成的代码是不可完全信任的——不是它故意使坏,而是它对你环境的理解可能出错,跑出一条rm误删、改错配置、疯狂占内存的命令完全有可能。所以沙箱的意义,就是在系统和你之间加一层可控的隔离墙。

沙箱至少要做四件事:文件系统隔离,让智能体只能碰挂载进去的目录;网络策略限制,默认断网或只允许白名单地址;资源配额,限制CPU、内存、磁盘用量,防止失控;生命周期管理,任务结束后容器自动销毁,不残留进程。

1.2 Windows下三种沙箱方案的取舍

Windows上要给Codex做沙箱,常见的有三条路。第一条是Windows自带的应用沙箱,隔离级别高,但它每次启动都要重置系统,状态无法持久化,对Codex这种需要跨会话保留工作区的场景来说基本没法用。第二条是WSL2里的Linux容器,轻量、启动快,但WSL2本身不是一个面向单进程隔离的运行时,更像是一个轻量虚拟机上的进程环境,隔离性有限。第三条就是Docker Desktop的容器沙箱,把智能体的工具调用跑在独立容器里,既有隔离又支持持久化,还能用Docker的成熟生态。

Codex默认采用的正是第三种方案:宿主Windows上的Codex CLI负责调度,所有工具调用通过API转发到WSL2后端的Docker运行时,在容器里执行。整个链路是宿主进程 → WSL2 → Docker守护进程 → 沙箱容器 → 工作区挂载目录。

理解了这条链路,你就会发现:所谓“Codex沙箱故障”,检查面其实横跨Windows应用层、WSL2虚拟化层、Docker存储层三块。这也是为什么这类问题排查起来特别容易晕。

1.3 沙箱生命周期里的两个关键机制

和这次故障直接相关的,是两个平时不太起眼的机制。第一个是任务锚定。每次会话创建时,Codex会生成一个会话ID,并将它映射到一个固定名字的容器,比如codex-sbx-a8f3c2d1。后续所有工具调用都发往这个容器,同时容器名和会话元数据会被写入宿主机上的状态文件,用于会话恢复。第二个是恢复机制。当任务中断或CLI重启后,Codex会尝试读取最近的会话元数据,找到对应容器,检查它是否还在运行,然后决定是恢复还是重建。

这两个机制本身逻辑不复杂,但它们都依赖一个前提:容器镜像和状态文件是完好一致的。镜像层一旦损坏或者状态文件和实际容器对不上,恢复逻辑就会走到一个非常尴尬的中间状态——既没法恢复旧会话,也建不了新容器。

2. 故障现场:不是简单启动失败,而是连环告警

2.1 故障现象记录

那天晚上我跑的是一批代码重构任务,某个跨平台系统迁移,需要Codex在沙箱里连续处理十几个模块。任务运行到第三个模块时,Codex突然报错退出。我记录了当时看到的所有关键现象。

第一个现象是启动即失败。重新运行Codex时,CLI直接报Failed to start sandbox: rpc error: code = Unavailable,连容器都没拉起来。第二个现象是容器启动后立刻退出。偶尔一次能启动成功,但几秒钟后容器就被杀掉,日志里只有一句Container exited with code 137。第三个现象是工作区文件系统异常,挂载目录内部分文件变成只读,个别目录即使有管理员权限也无法读取。第四个现象是会话恢复变成空白。Codex的--resume选项读不到之前的任务记录,状态文件存在但内容与当前容器完全不匹配。

这四个现象如果单独看,前两个像资源问题,第三个像文件系统问题,第四个像应用层问题。但它们同时出现,就没法用单点故障解释了。

2.2 故障时间线

我完整记录了从发现问题到最终恢复的全过程,这里按时间线还原。

  • 16:40:第三个模块重构任务开始,Codex正常进入沙箱。
  • 16:58:任务日志中断,沙箱容器被系统杀掉,退出码137。
  • 17:05:第一次尝试重启Codex,报rpc error: Unavailable。
  • 17:10:重启Docker Desktop,等待约三分钟后容器可启动,但会话内容全部丢失。
  • 17:30:把任务切到新会话重新跑,约二十分钟后再次被杀死,现象与第一次相同。
  • 18:10:检查事件日志,发现之前有强制关机记录,是在我昨晚下班时误触了电源键,系统没有正常走完关机流程。
  • 20:10:执行完整清理流程(WSL2冷重启 + 孤儿容器清理 + 镜像重建),服务恢复正常。

2.3 影响评估

这次故障的直接影响不只是那一个晚上的任务中断。首先是整个批次的任务回退到了第三个模块的起点,前面两个模块虽然已生成代码,但会话状态丢失导致无法继续增量处理。其次是工作区里大约3GB的中间产物因为容器重建被清掉,而这些产物理论上不应该依赖容器生存期。最后是恢复后我发现Codex的监控统计里出现了内存与文件系统异常的告警,说明异常状态实际上在故障前就已经有苗头,只是被我忽略了。

3. 排查全过程:从应用层一路挖到存储层

3.1 第一轮:先看应用层日志

故障发生后我的第一反应是查Codex自己的日志。在Windows上,Codex的日志通常在%USERPROFILE%\.codex\log\下面,打开当天的日志文件,里面反复出现的错误有两类:一类是容器RPC连接断开,时间点大概在进程被杀之前;另一类是状态文件读写失败,具体报错是failed to lock state file: resource temporarily unavailable。

这两个信息有一定价值,RPC断开说明容器进程先于CLI退出,状态文件报锁错误说明有另一个进程还占着状态文件的锁——也就是说,有一个残留的Codex守护进程在后台没有退出。

我在任务管理器里确实找到了几个残留的codex serve进程,强制结束后重新运行,情况好转了一点点:CLI能发命令了,但容器依然是启动后秒挂。到这里可以初步排除应用层独占锁的问题,继续往容器层看。

3.2 第二轮:检查容器事件和资源状态

我把排查重心转向Docker。先跑docker events --since 30m,看到的关键事件是die和oom,容器是被OOM杀手杀掉的。再看容器配置,docker inspect返回的HostConfig.Memory显示内存限制是2GB,MemorySwap是1GB。这个配置很可疑,按Linux的cgroup规则,SWAP是“内存换页上限”,而Docker Desktop在WSL2后端里的SWAP实际上由WSL2全局配置决定,容器层面的MemorySwap如果设得比WSL2可用的交换空间还大,就会产生一个虚假的“超额”内存预算,进程一跑到高位就容易被判定为OOM。

同时我注意到了一个更关键的数字:WSL2配置里.wslconfig的memory=被设置为4GB,但docker desktop setting里分配给WSL2的内存却按“动态分配”处理。也就是说,容器请求2GB内存 + 1GB交换,而WSL2本身可用内存不满4GB,Docker Desktop还要占掉一部分剩余内存,留给容器cgroup的实际配额可能不足2GB。这种情况下,容器内的高内存负载会被cgroup的OOM killer杀掉,退出码137,表现形式就是启动后秒挂。

3.3 第三轮:检查镜像层完整性

解决内存参数后又试了一次,容器没有秒挂,但工作区里依然有只读文件。我意识到这可能不是资源问题,而是文件系统层面的问题。去查容器镜像的状态,docker image inspect显示镜像的rootfs有多个层,其中一层的diff_id校验和异常,但这个异常并没有触发Docker的自动恢复机制——它的完整性信息是滞后更新的。

再查docker system df,发现镜像空间占用异常大,同一版本沙箱镜像竟然有六个历史副本,其中一个副本处于“悬挂”状态,没有标签但没有被清理。这种悬挂层在正常卸载更新时会被自动清理,但如果宿主机经历过程序异常终止,图层读写的元数据就来不及更新,导致悬挂层残留。而Codex的容器镜像在启动时会去读取这些残留层的元数据来组装rootfs,一旦某层的元数据不一致,轻则文件只读,重则启动失败。

3.4 第四轮:WSL2全局状态检查

到这里已经比较明确了,但我还是想确认WSL2本身有没有补刀。执行wsl --status输出显示Default Distribution: docker-desktop,正常运行。但wsl --shutdown之后重新启动Docker Desktop,界面上提示需要重置网络组件,说明WSL2的虚拟网络交换机状态也异常过。结合昨晚的强制关机记录,我判断这就是整个事件的起点:强制关机导致WSL2虚拟磁盘和Docker存储目录处于未正常卸载状态,再叠加Docker Desktop动态内存分配本身的资源竞争,最终引爆了后续的连环故障。

4. 根因分析:三个问题纠缠在一起的完整链条

4.1 根因之一:镜像层损坏叠加悬挂层残留

究其根源,一切始于昨晚那次坏关机。Windows在强制关机后不会对WSL2的虚拟磁盘文件(ext4.vhdx)执行优雅的fsck流程,导致Docker的overlay2存储驱动中的图层元数据出现不一致。这种不一致平时不会直接暴露,因为系统会尝试自动修复,但仓库里残留的悬挂层会干扰修复流程,它把破损层误当成“正在使用”的层,导致Docker拒绝清理,镜像启动时读取到的层信息又是旧的。

如果没有6个历史副本,也许不会这么严重。正是因为我之前多次更新镜像后没有主动清理旧镜像,悬挂层数量积累到了一个临界点,最终有一个旧层正好位于rootfs的关键路径上,才导致workload文件只读和部分工具调用失败。

4.2 根因之二:任务锚定机制与容器名冲突

Codex的任务锚定机制会按会话ID生成固定容器名,比如codex-sbx-a8f3c2d1。正常情况下,一个会话对应一个容器,任务结束后容器被销毁,名字释放。

但这次在强制关机后,Docker守护进程重启时会重新检测容器状态,它发现codex-sbx-a8f3c2d1的容器名仍在被一个僵尸容器占用,而Codex侧的状态文件又记录着这个会话ID。Codex启动新会话时尝试重建同名的容器,Docker返回Conflict错误,而Codex的容器创建代码没有针对这个冲突做重试,只是报了Unavailable。这解释了第一次启动失败的真正原因——不是镜像坏了,是容器名被僵尸容器占住了。

4.3 根因之三:内存配额与实际可用内存不匹配

前面提到容器内OOM的问题,背后是Docker Desktop在WSL2上的内存管理缺陷。WSL2默认是动态分配内存,但Docker Desktop为了给宿主机确保桌面响应,会在.wslconfig里设置一个上限。当容器内进程请求内存时,cgroup向WSL2内存子系统申请,而WSL2还没把内存从“已缓存”状态回收回来,就直接判定为无法满足,于是触发了OOM。

我检查了当时的默认配置,Docker Desktop界面上显示内存限制为2GB,.wslconfig里memory=4GB,看起来不会有问题。但运行中的实际内存状态里,WSL2的页面缓存占用了接近3GB,容器cgroup可用不到1GB。这导致容器内的Codex进程在构建时一旦超过800MB就秒挂。这属于配置与运行时不匹配问题,也是整个故障链中唯一一个在完全正常操作下也能复现的环节。

4.4 根因之间的交互关系

把三个根因串起来看,链条是这样的:强制关机导致镜像层与悬挂层损坏 → 容器启动时加载到错误的层 → 文件系统出现只读异常 → 容器进程在读写工作区时被卡住 → 内存快速上涨触发OOM → 容器被杀后残留僵尸容器 → 新会话建容器时名字冲突 → 状态文件与容器不一致导致恢复失败。每一个单独的问题都不致命,但它们像多米诺一样一个推一个,最终把整个沙箱彻底堵死。

5. 修复实操:从紧急恢复到根治方案

5.1 紧急恢复流程(直接照做)

如果你现在正被同样的问题卡住,可以按下面这套流程走,我已经实测有效。不要跳过任何一步,顺序也不能颠倒。

第一步,先把所有Codex进程杀掉。在PowerShell里执行:

Get-Process | Where-Object {$_.ProcessName -like '*codex*'} | Stop-Process -Force

第二步,彻底关闭Docker Desktop。不要点界面的退出按钮,直接用命令强制结束:

Stop-Process -Name "Docker Desktop" -Force # 再确认没有残留的后台进程 Get-Process | Where-Object {$_.ProcessName -like '*docker*'} | Stop-Process -Force

第三步,执行WSL2冷重启。这一步能强制清理虚拟磁盘上的文件锁和网络组件状态:

wsl --shutdown # 等待10秒让虚拟机和虚拟磁盘完全释放 Start-Sleep -Seconds 10

第四步,清理僵尸容器和悬挂镜像。重新启动Docker Desktop后执行:

docker rm -f $(docker ps -aq) docker system prune -af --volumes

注意--volumes会把所有容器卷数据也清掉,如果你有重要的中间产物,先把它们复制到宿主机目录里再执行。

第五步,重建沙箱镜像。Codex首次运行时会自动拉取,也可以手动指定镜像标签重建:

docker pull registry.internal/codex-sandbox:v2.3.1

跑完这五步,再正常启动Codex,我这里是立刻恢复了。

提示:这套流程会丢失所有历史会话状态,如果Codex里有还没保存到宿主机的输出文件,务必先在第一步之前把工作区整个复制出来。

5.2 根治方案一:调整资源预算

紧急恢复只是把当前问题处理掉,防止再次发生才是重点。先处理资源问题。Docker Desktop在Windows上的内存分配不要用动态模式,直接固定一个值。我的建议是,如果宿主机有16GB内存,就把WSL2的.wslconfig改成:

[wsl2] memory=8GB processors=4 swap=0

Docker Desktop设置里也要把内存限制改为8GB,CPU保持4核。容器级别的内存参数在Codex的配置文件里设置,以config.toml为例:

[sandbox] memory_limit_mb = 1536 memory_swap_mb = 0 cpu_shares = 2

关键是memory_swap_mb = 0,让容器的内存上限严格等于memory_limit_mb,不要给SWAP预算。因为WSL2的SWAP在Docker Desktop后端里的行为不可控,与其让它造成虚假的“可以超额内存”错觉,不如不要。

5.3 根治方案二:镜像不可变与定期清理

镜像层要走到“不可变”这个程度。我给自己的实践定了几条规则,你可以直接抄。

第一,每次更新镜像后,立刻清理旧镜像,不要留历史副本。第二,给镜像打上不可变标签,代码仓库里明确禁止在镜像上追加修改层,任何工作区变化都走卷挂载,不写进镜像层。第三,每周固定跑一次Docker的深度清理,不仅仅是docker system prune,要加上docker builder prune --all清理旧的构建缓存。

清理命令我放在计划任务里,每周日凌晨执行:

docker builder prune -af docker system prune -af --volumes

5.4 根治方案三:给沙箱加健康检查

最后一步,也是我觉得最重要的预防措施:让Codex启动沙箱时执行一次健康检查。这个功能不是Codex原生支持的,但可以写一个外挂脚本,在启动前检查三个东西。

# 检查退出码为137的僵尸容器 docker ps -aq --filter "status=exited" --filter "status=dead" | Measure-Object | Select-Object -ExpandProperty Count # 检查镜像层校验和 docker image inspect registry.internal/codex-sandbox:v2.3.1 --format '{{json .RootFS.Layers}}' # 检查WSL2虚拟磁盘占用 Get-ChildItem "$env:LOCALAPPDATA\Docker\wsl\disk\docker_data.vhdx" | Select-Object Length

如果第一条返回的计数大于3,基本可以预告会有容器名冲突问题。如果第二条返回的层数比镜像定义多出两个以上,镜像层已经有残留了。如果第三条文件大小超过宿主机内存的1.5倍,说明历史数据堆积已经比较危险。

6. 常见问题速查与后续排查清单

6.1 常见问题速查表

现象最可能的根因快速检查命令解决方案
启动报rpc error: Unavailable僵尸容器占用会话名或Docker守护进程异常docker ps -aq --filter "status=dead"清僵尸容器,重启Docker
容器启动后秒挂,退出码137内存配额耗尽,或cgroup与WSL2内存不匹配docker inspect --format '{{.State.OOMKilled}}' <容器名>调整.wslconfig内存上限,容器内去掉swap
工作区文件只读镜像层损坏或overlay2层不一致docker system df清理悬挂层,重建镜像
会话恢复后内容丢失状态文件与容器实际状态不一致检查.codex\state\下的会话元数据手动清理会话文件,重建容器
容器启动极慢,构建时卡顿WSL2虚拟磁盘碎片化,历史数据过多Get-Item "$env:LOCALAPPDATA\Docker\wsl\disk\docker_data.vhdx"定期清理镜像与卷,或执行docker system prune -a

6.2 后续需要留意的三个征兆

这次故障让我意识到,沙箱类问题往往不是突然炸的,而是有一段时间的“亚健康”状态,只是我没有注意。

第一个征兆是容器冷启动时间变长。以前启动一个沙箱容器只需要3到5秒,出故障前一段时间每次要8到10秒。这就是镜像层读取变慢的表现,层不完整时Docker要反复校验,启动时间会明显上涨。第二个征兆是vmmem进程的内存占用持续居高不下。正常情况下WSL2内部的内存可以被回收,如果任务结束后vmmem还占着50%以上物理内存,说明WSL2的内存回收机制已经出现异常。第三个征兆是Codex的日志里偶尔出现retrying sandbox start,这说明CLI已经感知到容器不稳定,只是暂时还能靠重试掩盖过去。

这三个征兆随便出现一个,就应该执行一次完整的清理流程,而不是等到故障来了再处理。

6.3 恢复后的验证与回归测试

修复完成后不能直接说“好了”,要做回归验证。我当时的验证分三步走。第一步,连续启动并销毁沙箱容器五次,每次启动时间都在5秒内,没有被OOM杀死的记录。第二步,跑一次完整工具调用链路:在沙箱内创建文件、修改文件、删除文件、执行构建命令、退出会话再恢复,确认所有操作正常。第三步,跑一个真实的小模块任务,让Codex连续执行超过200次工具调用,观察内存曲线是否稳定。只有这三步全部通过,才算真正恢复了。

7. 踩过这次坑后的几点经验

写到最后,分享几个我个人体会最深的经验。第一个经验是,在Windows上玩Codex这类依赖容器的工具,永远把“坏关机”当成头号风险源。一次非正常的电源中断,可能比升十个版本带来的问题都多。现在我每次离开工作台都会确认所有虚拟机服务已经正常关闭,宁可多等30秒,也不想再花4个小时去修复镜像层。

第二个经验是,Codex的沙箱配置绝不是默认值就能一路跑通的。默认配置在干净的Linux环境里或许没问题,但在Windows的WSL2后端下,内存和SWAP的默认策略必须手动调,而且调完一定要压测,不能只看界面上的参数。

第三个经验是关于中间产物的。以前我习惯把Codex的工作区直接当作数据保险箱,觉得容器里写的文件应该一直在。现在我会在任务的关键节点把输出同步到宿主机目录,沙箱容器本来就是可以随时销毁的临时环境,任何依赖容器生存期的数据都不可靠。

希望这篇复盘能帮到你。如果你的Codex沙箱也出现过类似的问题,欢迎按上面的检查清单先自查一遍,多数情况不需要重装系统,也犯不着一怒之下把Docker Desktop卸载重装。按从应用到容器到WSL2的层级,一步步查下来,总能定位到真正的问题。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进