AWS Lambdaによるサーバーレス構成でのCacheとCashを考える

# はじめに
[Serverless Advent Calendar 2017](https://qiita.com/advent-calendar/2017/serverless)の12日目の投稿です。

ここでは、AWSベースのサーバーレスアーキテクチャで、主にFaaS（Function-as-a-Service）の利用を前提としますが、データキャッシュ（Cache）が必要な場合にどのように実現するのか、その技術解とお金（Cash）の関係について整理してみました。

# サーバーレスアーキテクチャにしたは良いけど
サーバーレスアーキテクチャをベースにすることで、処理数に応じてリソースの最適化を図れるので、スモールスタートしやすく、またマイクロサービス化しやすいので、積極的に検討するようにしています。

ただ、よく自社で課題になるのが、データキャッシュに関する処理。
例えば、何かしらのIDを持つイベントを受け取り、それに対して名称を付与するようなケース。AWS Lambdaでデータを処理して、DynamoDBにデータを保存するような場合に、どこで名称のデータを保持するかが問題になります。

![lambda-cache.png](https://qiita-image-store.s3.amazonaws.com/0/22893/7192e850-cc2d-4c64-0134-f55f30ac6d6a.png)


DynamoDBは、[スループット課金](https://aws.amazon.com/jp/dynamodb/pricing/)のため、あまり高頻度なRead／Writeをするには向いていません。もちろん、それだけの投資が行えるサービスであれば良いのですが、ID->名称の変換だけで、それだけのコストが発生するとなると、なかなか費用帯効果が得にくいケースが多いのではないでしょうか？
しかも、DynamoDBのRead処理は、レイテンシがそこまで高速ではなく（といっても数十～数百msですが）、システムのパフォーマンスに制約が発生するので、あまりこのような用途には向かないでしょう。

ということで、普通はインメモリでのキャッシュを利用することに至りますよね。

# キャッシュパターン
ここでは、以下を前提に考えます。

- 料金は、東京リージョンでのオンデマンドとして計算する。
  - データ転送量は除く。
  - 1ドル＝115円で計算する。

## ElastiCache(Redis)
AWSにおけるキャッシュサービスといえば、まずはElastiCacheですよね。ElastiCacheのエンジンは、MemcachedとRedisがサポートされていますが、私はRedis派です。キャッシュとして保持できるデータもHash／List／Setなどの色々な型をサポートしており、対応できる幅が広く、レプリケーションにも対応しているためです。

ElastiCacheを利用する場合は、以下のような要件の場合に向きます。

- 高頻度なWrite／高頻度なRead
- Writeスルー[^1]

ただ、コストはそれなりに高いです。

|インスタンスタイプ|スペック|単位時間コスト|1ヶ月のコスト|
|---|---|---|---|
|cache.t2.medium|2vCPU、3.22GiB|$0.104/h|$76.13（約8,800円）|
|cache.m4.xlarge|4vCPU、14.28GiB|$0.452/h|$330.87（約38,100円）|

さらに注意が必要なのは、上記は **1ノード** である、ということです。ElastiCache(Redis)は、Multi-AZでの冗長化構成に対応していますが、追加のノードはレプリカノードとなるので、利用する場合は単純に2倍のコストがかかります。加えて、非同期レプリケーションとなるので、データのロストを避けるために同一AZにもレプリカノードを配置するとなると3～4倍のコストがかかることになります。

## DynamoDB Accelerator(DAX) 
いつもElastiCacheを使うかどうかで悩むことが多かったのですが、2017年の4月にDAXが発表され、新たな選択肢ができました。
DAXを利用することで、DynamoDBにデータを保持しつつも、数ミリ秒～マイクロ秒での低レイテンシでの処理が実現可能になります。

DAXを利用する場合は、以下のような要件の場合に向きます。

- 高頻度なRead
- Writeスルー[^1]

大事なポイントは、DAXのタイトルにあります。

> Amazon DynamoDB Accelerator(DAX) – Read heavyなワークロード向けインメモリ型キャッシュクラスタ

そう、**Read heavy** であることです。DAXでは、DynamoDBに書き込んだデータを読み込む際にキャッシュされるので、2回目以降のアクセスなどが高速化されます。書き込み自体が高速化されるわけではないので、頻繁にキャッシュを更新するような場合には、書き込みスループットの設定値を上げる必要が出てきてしまいます。

DAXを利用する場合のコストですが、ElastiCache（Redis）よりは1GiBあたりのコストは低くなります。

|インスタンスタイプ|スペック|単位時間コスト|1ヶ月のコスト|
|---|---|---|---|
|dax.r3.large|2vCPU、15.25GiB|$0.322/h|＄235.71（約27,100円）|

DAXでも、可用性向上のためには、3ノード以上でのクラスタ構成での運用が推奨されています。
ただ、DAXの場合、データ自体はDynamoDBに保持されているので、DAXノードがダウンしても、データ自体はロストせずに済みます。

また、LambdaはPythonで実装派の自分としては残念なところなのですが、DAXはまだPythonには対応していません。そのため、DAXを利用する場合はJavaかNodeのSDKを利用する必要があります。

## Lambda In-Memory
もうひとつは、適用条件は限られますが、Lambdaのインメモリを利用するパターンです。これは、Lambdaのインスタンスは再利用されるため、それを活用する方法です。
以下のような方法で実現しています。

- キャッシュ対象のデータをLambda関数のグローバル変数に保持する。
- インスタンスが新たに立ち上がった場合など、キャッシュがなければ、（DynamoDBやS3などから）データを取得する。
- 一定時間を過ぎている場合、データを再取得する。

Lambdaのインスタンスの制御は、AWS任せなので、自分でコントロールはできません。
そのため、ゆるい一貫性（ある程度の時間を要して、書き込みしたデータがキャッシュに反映される）で問題が無い場合での利用に限定されますが、Lambdaの処理だけでシンプルに実現でき、コストも抑えられるので、条件が合えば強力な選択肢です。実際のプロジェクトで急遽DynamoDBへのアクセス負荷を減らすのに必要になった際に、役に立ちました。

Lambda In-Memory を利用する場合は、以下のような要件の場合に向きます。

- 高頻度なRead
- 呼び出し回数が少ない
- ゆるい一貫性
- キャッシュデータが大きくない

2017/11/30にLambdaの最大メモリ量が、3,008MB（＝2.93GiB）に拡張されました（それ以前は、1,536MB）。そのため、2GiB程度までキャッシュが可能そうです（キャッシュを目的にして増えたわけではないでしょうが）。

キャッシュだけのコストを出すのは難しいですが、1回あたりのLambda処理が0.1秒として、1秒あたり10回の呼び出しを行う場合は、以下の程度のコストがかかります。

- 合計コンピューティング（秒/日）= 10回/秒 x 0.1秒 x 86,400秒/日 = 86,400 秒/日
- 合計コンピューティング（GB-秒/日）= 86,400 秒/日 × 1GB = 86,400 GB-秒/日
- 1ヶ月のコンピューティング料金 = 86,400GB-秒/日 x 30.5日 × ＄0.00001667/秒 = ＄43.93(5,100円)

もちろん、呼び出し回数が多ければその分コストは大きくなりますが、元々Lambdaで処理する内容であり、呼び出し回数が少ない状況では都合が良さそうです。

おおよそのコードの内容ですが、以下のようにして、定期的にキャッシュが更新されるようにしています。

```python3:pytyon
import datetime
import json

_beacon_dict = {}
_updatetime = 0

def lambda_handler(event, context):
    update_beacon_dict()

    req_body = json.loads(event['body'])
    add_attributes(req_body)

    # データの保存
    ・・・

    return {}

def add_attributes(beacon_data):
    '''
    キャッシュデータを利用して、属性を付与します。
    '''
    global _beacon_dict

    beacon_id = beacon_data['beaconId']
    if beacon_id in _beacon_dict:
        # キャッシュからデータを取得してパラメータを追加
        beacon_data['name']  = _beacon_dict[beacon_id]

def update_beacon_dict():
    '''
    有効期限をチェックし、キャッシュデータの更新を行います。
    '''
    global _beacon_dict
    global _updatetime

    diff_time = 0
    if len(_beacon_dict) != 0:
        now = datetime.datetime.now()
        epoch_now = datetime.datetime_to_epoch(now)
        epoch_updatetime = datetime.datetime_to_epoch(_updatetime)
        diff_time = epoch_now - epoch_updatetime

    if len(_beacon_dict) == 0 or diff_time >= 5*60*1000:
        # キャッシュデータの更新
        ・・・
        _beacon_dict = new_dict
        _updatetime = datetime.datetime.now()
```

# まとめ
キャッシュのパターンを整理すると、以下のようになります。
※費用は、あくまで目安です。インスタンスタイプやクラスタ構成などの条件で変わります。

|方法|適用が向くケース|制限|1GiBキャッシュあたりのコスト|
|---|---|---|---|
|ElastiCache(Redis)|・高頻度なWrite<br>・高頻度なRead<br>Writeスルー|・可用性を上げるなら3ノード以上必要|1ノードの場合：<br>2,600～2,800円<br>3ノードクラスタの場合：<br>7,800～8,400円|
|DynamoDB Accelerator(DAX)|・高頻度なRead<br>・Writeスルー|・可用性を上げるなら3ノード以上必要|1ノードの場合：<br>1,800円<br>3ノードクラスタの場合：<br>5,400円|
|Lambda|・高頻度なRead<br>・呼び出し回数が少ない<br>・ゆるい一貫性|・キャッシュデータ2GiB以下（目安）|10回実行/秒<br>100ミリ秒/回<br>の場合：<br>5,100円|

1GiBキャッシュあたりのコストで見ると、Lambdaでもそれなりの単価になりますね。上記はキャッシュする／しないに関わらず、Lambdaの実行にかかるコストのため単純な比較はできないですが、10回実行/秒以下の頻度の呼び出しであれば、メリットがありそうです。

キャッシュの更新や呼出をどう行うかによって、どの方法が良いかは変わりますが、処理の特性とコストを踏まえて、キャッシュの方式を選択できると良いと考えています。

[^1]: Writeスルー：キャッシュに書き込んだデータが、整合性を保って読み取り可能になる。