容器随时会死,但两小时的进度不该跟着死
Agent 沙盒系列第四篇。把进度从容器的内存里搬到一块共享硬盘上,然后当场删掉正在干活的容器——换一个容器接着从第 17 页往下跑。也照实说清楚:这一步的"自动恢复"其实还得人工点一下。
前三篇一路走到这里:任务之间隔离开了,等待也从 45 秒降到了零点几秒。但有个东西从第一篇起就没变过——进度还是记在容器自己的内存里。
这在过去只是“崩了就丢”。现在更糟:温池里的容器是随时会被换掉的。系统升级会换、机器故障会换、资源紧张会被回收。它们本来就是消耗品。
于是问题变成:凭什么让消耗品替你保管两小时的成果?
换个地方记:工地黑板,不是工人的脑子
想象一个工地:进度记在工人脑子里,换个人接班就全断了;记在工地墙上的黑板上,谁来接班都能看懂上一班干到哪儿。
代码要做的就是这件事——把黑板挂到墙上:
# 申请一块 1GB 的共享硬盘
kind: PersistentVolumeClaim
spec:
resources:
requests:
storage: 1Gi
---
# 让池子里的每个容器都挂上这块盘
volumeMounts:
- name: workspace-volume
mountPath: /workspace # 容器里看到的就是这个目录
共享硬盘在 K8s 里叫持久卷。关键词是“持久”:容器没了它还在,下一个容器可以接着挂上来。
然后干活的逻辑改两处。每干完一页,就在黑板上写一笔:
for i in range(start_page, total_pages):
await asyncio.sleep(1.0) # 抓一页
with open(f"/workspace/{task_id}/checkpoint.json", "w") as f:
json.dump({"pages_crawled": i + 1, "total": total_pages}, f) # 立刻写盘
开工之前,先看一眼黑板上有没有上一班留下的记录:
start_page = 0
if os.path.exists(ckpt_file):
start_page = json.load(open(ckpt_file)).get("pages_crawled", 0)
print(f"发现断点,从第 {start_page} 页继续")
就这么多。没有分布式事务,没有消息补偿,一个 JSON 文件而已。
当场把干活的容器删掉
这一步的价值必须靠“故意搞破坏”来验证,光看代码看不出来。
任务跑到一半,直接删掉正在干活的那个容器:
kubectl delete pod <正在干活的那个>
再把同一个任务重新派一次,看新容器的日志:
发现断点,从第 17 页继续
[worker-b] task-a3f9 progress: 18/30
[worker-b] task-a3f9 progress: 19/30
前 17 页没有重跑。容器换人了,活还在原处接着。
顺带还有一个变化容易被忽略:查询进度的接口,现在可以问任意一个活着的容器——因为它们挂的是同一块盘。状态已经不属于某个容器了。
这就是那句常被引用的话的实感:容器是牲口,工作区是宠物。 牲口可以随时替换,宠物有名字、要养着。
这一版还差什么(说实话的部分)
一、所谓“自动恢复”,其实要人工点一下。 代码里写得很清楚:
# 如果用户没传 task_id,说明是新任务;传了说明是续传
也就是说,没有任何东西在盯着“这个任务断了”。得有人发现它停了,再带着原来的任务号重新发一次请求。这不是自动故障转移,是“支持断点续传”——两者差着一整个监控与重试机制。这个缺口,是后面两篇要处理的。
二、那块盘在真集群里挂不上三个容器。 申请的是 ReadWriteOnce,意思是同一时刻只能挂在一台机器上。本地实验只有一台机器,所以三个容器共用它毫无问题;换成真正的多机集群,第二台机器上的容器就会卡在挂载失败。真要共享,得换成支持多机读写的存储(NFS、云厂商的文件存储那一类)。
三、每页写一次盘是个需要权衡的选择。 写得越勤,崩溃时丢得越少,但小文件写入本身有代价。真实系统里这个频率该由“重跑一页多贵”来定,而不是拍脑袋。
这一步定下来的事
进度离开了容器,容器就真的成了可替换的执行体。但你也会注意到:现在系统里没人知道一个任务是不是停了——分配器派完就不管了。
而池子满的时候,它依然只会说一句“没有空闲资源”。
这两件事其实是同一个缺口:缺一个记着“还有哪些活没干完”的地方。下一篇把它补上——顺便,我在那一步发现自己并没有真的做成想做的东西。
一句话版本:让状态活得比容器久,容器才敢随便死。
- Kubernetes
- AI Agent
- 容错
- 状态管理
- 架构演进