【psycopg2】AWS LambdaでPythonを動かすときに、モジュールインポートに失敗する原因についてまとめてみた

# はじめに
AWS LambdaでPythonを用いてコーディングする際に、Lambdaに標準で入っていないモジュールについてはレイヤーを作成して、動くようにする方法がメジャーかなと思います。
一方で、「**（Windows OSの）ローカル上で作成したレイヤーだとインポート時にエラーが出てしまって困っている**」みたいなケースが散見されているなと感じました。

特に、よくあるケースとしては、psycopg2のモジュールインポート時において、
```bash
[ERROR] Runtime.ImportModuleError: Unable to import module 'lambda_function': No module named 'psycopg2._psycopg'
```
というようなエラーが出てしまうというケースです。私自身、これに頭を悩ませていました。

# この記事の目的

この記事では、**上記のエラーが起きる根本の原因であるDLLをざっくり理解し、処理の流れを明らかにすること**を目的としています。
もちろん、実際にLambdaが動くようにするために、具体的な解決策を知りたい！ということは重々承知の上で、この記事はそこに主題を置いていないということを理解していただければと思います。

## （補足）解決方法について

この問題については、[この記事](https://memoribuka-lab.com/?p=3817)にも書かれているように、「**LinuxコンテナをDockerで作成し、その上で該当のパッケージを作成する** 」ことで、解決できます。

ちなみに私がやった際のコードは以下の通りなので、もし困っている場合は参考にしていただければと思います。（2023/11/30 の時点で動作確認済）
<details><summary>サンプルコード</summary>


```yaml
# docker-compose.yaml
version: "3"
services:
  web:
    build: .
    container_name: amazonlinux
    privileged: true
    volumes:
      - type: bind
        source: ./container_tmp # ここはローカルのディレクトリのPath
        target: /usr/tmp

```
```Dockerfile
# Dockerfile
FROM amazonlinux:2023

RUN dnf update -y && \
    dnf install -y systemd && \
    dnf install sudo -y

CMD ["/sbin/init"]

```
```bash
# ローカル上でのコマンド
 docker compose up --build -d
 docker exec -it amazonlinux /bin/bash -l

# コンテナの内部でのコマンド
[root@hogehoge tmp]# python3 --version # Python3.11 で使用したいのでバージョン確認
[root@hogehoge tmp]# sudo dnf install python3.11
[root@hogehoge tmp]# python3.11 --version
[root@hogehoge tmp]# dnf install python3.11-pip -y
## レイヤー作成の観点でpythonディレクトリが必要
[root@hogehoge tmp]# mkdir python
[root@hogehoge tmp]# pip3.11 install psycopg2-binary -t /python　
[root@hogehoge tmp]# dnf install zip -y
[root@hogehoge tmp]# zip -r psycopg2-binary_3_11.zip python
```
</details>

# なぜ、Dockerコンテナ上だとうまくいくのか？
では、ここからが本題です。まず結論から。
Dockerコンテナ上だとうまくいく原因は、「**Lambdaを動かしている環境がAmazon Linux環境である**」からです。

裏を返すと、この環境に直面するのは、基本的にWindows OSをローカルで使用している人のみだと思いますし、この問題に直面する理由は、「**Windows環境で作成したパッケージのレイヤーは、（Amazon Linux環境の）Lambdaではうまく機能していないから**」とまとめることができそうです。

# パッケージの中身の差分を見てみよう！

さらに深堀したいと思います。なぜ、OSの違いによってモジュールインポート時にエラーが起きてしまうのでしょうか？

それを解明するべく、「Windowsで作成したpsycopg2-binaryのレイヤー」と「Amazon Linux環境で作成したpsycopg2-binaryのレイヤー」を比較してみました。
比較結果は、`diff -r {2つのレイヤーのpath} > diff.txt`でtxtファイルに出力しています。

抜粋した結果は以下の通りです。（Pathは違いが分かるように変えています。）
```txt
Only in python_windows/python/psycopg2: _psycopg.cp311-win_amd64.pyd
Only in python_linux/python/psycopg2: _psycopg.cpython-311-x86_64-linux-gnu.so
(省略)
Only in python_linux/python: psycopg2_binary.libs
```

OSの違いによって変わっているのは、特定のファイルの拡張子です。
* Windowsで作成したレイヤー：`.pyd`
* Linuxで作成したレイヤー：`.so`

この拡張子について調べてみると、以下のようなことがわかります。
* `.pyd`はpython dynamic module の略で、`.dll` (dynamic link library)という**Windowの動的リンクライブラリで使用される拡張子**とほぼ同義である。（[ドキュメント](https://docs.python.org/2/faq/windows.html#is-a-pyd-file-the-same-as-a-dll)参照）
* `.so`はshared object の略で、**UNIX系の動的リンクライブラリで使用される拡張子**である。
* `.so` と `.pyd`には互換性はなく、`.pyd`のファイルをLinux環境で開くといったことは基本的には難しい。

ここまでで、「**動的リンクライブラリに使用されている拡張子がOSにより異なり、それがエラーの原因になっていること**」ということがわかりました。


### モジュールインポートエラーが起きるまでの流れを追ってみる
より詳細に処理を追うために、パッケージの`python/psycopg2/__init__.py`の中身を見てみます。
```python
# Import the DBAPI-2.0 stuff into top-level module.

from psycopg2._psycopg import (                     # noqa
    BINARY, NUMBER, STRING, DATETIME, ROWID,

    Binary, Date, Time, Timestamp,
    DateFromTicks, TimeFromTicks, TimestampFromTicks,

    Error, Warning, DataError, DatabaseError, ProgrammingError, IntegrityError,
    InterfaceError, InternalError, NotSupportedError, OperationalError,

    _connect, apilevel, threadsafety, paramstyle,
    __version__, __libpq_version__,
)
（省略）
# Register the Decimal adapter here instead of in the C layer.
# This way a new class is registered for each sub-interpreter.
# See ticket #52
from decimal import Decimal                         # noqa
from psycopg2._psycopg import Decimal as Adapter    # noqa
_ext.register_adapter(Decimal, Adapter)
del Decimal, Adapter
```

モジュールインポート時に、最初に読み込む`__init__.py`の中身に`from psycopg2._psycopg`があり、ここでエラーが起きているといることがわかります。そして、`_psycopg.cp311-win_amd64.pyd`の中身をインストールしようと試みていることも以下からわかります。

https://docs.python.org/2/faq/windows.html#is-a-pyd-file-the-same-as-a-dll
 > You can then write Python “import foo”, and Python will search for foo.pyd (as well as foo.py, foo.pyc) and if it finds it, will attempt to call initfoo() to initialize it. 

まとめると、モジュールインポート時のエラーは、「**パッケージ内のDLLがビルドできない**」ことが原因であるといえそうです。

## なぜ、動的リンクライブラリ（DLL）が必要なのか？
ここまでの話を通して、「動的リンクライブラリを使用する理由」を知ることもこの問題を理解するうえで重要になってきます。
そもそも、動的リンクライブラリって何？って人は[この記事](https://atmarkit.itmedia.co.jp/ait/articles/1105/27/news111.html)などを参考にしてみてください。
動的リンクライブラリ（DLL）は、「**C言語とPythonの橋渡し**」であると考えると理解しやすいでしょう。

`psycopg2`ライブラリでは、PostgreSQLとの通信を行うために、低レベル言語であるC言語の`libpq`ライブラリを利用しています。その際に、`psycopg2`ライブラリ内で、外部ライブラリである`libpq`のバージョンを静的に管理することが複雑であるため、動的リンクを用いています。これはプログラム実行時に適切なバージョンの`libpq`ライブラリを読み込むということを意味しています。さらに、`psycopg2-binary` パッケージを使うことにより、C言語でのコンパイル作業が不要となるので、ユーザーは自分でコンパイル作業をせずに済み、DLLを直接リンクするだけで使用することができるため、手間が省けます。

まとめると、「**C言語や他の言語で書かれたライブラリをPythonで利用するためにDLLがある**」ということが理解できていれば、十分でしょう。より詳細について知りたい場合やイメージをつかみたい人は、[Pythonのドキュメント](https://docs.python.org/ja/3/extending/extending.html)や[C言語でPythonのモジュールを作成した記事](https://qiita.com/Kashiwara/items/2088ba011446637aa8f4)なども参考になると思います。

# まとめ
まとめると、モジュールインポート時にエラーが出る原因は以下のようになっているとわかりました。

1. Lambda上のPythonで`import psycopg2`を試みる。
2. パッケージの中の`python/psycopg2/__init__.py`において、`from psycopg2._psycopg import ...`が実行される。
3. `_psycopg.cp311-win_amd64.pyd`の中身をLinux環境でインストールしようとするが、`.pyd`拡張子の互換性がないため、エラーが起きてしまう。Linux環境では、DLLとしては`.so`である必要がある。

また、今回のケースで、`.pyd`や`.so`などのDLLが使用されている主な要因は、「PostgreSQLとの通信を行うために、C言語のライブラリを使用する必要がある」ためであるということもわかりました。

今回の記事を通して、DLLというものの存在を知っていただき、それがモジュールインポートエラーの原因になっているということがわかっていただければ、幸いです。

# 参考記事
https://qiita.com/fumiya0238/items/793eb41ef63c1e80d81d

https://memoribuka-lab.com/?p=3817

https://qiita.com/hoto17296/items/a374efc2d8159d75bc71#fn2