1つのバケットに所有者が2人いると、最後にapplyした側が勝つ
S3のlifecycleルールを1つ追加したらterraform applyが失敗した。原因は、同じバケットのlifecycle設定を2つのリソースがそれぞれ所有する構造。S3のlifecycleはルール単位ではなくドキュメント単位なので、最後の勝者が相手のルールを静かに消す。
要点まとめ
presignだけされてcommitされなかったアップロードオブジェクトを回収するため、tmp/ prefixとlifecycleの有効期限ルールを設計した。ルールをマージするとstagingのapplyが失敗したのだが、原因はモジュールと環境設定が同じ物理バケットのlifecycleをそれぞれ所有していたこと。S3のlifecycle APIはドキュメント単位の全置換なので、2人の所有者はapplyのたびに互いのルールを上書きする。モジュール側のリソースをremovedブロック(destroy = false)でstateからだけ忘れさせ、単一所有者にした。ドキュメント単位のリソースの所有者は1人でなければならない。
目次
始まりはありふれた掃除の問題だった。ユーザーがメディアファイル(録音・画像)をアップロードする経路はpresigned URL方式だ。サーバーがアップロード用URLを発行し、クライアントがS3に直接アップロードした後、commit APIを呼んで「このキーを正式なアセットとして登録してほしい」と知らせる。問題は、presignだけ受けてcommitをしないケースだ。アプリが途中で落ちたり、ネットワークが切れたり、ユーザーが画面を離れたりすると、バケットにはどこにも登録されていないオブジェクトが残る。
これをlifecycleルールで掃除したかったのだが、設定できなかった。commit済みのオブジェクトと未commitのオブジェクトが同じprefixに混ざっていて、「古いものを消せ」というルールが正式なアセットまで消してしまうからだ。1日あたりのアップロードクォータが増殖を抑えてはいたものの、回収経路がないという事実はそのままだった。
設計: commitされていないものはtmpに住む
解決策の骨格は、キー名前空間の分離だ。
- presignが
tmp/prefixで一時キーを発行する。 - commitが検証(HEADでの存在確認、Content-Type、サイズ上限)を通過したら、CopyObjectで正式キーに昇格させ、tmpの原本を消す。
- commitがついに来なかったオブジェクトは
tmp/に残り、lifecycleルールが7日後に失効させる。
こうすればlifecycleは tmp/ だけを見ればいい。正式なアセットは最初からルールの射程外だ。クライアントは変える必要がなかった。アップロードフロー3つを全部読んで確認したところ、どれもcommitレスポンスに入っているキーを後続の呼び出しに使っていて、サーバーがキーの形を変えても気づかない。デプロイ時点ですでに飛んできていた旧形式キーのcommitも、既存の経路でそのまま処理される。
ここに罠が1つ。このバケットはバージョニングが有効になっている。バージョニングされたバケットでは、expirationはオブジェクトを消すのではなく削除マーカーを載せるだけで、元のバイトはnoncurrentバージョンとして残る。noncurrent_version_expiration(1日)をペアで設定しないと、掃除をしても容量は1バイトも減らない。
設計レビューも元を取った。昇格ロジックにレビューを付けたら、確定した欠陥が3つ出てきた。管理者用のカスタムキー経路で tmp/ キーを最終キーとしてcommitすると、lifecycleがライブのアセットを消してしまう穴(共有commit境界でtmpの最終キーを遮断)、昇格のsourceKeyが任意コピーのプリミティブになってしまう問題(tmpキーと最終キーの等式を強制)、同じtmpキーを同時にcommitすると片方のcopyが404で死ぬ冪等性の問題。3つともコードで直してから先へ進んだ。
そしてapplyが失敗した
lifecycleルールをterraformで追加してマージした。stagingのapplyが失敗した。
エラーはlifecycle設定書き込みのタイムアウトだったが、掘ってみるとタイムアウトは症状で、問題は構造だった。このバケットのlifecycleを、terraformリソース2つがそれぞれ所有していたのだ。1つは画像リサイズモジュールの中にあり(モジュールが自分の用途のルールを直接設定していた)、もう1つは環境別のバケット設定ファイルにあった(今回tmpルールを追加した場所)。同じ物理バケットに、所有者が2人。
S3のlifecycle APIに「ルールを1つ追加」は存在しない。PutBucketLifecycleConfigurationはドキュメント全体を置き換える。terraformのaws_s3_bucket_lifecycle_configurationもその上に立っていて、このリソースは自分の知っているルールでバケットのlifecycleドキュメント全体を上書きする。所有者が2人いると、それぞれが自分のルールだけを載せたドキュメントを押し込み合う。1つのapplyの中で2つが出会えば同時書き込みでタイムアウトになり、別々に出会えばもっと悪い。エラーなしで成功しながら、最後の勝者が相手のルールを消す。
これがこの一件でいちばん背筋の冷える部分だ。タイムアウトはむしろ騒がしい失敗で、幸運だった。この構造が生き続けていたら、次に画像リサイズモジュールに触れるどんなapplyでも、入れたばかりのtmp有効期限ルールを静かに消していただろう。planにはそのバケットリソースのin-place updateの1行としてしか見えなかったはずで、未commitオブジェクトはまた永遠に積もり始めただろう。掃除ルールが消えたことに気づくモニタリングは、どこにもなかった。
実はこの箇所は、コードレビューの段階で「2つのリソースが同じバケットを触っているようだ」という疑いとして一度指摘されていた。当時は潜在リスクに分類したのだが、マージ直後にapplyが失敗し、潜在が現実になるまで数分もかからなかった。
解決策: 所有者を1人に
修正の方向は明確だった。バケットごとのlifecycle所有者は1人でなければならない。モジュールからlifecycleリソースを取り除き、環境別の設定ファイルを単一所有者にして、モジュールが設定していたルールもそちらへ移した。
このとき使った道具が、terraformのremovedブロックだ。モジュールからリソース宣言をただ消すと、terraformは実物のlifecycle設定をdestroyしようとする。今ライブで動いているルールを削除してから作り直す窓が生まれるということだ。removedブロックにdestroy = falseを与えると、terraformはそのリソースをstateからだけ忘れる。実物はそのままに帳簿からだけ消すので、所有権が環境設定側へ無停止で移転する。
修正後、stagingのapplyが通り、prodまで反映した後にAWS APIで実際のバケット状態を直接確認した。両方のバケットでtmp有効期限ルールが生きていて、既存のルール3つも無事だった。terraform planだけ見て終わらせなかった理由は、この一件そのものが教えてくれたことだ。planは各所有者の帳簿にすぎず、ドキュメントを上書きする構造では、2冊の帳簿は互いを知らない。
残ったもの
ドキュメント単位のリソースの所有者は1人でなければならない。S3のlifecycleだけの話ではない。バケットポリシー、CORS設定、通知設定のように「全置換」APIの上に立つterraformリソースは、すべて同じ性質を持つ。同じ対象を指す宣言がモジュールと環境設定に1つずつあると、コンパイルエラーの代わりに、ランタイムの静かな上書きを手に入れることになる。新しいルールを追加する前に、そのドキュメントの所有者がすでにいないかをまず探すべきだ。
もう1つ。リソース宣言を消すことと実物を消すことは別の仕事で、terraformはその区別をremovedブロックで表現する。所有権の移転が目的のとき、destroyを経由した瞬間に無停止は壊れる。
最後に、回収経路はキー設計から始まる。「commitされたものとされていないもの」のように寿命の違うデータが同じ名前空間に住んでいると、どんな掃除ルールも安全には設定できない。寿命が違うなら、まず住む場所を分けるべきだ。ちなみにこの方式では、過去にすでに積もった未commitオブジェクトまで遡って回収することはできない。それらのオブジェクトは正式なアセットと区別する方法がなく(それがこの問題の出発点だった)、新規アップロードから適用する範囲を意図的に選んだ。
よくある質問
S3のlifecycleルールがterraform applyの後に消えます。なぜですか?
aws_s3_bucket_lifecycle_configurationは、バケットのlifecycle設定ドキュメント全体を所有するリソースです。同じバケットを指すこのリソースが2か所(例: モジュールと環境設定)にあると、それぞれが自分の知っているルールだけでドキュメント全体を置き換えるため、最後にapplyされた側が相手のルールを消します。同じapplyの中に両方あると、同時書き込みでタイムアウトになることもあります。バケットごとに所有リソースを1つに統合してください。
バージョニングされたS3バケットでlifecycleの有効期限を設定したのに容量が減りません。
バージョニングされたバケットでは、expirationはオブジェクトを削除するのではなく、削除マーカーを載せて既存バージョンをnoncurrentにするだけです。noncurrent_version_expirationを一緒に設定しないと、バイトは永遠に回収されません。有効期限ルールとnoncurrentバージョンの有効期限はペアで設計する必要があります。
関連記事
- 💻 開発
受け取り損ねていた割引 - SpotとGravitonはなぜ安いのか
AWSの請求書が2か月連続で予算を超えた。削れるリソースを探したが、本当のレバーは「どれだけ使うか」ではなく「どう買うか」だった。SpotとGravitonがなぜ安いのか、そして無駄に見えて実は切ってはいけないコスト(RDS Proxy)まで - 請求書の数字ひとつひとつに宿る「理由」についての話。
- 💻 開発
テストは緑なのに、通知は一度も送信されていなかった
キューにジョブを入れるコードが初日から毎回失敗していた。エラーは握りつぶされ、テストはモックのおかげで緑のまま。コロン一文字が3つの機能を静かに殺した話。
- 💻 開発
DBが落ちたという通知が来た。DBは一度も落ちていなかった
1日のうちにCRITICALの通知が4つたまった。504、Prisma P2028、管理画面の500、そして「DATABASEサービスダウン」。RDSの指標をまず開いたら、21時間ずっとCPU最大19.7%で無事だった。遅かったのはDBではなく、大西洋を25回渡る往復であり、「ダウン」の通知はヘルスチェックが自分で作り出したものだった。