PythonとOpenCVを動かすためにHerokuとDockerに入門した流れ

### 背景
OpenCVを使ったちょっとしたお遊びWebアプリを運用してみたくなった。Webアプリとして動作させる環境について検討し、HerokuとDockerに入門することになったので、その流れをまとめる。

### サーバ環境の検討
個人の趣味として開発する前提で、どこまで長続きするかも分からないので、始める敷居が低いことを重視した。そうすると、料金方面の理解が難しそうなAWS系より、確実に無料で試せるHerokuで始めてみることにした。

### Heroku
手始めにPython用の[公式のチュートリアル](https://devcenter.heroku.com/articles/getting-started-with-python)を実施し、問題なくスムーズに完了することができた😊

続けてHeroku上でOpenCVを使うことを試したい。Pythonでは[opencv-python](https://pypi.python.org/pypi/opencv-python)を入れることで、OpenCVを自力でビルドせずに使う方法があるので、ひとまずこれを試してみた。依存パッケージにこれを追加してHerokuにデプロイ…が動かない！😂
```libSM.so.6```がないというエラーになった。

こんな時にHerokuではBuildpackという仕組みを使うか、Dockerのコンテナをデプロイすることで、カスタマイズした環境を作ることができるようになっている。どうせどちらかを新しく覚えるならDockerを選ぶことにした。

### Docker
Docker自体のMacへの導入と[公式のチュートリアル](https://docs.docker.com/get-started/part2/)についてはスムーズに完了することができた😁

### DockerとHeroku
HerokuにDockerコンテナをデプロイする方法についても[Heroku公式のチュートリアル](https://devcenter.heroku.com/articles/container-registry-and-runtime)を問題なく完了することができた😃

と言っても、これだけでは用意されたものを手順どおりに動かしただけなので、これからの開発が進められるように、理解が必要そうなポイントを確認した。

#### DockerコンテナをHerokuで動かす要件の確認
まずはHerokuでDockerコンテナを動かす際の要件を把握したいと考えた。Herokuのドキュメントには、DockerfileのCMDでHTTPサーバーを起動しなければいけないようなことが書かれている。

チュートリアルではgunicornを使ったHTTPサーバーが起動される内容になっているが、この時点で私はgunicornがどういうものか知らなかったので、切り分けて理解することができなかった。そこで、Python標準添付のhttp.serverを起動する最小限のDockerコンテナを作成してみることにした。作業ディレクトリに次の2ファイルを作成した。

```:Dockerfile
FROM heroku/heroku:16

ADD . /opt
WORKDIR /opt

RUN useradd -m myuser
USER myuser

CMD python3 -m http.server $PORT
```

```index.txt
Hello, world!
```

次のコマンドでコンテナのビルドを行い、ローカル環境で起動して、localhost:5000/index.txtが無事に配信されることを確認できた。
```
$ docker build -t docker_heroku .
$ docker run -it -p 5000:5000 -e PORT=5000 docker_heroku
```

このコンテナをHerokuへのデプロイを試したい。まず、Herokuへのコンテナのデプロイが初めての場合は以下の準備が必要。
```
$ heroku plugins:install heroku-container-registry
$ heroku container:login
```

Herokuのapp作成とコンテナのデプロイ。ちなみに作業ディレクトリをgitリポジトリとして初期化しておかないと、```heroku create```の実行時にデプロイ先appが自動では登録されないので要注意。
```
$ heroku create
$ heroku container:push web
```

これでHerokuへのデプロイが始まり、500MBほどのアップロードをしようとするがアップロードが非常に遅くて3時間以上かかった😱
時間はかかったがひとまず問題なく完了し、Heroku上でindex.txtの表示を確認できた。これで任意のHTTPサーバーで$PORTをlistenすればいいということが納得できた。

デプロイ後は通常時と同じように、ps:scale web=0で停止したり、ps:scale web=1で起動したりできる。

アップロード時間については、2回目以降は差分のみになるはずなのと、たまたま自分が試したタイミングに調子が悪かっただけだと信じることにして、ひとまず先に進める。


#### ボリュームのマウント方法
Dockerコンテナによる開発環境では、開発したアプリケーションの実体はコンテナのビルド時にCOPYで組み込むのではなく、毎回のビルドを避けるためにVOLUMEでマウントする方が良いようだ。

一方、HerokuではDockerfileのVOLUMEはサポートされていない。ということは、ビルド時に組み込むしかないのだろうか？Dockerfileを分けるしかないのか？とちょっと混乱したのだが、よくよく調べると次のような方法が良いようだ。

- DockerfileではCOPYで、docker-compose.ymlではvolumesで、同じ意味になるように両方に設定を書いておく
- 開発時はdocker composeから起動することで、volumesの方が優先されてマウントされるような状態になり、ファイル更新の度にビルドする必要はなくなる
- デプロイ時はDockerfileからビルドされるのでCOPYの方で組み込まれる

この方法で運用できることは、[Herokuのチュートリアル](https://devcenter.heroku.com/articles/local-development-with-docker-compose)でもほのめかされてはいるのだが、両者で同じパスを指定しても動作に問題ないのかについては確信が得られなかった（私の英語力の問題の可能性もあるが）。しかし、[こちらの記事](http://ryoichi0102.hatenablog.com/entry/2017/05/11/184143)の引用部からDockerが想定している利用方法であることが確認できた。

動作確認したDockerfileの内容は先述のものと同じで、docker-compose.ymlは下記の内容。

```docker-compose.yml
version: '3'
services:
  web:
    build: .
    ports:
      - "5000:5000"
    environment:
      - PORT=5000
    volumes:
      - .:/opt
```

この状態で```docker compose up```でローカル環境でコンテナを起動し、ホスト側からindex.txtの内容を書き換えて、コンテナの再起動なしでその変更が反映されることを確認できた。

また、```heroku container:push web```でHerokuに再度デプロイを行って、問題がないことを確認できた。


### OpenCVの導入
これでDockerとHerokuで開発を進められそうな手応えを得られたので、ようやくここまでで作成したコンテナにOpenCVを入れてみる。

OpenCVは自分でビルドしてもいいのかもしれないが、今回はpipによるopencv-pythonパッケージの導入で済ませた。最初の方に書いた状況と同じく、それだけだと実行時に```libSM.so.6```がないというエラーになるので、そのインストールも追加している。

```Dockerfile
FROM heroku/heroku:16

ADD . /opt
WORKDIR /opt

RUN apt-get update && apt-get install -y \
    python3-pip \
    libsm6

RUN pip3 install opencv-python

RUN useradd -m myuser
USER myuser

CMD python3 main.py $PORT
```

何らかのOpenCVを使った処理を試す必要があるので、次のようなmain.pyを作成した。Lennaさんの画像を読み込んでそのサイズを返すという内容で済ませている。

```main.py
import os,sys
from http.server import HTTPServer, SimpleHTTPRequestHandler

import cv2

class MyHandler(SimpleHTTPRequestHandler):
    def do_GET(self):
        img = cv2.imread("./Lenna.jpg")
        body = "Lenna W:{0} H:{1}".format(img.shape[1], img.shape[0]).encode("utf-8")
        self.send_response(200)
        self.send_header("Content-type", "text/html; charset=utf-8")
        self.send_header("Content-length", len(body))
        self.end_headers()
        self.wfile.write(body)

port = int(sys.argv[1])
httpd = HTTPServer(("", port), MyHandler)
print("start")
httpd.serve_forever()
```

これを```docker compose up```で起動し、localhost:5000にアクセスすると"Lenna W:150 H:150"と期待した内容が表示できた。

最後にこのコンテナをHerokuにデプロイし、動作の確認ができた。

これでひとまず、この環境でやりたいことができそうな手応えを得ることができた😆