pipenvによるpythonアプリケーション実行環境をDockerとVirtualBox(Vagrant)で作る

# 前置き

pythonでバッチやWebアプリケーション(Django/Flask等)の環境を作るときのベースイメージとなるものをDockerとVagrantで作ったのでその記録です。（DockerもVagrant(VirtualBox)もベースはCentOS（7.6）です）

本当は、nginxやuWSGIなどとの連携や簡単なアプリケーションまで作って載せよう思いましたが、長くなったので、気が向いたら書くことにします。

# Dockerコンテナ版

## 前提
sshで接続できるpipenvで作るpython実行環境のDockerコンテナのサンプルです。
これをこのまま利用するというよりはこれでビルドしたイメージをベースにDjangoやuWSGIをインストールした派生イメージを作ったり、pythonのバッチアプリケーションを実行するコンテナを作ったりする想定で作ったものです。

試したDockerのバージョンは以下の通りです。

```shell-session
Client: Docker Engine - Community
 Version:           18.09.2
 API version:       1.39
 Go version:        go1.10.8
 Git commit:        6247962
 Built:             Sun Feb 10 04:12:39 2019
 OS/Arch:           darwin/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          18.09.2
  API version:      1.39 (minimum version 1.12)
  Go version:       go1.10.6
  Git commit:       6247962
  Built:            Sun Feb 10 04:13:06 2019
  OS/Arch:          linux/amd64
  Experimental:     false
```

## Dockerfileの中身

```Dockerfile
FROM centos:7

ARG APP_USR_PWD
ARG BASE_GROUP=apps
ARG APP_USER=app
ARG APP_HOME=/home/app
# only python3.x.y
ARG PYTHON_VERSION=3.7.4

RUN test -n "${APP_USR_PWD}" && \
    groupadd ${BASE_GROUP} && \
    useradd -d ${APP_HOME} -G ${BASE_GROUP} -s /bin/bash ${APP_USER} && \
    yum install -y epel-release && \
    yum install -y https://centos7.iuscommunity.org/ius-release.rpm && \
    yum install -y sudo python-pip openssh openssh-server passwd logrotate sudo wget vim jq \
    gcc git libffi-devel zlib-devel bzip2 bzip2-devel readline readline-devel sqlite sqlite-devel openssl openssl-devel && \
    yum -y update && yum -y clean all && rm -rf /var/cache/yum/ && \
    echo "${APP_USER}:${APP_USR_PWD}" | chpasswd && \
    echo "${APP_USER} ALL=(ALL) ALL" > /etc/sudoers.d/${APP_USER} && \
    pip install pip --upgrade && pip install pipenv

# install pyenv
RUN git clone https://github.com/pyenv/pyenv.git ${APP_HOME}/.pyenv && \
    chown -R ${APP_USER}:${BASE_GROUP} ${APP_HOME}/ && \
    echo '' >> ${APP_HOME}/.bash_profile && \
    echo 'export PYENV_ROOT="${HOME}/.pyenv"' >> ${APP_HOME}/.bash_profile && \
    echo 'export PATH="${PYENV_ROOT}/bin:${PATH}"' >> ${APP_HOME}/.bash_profile && \
    echo 'if command -v pyenv 1>/dev/null 2>&1; then' >> ${APP_HOME}/.bash_profile && \
    echo '  eval "$(pyenv init -)"' >> ${APP_HOME}/.bash_profile && \
    echo 'fi' >> ${APP_HOME}/.bash_profile && \
    echo "export PIPENV_DEFAULT_PYTHON_VERSION=${PYTHON_VERSION}" >> ${APP_HOME}/.bash_profile && \
    echo 'export PIPENV_VENV_IN_PROJECT=true' >> ${APP_HOME}/.bash_profile

# install python.
RUN su - ${APP_USER} -c "pyenv install ${PYTHON_VERSION} && pyenv global ${PYTHON_VERSION} && pyenv rehash && pip install --upgrade pip && pip install pipenv"

# setup ssh.
RUN ssh-keygen -q -f /etc/ssh/ssh_host_rsa_key -N '' -t rsa && \
    ssh-keygen -q -f /etc/ssh/ssh_host_ecdsa_key -N '' -t ecdsa && \
    ssh-keygen -q -f /etc/ssh/ssh_host_ed25519_key -N '' -t ed25519 && \
    mkdir -p ${APP_HOME}/.ssh && chmod 700 ${APP_HOME}/.ssh
# Add public key to container.
ADD id_rsa.pub ${APP_HOME}/.ssh/authorized_keys
RUN chown -R ${APP_USER}:${BASE_GROUP} ${APP_HOME}/.ssh/

EXPOSE 22

ENTRYPOINT ["/usr/sbin/sshd", "-D"]
```

## 事前作業

* Dockerfileと同じディレクトリにSSHログインする秘密鍵に対応する公開鍵を`id_rsa.pub`というファイル名で配置すること！！
* 社内のプロキシ環境下の場合は、DockerfileのFROMの直後当たりに環境変数の設定を追加する。

例）

```Dockerfile
FROM centos:7

ENV http_proxy=http://proxy.xx.yy:9999 \
    https_proxy=http://proxy.xx.yy:9999

# 以下省略・・・
```

## ビルド方法

```shell
docker build -t {イメージ名}[:{タグ}] --build-arg APP_USR_PWD={SSHログインユーザがsudoするときに必要なパスワード}
```

上記以外に以下の値が`--build-arg`で上書き可能である。

* BASE_GROUP：アプリケーションの実行（＝SSH接続）ユーザの所属グループ（デフォルト`apps`）
* APP_USER：アプリケーションの実行（＝SSH接続）ユーザ名（デフォルト`app`）
* APP_HOME：アプリケーションの実行（＝SSH接続）ユーザのホームディレクトリ（デフォルト`/home/app`）
* PYTHON_VERSION：pipenvでインストールするアプリケーションの実行（＝SSH接続）ユーザのグローバルのpythonバージョン（デフォルト3.7.4）

PYTHON_VERSIONはすべてのバージョンを確認しているわけではないので、指定するものによってはインストールするパッケージが不足している可能性もあります。

## 起動方法

```shell
docker run -d -p ホスト側のポート番号:22 --name {任意のコンテナ名} {イメージ名}[:{タグ}]
```

## dockerホストからのアクセス

* root  
`docker exec -it {任意のコンテナ名} bash`
* app  
`docker exec -it --user app {任意のコンテナ名} `

## Dockerホスト外からSSH接続方法

DockerホストOS側のファイアーウォール設定などで起動方法に記載した「ホスト側のポート番号」でアクセス可能であること。
（この点についてはDockerホスト環境に依存するので割愛）

接続情報は以下の通りです。適宜、利用するSSHクライアントツールに以下の情報を指定してください。

* ホスト：接続元から見たDockerホストのホスト名 or IPアドレス
* ポート番号：起動方法に記載した「ホスト側のポート番号」
* ユーザ：app(`--build-arg APP_USER=xxx`とした場合はxxx)
* 秘密鍵：Dockerfileと同じディレクトリに配置した公開鍵の秘密鍵

# Vagrant＋VirtualBox版

## 前提
VagrantのproviderはVirtualBoxを前提としていますが、VagrantfileのVirtualBox依存部分を他のプロバイダーに書き換えれば、問題なく動くと思います。
Vagrant版を作ったのはDockerが動かない（正確には動かすのにいろいろな作業が必要になりそう）環境の人がいたので、急遽つくったのものなので、
DockerfileのRUNでいろいろインストールしているところを外だしのprovistion.shに移したものです。
個人的には、実稼働環境の構築も考慮して、Ansible-Playbookで書きたいところなので、そのうち気が向いたら書き直します。

試したVagrantとVirutualBOXのバージョンは以下の通りです。

```shell-session
$ VBoxManage --version
6.0.8r130520
$ vagrant version
Installed Version: 2.2.5
Latest Version: 2.2.5
 
You're running an up-to-date version of Vagrant!
```

## 事前作業

* Vagarnt sshでログインできるのでSSHの設定は特別には行っていません。
* 社内のプロキシ環境下の場合は、以下の作業を実施しておくこと。
  * [vagrant-proxyconf](http://tmatilai.github.io/vagrant-proxyconf/)をインストールする。  
  `vagrant plugin install vagrant-proxyconf`
  * 環境変数に`http_proxy`/`https_proxy`/`no_proxy`を登録する。）

## Vagrantfileとprovision.sh

Vagrantfileのprovision設定をinlineで書くとみづらい＆書きづらいのでprovision.shに外だししました。

`forwarded_port`のホスト側のポート番号、`private_network`のIPアドレス、`sync_folder`のパス部分は各自の環境に合わせて変更してください。

```ruby:Vagranfile
# -*- mode: ruby -*-
# vi: set ft=ruby :

Vagrant.configure("2") do |config|

  # 利用するプラグイン定義
  config.vagrant.plugins = ["vagrant-vbguest", "vagrant-winnfsd"]

  # boxイメージ指定
  config.vm.box = "centos/7"
  config.vm.box_version = "1902.01"
  config.vm.box_check_update = false

  # GuestAdditionsを更新しない
  config.vbguest.auto_update = false

  # プロキシ設定
  if Vagrant.has_plugin?("vagrant-proxyconf")
    if ENV['http_proxy']
      config.proxy.http = ENV['http_proxy']
    end
    if ENV['https_proxy']
      config.proxy.https = ENV['https_proxy']
    end
    if ENV['no_proxy']
      config.proxy.no_proxy = ENV['no_proxy']
    end
  end

  # ポートフォワード設定（外部PCからのSSHアクセス用）
  config.vm.network "forwarded_port", id: "ssh", guest: 22, host: 2222

  # NFSに必要なprivate(host only)ネットワーク
  # ipの代わりに`type: "dhcp"`でも可。
  config.vm.network :private_network, id: "default-network", ip: "192.168.132.101"

  # ホスト名
  config.vm.hostname = "app.python.localohost"

  # ローカルでコーディングしたものをNFSマウントでゲスト側に認識させるためのディレクトリ
  # ⇒アプリを置く場所の想定です。
  config.vm.synced_folder ".", "/home/vagrant/apps", type: "nfs", nfs_export: true, nfs_version: 3

  # ゲストOSのリソースは適宜変更してください。
  config.vm.provider :virtualbox do |vb|
    vb.name = "python-app" # VirtualBox上のゲストOS名
    vb.gui = false
    vb.memory = 2048
    vb.cpus = 2
    vb.check_guest_additions = false
    vb.functional_vboxsf     = false
  end
  # python3に必要なパッケージやpipenv(pyenv)のインストールなど。
  config.vm.provision :shell do |shell|
    shell.name = 'provision'
    shell.env = {
      :PYTHON_VERSION => "3.7.4",
    }
    shell.path = "provision.sh"
  end
end
```

```shell:provision.sh
#!/bin/bash
set -eo pipefail

echo "Start provision!!"

# パラメータ定義
# pythonのバージョン定義（環境変数で上書き可能）
PYTHON_VERSION=${PYTHON_VERSION:-"3.7.4"}
# めんどくさいのでvagrantをそのまま使う。
APP_GROUP="vagrant"
APP_USER="vagrant"
APP_HOME="/home/${APP_USER}"

# rootのssh接続を不可/パスなし接続不可(公開鍵認証のみ)
sed -i -e 's/#PermitRootLogin yes/PermitRootLogin no/' \
       -e 's/#PermitEmptyPasswords no/PermitEmptyPasswords no/' /etc/ssh/sshd_config

# パッケージのインストール等
# epelとiusの追加
yum install -y epel-release https://centos7.iuscommunity.org/ius-release.rpm
# update
yum update -y
# 必要なパッケージの追加(pythonに特化すれば全部ではなく、一部はサーバー上に必要そうなコマンド群も含む)
yum install -y wget jq git openssl \
               gcc python-pip python-devel \
               zlib-devel libffi-devel bzip2-devel \
               openssl-devel ncurses-devel sqlite-devel \
               readline-devel tk-devel gdbm-devel \
               libuuid-devel xz-devel systemd-devel
# お掃除
yum -y clean all && rm -rf /var/cache/yum/

# pyenvをインストールする
git clone https://github.com/pyenv/pyenv.git ${APP_HOME}/.pyenv
# アプリユーザで利用できるようにインストール
cat << EOS >> ${APP_HOME}/.bash_profile
# Add pyenv setting
export PYENV_ROOT="\$HOME/.pyenv"
export PATH="\$PYENV_ROOT/bin:\$PATH"
if command -v pyenv 1>/dev/null 2>&1; then
    eval "\$(pyenv init -)"
fi
export PIPENV_DEFAULT_PYTHON_VERSION=${PYTHON_VERSION}
export PIPENV_VENV_IN_PROJECT=true
EOS
chown -R ${APP_USER}:${APP_GROUP} ${APP_HOME}/.pyenv
# アプリケーションユーザ用のpythonはpyenvでglobalにセット
su - ${APP_USER} -c "pyenv install ${PYTHON_VERSION} && pyenv global ${PYTHON_VERSION} && pyenv rehash && pip install --upgrade pip && pip install pipenv"

echo "Fisnish provision!!"
```

## 起動方法

Vagrantfile/provision.shを同じディレクトリにおいて、そのディレクトリをカレントにした状態で

```shell
vagrant up
```

## ホストからのアクセス

```shell
vagrant ssh
```

## VirtualBoxホスト外からSSH接続方法

ホストOS側のファイアーウォール設定などで起動方法に記載した「ホスト側のポート番号」でアクセス可能であること。

接続情報は以下の通りです。適宜、利用するSSHクライアントツールに以下の情報を指定してください。

* ホスト：接続元から見たVirtualBoxホストのホスト名 or IPアドレス
* ポート番号：2222
* ユーザ：vagrant
* 秘密鍵：`vagrant ssh-config`で実行した``に秘密鍵のパスが記載されています。

例）

```shell-session
Host default
  HostName 127.0.0.1
  User vagrant
  Port 2222
  UserKnownHostsFile /dev/null
  StrictHostKeyChecking no
  PasswordAuthentication no
  IdentityFile {カレントディレクトリ}/.vagrant/machines/default/virtualbox/private_key
  IdentitiesOnly yes
  LogLevel FATAL
```

なお、これはUnix/Linux/Macなどの${HOME}/.ssh/configに記載する内容となります。ただ、上の設定はlocalhostを前提にしているので`HostName`に指定する値は接続元から見たVirtualBOXホストのホスト名 or IPアドレスにしてください。
また、先頭のdefaultはVagrantの識別名なのでここも任意変更可能で、例えば`Host python`のように指定した場合、`ssh python`のように打つことでログインすることができます。（Vagrantの仕様とは直接関係ないので細かいことは割愛）


# SSH接続後の利用例（共通）

pipenvのことが書きたいわけではないので、ほんの触りだけ。。。

```shell
# プロジェクトディレクトリ作る。
mkdir test
cd test
# pipenv(pyenv)による仮想環境を作る
pipenv --python 3.7.4
# =>これでPipfileが作られる
# PyPIパッケージモジュールをインストールする（上位人気のPyPIパッケージ）
pipenv install simplejson setuptools requests python-dateutil PyYAML
# => Pipfileに追記され、Piplockファイルにインストールされたバージョン情報とか諸々書き込まれる
# あとは作るものに依存するので割愛します。
```

# 追記

↑のVagrantfileの`config.vm.synced_folder ".", "/home/vagrant/apps", type: "nfs"`の部分についてですが、VPN接続時にマウントエラーが発生するという事象にぶち当たりました。
回避方法は[別記事](https://qiita.com/satodoc/items/2092f73712099679eda2)にしましたので、ご参照ください。