Etag利用によるAPIの負荷軽減について検証した

## はじめに

### etagの基本
webサービスやAPIサーバなどhttp通信を使う場合にはコンテンツキャッシュの恩恵を受けることが多いです。一般的にwebコンテンツの公開はサーバ+CDNの構成にすることが多いかと思いますが、CDNのキャッシュ更新や、ブラウザからの問い合わせのレスポンスに活用されるのが、last ModifiedとeTagになります。

以下のサイトの説明が非常に詳しいです。

https://www.stream.co.jp/blog/blogpost-38692/

以下、サイト中から抜粋。

> 「Last-Modifiedヘッダ」と「Etagヘッダ」は、どちらもクライアントが現在持っているキャッシュが最新であるかどうかを確認するヘッダです。既にクライアントが持っているキャッシュが最新であれば、わざわざオリジンサーバーからコンテンツを取得する必要はないので、オリジンサーバーの配信負荷を抑えることが出来ます。

本記事はetagのお話しがメインなので、ここではetagを利用する例について考えます。

etagはwebデータに対してハッシュなどによって作成される値で、過去に読み込んだwebコンテンツに対して、再度リクエストを送る際にIf-None-Matchのヘッダーにetagを添えてサーバへリクエストを送ります。サーバ側ではこのetagを検証し、送り返そうとしているレスポンスと一致しているかをチェックします。一致の場合には、ステータスコード200ではなく304を送ります。

https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/If-None-Match


まとめると

- 前回返却したレスポンスから更新がないことをEtagにより検証できれば、304レスポンスを送り返す
- 304レスポンスの場合にはデータは送らない (前回と同じなので)
- 304レスポンスの場合には転送データ通信量の削減、ローディングタイムの削減ができる
- 特にサーバ側が課金対象にデータ量が含まれるクラウド環境の場合には有効


### やったこと

簡易httpdサーバを作成し、Etag利用によるAPIサーバの負荷軽減について検証してみました。

APIの場合レスポンス生成のためにサーバ側でDBなどの処理が必要ですが、

- **問い合わせURLに対してのレスポンスから計算したEtag情報をredisキャッシュに保存する設計**
- **次回のリクエストに対しては、前回のキャッシュ値を用いてEtag検証する**
- **DBの更新時にはこのキャッシュをリセットする**

でAPIサーバでのetag有効性を検証してみました。ソースコードは以下。 Json2RDB プログラムを使用しています。

https://qiita.com/yngwie6120/items/4448d1218490699ca58b

https://github.com/yngwie61/Json2RDB



## 結果について

### 検証結果

結果からいうと、

- **Etagの利用によりChromeブラウザからの無駄な通信、サーバ側での無駄な処理を削減できました**
- Etagの比較のみで処理できるリクエストはAPIサーバ上でのDBアクセス処理を短縮できています

今回、200応答の時のAPIレスポンスサイズはだいたい500kB程度の少し大きめのjsonとしています。304応答の時のサイズを見てみると、194B　になっております。2500倍のサイズです。


```
------------------UPDATE DB------------------
 * Serving Flask app 'httpd'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit
------------------DBアクセスが発生します------------------
127.0.0.1 - - [18/Jun/2023 05:05:51] "GET /example?id=1 HTTP/1.1" 200 -
127.0.0.1 - - [18/Jun/2023 05:05:53] "GET /example?id=1 HTTP/1.1" 304 -
127.0.0.1 - - [18/Jun/2023 05:05:54] "GET /example?id=1 HTTP/1.1" 304 -
127.0.0.1 - - [18/Jun/2023 05:05:54] "GET /example?id=1 HTTP/1.1" 304 -
127.0.0.1 - - [18/Jun/2023 05:05:55] "GET /example?id=1 HTTP/1.1" 304 -
127.0.0.1 - - [18/Jun/2023 05:05:58] "GET /example?id=1 HTTP/1.1" 304 -
127.0.0.1 - - [18/Jun/2023 05:06:01] "GET /example?id=1 HTTP/1.1" 304 -
127.0.0.1 - - [18/Jun/2023 05:06:02] "GET /example?id=1 HTTP/1.1" 304 -
127.0.0.1 - - [18/Jun/2023 05:06:02] "GET /example?id=1 HTTP/1.1" 304 -
127.0.0.1 - - [18/Jun/2023 05:06:03] "GET /example?id=1 HTTP/1.1" 304 -
------------------UPDATE DB------------------
------------------DBアクセスが発生します------------------
127.0.0.1 - - [18/Jun/2023 05:06:06] "GET /example?id=1 HTTP/1.1" 200 -
127.0.0.1 - - [18/Jun/2023 05:06:08] "GET /example?id=1 HTTP/1.1" 304 -
```


### 動かし方
mac book内に以下の実行環境を揃えました。

- python実行環境
- sqlite3の実行環境
- redisの実行環境 (httpd.py中のetagのキャッシュ用)

```
# redisの起動
brew install redis
redis-server

# DBの作成とサンプルデータの投入
[src/db]sqlite3 test.db
[src/db]sqlite3 test.db < init.sql

# flaskによるhttpサーバの起動
[src] python httpd.py
```

ここまでやったら

http://127.0.0.1:5000/example?id=1

にアクセスしてdevtoolで見てみてください。15秒に一回id=1のレコードは更新されるため、15秒に1回レスポンスが200、そのほかは304になるはずです。

ヘッダーを確認すると

Etag: 4c7b1f4ed530dc3b0b2396f8dd256397fa501ea202e2539d4f6cab99beef79c9

のような値がセットされています。これがレスポンスから計算したEtagの値で、200応答する際に更新されます。


## APIサーバでのETagの実装方法
APIサーバでETagを実装するために以下の手順で実装しています。

### ETagの生成とレスポンスヘッダーへの設定
ETagを生成し、APIのレスポンスヘッダーに設定します。以下のサンプルコードでは、getJsonFronRDB関数を使用してデータを取得し、そのデータのハッシュ値をETagとして設定しています。

```python
# ETagの生成とレスポンスヘッダーへの設定
response_json = getJsonFronRDB(id, cursor)
response_string = json.dumps(response_json, indent=4)
# SHA-256ハッシュを計算しETagとして設定
new_etag = calculate_sha256_hash(response_string)
# RedisにETagを保存
redis_client.set(uri, new_etag)
# レスポンス生成
response = make_response(response_string, 200)
response.headers['Content-Type'] = 'application/json'
response.headers['ETag'] = new_etag

```

### クライアントからのETagチェックとキャッシュ制御
クライアントから送信されたETagを取得し、サーバの現在のETagと比較します。一致する場合は、クライアントに304 Not Modifiedを返します。一致しない場合には200応答を作成しますが、その際にredis上のetagを更新します。

redisのkeyには想定するクエリ(ここではid)までを含めたリクエストパスを用います。

```python
uri = urlparse(request.url).path
etag = request.headers.get('If-None-Match')
id = request.args.get('id')
key_uri = f"{uri}?id={id}"
current_etag = get_etag(key_uri)
if current_etag is not None:
    current_etag = current_etag.decode('utf-8')

else:
    current_etag = ""

if etag == current_etag:
    response = make_response('', 304)
    response.headers['ETag'] = current_etag

else:
# データの更新と新しいETagの設定を行う
# ...


```

## データ更新に伴うETagの管理について

実際のAPIサーバでもDBの更新タイミングでレスポンスが変更になるため、キャッシュもクリアすることが想定されます。このあたりうまい実装がなかなか思い浮かばなかったので今回はとりあえずキャッシュはすべて削除しています。


### ダミーデータの更新とEtagキャッシュのクリア
定期的なデータの更新や変更があった場合、キャッシュをクリアする必要があります。以下のサンプルコードでは、update_example_api関数を使用してデータの更新をスケジュールしています。

```python
# データの更新とキャッシュのクリア
def update_example_api():
    content = generate_random_string(500000)
    # データ生成. 実際には SQL などを実行する.
    new_json_data = {
        "id": 1,
        "key1": content,
        "key2": "value2",
        "key3": 30
    }

    # SQLiteデータベースに接続
    connection = sqlite3.connect('db/test.db')
    cursor = connection.cursor()
    current_json_data = getJsonFronRDB(1, cursor)
    diff_json = getDiffFronJson(current_json_data, new_json_data)
    updateDBFromJson(diff_json, cursor)
    # キャッシュの削除, コミットと接続のクローズ
    clear_redis_cache()
    connection.commit()
    connection.close()

    # 次の更新をスケジュール
    threading.Timer(15, update_example_api).start()  # 10秒後に更新


```

### ETag処理の最適化
ETagの計算や比較の処理にはコストがかかるため、最適化が重要です。

- ETagの生成には、ハッシュ関数を使うのが一般的かと思います。今回はSHA256を使用しています。
- 本来は全部のキャッシュをリセットするのも控え、関わるリクエストのみのキャッシュを消すなどの機構を入れるのがいいのかなと思っております。


## おわりに

APIサーバでのEtagを用いた負荷軽減について

- Etagの利用によりChromeブラウザからの無駄な通信、サーバ側での無駄な処理を削減できました
- Etagの比較のみで処理できるリクエストはAPIサーバ上でのDBアクセス処理を短縮できています

実装の問題点として、DB更新時に全てのetagキャッシュを削除するようにしているところに若干の無駄があるかなと感じております。今回はローカルホスト上に立ち上げていますが、AWS上でも同様の構成はAPIGateway+lambdaなどの構成で実装できそうです。