nuxt generate + S3 + CloudFront + Lambda Edge で静的サイト構築＆ハマりポイントと解決法

Repsona LLCの[@GussieTech](https://twitter.com/GussieTech)です。[「プロジェクト管理と情報共有のためのツール（プラグイン不要でガントチャート無料）」Repsona](https://repsona.com/ja)を作っています。

前回の記事（[Nuxt + Sails + TypeScript + Fargateでタスク管理ツールを作ったら快適だった話](https://qiita.com/GussieTech/items/23c23608daf62017230b)）でRepsona本体側の全体像をざっくりと書きました。今回はウェブサイトについてです。

## Nuxt は静的ウェブサイト制作にも断然おすすめ

[https://repsona.com](https://repsona.com)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17680/c7290608-454f-ad85-6081-9c6d5ebea200.png)
Repsona では本体のアプリケーションに Nuxt を採用していますが、ウェブサイトにも Nuxt を使っています。**小規模なサイトをサクッと作りたい人にとってもおすすめ**です。

- [公式ドキュメント](https://ja.nuxtjs.org/guide/installation)のままやれば簡単に始められる
- [SPC](https://ja.nuxtjs.org/guide/views#%E3%83%9A%E3%83%BC%E3%82%B8) のおかげで HTML を書いている感覚でコンポーネント化できる
- おかげで パーツの使い回しがきく
- おかげで CSS を多少ムチャしても破綻しにくい
- 「JavaScriptでやりたーい」的な見せ方はライブラリでだいたいできる
- jQuery も組み込んじゃえば使える
- webpack とかJavaScript界の色々を考えなくていい（組み込まれてる）
- ビルドは `nuxt generate` だけでいい

そして快適に作ったサイトを、**S3 + CloudFront でデプロイしたい！**と思う方もいるでしょう。今回はその手順と、ハマったところと解決法を共有したいと思います。

## 前提

- ドメイン取得済み、Route 53 設定済み、ACM設定済み
- Nuxt環境構築済み、サイト作成済み、`nuxt generate`できる

## アーキテクチャ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17680/337de277-5a4c-ef06-22bf-d6c6012e7474.png)

- `nuxt generate`で静的ファイル生成
- gulpでS3にデプロイ ＆ CloudFront invalidate
- Lambda Edge でオリジンパスをハンドリング

## 構築手順

[公式ドキュメント](https://ja.nuxtjs.org/faq/deployment-aws-s3-cloudfront/)通りやればよし！なんですが、公式が**「シークレットキーを記載したdeploy.shを作って.gitignoreする」**というなんだか微妙なかんじなので、ちょっとアレンジした手元の手順を紹介します。

- S3 バケットを作成する
- CloudFront distribution を作成する
- Route 53 を設定する
- Lambda Edge でオリジンパスのハンドリングを設定する
- セキュリティアクセスを設定する
- ビルドスクリプトを作成する
- CloudFront invalidate のスクリプトを修正する
- デプロイして確認する

### S3 バケットを作成する

バケットを作ります。全部デフォルト設定のままでよかったはず。CloudFront経由のアクセスのみ許可するので、`静的ウェブホスティング`も`無効`でOKです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17680/99d565ad-da6f-0177-913f-5d398b013ae5.png)

### CloudFront distribution を作成する

- Create Distribution > Web - Get Started
- Origin Domain Name > さっき作ったバケットを選択 （プルダウンにでてくる）
- Origin Path > （空白）
- Origin ID > （勝手に入る値）
- Restrict Bucket Access > Yes
- Origin Access Identity > 初めてなら Create a New Identity
- Grant Read Permissions on Bucket > Yes, Update Bucket Policy
- Alternate Domain Names > ドメイン名

```:Repsonaの場合
repsona.com
www.repsona.com
```
- SSL Certificate > Custom SSL Certificate (example.com): （プルダウンにでてくる）
    - まだ作ってなければ [Learn more](http://docs.aws.amazon.com/console/acm) about using ACM.
- 他項目は各自の都合に合わせてください（デフォルトでもよかったはず）。

### Route 53 を設定する

管理下にあるドメインに「レコードセットの作成」から下記の設定を入れて、CloudFront distribution にまわしてやります。

```:Repsonaの場合
repsona.com.
タイプ: A
エイリアス先: さっき作った CloudFront distribution （プルダウンにでてくる）
```
```:Repsonaの場合
www.repsona.com.
タイプ: A
エイリアス先: さっき作った CloudFront distribution （プルダウンにでてくる）
```

### アクセスできるか確認する

ここまでで、青で囲んだ部分が通っているはずです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17680/0fc44ef0-8cdc-f203-c679-ae2cbddcfa32.png)

S3バケット直下に`index.html`を置いて、`https://ドメイン名/index.html`でアクセスできるか確認しておきましょう。

### Lambda Edge でオリジンパスのハンドリングを設定する

CloudFront には S3 でいうところのインデックスドキュメントにあたるものがありません。`Default Root Object`を設定すれば、以下の`index.html`ナシはいけますが

```
◯: https://ドメイン名/index.html
◯: https://ドメイン名/
◯: https://ドメイン名
```

以下の`index.html`ナシはいけません。

```
◯: https://ドメイン名/foo/index.html
×: https://ドメイン名/foo/
×: https://ドメイン名/foo
```

そこで、Lambda Edge を、`イベントタイプ: origin-request` に仕掛けます。

```js:index-handler/index.js
// arranged: https://github.com/CloudUnder/lambda-edge-nice-urls

const config = {
  suffix: '/index.html',
  appendToDirs: 'index.html',
}

const regexSuffixless = /\/[^/.]+$/ // e.g. "/some/page" but not "/", "/some/" or "/some.jpg"
const regexTrailingSlash = /.+\/$/ // e.g. "/some/" or "/some/page/" but not root "/"

exports.handler = function handler (event, context, callback) {
  const {request} = event.Records[0].cf
  const {uri} = request
  const {suffix, appendToDirs} = config

  if (suffix && uri.match(regexSuffixless)) {
    request.uri = uri + suffix
    callback(null, request)
    return
  }

  if (appendToDirs && uri.match(regexTrailingSlash)) {
    request.uri = uri + appendToDirs
    callback(null, request)
    return
  }

  callback(null, request)
}

```

Lambda Function のARN（`arn:aws:lambda:us-east-1:000000000000:function:index-handller:1`みないなの）をコピっておいて、CloudFront > Behaviors の下の方にある、 Lambda Function Associations の Lambda Function ARN にセットします。`$LATEST`は使えません。

### アクセスできるか確認する

ここまでで、青枠全部いけました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/17680/0a45602b-c261-3f2a-7f93-ab60f419c4eb.png)
S3バケットに`foo/index.html`を置いて、スラありスラなしなど含めて、アクセス確認をしてみます。

### セキュリティアクセスを設定する

デプロイ用のユーザーに「バケットへのファイル配置」と「CloudFrontのキャッシュ削除」の権限を与えます。以下のポリシーを作成し、デプロイを実行するユーザーにアタッチしてください。ユーザーがない場合はここで作成し、アクセスキーとシークレットキーを取得してください。
*※ 公式ママだとうまくいかず、すこし変更しています。*

```json
{
    "Version": "2012-10-17",
    "Statement": [ {
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObjectAcl",
                "s3:GetObject",
                "s3:AbortMultipartUpload",
                "s3:ListBucket",
                "s3:DeleteObject",
                "s3:PutObjectAcl",
                "s3:ListMultipartUploadParts"
            ],
            "Resource": [
                "arn:aws:s3:::さっき作ったS3バケット名/*",
                "arn:aws:s3:::さっき作ったS3バケット名"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "cloudfront:ListInvalidations",
                "cloudfront:GetInvalidation",
                "cloudfront:CreateInvalidation"
            ],
            "Resource": "*"
        }
    ]
}
```

### ビルドスクリプトを作成する

#### Gulp をインストールする

```:npmの場合
npm install --save-dev gulp gulp-awspublish gulp-cloudfront-invalidate-aws-publish concurrent-transform
npm install -g gulp
```

```:yarnの場合
yarn add -D gulp gulp-awspublish gulp-cloudfront-invalidate-aws-publish concurrent-transform
yarn global add gulp
```

#### `gulpfile.js` を作成する

*※ 公式ママだとうまくいかず、すこし変更しています。*

```js
const gulp = require('gulp')
const awspublish = require('gulp-awspublish')
const cloudfront = require('./modules/gulp-cloudfront-invalidate-aws-publish')
const parallelize = require('concurrent-transform')

// https://docs.aws.amazon.com/cli/latest/userguide/cli-environment.html

const config = {

  // 必須
  params: {Bucket: process.env.AWS_BUCKET_NAME},
  accessKeyId: process.env.AWS_ACCESS_KEY_ID,
  secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,

  // 任意
  deleteOldVersions: false,                 // PRODUCTION で使用しない
  distribution: process.env.AWS_CLOUDFRONT, // CloudFront distribution ID
  region: process.env.AWS_DEFAULT_REGION,
  headers: {'x-amz-acl': 'private' /*'Cache-Control': 'max-age=315360000, no-transform, public',*/},

  // 適切なデフォルト値 - これらのファイル及びディレクトリは gitignore されている
  distDir: 'dist',
  indexRootPath: true,
  cacheFileName: '.awspublish.' + environment,
  concurrentUploads: 10,
  wait: true,  // CloudFront のキャッシュ削除が完了するまでの時間（約30〜60秒）
}

gulp.task('deploy', function () {
  // S3 オプションを使用して新しい publisher を作成する
  // http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#constructor-property
  const publisher = awspublish.create(config, config)
  // console.log(publisher)

  let g = gulp.src('./' + config.distDir + '/**')
  // publisher は、上記で指定した Content-Length、Content-Type、および他のヘッダーを追加する
  // 指定しない場合、はデフォルトで x-amz-acl が public-read に設定される
  g = g.pipe(parallelize(publisher.publish(config.headers), config.concurrentUploads))

  // CDN のキャッシュを削除する
  if (config.distribution) {
    console.log('Configured with CloudFront distribution')
    g = g.pipe(cloudfront(config))
  } else {
    console.log('No CloudFront distribution configured - skipping CDN invalidation')
  }

  // 削除したファイルを同期する
  if (config.deleteOldVersions) {
    g = g.pipe(publisher.sync())
  }
  // 連続したアップロードを高速化するためにキャッシュファイルを作成する
  g = g.pipe(publisher.cache())
  // アップロードの更新をコンソールに出力する
  g = g.pipe(awspublish.reporter())
  return g
})
```

#### `package.json` に追記する

`yarn run deploy` でproductionデプロイできるようにscriptsを追加する。

```json
{
  "scripts": {
    "deploy": "rm -r ./dist; cross-env NODE_ENV=production nuxt generate; cross-env NODE_ENV=production gulp deploy",
  },
```

#### 環境変数を設定する

```:.env等
AWS_BUCKET_NAME　= バケット名
AWS_CLOUDFRONT　= 14文字の大文字のID
AWS_ACCESS_KEY_ID　= アクセスキー
AWS_SECRET_ACCESS_KEY　= シークレットキー
AWS_DEFAULT_REGION = リージョン（us-east-1みたいなの）
```

### CloudFront invalidate のスクリプトを修正する

Lambda Edge でオリジンパスのハンドリングをするおかげで、スラありスラなしでアクセス可能ですが、全て**別々のリソースとしてCloudFrontにキャッシュされてしまいます**。デプロイ時に同時にinvalidateしたいところですが、ここで使っているライブラリ`gulp-cloudfront-invalidate-aws-publish`の`indexRootPaths: true`は、スラなしinvalidateをリクエストしてくれないので、本家に手を加えて利用しています（[プルリク](https://github.com/lpender/gulp-cloudfront-invalidate-aws-publish/pull/24)）。

```js:modules/gulp-cloudfront-invalidate-aws-publish/index.js
// https://github.com/lpender/gulp-cloudfront-invalidate-aws-publish/blob/master/index.js

var PluginError = require('plugin-error')
  , log = require('fancy-log')
  , through = require('through2')
  , aws = require('aws-sdk')

module.exports = function (options) {
  options.wait = !!options.wait
  options.indexRootPath = !!options.indexRootPath

  var cloudfront = new aws.CloudFront()

  if ('credentials' in options) {
    cloudfront.config.update({
      credentials: options.credentials
    })
  } else {
    cloudfront.config.update({
      accessKeyId: options.accessKeyId || process.env.AWS_ACCESS_KEY_ID,
      secretAccessKey: options.secretAccessKey || process.env.AWS_SECRET_ACCESS_KEY,
      sessionToken: options.sessionToken || process.env.AWS_SESSION_TOKEN
    })
  }

  var files = []

  var complain = function (err, msg, callback) {
    callback(false)
    throw new PluginError('gulp-cloudfront-invalidate', msg + ': ' + err)
  }

  var check = function (id, callback) {
    cloudfront.getInvalidation({
      DistributionId: options.distribution,
      Id: id
    }, function (err, res) {
      if (err) {
        return complain(err, 'Could not check on invalidation', callback)
      }

      if (res.Invalidation.Status === 'Completed') {
        return callback()
      } else {
        setTimeout(function () {
          check(id, callback)
        }, 1000)
      }
    })
  }

  var processFile = function (file, encoding, callback) {
    // https://github.com/pgherveou/gulp-awspublish/blob/master/lib/log-reporter.js
    // var state

    if (!file.s3) {
      return callback(null, file)
    }
    if (!file.s3.state) {
      return callback(null, file)
    }
    if (options.states &&
      options.states.indexOf(file.s3.state) === -1) {
      return callback(null, file)
    }

    switch (file.s3.state) {
      case 'update':
      case 'create':
      case 'delete': {
        let path = file.s3.path

        if (options.originPath) {
          const originRegex = new RegExp(options.originPath.replace(/^\//, '') + '/?')
          path = path.replace(originRegex, '')
        }

        files.push(path)
        if (options.indexRootPath && /index\.html$/.test(path)) {
          files.push(path.replace(/index\.html$/, ''))
          files.push(path.replace(/\/index\.html$/, '')) // スラなしも invalidate してほしい
        }
        break
      }
      case 'cache':
      case 'skip':
        break
      default:
        log('Unknown state: ' + file.s3.state)
        break
    }

    return callback(null, file)
  }

  var invalidate = function (callback) {
    if (files.length == 0) {
      return callback()
    }

    files = files.map(function (file) {
      return '/' + file
    })

    cloudfront.createInvalidation({
      DistributionId: options.distribution,
      InvalidationBatch: {
        CallerReference: Date.now().toString(),
        Paths: {
          Quantity: files.length,
          Items: files
        }
      }
    }, function (err, res) {
      if (err) {
        return complain(err, 'Could not invalidate cloudfront', callback)
      }

      log('Cloudfront invalidation created: ' + res.Invalidation.Id)

      if (!options.wait) {
        return callback()
      }

      check(res.Invalidation.Id, callback)
    })
  }

  return through.obj(processFile, invalidate)
}
```

### デプロイして確認する

それではいってみます！

```cli
yarn run deploy
```

おめでとう！デプロイがうまくいけば、指定したドメインで、成果物にアクセスできるようになっているはずです。うまく動作したかどうかは、公式によると、下記らしいです。

> NOTE: CloudFront invalidation created：XXXX は CloudFront invalidation を行う npm パッケージからの唯一の出力です。それが表示されない場合は、動作していません。

## ハマりポイントと解決法

### ポリシーがうまく適用されない

公式通りのポリシーをあてているはずなのにうまく行かず、VisualEditorでいろいろといじりながらなんとかうまくいく設定にたどり着きました。`"cloudfront:UnknownOperation"`がダメだったのかな。現状動いていますが、`s3:ListBucket`はほんとはリソースわけなきゃいかんかも。

### デプロイ実行したらでたエラー

```
[12:32:01] Using gulpfile ~/xxxxxx/repsona-website/gulpfile.js
[12:32:01] Starting 'deploy'...
Configured with CloudFront distribution
[12:32:02] 'deploy' errored after 1.51 s
[12:32:02] AccessDenied: Access Denied
    at Request.extractError (/Users/xxxxxx/xxxxxx/repsona-website/node_modules/aws-sdk/lib/services/s3.js:585:35)
    at Request.callListeners (/Users/xxxxxx/xxxxxx/repsona-website/node_modules/aws-sdk/lib/sequential_executor.js:106:20)
    at Request.emit (/Users/xxxxxx/xxxxxx/repsona-website/node_modules/aws-sdk/lib/sequential_executor.js:78:10)
    at Request.emit (/Users/xxxxxx/xxxxxx/repsona-website/node_modules/aws-sdk/lib/request.js:683:14)
    at Request.transition (/Users/xxxxxx/xxxxxx/repsona-website/node_modules/aws-sdk/lib/request.js:22:10)
    at AcceptorStateMachine.runTo (/Users/xxxxxx/xxxxxx/repsona-website/node_modules/aws-sdk/lib/state_machine.js:14:12)
    at /Users/xxxxxx/xxxxxx/repsona-website/node_modules/aws-sdk/lib/state_machine.js:26:10
    at Request.<anonymous> (/Users/xxxxxx/xxxxxx/repsona-website/node_modules/aws-sdk/lib/request.js:38:9)
    at Request.<anonymous> (/Users/xxxxxx/xxxxxx/repsona-website/node_modules/aws-sdk/lib/request.js:685:12)
    at Request.callListeners (/Users/xxxxxx/xxxxxx/repsona-website/node_modules/aws-sdk/lib/sequential_executor.js:116:18)
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
```

Access が Denied であること以外なんもわからん！ポリシーも無事適用できて`put object`もできていただけにかなりハマりました。どうやらS3のアクセス権限設定で、いつからか、ブロックパブリックアクセスが**すべてブロック**がデフォルトになったようで、そうすると、publicなファイルは配置できません。それで、`gulpfile.js`に`headers: {'x-amz-acl': 'private'}`の記述を追加して、privateとしてputするようにしました。

### https://ホスト名/index.html じゃないと AccessDenied

上述の通り、Lambda Edgeで回避しました。

## まとめ

- どこでコケてるかわかりにくいので、確認できるポイント毎に確認すべし
- 構築は結構手間だけど、一度通ってしまえばすごく楽
- `nuxt generate`で静的ウェブサイトにもコンポーネントの概念を・・すごくイイ
- 静的サイト生成なので当たり前だけど、ものすごくはやい

という感じで快適に開発しています。ぜひお試しください。

そして、Repsonaもぜひお試しください。フリープランでも全機能、ずっと無料で使えます。
[無料ガントチャートのプロジェクト管理ツール - Repsona](https://repsona.com/ja)