アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

NuxtのSPAを S3+CloudFrontでホストする。デプロイはCodeBuildで自動化


≪2019/06/20追記≫
この記事を公開したのは2018-10-19です。
2019-06-20時点でより簡単さ、運用の容易さを求めるならAWS Amplify Consoleもおすすめです。

tech.actindi.net


morishitaです。

Nuxt で実装した SPA を S3 + CloudFront で配信する機会があったのでそれを書きます。

NuxtのSPA自体については、標準的な作りでTypescript、PugSassを使ってますよってことぐらいしか書くことがないのですっ飛ばして、 S3とCloudFrontでどのようにSPAをホストしたのか、そしてCodeBuildを使ったデプロイの自動化について書きます。

概要は次のとおりです。

  • NuxtのSPAをホストするための S3とCloudFrontを設定する
  • CodeBuildを使ってS3にデプロイする
    • GIthubリポジトリのmasterにpushしたら動く
    • Gulpを利用して、古いファイルの削除やCloudFrontのキャッシュ無効化も実施。

f:id:HeRo:20181018063222j:plain

ちなみに、主なライブラリ等のバージョンは次のとおりです。

以下、nuxt.actindi.net というドメインで配信するとして説明します。
nuxt.actindi.netは例であって実際には存在しません。念のため。

S3 の設定

バケット nuxt.actindi.netを作成します。 バケット内のディレクトリ構成は次のとおりです。

nuxt.actindi.net   # S3バケット
  ├ app/                # Nuxt SPAをデプロイするディレクトリ
  └ data/               # Nuxt で読み込むJSONファイルを置くディレクトリ

app ディレクトリには nuxt build の生成物をデプロイします。
data ディレクトリには JSON ファイルを置きます。このファイルを Nuxt SPA が動的に読み込んで表示します。 このJSON ファイルは別のアプリケーションが任意のタイミングで更新します1

このバケットには公開してよいリソースのみ配置するので Static website hostingを有効にして次の項目も設定します。

設定項目 設定値
インデックスドキュメント index.html
エラードキュメント /app/index.html(※後述)

次のバケットポリシーも設定しておきます。
これでこのバケットに置いたファイルへのパブリックアクセスを設定しています。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadGetObject",
      "Effect": "Allow",
      "Principal": "*",
      "Action": ["s3:GetObject"],
      "Resource": ["arn:aws:s3:::nuxt.actindi.net/*"]
    }
  ]
}

そして、data ディレクトリのJSONファイルはXHRで取得するのでCORSの設定もしておきます。

<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<CORSRule>
    <AllowedOrigin>*</AllowedOrigin>
    <AllowedMethod>GET</AllowedMethod>
    <MaxAgeSeconds>3000</MaxAgeSeconds>
    <AllowedHeader>*</AllowedHeader>
</CORSRule>
</CORSConfiguration>

CloudFront の設定

HTTPS にするために CloudFront を利用します。

AWSコンソールのタブごとに設定内容を説明します。

General

目的が HTTPS 化なので、SSL Certificate を設定します。
ここでは nuxt.actindi.net で配信したいので Alternate Domain Namesnuxt.actindi.net を設定します。
SSL Certificate には ACM で作った証明書を設定します。

Origin

キャッシュの有効期限を分けたいのでOrigin は 2 つ登録します。

1 つ目は次の設定。

オリジン(1)
Origin Domain Name S3 バケットの公開ドメイン
Origin Path / (表示上は空)

もう 1 つは次の設定。

オリジン(2)
Origin Domain Name S3 バケットの公開ドメイン
Origin Path /app

Behaviors

Behaviors は上記で作ったオリジンそれぞれに作ります。

まずは、オリジン(1)/に対する設定。

設定項目 設定値
Path Pattern /data/*
Origin オリジン(1)/
Whitelist Headers Origin (CORSのため)
Object Caching Customizeを選択し、TTLはすべて0にする

dataディレクトリ以下のファイルの更新を即時反映するため、キャッシュさせないようにTTLをすべて0にするのがポイントです。

続いて、オリジン(2)/appに対する設定。

設定項目 設定値
Path Pattern Default(*)
Origin オリジン(2)/app
Object Caching Use Origin Cache Headers

こちらはSPAを構成するファイル用なのでキャッシュさせます。
後述しますがデプロイ時に古いキャッシュを無効化するので、デプロイしても反映されないということは起こりません。

Error Pages

次の様に404エラーが発生した場合、index.htmlを返すようにします。重要です。

設定項目 設定値
HTTP Error Code 404: Not Found
Customize Error Response Yes
Response Page Path /index.html
HTTP Response Code 200: OK

なぜこの設定が必要かというと、NuxtではPageshoge.vueのような固定ルーティングのページを作るとnuxt buildしたときに実体として/hoge/index.htmlファイルが生成されます。

しかし、_id.vueのような動的ルーティングするページを作った場合、それに対応するファイルは生成されません2

その状態で /some_dir/10の様なパスにアクセスするとCloudFrontはS3にファイル/some_dir/10を探しに行きます。開発者の意図としては/some_dir/_id.vueで処理したいのですが、対応するファイルが無いので404エラーとなっていまいます。
Nuxtではindex.htmlでリクエストを受けないと何も処理できないのでS3上に存在しないパスへのリクエストにはとにかく index.htmlを返すようにします。そのため上記のError Pages設定を行います。(その上でNuxtの動的ルーティング上でも存在しなければNuxt上で404ページを表示します)。

Lambda Edgeで処理する方法もあるのですが、少々大げさな気がしますしLambdaのコストがかかるのでこうしました。

S3の設定でもエラードキュメントを設定しましたが、あの設定は念のためなので上記の様にError Pagesを設定すればS3のエラードキュメントの設定はたぶん不要です。

Nuxtの設定

デプロイする先が用意できたので、デプロイするものを作るための設定です。

ポイントとなるのはnuxt.config.jsの次の箇所です。

let generateDir = {};
if (process.env.DEPLOY_ENV === 'S3'){
  generateDir = {
    generate: { dir: "dist/app" },
  }
}

module.exports = {
  mode: 'spa',
  ...generateDir,
  // 〜中略〜
}

何をやっているのかというと、DEPLOY_ENVという環境変数にS3という値が設定されていれば、dist/appディレクトリにビルド生成物を出力するという設定です(デフォルトでは distディレクトリに出力されます)。
これにより、appディレクトリを丸ごとS3にアップすれば良くなります。

また、modeを 'spa'に設定しています。

CodeBuildでのデプロイ

デプロイ先とデプロイするものが揃ったので、デプロイ処理を作ります。
デプロイにはCodeBuildを使います。

CodeBuildのプロジェクトは次のように設定して作成します。

設定項目 設定値
ソースプロバイダ GithubのNuxtのSPAのリポジトリを設定
Webhook 有効にしてブランチフィルタに ^master$を設定
環境イメージ aws/codebuild/nodejs:10.1.0
インスタンスタイプ 3 GB メモリ、2 vCPU(最低スペックで十分)
Buildspec buildspec.yml

Webhookを有効にすると、GithubのリポジトリにWebhookを作ってくれます。 ただ、この時作られるWebhookには、Pull requestsの操作時もトリガーするように設定されてしまいます。
不要で邪魔なのでGithubのWebhook設定画面で Pull requestsのチェックを外して、Pushesのみにします。
ブランチフィルタを設定しているので masterにpushまたはRPをmasterにマージしたタイミングでCodeBuildが実行されるようになります。
アーティファクトは出力しません。

環境変数には次の4つの値を設定します。

環境変数名 設定値
AWS_BUCKET_NAME nuxt.actindi.net
AWS_CLOUDFRONT 作成したCloudFrontのディストリビューションID
API_URL_BROWSER https://nuxt.actindi.net/data
DEPLOY_ENV S3

上2つは あとで説明するGulpで参照するものです。
API_URL_BROWSERはNuxtに組み込まれているaxios-moduleの設定で、axiosのリクエストパスのプリフィックスを上書きするものです3

DEPLOY_ENVは前述したビルド生成物の出力先を変更するフラグですね。

で、CodeBuildプロジェクトで実行する処理を記述した buildspec.ymlは次のとおりです。

version: 0.2

phases:
  install:
    commands:
      - apt-get update -qq && apt-get install -y apt-transport-https
      - curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
      - echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
      - sudo apt-get update && sudo apt-get install yarn
  pre_build:
    commands:
      - echo 'Start pre_build phase'
      - yarn
  build:
    commands:
      - echo 'Start build phase'
      - nuxt build
  post_build:
    commands:
      - echo 'Start post_build phase'
      - ./node_modules/.bin/gulp deploy

特に難しいことはしていません。

install フェーズ

残念ながら、CodeBuild標準で用意されているNode.js環境にはyarnがインストールされていないので インストールしています。

pre_buildフェーズ

yarnを使ってモジュールをインストールしているだけです。

buildフェーズ

nuxt buildを実行してアプリケーションをSPAとしてビルドしています。

post_build フェーズ

生成物をS3にアップロードしているのですが、そのためにGulpを使っています。

Gulpの設定

CodeBuildからS3にビルド生成物をアップロードするために次の gulpfile.js を使いました。

var gulp = require('gulp');
var awspublish = require('gulp-awspublish');
var cloudfront = require('gulp-cloudfront-invalidate-aws-publish');
var parallelize = require('concurrent-transform');

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

var config = {

  // Required
  params: { Bucket: process.env.AWS_BUCKET_NAME },

  // Optional
  deleteOldVersions: true,
  originPath: '/app',       // ポイント①
  distribution: process.env.AWS_CLOUDFRONT, // Cloudfront distribution ID
  region: process.env.AWS_DEFAULT_REGION,
  headers: { /*'Cache-Control': 'max-age=315360000, no-transform, public',*/ },

  // Sensible Defaults - gitignore these Files and Dirs
  distDir: 'dist',              // ポイント②
  indexRootPath: true,
  cacheFileName: '.awspublish',
  concurrentUploads: 10,
  wait: true,  // wait for Cloudfront invalidation to complete (about 30-60 seconds)
}

gulp.task('deploy', function() {
  // create a new publisher using S3 options
  // http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#constructor-property
  var publisher = awspublish.create(config, config);

  var g = gulp.src('./' + config.distDir + '/**');
    // publisher will add Content-Length, Content-Type and headers specified above
    // If not specified it will set x-amz-acl to public-read by default
  g = g.pipe(parallelize(publisher.publish(config.headers), config.concurrentUploads))

  // Invalidate 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');
  }

  // Delete removed files
  // ポイント③
  if (config.deleteOldVersions) g = g.pipe(publisher.sync(config.originPath));  
  // create a cache file to speed up consecutive uploads
  g = g.pipe(publisher.cache());
  // print upload updates to console
  g = g.pipe(awspublish.reporter());
  return g;
});

このgulpfile.js は実はNuxtの公式ドキュメントの次のページにあるサンプルほぼそのままです。

https://nuxtjs.org/faq/deployment-aws-s3-cloudfrontnuxtjs.org

このgulpfile.jsは次の2つのライブラリを使ってS3へのアップロードとCloudFrontのキャッシュ無効化を行っています。

  • gulp-awspublish:S3にファイル/ディレクトリをアップロードしてくれる。古いファイルの削除も可能で大変便利。
  • gulp-cloudfront-invalidate-aws-publish:更新されて古くなったファイルのCloudFrontキャッシュを無効にしてくれます。大変便利。

ただ、Nuxtの公式ドキュメントの例そのままでは困るところがあり、いくつか手を入れています。

順にポイントを説明します。

ポイント①

deleteOldVersions: trueにすることで、S3上の古いファイルを削除してくれます。 しかし、バケット全体を対象にするので、そのままだと /dataディレクトリまで削除されてしまいます。 それは困るので originPath: '/app' で影響範囲を /appディレクトリ配下のみに限定しています。

ポイント②

distDir: 'dist'ディレクトリ以下をまるごとS3にアップしてくれます。 nuxt buildの生成物は /dist/appディレクトリに出力されるので、S3には/appディレクトリごとアップされます。

ポイント③

publisher.sync()は、古くなったS3上のファイルを削除してくれる関数ですが、 もともとのコードではpublisher.sync()の様に引数無しで使われています。 ポイント①で設定したoriginPathを渡すことにより、/appディレクトリとその中身のみ削除の対象とするようになります。

ポイント④

ポイント④はコードから削除した部分です。 Nuxtの公式ドキュメントにも書いてありますが、もともとのコードはAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYを環境変数で渡すように書かれており、 次のコードが記載されています。

var config = {
  // 〜略〜
  accessKeyId: process.env.AWS_ACCESS_KEY_ID,
  secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  // 〜略〜
}

CodeBuildを実行するロールに次の権限を与えておけば、これらの値をわざわざ設定しなくても S3とCloudFrontへの操作が可能になります。なので不要となり削除しました。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObjectAcl",
                "s3:GetObject",
                "s3:AbortMultipartUpload",
                "s3:ListBucket",
                "s3:DeleteObject",
                "s3:PutObjectAcl",
                "s3:ListMultipartUploadParts"
            ],
            "Resource": [
                "arn:aws:s3:::nuxt.actindi.net",
                "arn:aws:s3:::nuxt.actindi.net/*"
            ]
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": [
                "cloudfront:ListInvalidations",
                "cloudfront:GetInvalidation",
                "cloudfront:CreateInvalidation"
            ],
            "Resource": "*"
        }
    ]
}

他のCIの仕組みを使うと AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY という秘密情報を渡さないといけなくなり その管理どうしようと考えないといけなくなるのですが、CodeBuildではそれが不要です。便利ですね。

これですべての設定の説明は終わりです。
冒頭の図を再掲しますが、次のような流れが実現できます。

f:id:HeRo:20181018063222j:plain

まとめ

  • Nuxtで実装したSAPをS3+CloudFrontでホストしました。
  • 動的ルーティングを持つSPAも運用可能です。
  • CodeBuildで自動デプロイも楽々。

最後に

アクトインディは今のところRailsメインの会社ですが、Railsだけではありません。
あれこれ一緒に試してみたいエンジニアを募集しています。

actindi.net


  1. APIを作っても良かったのですが、今回の案件ではデータ量も多くないし、更新頻度も多くないので簡易に済ませました。

  2. nuxt generate でも_id.vueは無視され何も生成されません。

  3. https://github.com/nuxt-community/axios-module/blob/dev/docs/options.md#browserbaseurl