nuxt.jsで構築したSPAアプリをS3とCloudFrontで公開。AWS CodeBuildで自動ビルドしてみた。ログはSentryで。


この記事はWEBアプリケーションをNuxt.js実装し、CodeBuildでビルドしS3にアップロード、S3ではホスティングせずにCloudFront経由で読み込めるようにした実装を記録しています。データの受け渡し元APIはRailsにて実装していますが、そちらに関してはこの記事では言及しません。

環境

  • APIサーバー

Ruby on Rails 5.2.3(APIモード)
Ruby 2.6.3

  • SPAフレームワーク

Nuxt.js + Typescript

  • lint

ESLint + Prettier

  • ビルド

AWS CodeBuild

  • ホスティング

S3 + CloudFront

  • ログ

Sentry

アプリケーション本体実装(Nuxt.js)

Homebrewは入っているとして、nodebrewのインストール

brew update
brew install nodebrew
nodebrew setup

~/.bash_profileに下記を追加

export PATH=$HOME/.nodebrew/current/bin:$PATH

yarnをinstall

nodebrew install-binary stable
nodebrew use stable
nodebrew list
brew install yarn --ignore-dependencies
yarn -v

nuxt.jsのアプリを作成(プロジェクト名は[sample_nuxt_app])
いくつか質問されます。

$ yarn create-nuxt-app sample_nuxt_app
create-nuxt-app v2.8.0
✨  Generating Nuxt.js project in path_to_sample_nuxt_app
? Project name sample_nuxt_app
? Project description 説明文を入力
? Author name 著者を入力
? Choose the package manager Yarn
? Choose UI framework None(vuefyやbootstrapなど色々選べるが今回はデザインあるのでNoneを選択)
? Choose custom server framework None (Recommended)
? Choose Nuxt.js modules Axios
? Choose linting tools ESLintとPrettier
? Choose test framework Jest
? Choose rendering mode Single Page App(サーバーレンダリングも選べるが今回はSPA)

AxiosとLint, PrettierはSpaceKeyを押して選ばないと入らない。エンターキーだとスルーされる。ややこしい!

$ cd sample_nuxt_app
$ yarn dev

で一旦動く。


typescript化
まず、nuxtのバージョンをあげたい。
package.jsonのなかみを新しくする。

$ ncu

ncuがはいってなければ

$ yarn global add npm-check-updates

はいったら

$ ncu
Checking path_to/sample_nuxt_app/package.json
[====================] 20/20 100%

 @nuxtjs/axios            ^5.3.6  →    ^5.5.4 
 nuxt                     ^2.0.0  →    ^2.8.1 
 @nuxtjs/eslint-config    ^0.0.1  →    ^1.0.1 
 @nuxtjs/eslint-module    ^0.0.1  →    ^0.2.1 
 babel-eslint            ^10.0.1  →   ^10.0.2 
 eslint                  ^5.15.1  →    ^6.0.1 
 eslint-plugin-import   >=2.16.0  →  >=2.18.0 
 eslint-plugin-jest     >=22.3.0  →  >=22.7.2 
 eslint-plugin-node      >=8.0.1  →   >=9.1.0 
 eslint-plugin-nuxt      >=0.4.2  →   >=0.4.3 
 eslint-plugin-promise   >=4.0.1  →   >=4.2.1 
 eslint-plugin-vue        ^5.2.2  →    ^5.2.3 
 babel-jest              ^24.1.0  →   ^24.8.0 
 jest                    ^24.1.0  →   ^24.8.0 
 vue-jest                 ^3.0.3  →    ^3.0.4 
 nodemon                 ^1.18.9  →   ^1.19.1 

Run ncu -u to upgrade package.json

こうなるので問題なければ

ncu -u

書き換わったら、一回全部消して入れ直す。

rm -rf node_modules yarn.lock
yarn install

typescript本体

yarn add typescript ts-node @nuxt/typescript

設定ファイル作成・編集

nuxt.config.js → nuxt.config.ts

もともとがこれで↓

nuxt.config.js
export default {
  ...中身
}

こうする↓

nuxt.config.ts
import NuxtConfiguration from '@nuxt/config'

const nuxtConfig: NuxtConfiguration = {
  ...中身
}
export default nuxtConfig

+その中身にこれを追加

build: {
    extend(config, ctx) {
      if (ctx.isDev && ctx.isClient) {
        if (!config.module) return  // undefinedの場合、pushせずにreturnするように追加
        config.module.rules.push({
          enforce: 'pre',
          test: /\.(js|vue)$/,
          loader: 'eslint-loader',
          exclude: /(node_modules)/
        })
      }
    }
  }

tsconfig.json
ECMAバージョン周りでエラーが出る場合があるので
{target: es2015, module esNext} とした

tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "types": [
      "@types/node",
      "@nuxt/vue-app"
    ],
    "paths": { "@/*": [ "./*" ], "~/*": [ "./*" ] },
    "target": "es2015",
    "strict": true,
    "module": "esNext",
    "moduleResolution": "node",
    "experimentalDecorators": true
  }
}
  • parserOptions
    ESLintについてのところで記載

  • rules(コーディングルールになるので自由にどうぞ)

rules: {
    'no-console': process.env.NODE_ENV === 'production' ? 2 : 0,
    'space-before-function-paren': [2, {
      'anonymous': 'always',
      'named': 'always',
      'asyncArrow': 'always'
    }],
    'comma-dangle': ['error', 'only-multiline'],
}

vueのクラスコンポーネントで書くように変更
nuxtで拡張したものを使わず本家の方で問題ない

$ yarn add vue-class-component vue-property-decorator vuex-class

それぞれモジュールをtypescriptで記述
- index.vue
scriptの箇所を修正

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
import Logo from '~/components/Logo.vue'
@Component({
  components: {
    Logo
  }
})
export default class Index extends Vue {}
</script>
  • logo.vue script を追記
<script lang="ts">
import { Vue } from 'vue-property-decorator'
export default class Logo extends Vue {}
</script>

ESLintについてはこちら参照

$ yarn add -D @typescript-eslint/parser @typescript-eslint/eslint-plugin

.eslintrc.jsを修正
サイトにはこのように。

eslintrc.js
module.exports = {
  root: true,
  env: {
    browser: true,
    node: true
  },
  parserOptions: {
    parser: '@typescript-eslint/parser',
    sourceType: 'module',
    project: './tsconfig.json',
    ecmaFeatures: { "legacyDecorators": true }
  },
  extends: [
    '@nuxtjs',
    'plugin:nuxt/recommended',
    'plugin:prettier/recommended',
    'prettier/vue',
    'prettier/@typescript-eslint'
  ],
  plugins: [
    'prettier',
    '@typescript-eslint'
  ],
  // add your custom rules here
  rules: {
    "no-unused-vars": "off",
    "@typescript-eslint/no-unused-vars": "error"
  }
}

他にもエラーあったのでこのようにした。

eslintrc.js
module.exports = {
  root: true,
  env: {
    browser: true,
    node: true,
    commonjs: true,
    es6: true
  },
  parserOptions: {
    "parser": '@typescript-eslint/parser',
    "sourceType": "module",
    "project": './tsconfig.json',
    "ecmaVersion": 2018,
    "ecmaFeatures": {
      "legacyDecorators": true,
    },
  },
  extends: [
    '@nuxtjs',
    'plugin:nuxt/recommended',
    'plugin:prettier/recommended',
    'prettier/vue',
    // 'prettier',
  ],
  plugins: [
    'prettier',
    '@typescript-eslint'
  ],
  // add your custom rules here
  rules: {
    "no-unused-vars": "off",
    "@typescript-eslint/no-unused-vars": "error",
    'no-console': process.env.NODE_ENV === 'production' ? 2 : 0,
    'comma-dangle': ['error', 'only-multiline'],
  }
}

S3, CloudFrontの構築

公式に詳しく載ってる

S3

バケットを作成し、アクセス権限はprivateにしておく。

CloudFront

こちらに従って作成
他の参考サイト
エラーページは大事(らしい)

1. S3 バケットを作成する(プライベートバケットでOK)

バケット名をとっとくAWS_BUCKET_NAME="sample_bucket"

2. CloudFront distribution を作成する

distributionのIDを取得

AWS_CLOUDFRONT="UPPERCASEDID"

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

IAMのポリシーを作成、そのポリシーをアタッチしたユーザーを作成。

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

AWSアクセスキーとシークレットをとっとく。
AWS_ACCESS_KEY_ID="key_id_value"
AWS_SECRET_ACCESS_KEY="secret_value"

ローカルDeploy用のスクリプトを作成

deploy.sh

#!/bin/bash

export AWS_ACCESS_KEY_ID="key_id_value"
export AWS_SECRET_ACCESS_KEY="secret_value"
export AWS_BUCKET_NAME="sample_bucket"
export AWS_CLOUDFRONT="UPPERCASEDID"
export AWS_DEFAULT_REGION="ap-northeast-1"

# nvm(node version manager)を読み込み、node(.nvmrc 内のバージョン)をインストールし、npm パッケージをインストールします。
[ -s "$HOME/.nvm/nvm.sh" ] && source "$HOME/.nvm/nvm.sh" && nvm use
# まだインストールされていない場合は npm をインストールする
[ ! -d "node_modules" ] && yarn install

yarn run generate
gulp deploy
  • 権限変更
chmod +x deploy.sh
  • .gitignoreに追加
echo "
# Don't commit build files
node_modules
dist
.nuxt
.awspublish
deploy.sh
" >> .gitignore
  • yarnでgulpをインストール
yarn add --save-dev gulp gulp-awspublish gulp-cloudfront-invalidate-aws-publish concurrent-transform
yarn global add gulp
yarn add gulp   (こっちもしないと使えなかった)
  • gulpfile.js を作成(中身は上記nuxtの公式から。) やってることとしては、
    • yarnをインストール
    • yarnでいろいろインストール
    • gulp-awspublishでS3にアップロード。
    • gulp-cloudfront-invalidate-aws-publishでCloudFrontのキャッシュを削除。
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 = {

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

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

  // 適切なデフォルト値 - これらのファイル及びディレクトリは gitignore されている
  distDir: 'dist',
  indexRootPath: true,
  cacheFileName: '.awspublish',
  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
  var publisher = awspublish.create(config);

  var 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;
});
  • スクリプトを実行(ローカルからデプロイ)
./deploy.sh

CodeBuild構築

ここから先はAWS CodeBuildを使用してGithubをコミットフックとした自動デプロイの実装です。

CodeBuildの画面にアクセス。

  • プロジェクト名、説明文

  • バッジはかっこいいのでonにした。(アプリのREADME.mdにデプロイステータスのバッジをつけることができる)

  • 送信元:ソースをgithubにし、Repoを選択。
    ここではgithubのPersonal Access Tokenを使用しました。(登録すると、githubと連携してRepoを選択できるようになる。)

  • ウェブフックイベント
    これをOnにすると、PushやPRマージをフックにしてデプロイを自動化できる。ブランチ指定も可能
    「これらの条件でビルドを開始」をクリックし開いたところで下記を設定
    [イベントタイプ](複数選択可)をプッシュPULL_REQUEST_MERGEDに。
    [HEAD_REF - オプショナル]に^refs/heads/master$と入力

  • 環境
    [マネージド型イメージ]を選択
    [Ubuntu]
    [Standard]
    [aws/codebuild/standard:2.0]
    [最新のもの]
    の順に設定
    オプションを開き、タイムアウトを10分程度に。(SPAでそんなに時間かからない?)
    証明書はここでは省略。
    VPCにはアクセスしないのでなし。
    コンピューティングはいちばんしょぼいもので十分3 GB メモリ、2 vCPU
    環境変数に下記を指定
    AWS_BUCKET_NAME: バケット名
    AWS_CLOUDFRONT: distributionのID
    API_URL_BROWSER: CloudFrontで設定したDNS、なければCloudFrontで発行されてるURL(一旦)
    DEPLOY_ENV: "S3" と記載

  • ロール:先に作ってあればそれをセット。なければ新しいロール名を設定し、あとでポリシーをセットする。
    使うポリシーはS3とCloudFrontの設定ができるPolicy(前述のポリシーを付与で良い)
    (ASMなど使う場合はそのポリシーも付与する。)

  • buildspec(ビルド仕様)
    ビルドはリポジトリのルートに配置したbuildspec.ymlか、フォームに直接記述で指定できる。
    git管理したいのでリポジトリに配置。
    名前を変えることもできるがデフォルトでいいと思う。

  • アーティファクトなし

  • CloudWatchLog: 使う場合は設定

設定は以上、masterにプッシュして動作確認!(できたかな?)

Sentry実装

ログマネージメントを行いたいので、Sentryを採用。
こちらがとてもわかりやすくまとまってます。

  • Sentryのサイトで新規登録+プロジェクト新規作成。

言語、フレームワークを選択するような感じになってるけど選択しなくてもいい(やり方が出るだけ)+nuxt.jsない。
プロジェクト名などを入力し、DSNを取得できればそれでいい。

  • モジュールインストール
$ yarn add @nuxtjs/sentry
  • 設定
nuxt.config.ts
  // •••
  modules: [ '@nuxtjs/sentry' ],
  sentry: { dsn: 'https://[email protected]/000000' }

なお、直書きじゃない場合、envを使うのだが、これが少し厄介。
例えば

  sentry: { dsn: process.env.SENTRY_DSN }

とかやって、CodeBuildの環境変数にセットしてもうまく読めない。
下記サイトを参考にしてみてください。

[CodeBuild]buildspec.ymlでの環境変数指定方法あれこれまとめ
Sentry で Nuxt.js のエラー検知 + 環境変数の扱いに関する Tips

  • ログを出してみる

typescriptでログを出すには、moduleインポートが必要。

import { captureException } from '@sentry/browser'

として

captureException(new Error('Sentryから始まるエラーマネジメント'))

以上となります!
Frontendの実装はほとんど初めてになりますので間違いなどあればご指摘いただけると幸いです!

参考にさせていただいた記事

nuxt.js公式
nuxt.js公式(S3+CloudFront)
nuxt.js+Typescript
S3+CloudFront+CodeBuild
SentryとNuxt.js