alpineでC言語依存モジュールを pip install すると激重になる話

# TL;DR
PyPiに上げられているC言語依存のpythonモジュールは、alpine標準のmuslには対応してないから毎回手元でコンパイルされるよ。
docker:alpineでどうしても使いたい場合は必要モジュールをビルドしたイメージを個別に用意しておくと良いよ。

# alpineさんちのpip事情

pythonのモジュール管理行うpip。そのpipで採用されており快適にモジュールの導入を可能にしているwheel形式だが、alpineに対応した`.whl`がPyPi上に存在しないモジュールがある。

## wheel
先に少しだけwheelの話。
wheelは元々Eggという形式の後継で作られたもので、`Built Distribution`と呼ばれるインストール形式であるそう。
`Built Distribution`は[ここの用語集](https://python-packaging-user-guide-ja.readthedocs.io/ja/latest/glossary.html#term-built-distribution)を見る限りでは以下の通り。
> Distribution 形式のうち、中身のファイルとメタデータをターゲットシステムの正しい場所へ移動するだけでインストールができるもの。

Egg や Wheel の実体は zip/tar などの圧縮形式の拡張なので、`pip install`時はダウンロードして展開後にpipで管理している場所にファイルを移動しているだけとなる。なので速い。

コンパイル済み拡張モジュールを含んだパッケージ[^1]も同様で取得・展開・移動するだけなので、これによりストレスフリーに使うことができている訳だ。
[^1]: 用語集曰く`Binary Distribution`と呼ぶらしく、拡張モジュールごと移動させ使用している。

...ただしPyPi上に使用しているOS・アーキテクチャに対応したwheelファイルが存在する場合のみである。[^2]
[^2]: PyPiのモジュールのページ左側[Navigation]->[Download files]から確認できる。

## alineはmusl-libcで動く
alpineは[musl](https://musl.libc.org/)を採用している。
muslは軽量・高速・シンプルを目標に標準Cライブラリの実装を１から行っており、glibcなどの非標準な拡張ライブラリにも対応している。alpineにぴったりだ。
ただし、完全な互換はまだ実現できていない模様で稀に使いたい関数が存在しなかったりする。

で、今回のここで書く内容もalpineがmuslを使用している事が原因で掲題通りの事象が発生している。

ちなみに読み方はマッスルらしい。つよそう。

## alpineはglibcを使っていない 
今日、多くのLinuxディストリビューションはglibcを採用しているし、バイナリも共有ライブラリとして動的リンクさせてるのも少なくない。
numpyなどC言語を使用しているモジュールも例に漏れず[^3]、PyPiにアップロードされているwheelファイルはglibc環境下で動くことを想定したビルドがなされている。つまり musl lib上で動く事を想定していない。[^4]
[^3]: ダウンロードしたwheelを`unzip`で展開してやると`*.so`が見える。pipが正しく動いている以上、OS・アーキテクチャに最適化されたものが存在しているはずだ。
[^4]: [muslはアプリケーションを単一のポータブルなバイナリファイルとして配布できるように静的リンクに最適化している。](http://www.musl-libc.org/intro.html)([wikipediaから引用](https://ja.wikipedia.org/wiki/Musl)) `ld-musl-x86_64.so.1`しか存在しないので`ld-linux-x86-64.so.2`にダイナミックリンクしているものは軒並み落ちる。

そのためalpine上で`pip install numpy`をするとサポートされた`.whl`が存在しないため`.zip`やら`.tar.gz`が降ってくる。ビルド前のソースコードを丸々落としてきているのだ。

```
Collecting numpy
  Downloading numpy-1.18.3.zip (5.4 MB)
     |████████████████████████████████| 5.4 MB 1.3 MB/s 
  Installing build dependencies ... done
```

ダウンロード後はalpine用に**一からコンパイルが実行されwheelファイルを作成する**。そのあと出来上がったwheelファイルをpipは取り込み直すことでpython上でimportできるようにしている。
これがC言語依存モジュールを`pip install`すると時間がかかる原因である。

※ 他言語やOS・アーキテクチャに依存しないモジュールや、pureなpythonで書かれたモジュールだとalpineでもwheel形式で落ちてくるため時間はかからない。

# 浮かび上がる諸問題
C言語依存しているものでも数秒でコンパイルできるものもあるが、numpyを入れようとすると数分程度かかってしまうしnumpy依存のscipyなどを使おうとすると更に数十分コンパイルに時間がかかってしまうこともある。

一回限りのビルドで今後全ての開発環境を賄えるローカル環境であればそれでも良いかもしれないが、
製品やサービスをCI/CDを含めた環境構築することを考えていくとなると、膨大なコンパイル時間はとてつもないほど大きな障害となる。
git のブランチをフックにしてunitテストが走るCI環境、サーバーなどにサービスを安全にデリバリーするCD環境、etc、etc...
毎回毎回長いコンパイルが走ってしまうと細かい修正の確認ですら一時間単位で浪費してしまうことになるし、他の作業が滞ってしまう原因にもなる。
そしてなによりtwitterをする時間が減ってしまう。大問題だ。
実際に業務でpandasをalpineに突っ込んでしまった時は睡眠時間まで減ってしまったのだから、冗談抜きで死活問題である。

# 回避策
これを防ぐためには以下のものが考えられる。

1. alpineを諦める
2. コンパイル済みdocker imageで対処する
  1. ベースのディストリビューションとして使う
  2. multi stage ビルドを利用する

## alpineを諦める
最も手間が少なく考える時間をかける必要がない有効な方法である。
ubuntuやcentosなどコンパイル済みで手段も確立しているディストリビューションに乗り換えてしまうのだ。
ただし、既に作り込んでしまっていて容易に乗り換えられないケースもあるのでは無かろうか？
業務で詰まった時は次の方法を取った。

## コンパイル済みdocker imageで対処する
wheelのコンパイルが済んだ状態のdocker imageを自由に使える場所に置いてしまい、使いたい時にpullするというもの。

```dockerfile:base-image
FROM python:3.8-alpine3.11
COPY require/requirements.txt /tmp
RUN apk update && \
  apk add --no-cache hoge-dev && \
  pip install --upgrade --no-cache-dir pip setuptools wheel && \
  pip install -r --no-cache-dir /tmp/requirements.txt
```
先ずはこんな感じでimageを作っておき、`docker push`でdocker hubやawsのecrなどに保存しておく。

```shell
docker push hoge/huga:latest
```
2パターンあるがビルド完了しているものを使うという意味では同じ。

### ベースのイメージとして使う
公式イメージなどと同じように`FROM`を使ってベースイメージとして使う。
簡単だが、コンパイル時のみに必要なライブラリを消し忘れたりするとこちらのイメージサイズも大きくなってしまうなど、`base-image`dockerfileへの依存度が高くなりメンテナンス性が下がってしまう。

```dockerfile:builder-pattern
FROM hoge/huga:latest
```

### multi stage ビルドとして使う
Docker17.05以上なら使える機能で、成果物だけイメージから取り出すことができる方法。
wheelコンパイルに関連するものは`hoge/huga:latest`に封じ込められるので後々気が楽になるから個人的におすすめ。

```dockerfile:builder-pattern
FROM hoge/huga:latest as pip_build
FROM python:3.8-alpine3.11

COPY --from=pip_build /usr/local/lib/ /usr/local/lib/
COPY --from=pip_build /usr/local/bin/ /usr/local/bin/
COPY --from=pip_build /usr/local/include/ /usr/local/include/
```

上記だとディレクトリを直接張り付けてしまっているので、コンパイル済みの`*.whl`を`hoge/huga:latest`から持ってきて`pip install`する方が安全かも。[^5]
[^5]: 記事書いてみました。[multi stage build でpythonのC言語依存モジュールを wheel形式でインストールする](https://qiita.com/naka345/items/008aba5428063c61aa6c)

### 欠点
解決策としなかったのは上記の方法だと、複数のdockerイメージのメンテナンスを避けられないためだ。
作成した実行環境よりもソースコードで使用するライブラリや書式の方が最新になってしまった場合が起こるたび、長大なコンパイルを実行しpushし直す必要が出てくる...
安眠できる日々は長くは続かなさそうだ。

## 参考文献
* [Using Alpine can make Python Docker builds 50× slower](https://pythonspeed.com/articles/alpine-docker-python/) : 滅茶苦茶参考にしました。
* [Python Packaging User Guide:用語集](https://python-packaging-user-guide-ja.readthedocs.io/ja/latest/glossary.html)
* [Introduction to musl](http://www.musl-libc.org/intro.html)
* [お前のDockerイメージはまだ重い💢💢💢](https://speakerdeck.com/stormcat24/oqian-falsedockerimezihamadazhong-i?slide=20)
* [マルチステージビルドの利用](https://matsuand.github.io/docs.docker.jp.onthefly/develop/develop-images/multistage-build/)

## おまけ記事
[alpineでC言語依存モジュールを pip install した時の時間を計測してみた](https://qiita.com/naka345/items/7ff430915383211f2b48)
【5/26追記】
[multi stage build でpythonのC言語依存モジュールを wheel形式でインストールする](https://qiita.com/naka345/items/008aba5428063c61aa6c)