CUBE SUGAR CONTAINER

技術系のこと書きます。

OpenSSH の秘密鍵から公開鍵を復元する

OpenSSH だと公開鍵がなくなっても秘密鍵から復元できることを知った。 今回はそれを試してみる。

使った環境は次の通り。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.3 LTS"
$ uname -r
4.15.0-65-generic
$ ssh -V
OpenSSH_7.6p1 Ubuntu-4ubuntu0.3, OpenSSL 1.0.2n  7 Dec 2017

もくじ

公開鍵ペアを作る

ひとまず以下のようにして公開鍵のペアを作る。

$ ssh-keygen \
    -t rsa \
    -b 2048 \
    -P '' \
    -f $HOME/.ssh/id_rsa.example \
    -C example@example.jp
Generating public/private rsa key pair.
Your identification has been saved in /home/vagrant/.ssh/id_rsa.example.
Your public key has been saved in /home/vagrant/.ssh/id_rsa.example.pub.
The key fingerprint is:
SHA256:qrL+HHbnpNI57R+hgQM+eem2BCnAln1b632Kp1/BSw4 example@example.jp
The key's randomart image is:
+---[RSA 2048]----+
|                 |
|. o              |
|.+ ... .         |
|.. ..+oo. .      |
|  . *.=.SE.+     |
|   . =.o.o+.o    |
|    o.*++..=     |
|  .o.*+*o.+.     |
| .o++.o=*+.      |
+----[SHA256]-----+

これで、以下のように公開鍵のペアができた。

$ ls ~/.ssh | grep id_rsa
id_rsa.example
id_rsa.example.pub

公開鍵の中身は以下の通り。

$ cat ~/.ssh/id_rsa.example.pub 
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDPYjc9UeA6iZM6LhPy5BfXxby0TWyDMbItVk4reJfNEWgpng0tqZaKm2Kp4XizZupfDVMFf2MmCfS3lARO4lDXkmi6YoOoFCSmq4vjuIiykQchjrbRBoTAvNk8IX8Z9lWt7juhbeWL9LGM7tyo7aM+jrB1MteBr8rXB/ugnwmEe3rgLgq64N7zklhLwZKlN8gofH4UGO1MMTq9lPjzFr+Edq/1vIPOiQPtXjjsPCVHryaK+Kyy39qW2I7AeYMCCX5w/JxFAFC/kBLOgWIDOFoHaCxrsl5bXv1augUsHyy6yftc+H7f+Z/xJzWj0l/LRU6iqGMTcOgXtZG8w9vFrObD example@example.jp

ひとまず、作った公開鍵は移動しておく。

$ mv ~/.ssh/id_rsa.example.pub{,.orig}

秘密鍵から公開鍵を復元する

次のように ssh-keygen(1) は -y オプションを使うことで秘密鍵から公開鍵を復元できる。 秘密鍵のファイルは -f オプションで渡す。

$ ssh-keygen -y -f ~/.ssh/id_rsa.example > ~/.ssh/id_rsa.example.pub

内容を確認すると、コメントを除いて中身が復元できていることがわかる。

$ cat ~/.ssh/id_rsa.example.pub
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDPYjc9UeA6iZM6LhPy5BfXxby0TWyDMbItVk4reJfNEWgpng0tqZaKm2Kp4XizZupfDVMFf2MmCfS3lARO4lDXkmi6YoOoFCSmq4vjuIiykQchjrbRBoTAvNk8IX8Z9lWt7juhbeWL9LGM7tyo7aM+jrB1MteBr8rXB/ugnwmEe3rgLgq64N7zklhLwZKlN8gofH4UGO1MMTq9lPjzFr+Edq/1vIPOiQPtXjjsPCVHryaK+Kyy39qW2I7AeYMCCX5w/JxFAFC/kBLOgWIDOFoHaCxrsl5bXv1augUsHyy6yftc+H7f+Z/xJzWj0l/LRU6iqGMTcOgXtZG8w9vFrObD

ただ、残念ながらコメントまでは復元できない。 例えば、以下のように -C オプションをつけてもファイルには反映されなかった。

$ ssh-keygen -y -C example@example.jp -f ~/.ssh/id_rsa.example > ~/.ssh/id_rsa.example.pub

いじょう。

NVIDIA Container Toolkit を使って Docker コンテナで GPU を使う

今回は NVIDIA Container Toolkit を使って Docker コンテナから Docker ホストの GPU を使う方法について書く。 これまで Docker コンテナで GPU を使う方法は、nvidia-docker と nvidia-docker2 という二つの世代を経てきた。 それも、ここに来てやっと一息ついたかな、という印象がある。 GPU の基本的なサポートが Docker 本体側に取り込まれて (v19.03 以降)、GPU ベンダーはそのドライバを提供する形に落ち着いた。 そして、従来の nvidia-docker2 は非推奨 (Deprecated) な方法となっている。

なお、GPU ベンダーがドライバを提供する、と前述した通り NVIDIA のリソースが一切不要になったわけではない。 そのため、インストール手順やインターフェースの使い勝手という観点では nvidia-docker2 から大して変わっていない、というのが実状ではあるだろう。

使った環境は次の通り。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.3 LTS"
$ uname -r
4.15.0-1044-gcp
$ lspci -vv | grep -i nvidia
00:04.0 3D controller: NVIDIA Corporation Device 1eb8 (rev a1)
    Subsystem: NVIDIA Corporation Device 12a2
    Kernel modules: nvidiafb
$ sudo docker version
Client: Docker Engine - Community
 Version:           19.03.2
 API version:       1.40
 Go version:        go1.12.8
 Git commit:        6a30dfc
 Built:             Thu Aug 29 05:29:11 2019
 OS/Arch:           linux/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          19.03.2
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.12.8
  Git commit:       6a30dfc
  Built:            Thu Aug 29 05:27:45 2019
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.2.6
  GitCommit:        894b81a4b802e4eb2a91d1ce216b8817763c29fb
 runc:
  Version:          1.0.0-rc8
  GitCommit:        425e105d5a03fabd737a126ad93d62a9eeede87f
 docker-init:
  Version:          0.18.0
  GitCommit:        fec3683

もくじ

Docker をインストールする

まずは Docker をインストールする。

必要なパッケージをインストールしておく。

$ sudo apt -y install \
    apt-transport-https \
    ca-certificates \
    curl \
    gnupg-agent \
    software-properties-common

Docker のリポジトリを APT に登録する。

$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
$ sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
$ sudo apt update

そして、Docker のコンポーネントをインストールする。

$ sudo apt -y install docker-ce docker-ce-cli containerd.io
$ sudo docker version
Client: Docker Engine - Community
 Version:           19.03.2
 API version:       1.40
 Go version:        go1.12.8
 Git commit:        6a30dfc
 Built:             Thu Aug 29 05:29:11 2019
 OS/Arch:           linux/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          19.03.2
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.12.8
  Git commit:       6a30dfc
  Built:            Thu Aug 29 05:27:45 2019
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.2.6
  GitCommit:        894b81a4b802e4eb2a91d1ce216b8817763c29fb
 runc:
  Version:          1.0.0-rc8
  GitCommit:        425e105d5a03fabd737a126ad93d62a9eeede87f
 docker-init:
  Version:          0.18.0
  GitCommit:        fec3683

NVIDIA Graphics Driver をインストールする

続いて Docker ホストに NVIDIA の Graphics Driver をインストールする。

ドライバのリポジトリを APT に登録する。

$ sudo add-apt-repository ppa:graphics-drivers/ppa
$ sudo apt update

推奨されているドライバをインストールする。

$ sudo apt -y install ubuntu-drivers-common
$ sudo ubuntu-drivers autoinstall

NVIDIA Container Toolkit をインストールする

次に Docker ホストに NVIDIA Container Toolkit をインストールする。 この中に Docker で NVIDIA の GPU を使うのに必要なランタイムなどが含まれている。

まずはリポジトリを APT に登録する。

$ curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add -
$ curl -s -L https://nvidia.github.io/nvidia-docker/$(. /etc/os-release;echo $ID$VERSION_ID)/nvidia-docker.list | sudo tee /etc/apt/sources.list.d/nvidia-docker.list
$ sudo apt update

その上でツールキットをインストールする。

$ sudo apt -y install nvidia-container-toolkit

ここまで終わったら、一旦マシンを再起動しておく。

$ sudo shutdown -r now

インストール後の確認作業

まずは nvidia-container-cli info コマンドを使って、ちゃんと GPU が認識できていることを確認しよう。 また、同時に使用できるドライバと CUDA のバージョンが確認できる。

$ nvidia-container-cli info
NVRM version:   435.21
CUDA version:   10.1

Device Index:   0
Device Minor:   0
Model:          Tesla T4
Brand:          Tesla
GPU UUID:       GPU-bcf7de51-87c5-4b98-f8c1-b1a9072696ca
Bus Location:   00000000:00:04.0
Architecture:   7.5

ちなみに nvidia-container-cli list を使うと Docker コンテナ側に注入されるファイル一覧が確認できる。 これらのファイルがコンテナからアクセスできるようになることで GPU が使えるようになる。

$ nvidia-container-cli list
/dev/nvidiactl
/dev/nvidia-uvm
/dev/nvidia-uvm-tools
/dev/nvidia-modeset
/dev/nvidia0
/usr/bin/nvidia-smi
/usr/bin/nvidia-debugdump
/usr/bin/nvidia-persistenced
/usr/bin/nvidia-cuda-mps-control
/usr/bin/nvidia-cuda-mps-server
/usr/lib/x86_64-linux-gnu/libnvidia-ml.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-cfg.so.435.21
/usr/lib/x86_64-linux-gnu/libcuda.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-opencl.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-ptxjitcompiler.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-fatbinaryloader.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-compiler.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-encode.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-opticalflow.so.435.21
/usr/lib/x86_64-linux-gnu/libnvcuvid.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-eglcore.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-glcore.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-tls.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-glsi.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-fbc.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-ifr.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-rtcore.so.435.21
/usr/lib/x86_64-linux-gnu/libnvoptix.so.435.21
/usr/lib/x86_64-linux-gnu/libGLX_nvidia.so.435.21
/usr/lib/x86_64-linux-gnu/libEGL_nvidia.so.435.21
/usr/lib/x86_64-linux-gnu/libGLESv2_nvidia.so.435.21
/usr/lib/x86_64-linux-gnu/libGLESv1_CM_nvidia.so.435.21
/usr/lib/x86_64-linux-gnu/libnvidia-glvkspirv.so.435.21
/run/nvidia-persistenced/socket

Docker コンテナから GPU を使ってみる

それでは、実際に Docker コンテナから GPU を使ってみることにしよう。

まずは、特に何もケアせずに Ubuntu のコンテナを起動して nvidia-smi コマンドを実行してみる。

$ sudo docker run \
    --rm \
    -it ubuntu \
    nvidia-smi
docker: Error response from daemon: OCI runtime create failed: container_linux.go:345: starting container process caused "exec: \"nvidia-smi\": executable file not found in $PATH": unknown.

プレーンな Ubuntu のコンテナには、NVIDIA の GPU を扱うためのファイル群が含まれていない。 そのため、この実行は上記の通り失敗に終わる。

続いては --gpus オプションを指定して同じコマンドを実行してみよう。 すると、次のようにちゃんとコマンドの実行が成功する。

$ sudo docker run \
    --rm \
    --gpus all \
    -it ubuntu \
    nvidia-smi
Sun Sep 22 05:31:27 2019       
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 435.21       Driver Version: 435.21       CUDA Version: N/A      |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|===============================+======================+======================|
|   0  Tesla T4            Off  | 00000000:00:04.0 Off |                    0 |
| N/A   44C    P0    26W /  70W |      0MiB / 15109MiB |      0%      Default |
+-------------------------------+----------------------+----------------------+
                                                                               
+-----------------------------------------------------------------------------+
| Processes:                                                       GPU Memory |
|  GPU       PID   Type   Process name                             Usage      |
|=============================================================================|
|  No running processes found                                                 |
+-----------------------------------------------------------------------------+

同じように CentOS のイメージでも動作するか確認してみよう。 こちらも上手くいく。

$ sudo docker run \
    --rm \
    --gpus all \
    -it centos \
    nvidia-smi
Sun Sep 22 06:10:59 2019       
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 435.21       Driver Version: 435.21       CUDA Version: N/A      |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|===============================+======================+======================|
|   0  Tesla T4            Off  | 00000000:00:04.0 Off |                    0 |
| N/A   71C    P0    32W /  70W |      0MiB / 15109MiB |      0%      Default |
+-------------------------------+----------------------+----------------------+
                                                                               
+-----------------------------------------------------------------------------+
| Processes:                                                       GPU Memory |
|  GPU       PID   Type   Process name                             Usage      |
|=============================================================================|
|  No running processes found                                                 |
+-----------------------------------------------------------------------------+

上記から、もはや NVIDIA が提供している公式イメージも必須ではなくなったことがわかる。

上記は、前述した通り必要なファイルがコンテナからアクセスできるようになることで実現されている。 まず、何もオプションを付けずに実行したときの /usr/lib/x86_64-linux-gnu/ ディレクトリを確認してみよう。

$ sudo docker run \
    --rm \
    -it ubuntu \
    ls /usr/lib/x86_64-linux-gnu/
audit              libgnutls.so.30   libpanelw.so.5
coreutils          libgnutls.so.30.14.10  libpanelw.so.5.9
gconv              libhogweed.so.4   libpcreposix.so.3
libapt-pkg.so.5.0     libhogweed.so.4.4     libpcreposix.so.3.13.3
libapt-pkg.so.5.0.2      libidn2.so.0          libsemanage.so.1
libapt-private.so.0.0     libidn2.so.0.3.3     libstdc++.so.6
libapt-private.so.0.0.0    liblz4.so.1         libstdc++.so.6.0.25
libdb-5.3.so          liblz4.so.1.7.1      libtasn1.so.6
libdebconfclient.so.0     libmenu.so.5          libtasn1.so.6.5.5
libdebconfclient.so.0.0.0  libmenu.so.5.9      libtic.so.5
libffi.so.6           libmenuw.so.5     libtic.so.5.9
libffi.so.6.0.4          libmenuw.so.5.9   libunistring.so.2
libform.so.5          libnettle.so.6    libunistring.so.2.1.0
libform.so.5.9        libnettle.so.6.4      libzstd.so.1
libformw.so.5         libp11-kit.so.0   libzstd.so.1.3.3
libformw.so.5.9           libp11-kit.so.0.3.0      perl
libgmp.so.10          libpanel.so.5     perl-base
libgmp.so.10.3.2     libpanel.so.5.9

特に GPU 関連のファイルが含まれている形跡はない。

続いて --gpus オプションをつけて同じコマンドを実行してみよう。

$ sudo docker run \
    --rm \
    --gpus all \
    -it ubuntu \
    ls /usr/lib/x86_64-linux-gnu/
audit              libgnutls.so.30.14.10    libpanel.so.5
coreutils          libhogweed.so.4     libpanel.so.5.9
gconv              libhogweed.so.4.4       libpanelw.so.5
libapt-pkg.so.5.0     libidn2.so.0            libpanelw.so.5.9
libapt-pkg.so.5.0.2      libidn2.so.0.3.3       libpcreposix.so.3
libapt-private.so.0.0     liblz4.so.1         libpcreposix.so.3.13.3
libapt-private.so.0.0.0    liblz4.so.1.7.1      libsemanage.so.1
libdb-5.3.so          libmenu.so.5            libstdc++.so.6
libdebconfclient.so.0     libmenu.so.5.9      libstdc++.so.6.0.25
libdebconfclient.so.0.0.0  libmenuw.so.5     libtasn1.so.6
libffi.so.6           libmenuw.so.5.9     libtasn1.so.6.5.5
libffi.so.6.0.4          libnettle.so.6      libtic.so.5
libform.so.5          libnettle.so.6.4        libtic.so.5.9
libform.so.5.9        libnvidia-cfg.so.1      libunistring.so.2
libformw.so.5         libnvidia-cfg.so.435.21  libunistring.so.2.1.0
libformw.so.5.9           libnvidia-ml.so.1       libzstd.so.1
libgmp.so.10          libnvidia-ml.so.435.21   libzstd.so.1.3.3
libgmp.so.10.3.2     libp11-kit.so.0     perl
libgnutls.so.30           libp11-kit.so.0.3.0        perl-base

すると、libnvidia-* といった GPU 関連の共有ライブラリが確認できるようになる。

Docker Compose の対応について

残念なお知らせだけど、まだ現時点 (2019/09/22) では Docker Compose が上記の --gpus オプションに対応できていない。

$ sudo apt -y install python3-pip
$ sudo pip3 install docker-compose
$ docker-compose version
docker-compose version 1.24.1, build 4667896
docker-py version: 3.7.3
CPython version: 3.6.8
OpenSSL version: OpenSSL 1.1.1  11 Sep 2018

Docker Compose と一緒に万全の体制で使うには、もう少し時間を必要としそうだ。

Support for NVIDIA GPUs under Docker Compose · Issue #6691 · docker/compose · GitHub

補足

今回使った GPU マシン環境は次のようにして用意した。

$ gcloud compute instances create gcp-gpu-t4-x1 \
  --preemptible \
  --zone us-central1-a \
  --machine-type n1-standard-2 \
  --accelerator type=nvidia-tesla-t4,count=1 \
  --maintenance-policy TERMINATE \
  --restart-on-failure \
  --image-project ubuntu-os-cloud \
  --image-family ubuntu-1804-lts

いじょう。

リモートサーバ上の Docker コンテナで JupyterLab を使う

今回のエントリは、以下のエントリの改訂版となる。 起動するアプリケーションを Jupyter Notebook から JupyterLab にすると共に、いくつか変更を加えた。

blog.amedama.jp

JupyterLab は従来の Jupyter Notebook を置き換えることを目的とした後継プロジェクト。 基本的な部分は Notebook を受け継ぎつつも、より扱いやすいインターフェースを提供している。

今回使う環境は次の通り。

$ cat /etc/lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.3 LTS"
$ uname -r
4.15.0-62-generic

Docker をインストールする

まずはリポジトリを更新すると共に、過去にインストールした Docker 関連コンポーネントがあれば削除しておく。

$ sudo apt update
$ sudo apt -y remove docker docker-engine docker.io containerd runc

Docker のインストール用リポジトリを APT に登録する。

$ sudo apt -y install \
    apt-transport-https \
    ca-certificates \
    curl \
    gnupg-agent \
    software-properties-common \
    python3-pip
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
$ sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
$ sudo apt update

Docker の主要コンポーネントをインストールする。

$ sudo apt -y install docker-ce docker-ce-cli containerd.io
$ sudo docker version
Client: Docker Engine - Community
 Version:           19.03.2
 API version:       1.40
 Go version:        go1.12.8
 Git commit:        6a30dfc
 Built:             Thu Aug 29 05:29:11 2019
 OS/Arch:           linux/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          19.03.2
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.12.8
  Git commit:       6a30dfc
  Built:            Thu Aug 29 05:27:45 2019
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.2.6
  GitCommit:        894b81a4b802e4eb2a91d1ce216b8817763c29fb
 runc:
  Version:          1.0.0-rc8
  GitCommit:        425e105d5a03fabd737a126ad93d62a9eeede87f
 docker-init:
  Version:          0.18.0
  GitCommit:        fec3683

ついでに Docker Compose もインストールしておく。

$ sudo pip3 install docker-compose
$ docker-compose version
docker-compose version 1.24.1, build 4667896
docker-py version: 3.7.3
CPython version: 3.6.8
OpenSSL version: OpenSSL 1.1.1  11 Sep 2018

JupyterLab を起動するイメージを用意する

今回はデーモンを Supervisord 経由で起動することにした。 JupyterLab だけを起動するなら別に直接起動してもいいんだけど、拡張性などを考えて。 %% で囲まれている部分は後で環境変数経由で置き換える。

$ cat << 'EOF' > supervisord.conf
[supervisord]
nodaemon=true

[program:jupyterlab]
command=%%CONDA_HOME%%/envs/%%CONDA_VENV_NAME%%/bin/jupyter-lab
user=%%USERNAME%%
stdout_logfile=/var/log/supervisor/jupyter-lab.log
redirect_stderr=true
autostart=True
autorestart=True
EOF

続いて Supervisord を起動するエントリーポイントとなるシェルスクリプト。 この中では主に設定ファイルの準備をしている。 JupyterLab の Web UI にトークンなしでアクセスできるようにするなど。 JupyterLab の Web UI には SSH Port Forwarding 経由でアクセスするため、アクセス制御については SSH の部分で担保する。

$ cat << 'EOF' > docker-entrypoint.sh
#!/usr/bin/env bash

set -Ceux

: "Set home directory" && {
  export HOME=/home/${USERNAME}
}

: "Replace template variables" && {
  sed -i.back \
    -e "s:%%USERNAME%%:${USERNAME}:g" \
    -e "s:%%CONDA_HOME%%:${CONDA_HOME}:g" \
    -e "s:%%CONDA_VENV_NAME%%:${CONDA_VENV_NAME}:g" \
  /etc/supervisord.conf
}

: "Jupyter configuration" && {
  if [ ! -e /home/${USERNAME}/.jupyter ]; then
    sudo -iu ${USERNAME} ${CONDA_HOME}/envs/${CONDA_VENV_NAME}/bin/jupyter notebook --generate-config
    sudo -iu ${USERNAME} sed -i.back \
      -e "s:^#c.NotebookApp.token = .*$:c.NotebookApp.token = u'':" \
      -e "s:^#c.NotebookApp.ip = .*$:c.NotebookApp.ip = '*':" \
      -e "s:^#c.NotebookApp.open_browser = .*$:c.NotebookApp.open_browser = False:" \
      -e "s:^#c.NotebookApp.notebook_dir = .*$:c.NotebookApp.notebook_dir = \"${JUPYTER_HOME}\":" \
      /home/${USERNAME}/.jupyter/jupyter_notebook_config.py
  fi
}

: "Start supervisord" && {
  supervisord -c /etc/supervisord.conf
}
EOF

インストールしたい Python のパッケージの一覧を書いておくファイル。

$ cat << 'EOF' > requirements.txt
jupyterlab
EOF

続いては、肝心の Dockerfile となる。 Python の実行環境については Miniconda でやってしまうことにした。 インストール先や仮想環境は CONDA_HOMECONDA_VENV_NAME で変更できる。

$ cat << 'EOF' > Dockerfile
FROM ubuntu:18.04

ENV CONDA_HOME=/opt/conda \
    CONDA_VENV_NAME=venv \
    JUPYTER_HOME=/mnt/jupyter

# Use mirror repository
RUN sed -i.bak -e "s%http://[^ ]\+%mirror://mirrors.ubuntu.com/mirrors.txt%g" /etc/apt/sources.list

# Install prerequisite packages
RUN apt update \
 && apt -yq dist-upgrade \
 && apt install -yq --no-install-recommends \
      sudo \
      supervisor \
      ca-certificates \
      wget

# Install miniconda
RUN wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O /tmp/miniconda3.sh \
 && /bin/bash /tmp/miniconda3.sh -b -p ${CONDA_HOME} \
 && rm /tmp/miniconda3.sh \
 && ${CONDA_HOME}/bin/conda clean -tipsy \
 && ln -s ${CONDA_HOME}/etc/profile.d/conda.sh /etc/profile.d/

# Create virtual environment
COPY requirements.txt /tmp
RUN . /etc/profile.d/conda.sh \
 && conda create -y -n ${CONDA_VENV_NAME} python=3.7 \
 && conda activate ${CONDA_VENV_NAME} \
 && pip install -r /tmp/requirements.txt

# Expose container ports
EXPOSE 8888

# Boot process
COPY supervisord.conf /etc
COPY docker-entrypoint.sh /var/tmp
CMD bash /var/tmp/docker-entrypoint.sh
EOF

起動するときのオプションがなるべく増えないように Docker Compose の設定ファイルも作っておく。 以下では docker-compose.yml のあるディレクトリを JupyterLab の作業ディレクトリとして /mnt/jupyter にマウントしている。 必要に応じて場所は書き換えると良いと思う。

$ cat << 'EOF' > docker-compose.yml
version: "3"
services:
  jupyter-lab:
    build:
      context: .
    environment:
      - USERNAME
      - JUPYTER_HOME=/mnt/jupyter
    image: example/jupyterlab-container
    volumes:
      - /etc/passwd:/etc/passwd:ro
      - /etc/group:/etc/group:ro
      - /home:/home
      - ./:/mnt/jupyter
    ports:
      - "127.0.0.1:8888:8888"
EOF

また、パーミッション問題を避けるために上記では /etc/passwd/etc/group をコンテナにマウントしてしまっている。 このテクニックについては以下を参照のこと。

blog.amedama.jp

準備ができたらイメージをビルドする。

$ sudo docker-compose build

コンテナを起動する

コンテナを起動する前に、念のため SSH に使うポート以外をファイアウォールで閉じておく。 コンテナが bind するアドレスはループバックアドレスなのでインターネットには晒されないけど念のため。

$ sudo ufw allow 22
$ sudo ufw default DENY
$ yes | sudo ufw enable
$ sudo ufw status
Status: active

To                         Action      From
--                         ------      ----
22                         ALLOW       Anywhere                  
22 (v6)                    ALLOW       Anywhere (v6)             

JupyterLab のコンテナを起動する。

$ sudo docker-compose run \
    --name jupyter-lab-container \
    --rm \
    -e USERNAME=$(whoami) \
    --service-ports \
    jupyter-lab

起動ログにエラーがなく、次のように 127.0.0.1:8888 で Listen していれば上手くいっている。

$ ss -tlnp | grep 8888
LISTEN   0         128               127.0.0.1:8888             0.0.0.0:*       

JupyterLab の Web UI にアクセスする

これでリモートサーバ上では JupyterLab が動いたけどループバックアドレスなので疎通がない。 そこで SSH Port Forwarding を使ってリモートサーバ上のポートをローカルに引き出してくる。

今回は Vagrant を使って環境を作っているので次のようにする。

$ vagrant ssh-config > ssh.config
$ ssh -F ssh.config -L 8888:localhost:8888 default

ブラウザで localhost:8888 にアクセスする。

$ open http://localhost:8888

以下のような Web UI が見えればおっけー。 Jupyter Notebook よりも、ちょっと IDE っぽい見た目。

f:id:momijiame:20190920031044p:plain

いじょう。

Docker コンテナ内で Docker ホストと同じユーザを使う

Docker コンテナで Docker ホストのボリュームをマウントすると、パーミッションの問題が生じることがある。 これは、ホストで使っているユーザとコンテナで使っているユーザで UID と GID の不一致が起こるため。

今回は、それらの問題を解決する方法の一つとして Docker コンテナとホストで同じユーザを使う方法を試してみる。 具体的には、Docker ホストの /etc/passwd/etc/group をコンテナからマウントしてしまう。 そのため、このやり方は Docker for Mac や Docker for Windows では使えない。

典型的なユースケースは、パッケージやライブラリといった実行環境はコンテナ内に隠蔽しつつ、その他はホストの環境をなるべく使い回したいというものだろう。

使った環境は次の通り。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.3 LTS"
$ uname -r
4.15.0-62-generic

コンテナとホストでユーザが異なる場合の問題点について

まずはコンテナとホストでユーザの UID と GID が異なる場合の問題点について見ておく。

例えば Docker ホストでは次のユーザを使っているとする。

$ id
uid=1000(vagrant) gid=1000(vagrant) groups=1000(vagrant)

試しに、カレントディレクトリにファイルを作っておこう。

$ echo "Hello, World" > greet.txt
$ ls -lF
total 4
-rw-rw-r-- 1 vagrant vagrant 13 Sep 19 09:10 greet.txt

続いて Docker コンテナを起動する。 カレントワーキングディレクトリを /mnt/working にマウントしておく。

$ sudo docker run \
    --rm \
    -v $(pwd):/mnt/working \
    -it busybox

この状態ではコンテナに root ユーザでログインしている。

# whoami
root
# id
uid=0(root) gid=0(root) groups=10(wheel)

マウントしたディレクトリに移動してファイルを作ってみる。

# cd /mnt/working/
# echo "Hello, World" > greet-docker-root.txt

ディレクトリにあるファイルを確認すると、次のようになっている。 自分で作ったファイルは root:root の権限がついている。 先ほど Docker ホストから作ったファイルは 1000:1000 という UID と GID がそのまま表示されている。 これは Docker コンテナ側で、それらの ID を持ったユーザが存在しないために起きている。

# ls -lF
total 8
-rw-r--r--    1 root     root            13 Sep 19 09:14 greet-docker-root.txt
-rw-rw-r--    1 1000     1000            13 Sep 19 09:10 greet.txt

Docker ホスト側でも同様に ls してみた結果は次の通り。 こちらでも Docker コンテナで作ったファイルは root:root となっている。 ホストで作ったファイルは現在のユーザの権限がついている。

$ ls -lF
total 8
-rw-r--r-- 1 root    root    13 Sep 19 09:14 greet-docker-root.txt
-rw-rw-r-- 1 vagrant vagrant 13 Sep 19 09:10 greet.txt

念のため、root 以外でも異なる UID と GID を持ったユーザを作って確認しておこう。 次の Dockerfile では example ユーザが 10000:10000 という UID と GID を持つようにしてある。

$ cat << 'EOF' > Dockerfile
FROM ubuntu:18.04

# Non administrative username
ENV USERNAME=example

# Add non administrative user
RUN useradd -m ${USERNAME}

# Add example group
RUN groupadd -g 10000 example-users

# Change user id and group id
RUN usermod -u 10000 -g 10000 ${USERNAME}

# Change user
USER ${USERNAME}

# Boot initial process
CMD bash

EOF

イメージのビルドとコンテナの起動のために、次のように Docker Compose 用の設定ファイルも用意しておく。

$ cat << 'EOF' > docker-compose.yml
version: "3"
services:
  example:
    build:
      context: .
    image: example/permission
    volumes:
      - ./:/mnt/working
EOF

まずはイメージをビルドする。

$ sudo docker-compose build

続いてコンテナを起動する。

$ sudo docker-compose run --rm example

起動したコンテナでは、次の通り 10000:10000 の UID と GID を持ったユーザでログインしている。

$ whoami
example
$ id
uid=10000(example) gid=10000(example-users) groups=10000(example-users)

マウントしたディレクトリに移動してファイルを作ってみよう。 すると、ディレクトリに権限がないため書き込むことができない。

$ cd /mnt/working/
$ echo "Hello, World" > greet-docker.txt
bash: greet-docker.txt: Permission denied

毎回 sudo するという手もなくはないけど、めんどくさい。

コンテナで /etc/passwd/etc/group をマウントしてみる

先ほどの問題を解決すべく、コンテナから /etc/passwd/etc/group をマウントしてみる。 コンテナ側からは書き換えできないように、念のため ro (Read Only) フラグをつけておく。 /home についてはお好みで。

$ cat << 'EOF' > docker-compose.yml
version: "3"
services:
  example:
    image: ubuntu:18.04
    volumes:
      - /etc/passwd:/etc/passwd:ro
      - /etc/group:/etc/group:ro
      - /home:/home
      - ./:/mnt/working
EOF

起動時に -u オプションで実行時のユーザの UID と GID を指定できる。 次のようにすれば現在のユーザをそのまま引き継げる。

$ sudo docker-compose run \
    -u "$(id -u $USER):$(id -g $USER)" \
    --rm \
    example

コンテナ内でも、ホスト側と同じユーザとして見えることがわかる。

$ whoami
vagrant
$ id
uid=1000(vagrant) gid=1000(vagrant) groups=1000(vagrant)

これは、ユーザとグループに関する情報がコンテナとホストで共有されているため。

$ grep vagrant /etc/passwd 
vagrant:x:1000:1000:,,,:/home/vagrant:/bin/bash

先ほどと同じようにマウントされているディレクトリに移動してファイルを作ってみよう。 コンテナとホストで UID と GID が一致しているため、ちゃんと書き込むことができている。

$ cd /mnt/working/
$ echo "Hello, World" > greet-docker.txt
$ ls -lF 
total 20
-rw-rw-r-- 1 vagrant vagrant 324 Sep 19 09:18 Dockerfile
-rw-rw-r-- 1 vagrant vagrant 183 Sep 19 11:08 docker-compose.yml
-rw-r--r-- 1 root    root     13 Sep 19 09:14 greet-docker-root.txt
-rw-rw-r-- 1 vagrant vagrant  13 Sep 19 11:11 greet-docker.txt
-rw-rw-r-- 1 vagrant vagrant  13 Sep 19 09:10 greet.txt

今回のやり方では、コンテナのプラットフォーム間でのポータビリティは低下するというデメリットがある。 もし、プラットフォーム間のポータビリティも落としたくない場合は、ENTRYPOINT のスクリプトでユーザを編集する必要があるだろう。 とはいえ、そこを割り切って手っ取り早く解決する方法の一つとしては使い所を選べばありだと感じた。

参考

qiita.com

リモートサーバ上の Docker コンテナに X11 Forwarding する

リモートにあるサーバで動かしている Docker コンテナ上の X アプリケーションの GUI をローカルのマシンから確認したいと思った。 そこで、Docker コンテナとローカルマシンの間で X11 Forwarding してみることにした。 やってみると意外と手間取ったので記録として残しておく。

まず、リモートの Docker ホスト (Docker コンテナを動かすマシン) の環境は次の通り。

$ cat /etc/lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.3 LTS"
$ uname -r
4.15.0-62-generic

そして、ローカルのマシン環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G95

ローカルの環境には、あらかじめ XQuartz (macOS 向けの X Window System 実装) をインストールしておく。

$ brew cask install xquartz

今回進める作業の手順としては、次の通り。

  1. リモートのマシン上で SSH でログインできる Docker コンテナを動かす
  2. ローカルのマシンから Docker コンテナに X11 Forwarding を有効にして SSH でログインする
  3. Docker コンテナ上で X アプリケーションを実行する
  4. 実行結果をローカルマシン上で確認する

リモートのマシンに Docker をインストールする

まずは最初の手順としてリモートのマシンに Docker をインストールする。

APT のリポジトリを更新した上で、もし既にインストールされている Docker コンポーネントがあればアンインストールしておく。

$ sudo apt update
$ sudo apt -y remove docker docker-engine docker.io containerd runc

続いて、Docker 関連のコンポーネントをインストールするのに必要なパッケージをインストールする。

$ sudo apt -y install \
    apt-transport-https \
    ca-certificates \
    curl \
    gnupg-agent \
    software-properties-common \
    python3-pip

APT に Docker のリポジトリを登録する。

$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
$ sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"

Docker 本体をインストールする。

$ sudo apt update
$ sudo apt -y install docker-ce docker-ce-cli containerd.io

次のように docker version コマンドでクライアントとサーバの両方が正しく出力されれば上手くいっている。

$ sudo docker version
Client: Docker Engine - Community
 Version:           19.03.2
 API version:       1.40
 Go version:        go1.12.8
 Git commit:        6a30dfc
 Built:             Thu Aug 29 05:29:11 2019
 OS/Arch:           linux/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          19.03.2
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.12.8
  Git commit:       6a30dfc
  Built:            Thu Aug 29 05:27:45 2019
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.2.6
  GitCommit:        894b81a4b802e4eb2a91d1ce216b8817763c29fb
 runc:
  Version:          1.0.0-rc8
  GitCommit:        425e105d5a03fabd737a126ad93d62a9eeede87f
 docker-init:
  Version:          0.18.0
  GitCommit:        fec3683

続いて Docker Compose をインストールする。

$ sudo pip3 install docker-compose

こちらも、次のようにバージョン情報が出れば大丈夫。

$ docker-compose version
docker-compose version 1.24.1, build 4667896
docker-py version: 3.7.3
CPython version: 3.6.8
OpenSSL version: OpenSSL 1.1.1  11 Sep 2018

X11 Forwarding が有効な SSH ログインできる Docker イメージをビルドする

Docker がインストールできたら、次はコンテナのイメージを作る。

汎用性をもたせるためにプロセスは Supervisord 経由で起動するため以下の通り設定ファイルを用意する。

$ cat << 'EOF' > supervisord.conf
[supervisord]
nodaemon=true

[program:sshd]
command=/usr/sbin/sshd -D -ddd
user=root
stdout_logfile=/var/log/supervisor/sshd.log
redirect_stderr=true
EOF

コンテナが起動するプロセスとなるシェルスクリプトを用意する。 このスクリプトの中では SSH でログインするときのユーザのパスワード設定と Supervisord の起動を行っている。

$ cat << 'EOF' > docker-entrypoint.sh
#!/usr/bin/env bash

set -Ceux

: "Change user password" && {
  # check variable existence
  if [ ! -v PASSWORD_HASH ]; then
    echo "please define 'PASSWORD_HASH' environment variable"
    exit 1
  fi
  usermod -p "${PASSWORD_HASH}" ${USERNAME}
}

: "Start supervisord" && {
  supervisord -c /etc/supervisord.conf
}
EOF

続いて肝心の Docker イメージをビルドするための Dockerfile を用意する。 このイメージでは必要な設定を sshd に施すと共に、必要な関連パッケージをインストールしている。 ベースイメージは Docker ホストと同じ Ubuntu 18.04 LTS にした。

$ cat << 'EOF' > Dockerfile
FROM ubuntu:18.04

# Non administrative username (SSH login user)
ENV USERNAME=example

# Use mirror repository
RUN sed -i.bak -e "s%http://[^ ]\+%mirror://mirrors.ubuntu.com/mirrors.txt%g" /etc/apt/sources.list

# Install prerequisite packages
RUN apt update \
 && apt -yq dist-upgrade \
 && apt install -yq --no-install-recommends \
      sudo \
      xserver-xorg \
      openssh-server \
      supervisor \
      xauth \
      x11-apps

# Prepare OpenSSH server
RUN mkdir /var/run/sshd \
 && sed -i -e "s/^#AddressFamily.*$/AddressFamily inet/" /etc/ssh/sshd_config

# Setup non administrative user
RUN useradd -m ${USERNAME} \
 && usermod -aG sudo ${USERNAME}

# Expose container ports
EXPOSE 22

# Copy settings
COPY supervisord.conf /etc
COPY docker-entrypoint.sh /var/tmp

# Boot process
CMD bash /var/tmp/docker-entrypoint.sh
EOF

イメージとコンテナを管理するために Docker Compose 用の設定ファイルも用意しておこう。 この設定ではコンテナに SSH でログインする用の TCP/22 ポートを、Docker ホストの 127.0.0.1:2222 にマッピングすることになる。 意図的にループバックアドレスに bind するのは、下手にインターネットから到達できないようにするため。

$ cat << 'EOF' > docker-compose.yml
version: "3"
services:
  server:
    build:
      context: .
    environment:
      - PASSWORD_HASH
    image: example/sshd-container
    ports:
      - "127.0.0.1:2222:22"
EOF

Docker Compose 経由でイメージをビルドする。

$ sudo docker-compose build

上手くいけば次のようにイメージが登録される。

$ sudo docker images
REPOSITORY               TAG                 IMAGE ID            CREATED             SIZE
example/sshd-container   latest              0c9b2af9011d        19 minutes ago      318MB
<none>                   <none>              df1f957680d7        20 minutes ago      318MB
ubuntu                   18.04               a2a15febcdf3        4 weeks ago         64.2MB

コンテナを起動する

先ほど docker-entrypoint.sh の中でチェックしていた通り、コンテナの起動には SSH ログイン用のパスワードハッシュが必要になる。 例えば次のように Python などを使って生成しておく。

$ python3 -c "import crypt; print(crypt.crypt('mypasswd', crypt.METHOD_SHA512))"
$6$ADfMjPqRhaGQ4r80$Pc4n29qZmdRFMJBGK1FghsmWWQZOqgjom8ia2SA5r9HeCnhASKUoToq30Q5eCh3272/UvvaVfGa0IIpGyOwbK1

パスワードをターミナルの履歴に残したくないときは次のように getpass.getpass() を使うと良いかも。 あるいは、もっとちゃんとするならパスワード認証ではなく公開鍵を仕込むようにした方が良いと思う。

$ python3 -c "import crypt; from getpass import getpass; print(crypt.crypt(getpass(), crypt.METHOD_SHA512))"

生成したパスワードのハッシュを環境変数ごしに渡しつつコンテナを起動する。

$ export PASSWORD_HASH=$(python3 -c "import crypt; print(crypt.crypt('mypasswd', crypt.METHOD_SHA512))")
$ sudo docker-compose run \
    --name sshd-container \
    --rm \
    -e PASSWORD_HASH=${PASSWORD_HASH} \
    --service-ports \
    server

これでコンテナに SSH でログインするためのポートがリモートマシンの 127.0.0.1:2222 にマッピングされる。

$ ss -tlnp | grep 2222
LISTEN   0         128               127.0.0.1:2222             0.0.0.0:*

X11 Forwarding が有効な状態で SSH ログインする

ここまでの工程でコンテナにアクセスするためのポートは開いた。 ただし、bind されているアドレスが Docker ホストのループバックアドレスになっている。 セキュリティを考えてのことだけど、このままだとローカルのマシンからも疎通がない。 この点は SSH の Port Forwarding で解決する。

リモートの 2222 ポートを、ローカルの 22222 ポートに Port Forwarding で引き出してくる。 例えば今回は Vagrant を使ってリモートサーバを構築してあるので、次のようにする。 新しいターミナルを開いて Port Forwarding しよう。

$ vagrant ssh-config > ssh.config
$ ssh -F ssh.config \
      -L 22222:localhost:2222 \
      default

これでリモートの 2222 ポートが、ローカルの 22222 越しにアクセスできるようになった。

$ lsof -i -P | grep -i listen | grep 22222 
ssh       35299 amedama    5u  IPv6 0x9d2c7f5e707aebd5      0t0  TCP localhost:22222 (LISTEN)
ssh       35299 amedama    6u  IPv4 0x9d2c7f5e74def915      0t0  TCP localhost:22222 (LISTEN)

あとはローカルの TCP/22222 に向けて X11 Forwarding を有効にしつつ SSH でログインする。 OpenSSH の実装であれば -X をつければ X11 Forwarding が有効になる。

$ ssh -XC \
      -o StrictHostKeyChecking=no \
      -o UserKnownHostsFile=/dev/null \
      -p 22222 \
      example@localhost

ログインすると、ちゃんと DISPLAY 環境変数の内容もセットされている。

$ echo $DISPLAY
localhost:10.0

例えばサンプルとして xeyes を起動してみよう。

$ xeyes

見慣れた目玉が見えるようになるはず。

f:id:momijiame:20190912235905p:plain

もし上手くいかないときは、次のようにして Supervisord が起動した sshd のログを確認すると良い。

$ sudo docker exec -it sshd-container tail -f /var/log/supervisor/sshd.log

いじょう。

参考

blog.n-z.jp

Ubuntu 18.04 LTS の APT で高速なミラーリポジトリを使う

Vagrant や Docker のイメージの中には APT のリポジトリが決め打ちになっているものがある。 高速なミラーリポジトリが使えないとアップデートが遅くて作業に難儀することになる。 今回は、そんなときの解決方法について。

使った環境は次の通り。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.3 LTS"
$ uname -r
4.15.0-60-generic

ミラーリポジトリを使えるようにする

例えば、初期状態だと次のようになっている場合がある。

$ cat /etc/apt/sources.list | sed -e "/^#/d" -e "/^$/d"
deb http://archive.ubuntu.com/ubuntu bionic main restricted
deb http://archive.ubuntu.com/ubuntu bionic-updates main restricted
deb http://archive.ubuntu.com/ubuntu bionic universe
deb http://archive.ubuntu.com/ubuntu bionic-updates universe
deb http://archive.ubuntu.com/ubuntu bionic multiverse
deb http://archive.ubuntu.com/ubuntu bionic-updates multiverse
deb http://archive.ubuntu.com/ubuntu bionic-backports main restricted universe multiverse
deb http://security.ubuntu.com/ubuntu bionic-security main restricted
deb http://security.ubuntu.com/ubuntu bionic-security universe
deb http://security.ubuntu.com/ubuntu bionic-security multiverse

現行の APT はリポジトリの URL を mirror://mirrors.ubuntu.com/mirrors.txt に指定することでミラーが使えるようになるらしい。 試しに上記を sed コマンドで置換してみよう。

$ sudo sed -i.bak -e 's%http://[^ ]\+%mirror://mirrors.ubuntu.com/mirrors.txt%g' /etc/apt/sources.list

実行結果はこんな感じ。 なお、元のファイルも .bak という名前で残されているので失敗しても元に戻せる。

$ cat /etc/apt/sources.list | sed -e "/^#/d" -e "/^$/d"
deb mirror://mirrors.ubuntu.com/mirrors.txt bionic main restricted
deb mirror://mirrors.ubuntu.com/mirrors.txt bionic-updates main restricted
deb mirror://mirrors.ubuntu.com/mirrors.txt bionic universe
deb mirror://mirrors.ubuntu.com/mirrors.txt bionic-updates universe
deb mirror://mirrors.ubuntu.com/mirrors.txt bionic multiverse
deb mirror://mirrors.ubuntu.com/mirrors.txt bionic-updates multiverse
deb mirror://mirrors.ubuntu.com/mirrors.txt bionic-backports main restricted universe multiverse
deb mirror://mirrors.ubuntu.com/mirrors.txt bionic-security main restricted
deb mirror://mirrors.ubuntu.com/mirrors.txt bionic-security universe
deb mirror://mirrors.ubuntu.com/mirrors.txt bionic-security multiverse

更新してみるとネットワーク的に近場のミラーリポジトリが使われるようになるはず。

$ sudo apt update
$ sudo apt upgrade -y

ちなみに、上記のテクニックは Ubuntu 18.04 LTS (Bionic) だけでなく Ubuntu 10.04 (Lucid) 以降で使えるらしい。 めでたしめでたし。

Python: Keras で AutoEncoder を書いてみる

今回はニューラルネットワークのフレームワークの Keras を使って AutoEncoder を書いてみる。 AutoEncoder は入力になるべく近い出力をするように学習したネットワークをいう。 AutoEncoder は特徴量の次元圧縮や異常検知など、幅広い用途に用いられている。

使った環境は次の通り。

$ sw_vers        
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G95
$ python -V 
Python 3.7.4

下準備

まずは必要なパッケージをインストールしておく。

$ pip install keras tensorflow matplotlib

中間層が一層の AutoEncoder

Keras の Sequential API を使って実装した最も単純な AutoEncoder のサンプルコードを以下に示す。 データセットには MNIST を使った。 入力と出力が 28 x 28 = 784 次元なのに対し、中間層は一層で 36 次元しかない。 つまり、中間層では次元圧縮に相当する処理をしている。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import numpy as np
from keras import layers
from keras import models
from keras import callbacks
from keras.datasets import mnist
from matplotlib import pyplot as plt
from matplotlib import cm

def main():
    # MNIST データセットを読み込む
    (x_train, train), (x_test, y_test) = mnist.load_data()
    image_height, image_width = 28, 28
    # 中間層で圧縮される次元数
    encoding_dim = 36  # 中間層の出力を 6 x 6 の画像として可視化するため

    # Flatten
    x_train = x_train.reshape(x_train.shape[0], image_height * image_width)
    x_test = x_test.reshape(x_test.shape[0], image_height * image_width)

    # Min-Max Normalization
    x_train = x_train.astype('float32')
    x_test = x_test.astype('float32')
    x_train = (x_train - x_train.min()) / (x_train.max() - x_train.min())
    x_test = (x_test - x_test.min()) / (x_test.max() - x_test.min())

    # 中間層が一層だけの単純な AutoEncoder
    model = models.Sequential()
    model.add(layers.Dense(encoding_dim, activation='relu',
                           input_shape=(image_height * image_width,)))
    model.add(layers.Dense(image_height * image_width,
                           activation='sigmoid'))

    # モデルの構造を確認する
    print(model.summary())

    model.compile(optimizer='adam',
                  loss='binary_crossentropy')

    fit_callbacs = [
        callbacks.EarlyStopping(monitor='val_loss',
                                patience=5,
                                mode='min')
    ]

    # モデルを学習させる
    model.fit(x_train, x_train,
              epochs=100,
              batch_size=256,
              shuffle=True,
              validation_data=(x_test, x_test),
              callbacks=fit_callbacs,
              )

    # テストデータの損失を確認しておく
    score = model.evaluate(x_test, x_test, verbose=0)
    print('test xentropy:', score)

    # 学習済みのモデルを元に、次元圧縮だけするモデルを用意する
    encoder = models.clone_model(model)
    encoder.compile(optimizer='adam',
                    loss='binary_crossentropy')
    encoder.set_weights(model.get_weights())
    # 最終段のレイヤーを取り除く
    encoder.pop()

    # テストデータからランダムに 10 点を選び出す
    p = np.random.random_integers(0, len(x_test), 10)
    x_test_sampled = x_test[p]
    # 選びだしたサンプルを AutoEncoder にかける
    x_test_sampled_pred = model.predict_proba(x_test_sampled,
                                              verbose=0)
    # 次元圧縮だけする場合
    x_test_sampled_enc = encoder.predict_proba(x_test_sampled,
                                               verbose=0)

    # 処理結果を可視化する
    fig, axes = plt.subplots(3, 10)
    for i, label in enumerate(y_test[p]):
        # 元画像を上段に表示する
        img = x_test_sampled[i].reshape(image_height, image_width)
        axes[0][i].imshow(img, cmap=cm.gray_r)
        axes[0][i].axis('off')
        axes[0][i].set_title(label, color='red')
        # AutoEncoder で次元圧縮した画像を下段に表示する
        enc_img = x_test_sampled_enc[i].reshape(6, 6)
        axes[1][i].imshow(enc_img, cmap=cm.gray_r)
        axes[1][i].axis('off')
        # AutoEncoder で復元した画像を下段に表示する
        pred_img = x_test_sampled_pred[i].reshape(image_height, image_width)
        axes[2][i].imshow(pred_img, cmap=cm.gray_r)
        axes[2][i].axis('off')

    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみよう。 検証用データに対する損失は約 0.087 だった。

$ python ae.py
...(snip)...
Model: "sequential_1"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
dense_1 (Dense)              (None, 36)                28260
_________________________________________________________________
dense_2 (Dense)              (None, 784)               29008
=================================================================
Total params: 57,268
Trainable params: 57,268
Non-trainable params: 0
_________________________________________________________________
...(snip)...
test xentropy: 0.08722344622612

同時に、以下のグラフが得られる。 上段が入力画像、中段が AutoEncoder の中間層の出力、下段が復元された出力画像になっている。

f:id:momijiame:20190908213044p:plain

上記を見ると多少ボケたりかすれたりはしているものの、ちゃんと入力に近い画像が出力されていることがわかる。 中間層の出力は人間にはよくわからないけど、これでちゃんと元の画像に近いものが復元できるのはなんとも不思議な感じ。

中間層が 5 層の AutoEncoder

試しに先ほどのネットワークに中間層を足して、キャパシティを上げてみよう。 以下のサンプルコードでは次元を 784 -> 128 -> 64 -> 36 -> 64 -> 128 -> 784 と変化させている。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import numpy as np
from keras import layers
from keras import models
from keras import callbacks
from keras.datasets import mnist
from matplotlib import pyplot as plt
from matplotlib import cm

def main():
    # MNIST データセットを読み込む
    (x_train, train), (x_test, y_test) = mnist.load_data()
    image_height, image_width = 28, 28
    # 中間層で圧縮される次元数
    encoding_dim = 36  # 6 x 6 の画像として可視化してみるため

    # Flatten
    x_train = x_train.reshape(x_train.shape[0], image_height * image_width)
    x_test = x_test.reshape(x_test.shape[0], image_height * image_width)

    # Min-Max Normalization
    x_train = x_train.astype('float32')
    x_test = x_test.astype('float32')
    x_train = (x_train - x_train.min()) / (x_train.max() - x_train.min())
    x_test = (x_test - x_test.min()) / (x_test.max() - x_test.min())

    # 中間層を 4 層まで増やしたネットワーク
    model = models.Sequential()
    model.add(layers.Dense(128, activation='relu',
                           input_shape=(image_height * image_width,)))
    model.add(layers.Dense(64, activation='relu'))
    model.add(layers.Dense(encoding_dim, activation='relu'))
    model.add(layers.Dense(64, activation='relu'))
    model.add(layers.Dense(128, activation='relu'))
    model.add(layers.Dense(image_height * image_width,
                           activation='sigmoid'))

    # モデルの構造を確認する
    print(model.summary())

    model.compile(optimizer='adam',
                  loss='binary_crossentropy')

    print(model.summary())

    fit_callbacs = [
        callbacks.EarlyStopping(monitor='val_loss',
                                patience=5,
                                mode='min')
    ]

    # モデルを学習させる
    model.fit(x_train, x_train,
              epochs=100,
              batch_size=256,
              shuffle=True,
              validation_data=(x_test, x_test),
              callbacks=fit_callbacs,
              )

    # テストデータの損失を確認しておく
    score = model.evaluate(x_test, x_test, verbose=0)
    print('test xentropy:', score)

    # 学習済みのモデルを元に、次元圧縮だけするモデルを用意する
    encoder = models.clone_model(model)
    encoder.compile(optimizer='adam',
                    loss='binary_crossentropy')
    encoder.set_weights(model.get_weights())
    # 中間層までのレイヤーを取り除く
    encoder.pop()
    encoder.pop()
    encoder.pop()

    # テストデータからランダムに 10 点を選び出す
    p = np.random.random_integers(0, len(x_test), 10)
    x_test_sampled = x_test[p]
    # 選びだしたサンプルを AutoEncoder にかける
    x_test_sampled_pred = model.predict_proba(x_test_sampled,
                                              verbose=0)
    # 次元圧縮だけする場合
    x_test_sampled_enc = encoder.predict_proba(x_test_sampled,
                                               verbose=0)

    # 処理結果を可視化する
    fig, axes = plt.subplots(3, 10)
    for i, label in enumerate(y_test[p]):
        # 元画像を上段に表示する
        img = x_test_sampled[i].reshape(image_height, image_width)
        axes[0][i].imshow(img, cmap=cm.gray_r)
        axes[0][i].axis('off')
        axes[0][i].set_title(label, color='red')
        # AutoEncoder で次元圧縮した画像を中段に表示する
        enc_img = x_test_sampled_enc[i].reshape(6, 6)
        axes[1][i].imshow(enc_img, cmap=cm.gray_r)
        axes[1][i].axis('off')
        # AutoEncoder で復元した画像を下段に表示する
        pred_img = x_test_sampled_pred[i].reshape(image_height, image_width)
        axes[2][i].imshow(pred_img, cmap=cm.gray_r)
        axes[2][i].axis('off')

    plt.show()


if __name__ == '__main__':
    main()

上記を実行してみる。 テストデータの損失は先ほどより減って約 0.079 となった。

$ python ae.py
...(snip)...
Layer (type)                 Output Shape              Param #   
=================================================================
dense_1 (Dense)              (None, 128)               100480    
_________________________________________________________________
dense_2 (Dense)              (None, 64)                8256      
_________________________________________________________________
dense_3 (Dense)              (None, 36)                2340      
_________________________________________________________________
dense_4 (Dense)              (None, 64)                2368      
_________________________________________________________________
dense_5 (Dense)              (None, 128)               8320      
_________________________________________________________________
dense_6 (Dense)              (None, 784)               101136    
=================================================================
Total params: 222,900
Trainable params: 222,900
Non-trainable params: 0
_________________________________________________________________
...(snip)...
test xentropy: 0.07924877260923385

出力されたグラフは次の通り。 今度の中間層の出力は、先ほどよりもパターンが出ているような気がする。 例えば、左上と右下にどの画像でも白くなっている出力があったりするようだ。

f:id:momijiame:20190908213602p:plain

そんなかんじで。

PythonとKerasによるディープラーニング

PythonとKerasによるディープラーニング