リモート分析環境 PyCharm + Docker + Plotly

## 背景
これから勤める会社の環境を想定し、分析環境を整える。

## 前提
・リモートホスト(AWS EC2を想定)のDockerでPythonカーネルを動かす
・開発環境はPyCharmとする。PyCharmをScientific modeで動かせばインタラクティブな分析環境が実現できるので、Jupyterである必要はない(※)
・グラフ描画はPlotlyを使いたい

※ PyCharmのJupyterは開発が遅れているようで、コンソール機能がなく、実装される気配もない。参考: https://youtrack.jetbrains.com/issue/PY-31502
コードの試し書きをするコンソールがないのはデータサイエンス環境として致命的なので、PyCharmでのJupyterは諦める。

## 必要なファイル

```
project
│   Dockerfile
│   docker-compose.yml
│   requirements.txt
│   startup.py
└───dash-server
    │   app.py
    └───assets
        │   shared_fig_to_plot.pkl
```


### Dockerfile
中心となるDockerの設計。SSHログインできるようにしている。


```dockerfile:Dockerfile
# ベースコンテナは何がベストチョイスなのかわからなかったが、これを選択。
FROM python:3.8

# SSH周りの設定。ついでにsupervisorも。
RUN apt-get update && apt-get install -y openssh-server supervisor
RUN mkdir /var/run/sshd
RUN echo 'root:password' | chpasswd
RUN sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config
RUN sed -i 's/#PasswordAuthentication/PasswordAuthentication/' /etc/ssh/sshd_config

# SSH login fix. Otherwise user is kicked off after login
RUN sed 's@session\s*required\s*pam_loginuid.so@session optional pam_loginuid.so@g' -i /etc/pam.d/sshd

# Plotlyを画像ファイルにするためのPlotly-orcaを動かすために必要
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        wget \
        xvfb \
        xauth \
        libgtk2.0-0 \
        libxtst6 \
        libxss1 \
        libgconf-2-4 \
        libnss3 \
        libasound2 && \
    mkdir -p /opt/orca && \
    cd /opt/orca && \
    wget https://github.com/plotly/orca/releases/download/v1.2.1/orca-1.2.1-x86_64.AppImage && \
    chmod +x orca-1.2.1-x86_64.AppImage && \
    ./orca-1.2.1-x86_64.AppImage --appimage-extract && \
    rm orca-1.2.1-x86_64.AppImage && \
    printf '#!/bin/bash \nxvfb-run --auto-servernum --server-args "-screen 0 640x480x24" /opt/orca/squashfs-root/app/orca "$@"' > /usr/bin/orca && \
    chmod +x /usr/bin/orca

# 必要なモジュールをpipでインストール
COPY requirements.txt /tmp
WORKDIR /tmp
RUN pip install -r requirements.txt

# このあたりが私オリジナルな部分で、詳しくは後述
RUN mkdir /opt/my
COPY startup_for_pycharm.py /opt/my/
COPY dash-server/ /opt/my/dash-server/

# supervisorによってSSH(+他)を起動
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
CMD ["/usr/bin/supervisord"]
```

### docker-compose.yml
下記の通りすごくシンプルなので、別にdocker-compose使わなくてもいいが、用意しておくことでビルドや起動コマンドを覚えておく手間が省ける。

```yaml:docker-compose.yml
version: "3"
services:
  my-python:
    build: .
    image: my-python
    ports:
      - '9922:22'
      - '9923:9923'
```

### requirements.txt
インストールするPythonモジュールの定義。下記は最低限必要なものだけ記載してあるので、実際は適宜追加する。

```txt:requirements.txt
ipython
numpy
pandas
plotly
dash
psutil
matplotlib
```

### supervisord.conf
Dockerが起動するsupervisorの設定。SSH(+他)が起動される。

``` :supervisord.conf
[supervisord]
nodaemon=true

[program:sshd]
command=/usr/sbin/sshd -D

[program:dash]
command=/usr/local/bin/python /opt/my/dash-server/app.py
```

### startup.py
PyCharmでコンソールとしてiPythonが起動されるときに呼ばれるスクリプト。実際に呼ばれるようにするためには後ほどPyCharm上で設定する。上記Dockerfileではコンテナ内にコピーだけしている。以下、ポイントを解説する。

- __ポイント1__: PyCharmではMatplotlibでグラフを作成すると、それが静的ファイルとしてではあるがSciViewというペインに表示される仕組みがある。Plotlyのグラフには対応していないが、シンプルなハックで対応させたのがこちらである。https://stackoverflow.com/a/65952856
    - plt.show()に対応してSciViewにグラフが表示される仕組みを利用し、Plotlyのグラフを一度画像のデータにしてimshowでMatplotlibのグラフに変換している。
- __ポイント2__: SciViewは静的な画像としてグラフを観察することになるが、動的に動かせるグラフを見たい場合もある。後述の`dash-server`で表示するためのインターフェイスを用意している。


```python:startup.py
# 日常遣いのモジュールをインポートしておく
import sys, os
import io
import numpy as np
import pandas as pd

import plotly.express as px
import plotly.io as pio
import matplotlib.pyplot as plt
import matplotlib.image as mpimg
import pickle

# [ポイント1] SciViewでPlotlyを表示する
pio.orca.config.default_scale = 2.0 # figsizeとの兼ね合いだがこれくらいにするときれいになった
def sci_view(fig, figsize=(10, 10)):
    format = "png"
    img_bytes = fig.to_image(format=format)
    fp = io.BytesIO(img_bytes)
    with fp:
        i = mpimg.imread(fp, format=format)

    fig = plt.figure(figsize=figsize)
    ax = fig.add_subplot(111)
    fig.subplots_adjust(left=0, right=1, bottom=0, top=1) # 無駄な余白省く設定
    ax.axis("off")
    ax.imshow(i, interpolation='nearest')
    plt.show()

# [ポイント2] Dashを介してPlotlyを表示するため
shared_file = "/opt/my/dash-server/assets/shared_fig_to_plot.pkl"
def dash_view(fig):
    file = open(shared_file, 'wb')
    pickle.dump(fig, file)

```

### dash-server/app.py
所定のfigureファイルをブラウザに表示するだけのDashサーバ。Dashのhot reload機能によって、所定のfigureが更新されると自動で表示も変わる仕組みになっている。figureはDashがウォッチする所定の場所であるassetsに置く必要がある。

```python:dash-server/app.py
import dash
import dash_core_components as dcc
import dash_html_components as html
import pandas as pd
import pickle

# ウォッチするfigureファイル
# shared_file = "./assets/shared_fig_to_plot.pkl"
shared_file = "/opt/my/dash-server/assets/shared_fig_to_plot.pkl" # フルパスのほうが何かと間違いがない

def make_layout():
    try:
        print("load fig file")
        fig = pickle.load(open(shared_file, 'rb'))
    except:
        return html.Div(
            html.H3(children="Could not load figure")
        )

    now = pd.Timestamp.now()
    div = html.Div(
        children=[
        html.H3(children='Monitoring ' + str(now)),
        dcc.Graph(
            id='the-graph',
            figure=fig,
            style={
                "height":"90vh", # グラフをブラウザ全体に大きく表示するために
                },
        )
        ],
    )

    return div


external_stylesheets = ['https://codepen.io/chriddyp/pen/bWLwgP.css']
app = dash.Dash(__name__, external_stylesheets=external_stylesheets)
app.layout = make_layout

if __name__ == '__main__':
    # ポート番号は好きに設定。hostは'0.0.0.0'にしておかないとつながらない。debug=Trueは必須
    app.run_server(debug=True, port=9923, host='0.0.0.0')

```


## デプロイ手順

### AWS
EC2インスタンスを立ち上げ、セキュリティグループでポート9922と9923を開けておく。

### Dockerをビルドし、起動
EC2にDockerをインストールし、

```
$ docker-compose build
$ docker-compose up -d
```

### PyCharmでinterpreterの設定
Preferences -> Project: Python interpreterで、ギアのマークを押してAdd。出てくるウィザードにて、SSH interpreterを選択し、HostにEC2のアドレスを入力。
Portはdocker-composeで指定した通り、9922とする。Usernameはroot。パスワードはpassword。pythonのパスは`/usr/local/bin/python`である。うまく設定できれば下記のように登録されるはず。

![スクリーンショット 2021-03-12 21.51.26.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/801279/98c672c7-56b9-0dc2-d6d9-534bc73c9cdc.jpeg)

### スタートアップスクリプトの登録
同じくPreferencesから画像のように、先ほどコンテナの中に入れたスタートアップスクリプトが呼び出されるように設定する。
![スクリーンショット 2021-03-12 21.53.37.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/801279/54291f44-b2e2-51c8-90b2-1875ba982f6e.jpeg)


## 使い方

### sci_view()


```python:
import plotly.express as px
n = 100
fig = px.line(np.random.randn(n))
sci_view(fig)
```

![スクリーンショット 2021-03-12 22.14.20.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/801279/a6cd6927-d2a8-6e42-1a0d-5d5f5f9615e8.jpeg)


### dash_view()

```python:
import plotly.express as px
n = 100000
fig = px.line(np.random.randn(n))
dash_view(fig)
```

ブラウザで、http://EC2のアドレス:9923 にアクセスすると下記のようにグラフが表示され、インタラクティブに拡大縮小ができる。
PyCharmでdash_view()を呼び出すたびにこちらに表示されるグラフが変わることも確認できる。

![スクリーンショット 2021-03-12 21.59.35.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/801279/88d0e617-1062-e3be-b374-9843541ff45d.jpeg)


## パフォーマンス

EC2のインスタンスタイプ=t3.microで試した。
dash_view()のほうがサクサク動く。n=数十万サンプルでも問題なく描画できた。sci_view()はn=1万サンプル程度でもハングアップしてしまった。