LoginSignup
5

More than 5 years have passed since last update.

Node.jsでPrice List APIからAWSのサービス利用料を取得して、整形したものをS3に保存する

Last updated at Posted at 2018-03-19

きっかけ

SIMPLE MONTHLY CALCULATORよりもシンプルにざっくりと、AWSで月に何円かかるのかを計算できるサイトを作りたいと思い、まずはPrice List APIを試しました。サイトにアクセスする度にAPIを叩くのは無駄が多いので、事前に整形して軽量化したJSONをS3に保存しておきます。

Price List APIの現状

これまで、AWS Price List API は、リージョンおよび AWS のサービス レベルでのみ製品の料金情報を返していましたが、幅広い製品ディメンション全体でフィルタリングした料金情報を返すように拡張されました。

AWS Price List API を使用した詳細な製品の料金情報へのアクセス

以前に試したときは、エンドポイントを叩いたら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」がアタッチされていれば大丈夫そうです。

AWS CLI の設定

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サイトも良いです。

price.png

LambdaでS3にJSONを保存

serverlessのインストール

Lambdaの環境をserverlessで整えます。初めてのserverless & lambdaでしたが、ドキュメントのとおりにやればできました。serverlessの人すごい。

$ npm install -g serverless

getting-started | serverless

認証情報の設定

Note: In a production environment, we recommend reducing the permissions to the IAM User which the Framework uses.

Creating AWS Access Keys | serverless

本番環境では権限を使うやつだけに制限して〜とあったのですが、手っ取り早く動かすことを優先して「AdministratorAccess」をアタッチしたIAMユーザーを作成して設定しました。ここでハマりたくなかったので(小声)。

serverlessの使い方

コマンドに --help オプションをつけると使い方を確認できます。

$ serverless create --help

Lambdaのテンプレートを作成

$ serverless create --template aws-nodejs --path aws-rough-batch

コンフィグの設定

バケットを動的に切り替えるいい感じの方法がわからなかった・・・ので環境変数用のymlを別で作成しました。

serverless.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
serverless.env.yml
BUCKET_NAME: 'aws.noplan.cc'

Node.js 6.10(2018/3/18現在)

作成時点ではLambdaで使える最新のバージョンが6.10でした。async/awaitオブジェクトのスプレッド演算子などは使えませんが、Webpackのプラグインをいれると設定でハマりそうな予感がしたので、そのまま書くことにしました。

node.green

ハンドラーを作成

Lambdaから呼び出すハンドラーを作成します。Price List APIをSDKから叩いて情報を取得して、整形したJSONをS3へアップロードします。

handler.js
'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 しか用意されておらず、記述が冗長なのでシンプルにしました。

lib/services/ec2.js
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'] })
  }
}
lib/services/rds.js
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 を設定して繰り返し取りに行きます。

lib/getPrices.js
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() に渡して、結果をオブジェクトに戻します。泥臭い・・・

lib/getPrices
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へ保存するだけになりました。

handler.js
'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設定が楽だったので、少々の無駄はありますが毎日取得するように設定しました(手抜き)。

通知の設定

serverless.yml
functions:
  price:
    handler: handler.price
    events:
      - schedule: cron(0 1 * * ? *)

lambda.png

Lambdaの管理画面で設定を確認できます。

結果

一括だとEC2だけで15MB前後あったJSONを、欲しいサービスの最小限の項目に絞って20KBまで軽量化できました。

price.json
{
  "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の料金を「ざっくり」計算できるサイトを作るぞ

ソース

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5