AWSとCircleCIの力を借りて、Nuxt.jsで作った静的サイトの運用をできるかぎり自動化した話です。
3ヶ月ほど前からCIのサービスを使うようになり、入門記事はたくさんあって助かったのですが、具体的にどんな感じで使っているかの情報が少なかったので記事にしました。
もしかしたら、CIの使いかたが間違ってるかもしれないので、そのときは優しくコメントをいただけたら嬉しいです。
できあがった流れ
毎朝10時にLambdaを起こしてデータの更新を行い、静的ファイルを再生成してからデプロイする流れになっています。
対象のサイト
ざっくりAWSという、AWSの料金を日本円でざっくり計算できるサイトです。
Nuxt.jsで作成したものを、静的サイトとして生成して、AWSのS3にホスティングしています。
AWSの価格や為替は更新が必要なので、Lambdaで毎朝10時に取得して、S3にJSON形式で保存しています。このJSONを非同期で取得すれば、毎朝ファイルを再生成などする必要はないですし、きっとそっちの方が費用対効果は高いですが、すべてを静的にしたい欲求が抑えられない時期があったので、わざわざ再生成しています。
CIのサービスを使うのがはじめてで、いろいろなサービスを比較しても違いがピンと来なかったので、名前が好みだったCircleCIを選びました。
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を起こす
cronの設定はCircleCIでもできますが、今回はLambdaを起こしたかったので、CloudWatchのcronを使用することにしました。
Scheduling a Workflow | CircleCI
ルールのスケジュール式 | CloudWatch
Lambdaをそのまま使うのは結構大変なので、serverlessというフレームワークを使っています。serverlessを使うと、設定ファイルに数行追加するだけでcronを設定できます。serverlessがないとLambdaを使う気が起きないぐらいに浸かってしまっているので、少し怖いです。
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
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)
}
}
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で実行するように設定します。デプロイのときにPythonの環境が必要になるので、Dockerのイメージは、Python/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:
- ./*
{
"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
{
"scripts": {
"test:unit": "jest -c jest.config.unit.js test/unit",
"test:e2e": "jest -c jest.config.e2e.js test/e2e"
}
}
ユニットテスト
料金の算出を行うサイトなので、計算部分をメインにテストしています。
UIの挙動や表示は、E2Eテストで最低限の確認をしているので、コンポーネントのテストはしていません(面倒だったので)。
多少の表示崩れは許容できても、計算の間違いはなるべく起こさない方向で。
計算のテスト
AWSの各サービスの料金計算などの大切な処理は、Vuexから切り離しているため、関数単体でテストしています。
例として、ElastiCacheの場合は、計算がシンプルなのでこんな感じです。
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
}
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の外に切り離し、VuexでやることをStateの管理に絞っています。
Vuexのテストは、あまり良い方法がわからなかったので、テストのたびにVuexのStoreを生成して、dispatch
やcommit
をしたあとのstate
を確認しています。気合いだ。
mutations
は、関数としてテストをすることもできますが、しっかりと返り値があるわけではなく、引数のstateを変更する形なので、いっそcommit
した後の値を確認してしまった方がわかりやすいかなと思い、こんな形になりました。
import Vuex from 'vuex'
const store = () =>
new Vuex.Store({
state: {
error: {
isVisible: false
}
},
mutations: {
SHOW_ERROR(state) {
state.error.isVisible = true
}
}
})
export default store
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の設定ファイルで、事前にサーバーを起動するコマンドを指定できます。
module.exports = {
server: {
command: 'yarn serve',
port: 8888
}
}
{
"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()
})
// コードは雰囲気です!
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では、ジョブが途中で失敗した場合、後続のジョブは実行されません。なので、更新したデータがおかしい場合や、計算のロジックが誤っている場合はデプロイされず、テストに通ったコードが常に公開されている状態になります。
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の方でご確認ください。