CUBE SUGAR CONTAINER

技術系のこと書きます。

CentOS7 で Docker Swarm を試してみる

先日リリースされた Docker 1.12 から Docker Swarm が本体に同梱されるようになった。 この Docker Swarm というのは、複数の Docker ホストを束ねて使えるようにするオーケストレーションツールになっている。 今回は、その Docker Swarm がどういったものなのかを一通り触って試してみることにする。

今回使った環境は次の通り。 CentOS 7 を Docker ホストの OS に使う。

$ cat /etc/redhat-release
CentOS Linux release 7.2.1511 (Core)
$ uname -r
3.10.0-327.el7.x86_64

なお、Docker ホストは 3 台構成で、それぞれ node1, node2, node3 と呼ぶことにする。 各ノードはお互いに通信する必要があるので IP アドレスを 192.168.33.11, 192.168.33.12, 192.168.33.13 と振っておく。

ただ、この初期構築は結構めんどくさい。 なので、今回は Vagrant で途中まで自動化したものも用意した。 具体的には Docker のインストールと必要なポートを開けるところまで。

Vagrantfile for Docker Swarm · GitHub

使い方は次の通り。

$ git clone https://gist.github.com/1dc33c45e47c75d03408a44e63c7daa7.git vagrant-docker-swarm
$ cd vagrant-docker-swarm
$ vagrant up

以下は手動で構築する場合の手順になっている。 また、上記で構築できるのは Docker のインストールまで。 だから、クラスタの構築やサービスの定義はいずれにせよ手動で実施する必要がある。

初期設定

それでは各ホストを手動で構築していこう。 まずはホスト名を設定する。

$ sudo hostname node1.example.com
$ cat << 'EOF' | sudo tee /etc/hostname > /dev/null
node1.example.com
EOF

上記で設定する値 (nodeX) は各ホストごとに異なるので注意してほしい。

通信用のポートを開ける

次に Docker Swarm がクラスタ内で通信するのに使うポートを開ける。 具体的には 2377/TCP, 7946/TCP, 4789/TCP, 7946/UDP, 4789/UDP を開ける必要がある。

ただし、ひとつ注意点があって CentOS7 標準の firewalld ではなく iptables を直接使う必要があるようだ。

github.com

そこで、まずは firewalld のサービスを止める。

$ sudo systemctl stop firewalld
$ sudo systemctl disable firewalld

そして、代わりに iptables をインストールする。

$ sudo yum -y install iptables-services
$ sudo systemctl start iptables
$ sudo systemctl enable iptables

必要なポートを開けていこう。 先ほど挙げたポート以外に 80/TCP も開けている。 これは後ほど Docker コンテナ内で立ち上げるサービスへのアクセスに使う。

$ for i in 80 2377 7946 4789; do sudo iptables -I INPUT -j ACCEPT -p tcp --dport $i; done;
$ for i in 7946 4789; do sudo iptables -I INPUT -j ACCEPT -p udp --dport $i; done;

ポートを開けることができたら設定を保存しておく。

$ sudo service iptables save

Docker をインストールする

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

その前に、まずはパッケージを最新の状態にしておこう。

$ sudo yum -y update

次に Docker の提供するリポジトリを yum に登録する。 CentOS7 が標準で使える Docker は、公式が提供するそれよりも古いため。

$ cat << 'EOF' | sudo tee /etc/yum.repos.d/docker.repo > /dev/null
[dockerrepo]
name=Docker Repository
baseurl=https://yum.dockerproject.org/repo/main/centos/7/
enabled=1
gpgcheck=1
gpgkey=https://yum.dockerproject.org/gpg
EOF

一旦 yum のキャッシュをクリアする。

$ sudo yum clean all

そして Docker 本体をインストールする。 Docker Swarm はバージョン 1.12 から本体に同梱されるようになった。 そのため、これだけで使えるようになる。

$ sudo yum -y install docker-engine

インストールできたら Docker のサービスを起動する。

$ sudo systemctl start docker
$ sudo systemctl enable docker

インストールできたら docker version コマンドを実行しよう。 次のようにクライアントとサーバの両方でエラーが出ていなければ問題ない。

$ sudo docker version
Client:
 Version:      1.12.0
 API version:  1.24
 Go version:   go1.6.3
 Git commit:   8eab29e
 Built:
 OS/Arch:      linux/amd64

Server:
 Version:      1.12.0
 API version:  1.24
 Go version:   go1.6.3
 Git commit:   8eab29e
 Built:
 OS/Arch:      linux/amd64

ついでに Docker コンテナが実行できることも確認しておこう。 hello-world イメージを使って、次のようなメッセージになれば大丈夫。

$ sudo docker run hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world

c04b14da8d14: Pull complete
Digest: sha256:0256e8a36e2070f7bf2d0b0763dbabdd67798512411de4cdcf9431a1feb60fd9
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.

To try something more ambitious, you can run an Ubuntu container with:
 $ docker run -it ubuntu bash

Share images, automate workflows, and more with a free Docker Hub account:
 https://hub.docker.com

For more examples and ideas, visit:
 https://docs.docker.com/engine/userguide/

上記の作業をすべての Docker ホストで実行しよう。 そして、先ほど紹介した Vagrantfile で実行できるのは、ここまで。

また、次の作業からは各 Docker ホストで実行する内容が異なる。

クラスタを作る

さて、ここからはいよいよ Docker Swarm の機能を使っていく。 各ホストで実行する内容が異なるのでターミナルの先頭にノード名を記述することにする。

まずは Docker ホストを束ねたクラスタを作る。 クラスタにはマネージャとワーカというふたつの役割がある。 ワーカは Docker コンテナが動作するだけの Docker ホストになっている。 マネージャは、それに加えてクラスタの管理などを行う。

今回は node1 をマネージャにして、その他のホスト node2 と node3 をワーカにしてクラスタを組んでみよう。

まずはマネージャとして動作する node1 で docker swarm init コマンドを実行する。 これでクラスタを作成できる。 オプションの --listen-addr と --advertise-addr にはクラスタの通信で使う IP アドレスとポートを指定する。

node1 $ sudo docker swarm init --listen-addr 192.168.33.11:2377 --advertise-addr 192.168.33.11:2377
Swarm initialized: current node (37v1n4u827kqfv2iwuh0u395r) is now a manager.

To add a worker to this swarm, run the following command:
    docker swarm join \
    --token SWMTKN-1-1q6s0gycuxtt3hjqlrd02pmnu555fju2m62vx0ivvmnytbp9hs-83fccs5u4bb9lsb40fo9gbd69 \
    192.168.33.11:2377

To add a manager to this swarm, run the following command:
    docker swarm join \
    --token SWMTKN-1-1q6s0gycuxtt3hjqlrd02pmnu555fju2m62vx0ivvmnytbp9hs-c2co7kyo54yc9z3vnvz0ykbhf \
    192.168.33.11:2377

何やら色々と表示されているけど、これは他のノードがクラスタに参加するためのコマンドになっている。 このトークンを知らないとクラスタには参加できないというわけ。

トークンにはマネージャ用とワーカ用のふたつがある。 つまりマネージャもワーカも複数台をクラスタに追加できるということ。

クラスタに参加しているノードの状態は docker node ls コマンドで確認できる。 今はクラスタを作成した直後なので、参加しているノードは node1 だけ。

node1 $ sudo docker node ls
ID                           HOSTNAME           STATUS  AVAILABILITY  MANAGER STATUS
37v1n4u827kqfv2iwuh0u395r *  node1.example.com  Ready   Active        Leader

クラスタにノードを追加する

先ほど作ったクラスタにノードを追加しよう。 追加するには、先ほどクラスタを作成したときに表示されたコマンドを使う。

まずは node2 をクラスタに参加させる。

node2 $ sudo docker swarm join --token SWMTKN-1-1q6s0gycuxtt3hjqlrd02pmnu555fju2m62vx0ivvmnytbp9hs-83fccs5u4bb9lsb40fo9gbd69 192.168.33.11:2377
This node joined a swarm as a worker.

そして node3 も同じようにクラスタに参加させる。

node3 $ sudo docker swarm join --token SWMTKN-1-1q6s0gycuxtt3hjqlrd02pmnu555fju2m62vx0ivvmnytbp9hs-83fccs5u4bb9lsb40fo9gbd69 192.168.33.11:2377
This node joined a swarm as a worker.

上記が実行できたら node1 で再び docker node ls コマンドを実行してみよう。 今度は 3 台が表示されるはず。

node1 $ sudo docker node ls
ID                           HOSTNAME           STATUS  AVAILABILITY  MANAGER STATUS
33mmomv1up2azt0e38h959a48    node2.example.com  Ready   Active
37v1n4u827kqfv2iwuh0u395r *  node1.example.com  Ready   Active        Leader
5opby7sfnpovoqshnjkymxe0s    node3.example.com  Ready   Active

動作確認用の Docker イメージをダウンロードする

次に Docker Swarm の動作を確認するための Docker イメージをダウンロードしよう。 このイメージは Docker コンテナの ID を HTTP (80/TCP) で返すだけのシンプルなものになっている。

https://hub.docker.com/r/momijiame/greeting/

なぜ、このようなものが必要かというと Docker Swarm にはロードバランス機能が備わっているため。 詳しくは後述するが、クラスタ内で動作する各コンテナにアクセスを振り分けることができる。 そこで、アクセス先のコンテナを確認するために上記のようなイメージを用意した。

このイメージを、それぞれの Docker ホストでダウンロードしておこう。 一応、これはやらなくても必要なときにダウンロードされるので動作はする。 とはいえ、あらかじめやっておいたほうが必要な時間の短縮につながるはず。

まずは node1 でダウンロードする。 バージョン (タグ) が複数あるのは、あとで Docker Swarm のローリングアップデート機能を試すため。

node1 $ sudo docker pull momijiame/greeting:1.0
node1 $ sudo docker pull momijiame/greeting:2.0
node1 $ sudo docker pull momijiame/greeting:latest

同じように node2 でもダウンロードする。

node2 $ sudo docker pull momijiame/greeting:1.0
node2 $ sudo docker pull momijiame/greeting:2.0
node2 $ sudo docker pull momijiame/greeting:latest

そして node3 でもダウンロードする。

node3 $ sudo docker pull momijiame/greeting:1.0
node3 $ sudo docker pull momijiame/greeting:2.0
node3 $ sudo docker pull momijiame/greeting:latest

サービスを定義する

さて、ここまでで Docker Swarm を使う準備が整った。 次はクラスタに対してサービスを定義する。 サービスというのは、ようするにクラスタの上で提供してほしい機能をいう。 これは、例えば HTML の静的なホスティングかもしれないし、Rails のアプリケーションだったりするかもしれない。 その内容は、サービスで使う Docker イメージに依存する。

それでは docker service create コマンドを使ってサービスを定義しよう。 名前は helloworld にする。 --replicas オプションは、いくつの Docker ホスト上でコンテナを動作させたいかを表している。 -p オプションは docker run コマンドのときと同じように、提供するポートフォワーディングの設定だ。 そして最後がサービスで使用する Docker イメージになっている。 この他にもオプションは多数あるけど、ここで使うのは上記だけ。

node1 $ sudo docker service create --name helloworld --replicas=2 -p 80:8000 momijiame/greeting:1.0
f44y32qiu9mlma2gcffi0o4xs

docker service ls コマンドでクラスタが提供しているサービスが見られる。

node1 $ sudo docker service ls
ID            NAME        REPLICAS  IMAGE                       COMMAND
f44y32qiu9ml  helloworld  2/2       momijiame/greeting:1.0

上記は概要なので、より詳しい内容は docker service inspect コマンドを使おう。

node1 $ sudo docker service inspect --pretty helloworld
ID:     f44y32qiu9mlma2gcffi0o4xs
Name:       helloworld
Mode:       Replicated
 Replicas:  2
Placement:
UpdateConfig:
 Parallelism:   1
 On failure:    pause
ContainerSpec:
 Image:     momijiame/greeting:1.0
Resources:
Ports:
 Protocol = tcp
 TargetPort = 8000
 PublishedPort = 80

サービスの稼働状況を調べるには docker service ps コマンドを使うと良い。

$ sudo docker service ps helloworld
ID                         NAME          IMAGE                       NODE               DESIRED STATE  CURRENT STATE           ERROR
7uaq5jpd7zu3wx4y0t4qrxzqr  helloworld.1  momijiame/greeting:1.0  node3.example.com  Running        Running 42 seconds ago
634or7vt454kja1xyzb5fxcqb  helloworld.2  momijiame/greeting:1.0  node2.example.com  Running        Running 41 seconds ago

上記は helloworld サービスにおいて Docker コンテナが node2 と node3 で実行されていることを表している。

アクセス

さて、これだけでコンテナが各 Docker ホスト上にばらまかれて稼働し始めている。 実際にコンテナにアクセスしてみよう。 アクセスする先はポートフォワーディングをしている Docker ホストになる。

また、アクセスする先の IP アドレスは、コンテナが実際に稼働している Docker ホストを意識する必要がない。 なぜなら、クラスタ内のどの Docker ホストにアクセスしても、そのアクセスは実際に稼働している Docker ホストに振り分けられる。 また、アクセス内容もロードバランシングされる。

実際に curl コマンドを使って Docker ホストにアクセスしてみよう。

$ curl http://192.168.33.11
Hello, 1cfe277ecd10

これで、ロードバランス先のコンテナの ID が得られる。

続けて実行すると、ラウンドロビン方式でアクセスがロードバランスされていることが見て取れる。

$ curl http://192.168.33.11
Hello, 094ab7b026d0
$ curl http://192.168.33.11
Hello, 1cfe277ecd10
$ curl http://192.168.33.11
Hello, 094ab7b026d0

オートヒーリング

さて、Docker Swarm には一部のコンテナが落ちたときに別の場所で上げなおす機能もある。

例えば node2 で稼働しているコンテナを docker kill コマンドで止めてみよう。

node2 $ sudo docker ps
CONTAINER ID        IMAGE                        COMMAND                  CREATED             STATUS              PORTS               NAMES
1cfe277ecd10        momijiame/greeting:1.0   "/usr/local/bin/gunic"   4 minutes ago       Up 4 minutes        8000/tcp            helloworld.2.634or7vt454kja1xyzb5fxcqb
node2 $ sudo docker kill 1cfe277ecd10
1cfe277ecd10

そして node1 で docker service ps コマンドを実行してサービスの稼働状況を確認する。 すると、node2 で動作していたコンテナがシャットダウンした代わりに node3 で新しいコンテナが動き始めている。

node1 $ sudo docker service ps helloworld
ID                         NAME              IMAGE                       NODE               DESIRED STATE  CURRENT STATE          ERROR
7uaq5jpd7zu3wx4y0t4qrxzqr  helloworld.1      momijiame/greeting:1.0  node3.example.com  Running        Running 4 minutes ago
9eeuo9ug1og7txh2stq4m8css  helloworld.2      momijiame/greeting:1.0  node1.example.com  Running        Running 5 seconds ago
634or7vt454kja1xyzb5fxcqb   \_ helloworld.2  momijiame/greeting:1.0  node2.example.com  Shutdown       Failed 10 seconds ago  "task: non-zero exit (137)"

node3 で確認すると、たしかにコンテナが生まれている。

node3 $ sudo docker ps
CONTAINER ID        IMAGE                        COMMAND                  CREATED             STATUS              PORTS               NAMES
15d48e89915a        momijiame/greeting:1.0   "/usr/local/bin/gunic"   30 seconds ago      Up 27 seconds       8000/tcp            helloworld.2.9eeuo9ug1og7txh2stq4m8css

レプリカ数を増やす

同時に稼働するコンテナ数 (レプリカ) を動的に増減させることもできる。 これには docker service scale コマンドを使う。

ここでは数を 2 から 3 に増やしてみよう。

node1 $ sudo docker service scale helloworld=3
helloworld scaled to 3

先ほどと同じように docker service ps コマンドを使って状況を確認しよう。 すると、すべてのノードでコンテナが稼働しはじめたことがわかる。

node1 $ sudo docker service ps helloworld
ID                         NAME              IMAGE                       NODE               DESIRED STATE  CURRENT STATE               ERROR
7uaq5jpd7zu3wx4y0t4qrxzqr  helloworld.1      momijiame/greeting:1.0  node3.example.com  Running        Running 5 minutes ago
9eeuo9ug1og7txh2stq4m8css  helloworld.2      momijiame/greeting:1.0  node1.example.com  Running        Running about a minute ago
634or7vt454kja1xyzb5fxcqb   \_ helloworld.2  momijiame/greeting:1.0  node2.example.com  Shutdown       Failed about a minute ago   "task: non-zero exit (137)"
43gr58ha2a2f44eyy146wccvi  helloworld.3      momijiame/greeting:1.0  node2.example.com  Running        Running 11 seconds ago

ちなみに、レプリカ数はクラスタのノード数よりも増やすことができる。 その場合は、ひとつのノードで複数のコンテナが起動することになる。

ローリングアップデート

Docker Swarm には、サービスで使うイメージを順番に更新する機能もある。 更新している最中は、そこにアクセスが振り分けられることがない。

試しに helloworld サービスで使うイメージのタグを 1.0 から 2.0 に変更してみよう。 これには docker service update コマンドを使う。

node1 $ sudo docker service update --image momijiame/greeting:2.0 helloworld
helloworld

実行してから docker service ps コマンドを使うと、タグ 1.0 を使っていたコンテナはシャットダウンされている。 そして、代わりにタグ 2.0 を使ったコンテナが各ノードで稼働し始めていることがわかる。

node1 $ sudo docker service ps helloworld
ID                         NAME              IMAGE                       NODE               DESIRED STATE  CURRENT STATE            ERROR
5qg8rxo0sw6ywsgs1jkdf22lo  helloworld.1      momijiame/greeting:2.0  node1.example.com  Running        Running 25 seconds ago
7uaq5jpd7zu3wx4y0t4qrxzqr   \_ helloworld.1  momijiame/greeting:1.0  node3.example.com  Shutdown       Shutdown 28 seconds ago
dr9wpc6kpftiet87m1y60nkpg  helloworld.2      momijiame/greeting:2.0  node3.example.com  Running        Running 22 seconds ago
9eeuo9ug1og7txh2stq4m8css   \_ helloworld.2  momijiame/greeting:1.0  node1.example.com  Shutdown       Shutdown 24 seconds ago
634or7vt454kja1xyzb5fxcqb   \_ helloworld.2  momijiame/greeting:1.0  node2.example.com  Shutdown       Failed 8 minutes ago     "task: non-zero exit (137)"
5sffoje8rnjg2uhpowoutvxk1  helloworld.3      momijiame/greeting:2.0  node2.example.com  Running        Running 28 seconds ago
43gr58ha2a2f44eyy146wccvi   \_ helloworld.3  momijiame/greeting:1.0  node2.example.com  Shutdown       Shutdown 35 seconds ago

もちろんサービスの提供は継続している。

node1 $ curl http://192.168.33.11
Hello, 330a354fa8ea
node1 $ curl http://192.168.33.11
Hello, cfd58c37c40d
node1 $ curl http://192.168.33.11
Hello, 189668ee58a6

ちなみに、各ノードの更新間隔を指定することもできる。 これには docker service update コマンドで --update-delay オプションを指定する。 これはサービスを定義するときに指定することもできる。

$ sudo docker service update --update-delay 1m helloworld
helloworld

更新間隔を 1 分に広げてイメージを変更してみよう。

$ sudo docker service update --image momijiame/greeting:latest helloworld
helloworld

すると node1 が即座に更新される。

$ sudo docker service ps helloworld
ID                         NAME              IMAGE                          NODE               DESIRED STATE  CURRENT STATE           ERROR
8co9b2rt9a5u1q1xnpkmnr4xo  helloworld.1      momijiame/greeting:latest  node3.example.com  Running        Running 2 seconds ago
5qg8rxo0sw6ywsgs1jkdf22lo   \_ helloworld.1  momijiame/greeting:2.0     node1.example.com  Shutdown       Shutdown 3 seconds ago
7uaq5jpd7zu3wx4y0t4qrxzqr   \_ helloworld.1  momijiame/greeting:1.0     node3.example.com  Shutdown       Shutdown 8 minutes ago
dr9wpc6kpftiet87m1y60nkpg  helloworld.2      momijiame/greeting:2.0     node3.example.com  Running        Running 8 minutes ago
9eeuo9ug1og7txh2stq4m8css   \_ helloworld.2  momijiame/greeting:1.0     node1.example.com  Shutdown       Shutdown 8 minutes ago
634or7vt454kja1xyzb5fxcqb   \_ helloworld.2  momijiame/greeting:1.0     node2.example.com  Shutdown       Failed 16 minutes ago   "task: non-zero exit (137)"
5sffoje8rnjg2uhpowoutvxk1  helloworld.3      momijiame/greeting:2.0     node2.example.com  Running        Running 8 minutes ago
43gr58ha2a2f44eyy146wccvi   \_ helloworld.3  momijiame/greeting:1.0     node2.example.com  Shutdown       Shutdown 9 minutes ago

この状態では新しいコンテナの node1 と、古いコンテナの node2 にアクセスが振り分けられているようだ。 コンテナの更新中の node3 にはアクセスが振り分けられない。

$ curl http://192.168.33.11
Hello, e1c8d02b9b98
$ curl http://192.168.33.11
Hello, 189668ee58a6
$ curl http://192.168.33.11
Hello, e1c8d02b9b98
$ curl http://192.168.33.11
Hello, 189668ee58a6

しばらくすると node3 の更新がおわる。

$ sudo docker service ps helloworld
ID                         NAME              IMAGE                          NODE               DESIRED STATE  CURRENT STATE                ERROR
8co9b2rt9a5u1q1xnpkmnr4xo  helloworld.1      momijiame/greeting:latest  node3.example.com  Running        Running about a minute ago
5qg8rxo0sw6ywsgs1jkdf22lo   \_ helloworld.1  momijiame/greeting:2.0     node1.example.com  Shutdown       Shutdown about a minute ago
7uaq5jpd7zu3wx4y0t4qrxzqr   \_ helloworld.1  momijiame/greeting:1.0     node3.example.com  Shutdown       Shutdown 9 minutes ago
dr9wpc6kpftiet87m1y60nkpg  helloworld.2      momijiame/greeting:2.0     node3.example.com  Running        Running 9 minutes ago
9eeuo9ug1og7txh2stq4m8css   \_ helloworld.2  momijiame/greeting:1.0     node1.example.com  Shutdown       Shutdown 9 minutes ago
634or7vt454kja1xyzb5fxcqb   \_ helloworld.2  momijiame/greeting:1.0     node2.example.com  Shutdown       Failed 17 minutes ago        "task: non-zero exit (137)"
4jwq9mphyed0zzc4pvaxrxu4i  helloworld.3      momijiame/greeting:latest  node1.example.com  Running        Preparing 1 seconds ago
5sffoje8rnjg2uhpowoutvxk1   \_ helloworld.3  momijiame/greeting:2.0     node2.example.com  Shutdown       Shutdown 1 seconds ago
43gr58ha2a2f44eyy146wccvi   \_ helloworld.3  momijiame/greeting:1.0     node2.example.com  Shutdown       Shutdown 10 minutes ago

この状況では新しいコンテナの node1 と node3 にアクセスが振り分けられている。 更新中の node2 にはアクセスが振り分けられない。

$ curl http://192.168.33.11
Hello, 0734903b8f27
$ curl http://192.168.33.11
Hello, e1c8d02b9b98
$ curl http://192.168.33.11
Hello, 0734903b8f27
$ curl http://192.168.33.11
Hello, e1c8d02b9b98

そして、さらにしばらくするとすべてのコンテナの更新がおわる。

$ sudo docker service ps helloworld
ID                         NAME              IMAGE                          NODE               DESIRED STATE  CURRENT STATE                ERROR
8co9b2rt9a5u1q1xnpkmnr4xo  helloworld.1      momijiame/greeting:latest  node3.example.com  Running        Running 2 minutes ago
5qg8rxo0sw6ywsgs1jkdf22lo   \_ helloworld.1  momijiame/greeting:2.0     node1.example.com  Shutdown       Shutdown 2 minutes ago
7uaq5jpd7zu3wx4y0t4qrxzqr   \_ helloworld.1  momijiame/greeting:1.0     node3.example.com  Shutdown       Shutdown 11 minutes ago
838oiqxd69e5xkf12inr4hnfw  helloworld.2      momijiame/greeting:latest  node2.example.com  Running        Preparing 1 seconds ago
dr9wpc6kpftiet87m1y60nkpg   \_ helloworld.2  momijiame/greeting:2.0     node3.example.com  Shutdown       Shutdown 2 seconds ago
9eeuo9ug1og7txh2stq4m8css   \_ helloworld.2  momijiame/greeting:1.0     node1.example.com  Shutdown       Shutdown 10 minutes ago
634or7vt454kja1xyzb5fxcqb   \_ helloworld.2  momijiame/greeting:1.0     node2.example.com  Shutdown       Failed 18 minutes ago        "task: non-zero exit (137)"
4jwq9mphyed0zzc4pvaxrxu4i  helloworld.3      momijiame/greeting:latest  node1.example.com  Running        Running about a minute ago
5sffoje8rnjg2uhpowoutvxk1   \_ helloworld.3  momijiame/greeting:2.0     node2.example.com  Shutdown       Shutdown about a minute ago
43gr58ha2a2f44eyy146wccvi   \_ helloworld.3  momijiame/greeting:1.0     node2.example.com  Shutdown       Shutdown 11 minutes ago

この状況では、すべてのコンテナにアクセスが振り分けられる。

$ curl http://192.168.33.11
Hello, 5df4881d10a3
$ curl http://192.168.33.11
Hello, 0734903b8f27
$ curl http://192.168.33.11
Hello, e1c8d02b9b98

サービスを削除する

最後に、サービスが不要になったら docker service rm コマンドで削除しよう。

$ sudo docker service rm helloworld
helloworld

まとめ

Docker 1.12 からオーケストレーションツールの Docker Swarm が本体に同梱されるようになった。 運用に必要そうな機能も一通り揃っていて、単独ですぐに使い始められるところが魅力的だと感じた。

GNU Privacy Guard でファイルを気軽に暗号化する

このファイルは平文のまま置いておきたくないなーっていうようなファイルがたまにある。 例えば、何らかのトークンや個人情報などが書き込まれているもの。 そんなときは GNU Privacy Guard を使うとサクッと暗号化しておくことができて便利そう。 これは OpenPGP という暗号化ソフトウェアの仕様を実装したものだ。

今回使った環境は次の通り。

$ cat /etc/lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=16.04
DISTRIB_CODENAME=xenial
DISTRIB_DESCRIPTION="Ubuntu 16.04.1 LTS"
$ uname -r
4.4.0-21-generic

下準備

まずは apt で GnuPG をインストールする。

$ sudo apt-get -y install gnupg2

次に、暗号化・復号する対象となるファイルを用意しておく。 これは、ただのテキストファイルにした。

$ cat << 'EOF' > greeting.txt
Hello, World!
EOF

暗号化する

早速、ファイルを暗号化してみよう。

gpg2 コマンドに -c オプションをつけて、暗号化したいファイルを指定する。

$ gpg2 -c greeting.txt
gpg: directory '/home/vagrant/.gnupg' created
gpg: new configuration file '/home/vagrant/.gnupg/dirmngr.conf' created
gpg: new configuration file '/home/vagrant/.gnupg/gpg.conf' created
gpg: keybox '/home/vagrant/.gnupg/pubring.kbx' created

上記を実行すると、暗号化に使うパスワードを 2 回聞かれるので入力しよう。

上手くいけば拡張子に .gpg のついたファイルができあがる。

$ ls
greeting.txt  greeting.txt.gpg
$ file greeting.txt.gpg
greeting.txt.gpg: GPG symmetrically encrypted data (AES cipher)

復号する

次は、先ほど暗号化したファイルを復号してみよう。

まずは、まぎらわしいので元の平文は削除しておく。

$ rm greeting.txt
$ ls
greeting.txt.gpg

そして、今回は何もオプションをつけずに gpg2 コマンドを実行して、引数には暗号化されたファイルを指定する。

$ gpg2 greeting.txt.gpg
gpg: AES encrypted data
gpg: encrypted with 1 passphrase

すると復号されたファイルができあがる。

$ ls
greeting.txt  greeting.txt.gpg

中身を見ると、ちゃんと元のファイルに戻っている。

$ cat greeting.txt
Hello, World!

ちなみに、上記を実行するとパスワードを聞かれずに復号される。 これは、どうやら暗号化したときにキャッシュ的なものがシステムに残るからのようだ。 残る先は ~/.gnupg ディレクトリの中のようなので、これを削除してから復号を試みるとちゃんとパスワードを聞かれる。

$ rm -rf ~/.gnupg/

CentOS 7 の場合

ちなみに、上記では Ubuntu 16.04 LTS を使っていたけど CentOS 7 でも同じように使える。

$ cat /etc/redhat-release
CentOS Linux release 7.2.1511 (Core)
$ uname -r
3.10.0-327.el7.x86_64

インストールに使うのが apt ではなく yum になるだけでパッケージ名は同じだ。

$ sudo yum -y install gnupg2

Python: データセットの標準化について

今回は機械学習とか統計で扱うデータセットの標準化について。

まずは、標準化されていない生のデータセットについて考えてみよう。 それらの多くは、次元によって数値の単位がバラバラだったり、あるいは大きさが極端に異なったりする。 これをそのまま扱ってしまうと、各次元を見比べたときにそれぞれの関係が分かりにくい。 また、機械学習においては特定の次元の影響が強く (または反対に弱く) 出てしまったりすることもあるらしい。 そこで、それぞれの次元のスケールを同じに揃えてやりたい。 これを標準化というようだ。

今回は「Zスコア」という標準化のやり方を扱う。 これは、一言で言ってしまえばデータセットの各要素から平均を引いて、標準偏差で割ったもの。 これをすると、データセットは平均が 0 で標準偏差・分散が 1 になる。

使った環境は次の通り。

$ python --version
Python 3.5.1

NumPy を使った標準化

まずは一番単純な NumPy の配列を直接使ったやり方から。

ひとまず pip を使って NumPy をインストールしておこう。

$ pip install numpy

Python の REPL を起動しよう。

$ python

NumPy をインポートしたら、次にZスコア化する対象となる配列を変数 l に代入する。 ようするに、これがデータセットを模したものということ。

>>> import numpy as np
>>> l = np.array([10, 20, 30, 40, 50])

この時点でデータセットの算術平均は 30 になっている。

>>> l.mean()
30.0

Zスコアによる標準化の第一段階では、まずデータセットの各要素から、データセットの平均値を引く。

>>> l2 = l - l.mean()

データセットの平均値は、これで 0 になった。

>>> l2.mean()
0.0

各要素から平均値を引いたので、中身はこんなことになる。

>>> l2
array([-20., -10.,   0.,  10.,  20.])

次にZスコア化の第二段階では標準偏差を扱う。 今のデータセットの標準偏差は約 14.14 だ。

>>> l2.std()
14.142135623730951

この標準偏差で、同じくデータセットの各要素を割る。

>>> l3 = l2 / l2.std()

これでデータベースの標準偏差は 1 になった。 きれいに 1 になっていないのは浮動小数点の計算誤差だろうね。

>>> l3.std()
0.99999999999999989

中身はこんなことになる。

>>> l3
array([-1.41421356, -0.70710678,  0.        ,  0.70710678,  1.41421356])

そして、これこそが標準化されたデータセットだ。 各要素の単位は、もはや元々のデータセットで扱われていたそれではなくなっている。 代わりに、単に各要素の相対的な位置関係を表したZスコアになっている。

SciPy を使った標準化

さっきは NumPy の配列を自分で標準化してみたけど、実際にはこの作業はライブラリが代わりにやってくれる。 ここでは SciPy を使った例を挙げておこう。

先ほどと同じように、今度は SciPy を pip コマンドでインストールする。

$ pip install scipy

インストールできたら Python の REPL を起動する。

$ python

さっきと同じようにデータセットを模した NumPy 配列を変数 l として用意しておく。

>>> import numpy as np
>>> l = np.array([10, 20, 30, 40, 50])

SciPy では、そのものずばり zscore() という名前の関数が用意されている。

>>> from scipy.stats import zscore

これに NumPy の配列を渡せば一発でZスコアに標準化できる。

>>> zscore(l)
array([-1.41421356, -0.70710678,  0.        ,  0.70710678,  1.41421356])

あっけない。

scikit-learn を使った標準化

同じように scikit-learn にも標準化のユーティリティが用意されている。

まずは scikit-learn を pip でインストールしておく。

$ pip install scikit-learn

Python の REPL を起動したら…

$ python

データセットを模した NumPy 配列を用意して…

>>> import numpy as np
>>> l = np.array([10, 20, 30, 40, 50])

scikit-learn では StandardScaler というクラスを使って標準化する。

>>> from sklearn.preprocessing import StandardScaler

インスタンス化したら、データセットを模した配列を渡す。 これでデータセットの情報が StandardScaler のインスタンスにセットされる。

>>> sc = StandardScaler()
>>> sc.fit(l)

そして StandardScaler#transform() メソッドを使ってデータセットを標準化する。 返り値として標準化されたデータセットが返る。

>>> sc.transform(l)
array([-1.41421356, -0.70710678,  0.        ,  0.70710678,  1.41421356])

ばっちり。 ちなみに、上記を実行すると NumPy 配列の型が int から float に変換されたよ!っていう警告が出る。 けど、意図したものなので無視しておっけー。

まとめ

  • データセットは標準化してから扱ったほうが良いらしい
  • 標準化は「Zスコア」というものを使うのがメジャーっぽい
  • 標準化のやり方には NumPy / SciPy / scikit-learn それぞれにやり方がある

Python: シーケンスアンパックについて

Python では、右辺がタプルなどのとき、左辺に複数の変数を置くことで、その中身を展開できる。 これをシーケンスアンパック (sequence unpack) という。

シーケンスアンパックを試してみる

まずは python コマンドを実行して REPL を起動しておこう。

$ python

例えば example という変数にタプルで 3 つの要素を代入しておく。

>>> example = (1, 2, 3)

これを右辺に置いて、左辺には同じ数の変数 a b c をカンマ区切りで置く。

>>> a, b, c = example

するとタプルの中身が、各変数に代入される。 これがシーケンスアンパックの挙動だ。

>>> a
1
>>> b
2
>>> c
3

シーケンスアンパックできるオブジェクト・できないオブジェクト

シーケンスアンパックは、例えばリストでもできる。

>>> example = [1, 2, 3]
>>> a, b, c = example
>>> a
1
>>> b
2
>>> c
3

シーケンスアンパックできるか否かは、そのオブジェクトがイテラブル (Iterable) か否かによる。 例えば object クラスのインスタンスはイテラブルでないので、シーケンスアンパックできない。

>>> a, b, c = object()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'object' object is not iterable

ユーザ定義クラスでシーケンスアンパックできるようにする

次は、ユーザ定義クラスでシーケンスアンパックできるようにしてみよう。 これは、ようするにそのユーザ定義クラスのインスタンスをイテラブルにするということ。

ユーザ定義クラスをイテラブルにするには、イテレータプロトコルという特殊メソッドを実装する必要がある。 これは具体的には __iter__() と __next__() という、ふたつのメソッドだ。 ただし、イテレータのコンテナオブジェクトであれば、このうち __iter__() さえあればいい。 コンテナオブジェクトというのは、自身のメンバなどを元にイテレータオブジェクトを生成して返すオブジェクトのことをいう。

次のサンプルコードでは User クラスをイテラブルにすることでシーケンスアンパックできるようにしている。 具体的には __iter__() メソッドを実装することで、クラスをイテレータのコンテナオブジェクトにしている。 このメソッドの中では、自身のメンバである name と age をリストにラップした上で iter() 組み込み関数でイテレータオブジェクトを取り出して返している。

>>> class User(object):
...     def __init__(self, name, age):
...         self.name = name
...         self.age = age
...     def __iter__(self):
...         return iter([self.name, self.age])
...

それでは、上記のクラスをインスタンス化しよう。

>>> u = User('Spam', 20)

そして、右辺に上記のインスタンスを、左辺は name と age という変数でそれを受ける。

>>> name, age = u

すると、シーケンスアンパックされて各変数に内容が代入された。

>>> name
'Spam'
>>> age
20

ばっちり。

まとめ

  • タプルやリストの内容を複数の変数に一度に代入することをシーケンスアンパックという
  • シーケンスアンパックできるオブジェクトはイテラブルなオブジェクト
  • ユーザ定義クラスをイテラブルにするにはイテレータプロトコルを実装する

参考

5. データ構造 — Python 3.5.2 ドキュメント

Python: Ellipsis について

今回は Python の特殊な定数 Ellipsis について調べてみた。

Ellipsis ってなんだ

Ellipsis というのは、主に拡張スライス文と共に使われる特殊な定数のこと。 これを使うと、例えば配列などのスライスで「...」を指定できるようになる。

3. 組み込み定数 — Python 3.5.2 ドキュメント

具体的な使用例

例えば、高速な数値計算のための配列ライブラリである NumPy の配列には、この Ellipsis を使うことができる。

ひとまず NumPy をインストールしよう。

$ pip install numpy

そして Python の REPL を起動する。

$ python

array という名前で NumPy の配列を作る。

>>> import numpy as np
>>> array = np.array([1, 2, 3])

この配列のスライスに「...」を指定してみよう。 これが Ellipsis だ。

>>> array[...]
array([1, 2, 3])

NumPy では (その次元の) すべての要素を返すことを表すために使われている。

ユーザ定義クラスで Ellipsis を使ってみる

ユーザ定義クラスで Ellipsis を指定できるようにしてみよう。 まず、ユーザ定義クラスでは __getitem__() という特殊メソッドを実装すると、そのインスタンスにスライス ([]) が使えるようになる。 そのメソッドに渡されるキーとして Ellipsis が指定されたら「...(snip)...」という値を返すようにしてみる。

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


class MyClass(object):

    def __getitem__(self, key):
        if key is Ellipsis:
            return '...(snip)...'

        return None


def main():
    obj = MyClass()
    print(obj[...])


if __name__ == '__main__':
    main()

上記を ellipsis.py という名前で保存して実行してみよう。

$ python ellipsis.py
...(snip)...

ばっちり。

Python 2 と 3 における Ellipsis の振る舞い

実は Ellipsis は Python 2.x と 3.x で振る舞いが結構違っている。 それぞれのインタプリタで挙動の違いを確かめてみよう。

まずは Python 3.x から。

$ python --version
Python 3.5.1
$ python

Python 3.x では「...」が単独で Ellipsis オブジェクトとして使える。

>>> ...
Ellipsis

それ対し Python 2.x ではどうなるだろうか。

$ python --version
Python 2.7.10
$ python

なんと「...」だけでは文法エラーになってしまった。

>>> ...
  File "<stdin>", line 1
    ...
    ^
SyntaxError: invalid syntax

Python 2.x では Ellipsis の使えるシチュエーションが、とても限られていることがわかる。

例えば、次のように通常のメソッドの引数として Ellipsis を受け取るようにしてみよう。

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


class MyClass(object):

    def mymethod(self, value):
        if value is Ellipsis:
            return '...(snip)...'

        return None


def main():
    obj = MyClass()
    print(obj.mymethod(...))


if __name__ == '__main__':
    main()

これを、先ほどと同じように ellipsis.py という名前で保存する。 まずは Python 3.x で動かしてみよう。

$ python --version
Python 3.5.1
$ python ellipsis.py
...(snip)...

ちゃんと動いた。

それに対し Python 2.x だと、どうなるだろうか。

$ python --version
Python 2.7.10
$ python ellipsis.py
  File "ellipsis.py", line 16
    print(obj.greeting(...))
                       ^
SyntaxError: invalid syntax

こちらは文法エラーになってしまった。 通常のメソッドの呼び出しに Ellipsis は渡すことができないらしい。

まとめ

  • Ellipsis は「...」を表す特殊な定数
  • Python 2 と 3 では挙動が異なる
  • Python 3 では「...」が Ellipsis オブジェクトになっている
  • そのため、通常のメソッドの引数などにも Ellipsis が使える
  • それに対し Python 2 では拡張スライス文でしか使えない

Python: 環境ごとの依存ライブラリをセットアップスクリプトの extras_require で管理する

Python のパッケージを作っていると、特定の環境だけで必要となるパッケージが大抵はでてくる。 例えばデータベースを扱うアプリケーションなら、使う RDBMS によってデータベースドライバのパッケージが異なる。 あるいは、インストール先の Python のバージョンによっては標準ライブラリに用意されていないパッケージのバックポート版をインストールしなきゃいけない。 今回は、そんなときに便利なセットアップスクリプト (setup.py) の extras_require 引数を使ってみる。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.11.5
BuildVersion:   15F34
$ python --version
Python 3.5.1

下準備

まず最初に題材とするのはデータベースを扱うアプリケーションにしよう。 前述した通り、この状況では使う RDBMS によって異なるデータベースドライバをインストールしなきゃいけない。 今回は RDBMS に MySQL と Postgresql を使い分ける状況を想定しよう。

データベースドライバをビルドするために MySQL と Postgresql をインストールしておく。

$ brew install mysql postgresql

次に、題材とするアプリケーション本体のソースコード。 これには SQLAlchemy を使ってモデルを定義したモジュールを mydbapp という名前で保存しておく。 ただし、今回これはあくまで単なる例に過ぎないので実際に動かしたりすることはない。

$ cat < 'EOF' > mydbapp.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

from sqlalchemy.ext.declarative.api import declarative_base
from sqlalchemy.sql.schema import Column
from sqlalchemy.sql.sqltypes import BigInteger
from sqlalchemy.sql.sqltypes import Text


Base = declarative_base()


class User(Base):
    """データベースのスキーマの元になるモデル"""
    __tablename__ = 'users'

    id = Column(BigInteger, primary_key=True)
    name = Column(Text, nullable=False)
EOF

サンプルコードにセットアップスクリプトを書く

それでは、今回の本題となるセットアップスクリプト (setup.py) を書いてみることにする。

アプリケーションが共通で必要とするパッケージについては通常どおり install_requires に記述しよう。 今回においては SQLAlchemy がこれに当たる。 そして、環境に依存するデータベースドライバは extras_require に辞書の形で渡す。 辞書のキーは環境の名前で、バリューにはパッケージの入ったリストを指定することになる。 今回であれば mysql には mysqlclient を、そして postgresql には psycopg2 を指定している。

$ cat << 'EOF' > setup.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

from setuptools import setup


def main():
    setup(
        name='mydbapp',
        version='0.0.1',
        zip_safe=False,
        py_modules=['mydbapp'],
        install_requires=[
            # どのような環境でも SQLAlchemy は必要になる
            'SQLAlchemy',
        ],
        extras_require={
            # 使う RDBMS ごとに、それ専用のドライバが必要になる
            'mysql': ['mysqlclient'],
            'postgresql': ['psycopg2'],
        },
    )


if __name__ == '__main__':
    main()
EOF

これで mydbapp モジュールをインストールできるようになった。

環境を指定してインストールする

pip install サブコマンドでは setup.py のあるディレクトリを指定することで、そのパッケージ (モジュール) をインストールできる。 このとき extras_require を使ったものであれば、角括弧で環境を指定する。

それでは、環境として mysql を指定してインストールしてみよう。

$ pip install .[mysql]
...(省略)...
Running setup.py install for mydbapp ... done
Successfully installed SQLAlchemy-1.0.13 mydbapp-0.0.1 mysqlclient-1.3.7

インストールされたパッケージを確認すると SQLAlchemy や mydbapp に混じって mysqlclient が見つかる。

$ pip list
mydbapp (0.0.1)
mysqlclient (1.3.7)
pip (8.1.2)
setuptools (23.0.0)
SQLAlchemy (1.0.13)
wheel (0.29.0)

同じように postgresql を指定したときはどうなるだろうか。

$ pip install .[postgresql]
...(省略)...
Running setup.py install for mydbapp ... done
Successfully installed SQLAlchemy-1.0.13 mydbapp-0.0.1 psycopg2-2.6.1

先ほどとは異なり psycopg2 がインストールされている。 ちなみに Python の仮想環境は作りなおしている。

$ pip list
mydbapp (0.0.1)
pip (8.1.2)
psycopg2 (2.6.1)
setuptools (23.0.0)
SQLAlchemy (1.0.13)
wheel (0.29.0)

もちろん、環境の指定は pip install 以外のサブコマンドにも有効になっている。 例えば Wheel をビルドするときも指定すれば環境ごとの内容になる。

$ pip wheel .[mysql]
$ ls | grep whl$
SQLAlchemy-1.0.13-cp35-cp35m-macosx_10_11_x86_64.whl
mydbapp-0.0.1-py3-none-any.whl
mysqlclient-1.3.7-cp35-cp35m-macosx_10_11_x86_64.whl

Python のバージョンごとに依存ライブラリを切り替える

extras_require には、環境の名前を指定してインストールする以外にも便利な使い方がある。 例えば Python のバージョンごとにインストールする依存ライブラリを切り替えることができる。

それでは、例としてアプリケーションが ipaddress モジュールに依存している場合を考えてみよう。 ipaddress モジュールは Python 3.3 で新たに標準ライブラリの仲間入りを果たしたモジュールだ。 つまり、それ以前のバージョンでは使うことができない。 ただし、バックポート版を PyPI からダウンロードしてインストールすることはできる。

次のセットアップスクリプトでは Python のバージョンが 3.3 未満のときだけ ipaddress モジュールをインストールするようにしよう。 これには「:python_version<"3.3"」といった書式で extras_require のキーを指定する。

$ cat << 'EOF' > setup.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

from setuptools import setup


def main():
    setup(
        name='mydbapp',
        version='0.0.1',
        zip_safe=False,
        py_modules=['mydbapp'],
        install_requires=[
            # どのような環境でも SQLAlchemy は必要になる
            'SQLAlchemy',
        ],
        extras_require={
            # 使う RDBMS ごとに、それ専用のドライバが必要になる
            'mysql': ['mysqlclient'],
            'postgresql': ['psycopg2'],
            # Python 3.3 未満には ipaddress が標準ライブラリにない
            ':python_version<"3.3"': [
                'ipaddress',
            ],
        },
    )


if __name__ == '__main__':
    main()
EOF

それでは、上記を Python 2.7 の環境にインストールしてみよう。

$ python --version
Python 2.7.10
$ pip install .
...(省略)...
Running setup.py install for mydbapp ... done
Successfully installed SQLAlchemy-1.0.13 ipaddress-1.0.16 mydbapp-0.0.1

ipaddress モジュールがインストールされていることがわかる。

$ pip list
ipaddress (1.0.16)
mydbapp (0.0.1)
pip (8.1.2)
setuptools (23.0.0)
SQLAlchemy (1.0.13)
wheel (0.29.0)

次に Python 3.5 にもインストールしてみる。

$ python --version
Python 3.5.1
$ pip install .
...(省略)...
Running setup.py install for mydbapp ... done
Successfully installed SQLAlchemy-1.0.13 mydbapp-0.0.1

今度は ipaddress モジュールはインストールされていない!

$ pip list
mydbapp (0.0.1)
pip (8.1.2)
setuptools (23.0.0)
SQLAlchemy (1.0.13)
wheel (0.29.0)

ちなみに、上記で登場したバージョンの指定方法は PEP 426 という仕様で規定されているらしい。 具体的には、その中の Environment Markers だ。

Environment Markers については、次のブログ記事が詳しかった。 ちなみに、システムのプラットフォーム (Linux だとか Windows だとか) やアーキテクチャ (i386 や x86_64) まで判定できるようだ。

2014/07/10 PEP-0426 Environment Markers の調査 - 清水川Web

Python: (今のところ) Flask で Request#get_data(as_text=True) は使わない方が良い

今回は最近見つけた Flask (正確には、その中で使われている WSGI ツールキットの Werkzeug) のバグについて。 先にざっくりと概要を説明しておくと Flask の Request#get_data() の引数として as_text=True を渡したときの挙動に問題がある。 このメソッドは Content-Type に含まれる charset 指定にもとづいてマルチバイト文字をデコードできない。 デコードに使われる文字コードが UTF-8 に固定されてしまっているため、それ以外の文字コードを扱うことができない。

このエントリでは、上記の問題について詳しく見ていくことにする。

今回使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.11.5
BuildVersion:   15F34
$ python --version
Python 3.5.1
$ echo $LANG
ja_JP.UTF-8

下準備

まずは Flask をインストールしておく。

$ pip install Flask
$ pip list | grep -i flask
Flask (0.11.1)

Request#charset メンバについて

Flask (正確には Werkzeug) の Request オブジェクトには charset というメンバがある。 これは、おそらくは Content-Body をエンコードした文字コードを格納するためのものだろう。 ただし、「おそらく」と言ったように、実際にはそのようには動作しない。 このメンバの値は、今のところ UTF-8 に固定されてしまっているためだ。

動作を確認するために、次のようなサンプルコードを用意しよう。 このサンプルコードでは Request#charset の内容をレスポンスとして返す。

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

from flask import Flask
from flask import request

app = Flask(__name__)


@app.route('/', methods=['POST'])
def post():
   return request.charset

上記のサンプルコードを実行する。 Flask v0.11 からは Flask のテストサーバの推奨される起動方法が少し変わった。

$ export FLASK_APP=charset.py
$ flask run
 * Serving Flask app "charset"
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

まずは Content-Type に charset の指定がないとき。 これは「utf-8」になる。 まあ、猫も杓子も UTF-8 の昨今、これは特に違和感のない挙動だと思う。

$ curl -X POST -H "Content-Type: text/plain" -d "こんにちは" http://localhost:5000
utf-8

次に UTF-8 以外の文字コードを扱ってみることにしよう。 今回は EUC-JP を使うことにして、それでエンコードされたテキストファイルを用意する。 先ほどのサンプルコードでは Content-Body の内容を読みこんだりはしないけど、一応ね。

$ cat << 'EOF' > greeting.txt
こんにちは
EOF
$ nkf -e greeting.txt > greeting.euc.txt
$ nkf --guess greeting.euc.txt
EUC-JP (LF)

ちなみに Mac OS X に nkf はデフォルトではインストールされていないので Homebrew でインストールしよう。

$ brew install nkf

次は Content-Type に charset をつけてリクエストする。 Content-Body も、それに合わせて EUC-JP でエンコードされたテキストファイルを使って送る。

$ curl -X POST -H "Content-Type: text/plain; charset=EUC-JP" -d @greeting.euc.txt http://localhost:5000
utf-8

Content-Type の charset で EUC-JP と指定しているんだけど utf-8 になってしまっている。

このように Flask (正確には Werkzeug) の Request#charset は、今のところ正しく動作しない。

MIMETYPE の文字コードは何処に格納されるのか

じゃあ Flask では Content-Type で指定された文字コードを正しく扱うことはできないのか?というと、そうではない。 実は Request#mimetype_params という辞書の中に入っている。

これもサンプルコードを用意して、動作を確認してみよう。

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

from flask import Flask
from flask import request

app = Flask(__name__)


@app.route('/', methods=['POST'])
def post():
   return str(request.mimetype_params.get('charset'))

そしてアプリケーションを起動する。

$ export FLASK_APP=mimetype.py
$ flask run
 * Serving Flask app "mimetype"
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

先ほどと同じように EUC-JP を指定したリクエストを送ってみよう。

$ curl -X POST -H "Content-Type: text/plain; charset=EUC-JP" -d @greeting.euc.txt http://localhost:5000
EUC-JP

今度はちゃんと EUC-JP になっている!

ちなみに、この値にはデフォルト値が入るわけではない。 指定がないときは空になっている。

$ curl -X POST -H "Content-Type: text/plain" -d @greeting.euc.txt http://localhost:5000
None

Request#get_data(as_text=True) への副作用

先ほどの Request#charset が UTF-8 で固定される問題は、別のメソッドにも影響を与えている。 それが、今回のエントリのタイトルにもなっている Request#get_data() メソッドだ。 このメソッドはリクエストから Content-Body として送られたデータを取り出すためのメソッドになっている。

この Request#get_data() というメソッドには as_text という引数がある。 これは Content-Body をデコードした内容を受け取るためのオプションで、デフォルトでは False になっている。 つまり、デフォルトでは Request#get_data() を実行したとき得られるものはバイト列 (bytes) ということになる。 そして、この引数を True にすると、バイト列をデコードしたユニコード文字列 (Python3: str, Python2: unicode) になる。

問題は、この as_text オプションを True にしたとき使われる文字コードだ。 ここまで語ってきたように Request#charset は utf-8 に固定されてしまっている。 だから、このメンバにもとづいてデコードしているとアウトなんだけど、今 (v0.11.10) の Wekzeug は見事にそれをやってしまっている。

github.com

この挙動を確認するため、次のようなサンプルコードを用意しよう。 このサンプルコードでは Request#get_data(as_text=True) で取得した内容を、そのままレスポンスとして返す。

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

from flask import Flask
from flask import request

app = Flask(__name__)


@app.route('/', methods=['POST'])
def post():
    return request.get_data(as_text=True)

上記を実行する。

$ export FLASK_APP=getdata1.py
$ flask run
 * Serving Flask app "getdata1"
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

先ほどと同じように EUC-JP を含むリクエストを送ってみよう。

$ curl -X POST -H "Content-Type: text/plain; charset=EUC-JP" -d @greeting.euc.txt http://localhost:5000
����ˤ���

文字化けしてしまった…。

何が起こったのか

これはつまり、以下のようなことが起こっている。

[EUC-JP 文字列] -> (UTF-8 デコード) -> [Python ユニコード文字列] -> (UTF-8 エンコード) -> [UTF-8 文字列 (文字化け)]

HTTP クライアントから送られてきた EUC-JP のバイト列を UTF-8 でデコードしてしまっているのが間違い。 結果的にめちゃくちゃなユニコード文字列が生成されて、それをエンコードしたところで文字化けしてしまう、という寸法だ。

じゃあ、どうすればいいのか

Wekzeug のバグが修正されるまではワークアラウドでしのぐしかない。 Werkzeug 任せにすると、リクエストに含まれるバイト列が UTF-8 固定でデコードされてしまうのが根本的な原因だ。 つまり、デコードを自分でやれば問題は起きなくなる。

次のサンプルコードを見てほしい。 このコードではリクエストからバイト列でデータを取り出した上で、それを自分でデコードしている。 デコードに使う文字コードは Request#mimetype_params に入っている値で、それがなければ UTF-8 を使うようにした。

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

from flask import Flask
from flask import request

app = Flask(__name__)


@app.route('/', methods=['POST'])
def post():
    data = request.get_data()
    charset = request.mimetype_params.get('charset') or 'UTF-8'
    return data.decode(charset, 'replace')

実行してみる。

$ export FLASK_APP=getdata2.py
$ flask run
 * Serving Flask app "getdata2"
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

今度も同じように EUC-JP を含むリクエストを送ってみよう。

$ curl -X POST -H "Content-Type: text/plain; charset=EUC-JP" -d @greeting.euc.txt http://localhost:5000
こんにちは

今度は文字化けしていない!

今度は、次のようなことが起こっている。

[EUC-JP 文字列] -> (EUC-JP デコード) -> [Python ユニコード文字列] -> (UTF-8 エンコード) -> [UTF-8 文字列]

EUC-JP 文字列が正しい文字コードでデコードされて、本来のユニコード文字列になっている。 それを UTF-8 でエンコードしてレスポンスとして返した。 そして、ターミナルの文字コードも UTF-8 なので文字化けは起きない。

実は、このやり方は Flask の Request#get_json() を真似している。 このメソッドでも、同じように Request#mimetype_params に入っている charset にもとづいてデコードしているからだ。 つまり Request#get_json() はバグっていない。 github.com

最近は猫も杓子も JSON だし、UTF-8 以外の文字コードを使う機会も少ないから今回のバグを踏む人は少ないのかもしれない。 とはいえ、こういう問題があるので Flask のアプリケーションでマルチバイト文字を扱うときは注意しよう。

ちなみに

この不具合については Wekzeug にバグレポートした。 将来的には、いつか直るかもしれない。

Request#get_data(as_text=True) does not work with Content-Type/charset · Issue #947 · pallets/werkzeug · GitHub