はじめに
静岡でWeb開発をしているkazuomatzです。東京都とCode for Japanが開発して、そのソースコードを基に全国に展開されている「新型コロナウイルス感染症対策サイト(StopCovid19)」。静岡市でも4月23日より公開していますが、僕も開発と運営のお手伝いをさせていただいております。
StopCovid19は、JavaScriptでWebアプリケーションのユーザーインタフェイスを構築するためのVue.js、そのVue.jsを使ってWebアプリケーションを構築するためのフレームワークであるNuxtJsで開発されています。また、Netlifyという静的Webコンテンツを配信するホスティングサービスを使用し、GitHubでのオープンな開発、GitHub Actionsを使ったCI/CD(継続的なインテグレーションとデリバリー)といった開発環境は、地方でWeb開発しているエンジニアにもの凄い刺激を与えてくれています。
僕も、GitHubでソースコードをいろいろ眺めましたが、いろいろな発見と今まで行って来なかったアプローチを目の当たりにしてとても勉強になりました。
例えば、自分的にはこんなところが。
- ESLintやPrettierを導入した厳格な構文チェック(きちんとした開発では当たり前なのかもしれないけど・・)
- Transifexを利用したオープンな翻訳による多言語対応
- グラフをSNSなどでShareしたときに表示されるOGPイメージをGitHub Actionsで生成
- 謎のUTCでの8時のタイムスタンプ(2020-03-26T08:00:00.000Z)
このサイトは、多くのページビューが予想されるため、静的サイトでのホスティングという方針になっており、NuxtJSで開発されたソースコード、アセットをビルドして静的コンテンツとしてホスティングされています。データベースが存在して、SQLでデータを検索して動的にページを表示するような作りではありません。
Netlifyを選択したのも、Netlifyが静的なWebコンテンツを高速に配信が可能で、GitHubと連携したCI/CDが実現できるサービスだからだと思います。そのため、多くの自治体のStopCovid19のサイトは、Netlifyでホスティングされています。
静岡市のStopCovid19サイトについて
静岡市では、AWSの自治体向けのCOVID-19の情報発信サイトの支援プログラムを利用させて頂き、AWSで構築しました。
AWSで静的コンテンツのホスティングを行うには、S3とCloud Frontで実現できます。また、S3へのデプロイは、GitHub ActionsでAWS SDKを使って行うことができるため、GitHubの本番用ブランチのマージをトリガーに本番用のS3バケットにビルドコンテンツを転送することで可能です。
GitHub ActionsでのDeployのワークフローはこんな感じ。
name: Production Auto Deploy to AWS S3
on:
push:
branches:
- production-shizuoka
jobs:
deploy:
name: Deploy Production
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-1
- name: Build
run: |
echo "GOOGLE_ANALYTICS_ID=${GOOGLE_ANALYTICS_ID}" >> .env.production
cat .env.production
yarn install
yarn run generate:deploy --fail-on-page-error
env:
GOOGLE_ANALYTICS_ID: ${{ secrets.GOOGLE_ANALYTICS_ID }}
- name: Copy to s3
run: |
aws s3 cp ./dist ${{ secrets.AWS_SECRET_COVID_S3_PRODUCTION_PATH }} --recursive --acl public-read
- name: Invalidation CloudFront File Paths
run: |
aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_CLOUDFRONT_ID }} --paths "/*"
本番用Branchからソースをチェックアウトして、ビルドを実行し、ビルドされたファイルをS3に転送。その後、Cloud FrontのInvalidation(キャッシュクリア)を実行しています。
また、静岡市はCKANの静岡市オープンデータカタログサイトに「陽性患者属性」「検査実施人数」「コールセンター相談件数」を登録して公開しているので、CKANのAPIを利用してオープンデータを取得して、グラフの描画やデータ表示に必要なデータファイルいわゆる「data.json」生成しています。
ここは、Rubyでプログラムを書いて、AWSのLambdaに登録し、API Gatewayで呼び出す仕組みをSAM(Serverless Application Model)で構築しました。また、サイトの上部に表示するお知らせ情報は、Google Spread Sheetに登録してもらい、それを同様にLambdaから呼び出すプログラムを作成しました。
このあたりのCKANのデータの取り出し方や、API Gatewayの構築のノウハウは、浜松市のStopCovid19サイトの開発に参加されている@w2or3wさんのエントリーを参考にさせていただきました。浜松市は静岡県のオープンデータカタログ「ふじのくにオープンデータカタログ」にデータを登録しています。静岡県も静岡市も同じCKANベースのシステムを使っているので、このソースをそのまま使えるかなと思ったのですが、APIの仕様が微妙に違っていたり、提供データの種類も違っていたので、慣れないPythonで追加の処理を書くのが辛く、Rubyに逃げました・・・。
そして以下のような構成で、4月23日に静岡市公式としてサイトを公開しました。静岡市の職員の方、地元のWeb開発会社、有志のエンジニアの皆さんに協力してもらいながら約1週間の構築期間でした。
運用の悩み
このような構成で1日1回、データを更新しながらの運用が始まりました。データ更新の流れとしては、以下のような感じです。
- 静岡市の職員さんがデータを取りまとめ、オープンデータカタログサイトにデータをアップロード
- アップロードの連絡を頂き、開発者サイドで、データ生成のプログラムを実行し、data.jsonを作成、GitHubの開発ブランチにPush、ステージング用ブランチにマージ
- 自動でステージングサイトにソースがデプロイされるので(所要時間3分程度)、デプロイが完了したら、内容を市の職員さんに確認してもらう。
- OKであれば、本番用ブランチにマージ、自動的に本番サイトへデプロイ(所要時間3分程度)
うまく行けば、全部で10分くらいの作業なのですが、データの修正が入ったり、何らかのエラーでデータの生成の失敗も発生し、再アップを何度か繰り返す場面もありました。
市の職員さんもこのコロナ禍の中、いろいろな対応に追われ、データが更新時刻も不定期になりがちです。関係者がお互いの姿が見えない中、データ更新の作業を進めているので、この所要時間の3分がとても長く感じたりもしました。やはりこのデータ更新の運用をもっと楽にしないと、市の職員さんも開発者サイドも、今後サイトを運営していくのが辛いなと思い始めました。
data.jsonを切り離す
さて、ここからが本題です。データ更新の運用をもっと楽にしよう。それが次のミッションになりました。できれば、オープンデータの更新が終わったら、関係者の誰もが数クリックでサイトを更新できる仕組みを作れば運用が楽になるのではと思いました。
もともとこのサイトの設計では、グラフや数値データの基となるデータファイル「data.json」は、Webコンテンツの一部として扱われています。つまり、静的な画像ファイルなどと同じようにNuxt GenerateでWebPackによりパッケージ化されWebサイトに配置されます。
つまり、オープンデータが更新されたら、data.jsonを作成し、data.jsonを含め、毎回すべてのソースをビルドしてWebサイトにアップする仕組みなのです。例えdata.jsonだけが変更されたとしても、すべてのソースファイルのビルド処理が走ることになります。
このような仕組みにした理由は、以下のようなことなのではないかと思います(もしほかの理由があれば教えていただければと思います)。
- 公開当初の段階においては、継続的な改修とデプロイが繰り返されているため、デプロイと
データ更新のタイミングを合わせてサイトの更新を計画することができた。 - 多くの人に見られるサイトであり、データには正確さが求められる。データに間違いがあった場合を想定し、目視による確認の工程を入れる必要があった。
- データ生成に失敗したらサイト自体が表示されない可能性もある。正しいデータが生成されたことを確認してからデプロイしたい。
- ページロードする毎にajaxでデータを取得するためパフォーマンス的に表示の遅延などが予想される。
これはこれで、様々な選択肢の中から、様々な事情を踏まえて判断されたのだと思います。
ただ、静岡市の場合、サイトの公開も4月末ということもあって、すでに必要充分な機能が実装されており、プログラム改修の頻度もそんなに多くならないことが見込まれたこと、また、data.jsonのファイルサイズは数十KB程度であるため(東京は1.4MBくらいになっている)、データの遅延もあまり気にしなくてよく、むしろ、大きな課題はデータ更新の部分のみと言えました。
課題を解決する方法としては、非常にシンプルです。
- data.json、news.jsonをビルドに含めず、外部リソースとして切り離し、ajaxで取得して取り込むようプログラム改修をする。
- data.json、news.jsonをS3のバケットに配置する。データを生成したら、S3のバケットに転送するデータ更新用のWebアプリ開発する。
ただし、データの間違いやデータ不正が発生する場合を考慮し、データの外部リソースの置き場をステージング用と本番用の2つを用意して、一旦ステージング用のバケットにデータを転送し、ステージング用サイトで確認を行った後、本番用のバケットにデータを転送することとしました。
このことにより、データ更新はS3のバケットにdata.jsonとnews.jsonを転送するだけとなり、毎回ソースのビルドは必要なくなり、データ更新時のGitHubのマージも不要なので、静岡市の職員さんがオープンデータの更新を行った後に、データ更新用のWebアプリを操作することでサイトが更新されることになります。
データ更新用Webアプリイメージ
* データ更新Webアプリは、関係者のみしかアクセスできない仕組みを入れて、スマートホンから操作できるようにしています。
変更後の運用のイメージはこんな感じになります。
プログラム修正
data.json,new.jsonを切り離すために行った改修内容をまとめます。こちらの改修は、@tomof、@hota1024 の二人に協力してもらいました。
@nuxt/axiosの追加
ajaxでdata.jsonを取得するため、@nuxt/axiosを追加します。
+ "@nuxtjs/axios": "^5.10.1",
/*
** Nuxt.js modules
*/
modules: [
'@nuxtjs/pwa',
// Doc: https://github.com/nuxt-community/dotenv-module
['@nuxtjs/dotenv', { filename: `.env.${environment}` }],
['nuxt-i18n', i18n],
'nuxt-svg-loader',
'nuxt-purgecss',
['vue-scrollto/nuxt', { duration: 1000, offset: -72 }],
+ '@nuxtjs/axios'
],
storeの追加
Vuexのstoreにajaxで読み込んで保存するモジュールを追加。
import { MutationTree, ActionTree } from 'vuex'
export const state = () => ({
data: {} as any,
news: {} as any
})
export type State = ReturnType<typeof state>
export const mutations: MutationTree<State> = {
setData(state, data) {
state.data = data
},
setNews(state, news) {
state.news = news
}
}
export const actions: ActionTree<State, State> = {
async fetchData({ commit }) {
const dataUrl = process.env.DATA_URL + 'data.json'
const data = await this.$axios.$get<State['data']>(dataUrl)
commit('setData', data)
const newsUrl = process.env.DATA_URL + 'news.json'
const news = await this.$axios.$get<State['news']>(newsUrl)
commit('setNews', news)
}
}
data.jsonとnews.jsonを取得して、storeに保存します。データのURLはprocess.envから読み込みます。
ページ表示時にデータを取得する
- mounted() {
+ async mounted() {
+ await this.$store.dispatch('data/fetchData')
this.loading = false
this.getMatchMedia().addListener(this.hideNavigation)
},
各コンポーネントでのデータ参照を変更
<script>
- import Data from '@/data/data.json'
import formatGraph from '@/utils/formatGraph'
import formatTable from '@/utils/formatTable'
import DataTable from '@/components/DataTable.vue'
export default {
components: {
DataTable
},
data() {
+ // // Vuexからデータを取得
+ const Data = this.$store.state.data.data
// 感染者数グラフ
const patientsGraph = formatGraph(Data.patients_summary.data)
// 感染者数
const patientsTable = formatTable(Data.patients.data)
// (略)
}
他のコンポーネントについても、
import Data from '@/data/data.json'
としている箇所を、
const Data = this.$store.state.data.data
とVuexのstoreを参照するよう変更します。
news.jsonの参照についても、以下のようにVuexのStoreから参照するよう変更します。
// (略)
- import News from '@/data/news.json'
// (略)
export default Vue.extend({
// (略)
data() {
const data = {
Data,
headerItem: {
icon: 'mdi-chart-timeline-variant',
title: this.$t('都内の最新感染動向')
},
- newsItems: News.newsItems
+ newsItems: this.$store.state.data.news.newsItems
}
return data
},
// (略)
}
開発環境では、以下のようにDATAのURLを設定しておきます。開発用にdata.jsonとnews.jsonを static/dataに配置して読み込むようにします。
VUE_AXE=true
+ DATA_URL="/data/"
ステージング環境、本番環境では、このデータURLをGitHub Actionsのワークフローの中で設定してビルドを実行するようにします。
name: Production Auto Deploy to AWS S3
on:
push:
branches:
- production-shizuoka
jobs:
deploy:
name: Deploy Production
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-1
- name: Build
run: |
echo "GOOGLE_ANALYTICS_ID=${GOOGLE_ANALYTICS_ID}" >> .env.production
+ echo "DATA_URL=${DATA_BASE_URL}" >> .env.production
cat .env.production
yarn install
yarn run generate:deploy --fail-on-page-error
env:
GOOGLE_ANALYTICS_ID: ${{ secrets.GOOGLE_ANALYTICS_ID }}
+ DATA_BASE_URL: ${{ secrets.COVID_DATA_PRODUCTION_BASE_URL }}
- name: Copy to s3
run: |
aws s3 cp ./dist ${{ secrets.AWS_SECRET_COVID_S3_PRODUCTION_PATH }} --recursive --acl public-read
- name: Invalidation CloudFront File Paths
run: |
aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_CLOUDFRONT_ID }} --paths "/*"
GitHubのSecretsに "COVID_DATA_PRODUCTION_BASE_URL" を設定しています。これは、S3のファイルをキャッシュしているCloud FrontのURLになります。
まとめ
このような経緯を経て、静岡市のStopCovid19サイトの運用を変更しました。今までのデータ更新では、オープンデータを更新してから、最終的にサイトが更新されるまで、数名がかりで30分程度かかっていましたが、この運用に変えたことでデータの更新がわずか1分程度で行えるようになりました。
東京都、Code for Japanのみなさんが開発して、全国に広がりを見せているStopCovid19。このような素晴らしいプロダクトが日本で生まれたことはとても喜ばしく、僕たち地方のエンジニアにもの凄い刺激を与えてくれたと思います。本当に感謝です。
また、それぞれの地域のみなさんが、それぞれの自治体や地域の状況に合わせてシステムの改良をしていると思います。僕たちの書いたコードが少しでも社会の役に立つのであれば、こんなうれしいことはありません。
追記
data.jsonの中の日付が謎の「UTC時刻の08:00」となっている件、解ったときはニヤッとしてしまいました。
export default (data: DataType[]) => {
const graphData: GraphDataType[] = []
const today = new Date()
let patSum = 0
data
.filter(d => new Date(d['日付']) < today)
.forEach(d => {
const date = new Date(d['日付'])
const subTotal = d['小計']
if (!isNaN(subTotal)) {
patSum += subTotal
graphData.push({
label: `${date.getMonth() + 1}/${date.getDate()}`,
transition: subTotal,
cumulative: patSum
})
}
})
return graphData
}
現在日時より前の日付のデータをFilterしています。UTCで08:00は、日本時刻で17:00なので、17:00を過ぎるとその日のデータが表示されるようになっています。