スポンサーリンク

Serverless Frameworkを使用してS3+CloudFrontの構成に静的サイトをデプロイする備忘録

静的サイトについてローカルに開発環境を持たない&リリースについてSFTPで接続してのアップロードを行っていたという状況を改善する目的で、S3+CloudFrontのサーバーレスな環境にServerless Frameworkを利用してデプロイする構成を考えました。
90%くらい以下の2記事を参考にさせていただきました。

  1. CloudFront + S3 で静的ページをホストするための Serverless Framework 設定
  2. Serverless Framework + S3 + CloudFront – Qiita

今回もServerless Frameworkに関する話題ですが、これまでのLambda関数をデプロイする内容とは少し毛色の違う使い方に関する内容となります。

この構成のメリット、デメリット

  • メリット
    • ローカルからコマンド1つで静的サイトを公開できる
    • Webサーバーを動かす場所が不要(EC2、Lightsail、その他VPSサービス等)
      • 低コスト(のはず)
    • 乗っかるサービスの仕組み上、アクセス集中に関する心配が不要
  • デメリット
    • CloudFrontへのEDoSによって多額の請求が来る可能性がある

など

環境構築

今回作ったGitHubリポジトリは自分用のコメントが多数残っている状態なので非公開となります。そのため各リソースについて要点を記事の中で記載します。

FROM node:18-slim
SHELL ["/bin/bash", "-oeux", "pipefail", "-c"]

RUN npm install -g http-server@latest

USER node

WORKDIR /workspace

CMD [ "npx", "http-server", "-p", "80"]

始めにDockerfileですが、Serverless Frameworkを導入するのにnpmが必要なので、ベースとなるDockerイメージはnodeとなります。今回はnode:18-slimを使用。

また、静的サイトについてローカルで動作確認するのに、npmでインストール可能なhttp-serverというパッケージをグローバルインストールしています。http-serverのWebサーバーをCMD命令で実行しておくことで、開発者は意識せずにhttp://localhostにアクセスしての動作確認が可能となります。
最初はNode.js標準のhttpモジュールを使用した簡易なビルトインサーバーを作ったりもしましたが、npmが使える以上ちゃんとしたパッケージを使用した方が良いなと思い直して方針転換しました。

services:
  app:
    build:
      context: ./
      dockerfile: ./.docker/app/Dockerfile
    volumes:
      - .:/workspace:cached
    ports:
      - "80:80"
    environment:
      - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
      - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
      - AWS_DEFAULT_REGION=ap-northeast-1
      - AWS_S3_BUCKET_PREFIX=${AWS_S3_BUCKET_PREFIX}

docker-compose.ymlの中では.envよりAWSのアクセスキーとシークレットキーの他、後述するServerless Frameworkの中で使用するS3のバケット名のプレフィックスをコンテナに渡す設定を記述しています。
また、コンテナ内で動かすhttp-serverにホストPCから接続するためのポートフォワーディングの設定も行います。

ディレクトリ構成

添付の画像のようになりました。
publicディレクトリが静的サイトを含むディレクトリになります。
.serverlessディレクトリはデプロイ時に生成されるディレクトリです。

.gitignoreは以下の通り

.env
.serverless
node_modules

MakefileはServerless Frameworkやコンテナ操作についてコマンドをまとめてるだけなので省略。

デプロイに必要となるIAMポリシー

.envで設定中のIAMユーザーに以下のポリシーを持たせる必要があります

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "s3:*",
                "cloudformation:DescribeStackResource",
                "cloudformation:CreateChangeSet",
                "s3-object-lambda:*",
                "cloudformation:DeleteChangeSet",
                "cloudformation:DescribeStacks",
                "cloudfront:*",
                "cloudformation:DescribeStackEvents",
                "cloudformation:GetTemplate",
                "cloudformation:DeleteStack",
                "cloudformation:DescribeChangeSet",
                "cloudformation:ExecuteChangeSet",
                "cloudformation:ValidateTemplate",
                "cloudformation:ListStackResources"
            ],
            "Resource": "*"
        }
    ]
}

s3、s3-object-lambda、cloudfrontについてすべてのアクションを有効としている点は改善の余地がある状態。
ただ、デプロイに失敗したときにエラーログから不足しているアクションが分からないことがある&ロールバックが正しく行えず、再デプロイするのに手動でスタックに含まれるリソースを削除する必要が出てしまうという状況に陥ってしまうことがあったため調査を断念しているという経緯があります。。

package.json

{
  "private": true,
  "devDependencies": {
    "serverless": "^3.31.0",
    "serverless-cloudfront-invalidate": "^1.12.2",
    "serverless-s3-sync": "^3.1.0"
  }
}

Serverless Framework本体の他に、プラグインを2つ使用します(次項で解説)。

Serverless Frameworkについて

serverless.ymlの内容は記事の先頭に書いた参考ページ2つの情報を元に以下のようになりました。

service: s3-static-site

frameworkVersion: "3"

plugins:
  - serverless-s3-sync
  - serverless-cloudfront-invalidate

provider:
  name: aws
  region: ${env:AWS_DEFAULT_REGION}

custom:
  bucketName: ${env:AWS_S3_BUCKET_PREFIX}-${aws:accountId}
  s3Sync:
    - bucketName: ${self:custom.bucketName}
      localDir: public/
  cloudfrontInvalidate:
    - distributionIdKey: DistributionId
      items:
        - "/*"

resources:
  Resources:
    Bucket:
      Type: AWS::S3::Bucket
      Properties:
        BucketName: ${self:custom.bucketName}
        AccessControl: Private
        WebsiteConfiguration:
          IndexDocument: index.html
    BucketPolicy:
      Type: AWS::S3::BucketPolicy
      Properties:
        Bucket: !Ref Bucket
        PolicyDocument:
          Version: "2012-10-17"
          Statement:
            Sid: AllowCloudFrontServicePrincipalReadOnly
            Effect: Allow
            Principal:
              Service: cloudfront.amazonaws.com
            Action: s3:GetObject
            Resource:
              Fn::Sub: arn:aws:s3:::${Bucket}/*
            Condition:
              StringEquals:
                AWS:SourceArn:
                  Fn::Sub: arn:aws:cloudfront::${AWS::AccountId}:distribution/${Distribution}
    OriginAccessControl:
      Type: AWS::CloudFront::OriginAccessControl
      Properties:
        OriginAccessControlConfig:
          Name:
            Fn::Sub: ${self:custom.bucketName}-oac
          OriginAccessControlOriginType: s3
          SigningBehavior: always
          SigningProtocol: sigv4
    Distribution:
      Type: AWS::CloudFront::Distribution
      Properties:
        DistributionConfig:
          Enabled: true
          HttpVersion: http2and3
          Origins:
            - Id: ${self:custom.bucketName}
              DomainName:
                Fn::Join:
                  - '.'
                  - - Ref: Bucket
                    - s3
                    - Ref: AWS::Region
                    - Ref: AWS::URLSuffix
              OriginAccessControlId:
                Ref: OriginAccessControl
              S3OriginConfig:
                OriginAccessIdentity: ''
          DefaultCacheBehavior:
            TargetOriginId: ${self:custom.bucketName}
            ViewerProtocolPolicy: https-only
            ForwardedValues:
              QueryString: false
          DefaultRootObject: index.html
  Outputs:
    URL:
      Value:
        Fn::Sub: https://${Distribution.DomainName}
    DistributionId:
      Value: !Ref Distribution

いつもの内容と違う点としてはLambda関数が不要のためfunctions:が存在しません。今回のServerless Frameworkはresources:の内容によってS3+CloudFrontのスタックを作成→S3にコンテンツをアップロードするアップローダとしての役割を担っています。

デプロイ時に作成するリソースは以下の4つ

  • S3のバケット
  • S3のバケットポリシー
  • CloudFrontのディストリビューション
  • CloudFrontのオリジンアクセスコントロール

オリジンアクセスコントロールを利用することで非公開なS3バケットからバケットポリシーの設定を介してコンテンツにアクセスできるようにするという仕組み。
Distribution → Bucket → BucketPolicy → OriginAccessControl という順番でリソースが構築されます。

以下、上記serverless.ymlの抜粋した解説

plugins:
  - serverless-s3-sync

custom:
  s3Sync:
    - bucketName: ${self:custom.bucketName}
      localDir: public/

こちらの設定によってpublicフォルダの中のコンテンツをS3に同期しています。
serverless-s3-syncプラグインのその他のオプションについてはServerless Framework公式のプラグインのページかGitHubリポジトリで確認すると良いと思います。
Serverless Framework: Plugins
k1LoW/serverless-s3-sync: A plugin to sync local directories and S3 prefixes for Serverless Framework

plugins:
  - serverless-cloudfront-invalidate

custom:
  cloudfrontInvalidate:
    - distributionIdKey: DistributionId
      items:
        - "/*"

resources:
  Resources:
    Distribution:
      Type: AWS::CloudFront::Distribution

  Outputs:
    DistributionId:
      Value: !Ref Distribution

こちらの設定によってS3の同期の後にCloudFrontのキャッシュ削除を生成しています。
CloudFrontによってHTMLやCSSがキャッシュされることで更新後の内容が反映されなかったり、削除したページがキャッシュとして残り続けてしまうことを防ぐことができます。

resources:
  Resources:
    Distribution:
      Type: AWS::CloudFront::Distribution
      Properties:
        DistributionConfig:
          HttpVersion: http2and3

その他、CloudFrontディストリビューションについてデフォルトで有効となるHTTP/1.0、1.1プロトコルの他にHTTP/2とHTTP/3に対応させたい場合はhttp2and3という設定を行う必要があるみたいです。
詳しくは以下のDistributionConfigに関するドキュメント参照
AWS::CloudFront::Distribution DistributionConfig – AWS CloudFormation

HTTP/2、HTTP/3に対応したことにより対応したブラウザでのアクセスの際にプロトコルがHTTP/3となることの確認

デプロイ

Serverlessデプロイ
$ docker-compose exec app npx serverless deploy --verbose

Serverlessで作成したスタックを含む削除
$ docker-compose exec app npx serverless remove --verbose

Serverlessの設定確認
$ docker-compose exec app npx serverless print

初回のスタック構築も行うデプロイは7~8分程度掛かりました。

S3バケット名についてcustom:の中でAWSアカウントIDをサフィックスとして付与しようとした際、${AWS::AccountId}の疑似パラメータ参照が解決できずに文字列としてデプロイされてしまいスタックの作成に失敗するといったことがありました(custom:の中ではServerless Frameworkで提供されている${aws:accountId}という疑似パラメータの参照を行う必要がある)。
擬似パラメータ参照 – AWS CloudFormation
Serverless Framework Variables

serverless printによる確認。NGパターンとOKパターン

事前にserverless printしておけばおかしな設定となっていることに気づけた問題でした。。

まとめ、運用する場合の備考

S3+CloudFrontの構成の静的サイトについてServerless Frameworkを使用してローカルからデプロイすることができました。

なお、実際に運用する場合はCloudFrontディストリビューションに対しての標準ログ記録の有効化や、AWS WAFを使用した保護が必要かと思います。
サーバーレスによってWebサーバーを稼働させるインスタンスの料金が節約できてもAWS WAFの使用料がかかることになるので、元々t4g.nanoとかのインスタンスで十分な運用が行えている静的サイトの場合は積極的に移行する必要は無いかなとも思います。

以下は標準ログ記録の有効化をCloudFormationのテンプレートに含める場合の追記になります。

resources:
  Resources:
+    # 標準ログ記録用のバケットを作成
+    AccessLogBucket:
+      Type: AWS::S3::Bucket
+      Properties:
+        BucketName: ${self:custom.bucketName}-accesslog
+        AccessControl: LogDeliveryWrite
+        OwnershipControls:
+          Rules:
+            - ObjectOwnership: BucketOwnerPreferred
+        LifecycleConfiguration:
+          Rules:
+            - Status: Enabled
+              ExpirationInDays: 3
    Distribution:
      Type: AWS::CloudFront::Distribution
      Properties:
        DistributionConfig:
+          # 標準ログ記録を有効化
+          Logging:
+            Bucket: !GetAtt AccessLogBucket.DomainName

ポイントとしてCloudFrontの標準ログ記録で使用するS3バケットはCloudFrontからの書き込みについてACLを有効化した状態で許可する必要があるみたいです。
最初、ログを保管するバケットについて静的サイト用のものと同じ要領でACL無効化&バケットポリシーによる許可を行おうとしたところデプロイ時に「Invalid request provided: The S3 bucket that you specified for CloudFront logs does not enable ACL access…」というエラーが出てしまいました。
この件については以下の記事が参考になりました。
S3 のオブジェクト ACL を有効化にするケースまとめ | DevelopersIO

その他、日本国内の人に向けたページであればCloudFrontの配信について地理的制限を掛けることでEDoSのリスクを多少下げることが出来るかなと思います(実際のEDoSの手法を理解してないので間違ってるかも)。
そういった点について、今回紹介したserverless.ymlにはまだ改善できるところがありそうな状態だと思います。

参考リンク

記事の中に貼ったリンクなどについても再度ここに記載

タイトルとURLをコピーしました