Kohara's Blog

KoharaKazuya
職業はプログラマーです。趣味がプログラミングです。Web 技術が好きで、中でもフロントエンドの JS, HTML, CSS が好きです。得意なのは TypeScript, CSS, AWS, シェル芸です。

AWS で自動的にキャッシュ破棄する S3 バケットを作ろうとして諦めた

tl;dr

  • いろいろ考えたが採用するほどうまくいかなかったので記録して捨てる
  • S3 + CloudFront 構成で、S3 オブジェクトを更新したときに自動的に Invalidation したい
    • キャッシュの TTL を長くして、更新時に Invalidation することでヒット率を高める戦略
    • 運用ルールなどを追加せずに普通に S3 オブジェクトを更新するだけで実行されるようにしたい
  • S3 イベントで Lambda を起動して Create Invalidation すると実現できる
  • S3 イベントオブジェクト単位で発生するので、大量に発生する
    • 素直に毎回 Create Invalidation をするとオブジェクト数に比例してコストがかかる
      • 「一回の」更新でも 1,000 ファイルあれば 1,000 回分のコスト
      • 安いわガハハ、って場合は気にしなくてよい
    • Lambda グローバル変数を悪用すれば回避できる (不安定)

やりたいこと

静的サイトを作る際、S3 + CloudFront という構成をよく使う。 ファイルごとに CloudFront でキャッシュされる時間 (TTL) をアプリケーションの特性によって考える必要があるが、CloudFront ではキャッシュ破棄のコマンド Create Invalidation があるため、TTL を長めにして S3 更新時に手動で Create Invalidation をする戦略がとれる。これをすることでヒット率が上がりアプリケーションのローディング速度が上がる。 実際 CI/CD サーバーから AWS CLI でデプロイ後に Create Invalidation を実行するのはよくやる。

しかし、S3 自体が更新されたことを検知できるのだから、わざわざこちらから CloudFront に指示しなくても自動的に Create Invalidation を実行してくれるシステムにしたい。

実現方法

S3 はイベント通知の検知の機能を持ち、イベント発生時に Lambda 関数を実行できる。 Lambda 関数は当然 Create Invalidation を実行できるので非常に簡単に望むシステムを実現できる。

懸念点

S3 イベントはオブジェクトごとに発生する。 つまりアプリケーションの更新時に 1,000 ファイルを更新すれば 1,000 回 Lambda 関数が実行されることになる。

じゃあ 1,000 回の Lambda 関数実行が問題かというと、Lambda 自体は無料枠の範囲内で収められるぐらいコストが安いので気にしなくて良い。更新するファイル数が多いアプリケーションを毎日数回デプロイする場合は考える必要がありそうだが……。 Lambda 自体というよりかは CloudFront の Create Invalidation のコストが気になる。毎回 1,000 回も実行しているとすぐに大金を請求されることになる。

回避策

Lambda 関数が実行されることは問題ないので、Lambda 関数で Create Invalidation を呼び出す回数を減らす。 アプリケーションの「一回の」更新では短い時間の間に複数のファイルが更新されるはずなので、直近の更新を一つの Create Invalidation にまとめれば良い。

そのため Lambda 関数のグローバル変数を使用する。 Lambda 関数の実行コンテキストは短い期間であれば引き継がれるので今回の目的のためだと利用できる。実行コンテキストが引き継がれるのは単にパフォーマンスの問題のためだと思われるので、保証はない危ないやり方だが。 グローバル変数経由で前回実行時間を取得し連続した呼び出しをカットする。

また、Lambda 関数はスケーリングのために別インスタンス、つまり別実行コンテキストを起動してしまうのでこれを防ぐために Lambda 関数の同時実行数を 1 に制限しておく。

S3 イベント通知の Lambda 関数呼び出しは非同期的な呼び出しのため、同時実行数制限によるスロットリング発生では成功するまでリトライされる。そのため S3 上の更新発生からしばらく経過してから Lambda 関数にイベントが到着する可能性があるからこれもカットする必要がある。

これらを Python で書くとこうなった。

from datetime import datetime

# グローバル変数としての初期化時は無限遠の過去としてみなせる値を入力
last_invoked = datetime(1970, 1, 1)

def lambda_handler(event, context):
    global last_invoked
    n = datetime.utcnow()

    # 以前の実行から 3 秒経過していなければ実行しない
    if (n - last_invoked).seconds < 3:
        return

    # イベント発生日時から既に 3 秒経過していれば実行しない
    if len(event['Records']) == 0:
        return
    eventTime = datetime.strptime(event['Records'][0]['eventTime'], '%Y-%m-%dT%H:%M:%S.%fZ')
    if (n - eventTime).seconds >= 3:
        return

    last_invoked = n

    print('Create Invalidation')

評価

まず、Lambda 関数のグローバル変数利用は本来の意図から外れているだろうから、いつ挙動が変わってもおかしくない。このせいで一気に請求が来たりすると笑えない。危ない。

次に Lambda 関数を利用するということはそれに伴って CloudWatch Logs や IAM Role を用意する必要があり面倒。

S3 を更新するだけ、は非常に便利。 コンピューターを触る人間にとってファイルを更新するだけ以上の概念はややこしいことで自動的に解決されてほしい。

結論

最初に想像したより大げさな仕組みが必要だった。 S3 + CloudFront をよりシンプルに使えるようにするためだけにできるほどではなかった。