Serverless Framework で SPA 環境を構築してみる（with Lambda@Edge & API Gateway）

## はじめに

最近ある案件で [Serverless Framework](https://serverless.com/) (以下、SF)を触る機会があり、いろいろ試行錯誤したので、せっかくなのでブログにしてみました。

今回はAWSにおけるSPAとして割とメジャーな構成 (CloudFront + S3 + API Gateway) をSFでつくりつつ、その過程でちょっとハマったところについても最後に記載しています。

なお、今回はSFに焦点を当てており、Lambda関数や静的コンテンツ自体の実装については言及していません。
また、SFを含む各種ツールやプロダクトの紹介およびインストール方法などは割愛しています。

## 注意点など

**IAM**
今回、 IAM は administration 権限で sls コマンドなど実行していますので、
例えば本番環境で継続的にデプロイする場合など適切なポリシーを設定してください。
参考: https://serverless.com/framework/docs/providers/aws/guide/iam/

**SFバージョン**

```
Framework Core: 1.57.0
Plugin: 3.2.3
SDK: 2.2.1
Components Core: 1.1.2
Components CLI: 1.4.0
```

## 構成

ClodFrontでリクエストを受け取り、パスベースで S3 または API GW にリクエストを振り分けます。

ということで今回作成する構成は以下の通りです。

```
- S3 (SF デプロイ用バケット。us-east-1)
- S3 (SF デプロイ用バケット。ap-northeast-1)
- S3 (コンテンツ配信。CloudFront のオリジンの一つ。ap-northeast-1)
- API Gateway + Lambda (CloudFront のオリジンの一つ)
- CloudFront (Path ベースで API Gateway と S3 に振り分ける)
- Lambda@Edge (レスポンスにヘッダを付与する)
```

<img width="350" alt="spa_blog.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/118641/374f5339-1b69-8ca1-8554-ca36d3ba853e.png">

ここで、3つのS3バケットはあらかじめ作成しています。
デプロイ用のバケットは使い回しが可能なので、実質的に新規作成するのはコンテンツ配信用のバケットのみです。
また、コンテンツ配信用のS3は [Static website hosting](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/dev/HowDoIWebsiteConfiguration.html) としてすでに外部公開の設定が完了しているものとします。

## API Gateway + Lambda と S3 アップロード

### API Gateway + Lambda の作成

まずは、API Gateway と Lambda を作成します。

```serverless.yml
service: sample-api-lambda

provider:
  name: aws
  runtime: nodejs12.x
  stage: ${opt:stage, 'develop'}
  region: ap-northeast-1
  deploymentBucket:
    name: "deploymentBucket-ap-northeast-1"

functions:
  sample:
    name: sample-lambda
    runtime: nodejs12.x
    handler: handler.hello
    role: "arn:aws:iam::xxxxx:role/service-role/xxxxx"
    events:
      - http: GET api

resources:
  Outputs:
    ApiGwDomain:
      Description: "API Gateway Domain"
      Value:
        Fn::Join:
          - "."
          - - Ref: ApiGatewayRestApi
            - "execute-api"
            - ${self:provider.region}
            - "amazonaws.com"
      Export:
        Name: ApiGwDomain
```

ここで、`handler.js` は [サンプル](https://serverless.com/framework/docs/providers/aws/events/apigateway#lambda-proxy-integration) をそのまま利用することにします。
また、 API Gateway のドメインを CloudFront 側で参照したいので Outputs として定義しています。

### S3 アップロード

CloudFront で配信するために S3へのコンテンツアップロードも前述の serverless.yml に組み込んでおきます。
（ここは必須の設定ではないので、手動で実行しても大丈夫です。）

今回は [`serverless-s3-sync`](https://www.npmjs.com/package/serverless-s3-sync) plugin を利用します。以下を serverless.yml に追記します。

```yml
plugins:
  - serverless-s3-sync

custom:
  s3Sync:
    - bucketName: sample-s3-orin
      localDir: assets/
```

local の assets ディレクトリには `index.html` を配置しておきます。
（内容は `S3 bucket origin` としておきます。）

## CloudFront + Lambda@Edge と Invalidation

### CloudFront + Lambda@Edge の作成

次に CloudFront 側の設定です。
前述の API Gateway + Lambda の serverless.yml とは分割して作成します。

```serverless.yml
service: sample-cloudfront

provider:
  name: aws
  runtime: nodejs10.x
  stage: ${opt:stage, 'develop'}
  region: us-east-1
  deploymentBucket:
    name: "deploymentBucket-us-east-1"

functions:
  sampleLambdaEdge:
    handler: sample.handler
    events:
      - cloudFront:
          eventType: viewer-response
          origin:
            DomainName: sample-s3-orin.s3-website-ap-northeast-1.amazonaws.com
            CustomOriginConfig:
              OriginProtocolPolicy: match-viewer
      - cloudFront:
          eventType: viewer-response
          pathPattern: /api
          origin:
            DomainName: ${cf.ap-northeast-1:sample-api-lambda-${self:provider.stage}.ApiGwDomain}
            OriginPath: /${self:provider.stage}
            CustomOriginConfig:
              OriginProtocolPolicy: match-viewer
```

ここでは、

- Region を `us-east-1` にしています。
- API Gateway の DomainName を [`cf.REGION:stackName.outputKey`](https://serverless.com/framework/docs/providers/aws/guide/variables#reference-cloudformation-outputs) で動的に取得しています。
- sample.js は SF の [サンプル](https://serverless.com/framework/docs/providers/aws/events/cloudfront#simple-event-definition) を利用しています。

余談ですが、CloudFormation のシンタックス も利用可能なので、CloudFront を単体で作成する（Lambda@Edge を利用しない）ことも可能です。（が、この場合は SF 使わなくてもいいかもしれませんね。）

### Invalidation の設定

CloudFront のデプロイ後、Invalidation が自動的に実行されるように Invalidation の設定も組み込んでおきます。
（ここは必須の設定ではないので、手動で実行しても大丈夫です。）

今回は　[`serverless-cloudfront-invalidate`](https://www.npmjs.com/package/serverless-cloudfront-invalidate) plugin を利用します。
以下を CloudFront の serverless.yml に追記します。

```yml

plugins:
  - serverless-cloudfront-invalidate

custom:
  cloudfrontInvalidate:
    distributionIdKey: 'CDNDistributionId'
    items:
      - '/index.html'
```

## デプロイ

それでは、上記で作成したものをデプロイしていきます。
ここでディレクトリ構成は以下のようにしています。

```
events/
├── api-gateway
│   ├── assets
│   │   └── index.html
│   ├── handler.js
│   └── serverless.yml
└── cloudfront
    ├── sample.js
    └── serverless.yml
```

まずは、API Gateway+Lambda をデプロイします。

```
$ cd events/api-gateway/
$ sls deploy -v
```

- `ApiGwDomain: xxxxx.execute-api.ap-northeast-1.amazonaws.com`
- `S3 Sync: Synced.`

が出力されていれば成功です。次に、CloudFront をデプロイしていきます。

```
$ cd events/cloudfront/
$ sls deploy -v
```

- `CloudFrontDistributionDomainName: xxx.cloudfront.net`
- `CloudfrontInvalidate: Invalidation started` 

が出力されていれば成功です。

## 動作確認

デプロイが無事完了したら、`CloudFrontDistributionDomainName` で出力された CloudFront のURLにアクセスして確認します。

```bash

# S3 オリジン
$ curl https://xxx.cloudfront.net/
S3 bucket origin

# API GW オリジン
$ curl https://xxx.cloudfront.net/api
{"message":"Hello World!"}
```

よさそうですね。（もちろん、 [Sample](https://serverless.com/framework/docs/providers/aws/events/cloudfront#simple-event-definition)では `x-serverless-time`　ヘッダを Lambda@Edge で付与するようになっているので、これも確認ができました。）

## ハマったところ

いくつかあるのですが、ここでは2つほど挙げておきます。

### リージョンの指定

当初、CloudFront の serverless.yml を記述する際に、 region を `ap-northeast-1` としていました。
これによって、デプロイ時に 2 つのエラーに遭遇しました。

```
- Could not locate deployment bucket. Error: Deployment bucket is not in the same region as the lambda function
- CloudFront associated functions have to be deployed to the us-east-1 region.
```

まー言われてみれば当然なのですが、デプロイ用の S3 バケットは同一リージョンに存在する必要があるのと、
Lambda@Edge は 2019年12月現在 `us-east-1` でしか利用できないことが原因です。

従って、`API Gateway + Lambda` と `Cloudfront + Lambda@Edge` の serverless.yml を分割し、
各リージョンでデプロイ用バケット作成することで対応しました。

### CloudFormation のリージョンをまたがるクロススタックの参照

CloudFormation は[リージョンをまたがったクロススタック参照ができません](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-importvalue.html)。従って、SFも同様に　`ap-northeast-1` で作成したスタックは `us-east-1` では参照できないとばかり思い込んでいました。今回だと API Gateway のドメインなのでそうそう変更になるようなこともないですが、例えば、検証目的で作ったり壊したりをやっていると、都度書き出すのはちょっと不便だな...とも思っていました。

が、ドキュメントを読み返してたら、ありましたありました。（ちゃんと読みましょう、自分。。。）
[別リージョンの outputKey 参照方法](https://serverless.com/framework/docs/providers/aws/guide/variables#reference-cloudformation-outputs)（`cf.REGION:stackName.outputKey` の部分）

これで API Gateway + Lambda 側のスタックが変更になっても、CloudFront 側で追従してくれるようになります。複数リージョンで作成する場合、とても便利ですね。

## さいごに

ということで、Serverless Framework を使って、CloudFront, S3, API Gateway を作成してみました。
そこは間違っているとか、もっとこうした方がいいよなど、ご指摘やご意見お待ちしております。