AWS Lambda 用の python パッケージをクロスコンパイルして serverless で deploy した話

もっとスマートなやり方がないのかどなたか教えていただきたく。

# きっかけ
AWS Lambda から RDS (mysql / aurora) に繋げようと、mysqlclient を import したわけです。
ローカルでそつなく動いたので [serverless](https://serverless.com/) でデプロイ。AWS 管理コンソールからテスト実行してみると、なんと動かない。

どうやら mysqlclient にネイティブコードが含まれているせいで、Mac でビルドしたイメージを linux で動かそうとしてエラーが出ているご様子。

この辺を解決するために色々してみた、というお話です。


# まずそのままデプロイしてみる

ちなみに python の外部パッケージは [serverless-python-requirements](https://github.com/UnitedIncome/serverless-python-requirements) で管理しています。

- 参考：[Serverless Frameworkのプラグインを利用した外部モジュールの管理](http://dev.classmethod.jp/cloud/aws/managing-external-modules-with-serverless-framework-plugin/)

こんな感じの俺サービスが、

```
ore-service
├── handler.py
├── requirements.py
├── requirements.txt
└── serverless.yml
```

こんな内容だったとします。

```requirements.txt
mysqlclient
```

```handler.py
#!/use/bin/env python
# -*- coding: utf-8 -*-

import requirements
import MySQLdb

def lambda_handler(event, context):
    con = MySQLdb.connect(host='〜', db='〜', user='〜', passwd='〜', charset='utf8')
    cur = con.cursor()
    cur.execute("SELECT * FROM なんとか〜")

    ....(略)
```

sls deploy して AWS コンソールから AWS Lambda をテスト実行してみるとエラー。
Log output には以下のような出力があります。

```
Unable to import module 'handler': /var/task/_mysql.so: invalid ELF header
```

ああそうですか。
Mac でビルドした _mysql.so を linux で動かそうとしてますからね。

# serverless-python-requirements の dockerizePip を使ってみる

serverless-python-requirements には dockerizePip なるオプションがありまして、true にすると [docker-lambda](https://github.com/lambci/docker-lambda) イメージを使ってクロスコンパイルしてくれるとのこと。
早速やってみます。

serverless.yml は以下のような感じ。

```serverless.yml
...(略)

plugins:
  - serverless-python-requirements

custom:
  pythonRequirements:
    dockerizePip: true

...(略)
```

で、デプロイすると残念ながらめっちゃエラー。

```
$ sls deploy
Serverless: Installing required Python packages...

  Error --------------------------------------------------

     Command "python setup.py egg_info" failed with error
     code 1 in /tmp/pip-build-4MzA_g/mysqlclient/
```

これ、mysqlclient のビルドに必要な python-devel と mysql-devel が docker イメージに入ってないのが原因らしい。


# lambci/lambda でクロスコンパイルする

「EC2 でビルドしなきゃだめなの？」とテンションだだ下がりつつ serverless-python-requirements の[ソース](https://github.com/UnitedIncome/serverless-python-requirements/blob/master/index.js) 見てみたら、なんとなくやってることが理解できた。
コンテナ起動して pip install してるだけっぽい。

そしたら自分でコンテナ起動して、bash で入って必要ライブラリインストールすればいいんですよね。
以下のような感じ。

```
$ docker run -it --rm -v "$PWD":/var/task "lambci/lambda:build-python2.7" bash

bash-4.2# cd /var/task
bash-4.2# yum -y install python-devel mysql-devel
bash-4.2# pip install mysqlclient -t .
bash-4.2# cp /usr/lib64/mysql/libmysqlclient.so.18 .
```

コンテナの /var/task をローカルのカレントにマウントしておく。
この辺はさっき見てたソースの installRequirements() あたりの処理をまるパクリ。

で、必要ライブラリをインストールして pip。
ついでに libmysqlclient.so.18 も必要なのでコピーしておく。

serverless は serverless.yml がある場所にあるファイル、フォルダはまるっと zip にしてアップロードしてくれるので、あとはこのまま sls deploy すればよい。

（さっき serverless.yml に追記した dockerizePip: true は削除しておくこと）

これで lambda 内から linux 用にビルドした mysqlclient がロードされ、無事 RDS にアクセスできます。

# 実行環境別にロードするパッケージを使い分ける

ここで問題発生。
ローカルに linux イメージの mysqlclient があるせいでローカル実行ができなくなってしまいます。

```
$ python handler.py
Traceback (most recent call last):
  File "handler.py", line 18, in <module>
    import MySQLdb
  File "./MySQLdb/__init__.py", line 19, in <module>
    import _mysql
ImportError: dlopen(./_mysql.so, 2): no suitable image found.  Did find:
        ./_mysql.so: unknown file type, first eight bytes: 0x7F 0x45 0x4C 0x46 0x02 0x01 0x01 0x00
```

よってロードする mysqlclient バイナリを切り替える仕組みをととのえます。
「linux ELF は ./third-party/ 配下に置き、実行環境が mac 以外だったらそちらを先にロードする」という作戦。
プロジェクトルートが外部パッケージフォルダで汚れることもない。

ディレクトリ構成は以下の通り。

```
ore-service
├── third-party
│   ├── MySQLdb
│   ├── _mysql.so
│   ├── libmysqlclient.so.18
│   └── その他いろいろ linux 用ファイル群
├── handler.py
├── requirements.py
├── requirements.txt
└── serverless.yml
```

クロスコンパイルは以下のようにする。
（マウントポイントを third-party/ にしただけ）

```
$ mkdir third-party
$ cd $_

$ docker run -it --rm -v "$PWD"/third-party/:/var/task "lambci/lambda:build-python2.7" bash

(以下同文)
```

で、python 側で OS を判定してロード先を切り替えます。
sys.path の先頭に third-party を差し込むのと、あと python は LD_LIBRARY_PATH 的な設定がないらしいので libmysqlclient.so.18 を直接ロードしてあげる。

```handler.py
import platform
if platform.system() != "Darwin":
    import sys
    import ctypes
    sys.path.insert(0, './third-party/')
    ctypes.CDLL("./third-party/libmysqlclient.so.18")
```

泥臭いですが、これでなんとか目的は達成できました。

あとは third-party ディレクトリを .gitignore しておいて、クロスコンパイル処理を Dockerfile とかにまとめてしまえば構成管理的にも大丈夫かなというところです。

# まとめ

こんなんせなあかんのん？