S3とCloudFrontを使用してSPAをデプロイする

## 用語について調べてみる
- Amazon S3は、スケーラビリティ、データ可用性、セキュリティ、およびパフォーマンスを提供するオブジェクトストレージサービス
- CloudFrontは、静的および動的なWebコンテンツ、ビデオストリーム、APIを世界中に安全かつ大規模に配信するコンテンツ配信ネットワーク（CDN）サービス


## フロントエンドをS3とCloudFrontでデプロイするメリット

- CloudFront経由で配信しておくと、エンドユーザーからのリクエストはエッジロケーションにルーティングされるのでWebページの表示パフォーマンス高い
- キャッシュされていなくても、コンテンツは公共のインターネットではなくAWSのプライベートネットワークを通過し、CloudFrontはTCPハンドシェイクを最適化するため、リクエストとコンテンツのリターンは公共のインターネットを介したアクセスよりもはるかに高速

参考記事： https://aws.amazon.com/jp/blogs/networking-and-content-delivery/amazon-s3-amazon-cloudfront-a-match-made-in-the-cloud/

## デプロイの流れ

全体図はざっくりですが、以下のイメージです
![スクリーンショット 2021-07-04 15.32.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/546062/db5a9b95-162e-f39d-5a97-c061dfdf7e58.png)

- **バージニア北部リージョン**で、ACM Certificateを作成 (サイトのドメイン名を既に取得している前提)

https://docs.aws.amazon.com/ja_jp/acm/latest/userguide/acm-regions.html

- S3バケットを作成
    - reactアプリケーションをbuildして、/buildディレクトリのファイルを作成したS3バケットに配置する
    - アクセスポリシーを設定する

https://aws.amazon.com/jp/premiumsupport/knowledge-center/cloudfront-serve-static-website/

- CloudFront Distributionを作成
    - Origin Domain Nameには作成したS3バケットを選択する
    - バケットサクセスの制限には「はい」を選択する
    - Origin Access Identityには、[新しい ID の作成] を選択する
    - [バケットの読み取りアクセス許可を付与] には、[はい、バケットポリシーを更新します] を選択する



## CloudFormationを使用する場合

```acm.yml
## バージニア北部リージョンでスタックを作成する

AWSTemplateFormatVersion: "2010-09-09"

Parameters:
  DomainName:
    Type: String

Resources:
  ACMCertificate:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName: !Ref DomainName'
      SubjectAlternativeNames:
        - !Sub '*.${DomainName}'
      DomainValidationOptions:
        - DomainName: !Ref DomainName
          ValidationDomain: !Ref DomainName
      ValidationMethod: DNS
```


```static.yml
AWSTemplateFormatVersion: "2010-09-09"

Parameters:
  ProjectName:
    Type: String
  DomainName:
    Type: String
  AcmCertificateARN:
    Type: String

Resources:
  StaticBucket:
    Type: AWS::S3::Bucket
    Properties:
      AccessControl: Private
      BucketName: !Ref ProjectName
  StaticOai:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: !Ref AWS::StackName
  StaticBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref StaticBucket
      PolicyDocument:
        Statement:
          - Action: s3:GetObject
            Effect: Allow
            Resource: !Sub arn:aws:s3:::${StaticBucket}/*
            Principal:
              AWS: !Sub arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${StaticOai}
  S3WebsiteOriginCachePolicy:
    Type: AWS::CloudFront::CachePolicy
    Properties:
      CachePolicyConfig:
        DefaultTTL: 86400 # default = one day
        MaxTTL: 31536000 # default = one year
        MinTTL: 60
        Name: !Sub ${ProjectName}-s3-website
        ParametersInCacheKeyAndForwardedToOrigin:
          CookiesConfig:
            CookieBehavior: none
          EnableAcceptEncodingBrotli: true
          EnableAcceptEncodingGzip: true
          HeadersConfig:
            HeaderBehavior: none
          QueryStringsConfig:
            QueryStringBehavior: none
  Distribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Aliases:
          - !Ref DomainName
        Enabled: true
        HttpVersion: http2
        PriceClass: PriceClass_200
        ViewerCertificate:
          AcmCertificateArn: !Ref AcmCertificateARN
          MinimumProtocolVersion: TLSv1.2_2019
          SslSupportMethod: sni-only
        Origins:
          - Id: S3WebsiteOrigin
            DomainName: !GetAtt StaticBucket.DomainName
            S3OriginConfig:
              OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${StaticOai}
        CustomErrorResponses:
          - ErrorCode: 403 # SPAをリロードしても大丈夫なように
            ResponseCode: 200
            ResponsePagePath: /index.html
        DefaultRootObject: index.html
        DefaultCacheBehavior:
          TargetOriginId: S3WebsiteOrigin
          CachePolicyId: !Ref S3WebsiteOriginCachePolicy
          ViewerProtocolPolicy: redirect-to-https
        CacheBehaviors:
          - TargetOriginId: S3WebsiteOrigin
            AllowedMethods: [GET, HEAD]
            CachePolicyId: !Ref S3WebsiteOriginCachePolicy
            Compress: true
            PathPattern: '*'
            ViewerProtocolPolicy: redirect-to-https
```

## GitHubActionsでデプロイしてみる

```frontend-deploy.yml
name: Deploy Frontend
on:
  push:
    branches:
      - "master"

env:
  REACT_APP_BACKEND_HOST: ${{ secrets.API_HOST }}

jobs:
  deploy-frontend:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout
      uses: actions/checkout@v2.3.4

    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v1
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ap-northeast-1

    - name: Install dependencies
      run: yarn

    - name: Build
      run: yarn build

    - name: Deploy
      run: aws s3 sync ./build s3://${{ secrets.S3_BUCKET_NAME }} --delete
```


## APIもCloudFront経由で配信する

CloudFrontのBehaviorsにPathPatternを設定して、
例えば`/api`の時はApplication Load Balancerなどに通信を流し、バックエンドAPIの処理を呼び出すことができます。

そうなると、同じドメインでバックエンドもフロントエンドもデプロイすることができるので良さそうです。

上記したCloudFrontのデプロイだとSPAでリロードしてもエラーがでないように、CloudFrontのCustomErrorResponsesでステータスコード403, 404が帰ってきた場合にステータスコード200でindex.htmlを返す設定にしていました。
しかし、CloudFrontからAPI側、S3側両方に通信を流す場合には、これでは不具合が起きることがあるかと思います。

例えばAPIにリクエストした時に404 Not Foundが返ってくるべきところだったのに、設定のせいで200が返ってきてしまい適切なエラーメッセージをエンドユーザーに表示できないというようなことも起こるかと思います。
このような場合のベストな対処法に関しては、理解しきれていないところもあり、今後追記できたらと思います。