JavaScript
AWS
CircleCI
vue.js
nuxt.js

Nuxt.jsで作った静的サイトのビルド/テスト/デプロイを自動化して、毎朝10時に更新する。

AWSとCircleCIの力を借りて、Nuxt.jsで作った静的サイトの運用をできるかぎり自動化した話です。

3ヶ月ほど前からCIのサービスを使うようになり、入門記事はたくさんあって助かったのですが、具体的にどんな感じで使っているかの情報が少なかったので記事にしました。

もしかしたら、CIの使い方が間違っているかもしれないので、そのときは優しくコメントをいただけたら嬉しいです。

できあがった流れ

flow.png

毎朝10時にLambdaを起こしてデータの更新を行い、静的ファイルを再生成してからデプロイする流れになっています。

対象のサイト

ざっくりAWSという、AWSの料金を日本円でざっくり計算できるサイトです。
Nuxt.jsで作成したものを、静的サイトとして生成して、AWSのS3にホスティングしています。

計算に必要なAWSの価格や為替は、毎朝10時に取得したものをS3にJSONで保存し、そのJSONも含めて静的サイトとして生成しています。

CircleCIを呼び出すきっかけ

master ブランチへのプッシュ

master ブランチへプッシュされたらテストしてデプロイ、の流れはこんな感じでワークフローを設定しています。

workflows:
  version: 2
  test-deploy:
    jobs:
      - install
      - build:
          requires:
            - install
      - test:
          requires:
            - build
      - deploy:
          filters:
            branches:
              only: master
          requires:
            - test

https://github.com/noplan1989/aws-rough/blob/master/.circleci/config.yml

毎朝10時にLambda

CircleCIでもcronの設定はできますが、今回は別のリポジトリで管理しているLambdaを毎朝起こしたかったので、CloudWatchのcronを使用することにしました。

Scheduling a Workflow | CircleCI
ルールのスケジュール式 | CloudWatch

Lambdaをそのまま使うのは結構大変なので、serverlessというフレームワークを使っています。serverlessを使うと、設定ファイルに数行追加するだけでcronを設定できます。serverlessがないとLambdaを使う気が起きないぐらいに浸かってしまっているので、少し怖いです。

Schedule | serverless

serverless.yml
functions:
  fx:
    handler: functions/batch/fx/handler.main
    events:
      - schedule: cron(0 1 * * ? *)

https://github.com/noplan1989/aws-rough-functions/blob/master/serverless.yml

CircleCIをAPIで呼び出す

CircleCIには、APIが用意されていて、外部から呼び出せるようになっているので、Lambdaで価格の更新が終わったら、フロントエンドのリポジトリのmasterブランチのビルドやデプロイを実行するようにしました。APIの実行に必要なトークンは、アカウントのダッシュボードから取得できます。

CircleCI API v1.1 Reference
Trigger a new Build by Project

handler.js
const updatePrice = require('./updatePrice')
const { sendWarning } = require('../../../lib/slack')
const { deploy } = require('../../../lib/circleci')

exports.main = async (event, context, callback) => {
  try {
    await updatePrice()
    await deploy('master')

    callback(null, 'success')
  } catch (err) {
    await sendWarning(err)
    callback(err)
  }
}
circleci.js
const axios = require('axios')
const { CIRCLE_API_TOKEN, CIRCLE_BUILD_ENDPOINT } = process.env

// CIRCLE_API_TOKEN=API_TOKEN_HERE
// CIRCLE_BUILD_ENDPOINT=https://circleci.com/api/v1.1/project/:vcs-type/:username/:project/build

const deploy = async branch => {
  await axios.post(`${CIRCLE_BUILD_ENDPOINT}?circle-token=${CIRCLE_API_TOKEN}`, { branch })
}

静的サイトの生成

Nuxt.jsでは、nuxt generateコマンドでファイルを静的に生成できるので、CircleCIでそれを実行するように設定します。先ほどLambdaで更新した価格や為替のJSONは、nuxtServerInitアクションで取得して、まるっと静的に出力しています。

デプロイのときにPythonの環境が必要になるので、Dockerのイメージは、Python/Node.js/ブラウザ全部入りのものを共通で使用しています。CircleCIのNode.jsイメージには、デフォルトでyarnが入っているのが地味に嬉しいです。

version: 2

defaults: &defaults
  working_directory: ~/aws-rough
  docker:
    - image: circleci/python:3.6-jessie-node-browsers

jobs:
  install:
    <<: *defaults
    steps:
      - checkout
      - restore_cache:
          name: Restore Yarn Package Cache
          keys:
            - yarn-packages-{{ checksum "yarn.lock" }}
      - run: yarn install
      - save_cache:
          name: Save Yarn Package Cache
          key: yarn-packages-{{ checksum "yarn.lock" }}
          paths:
            - ~/.cache/yarn
      - persist_to_workspace:
          root: ~/aws-rough
          paths:
            - ./*

  build:
    <<: *defaults
    steps:
      - attach_workspace:
          at: ~/aws-rough
      - run: yarn generate
      - persist_to_workspace:
          root: ~/aws-rough
          paths:
            - ./*
package.json
{
  "scripts": {
    "generate": "nuxt generate"
  }
}

テスト

テストのライブラリにはJestを使用しています。

test:
  <<: *defaults
  steps:
    - attach_workspace:
        at: ~/aws-rough
    - run: yarn test:unit
    - run: yarn test:e2e
    - persist_to_workspace:
        root: ~/aws-rough
        paths:
          - dist
package.json
{
  "scripts": {
    "test:unit": "jest -c jest.config.unit.js test/unit",
    "test:e2e": "jest -c jest.config.e2e.js test/e2e"
  }
}

ユニットテスト

料金の算出を行うサイトなので、計算部分をメインにテストしています。
UIの挙動や表示は、E2Eテストで最低限の確認をしているので、コンポーネントのテストはしていません(面倒だったので)

計算のテスト

各サービスの料金計算などの大切な処理は、Vuexから切り離しているため、関数単体でテストしています。
例として、ElastiCacheの場合は、計算がシンプルなのでこんな感じです。

lib/calc/elasticache.js
import { toNumber } from '@/lib/validator'
import { parseInstance } from '@/lib/service'
import { MONTHLY_HOURS } from '@/config/constants'

export default (row, priceList) => {
  const unit = toNumber(row.unit)

  let total = 0

  if (row.instance && unit) {
    const instance = parseInstance(row.instance, priceList.elasticache.instance)

    total += instance.price * unit * MONTHLY_HOURS
  }

  return total
}

test/unit/lib/calc/elasticache.spec.js
import elasticache from '@/lib/calc/elasticache'
import { MONTHLY_HOURS } from '@/config/constants'

describe('elasticache', () => {
  test('ElastiCacheの料金を計算できる', () => {
    const priceList = {
      // ここに価格
    }
    const row = {
      instance: 'cache.t2.micro',
      unit: 2
    }
    const expected = 0.026 * MONTHLY_HOURS * 2

    expect(elasticache(row, priceList)).toBe(expected)
  })
})

Vuexのテスト

Vuexでは複雑な計算を行わず、単純なStateの管理に絞っているため、シンプルに状態の変化をテストしていきます。Vuexのテストについては、調べてみてもあまり良い方法が見つからなかったので、テストのたびに新しいStoreを生成して、dispatchcommitをしたあとのstateを確認しています。気合いだ。

mutationsは、関数としてテストをすることもできますが、しっかりと返り値があるわけではなく、引数のstateを変更するので、いっそcommitした後の値を確認してしまった方がわかりやすいかなと思い、こんな形になりました。

store/index.js
import Vuex from 'vuex'

const store = () =>
  new Vuex.Store({
    state: {
      error: {
        isVisible: false
      }
    },
    mutations: {
      SHOW_ERROR(state) {
        state.error.isVisible = true
      }
    }
  })

export default store

test/unit/store/index.spec.js
import Vuex from 'vuex'
import { createLocalVue } from '@vue/test-utils'
import createStore from '@/store'

let store

describe('store', () => {
  beforeEach(() => {
    const localVue = createLocalVue()
    localVue.use(Vuex)
    store = createStore()
  })

  describe('SHOW_ERROR', () => {
    test('エラーを表示できる', () => {
      store.commit('SHOW_ERROR')
      expect(store.state.error.isVisible).toBe(true)
    })
  })
})

E2Eテスト

ユニットテストだけでもある程度はカバーできますが、より実際に近い形で「/ec2/のページを開いて、インスタンスにt2.smallを選択し、台数に2が入力されたら○○○ドルぐらいになる」というレベルで確認しておきたかったので、E2Eテストもやることにしました。

Webサイト上では計算結果が日本円で表示されていますが、テストでは為替の影響を受けたくなかったのでドルに換算しています。

Nuxt.jsでは、ドキュメントに記載があるように、テストコード上でビルドしてサーバーを起動したりできますが、今回はせっかく静的サイトを生成しているので、Nuxt.jsに依存しない形でテストできるようにしました。

Jestのドキュメントに、Using with puppeteerというページがあったので、このページを参考にjest-puppeteerというライブラリを使用しました。JestでPuppeteerを良い感じに使いやすくしてくれるやつです。

Webサーバーの選択肢はいろいろとありましたが、使いやすそうなhttp-serverにしました。jest-puppeteerの設定ファイルで、事前にサーバーを起動するコマンドを指定できます。

jest-puppeteer.config.js
module.exports = {
  server: {
    command: 'yarn serve',
    port: 8888
  }
}
package.json
{
  "scripts": {
    "serve": "http-server ./dist -p 8888"
  }
}

ページを開いて料金の計算

基本的にどのページも同じ構成で、入力項目を埋めたら金額が算出される流れなので、全ページに対してPuppeteerのコードは書かず、サービスごとの設定をファイルにまとめて管理するようにしました。もっとも項目が少ないElastiCacheの場合は、こんな感じの雰囲気でテストが実行されます。

// コードは雰囲気です!
import getPriceAfterInput from './util/getPriceAfterInput'

const buildUrl = path => `http://localhost:8888${path}`
const usdjpy = 100

// 何を入力して、何ドルぐらいになって欲しいか
const useCase = {
  waitFor: '[data-test="instance"]',
  actions: [
    { type: 'select', target: '[data-test="instance"]', value: 'cache.t2.small' },
    { type: 'type', target: '[data-test="unit"]', value: '3' }
  ],
  price: {
    target: '[data-test="service-calc"] [data-test="price"]'
  },
  range: { min: 110, max: 120 }
}

test('ElastiCacheの計算結果が想定内', async () => {
  const servicePage = await browser.newPage()

  const serviceUrl = buildUrl('/elasticache/')
  const price = await getPriceAfterInput(servicePage, serviceUrl, useCase)
  const priceInUsd = price / usdjpy

  expect(priceInUsd).toBeGreaterThanOrEqual(useCase.range.min)
  expect(priceInUsd).toBeLessThanOrEqual(useCase.range.max)

  servicePage.close()
})
// コードは雰囲気です!
test/e2e/util/getPriceAfterInput.js
export default async function(page, url, useCase) {
  await page.goto(url)
  await page.waitForSelector(useCase.waitFor)

  if (Array.isArray(useCase.actions) && useCase.actions.length) {
    for (const action of useCase.actions) {
      await page[action.type](action.target, action.value)
    }
  }

  const price = await page.$eval(useCase.price.target, el => el.textContent)

  return parseFloat(price.replace(/,/g, ''))
}

実際は、設定を別ファイルで管理しているので、上記のコードはあくまで雰囲気です。毎朝動いているコードはこちらでご確認を。力技なのでもう少し綺麗に書きたい・・・
https://github.com/noplan1989/aws-rough/tree/master/test/e2e

E2Eテストの実行時間

ヘッドレスとはいえブラウザを使用しているので、ユニットテストよりは実行に時間がかかっています。多少のバラつきがありますが、20ページで8秒ほどです。参考までにユニットテストは60個で3秒ぐらいです。

テストがコケた場合はデプロイせずにSlackへ通知

By default, CircleCI will execute job steps one at a time, in the order that they are defined in config.yml, until a step fails (returns a non-zero exit code). After a command fails, no further job steps will be executed.

CircleCI

CircleCIでは、ジョブが途中で失敗した場合、後続のジョブは実行されません。なので、更新したデータがおかしい場合や、計算のロジックが誤っている場合はデプロイされず、テストに通ったコードが常に公開されている状態になります。

Slackとの連携も簡単にできるので、何かがあったときは通知するように設定しています。
Enable Chat Notifications | CircleCI

デプロイ

Nuxt.jsで静的に生成したファイルは、distディレクトリへ出力されるので、それをAWS CLIのコマンドでS3にアップしてから、CloudFrontのキャッシュを削除しています。

deploy:
  <<: *defaults
  steps:
    - attach_workspace:
        at: ~/aws-rough
    - run: sudo pip install awscli
    - run: aws s3 sync ./dist/ s3://aws.noplan.cc --exact-timestamps --delete --exclude "*.html" --cache-control max-age=31536000
    - run: aws s3 sync ./dist/ s3://aws.noplan.cc --exact-timestamps --delete --exclude "*" --include "*.html" --cache-control no-store
    - run: aws cloudfront create-invalidation --distribution-id $CF_DIST_ID --paths "/*"

おわり

以上が、Lambdaを毎朝10時に起こしてデータを更新し、CircleCIでビルド&テストして、デプロイするまでの流れになります。まだ手探りの状態なので完成形ではありませんが、しばらくはこの形で運用する予定です。

ところどころでコードの断片を載せていますが、説明しやすくするために省略している部分もあるので、実際に動いているコードはGitHubの方でご確認ください。

フロントエンド
https://github.com/noplan1989/aws-rough

Lambda
https://github.com/noplan1989/aws-rough-functions