Pipenv と Docker を使った開発環境のベストプラクティス

この記事は僕の以下のブログからの転載です。

https://kawasin73.hatenablog.com/entry/2019/09/22/220901

---

イテレーションの速さがあなたの生産性を左右する。どうも、かわしんです。生産性の高いプログラマって１つ１つの試行が素早い（自動化しているかツールを使っている）ためにものすごいスピードで開発できていると思うんですよね。

さて、最近 Python で開発をしているのですが、世の中の Docker と Pipenv の開発環境を調べてもろくなものがなかったので、自分でテンプレートを作りました。いわゆる「俺の考える最強の Pipenv + Docker 開発環境」というやつです。

リポジトリはこちらになります。

https://github.com/kawasin73/pipenv-docker-development

特徴としては、以下の2つが大きいです。

- `pipenv install` をコンテナ起動時に行うため、docker イメージを作り直す必要がない
- `pipenv shell` 相当の仮想環境のアクティベートを自動で行う

## なぜ Docker + Pipenv なのか

Docker は、pypy パッケージ以外のライブラリの環境（`apt instal` するもの）を隔離して複数の開発者間で再現させるために必要です。

Pipenv は `Pipfile` と `Pipfile.lock` を使ったバージョン固定の仕組みと `packages` と `dev-packages` の分離の機能があるため、pip ではなく Pipenv を利用したいです。Docker を使う場合環境の分離は達成されるため、Pipenv の仮想環境の仕組みは必要ないですがモダンなパッケージ管理機能を提供するものは Pipenv しかないのでこれを使いたいです。

つまり、**Docker と Pipenv のそれぞれを利用する合理的なメリット** があります。（docker 使わなくていいじゃ〜んとか、pip でいいじゃ〜んとかは言わないでください）

## Pipenv の辛いところ

Pipenv の辛いところは `install` や `update` や `lock` の仕組みが直感に反していて使いづらいとか、`lock` が遅すぎるとか、重複した `install` のスキップが遅いとかありますが、一番の辛いところは、**仮想環境の virtualenv の仕組みとパッケージ管理の仕組みが密結合している** ということにあります。

一応 `pipenv install --system` というコマンドがありシステムグローバルにパッケージをインストールするオプションがあり、世の中の pipenv と docker の環境の説明記事ではこれを利用していますが、開発環境としての利用には全く向いていません。

確かに docker と virtualenv の 2 重の仮想環境を避けることができますが、`pipenv lock` `pipenv graph` などのパッケージ管理の機能は virtualenv による仮想環境を必須としており、新しいパッケージをインストールするときに **結局仮想環境が作られ、2 重にパッケージのインストールがされてしまいます** 。

既存の pip はモダンなパッケージ管理ツールとしては機能不足（`packages` と `dev-packages` の分離やパッケージのゆるいバージョン指定と lock ファイルの仕組みなど）、pipenv は **仮想環境機能との密結合** とそれぞれのコマンドが遅い（`lock` が致命的に遅い）など、**Python のパッケージ管理ツールには満足いくものがない** ので、誰かモダンな Python のパッケージ管理ツールを作れば流行ると思います。（Vendoring の仕組みを使えばできるはずです。Ruby の bundler とかで達成されているもの）

## 解決策

そこで僕の考え出したのが以下の環境です。

https://github.com/kawasin73/pipenv-docker-development

特徴を列挙しておきます。

- 開発環境では、Docker の中に pipenv の仮想環境を作る
  - 開発環境なのでオーバーヘッドは許容できる。また、`pipenv graph` などのコマンドを満足に使えるメリットの方が大きい。
- `docker-compose build` では pypy パッケージのインストールは行わず、ENTRYPOINT でインストールする。virtualenv の環境を Docker Volume に載せる
  - 新しいパッケージのインストールでいちいち docker イメージを作り直す必要がなくなる。
  - Docker Volume にインストールしたパッケージが載っているのでコンテナを作り直してもインストールし直す必要がない。
  - **高速に気軽にライブラリインストールを試すイテレーションを回せる**
- 自動で pipenv の仮想環境をアクティベートする
  - `docker-compose run` でも `docker-compose exec <service> bash` でも、もちろん `docker-compose up` でも、pipenv の仮想環境内で実行される。
  - いちいち `pipenv shell` などをして仮想環境のアクティベートをする必要はない。
  - **pipenv のアクティベートを気にしなくてよくて、めんどくさくない**


ではファイルを列挙していきます。

### docker-compose.yml

```yaml
version: "3"
services:
  app:
    build:
      context: .
      dockerfile: "Dockerfile.dev"
    command: python main.py
    volumes:
      - .:/app
      - python-packages:/root/.local/share
volumes:
  python-packages:
```

開発環境のための `Dockerfile.dev` を利用します。

あと、docker volume を virtualenv の仮想環境が配置される `/root/.local/share` に割り当てています。

### Dockerfile.dev

```docker
FROM python:3.7.4

RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    build-essential \
    ca-certificates \
    git \
    && apt-get clean && \
    rm -rf /var/lib/apt/lists/*

ENV ENTRYKIT_VERSION 0.4.0

RUN wget https://github.com/progrium/entrykit/releases/download/v${ENTRYKIT_VERSION}/entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz \
    && tar -xvzf entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz \
    && rm entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz \
    && mv entrykit /bin/entrykit \
    && chmod +x /bin/entrykit \
    && entrykit --symlink

WORKDIR /app

RUN pip install --upgrade pip && pip install pipenv

RUN echo "if [[ -z \"\${VIRTUAL_ENV}\" ]]; then" >> /root/.bashrc && \
    echo "source \$(pipenv --venv)/bin/activate" >> /root/.bashrc && \
    echo "fi"                                    >> /root/.bashrc

COPY scripts/ /opt/bin/

ENTRYPOINT [ \
    "prehook", "/opt/bin/docker-setup.sh", "--", \
    "/opt/bin/docker-entrypoint.sh"]
```

ENTRYPOINT のフックに [Entrykit](https://github.com/progrium/entrykit) を利用しています。別にこれでなくてもいいのですが、`docker stop` の `SIGTERM` を実行プロセスに伝搬させるために利用しています。

ENTRYPOINT では `/opt/bin/docker-setup.sh` で `pipenv install` を、`/opt/bin/docker-entrypoint.sh` で virtualenv の仮想環境のアクティベートを行なっています。

また、`.bashrc` に、`source \$(pipenv --venv)/bin/activate` を設定しています。`docker-compose exec` をした時は ENTRYPOINT が回避されてしまうので仮想環境のアクティベートを `.bashrc` でフックして行います。（そのため、`docker-compose exec` で `bash` 以外が実行されるとアクティベートされなくなりますが、基本的に開発環境で `exec` する時は `bash` 以外ないのでこれでよしとしています。）

しかし、`docker-compose run` したときに `bash` を実行されると `/opt/bin/docker-entrypoint.sh` と `.bashrc` で 2 重にアクティベートされてしまいます、これを防ぐために `VIRTUAL_ENV` という環境変数を確認しています。

### scripts/docker-setup.sh

```bash
#!/usr/bin/env bash

pipenv --venv > /dev/null || pipenv install --skip-lock --dev --ignore-pipfile
```

コンテナの初回起動時にだけ `pipenv install` を行います。それ以降の起動時には、`pipenv --venv` に成功するので `pipenv install` をスキップすることができます。（すでにインストール済みの場合でも `pipenv install` は遅いのでスキップします）

### scripts/docker-entrypoint.sh

```bash
#!/usr/bin/env bash

if [[ -z "${VIRTUAL_ENV}" ]]; then
    source "$(pipenv --venv)/bin/activate"
fi

exec "$@"
```

virtualenv のアクティベートを行なってから `command` に指定されたものを実行しています。

### Dockerfile

```docker
FROM python:3.7.4-slim

RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    ca-certificates \
    git \
    && apt-get clean && \
    rm -rf /var/lib/apt/lists/*

ENV WORKDIR /app/

WORKDIR ${WORKDIR}

COPY Pipfile Pipfile.lock ${WORKDIR}

RUN pip install pipenv --no-cache-dir && \
    pipenv install --system --deploy && \
    pip uninstall -y pipenv virtualenv-clone virtualenv

COPY . $WORKDIR

CMD ["python", "main.py"]
```

これは本番環境のための Dockerfile です。`pipenv install --system --deploy` によってパッケージをシステムグローバルにインストールしています。

また、`CMD` の実行には pipenv は必要ないので pypy パッケージのインストール後に pipenv はアンインストールしています。

世の中の記事の一部には、`pipenv lock -r /tmp/requirments.txt && pip install -r /tmp/requirements.txt` をしてるものもありましたが、`pipenv lock -r` で virtualenv の仮想環境が作られるのでメリットはないと思っています。

## まとめ

以上となります。

pipenv よりマシな Python のパッケージ管理ツールが出てくることを待ってます。