私の開発チームでの Django バックエンド開発環境の紹介 (Python3.8 を docker-compose でデバッグする)

macを新調したところ、Python のバージョンがデフォルトで 3.9 になってました。 

私の開発チームでは、まだ Python 3.8 で開発しているものがあるため、3.8 での開発環境を構築する必要がありました。ダウングレードのPython環境を作る場合、 pyenv を使うのが一般的だと思いますが、今回は Python 3.8 が含まれる Alpine のコンテナを docker-compose で起動して、pycharm のリモートデバッグで開発を行うようにしました。社内共有がてら、構成を書きます。

ちなみに、docker-compose でのリモートデバッグ開発環境の欠点として、selenium (selene) で、ブラウザウィンドウを表示しながらのオートメーションやテストを行うことが困難なため、そのような用途では mac 上にpyenvで仮想環境を作ったほうが良いでしょう。ヘッドレスであれば問題なさそうです。

なお、本記事内で、docker イメージのバージョン番号は一切書かれていませんが、実際の運用ではバージョン番号を入れています。

# 要約
この記事では、私の開発チームでの Django のサーバサイドアプリの基本的な構成を紹介するとともに、

+ プライベートリポジトリを含む Pipfile の内容を docker イメージにインストールする方法
+ PyCharm に docker-compose でのスクリーンショットつきデバッグ実行設定手順
+ docker-compose.yml に環境変数のデフォルト値を設定する方法
+ uwsgi.ini に環境変数のデフォルト値を設定する方法
+ nginx を使わず、 uwsgi からスタティックファイルをホストする方法
+ docker login の結果のトークンを kubernetes の secret として保存する pythonスクリプト

などが書いてあります。


# デモリポジトリ
https://github.com/torico-tokyo/torico-myapp

```zsh
docker/build.sh
docker/run.sh
```
でのコンテナの起動、docker/docker-compose.yml を使ってのデバッグ実行が試せます。

![Django__納期を逃さない完璧主義者のためのWebフレームワーク.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/fab6f0fb-1325-c746-c943-359ab97bfbc1.png)


# ディレクトリ構成

+ app
  + manage.py
  + myapp
    + settings
      + local.py
      + production.py
+ uwsgi
  + uwsgi.ini
+ docker
  + docker-compose.yml
  + Dockerfile
  + build.sh
  + config.sh
  + run.sh
+ kubernetes
  + apply.sh
  + deployment.yml
  + service.yml



# docker

## dockerビルド・実行方法
デモリポジトリの clone 後、

```zsh
docker/build.sh
docker/run.sh
```
ブラウザで  http://127.0.0.1:8000 を開くと、Django のランディングページが表示されます。


## ディレクトリ内容

```zsh:docker/config.sh
image_name=torico/myapp
container_name=myapp
```
共通で使う、Docker イメージ名などを定義しています。

```zsh:docker/build.sh
#!/usr/bin/env zsh
cd "$(dirname $0)" || exit
. ./config.sh
cd ..
docker build . --ssh default -t ${image_name} -f docker/Dockerfile
```
Dockerfile をプロジェクトルートに入れず、docker ディレクトリの中に入れてますが、
ビルド対象はプロジェクトルートのため、一回上位ディレクトリに移動してからビルドしてます。

`--ssh` オプション、および  `Dockerfile` の1行目の
`# syntax=docker/dockerfile:1.0.0-experimental`
は、Pipfile から Python のプライベートリポジトリのライブラリをインストールする時に使います。
プライベートリポジトリのライブラリが無い場合は必要ありません。

### Dockerfile

```Dockerfile
# syntax=docker/dockerfile:1.0.0-experimental

FROM alpine:3.12

ENV PYTHONUNBUFFERED 1

RUN apk --no-cache add \
    python3 \
    py3-pip \
    uwsgi \
    uwsgi-http \
    uwsgi-python3 \
    mariadb-connector-c \
    git \
    openssh \
    jpeg

RUN pip3 install pipenv --ignore-installed distlib

COPY Pipfile /tmp/Pipfile

RUN apk add --no-cache --virtual=.build-deps \
    gcc \
    make \
    python3-dev \
    musl-dev \
    libffi-dev \
    mariadb-dev \
    postgresql-dev \
    g++ \
    libgcc \
    libstdc++ \
    libxml2-dev \
    libxslt-dev \
    jpeg-dev \
    && PIPENV_PIPFILE=/tmp/Pipfile pipenv install --dev --system --skip-lock --deploy \
    && apk del .build-deps \
    && rm -rf /var/cache/apk/* \
    && rm -rf /tmp/*

RUN mkdir -m 777 -p /var/log/myapp

COPY uwsgi /var/src/uwsgi
COPY app /var/src/app
RUN chown -R uwsgi:uwsgi /var/src

USER uwsgi
RUN cd /var/src/app && python3 ./manage.py collectstatic --noinput

EXPOSE 8000
CMD ["uwsgi", "--ini", "/var/src/uwsgi/uwsgi.ini"]
```
ライブラリを多く盛り込んでいます。今回のように、ただ Django を起動するだけではこれらのライブラリは
必要ありませんが、 PIL、ソーシャルログイン、HTMLパーサー、mysqlclient、firebase、
もろもろ Pipenv に追加していくと、ビルドのためのライブラリが必要になります。

今回のように、必要ライブラリをすべて入れてビルドする以外にも、
ビルドが必要なライブラリは Pipenv ではインストールせず、
ビルド済みライブラリをインストールする方法であったり、
ビルドを行うにしてもマルチステージの Dockerfile を作って行う方法もありますが、
Alpine では `apk --virtual=` でのインストールが、
Dockerfile を簡潔に保てて良いと感じており、よく使います。

Dockerイメージ単体で(ボリュームのマウントなしで)起動するようにするため、
Djangoのソースコードをコピーして含めています。
Admin などで static ディレクトリを使うため、Dockerビルド段階で collectstatic を実行しておきます。

なお、Alpine Linux にはロケールデータが無いため、国際化 i10n, i18n は難しいです。
国際化が必要な場合は、debian を使うのが楽だと思います。

参考までに、マルチステージビルドを行った場合の (別プロジェクトで使っている) Dockerfile も載せます。

```Dockerfile
FROM alpine:3.12 AS builder
# マルチステージビルドを試してみたが、他のプロジェクトでやってるような
# apk --virtual をつかう手法と比べてイメージサイズを少なくできるわけではない。
# --virtual のほうが dockerfile が短くできるので、そっちがいいかな。

RUN apk --no-cache add \
  python3 \
  py3-pip

RUN pip3 install pipenv --ignore-installed distlib

COPY Pipfile /tmp/Pipfile

RUN apk add --no-cache --virtual=.build-deps \
  gcc \
  make \
  python3-dev \
  musl-dev \
  libffi-dev \
  mariadb-dev \
  postgresql-dev \
  g++ \
  libgcc \
  libstdc++ \
  libxml2-dev \
  libxslt-dev \
  jpeg-dev \
  && PIPENV_PIPFILE=/tmp/Pipfile pipenv install --system --skip-lock --deploy \
  && apk del .build-deps \
  && rm -rf /var/cache/apk/* \
  && rm -rf /tmp/*


FROM alpine:3.12

COPY --from=builder /usr/lib/python3.8/site-packages /usr/lib/python3.8/

RUN apk --no-cache add \
  git \
  python3 \
  py3-pip \
  musl \
  uwsgi \
  uwsgi-http \
  uwsgi-python3 \
  mariadb-connector-c \
  libxml2 \
  libxslt \
  py3-pillow

# py3-pillow は、画像のライブラリをインストールするため

RUN mkdir -m 777 -p /var/log/awesome-app

COPY conf /var/src/conf
COPY awesome_app /var/src/awesome_app
RUN chown -R uwsgi:uwsgi /var/src

USER uwsgi
RUN cd /var/src/awesome_app && python3 ./manage.py collectstatic --noinput

EXPOSE 8080
CMD ["uwsgi", "--ini", "/var/src/conf/uwsgi.ini"]
```


## PyCharm からのデバッグ方法
新しい mac は、Python 3.9 がデフォルトでインストールされており、
そのままでは Python 3.8 の Python 仮想環境 (.venv)
を作ることができません。

brew で pyenv をインストールして、pyenv 内でダウングレードバージョンの
Python を管理するのも良いのですが、Pycharm は docker でのデバッグ実行も可能です。

docker でのデバッグ実行をする際は、docker イメージのバージョンをファイルで管理したほうが簡潔なため、
docker-compose 経由での起動がおすすめです。

(docker-compose を使わず、 docker run 相当の実行コンフィギュレーションを書いて、デバッグ実行させることもできますが、その場合各実行コンフィギュレーションの中に Dockerイメージのバージョン番号を埋め込むことになるため、イメージのバージョンアップを行った都度設定を書き換えないといけないため手間です。イメージのバージョン番号を docker-compose.yml の中にいれておけば、管理が容易になりますので、docker-compose を勧めています。)

### PyCharm での Python Interpreter の指定
`⌘+,` → Python Interpreter → Add

![スクリーンショット_2020-12-11_20_35_31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/e81eb841-3dd0-fcd4-4026-d2779c4e73f2.png)

`Docker Compose` → `New` → `Docker for mac`

![スクリーンショット_2020-12-11_20_40_19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/74ef07dd-ba2f-769b-654c-3c1b1a7dd97d.png)



`Configuration file(s)` に、`docker/docker-compose.yml` を指定

`Service` に `myapp` を指定

`Python interpreter path:` は、`python` から `python3` に変更しておく。

Alpine linux では、python コマンドでは python3 が起動できないためです。

![Cursor_と_Add_Python_Interpreter.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/120b7670-f57c-39f0-dbf1-1bb4d2b0aec7.png)


設定したら `[OK]` をクリック。

すると、先程作った Docker イメージ内の Python3.8環境が認識されます。

Path mappings も設定します。

![Edit_Project_Path_Mappings_と_Preferences-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/5bcbe85f-6fb6-f2a5-0ecc-ca569e4126f8.png)

![スクリーンショット 2020-12-11 21.22.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/072c3918-1f47-2a31-67b2-c0764073bacb.png)


プロジェクト内の `app` ディレクトリを、`/var/src/app` にマッピングします。



### PyCharm での Project Structure の設定

`⌘+,` → Project Structure

Django ディレクトリを Sources に追加

![Preferences-5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/48f8ae21-3117-3d7e-5613-f1da0063e692.png)


### PyCharm での Django Support の設定
`⌘+,` → Django

`Enable Django Support` にチェック

`Django project root` は、`django` ディレクトリ

`Settings` には、`myapp/settings/local.py` を設定

![Preferences-4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/e592695b-c695-7def-ecfc-6438a9bf1adf.png)


### 実行コンフィギュレーションの作成

右上の、`Edit Configurations` より
`+` → `Django Server` → `Host` に `0.0.0.0` を指定して、環境を作ります。

![スクリーンショット 2020-12-11 20.50.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/fcb66248-35c3-f9ce-bc44-9dc2f24cda1e.png)

![Run_Debug_Configurations_と_torico-myapp_–_docker-compose_yml.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/68302/2e3a40ed-dc15-4fe0-67b4-f27f28d65a9a.png)

Python Interpreter は、先程設定した Docker compose になっているはずです。

虫ボタンからデバッグ実行すると、docker-compose で起動した Python へリモートデバッグが行えます。

## docker-compose.yml の解説

```yaml
version: '3'

services:
  myapp:
    image: torico/myapp
    container_name: myapp
    ports:
      - "8000:8000"
    environment:
      DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-myapp.settings.local}
    restart: always
    volumes:
      - ../app:/var/src/app
```

```yaml
DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-myapp.settings.local}
```
docker compose version 3 より、環境変数のデフォルト値が使えるようになっています。

この場合は、環境変数 `DJANGO_SETTINGS_MODULE` が設定されていなければ、
`myapp.settings.local` を使うという設定になります。

そのため、

```
DJANGO_SETTINGS_MODULE=myapp.settings.development docker-compose up -d
```
といった、環境を切り替えてのDjango の起動も容易です。

```yaml
    volumes:
      - ../app:/var/src/app
```

Dockerファイルで、app ディレクトリをまるごとコピーしてイメージを作っていますが、
docker-compose から起動した際はホストのディレクトリを docker イメージのディレクトリを上書きする形でマウントしています。
開発のためです。

# uwsgi

冒頭で、

```zsh
docker/build.sh
docker/run.sh
```

のコマンドで Django サーバを起動したり、後述の kuberneters で起動する際は、
webアプリケーションサーバとして uwsgi が起動します。

uwsgi の設定ファイルは以下の形ですが

```ini:uwsgi.ini
[uwsgi]
base = /var/src/app
chdir = %(base)

plugins = http,python3
http = 0.0.0.0:8000
vacuum = true
die-on-term = true

module = myapp.wsgi:application
master = true

if-not-env = DJANGO_SETTINGS_MODULE
env = DJANGO_SETTINGS_MODULE=myapp.settings.local
endif =

env = LC_ALL=en_US.UTF-8
env = LANG=en_US.UTF-8
touch-reload = %(base)/myapp/wsgi.py
uid = uwsgi
static-map = /static=/var/src/staticfiles
logto = /var/log/myapp/%n.log
thunder-lock = true
buffer-size = 32768
processes = %k
threads = 16
```

ポイントとしては

```ini
if-not-env = DJANGO_SETTINGS_MODULE
env = DJANGO_SETTINGS_MODULE=myapp.settings.local
endif =
```
ここで、もし環境変数 `DJANGO_SETTINGS_MODULE` がセットされていなければ、
デフォルト値 myapp.settings.local を使うようにしています。

kubernetes で起動する際、settings を外から指定できるようにするためです。

```ini
static-map = /static=/var/src/staticfiles
```
こちらの設定では、リクエストパスが `/static` から始まる場合、リクエストを Django に渡さずに uwsgi のみで静的ファイルのレスポンスを返すようにしています。 staticfiles ディレクトリの中身は、docker イメージのビルド時に `./manage.py collectstatic` で作っています。

```ini
processes = %k
```
こちらは、processes の数値としてCPUコア数をそのまま使っています。

外部通信 (DB, 決済API、ElasticSearch, Redis, メール, ログ等) が多かったりして
待機時間が多いアプリの場合は、並列で多くのリクエストを扱えるよう、threads は多めにしています。

検証環境は少なめ、本番環境は多めにするために

```ini
threads = %(%k * 10)
```

といった設定にすることもあります。(コア数の10倍)

# kubernetes
作ったイメージは、EKS にプッシュして kubernetes にデプロイします。

mac で、kubernetes/apply.sh を実行して、本番環境を構築します。

```yaml
#!/usr/bin/env zsh

export KUBECONFIG=${HOME}/.kube/my-kubeconfig

kubectl apply -f deployment.yml
kubectl apply -f service.yml
kubectl apply -f ingress.yml
```

予め、 kubernetes クラスタの kubeconfig を、mac の ${HOME}/.kube/my-kubeconfig
としてコピーしておき、そのパスを環境変数 KUBECONFIG に設定することで、
本番クラスタを操作できます。

便宜上、今回のマニフェスト (deployment.yml) は

```yaml
containers:
  - name: myapp
    image: torico/myapp
    imagePullPolicy: Never
```

としており、ローカルの docker イメージを使う設定となっていますが、実際はECR からプルするため

```yaml
containers:
  - name: manga-master
    image: 000000000.dkr.ecr.ap-northeast-1.amazonaws.com/torico/myapp
    imagePullPolicy: Always
```
となります。

これは EKS 内での起動を想定しているため、認証の設定がありません。
EKS ではなく独自にクラスタを立てている場合は、下記のように認証設定を行います。

```yaml
containers:
  - name: manga-master
    image: 000000000.dkr.ecr.ap-northeast-1.amazonaws.com/torico/myapp
    imagePullPolicy: Always
imagePullSecrets:
  - name: ecr-credeintial
```

このようにして、ecr-credential を作る時は、python スクリプトでこのように作っています。

(`aws ecr get-login` の結果を、`kubectl create secret` する)

```python3
#!/usr/bin/env python3

import subprocess

namespace = 'torico'
secret_name = 'ecr-credeintial'
aws_region = 'ap-northeast-1'
docker_server = 'https://00000000.dkr.ecr.ap-northeast-1.amazonaws.com'


def main():
    output = subprocess.check_output([
        '/snap/bin/aws', 'ecr', 'get-login',
        '--no-include-email', '--region', aws_region,
    ]).decode()
    words = output.split()
    username = words[words.index('-u') + 1]
    password = words[words.index('-p') + 1]

    command = [
        '/snap/bin/kubectl', '-n', namespace, 'delete',  'secret', secret_name]
    subprocess.run(command)

    command = [
        '/snap/bin/kubectl', '-n', namespace, 'create', 'secret',
        'docker-registry', secret_name,
        f'--docker-username={username}',
        f'--docker-password={password}',
        f'--docker-server={docker_server}'
    ]
    subprocess.run(command)


if __name__ == '__main__':
    main()
```