应用的普通实例可以互换:任何一个都能响应任何请求,平台会随着请求的来去创建和删除它们。键控实例只属于一个键。它在第一次使用这个键时创建,从那以后,带这个键的每个请求都会送到它这里,它的内存和磁盘里有什么都还在。可以用它承载智能体的会话、用户的工作区、租户的数据库:任何需要再次找到自己状态的东西。
键控实例是项目中的一个沙箱,从应用当前版本的模板创建。沙箱有的一切它都有:命令、文件、终端、快照、分叉,都在 /v1/sandboxes/{id}/… 下。
键从哪里来
keys.from 指定发往应用地址的请求在哪里携带键。它立即生效,不会产生新版本。用 Python 设置(命令行目前还没有对应的选项):
from glac import Glac
glac = Glac()
app = glac.cube.deploy("ghcr.io/acme/agent:3", name="agent", port=8080,
keys={"from": {"header": "X-Agent-Key"}, "delete_after_idle_ms": 86_400_000}){"header": "X-Agent-Key"}:键是该请求头的值。{"path_segment": 1}:键是路径中的一段(1 表示第一段;最多到 32),解码后使用:/agent%2042/x的键是agent 42。
不带键的请求会送到应用的普通实例。带键的请求携带的是签过名的键(见下一节)。键由 1 到 128 个字符组成,不能含控制字符;其他情况都会以 400 invalid_key 响应。
签名键
任何能访问应用地址的人都可以编造键,而每个新键都会在你的账户上创建一个实例。所以经由边缘节点的请求,携带的是由你的后端用应用的机密签过名的键,只有签名正确时边缘节点才放行:每个请求都要检查,不只是键的第一个请求。通过 API (POST /v1/apps/{app}/instances) 创建的键不需要签名:你的 API 密钥就是证明。
创建一个机密(它只显示一次;一个应用最多有两个,这样你可以换用新机密而不拒绝任何请求):
glac cube key-secrets create agent # prints GLAC_KEY_SECRET=ks_…你的后端为它发出的每个键签名。签名后的键放在原来放键的地方:作为请求头的值,或作为路径中的那一段。
import glac
signed = glac.sign_key(secret, app.id, "agent-42", expires_at=datetime.now(timezone.utc) + timedelta(hours=1))
requests.get(f"{app.url}/chat", headers={"X-Agent-Key": signed}) # keys.from: header
link = f"{app.url}/{glac.sign_key(secret, app.id, 'agent 42', path=True)}/" # keys.from: path_segment 1签名后的形式是 <key>.<expiry>.<signature>:过期时间为 Unix 秒数(不过期则留空:agent-42..<signature>),签名是以机密为密钥、对应用 id、键和过期时间(每项一行)计算的 HMAC-SHA256,用不带填充的 base64url 编码。每个 SDK 都有辅助函数(sign_key、signKey、SignKey、KeySecret.signKey),命令行也有:
GLAC_KEY_SECRET=ks_… glac cube keys sign agent agent-42 --expires 12h| 响应 | 情形 |
|---|---|
403 key_signature_invalid |
没有签名;为别的键或应用签的名;用应用已不再拥有的机密签的名;或应用还没有机密(错误信息会说明)。 |
403 key_signature_expired |
签名正确,但已过期。 |
被拒绝的请求不会到达任何实例:不会创建或唤醒任何东西,也不按请求计费(应用的日志用量把它计在 edge_refused 下)。
实例看到的内容。只有键本身,位置不变:请求头中是 agent-42(只出现一次,无论请求发送了多少次),路径段中是请求写入时的键 (/agent%2042/chat)。来自同一主机页面的 Referer 中的签名也会被去掉。签名和机密都不会到达实例,所以你的应用根据键生成的链接不带签名:在签名路径下提供的页面中,请使用相对链接。
轮换。创建第二个机密,把后端换到它上面,再删除第一个。两个都存在时,用哪一个签名都可以。删除机密的调用会在已连接的边缘节点停止接受它之后返回(一秒之内);与控制平面断开的边缘节点会一直沿用旧表,直到重新连接。
| 调用 | 作用 |
|---|---|
POST /v1/apps/{app}/key-secrets |
创建一个机密 (201);secret 只出现在这次响应中。创建第三个会返回 403 key_secret_limit_reached。 |
GET /v1/apps/{app}/key-secrets |
应用的机密,最新的在前:id、prefix(开头 11 个字符)、created_at。从不包含值。 |
GET /v1/apps/{app}/key-secrets/{id} |
按 id 查看一个机密,不含它的值 (glac cube key-secrets get agent ksec-…)。 |
DELETE /v1/apps/{app}/key-secrets/{id} |
删除一个。 |
创建与查找
| 调用 | 作用 |
|---|---|
POST /v1/apps/{app}/instances {key, region?} |
创建这个键的实例 (201),或唤醒它已有的实例并返回它 (200)。对同一个键调用两次只会创建一个实例。 |
GET /v1/apps/{app}/instances |
应用的键控实例,最新的在前(?key= 只看一个键)。 |
GET /v1/apps/{app}/instances/{key} |
一个键的实例,按它的机器此刻看到的状态(键没有实例时返回 404 key_not_found)。 |
DELETE /v1/apps/{app}/instances/{key} |
删除实例并释放键:下一个带这个键的请求会创建新实例。 |
在路径中,把键写成一段,除字母、数字和 -._~ 之外的每个字节都写成 %XX(agent 42 写作 agent%2042)。SDK 会替你这样做:
inst = app.instance("user-42") # made, or woken
print(inst.id, inst.key, inst.region)
print(inst.run("ls /").stdout) # it is a sandbox
app.delete_instance("user-42")同一个键的两个首次请求同时到达时,只会创建一个实例:第二个请求得到第一个创建的实例。实例在请求进入的边缘节点所在的区域创建(或调用指定的 region),范围是应用运行的区域。
何时休眠,何时删除
键控实例和沙箱一样休眠:机器本身安静 sleep_after_ms 毫秒(至少一秒)之后,安静与否要看它的 CPU、网络、连接和定时器,而不只是请求。任何需要它的东西都会唤醒它。
它不会因为空闲而被删除,除非你这样设置:
- 应用上的
keys.delete_after_idle_ms(至少 1,000 毫秒):休眠了这么久的键控实例会被删除,它的键被释放; - 实例自己的
delete_after_idle_ms(PATCH /v1/sandboxes/{id}),它优先于应用的设置; DELETE /v1/apps/{app}/instances/{key},把它作为沙箱删除,或删除应用。
因支出上限、套餐或未付余额而被休眠的实例,从不会因为空闲而被删除。
每个键一个云盘
有了云盘,每个键的实例都可以有自己的云盘,随它的第一个实例创建,随键删除。由版本指定它的名称、路径和大小:
glac deploy ghcr.io/acme/agent:3 --name agent --port 8080 --key-drive home:/home/app:10GiB这个云盘出现在 GET /v1/drives 中,带有应用的 id 和键。键持有它期间,它不能被删除,也不能挂载到别处 (409 key_drive)。不指定大小时,它是套餐允许的最大云盘,最多 64 GiB。
为单个实例换新版本
部署不会动键控实例。要把某个实例迁到另一个版本,需要专门请求:
| 调用 | 作用 |
|---|---|
POST /v1/apps/{app}/instances/{key}/upgrade {version?, when?, keep_days?} |
把键的实例迁到 version(版本的 id 或编号;不给出时为应用的当前版本,也可以是更旧的就绪版本):返回 202 和这次升级。实例已经在该版本上时,返回 200 和实例。when:now(默认)或 asleep。keep_days:回退所需内容的保留天数,0 到 365,默认 7。接受 Idempotency-Key。 |
GET /v1/apps/{app}/instances/{key}/upgrades |
键的升级记录,最新的在前。 |
GET /v1/apps/{app}/instances/{key}/upgrades/{id} |
一次升级;?wait_seconds=(最多 600)会等到它结束。 |
DELETE /v1/apps/{app}/instances/{key}/upgrades/{id} |
取消仍处于 waiting 的升级;已开始的升级返回 409 upgrade_started。 |
POST /v1/apps/{app}/instances/{key}/upgrades/{id}/revert {keep_days?} |
把实例恢复到那次升级之前的状态:返回 202 和这次回退,它是一次 kind: revert 的升级。接受 Idempotency-Key:响应丢失后再次请求,得到的是同一次回退,而不是在此后写入的内容之上再回退一次。 |
实例保留它的 id、节点、键、项目地址、令牌,以及它自己的云盘和其中的内容。它的磁盘、进程、启动命令和标签换成新版本的:内存中的内容和打开的连接都会消失。在节点切换它的期间,边缘节点会暂存它的请求,等新版本起来后再转发过去,和迁移时一样;对其沙箱的调用返回 503 upgrading 并带 Retry-After。读取沙箱 (GET /v1/sandboxes/{id}) 仍然有响应,返回最后一次看到的沙箱状态,status 为 upgrading。旧的启动命令按旧版本的 stop 设置停止。新版本没能起来时,实例恢复原样,云盘也恢复到之前的内容:升级状态为 failed,error.code 为 upgrade_failed,put_back: true,实例照常响应。
它自己的云盘按名称对应。如果版本的 key_drives 指定了键还没有的云盘,这时就会得到它:在动实例的任何东西之前,先在实例所在的机器上为这个键创建这个云盘,大小按版本的设定,并挂载到它的路径上,带有镜像在该路径下的内容(除非版本设置了 nocopy)。新版本不再指定的云盘会被卸载并保留:它仍属于这个键,出现在 GET /v1/drives 中,按云盘计费,随键删除,之后指定它的版本会把它原样重新挂载。无法创建的云盘(比如大于你的套餐允许的云盘大小)会让升级以 failed 结束,error.code 为 upgrade_not_here,原因在 error.message 中,实例保持原样;没有以 done 结束的升级会删除它创建的云盘,这些云盘不产生任何费用。在机器上失败的升级(版本没能起来,实例已恢复)会在这些云盘删除之前片刻就读到 failed:请等待它们的 drive.deleted 事件。从升级开始,实例的日志行归到它所运行的版本下。
升级依次经过 preparing(实例的节点获取版本的模板)、switching,然后是 done 或 failed;timings.held_ms 表示它的请求被暂存了多久。一个键同一时间只能有一次升级(409 upgrade_in_progress,并指明正在进行的那次),未就绪的版本返回 409 version_not_ready。instance.upgraded 和 instance.upgrade_failed 说明每次升级如何结束。
等它休眠时。设置 when: asleep 时,升级会处于 waiting,直到实例休眠才开始。等待期间,实例照常提供服务,它的请求一个都不会被暂存;只有恰好在切换开始那一刻到达的请求需要等待。从不休眠的实例会让升级一直等待,直到你取消它。
回退。完成的升级会在 keep_days 天内保留回退所需的内容:实例原来状态的快照(项目中的一个快照,名为 before-<upgrade id>,按快照计费),以及它当时拥有的每个自有云盘的快照(before-upgrade-<upgrade id>,按云盘快照计费);升级的 rollback 会列出它们,并说明保留到什么时候。回退会恢复它的磁盘、云盘原来的内容,以及它原来的版本(那个版本没有挂载的云盘会被卸载并原样保留);没有云盘的实例,进程会恢复原样;有云盘的实例,会在云盘挂载后重新运行启动命令。回退本身也会保留同样的内容,以便回退这次回退,其中包括回退之前云盘的内容。期限一过,两种快照都会被删除,回退会返回 409 rollback_gone;keep_days: 0 时,或其中某个快照被删除时,也是如此。instance.reverted 表示每次回退已完成。
一次升级所有键。POST /v1/apps/{app}/upgrades {version?, from_versions?, keys?, when?, keep_days?, concurrency?, max_failures?} 创建一个批次:为实例不在该版本上的每个键各做一次升级(可以按你的需要限定为 from_versions 中的版本、keys 中的键)。除非 when 为 now,否则它会等每个实例休眠;同时进行的升级最多 concurrency 个(默认 4),同一台机器上最多两个;失败数达到 max_failures(默认 5)时停止:仍在等待的升级被取消,已完成的保持在新版本上。GET /v1/apps/{app}/upgrades 和 …/upgrades/{id} 显示批次,以及各状态的数量;POST …/upgrades/{id}/stop 停止一个批次。应用设置了 keys.upgrade_on_deploy: true 时,每次部署都会创建这样一个批次,各个键会在各自休眠时跟上最新版本。
它自己的 IPv6 地址
在拥有 IPv6 地址池的机器上创建的键控实例,和这类机器上的任何沙箱一样,有它自己的公网 IPv6 地址,在开放端口之前拒绝一切入站流量(网络)。无论实例在哪里,都按应用和键访问它的端口:
| 调用 | 作用 |
|---|---|
PUT /v1/apps/{app}/instances/{key}/tcp/{port} {from} |
向 from 列出的来源(IPv6 前缀或地址,::/0 表示所有人)开放一个 TCP 端口,或把已开放端口的来源改为这些。用 udp 代替 tcp 表示 UDP。实例没有自己的地址时返回 409 no_ipv6。 |
DELETE /v1/apps/{app}/instances/{key}/tcp/{port} |
关闭端口:新连接会被拒绝,已有的连接继续。 |
GET /v1/apps/{app}/instances/{key}/tcp |
开放的端口,以及各自的来源。 |
实例在休眠和迁移后仍保留这些端口;这个键的新实例则没有。实例的机器无法访问时,这些调用返回 503 node_unavailable。
glac cube instances ports open api user-42 5432 --from 2001:db8::/32
glac cube instances ports ls api user-42
glac cube instances ports close api user-42 5432SDK 在应用上也有这三个调用,按键使用:Python 中是 app.open_instance_port(key, port, from_=[…])、app.instance_ports(key) 和 app.close_instance_port(key, port)(各 SDK 的页面给出各自的名称),MCP 服务器则有 cube_instance_ports。
检查点
机器丢失时正在运行的键控实例,会在别处从它的磁盘重新启动:正在运行的东西都没了。有了检查点,它会带着进程继续运行。应用的 checkpoint 为它的每个键控实例开启检查点,立即生效,不产生新版本(任何沙箱也都可以使用:沙箱的检查点):
glac cube update agent --checkpoint 10 # every 10 seconds while awake (on: 10)
glac cube update agent --reset checkpoint # none- 保存什么。实例运行期间,每隔
every_seconds秒(1 到 600;默认 10),把它的内存和磁盘自上一个检查点以来的变化压缩后送到对象存储;每次机器会把它暂停几毫秒,期间网络也保持静止。它的云盘(包括它自己的key_drives)会与磁盘在同一时刻固定下来。机器丢失时,另一台机器从最新的检查点接管实例,连同进程,并把云盘恢复到那一刻的状态。休眠的实例从休眠状态继续,不会丢失任何东西。唤醒后的第一个检查点,如果存储中已经有它休眠时的状态,就只发送它此后写入的内容。 - 接管会丢失什么。实例回到最新检查点的那一刻。此后它收到并确认的数据都没了,对端也不会再发一次;它当时的连接会在下一个数据包到来时被重置,所以它的程序需要重新连接。此后它对外部世界做过的事(写入别处的数据库、发出的消息)可能会再做一次:对外部而言,这是“至少一次”。
- 写入很多的实例会落后,而不会占满链路:上一个检查点进入存储后,下一个才开始。
- 进行情况:实例的
checkpoint(GET /v1/sandboxes/{id}、glac sandboxes get)包含every_seconds、last_at、behind_seconds(最新的检查点比every_seconds晚了多少)、bytes_per_second、stored_bytes和error(最近一个检查点没有创建或没有发送的原因)。 - 有状态服务的实例,以及挂载了非云盘存储卷(普通或共享存储卷,存储中没有它的副本)的实例,不会创建检查点:它的
checkpoint.error会说明这一点。 - 费用。实例运行期间,存储为它最新检查点保存的内容,按休眠实例的状态计费(
state,每 GB·月 $0.02);发送检查点不收费,也不算出站流量。
SDK 和应用的其他设置一起设置它(Python 中为 checkpoint={"every_seconds": 10},TypeScript 中为 checkpoint: { everySeconds: 10 },Go 中为 Checkpoint(10),Rust 和 Java 中为 checkpoint(10)),MCP 服务器的 cube_deploy 则用 checkpoint_seconds。
须知
- 如果它的服务器自行停止,它会在同一个实例中重新启动,磁盘保持原样(替换实例会丢失这个键保存的内容),与有状态服务的做法相同(服务):
instance.restarted事件会指明应用和键,它的start_command会说明重启了多少次。 - 版本。部署不会动已有的键控实例:每个实例都连同内存和磁盘留在创建它的版本上,直到被升级(见上文),除非应用设置了
keys.upgrade_on_deploy。新的键会得到承接流量的那个版本。 - 尚未支持:命令行和 SDK 还不能回退、不能做等待休眠的升级(
when、keep_days)、不能做批次,它们目前只能发起和跟踪升级(glac cube upgrade、SDK 的upgrade);MCP 服务器不发起任何升级(这要由人来做);API 拥有全部功能;应用的日志中没有键控实例的日志行;应用的网络规则 (egress) 对键控实例不起作用。 - 签名的开销。边缘节点检查签名并从请求中取出键,只需一两微秒,然后请求才会继续送往实例。
- 计费。键控实例和任何沙箱一样计量。它的请求计在它的
key标签下,所以GET /v1/usage?group_by=metadata.key会显示每个键的用量。 - 限制。每个键控实例都计入账户同时运行的沙箱数及其 vCPU 和内存,也计入每分钟创建的沙箱数和保持休眠的沙箱数(限制)。