Why NextWhy Next
返回列表
💻 开发阅读约 5 分钟

一个桶有两个主人时,最后 apply 的一方获胜

我加了一条 S3 lifecycle 规则,terraform apply 却失败了。原因是同一个桶的 lifecycle 配置被两个资源各自拥有。S3 lifecycle 不是按规则粒度,而是按整个文档粒度生效,最后的赢家会悄悄抹掉对方的规则。

核心摘要

为了回收只做了 presign 却从未 commit 的上传对象,我设计了 tmp/ 前缀加 lifecycle 过期规则。规则合并后 staging apply 失败,原因是模块和环境配置各自拥有同一个物理桶的 lifecycle。S3 的 lifecycle API 是整个文档级替换,两个所有者每次 apply 都会互相覆盖对方的规则。我用 removed 块(destroy = false)让模块侧资源只从 state 中被遗忘,变成单一所有者。文档级资源必须只有一个主人。

目录

一切从一个平常的清理问题开始。用户上传媒体文件(录音和图片)走的是 presigned URL 方式。服务器签发上传用的 URL,客户端直接传到 S3,然后调用 commit API 告诉服务器"把这个 key 登记为正式资产"。问题出在只拿了 presign 却没有 commit 的情况。App 中途崩溃、网络断开、用户离开页面,桶里就会留下一个哪里都没有登记的对象。

我想用 lifecycle 规则来清理这些对象,却写不出这条规则。已 commit 和未 commit 的对象混在同一个前缀下,"删掉旧的"这种规则会把正式资产也一起删掉。每日上传配额虽然挡住了增长速度,但没有回收路径这个事实没有改变。

设计:没有 commit 的东西住在 tmp 里

解法的骨架是 key 命名空间分离。

  1. presign 用 tmp/ 前缀签发临时 key。
  2. commit 通过校验(用 HEAD 确认存在、Content-Type、大小上限)后,用 CopyObject 把对象晋升到正式 key,并删除 tmp 原件。
  3. 始终没等到 commit 的对象留在 tmp/ 里,由 lifecycle 规则在 7 天后使其过期。

这样 lifecycle 只需要盯着 tmp/。正式资产从一开始就在规则的射程之外。客户端不需要任何改动。我把三条上传流程全部读了一遍确认过:它们都是拿 commit 响应里返回的 key 去做后续调用,所以服务器改变 key 的形状它们也不会察觉。部署时刻已经在途的旧格式 key 的 commit,也照旧走原有路径处理。

这里有一个陷阱。这个桶开着版本控制。在开启版本控制的桶上,expiration 并不删除对象,只是加一个删除标记,原来的字节会作为 noncurrent 版本留下来。如果不成对设置 noncurrent_version_expiration(1 天),清理照跑,容量却一个字节也不会减少。

设计评审也物有所值。给晋升逻辑挂上评审后,查出了三个确定的缺陷。一个漏洞是管理员专用的自定义 key 路径可以把 tmp/ key 直接 commit 成最终 key,导致 lifecycle 把线上资产删掉(在共享的 commit 边界上拦截了 tmp 最终 key);晋升的 sourceKey 变成任意复制原语的问题(强制 tmp 与最终 key 之间的等式);以及同一个 tmp key 被并发 commit 时,一方的 copy 会因 404 而失败的幂等性问题。三个都在代码里修完才继续往前走。

然后 apply 失败了

我用 terraform 加上 lifecycle 规则并合并了。staging apply 失败了。

报错是写入 lifecycle 配置超时,但深挖下去,超时只是症状,结构才是问题。这个桶的 lifecycle 被两个 terraform 资源各自拥有。一个在图片缩放模块里(模块为自己的用途直接挂了规则),另一个在按环境划分的桶配置文件里(也就是这次加 tmp 规则的地方)。同一个物理桶,两个主人。

S3 的 lifecycle API 里没有"添加一条规则"这种操作。PutBucketLifecycleConfiguration 替换的是整份文档。terraform 的 aws_s3_bucket_lifecycle_configuration 建立在它之上,所以这个资源会用自己知道的规则覆盖桶的整份 lifecycle 文档。有两个所有者时,双方都在往里推只装着自己规则的文档。在同一次 apply 里相遇,并发写入导致超时;分开相遇则更糟:apply 无错成功,最后的赢家抹掉对方的规则。

这是整件事里最让人后背发凉的部分。超时反而是吵闹的失败,算是万幸。如果这个结构一直活着,下一次任何碰到图片缩放模块的 apply,都会悄悄删掉我刚加的 tmp 过期规则。plan 里只会显示那个桶资源的一行 in-place update,未 commit 的对象又会开始永远地堆积。而察觉清理规则消失的监控,哪里都没有。

其实这个点在代码评审阶段就被指出过一次,当时的怀疑是"这两个资源好像在碰同一个桶"。那时被归类为潜在风险,而合并之后 apply 一失败,潜在变成现实只用了几分钟。

解法:只留一个主人

修复方向很明确。每个桶的 lifecycle 所有者必须只有一个。我把 lifecycle 资源从模块里移除,让按环境划分的配置文件成为单一所有者,把模块原来挂的规则也搬了过去。

这时用到的工具是 terraform 的 removed 块。如果直接从模块里删掉资源声明,terraform 会试图 destroy 真实的 lifecycle 配置,也就是会出现一个窗口:线上正在生效的规则被删掉再重建。给 removed 块加上 destroy = false,terraform 就只在 state 里遗忘这个资源。实物原封不动,只从账本上抹掉,所有权就无中断地转移到环境配置那边。

修复后 staging apply 通过,推到 prod 之后,我又通过 AWS API 直接确认了桶的实际状态。两个桶的 tmp 过期规则都活着,原有的三条规则也完好无损。之所以没有只看 terraform plan 就收工,正是这次事件本身教给我的:plan 只是每个所有者自己的账本,而在覆盖整份文档的结构里,两本账互不知情。

留下了什么

文档级资源必须只有一个主人。不只是 S3 lifecycle。桶策略、CORS 配置、通知配置,凡是建立在"整体替换"API 之上的 terraform 资源都有同样的性质。指向同一目标的声明在模块和环境配置里各有一份时,你得到的不是编译错误,而是运行时的无声覆盖。添加新规则之前,先去找找那份文档是不是已经有主人了。

还有一点。删除资源声明和删除实物是两件不同的事,terraform 用 removed 块来表达这个区别。当目的是所有权转移时,一旦经过 destroy,无中断就没了。

最后,回收路径始于 key 设计。像"已 commit 和未 commit"这样寿命不同的数据住在同一个命名空间里时,任何清理规则都无法安全地挂上去。寿命不同,就要先把住处分开。顺带一提,这个方案并不能追溯回收过去已经堆积的未 commit 对象。那些对象没有办法和正式资产区分开(这正是整个问题的起点),所以我有意选择了只对新上传生效的范围。

常见问题

S3 lifecycle 规则在 terraform apply 之后消失了,为什么?

aws_s3_bucket_lifecycle_configuration 是一个拥有桶的整份 lifecycle 配置文档的资源。如果指向同一个桶的这种资源存在于两个地方(比如一个在模块里,一个在环境配置里),每一方都只用自己知道的规则替换整份文档,所以最后 apply 的一方会抹掉对方的规则。如果两者出现在同一次 apply 中,并发写入还可能导致超时。请把每个桶的所有者资源合并为一个。

我在开启版本控制的 S3 桶上设置了 lifecycle 过期,但容量没有减少。

在开启版本控制的桶上,expiration 并不会删除对象,而是加上一个删除标记,把现有版本变成 noncurrent 版本。如果不同时设置 noncurrent_version_expiration,那些字节永远不会被回收。过期规则和 noncurrent 版本过期必须成对设计。

相关文章

0 条评论