きっかけ
SIMPLE MONTHLY CALCULATORよりもシンプルにざっくりと、AWSで月に何円かかるのかを計算できるサイトを作りたいと思い、まずはPrice List APIを試しました。サイトにアクセスする度にAPIを叩くのは無駄が多いので、事前に整形して軽量化したJSONをS3に保存しておきます。
Price List APIの現状
これまで、AWS Price List API は、リージョンおよび AWS のサービス レベルでのみ製品の料金情報を返していましたが、幅広い製品ディメンション全体でフィルタリングした料金情報を返すように拡張されました。
以前に試したときは、エンドポイントを叩いたらMB超えのJSONが降臨する、控えめに言ってクソめんどくさいAPIだったと記憶しています。今はフィルターに対応していたりSDKがあったりしたので、当初と比べるとかなり楽に扱えそうです。AWSの人すごい。
MB超えのJSON
https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonEC2/current/ap-northeast-1/index.json
AWS SDK for JavaScript
$ node -v # v8.9.4
SDKのインストール
npm install aws-sdk
認証情報の設定
AWSの認証情報が設定されていない場合は、公式を参考に設定してください。IAMのポリシーは「AWSPriceListServiceFullAccess」がアタッチされていれば大丈夫そうです。
Class: AWS.Pricing
SDKが使えるようになったおかげで、curlしてAPIを叩かなくて済むようになりました。
const AWS = require('aws-sdk')
const pricing = new AWS.Pricing({
region: 'us-east-1'
})
// サービスの一覧と属性取得
pricing.describeServices(
{},
(err, data) => console.log(data)
)
// サービスと属性を指定してを指定して値の一覧を取得
pricing.getAttributeValues(
{ ServiceCode: 'AmazonEC2', AttributeName: 'volumeType' },
(err, data) => console.log(data)
)
// 価格などの詳細を取得
pricing.getProducts(
{ ServiceCode: 'AmazonEC2' },
(err, data) => console.log(data)
)
describeServices
サービスの一覧を取得する。後で必要になるサービス名や属性を確認できます。
pricing.describeServices(
{},
(err, data) => console.log(data)
)
{
Services:[
{
ServiceCode:'AmazonS3',
AttributeNames:[
'productFamily',
'volumeType',
'durability',
...
]
},
{
ServiceCode:'AmazonS3',
AttributeNames:[]
},
...
]
}
getAttributeValues
サービス名と属性を指定して値の一覧を取得できます。
pricing.getAttributeValues(
{ ServiceCode: 'AmazonEC2', AttributeName: 'volumeType' },
(err, data) => console.log(data)
)
{
AttributeValues: [
{ Value: 'General Purpose' },
{ Value: 'Magnetic' },
...
]
}
getProducts
一番欲しいやつです。価格やサービスの詳細を取得できます。パラメーターにフィルターを設定すると、結果を絞り込めます。
pricing.getProducts(
{ ServiceCode: 'AmazonEC2' },
(err, data) => console.log(data)
)
{
PriceList: [
{
serviceCode: 'AmazonEC2',
product: {
productFamily: 'Compute Instance',
attributes: {
enhancedNetworkingSupported: 'Yes',
memory: '30.5 GiB',
location: 'US West (Oregon)',
...
}
},
terms: {
OnDemand: {
'223BX6UNNB3JE9ET.JRTCKXET': {
priceDimensions: {
{
'223BX6UNNB3JE9ET.JRTCKXETXF.6YS6EN2CT7': {
unit: 'Hrs',
endRange: 'Inf',
description: '$0.366 per On Demand SUSE r4.xlarge Instance Hour',
appliesTo: [],
rateCode: '223BX6UNNB3JE9ET.JRTCKXETXF.6YS6EN2CT7',
beginRange: '0',
pricePerUnit: {
USD: '0.3660000000'
}
}
}
}
}
},
Reserved: {
'223BX6UNNB3JE9ET.HU7G6KETJZ': {
priceDimensions: {
}
},
...
}
}
},
...
]
}
EC2の欲しい項目だけ取得して整形
Tokyo/Amazon Linux/一般的な目的/共有のインスタンスに絞って、インスタンスタイプと属性とオンデマンドの価格を取得してます。価格がかなり底の方にいるので、深く潜って力技で取得しました。
const AWS = require('aws-sdk')
const pricing = new AWS.Pricing({ region: 'us-east-1' })
pricing.getProducts(
{
ServiceCode: 'AmazonEC2',
Filters: [
{
Field: 'location',
Type: 'TERM_MATCH',
Value: 'Asia Pacific (Tokyo)'
},
{
Field: 'operatingSystem',
Type: 'TERM_MATCH',
Value: 'Linux'
},
{
Field: 'currentGeneration',
Type: 'TERM_MATCH',
Value: 'Yes'
},
{
Field: 'instanceFamily',
Type: 'TERM_MATCH',
Value: 'General purpose'
},
{
Field: 'tenancy',
Type: 'TERM_MATCH',
Value: 'Shared'
}
]
},
(err, data) => {
if (err) {
console.log(err)
return
}
const result = data.PriceList.map(item => {
const { product: { attributes }, terms: { OnDemand } } = item
const { instanceType } = attributes
const { priceDimensions } = OnDemand[Object.keys(OnDemand)[0]]
const price = priceDimensions[Object.keys(priceDimensions)[0]].pricePerUnit.USD
return {
instanceType,
price,
attributes
}
})
console.log(result)
}
)
// TODO: あとでソートする
[
{ instanceType: 'm4.16xlarge', price: '4.1280000000', attributes: {}},
{ instanceType: 'm4.2xlarge', price: '0.5160000000', attributes: {}},
{ instanceType: 'm3.medium', price: '0.0960000000', attributes: {}},
{ instanceType: 'm4.large', price: '0.1290000000', attributes: {}},
{ instanceType: 't2.micro', price: '0.0152000000', attributes: {}},
{ instanceType: 'm3.large', price: '0.1930000000', attributes: {}},
{ instanceType: 'm4.xlarge', price: '0.2580000000', attributes: {}},
{ instanceType: 't2.medium', price: '0.0608000000', attributes: {}},
{ instanceType: 't2.nano', price: '0.0076000000', attributes: {}},
{ instanceType: 't2.2xlarge', price: '0.4864000000', attributes: {}},
{ instanceType: 'm3.xlarge', price: '0.3850000000', attributes: {}},
{ instanceType: 'm4.10xlarge', price: '2.5800000000', attributes: {}},
{ instanceType: 'm3.2xlarge', price: '0.7700000000', attributes: {}},
{ instanceType: 't2.large', price: '0.1216000000', attributes: {}},
{ instanceType: 'm4.4xlarge', price: '1.0320000000', attributes: {}},
{ instanceType: 't2.small', price: '0.0304000000', attributes: {}},
{ instanceType: 't2.xlarge', price: '0.2432000000', attributes: {}}
]
フィルターに使う属性の調べ方
フィルターに使う属性は、JSON ViewerというWebサイトで整形したJSONを見ながら、地道に選びました。ある程度構造がわかっているJSONを操作するにはjqのようなコマンドが使いやすいですが、未知のJSONから欲しい情報を探すには、Webサイトも良いです。
LambdaでS3にJSONを保存
serverlessのインストール
Lambdaの環境をserverlessで整えます。初めてのserverless & lambdaでしたが、ドキュメントのとおりにやればできました。serverlessの人すごい。
$ npm install -g serverless
認証情報の設定
Note: In a production environment, we recommend reducing the permissions to the IAM User which the Framework uses.
本番環境では権限を使うやつだけに制限して〜とあったのですが、手っ取り早く動かすことを優先して「AdministratorAccess」をアタッチしたIAMユーザーを作成して設定しました。ここでハマりたくなかったので(小声)。
serverlessの使い方
コマンドに --help
オプションをつけると使い方を確認できます。
$ serverless create --help
Lambdaのテンプレートを作成
$ serverless create --template aws-nodejs --path aws-rough-batch
コンフィグの設定
バケットを動的に切り替えるいい感じの方法がわからなかった・・・ので環境変数用のymlを別で作成しました。
service: aws-rough-batch
provider:
name: aws
runtime: nodejs6.10
region: ap-northeast-1
profile: ${opt:profile, self:custom.defaultProfile}
environment:
BUCKET_NAME: ${self:custom.BUCKET_NAME}
iamRoleStatements:
- Effect: Allow
Action:
- "pricing:*"
Resource: "*"
- Effect: "Allow"
Action:
- "s3:*"
Resource: "arn:aws:s3:::${self:custom.BUCKET_NAME}/*"
custom:
defaultProfile: default
BUCKET_NAME: ${file(./serverless.env.yml):BUCKET_NAME}
functions:
price:
handler: handler.price
BUCKET_NAME: 'aws.noplan.cc'
Node.js 6.10(2018/3/18現在)
作成時点ではLambdaで使える最新のバージョンが6.10でした。async/awaitやオブジェクトのスプレッド演算子などは使えませんが、Webpackのプラグインをいれると設定でハマりそうな予感がしたので、そのまま書くことにしました。
ハンドラーを作成
Lambdaから呼び出すハンドラーを作成します。Price List APIをSDKから叩いて情報を取得して、整形したJSONをS3へアップロードします。
'use strict'
const AWS = require('aws-sdk')
const s3 = new AWS.S3({ region: 'ap-northeast-1' })
const pricing = new AWS.Pricing({ region: 'us-east-1' })
const BUCKET_NAME = process.env.BUCKET_NAME
exports.price = (event, context, callback) => {
pricing.getProducts(
{
ServiceCode: 'AmazonEC2',
Filters: [
{
Field: 'location',
Type: 'TERM_MATCH',
Value: 'Asia Pacific (Tokyo)'
},
{
Field: 'operatingSystem',
Type: 'TERM_MATCH',
Value: 'Linux'
},
{
Field: 'currentGeneration',
Type: 'TERM_MATCH',
Value: 'Yes'
},
{
Field: 'instanceFamily',
Type: 'TERM_MATCH',
Value: 'General purpose'
},
{
Field: 'tenancy',
Type: 'TERM_MATCH',
Value: 'Shared'
}
]
},
(err, data) => {
if (err) {
return callback(err)
}
const result = data.PriceList.map(item => {
const { product: { attributes }, terms: { OnDemand } } = item
const { instanceType } = attributes
const { priceDimensions } = OnDemand[Object.keys(OnDemand)[0]]
const price =
priceDimensions[Object.keys(priceDimensions)[0]].pricePerUnit.USD
return {
instanceType,
price,
attributes
}
})
s3.upload(
{
Bucket: BUCKET_NAME,
Key: 'json/price.json',
Body: JSON.stringify(result)
},
(err, data) => {
if (err) {
return callback(err)
}
callback(null, 'success')
}
)
}
)
}
デプロイして実行
# デプロイ -v 詳細を表示
serverless deploy -v
# 関数を実行 -l ログを表示
serverless invoke -f price -l
これで、指定したバケットの /json/price.json
に整形した価格のJSONが出力されました。
様々なサービスに対応する
今はEC2だけをゴリゴリとフィルターしているので、他のサービスを拡張しやすい形に変更します。
各サービス用のファイルを作る
価格の取得に必要な情報は、フィルターなどのパラメータとパースする関数なので、それだけ別ファイルに切り出します。
フィルターのタイプが、今は TERM_MATCH
しか用意されておらず、記述が冗長なのでシンプルにしました。
module.exports = {
instance: {
params: {
ServiceCode: 'AmazonEC2',
Filters: {
location: 'Asia Pacific (Tokyo)',
operatingSystem: 'Linux',
currentGeneration: 'Yes',
instanceFamily: 'General purpose',
storage: 'EBS only',
tenancy: 'Shared'
}
},
parse: priceList => parseInstances(priceList, { index: 0, order: ['t', 'm'] })
}
}
module.exports = {
instance: {
params: {
ServiceCode: 'AmazonRDS',
Filters: {
location: 'Asia Pacific (Tokyo)',
currentGeneration: 'Yes',
instanceFamily: 'General purpose',
databaseEngine: 'MySQL',
storage: 'EBS only',
deploymentOption: 'Single-AZ'
}
},
parse: priceList => parseInstances(priceList, { index: 1, order: ['t', 'm'] })
},
storage: {
params: {
ServiceCode: 'AmazonRDS',
Filters: {
location: 'Asia Pacific (Tokyo)',
usagetype: 'APN1-RDS:GP2-Storage',
deploymentOption: 'Single-AZ'
}
},
parse: priceList => parseFirstPrice(priceList[0])
}
}
価格を取得する
一度に取得できるのは最大で100件のようなので、それ以上ある場合は NextToken
を設定して繰り返し取りに行きます。
const getPrice = (pricing, service) =>
new Promise((resolve, reject) => {
const fetchPrice = (params, arr) => {
pricing.getProducts(
Object.assign(params, { Filters: formatFilters(params.Filters) }),
(err, data) => {
if (err) {
return reject(err)
}
const { PriceList, NextToken } = data
const priceLists = arr.concat(PriceList)
if (NextToken) {
fetchPrice(
Object.assign(params, { NextToken: NextToken }),
priceLists
)
} else {
resolve(service.parse(priceLists))
}
}
)
}
fetchPrice(service.params, [])
})
各サービスの価格を取得する
各サービスのパラメーターを設定したオブジェクトを、平たい配列にして Promice.all()
に渡して、結果をオブジェクトに戻します。泥臭い・・・
const getPrices = (pricing, services) =>
new Promise((resolve, reject) => {
const kv = separate(services)
Promise.all(kv.v.map(v => getPrice(pricing, v)))
.then(data => resolve(combine(kv.k, data)))
.catch(err => reject(err))
})
ハンドラーの整理
各サービスのパラメーターと取得する処理を切り出したので、ハンドラーではS3へ保存するだけになりました。
'use strict'
const AWS = require('aws-sdk')
const s3 = new AWS.S3({ region: 'ap-northeast-1' })
const pricing = new AWS.Pricing({ region: 'us-east-1' })
const services = require('./lib/services')
const getPrices = require('./lib/getPrices')
const BUCKET_NAME = process.env.BUCKET_NAME
exports.price = (event, context, callback) => {
getPrices(pricing, services)
.then(data => {
s3.upload(
{
Bucket: BUCKET_NAME,
Key: 'json/price.json',
Body: JSON.stringify(data)
},
err => {
if (err) {
return callback(err)
}
callback(null, 'success')
}
)
})
.catch(err => callback(err))
}
cronの設定
SNSで料金の変更通知を受け取れるようですが、serverlessでのcron設定が楽だったので、少々の無駄はありますが毎日取得するように設定しました(手抜き)。
functions:
price:
handler: handler.price
events:
- schedule: cron(0 1 * * ? *)
Lambdaの管理画面で設定を確認できます。
結果
一括だとEC2だけで15MB前後あったJSONを、欲しいサービスの最小限の項目に絞って20KBまで軽量化できました。
{
"ec2": {
"instance": [
{
"instanceType": "t2.nano",
"price": 0.0076,
"attributes": {
"memory": "0.5 GiB",
"vcpu": "1",
"...": "..."
}
},
{}
]
},
"elb": {
"instance": 0.027,
"transfer": 0.008
},
"ebs": {
"gp2": 0.12
},
"s3": {
"storage": [
{
"beginRange": 0,
"endRange": 51200,
"price": 0.025
},
{
"beginRange": 51200,
"endRange": 512000,
"price": 0.024
},
{}
]
},
"rds": {
"instance": [
{
"instanceType": "db.t2.micro",
"price": 0.026,
"attributes": {
"engineCode": "2",
"memory": "1 GiB",
"vcpu": "1",
"...": "..."
}
},
{}
],
"storage": 0.138
},
"lambda": {
"request": {
"price": 2e-7,
"free": 1000000
},
"memory": {
"price": 0.0000166667,
"free": 400000
}
},
"transfer": {
"out": [
{
"beginRange": 0,
"endRange": 1,
"price": 0
},
{
"beginRange": 1,
"endRange": 10240,
"price": 0.14
},
{}
]
}
}
このあと
シンプルな価格のJSONを作成できたので、この価格をもとに、ざっくり料金を計算できるサイトのフロントエンドを作っていく予定です。
※フロントエンド作りました
AWSの料金を「ざっくり」計算できるサイトを作るぞ