dely engineering blog

レシピ動画サービス「kurashiru」を運営するdelyのテックブログ

AWSの意図しない料金の上昇に気付く仕組み

はじめに

本記事はSRE 2 Advent Calendar 2018の11日目の記事です。

SRE 2 Advent Calendar 2018 - Qiita

dely Advent Calendar 2018もやっていますので目を通していただけると嬉しいです。クラシルの秘話がたくさん書かれています。

dely Advent Calendar 2018 - Adventar
dely Advent Calendar 2018 - Qiita

こんにちは!delyでSREをやっている井上です。

SREのみなさん!インフラコストの最適化してますか?
delyはどうかというと、正直まだまだ不十分な状況です。。。

クラシルでまだまだやりたいこと・やるべきことがたくさんあり、コスト最適化の優先順位がなかなか上がりにくいのが現状です。

ちなみについ先日クラシルに待望の献立機能がリリースされました!「毎日のメニューを考えるのが大変」という悩みを抱えるSREのみなさん!是非使ってみてください!

prtimes.jp

そんなフェーズのdelyにおいてもインフラコストについて最低限度行っている取り組みがありますので、そちらを紹介しようと思います!

AWSの意図しない料金の上昇に気付く仕組み

delyでは過去にAWSなどの従量課金のサービスの料金が想定以上に増加したことがあり、その対策としてAWSの意図しない料金の上昇に気付くための決まりを作りました。

f:id:gomesuit:20181211104957p:plain:w200

決まりは非常に単純です。

  • 月初めに当月分の料金を見積もる
  • 料金を日々Slackに通知するようにする
  • 上昇に気づいたら決められた内容を調査して報告する

報告内容は

  • 料金の内訳(何の作業でかかっている料金か)
  • 報告時点でかかっている料金
  • かかっている料金がランニングコストなのかイニシャルコストなのか、どちらも含むのか
  • イニシャルコストが合計でいくらかかるか
  • ランニングコストが毎月いくらかかるか

としています。

Slackに通知している内容としては、料金の前日比や対象日時点での見積と実績の比率などです。例えば下記のようなものになります。 f:id:gomesuit:20181210153112p:plain:w400

もちろん上記だけだと、AWSの何の料金が上がっているのか特定することが出来ません。
直前に設定変更を行ったなど自覚があればある程度想定出来るのですが、自覚のないもの(例えば外的要因など)はその都度調査する必要があります。

そのためには上昇した料金に対して、「サービス」毎、「コスト配分タグ」毎、「Usage Type」毎、「API Operation」毎、「リソースID」毎といったようにドリルダウンしていきながら原因を特定できる手段を用意しておく必要があります。

Cost Explorer

AWSにはCost Explorerというコストを表示および分析するためのツールがあります。
(以前は割と使いづらかったのですが、最近UIが改善されて使いやすくなりましたね。)

docs.aws.amazon.com

Cost Explorerを使うことで、特定のサービスに対して「Usage Type」毎や「API Operation」毎や「コスト配分タグ」毎にどれだけ料金がかかっているのか内訳を表示することができます。

ただしCost Explorerでは「リソースID」の指定や「リソースID」毎の集計は出来ません。 そのため例えば下記のような特徴をもったサービスにおいては特定を行うことは難しいのです。

  • Glueなどコスト配分タグ未対応のサービス
  • S3、CloudWatchLogsなどリソースが多くなりがちなサービス

よってリソースIDレベルでの詳細な特定を行うためには、Cost Explorer以外の手段を検討する必要があります。

リソースおよびタグ付きの請求明細レポート

AWSには「リソースおよびタグ付きの請求明細レポート」を出力する機能があります。 「リソースおよびタグ付きの請求明細レポート」はリソースIDとコスト配分タグを含んだ料金明細です。
この料金明細を利用することでリソースID毎の料金を算出することが可能になります。

docs.aws.amazon.com

設定を行うことで任意のS3バケットに定期的に下記のようなオブジェクト名で出力されるようになります。
XXXXXXXXXXX-aws-billing-detailed-line-items-with-resources-and-tags-YYYY-MM.csv

このファイルを直接エディタで開いてリソースID別料金を確認することも理論的には可能ですが、データ量が多すぎるのであまり現実的ではありません。
そのためAthenaを使ってSQLを実行できるようにする必要があります。そのままだとAthenaが認識できないのでGlueのETLジョブ機能を使って前処理を行います。

Glue ETLジョブ

参考に実際に実行しているコードを紹介します。(雑コードですいません)
Glueの実行環境を利用しているだけで変換処理自体は純粋なpysparkで行っています。

import sys
from awsglue.transforms import *
from awsglue.utils import getResolvedOptions
from pyspark.context import SparkContext
from awsglue.context import GlueContext
from pyspark.sql import SQLContext
from awsglue.job import Job
from awsglue.dynamicframe import DynamicFrame
from pyspark.sql.types import *

import boto3
import zipfile
from datetime import datetime, date, timedelta

args = getResolvedOptions(sys.argv, ['JOB_NAME', 'year', 'month'])

date_time = datetime.now() - timedelta(hours=12)

# 定期実行時には実行されたタイミングの年月のレポートを対象に動作するようにしていて、
# パラメータを変更することで任意の年月で実行することも出来るようにしています。
if args['year'] == '9999':
    year = date_time.strftime("%Y")
else:
    year = args['year']

if args['month'] == '99':
    month = date_time.strftime("%m")
else:
    month = args['month']

print('year:' + year)
print('month:' + month)

sc = SparkContext()
glueContext = GlueContext(sc)
job = Job(glueContext)
job.init(args['JOB_NAME'], args)
 
s3 = boto3.resource('s3')

# 出力先のS3バケット名に置き換える
bucket_name = '<リソースおよびタグ付きの請求明細レポートが格納されたS3バケット>'
bucket = s3.Bucket(bucket_name)

# XXXXXXXXXXXを自身のIDに置き換える
csv_name = "XXXXXXXXXXX-aws-billing-detailed-line-items-with-resources-and-tags-%s-%s.csv" % (year, month)
zip_name = csv_name + '.zip'
bucket.download_file(zip_name, 'billing.zip')

# zipファイルのままだとpysparkで処理できないので展開してcsvファイルをアップロードし直しています。
zip_file = zipfile.ZipFile('billing.zip')
filename = zip_file.namelist()[0]
zip_file.extract(filename)
bucket.upload_file(filename, filename)

# glueは使わず純粋なpysparkだけで実行しています。
# RecordTypeがLineItemのものだけを抽出してparquet型で出力しています。
sqlContext =SQLContext(sc)
filename = "s3a://%s/%s" % (bucket_name, csv_name)
df = sqlContext.read.format("com.databricks.spark.csv").option("header", "true").option("inferSchema", "true").load(filename)
df.printSchema()

df2 = df.filter(df.RecordType == 'LineItem').withColumn('InvoiceID', df.InvoiceID.cast(StringType()))
df2.printSchema()

dyf1 = DynamicFrame.fromDF(df2, glueContext, 'dyf1')

prefix = "reports_parquet/year=%s/month=%s" % (year, month)
s3client = boto3.client('s3')

# 前回実行時のparquetファイルを削除しています。
def delete_all_keys(bucket, prefix, dryrun=False):
    contents_count = 0
    next_token = ''
    while True:
        if next_token == '':
            response = s3client.list_objects_v2(Bucket=bucket, Prefix=prefix)
        else:
            response = s3client.list_objects_v2(Bucket=bucket, Prefix=prefix, ContinuationToken=next_token)
        if 'Contents' in response:
            contents = response['Contents']
            contents_count = contents_count + len(contents)
            for content in contents:
                if not dryrun:
                    print("Deleting: s3://" + bucket + "/" + content['Key'])
                    s3client.delete_object(Bucket=bucket, Key=content['Key'])
                else:
                    print("DryRun: s3://" + bucket + "/" + content['Key'])
        if 'NextContinuationToken' in response:
            next_token = response['NextContinuationToken']
        else:
            break
    print(contents_count)

delete_all_keys(bucket_name, prefix)

glueContext.write_dynamic_frame.from_options(
    frame = dyf1,
    connection_type = "s3",
    connection_options = {"path": "s3://%s/reports_parquet/year=%s/month=%s" % (bucket_name, year, month)},
    format = "parquet"
)

job.commit()

リソースIDレベルでの料金の算出

SQLが実行できれば、念願のリソースIDレベルでの料金の算出が出来るようになります。

delyでは可視化ツールにRedashを利用しているのですが、例えば下記のようにS3バケット別の料金も見れるようになっています。 料金上昇の原因もすぐに出来て安心ですね。 f:id:gomesuit:20181211013245p:plain

Redashによる可視化

SQLが実行できるようになればこっちのものですね! あとは可視化ツールで煮るなり焼くなり好きにしましょう。
せっかくなのでdelyでの具体例をいくつか紹介します。モザイクばかりですいません!

下記は特定の月のダッシュボードです。一つ一つはCost Explorerでも表示可能ですが、やっぱりダッシュボードで一括して見れると便利ですね。 f:id:gomesuit:20181210182804p:plain

月とサービスの料金一覧をピボットテーブルで表示しています。 f:id:gomesuit:20181211012700p:plain

下記ではS3の「API Operation」別の料金を表示しています。ピボットテーブルなので更に「Usage Type」毎にドリルダウンすることも出来てアドホックに分析することが可能になっています。 f:id:gomesuit:20181211012926p:plain

さいごに

この記事を書いてる途中でAWSのドキュメントに下記の文章を見つけました。

以下のレポートは利用できなくなります。
代わりに「AWS Cost and Usage Report」を使用することを強くお勧めします。

・・・

調べてみると2018年11月15日から「AWS Cost and Usage Report」の機能を使えばparquet型で出力してくれて、かつ設定用のCloudFormationのテンプレートを出力してくれるようになったようです。

docs.aws.amazon.com

試しに設定してみましたが、とても簡単にSQLを実行できるようになりました。GlueのETLジョブを定期的に実行する必要もないので断然こちらがおすすめです!
過去に遡って出力することは出来ないようなので、まだ設定していない方はとりあえず出力設定だけはやっておいた方が良さそうです。

簡単にはなりますがdelyでのコスト周りの決まりについてご紹介しました。
AWSなど従量課金のサービスのコストは見積もりが難しいですが、コントロール出来るように現状を常に把握しておけると良いですね。

ということで、最後にCost Explorerに個人的に欲しい機能を記載して終わろうと思います。

  • Cost Explorerに個人的に欲しい機能
    • 「テーブル」、「線グラフ」、「棒グラフ」以外のビジュアライズ
    • group byの複数指定
    • ダッシュボードの作成

以上になります。ありがとうございました!