公開日時指定可能な静的コンテンツ配信システムをサーバレスで作ってみた

こんにちは、インフラストラクチャーグループの沼沢です。
今回は、タイトルの通り 公開日時の指定が可能な静的コンテンツ配信用のシステムを、サーバレスで作ってみました。

なお、リージョンは全て東京リージョンとします。

構成図

まずは構成図です。

公開日時が指定可能な仕組み

S3 には、時間で公開状態へ移行するような設定や仕組みがありません。
これを実現するために、S3 オブジェクトのキーをもとに、Lambda を利用して、あたかも公開日時を指定しているかのような動きを実装します。
なお、本稿では分まで指定できる設定としていますが、要件によってカスタマイズすることも可能です。

S3

  • 「非公開 S3 バケット」と「公開用 S3 バケット」を用意
  • 「非公開 S3 バケット」のバケット直下には、プレフィックスに 公開したい年月日時分を示す yyyymmddHHMM 形式のディレクトリ を作成
    • 2020年1月1日0時0分に公開したい場合は 202001010000
  • 作成した公開予定ディレクトリの配下に、公開予定のファイルをアップロード

余談ですが、バケットポリシーを駆使すれば非公開と公開でバケットを分けなくても実現は可能なのですが、今回は分けています。
というのも、バケットポリシーは設定を誤ると root 以外操作できない状態になったり、非公開のつもりのディレクトリが公開状態になっていて公開前情報が流出しまったり…

とにかく、2つ用意したからってコストに差がでるわけでもありませんので、なるべく設定をシンプルにするためにバケットを2つ用意することにしました。

CloudFront

「公開用 S3 バケット」をオリジンとして、CloudFront を経由してコンテンツを配信します。

今回用意する仕組みでは 公開済みファイルの上書きも可能 となっていて、分毎にコンテンツが上書きされる可能性があるため、CloudFront の Default TTL を60秒としておくのが良いでしょう。

また、S3 への直アクセスを防ぎ、必ず CloudFront を経由するようにするため、Origin Access Identity を設定することをおすすめします。

Lambda

「非公開 S3 バケット」から「公開用 S3 バケット」にファイルをコピーする処理を行う公開用 Lambda を用意します。(ソースコードは後述)
この Lambda では、ざっくりと以下の処理をしています。

  • 起動した年月日時分に該当するディレクトリが「非公開 S3 バケット」直下に存在するか確認
  • 存在すればその配下のファイルを全て公開用バケットにコピー
    • コピーする際は、プレフィックスの yyyymmddHHMM は除いたキーでコピー
    • ディレクトリ構成も全てそのままコピーする

例えば、202001010000 ディレクトリが存在する場合のコピーは以下の様なイメージ。

s3://非公開S3バケット/202001010000/aaa.html
s3://非公開S3バケット/202001010000/hoge/bbb.html
s3://非公開S3バケット/202001010000/hoge/fuga/ccc.html

s3://公開用S3バケット/aaa.html
s3://公開用S3バケット/hoge/bbb.html
s3://公開用S3バケット/hoge/fuga/ccc.html

その他、この Lambda には以下のような設定をしておきます。

  • IAM ロールには AWSLambdaBasicExecutionRoleAmazonS3FullAccess のマネージドルールをアタッチ
    • ただし、AmazonS3FullAccess は強力なので、本番利用の際は適切に権限を絞ったカスタムポリシーの用意を推奨
  • タイムアウト値は、コピーするファイルの量にもよるので、要件に合わせた設定を推奨

CloudWatch Events

  • 公開用 Lambda を毎分キックするように CloudWatch Events を設定
    • Cron 式で * * * * ? * を指定
    • Cron 式の詳細は こちら を参照

Lambda Function のソースコード

今回は Python 3.7 用のソースコードを用意しました。
やっていることは単純なので、得意な言語に書き換えても良いと思います。

unpublish_bucketpublish_bucket の値だけ変更すれば動くと思います。

import json
import boto3
from datetime import datetime, timedelta, timezone

JST = timezone(timedelta(hours=+9), 'JST')
s3 = boto3.client('s3')

unpublish_bucket = 'unpublish_bucket' # 非公開 S3 バケット
publish_bucket   = 'publish_bucket'   # 公開用 S3 バケット

def lambda_handler(event, context):
    now = "{0:%Y%m%d%H%M}".format(datetime.now(JST)) # 現在日時(分まで)
    now_format = "{0:%Y/%m/%d %H:%M}".format(datetime.now(JST)) # 現在日時(分まで)

    unpublished_list = s3.list_objects(
        Bucket=unpublish_bucket,
        Prefix=now + '/'
    )

    if not 'Contents' in unpublished_list:
        print(f'[INFO] {now_format} に公開予定のファイルはありません。')
        return

    flag = False
    for obj in unpublished_list['Contents']:
        unpublished_path = obj['Key']
        if unpublished_path.endswith('/'):
            continue
        publish_path = unpublished_path.replace(now + '/', '')
        copy_result_etag = publish_file(publish_path, unpublished_path)
        get_result_etag = publish_file_check(publish_path)
        if copy_result_etag == get_result_etag:
            print(f'[SUCCESS] s3://{unpublish_bucket}/{unpublished_path} -> s3://{publish_bucket}/{publish_path} にコピーしました。')
            flag = True
        else
            print(f'[FAILED] s3://{preview_bucket}/{unpublished_path} -> s3://{publish_bucket}/{publish_path} のコピーに失敗しました。')

    if flag == False:
        print(f'[INFO] {now_format} 用のディレクトリ(s3://{unpublish_bucket}/{now}/)はありましたが、ファイルがありませんでした。')

def publish_file(publish_path, unpublished_path):
    result = s3.copy_object(
        Bucket=publish_bucket,
        Key=publish_path,
        CopySource={
            'Bucket': unpublish_bucket,
            'Key': unpublished_path
        }
    )
    if 'CopyObjectResult' in result:
        return result['CopyObjectResult']['ETag']
    else:
        return None

def publish_file_check(publish_path):
    result = s3.get_object(
        Bucket=publish_bucket,
        Key=publish_path
    )
    if 'ETag' in result:
        return result['ETag']
    else:
        return None

注意事項

  • 前述の通り、Lambda 用の IAM ロールに付与する権限には注意してください
  • CloudWatch Events はが最小単位です
    • 正確にその分の0秒に Lambda が起動するわけではありません
    • よって、秒まで指定した公開日時を指定することは本稿の内容では実現できません

コストについて

大抵の Lambda 実行は空振りになってしまってコストがもったいないと思うかもしれませんが、Lambda は無料利用枠の恩恵が大きく、この仕組だけでクラウド破産のようなことにはならないかと思いますので、そこはご安心を。

EC2 等、時間単位で課金されるものを利用していないため、トラフィックによる従量課金を除いた場合に発生する主要なコストとしては以下のものぐらいかと思います。

  • Lambda の無料利用枠を超えた分
  • CloudWatch Logs(Lambda のログ) への転送量と保存容量
  • S3 の保存容量

Lambda のコードをチューニングしたり、「非公開 S3 バケット」を低頻度アクセスストレージにしたりすると、更にコストを抑えられます。

その他

分毎ではなく時間毎など他の間隔にする

例えば時間毎で公開設定をしたい場合は、本稿の仕組みを以下のように修正すれば実現可能です。

  • アップロード時のプレフィックスを 公開したい年月日時を示す yyyymmddHH 形式のディレクトリ を作成する
  • CloudWatch Events の Cron 式に 0 * * * ? * を指定
  • Lambda のコードの現在時刻を取るフォーマットを時間までのものにする

ついでに CloudFront のキャッシュ時間も、公開間隔に合わせて調整することでキャッシュ効率も上がります。

非公開日時の指定をできるようにする

本稿の仕組では、非公開(「公開用 S3 バケット」からのファイル削除)日時の指定までは行っていません。
そこで、CloudWatch Events と Lambda の組み合わせをもう一組作るのも良いと思います。

Lambda でのエラー発生時のリカバリ策を仕込む

本稿では、Lambda でエラーが発生した際のリカバリを考慮していません。
コピー失敗などが発生すると、公開されるべきコンテンツが公開されず、エンドユーザやビジネスへの影響が出る事故になりかねません。

Lambda のコードでは、print で CloudWatch Logs に [SUCCESS][FAILED] を出力するようにしてあるので、その文字列を CloudWatch などで監視して、検知をトリガーにリカバリ用の仕組みがキックされるようにしておくと、事故の発生率を下げることができます。

簡単に思いつくリカバリの方法としては、もう一度 Lambda をキックするなどしょうか。
もしくは Twilio 等の電話通知を行い、それに気付いた運用者が、「非公開 S3 バケット」から「公開用 S3 バケット」へ、手動でコンテンツをコピーする形でも良いかもしれませんね。

あとがき

今回は、公開日時指定可能な静的コンテンツ配信システムをサーバレスで作成しました。

AWS を利用する場合、安易に EC2 でごにょごにょしようとせず、「可能な限りサーバ管理を AWS に任せる」という発想のもと、マネージドサービスを活用した設計を心がけてみましょう。

そうすることで、実際のインフラコストだけではなく、構築時や運用開始後の人的コストが劇的に下がり、エンジニアが より価値を生み出す生産的な活動に時間を割けるようになる ので、ぜひ検討してみてください。

ちなみに、この仕組みは3時間程度でできました。
本稿の執筆にかかった時間の方が長いです。

お後がよろしいようで。