AWS Lambda 上で Jinja2 を使う場合の実行時間を削減する方法

# はじめに
[DMMグループ Advent Calendar 2020](https://qiita.com/advent-calendar/2020/dmm) 3日目の記事です。

AWS Lambda 上で HTML を生成するアプリケーションを動かす場合に、 Python ランタイムを選択し [Jinja2](https://pypi.org/project/Jinja2/) を利用することが考えられます。
AWS Lambda では [コードの実行時間に対して課金される](https://aws.amazon.com/lambda/pricing/) ため、Jinja2 による HTML 生成にかかる時間が小さい方がコスト的に嬉しいです。

そこでこの記事では、AWS Lambda 上で Jinja2 により HTML を生成する際に実行時間を小さくするための方法を検討します。

# 前提
下記の環境を前提とします。

* AWS Lambda ランタイム: Python 3.8
* AWS Lambda メモリ割り当て: 128MB
* Jinja2: 2.11.2


# 結論
Jinja2 での HTML 生成にかかる時間を小さくするには、下記の方法が有効です。

* Jinja2 の [Environment](https://jinja.palletsprojects.com/en/2.11.x/api/#jinja2.Environment) のインスタンスを使い回すようにする
* テンプレートを事前にコンパイルしておく


# ベースとなるコード
検討の元になるコードとして、以下を用意しました。

```
.
├── Pipfile                  # pipenv による依存定義ファイル
├── Pipfile.lock             # pipenv による依存管理用ファイル
└── sampleapp                # アプリケーションコード
    ├── __init__.py
    ├── handler.py           # Lambda の関数ハンドラー定義
    ├── lambda_typing.py     # Lambda で扱うデータの型定義
    └── templates            # HTML テンプレート
        ├── child.html
        ├── child2.html
        ├── child3.html
        ├── child4.html
        ├── child5.html
        ├── child6.html
        ├── child7.html
        ├── child8.html
        ├── child9.html
        └── parent.html
```

`Pipfile` は以下の通りで、 Jinja2 を利用するように設定しています。

```
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]
jinja2 = "*"

[dev-packages]
black = "*"
mypy = "*"

[requires]
python_version = "3.8"

[pipenv]
allow_prereleases = true
```

`sampleapp/handler.py` は以下に示す通りで、 `parent.html` のテンプレートにパラメーターを渡して HTML を生成し、その結果をレスポンスとして返す Lambda 関数ハンドラー `handler` を定義します。

```py
"""
Lambda ハンドラー定義
"""
from dataclasses import dataclass
from typing import TypedDict, cast

from .lambda_typing import Context, Event

import jinja2


class LambdaResponse(TypedDict):
    """
    Lambda ハンドラーの呼び出し結果
    """

    message: str


@dataclass(frozen=True)
class TemplateParameter:
    """
    テンプレートに渡すパラメーター定義
    """

    name: str
    num: int


def handler(event: Event, context: Context) -> LambdaResponse:
    parameter = TemplateParameter(
        name=cast(str, event.get("name")),
        num=cast(int, event.get("num")),
    )

    env = jinja2.Environment(
        autoescape=True,
        loader=jinja2.FileSystemLoader("sampleapp/templates"),
    )
    template = env.get_template("parent.html")

    return {"message": template.render(parameter=parameter)}
```

parent.html は以下の通り child.html から child9.html を読み込む HTML テンプレートです。

```jinja
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Sample HTML</title>
  </head>
  <body>
    <h1>Hello, {{parameter.name}}!</h1>
    {% include "child.html" %}
    {% include "child2.html" %}
    {% include "child3.html" %}
    {% include "child4.html" %}
    {% include "child5.html" %}
    {% include "child6.html" %}
    {% include "child7.html" %}
    {% include "child8.html" %}
    {% include "child9.html" %}
  </body>
</html>
```

child.html から child9.html は全て同じ内容です。

```jinja
<ul>
  {% for i in range(parameter.num) %}
  {% if i is even %}
  <li class="even">even number: {{i}}</li>
  {% else %}
  <li class="odd">odd number: {{i}}</li>
  {% endif %}
  {% endfor %}
</ul>
```

## 計測
上記のベースとなるコードを AWS Labmda に反映し、10回呼び出して各回の実行時間を記録します。
呼び出し時のイベントには下記の JSON を渡しました。

```json
{
  "name": "value1",
  "num": 10
}
```

実行結果は以下の通りです。

| 回 | 実行時間(ミリ秒) |
|:-:|:-:|
|  1 | 346.87 |
|  2 | 314.04 |
|  3 | 300.21 |
|  4 | 307.61 |
|  5 | 315.19 |
|  6 | 305.74 |
|  7 | 293.09 |
|  8 | 305.83 |
|  9 | 276.84 |
| 10 | 300.36 |


# 変更1: Jinja2 の Environment のインスタンスを使い回すように変更

`Environment` の `get_template()` メソッドでレンダリング用の `Template` のインスタンスを取得する際に、Jinja2 のテンプレートの内容は以下のように変換されます。

```
[Jinja2 のテンプレート] -> [python コード] -> [python のバイトコード]
```

この変換結果は `Environment` のインスタンス変数 `cache` にキャッシュされます。
（参考: https://github.com/pallets/jinja/blob/2.11.2/src/jinja2/environment.py#L846-L860 ）
しかしながら、ベースとなるコードのように `Environment` のインスタンスを Lambda の関数ハンドラー内で都度生成すると、このキャッシュ機構が活用できません。
そのため、ベースとなるコードを以下のように変更し、 `Environment` のインスタンスを1度生成した後は使い回すように変更します。

```diff
diff --git a/sampleapp/handler.py b/sampleapp/handler.py
index 5f25d20..3b7a39b 100644
--- a/sampleapp/handler.py
+++ b/sampleapp/handler.py
@@ -2,6 +2,7 @@
 Lambda ハンドラー定義
 """
 from dataclasses import dataclass
+from functools import lru_cache
 from typing import TypedDict, cast
 
 from .lambda_typing import Context, Event
@@ -27,16 +28,24 @@ class TemplateParameter:
     num: int
 
 
+@lru_cache
+def create_environment() -> jinja2.Environment:
+    """
+    jinja2 Environment を作成
+    """
+    return jinja2.Environment(
+        autoescape=True,
+        loader=jinja2.FileSystemLoader("sampleapp/templates"),
+    )
+
+
 def handler(event: Event, context: Context) -> LambdaResponse:
     parameter = TemplateParameter(
         name=cast(str, event.get("name")),
         num=cast(int, event.get("num")),
     )

-    env = jinja2.Environment(
-        autoescape=True,
-        loader=jinja2.FileSystemLoader("sampleapp/templates"),
-    )
+    env = create_environment()
     template = env.get_template("parent.html")
 
     return {"message": template.render(parameter=parameter)}
``` 


## 変更1適用後の計測
ベースとなるコードと同様に、10回呼び出して実行時間を記録します。

結果は以下の通りで、初回実行以降は実行時間が短縮されています。
（比較のために変更前の実行時間も併記しています）
初回実行時はJinja2テンプレートのバイトコード変換が発生するためベースとなるコードと同程度の実行時間になっていますが、2回目以降はバイトコード変換処理がなくなった分実行時間が小さくなっていることがわかります。

| 回 | ベースとなるコードの実行時間(ミリ秒) | 変更1適用後の実行時間(ミリ秒) |
|:-:|:-:|:-:|
|  1 | 346.87 | 352.89 |
|  2 | 314.04 | 1.82   |
|  3 | 300.21 | 1.94   |
|  4 | 307.61 | 2.06   |
|  5 | 315.19 | 2.01   |
|  6 | 305.74 | 1.83   |
|  7 | 293.09 | 1.91   |
|  8 | 305.83 | 1.75   |
|  9 | 276.84 | 1.89   |
| 10 | 300.36 | 1.94   |


# 変更2: Jinja2テンプレートを事前にコンパイルしておく
変更1で `Environment` のインスタンスを使い回すようにしたことにより、初回実行以外の実行時間が変更前より小さくなり改善されました。
そこで次は、初回実行時の実行時間の短縮を狙います。

[Jinja2 のドキュメント](https://jinja.palletsprojects.com/en/2.11.x/api/#jinja2.Environment.compile_templates) に記載がありますが、以下の変更を加えることで Jinja2 テンプレートを事前にコンパイルしておき、そのコンパイルされたものをテンプレートレンダリングで利用することができます。

1. 事前準備: `Environment` の `compile_templates()` メソッドを呼び出し、Jinja2 テンプレートを python コードに変換する
2. Lambda の関数ハンドラー: `Environment` に渡す loader を [ModuleLoader](https://jinja.palletsprojects.com/en/2.11.x/api/#jinja2.ModuleLoader) に変更する

1の事前準備では以下のような python スクリプトを用意し実行すれば OK です。

```py
import jinja2


env = jinja2.Environment(
    loader=jinja2.FileSystemLoader("sampleapp/templates"),
)
env.compile_templates(target="sampleapp/compiled_templates", zip=None, ignore_errors=False)
```

2の変更は以下のようになります。

```diff
diff --git a/sampleapp/handler.py b/sampleapp/handler.py
index 3b7a39b..c54704d 100644
--- a/sampleapp/handler.py
+++ b/sampleapp/handler.py
@@ -35,7 +35,7 @@ def create_environment() -> jinja2.Environment:
     """
     return jinja2.Environment(
         autoescape=True,
-        loader=jinja2.FileSystemLoader("sampleapp/templates"),
+        loader=jinja2.ModuleLoader("sampleapp/compiled_templates"),
     )
```


## 変更2適用後の計測
こちらも同様に、10回呼び出して実行時間を記録します。

結果は以下の通りで、初回実行時の実行時間が変更前に比べて小さくなっており、改善されたことがわかります。
（比較のためにこれまでの変更も含めて実行時間を併記しています）

| 回 | ベースとなるコードの実行時間(ミリ秒) | 変更1適用後の実行時間(ミリ秒) | 変更2適用後の実行時間(ミリ秒) |
|:-:|:-:|:-:|:-:|
|  1 | 346.87 | 352.89 | 69.9 |
|  2 | 314.04 | 1.82   | 1.59 |
|  3 | 300.21 | 1.94   | 1.67 |
|  4 | 307.61 | 2.06   | 1.57 |
|  5 | 315.19 | 2.01   | 1.67 |
|  6 | 305.74 | 1.83   | 1.65 |
|  7 | 293.09 | 1.91   | 1.81 |
|  8 | 305.83 | 1.75   | 1.6  |
|  9 | 276.84 | 1.89   | 1.59 |
| 10 | 300.36 | 1.94   | 1.72 |



# おわりに
Jinja2 テンプレートのバイトコード変換結果を再利用できるようにするために、 `Environment` のインスタンスを都度生成するのではなく、1度生成したものを使い回すように変更しました。
これは、[AWS Lambda 関数を使用するためのベストプラクティス](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/best-practices.html) に記載されている "Take advantage of execution environment reuse to improve the performance of your function" に沿って Lambda の実行環境が再利用されることを活用した形です。

また、Lambda の初回実行時の実行時間を小さくするために、テンプレートを事前にコンパイルし、そのコンパイルされたものを利用するように変更しました。

----

というわけで3日目の記事でした。
[DMMグループ Advent Calendar 2020](https://qiita.com/advent-calendar/2020/dmm) 4日目は @2357gi さんです。