Dockerで作る開発環境

# 動機

開発したアプリケーションは自分の書いたコードだけで動いているわけではありません。コードだけを管理していても環境毎にライブラリなどが変更されていると動かなかったり挙動が変わってしまったりしてしまいます。
Ansibleなどの構成管理ツールを使う手もありますが、競合が発生してしまう恐れがありますし、ディストリビューション毎の差異を吸収してやる必要もあります。専用サーバやVMなどで環境を独占するというのも手ですが、Dockerなら簡単に用意することができる上に仮想化の実行コストが無く、メモリ消費もプロセスが使う分だけですみます。

また、Dockerを利用することでカーネル以外のディストリビューションや実行するアカウント名、ファイルパスなども含めて決め打ちにしてしまうことが可能となり、設定項目を絞り込むことができます。

# 目標

* ソースコード以外をDocker環境に閉じ込めます
* ソースコードはホスト環境から編集できるようにします

# 環境

* Docker 1.10以降

# ディレクトリ構成

ディレクトリ構成はだいたい下記のようにしています。docker環境に納める必要のあるディレクトリはapp/以下に置き、それ以外はapp/の外に置くことでDocker環境構築時に転送しなければいけないファイルの削減をしています。自分の書くコードはapp/project_dir/以下に置き、ここをボリュームマウントします。

```
├ README.md
├ app
│   ├ bin
│   │   └ test.sh
│   ├ project_dir
│   └ env
│       └ docker
│           ├ Dockerfile
│           ├ mkdocker.sh
│           ├ packages.txt
│           ├ packages_test.txt
│           ├ setup-python.sh
│           └ setup-ubuntu.sh
├ bin
│   ├ devsh.sh
│   └ test.sh -> devsh.sh
├ doc
├ share
└ var
```

# 環境構築

環境構築に必要なファイルは`app/env/docker/`以下に置きます。

## app/env/docker/Dockerfile

```Docker
FROM ubuntu:16.04
ARG uid=1000
ARG username=user
ARG project=project
ARG debug=
ENV DEBIAN_FRONTEND noninteractive
ADD env/docker/setup-ubuntu.sh /tmp/
RUN /tmp/setup-ubuntu.sh ${debug}
RUN update-locale LANG=ja_JP.UTF-8
RUN pip3 install virtualenv
RUN adduser --uid ${uid} --disabled-password --disabled-login ${username}
USER ${username}
ENV LANG ja_JP.UTF-8
WORKDIR /home/${username}
RUN mkdir ${project} ${project}/share ${project}/app ${project}/app/bin ${project}/app/env
ADD env/ ${project}/app/env/
RUN ${project}/app/env/docker/setup-python.sh ${debug}
ADD bin/ ${project}/app/bin/
```

## app/env/docker/mkdocker.sh

プロジェクト毎のユーザー名とプロジェクト名の設定と、実行ユーザーのUIDの取得を行っています。dockerで外部ディレクトリをマウントした場合、ファイルシステムはUIDでしか管理していないためdocker環境のユーザのUIDと一致しないことがあります。
このままではプログラムがファイルを出力する際にパーミションの問題で書き込めなかったり出力したファイルのユーザーが別でホスト環境から削除できなくなったりしてしまいます。
そこで、docker環境で作成するユーザーのUIDを実行ユーザーのUIDにして一致させます。

このことからわかるように、dockerにアクセスできるユーザーは任意のUIDでホスト環境のファイルにアクセスすることが可能ですので、マルチユーザーでDockerが実行できる環境は仮想マシンなどで専用に用意すべきです。

```sh
#!/bin/sh -eu

USERNAME=[ユーザー名]
PROJECTNAME=[プロジェクト名]
BASE_PATH=`dirname $0`
userid=`id --user`
DOCKERFILE=${BASE_PATH}/Dockerfile
IMAGENAME=$PROJECTNAME
ARG_UID="--build-arg uid=${userid}"
ARG_USERNAME="--build-arg username=${USERNAME}"
ARG_PROJECTNAME="--build-arg project=${PROJECTNAME}"
DOCKER_DEBUG=""
NAMESPACE=`whoami`
DOCKERTAG=""
if [ ${userid} -lt 500 ] ; then
	uid=""
fi
while getopts t:dr OPT
do
	case $OPT in
		d)
			IMAGENAME=${IMAGENAME}_test
			DOCKER_DEBUG="--build-arg debug=-d"
			;;
		t) DOCKERTAG=":$OPTARG" ;;
	esac
done

# django
docker build ${ARG_UID} ${ARG_PROJECTNAME} ${ARG_USERNAME} ${DOCKER_DEBUG} -t ${NAMESPACE}/${IMAGENAME}${DOCKERTAG} --force-rm -f ${DOCKERFILE} ${BASE_PATH}/../..
```

## app/env/docker/setup-{python,ubuntu}.sh

中身は割愛しますが、-dオプションをつけて呼ばれた場合は開発用のパッケージなどもインストールするようにします。
なお、docker環境にアクセスするためなどにsshをインストールしてはいけません。
環境にアクセスするときは`docker exec -it [コンテナID] /bin/bash`を利用しましょう。rootの権限が必要な場合は`-u root`を付け加えます。

## app/env/docker/packages{,_test}.txt

中身は割愛しますが、pythonのパッケージを記述しておきます。`pip freeze`して必要なパッケージを確認します。`packages.txt`はパッケージのバージョンを指定し、`packages_test.txt`はバージョンを指定しません。`packages_test.txt`はpylint等のインストールに使っています。
また、pylint等の設定ファイルは`/app/env/`以下に配置しています。

# 実行

## bin/devsh.sh

このスクリプトは実行時のファイル名に応じて起動する環境などを調整します。
別々のスクリプトにすると同じ修正を複数のスクリプトに対して行わなければならなかったり、環境毎の扱いの差異が発生してしまうのでそれらを防ぐために同一スクリプトにしています。
コンテナ環境を立ち上げたあとは実行時のファイル名と同名の`app/bin/`ディレクトリのスクリプトを実行し、終了時にコンテナの掃除をします。`devsh.sh`で実行した場合は`bash`を実行して環境に入ります。

なお、ここではDB用のコンテナも別に建てていますが、必要に応じて建てなかったり、memcached用のコンテナの構築を行ったりもします。

```
#!/bin/bash

SCRIPT_PATH=`dirname $0`
CMD=`basename $0 .sh`
BASEDIR=`cd ${SCRIPT_PATH}/.. && pwd`
PROJECT_NAME=[プロジェクト名]
USER_NAME=${PROJECT_NAME}
PROJECT_DIR=/home/${USER_NAME}/${PROJECT_NAME}
RUNNER="-u ${USER_NAME}"
VOLUMES=("app/${PROJECT_NAME}" "var")
TTY_MODE="-it"
IMAGE_NAME=`whoami`/${PROJECT_NAME}
DOCKERTAG=""

PORT=""
MKDOCKER_OPT=""
DEV_ENV="-e PROJECT_NAME=${PROJECT_NAME}"

COMMAND=""
if [ $CMD != "devsh" ]; then
	COMMAND="${PROJECT_DIR}/app/bin/${CMD}.sh"
fi
if [ $CMD = "test" ]; then
	MKDOCKER_OPT="-d"
	IMAGE_NAME=`whoami`/${PROJECT_NAME}_test
	TTY_MODE=""
fi

while getopts t:p:u:d OPT
do
	case $OPT in
		p) PORT="-p $OPTARG:8000" ;;
		u) RUNNER="-u ${OPTARG}" ;;
		t)
			DOCKERTAG=":${OPTARG}"
			MKDOCKER_OPT="${MKDOCKER_OPT} -t ${OPTARG}"
			;;
		d)
			MKDOCKER_OPT="${MKDOCKER_OPT} -d"
			IMAGE_NAME=`whoami`/${PROJECT_NAME}_test
			;;
	esac
done
shift $((OPTIND - 1))

VOLUME_OPT=""
for D in ${VOLUMES[@]}
do
	VOLUME_OPT="${VOLUME_OPT} -v ${BASEDIR}/${D}:${PROJECT_DIR}/${D}"
done

set -eu
${BASEDIR}/app/env/docker/mkdocker.sh ${MKDOCKER_OPT}
set +eu
DB_NAME=${PROJECT_NAME}_${CMD}_db
DB_CONTAINER=`docker run --name ${DB_NAME} -d postgres`
DOCKER_OPTIONS="--link ${DB_NAME}:db ${VOLUME_OPT} ${RUNNER} ${DEV_ENV} ${IMAGE_NAME}${DOCKERTAG}"
docker run --rm=true ${TTY_MODE} ${PORT} ${DOCKER_OPTIONS} $COMMAND $@
RESULT=$?
docker stop ${DB_CONTAINER}
docker rm ${DB_CONTAINER}
exit $RESULT
```