Repsona LLCの@GussieTechです。「プロジェクト管理と情報共有のためのツール(プラグイン不要でガントチャート無料)」Repsonaを作っています。
前回の記事(Nuxt + Sails + TypeScript + Fargateでタスク管理ツールを作ったら快適だった話)でRepsona本体側の全体像をざっくりと書きました。今回はウェブサイトについてです。
Nuxt は静的ウェブサイト制作にも断然おすすめ
https://repsona.com
Repsona では本体のアプリケーションに Nuxt を採用していますが、ウェブサイトにも Nuxt を使っています。小規模なサイトをサクッと作りたい人にとってもおすすめです。
- 公式ドキュメントのままやれば簡単に始められる
- SPC のおかげで HTML を書いている感覚でコンポーネント化できる
- おかげで パーツの使い回しがきく
- おかげで CSS を多少ムチャしても破綻しにくい
- 「JavaScriptでやりたーい」的な見せ方はライブラリでだいたいできる
- jQuery も組み込んじゃえば使える
- webpack とかJavaScript界の色々を考えなくていい(組み込まれてる)
- ビルドは
nuxt generate
だけでいい
そして快適に作ったサイトを、**S3 + CloudFront でデプロイしたい!**と思う方もいるでしょう。今回はその手順と、ハマったところと解決法を共有したいと思います。
前提
- ドメイン取得済み、Route 53 設定済み、ACM設定済み
- Nuxt環境構築済み、サイト作成済み、
nuxt generate
できる
アーキテクチャ
-
nuxt generate
で静的ファイル生成 - gulpでS3にデプロイ & CloudFront invalidate
- Lambda Edge でオリジンパスをハンドリング
構築手順
公式ドキュメント通りやればよし!なんですが、公式が**「シークレットキーを記載したdeploy.shを作って.gitignoreする」**というなんだか微妙なかんじなので、ちょっとアレンジした手元の手順を紹介します。
- S3 バケットを作成する
- CloudFront distribution を作成する
- Route 53 を設定する
- Lambda Edge でオリジンパスのハンドリングを設定する
- セキュリティアクセスを設定する
- ビルドスクリプトを作成する
- CloudFront invalidate のスクリプトを修正する
- デプロイして確認する
S3 バケットを作成する
バケットを作ります。全部デフォルト設定のままでよかったはず。CloudFront経由のアクセスのみ許可するので、静的ウェブホスティング
も無効
でOKです。
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.com
www.repsona.com
- SSL Certificate > Custom SSL Certificate (example.com): (プルダウンにでてくる)
- まだ作ってなければ Learn more about using ACM.
- 他項目は各自の都合に合わせてください(デフォルトでもよかったはず)。
Route 53 を設定する
管理下にあるドメインに「レコードセットの作成」から下記の設定を入れて、CloudFront distribution にまわしてやります。
repsona.com.
タイプ: A
エイリアス先: さっき作った CloudFront distribution (プルダウンにでてくる)
www.repsona.com.
タイプ: A
エイリアス先: さっき作った CloudFront distribution (プルダウンにでてくる)
アクセスできるか確認する
ここまでで、青で囲んだ部分が通っているはずです。
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
に仕掛けます。
// 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
は使えません。
アクセスできるか確認する
ここまでで、青枠全部いけました。
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 install --save-dev gulp gulp-awspublish gulp-cloudfront-invalidate-aws-publish concurrent-transform
npm install -g gulp
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",
},
環境変数を設定する
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/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.js
にheaders: {'x-amz-acl': 'private'}
の記述を追加して、privateとしてputするようにしました。
https://ホスト名/index.html じゃないと AccessDenied
上述の通り、Lambda Edgeで回避しました。
まとめ
- どこでコケてるかわかりにくいので、確認できるポイント毎に確認すべし
- 構築は結構手間だけど、一度通ってしまえばすごく楽
-
nuxt generate
で静的ウェブサイトにもコンポーネントの概念を・・すごくイイ - 静的サイト生成なので当たり前だけど、ものすごくはやい
という感じで快適に開発しています。ぜひお試しください。
そして、Repsonaもぜひお試しください。フリープランでも全機能、ずっと無料で使えます。
無料ガントチャートのプロジェクト管理ツール - Repsona