DynamoDB + Lambda + CloudFront + S3でシンプルなウェブサービスを作る話
少し前の話になりますが,お正月に「名言が書かれたおみくじを引く」というような簡単なウェブサービスを個人で作りました.
その作成経緯について簡単にまとめてみたのでもし良ければ読んでみてください.
先にそのウェブサイトを見たいという方はこちらをご覧ください 🔮
モチベーション
1.作りたいプロダクトがあった
去年(2020年)のお正月に,名言が書かれた”おみくじ”を対面で配っていたのだけれど,今年(2021年)もみんなで名言おみくじをやりたかった.Webで作ればリモートでもみんなでできると思った.
2.技術的な勉強をしておきたかった
- Serverless Frameworkを用いたAWS LambdaのIaC管理
- AWSでのサーバーレスAPIの構築
- DynamoDB
- CloudFront + S3でのホスティング
- Nuxt/Typescriptの勉強
作ったもの
ボタンを押すと名言が1個でてくるおみくじ的なウェブサイト
名言は英語と日本語に対応しており,それぞれ300個くらいの候補からランダムに1つ出てくる
作り始める時点でのこだわり
- CI/CDを構築して開発に集中できるものにする
- 最初から多言語対応しておく
作った順序
- AWSでドメインを購入
- とにかく
create nuxt-app
しただけのフロントのコードをS3にデプロイできるCDを構築 - CloudFront+S3で外部アクセス可能なCDNをAWSコンソールで構築
- Lambda+DynamoDBをコード管理するServeless Frameworkを作成
- 外部アクセス可能なAPIエンドポイントをAWSコンソールで構築
- API側の実装(日本語)
- モック作成
- フロント側の実装(日本語)
- 多言語化(API, Client)
- エラー監視,GA導入
いかんせん不慣れな部分が多かったため,とにかく最初は実際にアクセスできることを担保したくてフロントもAPIも中身はHello World状態のコードをAWSにデプロイできることを目指しました.そのあとAPIの中身を作成して,エンドポイントができたらフロント側を作成するという手順です.
ただ,ブログの執筆という点では,APIの構築とフロントの作成に分けて書いた方が読みやすそうなので,以下ではそれぞれの作成手順を記載します.
APIの構築
DynamoDB,Lambda,API GatewayでAPIを構成しました.作ったエンドポイントはカスタムドメインとRoute53の経路制御で実際に利用できるようにします.
DynamoDBを選択したのは
- 使ってみたかった
- DBサーバー代が事実上タダ
- マネージドサービス
という理由からです.もちろん,おみくじを引くという機能だけを考えればRDBを使ってもいいですし,そもそもDBなんか持たずフロントに直置きしたって大丈夫です.
基本的にはServerless Frameworkが出している以下のブログを参考にします.
Building a REST API in Node.js with AWS Lambda, API Gateway, DynamoDB, and Serverless Framework
blogになかった部分も多いのですが,主に以下のような点は別途いろいろ調べながら作りました.
- DynamoDBに対するBatchWrite
- シードファイルを置いたS3へのアクセス権限やS3リソースのIaC管理
- REST APIではなくHTTP APIを利用
- ランダムに1個レコードを取得するロジック
DB構築
DynamoDBはAWSのセッション動画が秀逸です.NoSQLのテーブル構成はアクセスパターンを元に考えるのがいいとされています.
今回の場合アクセスパターンは基本的に1つです.
- ランダムに1つのレコードを取る
- ただし言語の指定がある
どうやってランダムに1つ取るかは今回のロジックの中で一番クセのある問題でした
- 1回目にTotalレコード数を問い合わせて2回目にint型のIDを指定する?
- 対象とするレコードは1個のpartitionに固めておいて別の方法でランダムに一つとる?
ここでFull stack over flowしたのでググります
AWS DynamoDB - Pick a record/item randomly?
いいの発見,要するに,PartitionKeyに母集団を指定して,SortKeyに文字列ID(UUID)の比較演算を入れてマッチ1件を取るという仕組みにするとうまくいくらしい
というわけで
Partition Keyを言語 lang
Sort KeyをID uuid
このように設計しました.IDをPartitionKeyにしてGSIを作っておくとかも自然な考えだと思いますが,今回はランダムにピックアップするという1アクセスパターンだけなのでやめました.
以下はテーブル定義を書いたserverless.yml
ファイルの中身
# serverless.yml
[...]
resources:
Resources:
QuotesDynamoDbTable:
Type: 'AWS::DynamoDB::Table'
Properties:
AttributeDefinitions:
-
AttributeName: "lang"
AttributeType: "S"
-
AttributeName: "id"
AttributeType: "S"
KeySchema:
-
AttributeName: "lang"
KeyType: "HASH"
-
AttributeName: "id"
KeyType: "RANGE"
TableName: ${self:provider.environment.QUOTE_TABLE}
シーディング用のエンドポイント
DynamoDBにデータを入れるためのエンドポイントを作ります.
シードファイルはCSV形式で,S3に自分でアップロードしておきました.
AWSインフラのリソース管理はこんな
# serverless.yml
[...]
provider:
name: aws
runtime: nodejs12.x
stage: dev
region: ap-northeast-1
environment:
QUOTE_TABLE: ${self:service}-${opt:stage, self:provider.stage}1
iamRoleStatements:
- Effect: Allow
Action:
- dynamodb:Query # ランダムで1個取るときに利用
- dynamodb:Scan # 使っていない
- dynamodb:GetItem # 使っていない
- dynamodb:PutItem # 使っていない
- dynamodb:BatchWriteItem # -> seedを流すときにバルクで書き込める
Resource: "*"
- Effect: Allow
Action:
- s3:GetObject # -> seedのCSVファイルを置くS3へのアクセスを許可
Resource:
Fn::Join:
- ''
- - 'arn:aws:s3:::'
- 'Ref': QuotesSeedBucket
- '/*'
Lambdaで動くシーディングの処理はこちら
const seed = (event, context, callback) => {
const onBatchWrite = (err, data) => {
[...] // callbackは省略
};
// CSVをパースして,DBに投げるparamsを生成する
parseCsv().then(async (data) => {
const itemSize = data.length;
const batchSize = 25;
// 25レコードずつしかバルクで書き込みできなかった
for (let j = 0; j * batchSize < itemSize; j++) {
[...]
let subParams = {
PutRequest: {
Item: {
lang: data[i]['lang'],
id: uuidv4(), // uuidはのちに利用する
quote: data[i]['quote'],
whose: data[i]['whose'],
}
}
};
params.RequestItems[process.env.QUOTE_TABLE].push(subParams);
}
// DynamoDBへの書き込み
dynamoDb.batchWrite(params, onBatchWrite);
await sleep(20);
}
});
};
ランダムに1個名言を取るエンドポイント
さて,次はいよいよランダムに名言が1個返ってくるAPIのエンドポイントです.
シーディングのエンドポイントと同様に作っていきます.
Lambdaの処理を一部抜粋します.DynamoDBにクエリを投げている部分です.
const pick = (event, context, callback) => {
// ランダムな値を生成
const randomRangeKey = uuidv4();
// DynamoDBに投げるクエリ
let params = {
TableName: process.env.QUOTE_TABLE,
// 返却フィールドの指定
ProjectionExpression: "quote, whose",
// クエリ条件(idがrandomRangeKeyより大きいものを1つだけ取る)
KeyConditionExpression: "lang = :lang and id > :randomRangeKey",
ExpressionAttributeValues: {
// APIのクエリパラメーターで言語を指定
":lang": event.queryStringParameters.lang || "en",
":randomRangeKey": randomRangeKey
},
Limit: 1
};
const onQuery = (err, data) => {
[...]
};
dynamoDb.query(params, onQuery);
};
deploy
Serverlessが用意してる sls deploy
コマンドでデプロイしました.それをCircleCIで自動化します.大きな問題なくCDは作れると思います.
フロントの作成
Nuxtでアプリケーションを作成し,generateした静的ファイルをS3に保存.
S3のStatic Website Hostingを有効にしてCloudFrontの対象ソースにする,という構成です.
UX,UI
全体のユーザー体験,ユーザーとのインターフェイスについて考えます.
User Experience
- ダイバーシティを受け入れる姿勢:I18nで多言語対応
- 名言は高貴なもの
- 白の余白をたっぷりつけて高級感を出す
- 白色は全て光らせて高級感を出す
- おみくじはみんなに自慢したい
- シェアできるように
- 待つときのドキドキが重要
- あえて待たせる設計に
User Interface
- PANTONEが発表した2021年カラーを採用
- 黄色とグレー色で全体的に明るい仕様にしつつ,操作部が明確にわかるように
- 操作部を背景にそのまま映すと見辛いので白地ボタンに
- メッセージセンテンスは背景にそのまま白文字で記述
- 余計なアフォーダンス(人間がインタラクトできると認知する部分)は入れない
- 最初からシェアボタンは出さない
気を遣った部分
- 美的センスを出したく,美術ポスターのような縦文字をあえてWebに入れた
- ちなみに私は美術の成績は5段階の2です
OGP, favicon
OGPはできれば言語ごとに分けたかったのですが,やり方がすぐに思い付かず,迷ったが英語日本語混じりのものに.
Canvaでいい感じの画像を作成しました(写真は作っていたときの候補)
OGP Checkerで見切れがないかを確認
faviconもCanvaで適当に作りました.
Nuxt
使った主なライブラリは以下のようなもので,至ってシンプルです.
- nuxt-i18n
- axios (API request)
- sentry
- fontawesome
- gtm
- webfontloader
- style-resources
plugin, middlewareは特に使わなず,storeも利用していないのでNuxtの練習というには少し不足です.
コンポーネントディレクトリの構成はこちら
components
├── atoms
│ ├── VButton.vue
│ ├── VButtonFacebook.vue
│ └── VButtonTweet.vue
├── molecules
└── organisms
└── QuoteBox.vue
layouts
└── default.vue
pages
└── index.vue
気を遣った部分
- ボタンを押されてから少し待たせたかったのでAPIのレスポンスをもらっても1秒待機
- CORS問題があったのでローカルではProxyサーバー経由でAPIにアクセス(時間なかったので全環境で本番API叩いていた)
style
基本的にはモック通りに書くだけです.
気を使った部分
- 言語ごとにフォントを変えたかったが,
nuxt generate
時にどうすればよいのかわからず,computedのメソッドで現在のlocaleに応じたfontfamilyを指定 - レスポンシブ(特に文字サイズとパーツの表示・非表示)
- ローディング中のアイコンはCodePenから良さげなものを拝借
- 美的センスがないので個別に細かくスタイルを変えず,徹底的に定義した変数を使う
# _grid.scss
$container-max-width: 1000px;
$distance-xxl: 120px;
$distance-lg: 60px;
$distance-md: 40px;
$distance-sm: 20px;
$distance-xs: 10px;
$font-xl: 36px;
$font-lg: 24px;
$font-md: 18px;
$font-sm: 12px;
$font-xs: 9px;
...
CSSを酷使する経験がないので,カッコよくするために作ったこのモックも実装に苦労した...
deploy
公式ドキュメントに書いてあったとおりにgulpを用いたデプロイを行います.それをCircleCIで自動化しました.
Deploy Nuxt on Amazon Web Services
そのほかいろいろ
- 多言語対応はAPIでも最初から想定した作りにしていたし,フロントもI18nを最初から入れたので負担はほぼなかった
- ただし
font-family
を言語ごとに設定する方法が見当たらず,JSで変更させる作りとなってしまった(一瞬フォントが変わるのが見えてしまうので,修正したい) -
nuxt.config.ts
に書いている meta tagを言語ごとに設定する方法も同様にすぐ見当たらず,とりあえず英語で設定した
- ただし
- できた!と思って家族に触ってもらうとiOSで動かない
- Lambda Testを使ってiOSでの動作をエミュレートする
- Sentryを入れてエラーログを確認する
- 結果的にServerlessのブログに記載されてたCORSの設定では一部のOS/ブラウザでPreflightリクエストに正しくレスポンスできてないことがわかり,修正
- CircleCIのDockerImageのPull制限にかからないようにDockerHubアカウントをつけておく
- シードについては,日本語版は昨年社内で使ったGoogle SheetからCSVに
- 英語のシードについてはこのサイトからCSVを作った.エディタの置換機能をうまく使えば思ったほど時間はかからなかった
1ヶ月の運用費用は56円
もちろんアクセスが少ないからではあるものの,サービス立ち上げっぱなしでこの値段なので,本当に幸せな気持ちになりました.EC2やRDSでは実現できない.
目的 | ツール | cost |
---|---|---|
Database | DynamoDB | ¥0 |
API Server | API Gateway, Lambda | ¥0 |
Client | S3, Cloudfront | ¥1 |
Routing | Route53 | ¥55 |
Design | Figma, Canva | ¥0 |
CI/CD | CircleCI | ¥0 |
Access Analytics | GTM, GA | ¥0 |
Monitoring | Sentry | ¥0 |
Test | Lambda Test | ¥0 |
Total | Total | ¥56 |
実際に出してみて
- 社内では知らないSlackチャンネルで盛り上がってくれたらしい ☺️
- 社内の人が1on1で活用してくれた 🤩
- 「これソースコードの中身出ちゃってませんかw」とエンジニアの先輩に言われる😂
その先輩が指摘したのが,なんと私が美的センスを注入して作ったこちらの部分
これは,,2021年のカラーコードを示しているのですよ.そもそもソースコード間違ってもそれが縦には出てこないっすw
13-0647 Illuminating
17-5104 Ultimate Gray
やり残したこと
年始にみんなに見せたかったので,時間がきてしまい以下はやり残し
- staging/local環境の構築
- composition API
- OGPの動的っぽい生成
- 0.3%の確率で起きるNo Quoteの対応
- API Gateway custom domainもserverlessで管理
- エラーページ
まとめ
- カッコつけてWebで縦に文字を書くのはやめよう
ちなみに完成して私が引いたおみくじはこれでした
どんな強い人間にも弱いところがある。その弱いところが垣間見れた時、その人はより魅力的に見える (オードリー・ヘプバーン) | 2021年の名言
本年も良い1年にしていきましょう.
読んでいただきありがとうございました 🧑🏻🌾🙇♂️
名言おみくじ
Discussion