Nuxt.js(SPA)をCloudFront + S3にCircleCIからデプロイする方法

## はじめに
CloudFront + S3な環境に対してCircleCIからデプロイしたいケースは結構あると予想しているのですが[^1]ググっても出てこなかったので詳細を覚えているうちに誰かのお役にたてるよう、記事に残しておきます。

## 目標
 - コストを低く押さえたい
 - 管理コストを多く払いたくないのでなるべくサーバ立てたくない
 - GitOpsに寄せつつも、本番だけはデプロイタイミングをGitと切り離して任意のタイミングで制御したい

## 前提条件
 - フロントエンド専用リポジトリを利用している
 - [バックエンドに同梱](https://qiita.com/yushi_koga/items/65e94a97af1d0b0dc8b1)されていない（バックエンドからホストしない）
 - SEOは不要


## 実際に作ってデプロイしたアプリ

development想定 -> https://d1memmxu7q6yg2.cloudfront.net/
staging想定 -> http://d31zh8j8zfwafr.cloudfront.net/
production想定 -> https://dnn8bwm84bdn2.cloudfront.net

## サンプルコード
[](https://github.com/kogayushi/nuxt-cloudfront-s3-circleci-sample)

## 設計
### 環境（AWS周り）
- 環境はdevelopment/staging/productionの3種類
- デプロイ先はS3
  - サーバを用意しなくて済む＆低コスト…！！
- CloudFrontから配信
  - サーバを用意しなくて済む＆低コスト…！！
- CIツールはCircleCI
  - 所属会社の環境がそうだから前提に加えたというのが一番の理由ですが、ジョブが設定ファイル化されているのは差分管理や履歴管理ができるため気に入っています。
  - Githubと連携して使えて、トリガーも柔軟に設定できるので非常にGitOpsフレンドリーだと思います。
- IAMはデプロイに必要な最低限の権限のみを与えたユーザを使用する

### Nuxt.js
- Nuxt.jsはSPAモード
  - 静的コンテンツをホストすれば済むため、サーバ立てる必要がありません。
  - SEOいらないなら無駄な複雑さを持ち込まないようにSPAにするほうが良いと判断しました
    - UniversalモードだとCSRとSSRのどちらで動いているか考える必要性が出てきてしまいメンテコストが上がると判断しました。
  - dotenvを利用して、環境毎にトップ画面で出す文字列を差し替えるようにします。

### GitとCircleCI
- develop,stagingブランチへのPRマージでビルドとデプロイを実行
  - GitOpsっぽい！
- （masterブランチへ）特定のprefixがついたタグがついたらテストとビルドを実行
  - GitOpsに寄せつつ、人の手でデプロイタイミングを制御できる…！
  - その後、CircleCI上で承認したらデプロイ
    - 承認フェーズを設けてるのはタグ付からリリースまでのタイムラグを想定したため
      - サービス停止を伴うリリースの場合に、準備だけでも事前に済ませたい、などのシチュエーションを想定
- ロールバック方法
  - ロールバックしたいコミットに新しいタグを付けて対応する

## S3の解説

### bucket
以下の4つのbucketを作成します。

1. nuxt-cloudfront-s3-circleci-sample-accesslog
2. nuxt-cloudfront-s3-circleci-sample-development
3. nuxt-cloudfront-s3-circleci-sample-staging
4. nuxt-cloudfront-s3-circleci-sample-production

#### 各bucketの説明
##### 1. nuxt-cloudfront-s3-circleci-sample-accesslog
すべての環境のS3への直接アクセス時とCloudFrontへのアクセスログを格納します。私自身は、環境毎にbucketを用意したほうが良いと考えていますが「こういうこともできるよ」と示すためだけにこうしてます。

<font color="red">**アクセス制御がやりやすくなる**</font>という理由で、実際に<font color="red">**本番で運用するときは環境毎にbucketを用意**</font>するつもりです。

##### 2. nuxt-cloudfront-s3-circleci-sample-development
development環境用のbucketです。
`nuxt-cloudfront-s3-circleci-sample-accesslog/development-s3`にアクセスログを出力します。

##### 3. nuxt-cloudfront-s3-circleci-sample-staging
staging環境用のbucketです。
`nuxt-cloudfront-s3-circleci-sample-accesslog/staging-s3`にアクセスログを出力します。

##### 4. nuxt-cloudfront-s3-circleci-sample-production
production環境用のbucketです。
`nuxt-cloudfront-s3-circleci-sample-accesslog/production-s3`にアクセスログを出力します。


### bucketの設定
アクセスログのbucketはすべてデフォルトのため、詳細な説明は割愛します。

ちなみに、実際にアクセスログが出力されるとこんな感じになります。

![0. アクセスログ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/306730/186a24ce-f4bc-fb65-bb40-4ce6e9b5ff98.png)



#### バケット名とリージョン設定
<img width="1101" alt="1. S3のバケット名とリージョン設定.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/306730/48b06839-23b5-35fc-b6a0-cc6e95068691.png">

特に説明の必要はないと思います。

#### アクセスログ設定

![2. S3のアクセスログ設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/306730/f28da708-d308-96c4-8466-8e64104237fb.png)



1. 『バケットへのアクセスリクエストを記録します』にチェックを入れアクセスログを保存します。
2. ターゲットバケットにはアクセスログ用のbucketを指定します。
3. ログ出力先をルートから変更したい場合、任意のパスを設定します。
    - 今回は、`{環境名}-s3/`としてディレクトリにしました。次のセクションで説明するcloudfrontのアクセスログは`[環境名]-cloudfront`と言うディレクトリにする想定です。

#### ブロックパブリックアクセスの設定

<img width="1101" alt="3. S3のブロックパブリックアクセスの設定.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/306730/f5abad75-1bd2-9ac9-1590-c87143cf954c.png">


こちらの設定はデフォルトのままとし、すべてのアクセスを遮断します。このままだとcloudfrontからもアクセスできません。
cloudfrontにアクセスさせるために必要なポリシーは次のセクションのcloudfrontで設定します。


## CloudFrontの作成
デフォルトのままの箇所を割愛して設定を変更している部分のみピックアップします。

### Origin Settings
<img width="588" alt="10. CloudFrontのOrigin Settings.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/306730/a9a0abab-05c9-beef-c6af-9afbcbf5d4aa.png">

1. 『Origin Domain name』に該当するS3のURLを指定します。
2. 『Ristrict Bucket Access』を`Yes`に指定します。
    - コンテンツへのアクセスを必ずCloudFrontを経由させるように強制します。
3. 『Origin Access Identity』を`Create a New Identity`に指定します。
    - (2)のために必要です。CloudFrontのユーザをS3のアクセスのために割り当てる必要があります。これはIAMユーザとは別のようです。
    - 今回は自動作成させ、かつ環境の数だけ用意します。
        - 1つのOrigin Access Identityを使い回すことも可能です。[^3]
3. 『Grant Read Permissions on』を`Yes, Update Bucket Policy`に指定します。
    - (3)のユーザからのアクセスを許可するためのポリシーを対象S3に自動で設定してくれます。

### Distribution Setting
<img width="618" alt="11. CloudFrontのDistribution Setting.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/306730/cec95c9d-5449-49af-7880-f92c007f57f6.png">


1. 『Default Root Object』に`index.html`を指定します。
2. 『Bucket for Logs』にアクセスログ保存用のbucketを指定します。
    - 私自身は明確に助かったなあというエピソードはそれほどありませんが、何かの調査や分析用途に残しておいたほうが無難だと考え設定しています。
3. 『Log Prefix』には、ログ出力先をルートから変更したい場合、任意のパスを設定します。
    - 今回は、`{環境名}-cloudfront`としました。

以上の設定でひとまず作成までは完了です。`Create Distribution`ボタンを押し、Distiributionを作成してください。

最後の仕上げとして、ルート以外にアクセスされた場合に`/index.html`に転送する設定を行います。

### Error Pages
#### 対象のDistributionをクリックして詳細ページに遷移します。
![20. Distribution_モザイク後.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/306730/655dcdb5-27a9-873f-a87c-03646fffd4ec.png)



#### Error Pageタブをクリックし、Create Custom Error Responseボタンをクリックします。

![21. Error Page.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/306730/80bc6ffb-91d2-bd99-bbdb-ede8a3d71685.png)

#### ルート以外にアクセスした場合、`/index.html`に転送しつつ、200 OKを応答するように設定します。

ルート以外にアクセスした場合、S3に存在しないオブジェクトのパスだとCloudFrontのデフォルトでは403を返却します。
それを`/index.html`へのアクセスに転送しつつ、応答コードも200 OKに変更する設定です。

![22. Error Response.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/306730/85341a71-d4f0-4113-1c9b-6c55c8a160fb.png)


1. カスタマイズするエラーレスポンスを指定します。
2. カスタマイズするので『yes』を指定します。
3. カスタマイズした応答の静的ファイルを指定する箇所ですが、やりたいことはルートへの転送なので、`index.html`を指定します。
4. `index.html`へ転送後の応答は200 OKが正常のため、そう指定します。

#### 設定完了
これでCloudFrontの設定完了です。
Distributionへの反映には少々時間がかかるのでのんびり待ちましょう。

### 補足
なお、『Default Cache Behavior Settings』のセクションに『Allowed HTTP Methods』という項目があり、OPTIONや更新系のその他のメソッドを許可するかどうか指定できますが、SPAで動かしているのであればGETとHEADのみ許可していればいいはずです。

もし、その他のメソッドも許可する必要があるケースをご存じの方は、ぜひコメントで教えて下さい。

## IAMの設定

デプロイで必要になる最低限の権限を与えたポリシーを作成し、デプロイ専用のIAMユーザにそれを割り付ける設計にしました。

作成したポリシーは以下になります。

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:ListBucket"
                "s3:DeleteObject"
            ],
            "Resource": "arn:aws:s3:::{ your bucket name, wild card is okay }"
        },
        {
            "Effect": "Allow",
            "Action": [
                "cloudfront:GetInvalidation",
                "cloudfront:CreateInvalidation"
            ],
            "Resource": [
                "arn:aws:cloudfront::{ your account id }:distribution/{ your development distribution id }",
                "arn:aws:cloudfront::{ your account id }:distribution/{ your staging distribution id }",
                "arn:aws:cloudfront::{ your account id }:distribution/{ your production distribution id }"
            ]
        }
    ]
}
```

### S3
S3にファイルをアップロードするため、当然`s3:PutObject`が必要です。
デプロイ時に古いファイルをS3から削除するのであれば、`s3:ListBucket`と`s3:DeleteObject`が必要になります。

また、操作を許可するbucketの指定を行ったほうが良いでしょう。
配列で指定するかワイルドカードを使用して指定しましょう。

### CloudFront
キャッシュの無効化の操作をするため、`cloudfront:GetInvalidation`と`cloudfront:CreateInvalidation`が必要です。

また、操作を許可するDistributionの指定を行ったほうが良いでしょう。
こちらも配列かワイルドカードでの指定が可能です。

## Nuxt.jsの解説

### アプリ
nuxt.js自体は余り解説することがありません。
`npx create-nux-app`したあとにアプリとして手を加えたのはSPAモードにしていることと、以下のようにトップ画面にデモ用にdotenvから取得した値を表示しているくらいです。

![トップ画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/306730/e23185b5-96dd-fcef-8af0-04238e940178.png)


### dotenv
`dotenv`ディレクトリを掘って、環境毎に分岐する値をdotenvに保存しています。
今回のサンプルだとこのような感じです。

```
ENVIRONMENT={environment}
AWS_BUCKET_NAME={your bucket name}
AWS_CLOUDFRONT={your cloud front distribution id}
```

<font color="red">**資格情報**</font>（AWSのアクセスキーなど）は<font color="red">**このファイルには含めません。**</font>[^2]

### デプロイ周り
基本的にはNuxt公式の[Deploy Nuxt on Amazon Web Services - AWS w/ S3 + CloudFront](https://nuxtjs.org/docs/2.x/deployment/deployment-amazon-web-services#aws-w-s3--cloudfront)の通りです。

gulpを利用してデプロイします。

#### grupfile.js

一点だけ変更点というか、注意点があります。
S3ですべてのパブリックアクセスをブロックしている場合、`'x-amz-acl': 'private'`をヘッダーに加える必要があるのでその点を注意してください。

以下に、設定箇所だけ抜粋します。

```
headers: {
  'x-amz-acl': 'private' // S3はすべてのパブリックアクセスをブロックしているので、privateにする必要がある
},
```

#### deploy.sh
変更点は2点です。yarnを使用していることと、yarnでローカルパッケージのgulpを呼び出していることです。

今回CIツールはCircleCIで利用しますが、gulpはインストールされていないのでローカルパッケージを利用する必要があります。

置き換え後のものを一応以下に記載します。

```
$ yarn generate
$ yarn run gulp deploy
```
## Git
特に想定しているブランチ戦略はありません。

環境に対応したブランチが必要なためdevelop、staging、masterブランチを使います。

名前を見ただけで想像はついているかと思いますが、対応は以下のとおりです。

- develop -> dev環境
- staging -> stg環境
- master -> prod環境

## CircleCI

こちらは設定ファイルで説明します。
作り込んでいるところのみ、コメントで解説します。

```yml
version: 2
jobs:
  test:
    docker:
      - image: circleci/node:12.16.0
    steps:
      - checkout
      - restore_cache:
          keys:
            - yarn-{{ checksum "package.json" }}
      - run: yarn install
      - save_cache:
          paths:
            - node_modules
          key: yarn-{{ checksum "package.json" }}
  # 実際にlistやtestを実行する場合はここに定義する
  #      - run: yarn lint
  #      - run: yarn test
  deploy-development:
    docker:
      - image: circleci/node:12.16.0
    steps:
      - checkout
      - restore_cache:
          keys:
            - yarn-{{ checksum "package.json" }}
      - run: mv ./dotenv/.env.development ./.env # dev環境用のdotenvをリネームして使う
      - run: ./deploy.sh
  deploy-staging:
    docker:
      - image: circleci/node:12.16.0
    steps:
      - checkout
      - restore_cache:
          keys:
            - yarn-{{ checksum "package.json" }}
      - run: mv ./dotenv/.env.staging ./.env # stg環境用のdotenvをリネームして使う
      - run: ./deploy.sh
  deploy-production:
    docker:
      - image: circleci/node:12.16.0
    steps:
      - checkout
      - restore_cache:
          keys:
            - yarn-{{ checksum "package.json" }}
      - run: mv ./dotenv/.env.production ./.env # prod環境用のdotenvをリネームして使う
      - run: ./deploy.sh

workflows:
  version: 2
  test:
    jobs:
      - test:
          filters:
            branches:
              ignore: # test jobは以下のブランチでは実行しない。マージされたときにはすでにtestは通っているはず。
                - develop
                - staging
                - master
  deploy:
    jobs:
      - deploy-development:
          filters:
            branches:
              only:
                - develop # developブランチへのマージで発火することを想定している
      - deploy-staging:
          filters:
            branches:
              only:
                - staging # stagingブランチへのマージで発火することを想定している
      - hold:
          type: approval
          filters:
            tags:
              only: /^release-.*/ # リリースタグをトリガーに指定するが、CircleCI上から承認しなければリリースされない
            branches:
              ignore: /.*/ # リリースタグで発火させるためにはすべてのブランチを対象にする必要がある
      - deploy-production:
          requires:
            - hold # CircleCI上での承認操作でこのjobを発火する
          filters:
            tags:
              only: /^release-.*/ # リリースタグをトリガーに指定するが、CircleCI上から承認しなければリリースされない
            branches:
              ignore: /.*/ # リリースタグで発火させるためにはすべてのブランチを対象にする必要がある
```

## 参考にしたサイトなど

- [S3 と CloudFront を使用して AWS へデプロイするには？](https://ja.nuxtjs.org/faq/deployment-aws-s3-cloudfront/)
- [ジョブの実行を Workflow で制御する](https://circleci.com/docs/ja/2.0/workflows/)

## 課題というか宿題というか
### qa環境へのデプロイをどうするか？
releaseブランチを用意してdevelopment,staging環境と同じ流れでデプロイすると良さそうな気がしています。
実際にやる機会があればこの記事に追記するかもしれません。

### もしsandbox環境を用意する場合のフローをどうするか？
本番環境と同じで良いと思っています。
もし、sandboxを本番リリース前の事前評価環境と兼用するとすれば、本番リリース前にsandbox環境へリリースするかもしれません。

### blue/green deploymentへの対応方法は？
blue/greenそれぞれのdistribusionを用意し、blue用リリースタグとgreen用リリースタグの命名規則でそれぞれに対応するように作り込むと思います。
さらに、承認jobをはさみつつRoute 53の向き先の変更もCircleCIから切り替えるようにすると尚良いと思います。

しかし、そこまで作り込むのが難しそうであれば、リリースまではCircleCIで対応し、向き先の変更のみAWSのマネコンやshell実行でも十分に運用できそうな気はします。

ALBからCloudFrontへforwardする方法があるなら、ALBを使うほうがより簡単かもしれません。

### 環境毎にAWS複数アカウントを用意している場合のAWS資格情報の取得をどうするか？
環境が分かれるため、CircleCIの環境変数では管理しづらくなります。

そのため、追加で資格情報を保存しておくための別のRepositoryを用意し、そこから資格情報をdotenvにコピーしてくるjobを定義する必要があると想定しています。

これは、そのうち私自身が検証する可能性が高いです。この記事に追記するかもしれません。


## 最後に

以上は、実際に本番運用するつもりで検証済みとはいえまだ本番には未適用のもののため、改善ポイントや運用しにくい部分があるかもしれません。
実際に本番に適用してみて気づいたことをそのうち記事に反映できたら良いなと思っています。


何かお気づきのことがあればフィードバックを頂けると大変うれしいです。

以上です！


[^1]: 実際に私がそうでした。
[^2]: AWSにアクセスしてはいけない開発者にもアクセスキーを渡してしまうことになるからです。CircleCIの環境変数に登録する、特定の開発者のみアクセスできるような別のGit Repositoryを用意するなど、アプリ開発のみを担当する開発者には資格情報が渡らないように工夫します。
[^3]: どういうケースで使い回すべきなのか把握できていません。実は適切な管理方針がいまいちよくわからないのでアドバイス欲しいです…。