Docker for WindowsでLinux作業環境とWebサーバ環境を構築する

スポンサーリンク

はじめに

コンピュータ上に仮想化環境を構築して、異なるOSやWebサービスのテストや運用をすることはすでに一般的で、複数ある選択肢のうちDockerに興味があったので使い始めました。
参考:仮想環境についてまとめてみる(https://qiita.com/9en/items/f4eab2f61485a9f3885a

Dockerでは、仮想化環境の構成要素を設定ファイルとして記述して環境構築を自動化します。そのため、Dockerが動いている他のマシンと設定ファイルを共有することで同じ仮想化環境を構築できるほか、同じ設定をCloudにデプロイしてローカルで試行した環境をほぼそのままCloudサーバ上に構築するといった使い方ができることもDockerの魅力だと思います。
しかしながら、Dockerを使用すればホストマシンによらず同じ環境を構築することができるかというと、必ずしもそうではなく、特にDocker for Windowsの問題なのかもしれませんが、Volumeがうまく機能しない場合があるなど、他のサイトで紹介されているすべての内容が同じように動くとは限らないことがわかってきました。

今回、Docker for Windowsを利用して下図のような作業用Linux環境(Debian)とローカルマシン上で動作する2種類(NginxベースとNode.jsベース)のWebサーバ環境を構築し、ある程度まともに動くようになったので、これまでに得たノウハウをこのページに記します。

Docker for Windowsのインストール

Docker for Windowsの前提条件:
Windows 10 Pro または Enterprise and Education

以下のページを参考にDocker for Windowsをインストールしてアカウントを作成します。

Dockerの起動

インストールできたらデスクトップにクジラのショートカットができていると思います。

このショートカットからDockerを起動するか、または、デフォルトで以下のような場所にインストールされていると思いますので、exeファイルを実行します。
C:¥Program Files¥Docker¥Docker¥Docker for Windows.exe
インストール直後はSettingsの「Start Docker Desktop when you log in」にチェックが入っていてPCを起動するとDockerが立ち上がる状態でしたが、使う時だけDockerを立ち上げたかったので、チェックを外しました。後述の「エラー対応」に記載したようにWindowsを終了する前にDockerを終了しておかないと次回起動時にうまく動かないことがあったことも自動起動をOFFにした理由の1つです。

Dockerが正常に起動していれば、コマンドプロンプトやPowerShellから以下のコマンドを実行するとヘルプが表示されるはずです。

docker --help

設定ファイルの準備

任意の場所に作業フォルダを作成して、そこに以下の設定ファイルを作成していきます。

  • docker-compose設定ファイル: docker-compose.yml
    Dockerの環境構築を楽にしてくれるDockerのラッパーコマンドdocker-composeの設定ファイル
  • Docker設定ファイル: Dockerfile
    Dockerによる環境構築内容を指定する設定ファイル

docker-composeを使うと多数のDockerのオプションを設定として記述でき、ビルドや起動が楽になるのでおすすめです。

フォルダ構成

docker/
 ├─ docker-compose.yml
 ├─ base/
 │   └─ Dockerfile
 ├─ work/
 ├─ data/
 │   ├─ html/
 │   │   ├─ disp.js
 │   │   ├─ get.php
 │   │   ├─ index.html
 │   │   └─ style.css
 │   └─ node/
 │        ├─ app.js
 │        └─ views/
 │             └─ index.ejs
 ├─ nginx/
 │   └─ default.conf
 ├─ node/
 │   └─ Dockerfile
 └─ postgres/
      ├─ createdb.sql
      ├─ Dockerfile
      └─ postgresql.conf

以降でファイルの内容を紹介しますが、上記全てのファイルの中身を記載しておりません。
GitHubに全ファイルを置いていますので、紹介していないファイルの内容を知りたい場合はご利用ください。

docker-compose.ymlの例

docker-compose.ymlはインデント(字下げ)で階層構造を表すYAML形式の設定ファイルです。
Dockerではデフォルト状態ではサービスに割り振られるIPアドレスが毎度変わってしまいますが、別のサービスから接続する際にURLが変わってしまうと不便なため、一部のサービスのIPアドレスが固定されるNetwork設定にしています。

version: "3.4"
services:
  base:
    build: base
    container_name: base
    command: /usr/sbin/sshd -D
    ports:
      - "49944:22"
      - "50080:8080"
    volumes:
      - type: bind
        source: ./work
        target: /work
      - type: bind
        source: ./data
        target: /data
    networks:
      mynet:
        ipv4_address: 172.18.0.10

  nginx:
    image: nginx:1.13.5-alpine
    container_name: nginx
    ports:
      - "80:80"
    depends_on:
      - php
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
      - ./data/html:/var/www/html

  php:
    image: php:7.1.9-fpm
    container_name: php
    volumes:
      - ./data/html:/var/www/html

  node:
    build: ./node
    container_name: node
    volumes:
      - ./data/node/app.js:/src/app.js
      - ./data/node/views:/src/views
    working_dir: /src
    ports:
      - "3000:3000"
    networks:
      mynet:
        ipv4_address: 172.18.0.4
    depends_on:
      - postgres
    entrypoint:
      - node
      - app

  postgres:
    build: ./postgres
    container_name: pg
    environment:
      POSTGRES_DB: mydb
      POSTGRES_USER: dbuser
      POSTGRES_PASSWORD: dbpass
    ports:
      - 5432:5432
    volumes:
      - pgdb:/var/lib/postgresql/data
    networks:
      mynet:
        ipv4_address: 172.18.0.2

volumes:
  pgdb:
    external: false

networks:
  mynet:
    ipam:
      config:
        - subnet: 172.18.0.0/16

49152~65535はユーザが自由に使えるポート番号です。netstatコマンド等で使用状況を調べ、空いている番号を選択するのが無難です。

Dockerfileの例

DockerfileはDockerがイメージを作る際の設定ファイルになります。
base/Dockerfile:

FROM python:3.6
ARG USER=user
ARG GROUP=developer
ARG PASS=password

ENV DEBIAN_FRONTEND noninteractive
ENV DEBCONF_NOWARNINGS yes

# Common
RUN apt-get update && apt-get install -y \
  apt-transport-https \
  libasound2-dev \
  bash-completion \
  build-essential \
  bzip2 \
  cmake \
  curl \
  gcc \
  g++ \
  git \
  less \
  libatlas-base-dev \
  libgl1-mesa-dev \
  locales \
  make \
  man \
  manpages-dev \
  mosquitto-clients \
  net-tools \
  openssh-server \
  openssh-client \
  p7zip \
  postgresql-client \
  software-properties-common \
  sudo \
  unzip \
  vim \
  wget \
  xorg-dev \
  zlib1g-dev \
  zsh

# Node-js
RUN curl -sL https://deb.nodesource.com/setup_8.x | bash -
RUN apt-get update && apt-get install -y nodejs

# Heroku CLI
RUN npm install -g heroku

RUN groupadd -g 1000 $GROUP
RUN useradd -g $GROUP -G sudo -m -s /bin/bash $USER

RUN mkdir /var/run/sshd
RUN echo "${USER}:${PASS}" | chpasswd
RUN echo "root:${PASS}" | chpasswd

# SSH settings. Otherwise user is kicked off after login
RUN sed 's@session\s*required\s*pam_loginuid.so@session optional pam_loginuid.so@g' -i /etc/pam.d/sshd
RUN sed -e 's@#Port 22@Port 22@' -e 's@#AddressFamily any@AddressFamily inet@' -i /etc/ssh/sshd_config

# OpenCV and Tensorflow
RUN ln -s /usr/include/libv4l1-videodev.h /usr/include/linux/videodev.h

RUN mkdir /tmp_cv
RUN cd /tmp_cv && wget https://github.com/Itseez/opencv/archive/3.1.0.zip && unzip 3.1.0.zip
RUN cd /tmp_cv/opencv-3.1.0 && cmake CMakeLists.txt -DWITH_TBB=ON \
                                                    -DINSTALL_CREATE_DISTRIB=ON \
                                                    -DWITH_FFMPEG=OFF \
                                                    -DWITH_IPP=OFF \
                                                    -DCMAKE_INSTALL_PREFIX=/usr/local
RUN cd /tmp_cv/opencv-3.1.0 && make -j2 && make install
# Note: The latest tornado(6.0.1) caused a problem not to connect to jupyter kernel; tornado==5.1.1 is a W/A.
RUN pip3 install numpy tensorflow opencv-python Pillow scipy matplotlib pandas keras jupyter tornado==5.1.1

# Go 1.11
RUN mkdir /tmp_go
RUN apt-get update && apt-get -y upgrade && \
  cd /tmp_go && \
  wget https://dl.google.com/go/go1.11.4.linux-amd64.tar.gz && \
  tar xvf go1.11.4.linux-amd64.tar.gz && \
  mv go /usr/local


#### User ####
USER $USER
WORKDIR /home/$USER

# Settings
RUN echo 'alias ls="ls -a --color=auto --show-control-chars --time-style=long-iso -FH"' >> /home/$USER/.profile
RUN echo 'alias ll="ls -a -lA"' >> /home/$USER/.profile
RUN echo 'alias h=history' >> /home/$USER/.profile
RUN echo 'alias vi=vim' >> /home/$USER/.profile
RUN echo 'alias jupyter_notebook="jupyter notebook --ip=0.0.0.0 --port=8080"' >> /home/$USER/.profile
RUN echo 'export GOROOT="/usr/local/go"' >> /home/$USER/.profile
RUN echo 'export GOPATH="/work/go"' >> /home/$USER/.profile
RUN echo 'export PATH="/work/bin:$GOPATH/bin:$GOROOT/bin:$PATH"' >> /home/$USER/.profile
RUN echo 'PS1="\$ "' >> /home/$USER/.bashrc
RUN echo 'set background=dark' > /home/$USER/.vimrc
RUN echo 'syntax on' >> /home/$USER/.vimrc
RUN touch /home/$USER/.Xauthority && chmod 600 /home/$USER/.Xauthority

USER root

#ADD .netrc /home/$USER
#ADD .gitconfig /home/$USER
#RUN chown $USER:$GROUP /home/$USER/.netrc /home/$USER/.gitconfig
#RUN chmod 644 /home/$USER/.netrc /home/$USER/.gitconfig

EXPOSE 22
EXPOSE 8080

ファイル先頭付近で設定しているARG変数USER, GROUP, PASSはお好みの内容に変えてください。
環境変数DISPLAYを設定していませんが、Dockerでは何も設定せずともデフォルトでホストマシンのDISPLAYが設定されるようです。
上記DockerfileにはGo言語やHeroku CLIなどの設定も含んでいますが、本記事の中では触れていません。

php/Dockerfile:

FROM php:7.1.9-fpm

ADD php.ini /usr/local/etc/php/conf.d/php.ini

WORKDIR /tmp

# For composer
RUN apt-get update \
    && apt-get install -y apt-utils libzip-dev zlib1g-dev \
    && docker-php-ext-install zip

# Copy composer
COPY --from=composer:1.5 /usr/bin/composer /usr/bin/composer

# Set composer environment
ENV COMPOSER_ALLOW_SUPERUSER 1
ENV COMPOSER_HOME /composer
ENV PATH $PATH:/composer/vendor/bin

# Install laravel installer
RUN composer global require "laravel/installer"

# PHP's DB setting
RUN apt-get update \
    && apt-get install -y libpq-dev \
    && docker-php-ext-install pdo_mysql pdo_pgsql mysqli

RUN apt-get update \
    && apt-get install -y libmemcached-dev zlib1g-dev \
    && pecl install memcached-3.0.3 \
    && docker-php-ext-enable memcached opcache

# Install Node.js
RUN curl -sL https://deb.nodesource.com/setup_6.x | bash - \
    && apt-get update \
    && apt-get install -y nodejs

node/Dockerfile:

FROM node:10.15-alpine

RUN mkdir /src
WORKDIR /src

RUN echo {} > package.json
RUN npm install --save express && \
    npm install --save ejs && \
    npm install --save pg

EXPOSE 3000

postgres/Dockerfile:

FROM postgres:alpine

COPY ./postgresql.conf /var/lib/postgresql
COPY ./createdb.sql /var/lib/postgresql

EXPOSE 5432

CMD ["postgres", "-D", "/var/lib/postgresql/data", "-c", "config_file=/var/lib/postgresql/postgresql.conf"]

環境変数PATHなど、コンテナ内でも環境変数として利用したい値はENVを使い、ビルド時にのみ利用したい値はARGを使うと良いです。
画面表示を伴うプログラムも使えるよう、base環境はssh接続+X11フォワーディングでGUI表示を実現しています。これ以外にVNC系のツールでブラウザなどに画面を飛ばす方法も試しましたが、私は前者が使いやすかったので採用しました。

docker-composeコマンド

通常はdocker-composeコマンドで操作するのが便利です。以下によく使うコマンドを紹介します。

コマンド説明補足
docker-compose buildサービスの構築単にdocker-compose buildとすると、docker-compose.ymlに定義したすべてのサービスをビルドします。
ただし、Dockerfileを使わない既存イメージそのままのサービスはビルドされず、up時にpull(ダウンロード)されます。
docker-compose build サービス名 とすると指定したサービスをビルドします。
docker-compose upコンテナの作成と開始単にdocker-compose upとすると、docker-compose.ymlに定義したすべてのサービスのコンテナを作成・開始します。
docker-compose up サービス名 とすると指定したサービスのコンテナを作成・開始します。
ビルドしていないサービスはビルドしてからコンテナ作成・開始されます。
docker-compose up -dまたはdocker-compose up -d サービス名 とするとバックグラウンドで実行されます。
docker-composeで複数のサービスを対象にする場合、-d を付けてバックグラウンド実行するのが基本になると思います。
docker-compose downコンテナの停止と削除docker-compose.ymlに定義したサービスのコンテナを停止・削除します。

dockerコマンド

dockerコマンドでなければ実施できない操作がありますので、以下を覚えておくと良いでしょう。

コマンド説明補足
docker psコンテナの一覧表示単にdocker psとすると、起動中のコンテナ一覧が表示されます。
docker ps -aとすると、停止中のコンテナも表示されます。
docker runコンテナの作成・開始・接続docker run --rm -v /c/Users/user/docker/work:/work -p 49944:22 -it IMAGE-ID bash
のようにvolumeとポートを指定して実行することが多い。
-vで相対パス(./data/workなど)を使用するとDocker for Windowsではエラーになるので絶対パスで書きます。
docker start CONTAINER-IDコンテナの開始指定したIDのコンテナを開始します。
docker éxec CONTAINER-ID
コマンド実行指定したIDの実行中コンテナ内で新たにコマンドを実行します。
docker attach CONTAINER-IDコンテナへの接続指定したIDのコンテナへ接続します。
docker stop CONTAINER-IDコンテナの停止指定したIDのコンテナを停止します。
docker rm CONTAINER-IDコンテナの削除指定したIDのコンテナを削除します。
docker rmi IMAGE-IDイメージの削除指定したIDのイメージを削除します。
docker volume lsVolumeを表示Volumeの一覧を表示します。
docker volume create VOLUME-NAMEVolumeを作成新規Volumeを作成します。
docker volume inspect VOLUME-NAMEVolumeの情報表示指定した名前のVolumeの情報を表示します。
docker volume rm VOLUME-NAMEVolumeを削除指定した名前のVolumeを削除します。
docker volume pruneVolumeの実体を削除docker volume rmではVolumeの実体は消えず、また同名でdocker volume createするとデータが復活します。実体を消去するためにこのコマンドを使います。
docker network lsNetworkを表示Networkの一覧を表示します。
docker network create NETWORK-NAMENetworkを作成新規Networkを作成します。
docker network inspect NETWORK-NAME Networkの情報表示指定した名前のNetworkの情報を表示します。
docker network rm NETWORK-NAMENetworkを削除指定した名前のNetworkを削除します。
docker logs [-f] CONTAINER-NAMEコンテナのログを参照する指定した名前のコンテナのログを参照します。-fを付けると最新のログを待ち受ける状態になります(tail -fと同様)。

※ WordPressのTablepressプラグインではテーブル内にexeという文字列を書けないようで、eの代わりにéを使って表現していますが、実際は通常のeを使ってdocker execとしてください。

Volumeについて

Dockerでは起動したコンテナ上で作成したファイルは、コンテナを停止すると消えてしまいますが、データを永続化するためにVolumeという仕組みが用意されています。Volumeには以下の2種類があります。

①ホストマシン(ここではWindows)のディレクトリやファイルをマウントしてファイルを共有。ホストマシンから参照したり、ファイルを変更することが可能。

②データVolumeとしてdockerが管理するデータ領域を確保し、コンテナ上のディレクトリにマウントする。ホストマシンから直接中身を参照できない。

ただし、やっかいなのがVolumeとしてマウントしたディレクトリはroot権限になってしまいます(これはどうやらDocker for Windowsだけのようです)。
ホームディレクトリは環境立ち上げ後に各種ツールの設定を変更したり、historyを残したりしたいと思い、当初、ユーザのホームディレクトリをVolume指定しようと試みたのですが、vimを閉じる際に.viminfoのオーナーがrootのため、.viminfoへの自動書き込みに失敗するといった問題が生じました。
これを回避する良い方法が見つけられず、別途作成したVolumeディレクトリにホームのファイルとパーミッションをセーブしたりロードするシェルスクリプトを書いてバックアップするようにしていました。
しかし、この自力バックアップ方式も次第に使わなくなっていきました。結局ホームディレクトリの状態を更新したくなるのは、インストールするツールを増やしたときであり、そのときはコンテナをbuildし直すので、Dockerfileで.profileなどに書き足したり、ADDでファイルを置いてchown, chmodする方法に落ち着きました。.bash_historyのコマンド履歴がコンテナ停止時に無くなることは、私にとってはさほど重要ではありませんでした。

実行確認

以降、構築した環境が正しく動作するか確認した内容を記載します。確認のレベルがそれぞれ異なることについては、ご容赦ください。

Dockerコンテナの起動

PowerShellでdocker-compose.ymlファイルを置いたフォルダに移動し、docker-compose up -d コマンドを使ってDockerコンテナを起動します。

PS> docker-compose up -d
Creating network "docker_mynet" with the default driver
Creating network "docker_default" with the default driver
Creating php  ... done
Creating base  ... done
Creating pg   ... done
Creating node  ... done
Creating nginx ... done

次にMobaXtermやTera Termなどを使ってbase環境にssh接続を行います。

MobaXtermの場合、起動すると下図のウィンドウが立ち上がります。下図はbase接続を設定してから画面キャプチャーしたものになります。一度設定するとRecent Sessionsのところにあるbaseをクリック、または左のタブにあるbaseをダブルクリックすることでコンテナに入れます。

グラフィックなどX11経由で画面表示できるよう、Settings→ConfigurationからX11タブを開き、X11 remote accessをfullに設定しておきます。

Sessions→New session→SSHから、以下のように接続先を設定してSSH接続を行います。
Remote hostには localhost を入れ、Specify usernameにチェックを入れてDockerfileで作成したユーザ名を記入、PortにはDockerfileでbaseコンテナの22番ポートに割り当てたホスト側のポート番号(:の左側、私の例では49944)を入れてOKを押します。
その後、初回の接続時のみパスワードが必要になります。

うまく接続できると以下のようにbashのコマンドウィンドウが立ち上がります。

複数ウィンドウを立ち上げたい場合は、Terminal→Duplicate current tab または bashウィンドウの上の方のbaseと表示のある部分で右クリック→Duplicate tabから立ち上げられます。
立ち上げた後は、baseと表示のある部分をドラッグしてMobaXtermの枠外にドロップすることで複数ウィンドウを並べて表示できます。

Tera Termの場合、新しい接続の画面から下図のようにホスト名に localhost を入れ、TCPポート#にDockerfileでbaseコンテナの22番ポートに割り当てたホスト側のポート番号(:の左側、私の例では49944)を入れてOKを押します。

ユーザ名とパスフレーズにDockerfileで作成したユーザ名とパスワードを入れてOKを押します。

正しく接続できると以下のように表示されます。

続いてそれぞれのサービスの動作確認をしていきます。まずは、作業場所となるbase環境から。

base/ Python

Pythonの動作確認としてnumpy, pandas, scypy, matplotlibを試していきます。

import numpy as np
import scipy.integrate as itg
import matplotlib.pyplot as plt
import pandas as pd

VMIN = -2.0
VMAX =  2.0

def func1(x):
    return x ** 2

def func2(x0, x1):
    return itg.quad(func1, x0, x1)[0]

x = pd.Series([i for i in np.arange(VMIN, VMAX, 0.01)])
y = x.map(func1)
y_sum = pd.Series([func2(VMIN, i) for i in x])


table = pd.DataFrame({'y':y, 'y_sum':y_sum})
table.index = x

print(table)

table.plot(title='Plot Example')
plt.show()

上のようなPythonコードを用意して実行します。この例では-2~2の範囲で y=x2 とその積分値をグラフ化しています。

cd /work/test
python test_python.py

私の例では/work/testにPythonプログラムを保存したので、そこへcdで移動しています。
以下のようなグラフが表示されれば成功です。

base/ OpenCV(Python)

OpenCVで画像の平滑化とエッジ抽出を試してみます。

import numpy as np
import cv2
import matplotlib.pyplot as plt

#%matplotlib inline
RESIZEX = 300
RESIZEY = 400

img = cv2.imread('../img/sample/dedenne.jpg')
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
img = cv2.resize(img, (RESIZEX, RESIZEY))
blr = cv2.blur(img, (5, 5))
edg = cv2.Canny(blr, 50, 80)

plt.subplot(1, 3, 1), plt.imshow(img)
plt.title('Original'), plt.xticks([]), plt.yticks([])
plt.subplot(1, 3, 2), plt.imshow(blr)
plt.title('Blur'), plt.xticks([]), plt.yticks([])
plt.subplot(1, 3, 3), plt.imshow(edg, cmap = 'gray')
plt.title('Edge'), plt.xticks([]), plt.yticks([])
plt.show()

上のようなPythonコードを用意して実行します。この例では入力画像をリサイズしたものをOriginalとし、平滑化、エッジ抽出しています。

python test_opencv.py

以下のような画像が表示されれば成功です。

base/ Tensorflow, Keras, Jupyter-Notebook

機械学習ライブラリKerasとTensorflowバックエンドを使ってFashion MNIST(服の分類)を試してみます。

import keras
from keras.datasets import fashion_mnist
from keras.models import Sequential
from keras.layers import Dense, Dropout
from keras.optimizers import RMSprop
from IPython import get_ipython
import matplotlib.pyplot as plt
import numpy as np
import copy

#%matplotlib inline
ipy = get_ipython()
if ipy is not None:
    ipy.run_line_magic('matplotlib', 'inline')

np.random.seed(0)

batch_size = 128
num_classes = 10
epochs = 20

# the data, split between train and test sets
(x_train, y_train), (x_test, y_test) = fashion_mnist.load_data()
x_test_org = copy.deepcopy(x_test)

# show a part of training images
label_name = ('T-shirt/top', 'Trouser', 'Pullover', 'Dress', 'Coat', 'Sandal', 'Shirt', 'Sneaker', 'Bag', 'Ancle boot')

n_rows = 5
n_cols = 5
fig, axs = plt.subplots(n_rows, n_cols, figsize=(28,28))
plt.subplots_adjust(wspace=0.0, hspace=0.6)
i = 0
for ax, pixels, label in zip(axs.flat, x_train, y_train):
    ax.imshow(pixels, cmap="gray")
    ax.set_title('{}: {}'.format(i, label_name[label]), fontsize=20)
    ax.set_xticks([])
    ax.set_yticks([])
    i+=1
plt.show()

x_train = x_train.reshape(60000, 784)
x_test = x_test.reshape(10000, 784)
x_train = x_train.astype('float32')
x_test = x_test.astype('float32')
x_train /= 255
x_test /= 255
print(x_train.shape[0], 'train samples')
print(x_test.shape[0], 'test samples')

# convert class vectors to binary class matrices
y_train = keras.utils.to_categorical(y_train, num_classes)
y_test = keras.utils.to_categorical(y_test, num_classes)

model = Sequential()
model.add(Dense(512, activation='relu', input_shape=(784,)))
model.add(Dropout(0.2))
model.add(Dense(512, activation='relu'))
model.add(Dropout(0.2))
model.add(Dense(num_classes, activation='softmax'))

model.summary()

model.compile(loss='categorical_crossentropy',
              optimizer=RMSprop(),
              metrics=['accuracy'])

history = model.fit(x_train, y_train,
                    batch_size=batch_size,
                    epochs=epochs,
                    verbose=1,
                    validation_data=(x_test, y_test))
score = model.evaluate(x_test, y_test, verbose=0)
print('Test loss:', score[0])
print('Test accuracy:', score[1])

# pick wrong choices
prex = model.predict(x_test)
wrong = []
title = []
i = 0
while True:
    yi = y_test[i:i+1]
    prei = prex[i:i+1]
    if prei.argmax() != yi.argmax():
        wrong.append(i)
        title.append('{}: {} -> {}'.format(i, label_name[yi.argmax()], label_name[prei.argmax()]))
    if len(wrong) == n_rows * n_cols or i == len(y_test):
        break
    i+=1

print('wrong choice: {}'.format(title))

fig, axs = plt.subplots(n_rows, n_cols, figsize=(28,28))
plt.subplots_adjust(wspace=0.0, hspace=0.6)
for ax, num, label in zip(axs.flat, wrong, title):
    ax.imshow(x_test_org[num], cmap="gray")
    ax.set_title(label, fontsize=20)
    ax.set_xticks([])
    ax.set_yticks([])
plt.show()

このプログラムは前の例と同様にコマンドラインからpythonで実行することもできますが、今回は可視化に優れた解析ツールJupyter-Notebookを使って実行してみます。

jupyter_notebook

上のコマンドはDockerfileで jupyter notebook –ip=0.0.0.0 –port=8080 にaliasしていますので、実際に以下のように打ち込んでも同じです。

jupyter notebook --ip=0.0.0.0 --port=8080

上記どちらかの方法でJupyter-Notebookを実行すると、画面に出るログの中に以下のような部分が見つかると思います。
その中の /?token= 以降をコピーしておきます。

http://(91280c4dba11 or 127.0.0.1):8080/?token=~

Windows上でブラウザを立ち上げ、URLとして localhost:50080 に続けて先ほどコピーした文字列を貼り付けEnterを押します。

localhost:50080/?token=~

ポート番号の 50080 はDockerfileでbaseコンテナの 8080 ポートに割り当てたホスト側のポート番号です。
下図のようなJupyter-Notebookの画面がブラウザに表示されるので、Newと表示されているプルダウンメニューからPython 3を選択します。

コマンド入力部に以下を打ち込み、Shift+Enterを押します。

run -i test_keras.py

以下のように出力されれば成功です。Jupyter-Notebookでは出力した画像もログのように扱われて履歴として残るので便利ですね。

上の例では6万件の訓練用画像を学習したモデルを評価用画像に適用して、誤認識したものの一部を表示してみました。

Nginx + PHP

WebサーバーのNginx(エンジンエックス)とサーバーサイドWeb開発スクリプト言語であるPHPを使う環境を試します。
前述のdocker-compose.ymlの例のとおり、今回はnginx:1.13.5-alpineとphp:7.1.9-fpmのイメージをそのまま使っているので、NginxとPHP用のDockerfileは作成していません。
Nginxの設定ファイルであるdefault.confは以下の内容とし、Volumeでコンテナに反映します。

server {
    listen 80;
    server_name _;
    root  /var/www/html;
    index index.html;

    access_log /var/log/nginx/access.log;
    error_log  /var/log/nginx/error.log;

    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }

    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(\.+)$;
        fastcgi_pass php:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }
}

data/htmlには次のようなファイルを置いておきます。
index.html:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Test Ajax and PHP</title>
        <link rel="stylesheet" href="style.css" type="text/css" />
        <script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
        <script src="disp.js"></script>
    </head>
    <body>
        <p id="first" style="display:inline">Hello </p>
        <p id="second" style="display:inline">World!</p>
        <br>
        <select id="greet">
            <option value="Hello">Hello </option>
            <option value="おはよう">おはよう </option>
            <option value="やあ">やあ </option>
        </select>
        <button id="world" class="button1">World!</button>
        <button id="everyone" class="button1">みなさん</button>
        <button id="japan" class="button1">日本</button>
    </body>
</html>

style.css

button.button1{
    height: 30px;
    line-height: 30px;
}

disp.js:

$(function(){
    $('#greet').on("change",function(){
        let param = { "opt": $(this).val() };
        $.post({
            url: "get.php",
            data: param,
            dataType: "json",
        }).done(function(data){
           $("#first").text(data.output_text);
        }).fail(function(XMLHttpRequest, textStatus, errorThrown){
            alert(errorThrown);
        });
    });
    $('#world').on("click",function(){
        let param = { "opt": "World!" };
        $.post({
            url: "get.php",
            data: param,
            dataType: "json",
        }).done(function(data){
           $("#second").text(data.output_text);
        }).fail(function(XMLHttpRequest, textStatus, errorThrown){
            alert(errorThrown);
        });
    });
    $('#everyone').on("click",function(){
        let param = { "opt": "みなさん" };
        $.post({
            url: "get.php",
            data: param,
            dataType: "json",
        }).done(function(data){
           $("#second").text(data.output_text);
        }).fail(function(XMLHttpRequest, textStatus, errorThrown){
            alert(errorThrown);
        });
    });
    $('#japan').on("click",function(){
        let param = { "opt": "日本" };
        $.post({
            url: "get.php",
            data: param,
            dataType: "json",
        }).done(function(data){
           $("#second").text(data.output_text);
        }).fail(function(XMLHttpRequest, textStatus, errorThrown){
            alert(errorThrown);
        });
    });
});

get.php:

<?php
// Accept only from ajax
$request = isset($_SERVER['HTTP_X_REQUESTED_WITH']) ? strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) : '';
if($request !== 'xmlhttprequest') exit;
 
$text = filter_input(INPUT_POST, 'opt');
echo json_encode(['output_text' => "$text"]);

ここまで準備できたら、nginxとphpを起動し、ホストマシン(Windows)のWebブラウザでlocalhostにつなぐと下のように動的に文字を変更するページが表示されるはずです。

JQueryでクリック動作を拾い、PHPにJSONデータを投げ、PHPからJSONで返ってきたテキストデータで<p>オブジェクトの内容を書き換えています。
処理の内容としては全くPHPを使う必然性がありませんが、これで一応NginxとPHPを連携させた動的なWebページの動作確認ができました。

Node.js + PostgreSQL

次はイベント駆動のJavascript環境であるNode.jsとPostgreSQLデータベースの連携でデータを永続化した動的Webページを試します。
当初、データベースとしてmongoDBやmysqlを試していたのですが、データの永続化がうまくいかず、PostgreSQLを試しているうち、前述の「Volumeについて」の②の方法でデータの永続化ができたのでこれを採用しました。(もしかするとmongoDBやmysqlでも②の方法で動くのかもしれませんが、PostgreSQLでうまくいったあとは試していません。)

PostgreSQLサービスのIPアドレスはdocker-compose.ymlの設定で172.18.0.2固定にしてあります。
WebアプリフレームワークExpressとテンプレートエンジンejsを使ってNode.js用JavaScriptを記述します。
data/node/app.js:

//======== Express settings ========
let express = require('express'),
    app = express();

app.set('views', __dirname + '/views');
app.set('view engine', 'ejs');

//======== Postgresql settings ========
let address_list;
let { Client } = require('pg');
let client = new Client({
    user: 'dbuser',
    database: 'mydb',
    password: 'dbpass',
    host: '172.18.0.2',
    port: 5432
});
client.connect();

//======== Routing ========
app.get('/', (req, res) => {
    client.query("SELECT * from address_book")
        .then(res1 => {
            address_list = res1.rows;
            console.log('--------');
            console.log(address_list);
            res.render('index', {posts: address_list});
        })
        .catch(e => console.error(e.stack));
});

app.get('/add/:name', (req, res) => {
    client.query("INSERT INTO address_book (name, address) VALUES ('"
        + req.params.name + "', '" + req.query.address + "')")
        .then(res1 => {
            client.query("SELECT * from address_book")
                .then(res2 => {
                    address_list = res2.rows;
                    console.log('--------');
                    console.log(address_list);
                    res.json(address_list);
                })
                .catch(e => console.error(e.stack));
        })
        .catch(e => console.error(e.stack));
});

app.get('/del/:name', (req, res) => {
    client.query("DELETE FROM address_book WHERE name='"
        + req.params.name + "' AND address='" + req.query.address + "'")
        .then(res1 => {
            client.query("SELECT * from address_book")
                .then(res2 => {
                    address_list = res2.rows;
                    console.log('--------');
                    console.log(address_list);
                    res.json(address_list);
                })
                .catch(e => console.error(e.stack));
        })
        .catch(e => console.error(e.stack));
});

app.listen(3000, () => {
    console.log('server listening...');
});

初回’/’にGET要求が来た場合はテンプレートindex.ejsの中身を置き換えて表示し、Add, DelボタンクリックからのGET要求に対しては追加や削除後のリストデータをJSON形式で返し、JQueryで動的にテーブルの内容を更新しています。
data/node/views/index.ejs:

<html>
<head>
    <title>Address Book</title>
    <meta charset="utf-8">
    <script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
    <script>
        $( () => {
            let update = (data) => {
                $('#tbl').empty();
                $('#sel').empty();
                $('#tbl').append('<tr style="background: #ccccff;"><td>Name</td><td>Address</td></tr>');
                for(let i in data){
                    $('#tbl').append('<tr><td>' + data[i].name + '</td><td>' + data[i].address + '</td></tr>');
                    $('#sel').append('<option address="' + data[i].address + '">' + data[i].name + '</optio>');
                }
            }
            $('#add').on("click", () => {
                $.get(
                    'http://localhost:3000/add/' + $('#name').val(),
                    {address:$('#address').val()},
                    'json'
                ).done( (data) => {
                    update(data);
                    $('#name').val('');
                    $('#address').val('');
                }).fail( (XMLHttpRequest, textStatus, errorThrown) => {
                    alert(errorThrown);
                });
            });
            $('#del').on("click", () => {
                $.get(
                    'http://localhost:3000/del/' + $('#sel option:selected').val(),
                    {address:$('#sel option:selected').attr('address')},
                    'json'
                ).done( (data) => {
                    update(data);
                }).fail( (XMLHttpRequest, textStatus, errorThrown) => {
                    alert(errorThrown);
                });
            });
        });
    </script>
</head>
<body>
    <p>Address List</p>
    <form>
        Name: <input type="text" id="name">
        Address: <input type="text" id="address">
        <button id="add">Add</button>
    </form>
    <select id="sel">
        <% for (let i in posts){ %>
        <option address="<%= posts[i].address %>"><%= posts[i].name %></option>
        <% } %>
    </select>
    <button id="del">Delete</button>
    <br><br>
    <table border="1" cellpadding="5" cellspacing="2" id="tbl">
        <tr style="background: #ccccff;"><td>Name</td><td>Address</td></tr>
        <% for (let i in posts){ %>
        <tr><td><%= posts[i].name %></td><td><%= posts[i].address %></td></tr>
        <% } %>
    </table>
</body>
</html>

続いて、PostgreSQLのデータベースを作成し、初期データを格納するSQL文を次のように作成します。
postgres/createdb.sql:

CREATE TABLE address_book (name varchar(255), address varchar(255));
GRANT ALL ON address_book TO dbuser;
INSERT INTO address_book(
            name,   address)
    VALUES ('User', 'Tokyo Japan'),
           ('John', 'Calif US');

これを使って以下のようにコンテナに入り、psqlコマンドでPostgreSQLをコマンドラインで立ち上げ、¥iコマンドでcreatedb.sqlを読み込みます。

PS> docker exec -e POSTGRES_DB=mydb -e POSTGRES_PASSWORD=dbpass -it pg bash
bash> psql -U dbuser mydb
psql> ¥i /var/lib/postgresql/createdb.sql
CREATE TABLE
GRANT
INSERT 0 2

以下のテーブルができていることを確認します。

psql> ¥dt
           List of relations
 Schema |     Name     | Type  | Owner
--------+--------------+-------+--------
 public | address_book | table | dbuser
(1 row)

次にテーブルの中身を確認します。

psql> select * from address_book ;
 name |   address
------+-------------
 User | Tokyo Japan
 John | Calif US
(2 rows)

ログは以下のように確認できます。

PS> docker logs pg
PostgreSQL init process complete; ready for start up.

2019-04-27 02:09:11.591 UTC [1] LOG:  listening on IPv4 address "0.0.0.0", port 5432
2019-04-27 02:09:11.591 UTC [1] LOG:  listening on IPv6 address "::", port 5432
2019-04-27 02:09:11.600 UTC [1] LOG:  listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
2019-04-27 02:09:11.628 UTC [41] LOG:  database system was shut down at 2019-04-27 02:09:11 UTC
2019-04-27 02:09:11.636 UTC [1] LOG:  database system is ready to accept connections
2019-04-27 02:09:35.621 UTC [54] LOG:  statement: CREATE TABLE address_book (name varchar(255), address varchar(255));
2019-04-27 02:09:35.640 UTC [54] LOG:  statement: GRANT ALL ON address_book TO dbuser;
2019-04-27 02:09:35.643 UTC [54] LOG:  statement: INSERT INTO address_book(
                    name,   address)
            VALUES ('User', 'Tokyo Japan'),
                   ('John', 'Calif US');
   :(省略)
2019-04-27 02:09:52.005 UTC [54] LOG:  statement: select * from address_book ;

以下はコンテナの起動からlocalhost:3000への接続、表示されたアドレス帳ページで項目の追加、削除を行う実施例です。

データベースの情報はVolumeに記録されるので、コンテナやdockerを停止してもデータは永続化され、次回起動時には最後の状態が表示されます。

Dockerコンテナの停止・削除

PowerShellからdocker-composeコマンドを使ってDockerコンテナを停止・削除します。

PS> docker-compose down
Stopping nginx ... done
Stopping node  ... done
Stopping base  ... done
Stopping php   ... done
Stopping pg    ... done
Removing nginx ... done
Removing node  ... done
Removing base  ... done
Removing php   ... done
Removing pg    ... done
Removing network docker_mynet
Removing network docker_default

エラー対応

何らかのエラーが出て、いつものようにコンテナが立ち上がらないなどの場合、Windowsツールバーの^印からDockerのクジラマーク上で右クリック→RestartでDockerを再起動するとうまくいくケースがありました。
PCの電源を落とす前にDocker for Windowsを終了しておくと起動時のトラブルが減るように思われます。

Error response from daemon: driver failed programming external connectivity on endpoint ~
上記のエラーが出てDockerを再起動しても直らないときは、ネットワークデバイスをクリンナップする以下のコマンドを実行し、PCを再起動します。

netcfg -d

おまけ:Ctrl+N無効化

私はssh接続を選択しましたが、NoVNCなどブラウザでデスクトップ画面を表示する方法を試した際の困りごと解決方法について書きます。
Linuxではコマンドhistoryをさかのぼったり、進めたりするのにCtrl+P, Ctrl+N(矢印キー↑↓も可)を使います。しかし、WindowsマシンでCtrl+Nを押すと新しいウィンドウ(ブラウザ)が立ち上がってしまって不快な思いをしました。
対策を調べるうち、AutoHotkeyが良い解決法であることがわかりました。
以下の内容をAutoHotkey.ahkというファイル名で「ドキュメント」フォルダ(C:¥Users¥¥Documents)に置くことでCtrl+Nを↓に置き換えることができ、快適になりました。

^n::Send,{Down}

以下のフォルダにAutoHotkey.exe(C:¥Program Files¥AutoHotkey¥AutoHotkey.exe)へのショートカットを置くことで自動的に起動するようになります。
C:¥Users¥user¥AppData¥Roaming¥Microsoft¥Windows¥Start Menu¥Programs¥Startup

Docker for Macでもテスト

本ページの内容をDocker for Macでも試し、以下のようにXquartzをインストールしておくことで動作することを確認しました。
1行目のコマンドはbrewのインストールですので、インストール済みの場合は2行目のみ実行することで良いです。

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"
brew cask install xquartz

Xquartzのインストールが終わったら一旦Macを再起動します。
コンテナを立ち上げてssh接続します。

docker-compose up -d
ssh -Y -p 49944 user@localhost

感想

Dockerは何かと問題に当たることも多いですが、一度好みの環境が立ち上がると快適で、スタンドアロンのLinux PCの置き換えとして十分機能すると思います。さらに複数マシンがローカルなネットワークにつながった状態を作って試せるのはとても便利です。
Python等の依存関係に問題が起きた場合など、通常は再インストールからやり直すのはハードルが高いですが、DockerではユーザデータはVolumeとして別管理にできるのと環境構築が自動化されていることで、何度でも環境を更新できるのも良いと感じています。
みなさまも快適なDocker Lifeを!

なお、現時点で構築したDocker環境について書きましたが、今後もDockerを使いながら環境を更新していくので、それに合わせて本ページの内容を更新することがあると思います。

書籍紹介

Dockerコンテナ開発・環境構築の基本

コンテナ型アプリケション開発の概念から運用まで説明されており、これまで何となくDockerを使っていた方が本格的に学ぶのに役立つ一冊です。

参考文献

タイトルとURLをコピーしました