multi stage build でpythonのC言語依存モジュールを wheel形式でインストールする

#背景
[alpineでC言語依存モジュールを pip install すると激重になる話](https://qiita.com/naka345/items/d1e05ac9649ff2292ef0)

* alpineだと上記事象が避けられない
 * multi stage buildで生成物を引っ張ってくるのが折衷案として良さそう
* 生成物を再利用する方法が安全ではない
  * ベースイメージから生成物をベタに`COPY`している部分をやめる
  * `*.whl`を持ってきて安全にインストールする方法を採ってみる
* ついでに小手先技でimageを軽くしてみる

# 手順
1. alpineイメージ上で必要モジュールをビルド
 1. 生成物(wheelファイル)は再利用しやすいように一箇所にまとめる
1. ビルド完了済みイメージとして docker hub に push
1. 実行環境用のalpineに multi stage build でwheelのディレクトリだけ取得しインストール
1. 実行確認とお掃除

# 実装
まずは、必要なモジュール郡の取得から
今回は以下のモジュールを導入してみる

```python:requirements.txt
cycler==0.10.0
Cython==0.29.17
h5py==2.10.0
joblib==0.14.1
kiwisolver==1.2.0
matplotlib==3.2.1
numpy==1.18.4
pandas==1.0.3
Pillow==7.1.2
pyparsing==2.4.7
python-dateutil==2.8.1
pytz==2020.1
scikit-learn==0.22.2.post1
scipy==1.3.3
six==1.14.0
```

## 必要モジュールを一箇所に集める
alpineの場合だとc言語依存モジュールはtarやzipなどの圧縮形式が落ちてくる。
これらをwhl形式に変換しておく必要がある。

### 下準備
whlに変換時に必要なライブラリが存在するはずなので、apk経由でinstallしておく

```
apk update \
  && apk add --virtual .build --no-cache openblas-dev lapack-dev freetype-dev 
...
  && apk add --virtual .community_build --no-cache -X http://dl-cdn.alpinelinux.org/alpine/edge/community hdf5-dev
```


### 必要なwhlファイルを用意する
`pip download`でもモジュールのダウンロードを実行できるが、
`pip wheel`コマンドだとダウンロードとtar/zipファイルを自動展開してビルドまで行ってくれるのでこちらを使用。
`pip wheel`も`-r`オプションを使用できるので`pip freeze > requirements.txt`などでバージョニングファイルを指定する。

```
pip wheel --no-cache --wheel-dir=./whl -r requirements.txt
```
- オプション補足
 - `--no-cache-dir`: キャッシュを使用・作成をしない。指定しないと`~/.tmp`とかにもりもりキャッシュされる。ビルド時の生成物もキャッシュされる。
 - `--wheel-dir`: wheelファイルのアウトプット先。

#### pip wheelを使用する場合の補足
残念ながら今回の場合は**途中で失敗する**
別alpine環境で構築しpip freezeした`requirements.txt`を使用しているのだが、
`numpy`,`scipy`が使用できる環境でないため`scikit-learn`ビルド中に落ちる。

`pip install -r requirements.txt`だとpip側がよしなにインストールしてくれるが[^1]
[^1]: pipのインストール順序は依存ライブラリや優先順位などを考慮せず一気通貫で実行されるため、`pip install`でも同様の事象は起こる。代わりに"circular dependency"なため途中で失敗したモジュールは、他全てのモジュールの導入が済み次第もう一度ビルドを実行し直すことで回避している。
今回に限っては先に依存モジュールを入れておくしか無い。[^2]

[^2]: 手元の環境でscipy~=1.4だとエラーが出て失敗するため、素直に入ってくれた1.3系を指定

```
pip install cython numpy==1.18.4 scipy==1.3.3
pip wheel --no-cache --wheel-dir=./whl -r requirements.txt
```

numpyやscipyのビルドを回避するために別途イメージを作ろうとしていたのに
なんだか無意味なことをしている気がしてきたぞ...？


## ビルド完了後`docker hub`にpush
適当にタギングしてpush

```
docker tag 123456789a hoge/builder-image:latest
docker push hoge/builder-image:latest
```

## 実行環境用に生成物を持ってくる
ここからは実行環境用のdockerfile上で作業していく。

### ローカルディレクトリのwheelをインストール
`pip install`で複数モジュールを指定するにはベタ書きしていくか`--requirement`でテキストファイルを指定する。
適当なディレクトリにwhlを集めて丸ごとインストールできるような仕様は無い。

今回はマルチステージビルドでwheelが入ったディレクトリを`COPY`し下記コマンドを実行することでローカルのwheelからインストールする。

```
pip install --no-index --no-deps --no-cache-dir -f ./whl -r requirement.txt
```
- オプション補足
 - `--no-index`: PyPiのようなインデックスサイトを使わない。オンラインを経由したくない時に使う
 - `--no-deps`: 依存モジュールをインストールしない。ただし、モジュール側で明確に指定されている場合はその限りでない模様。
 - `-f`,`--find-links`: モジュールの検索先を指定。ローカルパスを指定したい時はこれを使う

### --upgrade で対応するモジュール
pipやsetuptoolsなど`--upgrade`オプションをつけて導入したいモジュールはアップグレード用のテキストファイルに分けて導入する。
`-r`オプションで参照するテキストファイルはバージョン指定無しでもインストールは可能だ。

```text:upgrade.txt
pip
setuptools
wheel
```

下記コマンドで特定ディレクトリにまとめたモジュールをupgrade

```
pip install -U --no-index --no-deps --no-cache-dir -f ./upgrade  -r upgrade.txt
```
ただ、管理するファイルも増えるのでオフライン環境下でもない限りはdockerfileに直接書いた方がいい。

### 実行確認
importできるか確認。shellファイルにしておいて`RUN`コマンド実行に直接叩く。

```shell:import_test.sh
#!/bin/sh
python -c "import numpy"
python -c "import scipy"
python -c "import h5py"
python -c "import pandas"
python -c "import matplotlib"
python -c "import sklearn"
```

## 後始末
dockerイメージの軽量化のため余分なファイル削除
whlのビルドに使っていたイメージは生成物さえ残っていればいいので他は全部消す。

```shell:builder-image
apk del --purge .build .testing_build
pip freeze | xargs pip uninstall -y
pip cache purge
```

余分なファイルを削除した事で、ビルドしたイメージがどれだけ軽くなったか確認。**360MB**ほど減量に成功した模様

```
# docker images
REPOSITORY              TAG                 IMAGE ID            CREATED             SIZE
naka345/wheel_build     latest              b6c9df898334        9 minutes ago       1.04GB 
naka345/wheel_build     latest              3236cf2f87de        2 days ago          639MB
```

次は実行環境側の整理。
[公式のpython docker](https://github.com/docker-library/python/blob/3ebdcfdf001fb2a5b6c4d283379371f7d0537436/3.8/alpine3.11/Dockerfile)がとてもスマートなので、これに倣ってファイルを消し込んでいく。[^3]

[^3]: `-no-cache-dir`を指定してインストールした実行環境だと`pip cache purge`を実行するとcacheファイルが見つからずエラーコードを返す。地味に使いづらい。

```shell:execution-image
# モジュールに必要なファイルだけ新しい仮想パッケージとして括り、
find /usr/local -type f -executable -not \( -name '*tkinter*' \) -exec scanelf --needed --nobanner --format '%n#p' '{}' ';' \
    | tr ',' '\n' \
    | sort -u \
    | awk 'system("[ -e /usr/local/lib/" $1 " ]") == 0 { next } { print "so:" $1 }' \
    | xargs -rt apk add --no-cache --virtual .module-rundeps && \
  # ビルド時に使っていたパッケージ群は全て消す
  apk del --purge .build .community_build
# python側の余分なファイルやゴミの削除
find /usr/local -depth \
		\( \
			\( -type d -a \( -name test -o -name tests -o -name idle_test \) \) \
			-o \
			\( -type f -a \( -name '*.pyc' -o -name '*.pyo' \) \) \
		\) -exec rm -rf '{}' + 

# 今回の実行範囲分のゴミ掃除
rm -rf /tmp/whl
```

実行環境側で消さなかった時との比較をしてみる。

```bash
# docker images
REPOSITORY              TAG                 IMAGE ID            CREATED             SIZE
naka345/wheel_install   latest              f0df8a9887de        3 hours ago         1.29GB
↓
naka345/wheel_install   latest              27b4805053f2        3 hours ago         968MB
```

なんとか1GB以下に抑えることができた。

## dockerfileにしてみる
上記を踏まえてdockerfileに書き下す。
長くなるので[githubのリンク](https://github.com/naka345/qiita_product/tree/master/docker/wheel_build)を貼っておしまい。

# まとめ
時間がかかるモジュールもpip経由で安全に比較的手早く持ち込めるようにした。
dockerイメージも多少軽量化できた。

ただし、イメージを複数持たないといけない部分は据え置き。
requirements.txtの整合性が要になるので、
こいつが更新されたタイミングで`docker hub`に両イメージがpushされる仕組みがあれば楽になれるのかな？

## 参考文献
- [pip : Installation Order](https://pip.pypa.io/en/stable/reference/pip_install/#installation-order)
- [イメージのタグ付け、送信、取得](http://docs.docker.jp/linux/step_six.html)
- [pip installをオフラインで行う](https://qiita.com/saten/items/d2ac85947583723246bf)
- [Python, pipでrequirements.txtを使ってパッケージ一括インストール](https://note.nkmk.me/python-pip-install-requirements/)
- [docker-library/python : python/3.8/alpine3.11/Dockerfile](https://github.com/docker-library/python/blob/3ebdcfdf001fb2a5b6c4d283379371f7d0537436/3.8/alpine3.11/Dockerfile)