Why NextWhy Next
一覧へ戻る
💻 開発読了 7 分

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に住む

解決策の骨格は、キー名前空間の分離だ。

  1. presignが tmp/ prefixで一時キーを発行する。
  2. commitが検証(HEADでの存在確認、Content-Type、サイズ上限)を通過したら、CopyObjectで正式キーに昇格させ、tmpの原本を消す。
  3. 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バージョンの有効期限はペアで設計する必要があります。

関連記事

コメント 0 件