跳到主要内容
agent runtime 说「暂停会保留内存」,我写了个不会说谎的计数器

agent runtime 说「暂停会保留内存」,我写了个不会说谎的计数器

abanana
abanana

· 阅读约 6 分钟

前几天冲着 DigitalOcean Managed Agents 文档里的一句话去试了一把:暂停会话会保留进程、内存和工作区文件系统,恢复之后都回来。公共预览、只在一个区域、没有 SLA,这些我都知道,这篇不展开。我想说的是这句话本身怎么验,以及为什么大部分人一开始就会验错。

先给判断:拿文件系统快照来验的,全是在骗自己。沙箱里写个文件,暂停,恢复,文件还在——这只说明磁盘没丢,随便一个容器重启都能过这一关。文档承诺的三样里,进程和内存是最容易糊弄过去的两样。要验,就得往里放一个文件系统伪造不出来的状态。

我的办法很土。跑一个 bash 循环,计数器放在 shell 变量里,每秒加一,把当前值和 UTC 时间 append 进日志文件:

c=0
while true; do
  echo "$c $(date -u +%H:%M:%S)" >> /tmp/tick.log
  c=$((c+1))
  sleep 1
done

注意这个进程从头到尾不读回日志。日志只是往外写的记录,计数器的真身在内存里,而且只有这一份。任何靠挂 volume、重放文件、重启进程来「恢复」的做法,都不可能让计数器接着 47 往下数。💡 这是我这次唯一想传出去的小技巧——先想清楚哪种状态是文件系统伪造不了的。

第一个坑,也是我返工最久的一次。第一次我用 bash -s 经 exec 通道把它拉起来,命令返回成功,日志前几行也写了,我以为成了;暂停回来一看,进程早没了。exec 通道一关,它的子进程跟着一起死。后来改成 setsid 加 nohup 加 disown:

setsid nohup bash /tmp/ticker.sh >/dev/null 2>&1 &
disown

一开始没搞懂为什么非要三层,后来才想明白:setsid 让它脱离当前会话、拿到自己的进程组,nohup 挡掉挂断信号,disown 把它从 shell 的作业表里摘出去。少任何一层,通道关闭的时候都可能被顺手带走。这个坑我绕了两次才躲开,不是一开始就知道的。

跑够一分钟左右,暂停:

doctl ... pause <session-id>

我记的是 create / pause / resume 这三步,具体的子命令名以你手上的 doctl 版本为准,别照抄我这里。暂停调用 0.86 秒返回,恢复 1.16 秒。去冲了杯咖啡回来,日志长这样:

46 08:05:41
47 08:05:42
48 08:10:10
49 08:10:11

47 变 48,时间从 08:05:42 跳到 08:10:10,中间空了 4 分 28 秒,一个 tick 都没有。恢复后 PID 还是 590,shell 变量接着内存里那个值往下数。我本来以为省下来这四分半主要意义在省钱,一算账,余额从 4.40 掉到 4.33,全部加起来七美分——真正省下来的是资源没被占着这件事,跟钱不是一回事。

顺带说一下内存为什么能留下来。沙箱里跑出来是 KVM 虚拟机,2 个 vCPU、3939 MB 内存、6.1.176 内核、/dev/vda,不是共享主机上的命名空间。从 API 创建到状态变 READY 花了 15.97 秒,比容器慢,接近 Firecracker 那一档的启动成本。知道这个之后再回头看「暂停保留内存」这句话,就没那么意外了。

还有一层要单独测:agent 自己的上下文跟 OS 状态是两码事,混在一起验就白验了。我暂停前让 agent 生成一个随机工单号写进文件,恢复后问它那个号是多少——它用 8 个输出 token 从对话历史里答出来了,没去读文件。要是它读了文件,我就分不清是「对话记忆恢复了」还是「它找到了文件」。

fork 是我顺手做的,结果比暂停本身更让我改观。对运行中的父会话 fork 两次,总共 30.98 秒。三个沙箱里都有 PID 590 的 ticker:父进程计数 177,两个子进程 165 和 162;过一分钟再查,父 244,子 231 和 228。

我原以为 fork 就是「对磁盘打个快照,再给你一个文件一样的沙箱」,不是。父和子各自都在继续走时钟,差额就是 fork 那一刻的时间差——这是同一个正在运行的程序,从共同祖先状态分出来的三个副本。文档里「fork」这个词太容易被读成 cp -r,差得远。单独跑一次 checkpoint 要 25.25 秒,报告大小 111 GB,稀疏分配,不是真占了这么多;第一次看到这个数字我也愣了一下。

真正的风险在这儿:暂停和 fork 对进程本身是不可见的。我那个计数器能活,恰恰是因为它不在乎时钟,只需要「我还在跑」这一件事。换个持有租约的进程试试——暂停四分钟,租约早过期了,恢复后它拿着一把已经失效的锁继续干活,而且它自己不知道。fencing token、幂等键、fork 之后两边共用的请求 ID,全是同一类问题。所以恢复后的实例必须知道自己那段记忆有多旧,写入顺序和过期策略得一起想,不能只验「状态还在不在」。

跟暂停无关但顺手记一条:HARNESS_INFERENCE_API_KEY 在沙箱里就是个普通环境变量,里面任何进程都能把它打印出来。出站默认是开放的,能直连 example.com 和 api.github.com;在 manifest 里加一个主机之后,策略立刻变成默认拒绝,只有那个主机通。这条先记下来,下次评估这类 runtime 应该用得上。

另外两个小错也记一下,省得下次再犯:第一次用 bash -s 经 exec 通道起 ticker,进程随通道死掉;还有一次我以为 checkpoint 用的是 --name,实际标志是 --label,白折腾一轮。

划重点:第一,验暂停声明,先设计一个文件系统伪造不了的测试——在内存里放一个从不写下的状态,然后看恢复后它是否仍在计数;第二,只测文件系统的朴素版本会轻松通过,通过了也不说明任何事;第三,状态还在不等于状态还能用,时钟、租约、身份这些进程自己看不见的东西,才是恢复之后最容易出事的地方。

你可以照着自己手上的 runtime 起一个 ticker 试一遍,三条命令的事。如果踩到别的坑,欢迎回来留言,我一起补一条笔记。

abanana
abanana

把自己踩过的坑整理成一篇能复现的笔记,写给三个月前的自己看。

查看主页 →