Flask + pytest + Dockerで今風な開発環境を雰囲気で触る

![e5541a024a275b3260831a2b881e191b.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258841/22369017-28c6-ad6d-db76-76fb20b8ebca.png)

## 背景

ハンズオンっぽい形で説明する機会があったのでそれ用にまとめた資料を記事に起こしてみたやつ。

## TL;DR

① アプリケーション作成 (Flask + uWSGI)
② コンテナ化 (Docker)
③ CI (CircleCI + pytest + pycodestyle)
④ CD (Heroku)

``` :ディレクトリ構成
.
├── Dockerfile
├── LICENSE
├── README.md
├── __init__.py
├── app.py
├── flask_app
│   ├── bin
│   ├── include
│   ├── lib
│   └── pyvenv.cfg
├── requirements.txt
├── settings
│   └── app.ini
├── templates
│   └── index.html
└── tests
    └── __init__.py
```



## 概要

* [venvとは](https://docs.python.org/ja/3/library/venv.html)
  * 一つのマシン上で分離されたPython環境を構築するソフトウェア
* [Flaskとは](https://palletsprojects.com/p/flask/)
  * Python上で動くマイクロウェブアプリケーションフレームワーク
* [uWSGIとは]()
  * PythonでWebサービスを動かすAPサーバ
* [pytestとは]()
  * Pythonのテストフレームワークの一つ

## 環境

``` bash:実行イメージ
$ uname -a
Darwin w022581902949m.local 18.7.0 Darwin Kernel Version 18.7.0: Tue Aug 20 16:57:14 PDT 2019; root:xnu-4903.271.2~2/RELEASE_X86_64 x86_64

$ python3 -V
Python 3.7.4

$ pip3 -V
pip 19.2.3 from /usr/local/lib/python3.7/site-packages/pip (python 3.7)
```

## venv基礎

Python3系の本番での環境構築に利用できます。
virtualenvと同じような機能ですがこちらの方が多く使われいるようです。

``` bash:実行イメージ
# 環境の作成
$ mkdir flask_app && cd &_
$ python3 -m venv flask_app

# Active化
$ source flask_app/bin/active

# 確認
(flask_app)$ python -V
Python 3.7.4

(flask_app)$ pip -V
pip 19.0.3 from /Users/JPZ2046/tmp/flask_app/fla/lib/python3.7/site-packages/pip (python 3.7)

# (無効化するとき)
(flask_app)$ deactivate
```

## Flask + pytest + pep8基礎

上記で構築した仮想環境の中で2点をインストールしていく

``` bash:実行イメージ
(flask_app)$ pip install --upgrade pip
(flask_app)$ pip install Flask uWSGI
```

ここでlintとテスト用のモジュールもインストール

``` bash:実行イメージ
(flask_app)$ pip install pytest pycodestyle
```

#### FlaskでHello World

Hello Worldと実行しているPIDとアクセス元のIPアドレスを返すだけの簡単なFlaskアプリケーションを作成します。

``` python:app.py
from flask import Flask, request, jsonify, render_template
import psutil
import os
import re

app = Flask(__name__)


def gen_counter():
    """
    アクセスカウンタ用の関数
    """
    cnt = 0

    def _count():
        nonlocal cnt
        cnt += 1
        return cnt
    return _count


def get_resource():
    """
    ホストのメトリクスを収集する関数
    """
    return psutil.cpu_percent(interval=1), \
        psutil.virtual_memory().percent, \
        os.getloadavg()[0],


# カウンタ用のクロージャ
gc = gen_counter()


@app.route('/')
def hello():
    return f"Hello, World!\nIP:{request.remote_addr}\nPID:{os.getpid()}\n"


@app.route('/__health')
def test():
    agent = request.headers.get('User-Agent')

    # ホストのリソース使用料を取得する
    cpu_per, mem_per, ldg_per = get_resource()

    if "Mozilla" in agent:
        return render_template('index.html',
                               cpu=cpu_per, mem=mem_per, ldg=ldg_per)
    else:
        return jsonify(
            {
                'CPU': cpu_per,
                'MEM': mem_per,
                'LDG': ldg_per
            }
        )


@app.route('/__count')
def counter():
    return "ACCESS : " + str(gc()) + "\n" + "PID : " + str(os.getpid()) + "\n"


@app.errorhandler(404)
def err_404(error):
    response = jsonify({"message": "Not Found", "result": error.code})
    return response, error.code


if __name__ == '__main__':
    app.run(host='0.0.0.0', debug=True)
```

上記を実行し別のターミナルからcurlを実行して動作を確認します。
FlaskではPortの指定などをしていない場合はデフォルトの5000で上がります。

``` bash:実行イメージ
# アプリケーション起動
(flask_app)$ python app.py

# 別ターミナルから
$ curl -s http://localhost:5000
Hello, World!
IP:127.0.0.1
PID:41789
```

#### pytest

``` python:tests/test_01.py
from .. import app
import pytest

class TestCommon:

    def test_get_resource(self):
        assert app.get_resource()

    def test_count(self):
        gc = app.gen_counter()
        for _ in range(10):
            gc()
        assert gc() == 11

    def test_count_init(self):
        gc = app.gen_counter()
        assert gc() == 1
```

``` bash:実行イメージ
(flask_app)$ pytest -v
===================================================== test session starts ======================================================
platform darwin -- Python 3.7.4, pytest-5.1.2, py-1.8.0, pluggy-0.12.0 -- /usr/local/opt/python/bin/python3.7
cachedir: .pytest_cache
rootdir: /Users/JPZ2046/repo/fuppd
collected 3 items

tests/test_app.py::TestCommon::test_get_resource PASSED                                                                  [ 33%]
tests/test_app.py::TestCommon::test_count PASSED                                                                         [ 66%]
tests/test_app.py::TestCommon::test_count_init PASSED                                                                    [100%]
```

#### pycodestyle

pycodestyleはPEP8のスタイルを元にしたPythonコードをチェックするツールです。
(pep8と呼ばれるパッケージで呼ばれてましたが現在はこちらとなってます)

``` bash
(flask_app)$ pycodestyle --show-source --show-pep8 app.py
```

番号|メッセージ|概要
|:--|:--|:--|
|E111| indentation is not a multiple of four | インデントは4の倍数ではありません |
|E112| expected an indented block | インデントブロックが予想される |
|E115| expected an indented block (comment) | インデントされたブロック（コメント） |
|E116| unexpected indentation (comment) | 予期しないインデント（コメント） |
|E133| closing bracket is missing indentation | 閉じ括弧にはインデントがありません |
|E201| whitespace after ‘(‘ | 空白の後に '（' |
|E202| whitespace before ‘)’ | 空白の前に '）' |
|E203| whitespace before ‘:’ | 空白の前に '：' |
|E211| whitespace before ‘(‘ | 空白の前に '（' |
|E221| multiple spaces before operator | オペレータの前に複数のスペース |
|E222| multiple spaces after operator | オペレータの後の複数のスペース |
|E223| tab before operator | 操作前のタブ |
|E224| tab after operator | 操作後のタブ |
|E225| missing whitespace around operator | オペレータの周囲に空白がない |
|E226| missing whitespace around arithmetic operator | 算術演算子の周りに空白がない |
|E228| missing whitespace around modulo operator | モジュロ演算子の周囲に空白がない |
|E231| missing whitespace after ‘,’, ‘;’, or ‘:’ | '、'、 ';'、または '：'の後に空白がない |
|E262| inline comment should start with ‘# ‘ | インラインコメントは '＃'で始まります |
|E265| block comment should start with ‘# ‘ | ブロックコメントは '＃'で始まります |
|E266| too many leading ‘#’ for block comment | ブロックコメントの先頭に「＃」が多すぎます |
|E271| multiple spaces after keyword | キーワードの後の複数のスペース |
|E272| multiple spaces before keyword | キーワードの前に複数のスペース |
|E273| tab after keyword | キーワードの後のタブ |
|E274| tab before keyword | キーワードの前のタブ |
|E275| missing whitespace after keyword | キーワードの後に​​空白がない |
|E303| too many blank lines (3) | あまりにも多くの空白行（3） |
|E401| multiple imports on one line | 1行に複数のインポート |
|E501| line too long (82 > 79 characters) | 行が長すぎる（82> 79文字） |
|E701| multiple statements on one line (colon) | 1行の複数のステートメント（コロン） |
|E702| multiple statements on one line (semicolon) | 1行に複数のステートメント（セミコロン） |
|E703| statement ends with a semicolon | ステートメントはセミコロンで終わります |
|E704| multiple statements on one line (def) | 1行に複数のステートメント（def） |
|E901| SyntaxError or IndentationError | SyntaxErrorまたはIndentationError |
|E902| IOError | IOError |

#### uWSGIでマルチプロセス化

uWSGIは、WSGIアプリケーションの実行環境を提供します。
そのため、マルチプロセス処理をしてくれるので、(比較的に)手数が少なくアプリケーションのパフォーマンスを上げることができます。

* [uWSGIのiniファイルの文法まとめ](https://qiita.com/11ohina017/items/da2ae5b039257752e558)

``` ini:settings/app.ini
[uwsgi]
wsgi-file = app.py
http=0.0.0.0:5000
callable = app
processes = 4
vacuum = true
die-on-term = true
```

|設定項目|概要|
--------|---
|wsgi-file|ロードするuswgiファイル|
|http|アクセス許可ホスト:ポート|
|callable|
|processes|実行するプロセス数|
|socket|ソケットの設定|
|pidfile|出力するpidファイルパスの設定

``` bash:実行イメージ
(flask_app)$ uwsgi --ini app.ini
*** uWSGI is running in multiple interpreter mode ***
```

## Docker

#### コンテナ化

venvで構築した環境をDockerコンテナへと構築します。

``` bash:実行イメージ
# pipで管理しているPythonパッケージのファイルを生成
(flask_app)$ pip freeze > requirements.txt

(flask_app)$ cat requirements.txt
astroid==2.2.5
Click==7.0
Flask==1.1.1
isort==4.3.21
itsdangerous==1.1.0
Jinja2==2.10.1
lazy-object-proxy==1.4.2
MarkupSafe==1.1.1
mccabe==0.6.1
psutil==5.6.3
pycodestyle==2.5.0
pylint==2.3.1
six==1.12.0
typed-ast==1.4.0
uWSGI==2.0.18
Werkzeug==0.15.6
wrapt==1.11.2
```

用意するものは上記のみです。これを元にコンテナを作成します。
下記のようなDockerfileを作成します。

``` Dockerfile:Dockerfile
FROM python:3.7.4-alpine3.10
LABEL maintainer="ryuichi1208 (ryucrosskey@gmail.com)"

WORKDIR /app
EXPOSE 5000
COPY . /app

RUN apk add --no-cache \
	git openssl ffmpeg \
	opus libffi-dev gcc \
	curl musl-dev
RUN pip install --upgrade pip
RUN pip install -r requirements.txt

ENTRYPOINT ["uwsgi", "--ini", "app.ini"]
```

DockerHubへ上げる必要があるのですが、まずはローカルで動作確認をします。

``` bash:実行イメージ
(flask_app)$ docker image build -t app_01 .

(flask_app)$ docker container run -d -p 5000:5000 app_01

# 起動したらcurlをポート5000へ向けて実行してください。
(flask_app)$ curl -LI http://localhost:5000 -o /dev/null -w '%{http_code}\n' -s
200 # ここでステータスコードが200以外が返ってきた場合は設定ミスです。
```

#### CicleCI

``` yaml:.circleci/config.yml
version: 2.1
jobs:
  lint:
    docker:
      - image: python:3.7.4-alpine3.10
        environment:
          PIPENV_VENV_IN_PROJECT: true
    steps:
      - checkout
      - run:
          name: install package
          command: |
            pip -V
            apk add --no-cache \
              git openssl ffmpeg \
              opus libffi-dev gcc \
              curl musl-dev
            pip install --upgrade pip
            pip install -r requirements.txt
      - run:
          name: do lint
          command: pycodestyle app.py
      - run:
          name: do test
          command: pytest -v

  build:
    machine: true
    steps:
      - checkout
      - run:
          name: start container
          command: docker version && docker image build -t app01 .
      - run:
          name: start access
          command: script/do_curl.sh

workflows:
  version: 2
  build_and_test:
    jobs:
      - lint
      - build
```

#### imageをDockerHubへPUSH

docker tagつけてdocker pushするだけなので省略

#### まとめ

執筆中。