AWS lambda Pythonにて色々なcache機能を試す

AWS lambda Pythonにて色々なcache機能を試す

お世話になっております。
プログラマーのskkです。

何かのシステムをクラウド化させる際に
機能単位でAWSlambdaにて作成することが最近良くあります。
インスタンスを作成したりしないのでサーバレスでメンテナンスも少なくて重宝します 🙂

そんなlambda内の処理で外部APIURLからjsonを取得したり、
huggingfaceで学習済みモデルを読み込んだり、
何かと大きなオブジェクトを読み込む処理があったりします。

そういう時に気になってしまうのは読み込みに対する時間ですね…

例えばAPIgatewayから呼び出すlambdaが何か大きなモデル読み込みをするとして
ユーザー側からアクセスした時に応答時間が掛かるとストレスになってしまいます。
更には開発側としてもlambdaは処理時間に応じて使用料金が発生します。

双方の利害の為にもどうにか処理時間を減らしたいというわけです。

そこで読み込み部分を初回で読み込みと同時にキャッシュ保存して
初回以降はキャッシュから読み込む形に変えて処理時間の短縮を狙います。

キャッシュ保存といっても方法は様々あります。
メモリ保存、ファイル保存としても色々と…

今回はpythonプログラムで使用できるキャッシュ保存機能を
3つ紹介して、それぞれキャッシュ未使用時と比べてどれだけ処理時間を削減出来たか展開していきたいと思います。

今回使用するサンプル
url = "https://api.steampowered.com/ISteamApps/GetAppList/v2/"
response = requests.get(url)

requestsモジュールを用いて対象のURLからjsonデータを取り出す処理のresponse変数部分をキャッシュ化

lambda側の設定としてはpython3.10、メモリを256MB、タイムアウト秒数を10秒として、その他の設定はデフォルトのままとした。

因みに初回起動、キャッシュが生成されないcold状態での実行はどの手法であっても 約2.3秒 の実行時間でした。

lambdaは最初に実行したときと、しばらく置いてから実行した時はプログラムを載せたマシンの稼働から行われる
いわゆるcold standby状態での実行となります。
実行してからしばらくはlambdaを呼び出すとマシン稼働中実行となる
warm standby状態での実行となります。
今回の検証は頻繁に呼び出される想定で
どの手法でもwarm状態での比較となります。

検証前に比較として、キャッシュ機能を用いない素のコード構成でwarm状態で実行した時の処理時間は
1.4613秒、1.5375秒、1.5110秒…
の、平均約1.5秒でした。

1. global変数

1つ目からキャッシュ機能というには少し違うものなのですが、
lambdaにおいて実行時に呼び出される関数「lambda_handler」以外の記述部分で変数代入を行った場合、
warm状態であればこの部分はglobal変数として扱われ、
その変数内容を別のlambda呼び出しでも保持するといった仕様がある。
これを利用してresponseをglobal変数に保存してwarm状態での実行はここから呼び出す形でキャッシュの役割をもたせます。

import requests
import json
import os
import time

res_obj = None

def lambda_handler(event, context):
    global res_obj
    
    # 処理前に現在時刻を取得
    start_time = time.time()
    
    response = fetch_response()
    datas = json.loads(response.text)
    
    for i in range(10):
        appid = datas["applist"]["apps"][i]['appid']
        print("appid->")
        print(appid)
    
    # 処理後に現在時刻を取得
    end_time = time.time()
    # 処理にかかった時間を計算
    elapsed_time = end_time - start_time
    print("json_print_sec->"+str(elapsed_time))
    
    return {
        'statusCode': 200,
        'body': json.dumps('Hello from Lambda!')
    }



def fetch_response():
    global res_obj
    
    if res_obj is None:
        print("not in global cache")
        # call schema registery and fetch schema 
        res_obj = get_by_url_response()
    return res_obj
        

def get_by_url_response():
    url = "https://api.steampowered.com/ISteamApps/GetAppList/v2/"
    response = requests.get(url)
    return response

処理時間は
0.9079秒、0.8798秒、0.9136秒…
平均約0.9秒となり、短縮効果がありました。

特徴としては
cold状態に戻るたびにglobal変数の中身は破棄されるので、
ずっとメモリに滞留する問題もなく、
また、lambda仕様的に嬉しい点として、追加のモジュールを必要としないので、
レイヤー機能でモジュールをアップして使用する手間も削減できるので扱いやすい印象を受けます。

裏を返せば、通常にサーバを立ててpythonファイルを動かす上ではキャッシュとしては扱いにくいものとなっており、
lambdaの仕様だからこそ上手く扱える機能なのではないかなと思います。

2. cachetools

https://github.com/tkem/cachetools/
設定した関数のreturnを
RAMにキャッシュするpython拡張モジュールです。

import requests
import json
import os
import time
from cachetools import cached

def lambda_handler(event, context):
    
    
    
    # 処理前に現在時刻を取得
    start_time = time.time()
    
    response = get_response()
    datas = json.loads(response.text)
    
    for i in range(10):
        appid = datas["applist"]["apps"][i]['appid']
        print("appid->")
        print(appid)
    
    # 処理後に現在時刻を取得
    end_time = time.time()
    # 処理にかかった時間を計算
    elapsed_time = end_time - start_time
    print("json_print_sec->"+str(elapsed_time))
    
    return {
        'statusCode': 200,
        'body': json.dumps('Hello from Lambda!')
    }


# キャッシュ有効化
@cached(cache ={})
def get_response():
    url = "https://api.steampowered.com/ISteamApps/GetAppList/v2/"
    response = requests.get(url)
    return response

処理時間は
0.9157秒、0.9141秒、0.8961秒…
平均約0.91秒となり、こちらも短縮効果がありました。

拡張モジュールの為、レイヤー登録するといった手間は少々ありますが、
プログラム記述はかなりシンプルで扱いやすい機能でした。

今回のサンプルではデフォルトの保持期間を設定していないcachedでのキャッシュ登録でしたが、
cachetoolsでは他の設定として

キャッシュ保持を時間制限とするTTLCache、
使用頻度の少ないものを破棄するLFUCache、
最終使用が古いものを破棄するLRUCache、
ランダムに破棄するRRCache と

様々なオプションを使用することができます。

これにより、lambda以外の環境で使用する場合にキャッシュが滞留する問題も対応できるようになっております。
汎用的にコードを流用する場合には一考の余地がある選択肢かなと思います。

3. joblib

https://joblib.readthedocs.io/en/latest/
並列処理をサポートするpython拡張モジュールですが
機能の中にファイル出力形式でキャッシュを生成する機能があります。これを使います。

import requests
import json
import os
#import pickle
import joblib
import time

# キャッシュファイルのパス
cache_path = '/tmp/cache'

def lambda_handler(event, context):
    
    joblib.parallel_backend('threading')
    
    # 処理前に現在時刻を取得
    start_time = time.time()
    
    try:
        response = joblib.load(cache_path)
        print('Cache loaded.')
    except FileNotFoundError:
        url = "https://api.steampowered.com/ISteamApps/GetAppList/v2/"
        response = requests.get(url)
        joblib.dump(response, cache_path)
        print('Model loaded and cached.')
    datas = json.loads(response.text)
    
    for i in range(10):
        appid = datas["applist"]["apps"][i]['appid']
        print("appid->")
        print(appid)
    
    # 処理後に現在時刻を取得
    end_time = time.time()
    # 処理にかかった時間を計算
    elapsed_time = end_time - start_time
    print("json_print_sec->"+str(elapsed_time))
    
    return {
        'statusCode': 200,
        'body': json.dumps('Hello from Lambda!')
    }

処理時間は
0.9215秒、0.9150秒、0.8947秒…
平均約0.91秒となり、こちらも短縮効果がありました。

記述こそ、そこまでややこしくはないのですが、
lambdaで使用する場合にレイヤー機能でモジュールをアップする事に加えて、
マルチプロセスで動作しないことを明示的に宣言させる必要がありました。

参考記事
https://stackoverflow.com/questions/55577358/how-should-i-treat-joblib-multiprocessing-in-an-aws-lambda-implementation

lambda側の環境変数設定で
JOBLIB_MULTIPROCESSING = 0 と設定しないと
joblibを読み込むことが出来ませんでした。

速度こそ他のキャッシュ機能と変わりませんでした。
設定の手間を考えると
速度同じであれば、lambdaではあえてこちらのjoblibモジュールを使用する意味合いは薄いかなと思います。

結論

今回のようなlambdaで使用する場合には
拡張モジュールのレイヤー追加の必要がない
global変数 を用いるのがベストかなと個人的には思いました。

他のモジュールの用途、利点もまとめておくと、

cachetools は lambda以外でキャッシュ利用する場合に
保持・破棄設定を追加出来るので、
キャッシュが嵩張らないクリーンな運用が出来ると思います。

joblib は キャッシュ機能以外に
並列処理を行うためのモジュールですので、
並列処理利用時に導入して合わせてキャッシュ機能を使うといった運用になるのかなと思います。

皆様もキャッシュ機能を導入して高速なプログラムを作っていきましょう!

最新ブログ一覧