Help us understand the problem. What is going on with this article?

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

Repsona LLCの代表兼エンジニア(ひとり)の、ガッシーです。ひとりで、「理想のタスク管理ツール」Repsona(レプソナ)を作っています。

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

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

https://repsona.com
image.png
Repsona では本体のアプリケーションに Nuxt を採用していますが、ウェブサイトにも Nuxt を使っています。小規模なサイトをサクッと作りたい人にとってもおすすめです。

  • 公式ドキュメントのままやれば簡単に始められる
  • SPC のおかげで HTML を書いている感覚でコンポーネント化できる
  • おかげで パーツの使い回しがきく
  • おかげで CSS を多少ムチャしても破綻しにくい
  • 「JavaScriptでやりたーい」的な見せ方はライブラリでだいたいできる
  • jQuery も組み込んじゃえば使える
  • webpack とかJavaScript界の色々を考えなくていい(組み込まれてる)
  • ビルドは nuxt generate だけでいい

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

前提

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

アーキテクチャ

image.png

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

構築手順

公式ドキュメント通りやればよし!なんですが、公式が「シークレットキーを記載したdeploy.shを作って.gitignoreする」というなんだか微妙なかんじなので、ちょっとアレンジした手元の手順を紹介します。

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

S3 バケットを作成する

バケットを作ります。全部デフォルト設定のままでよかったはず。CloudFront経由のアクセスのみ許可するので、静的ウェブホスティング無効でOKです。
image.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 about using ACM.
  • 他項目は各自の都合に合わせてください(デフォルトでもよかったはず)。

Route 53 を設定する

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

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

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

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

image.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 に仕掛けます。

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
S3バケットにfoo/index.htmlを置いて、スラありスラなしなど含めて、アクセス確認をしてみます。

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

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

{
    "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 を作成する

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

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を追加する。

{
  "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-publishindexRootPaths: trueは、スラなしinvalidateをリクエストしてくれないので、本家に手を加えて利用しています(プルリク)。

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)
}

デプロイして確認する

それではいってみます!

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.jsheaders: {'x-amz-acl': 'private'}の記述を追加して、privateとしてputするようにしました。

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

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

まとめ

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

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

そして、Repsonaもぜひお試しください。フリープランでも全機能、ずっと無料で使えます。
チームのための理想のタスク管理ツール | Repsona

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away