CUBE SUGAR CONTAINER

技術系のこと書きます。

inode (アイノード) を枯渇させてみる

inode (アイノード) は Unix 系のファイルシステムに登場する概念の一つ。 これはファイルシステム上に保存しているファイルなどのメタデータを格納するデータ構造になっている。 本のメタファーで説明すると、本文が実際に保存されているデータなら inode は目次や索引に相当する。 そして inode はファイルシステム上において有限な資源なので場合によっては枯渇することがある。 今回は、そんな inode を意図的に枯渇させることで何が起こるかを確認する。

使った環境は次の通り。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 16.04.4 LTS
Release:    16.04
Codename:   xenial
$ uname -r
4.4.0-87-generic

下準備

まずは tmpfs を使って適当な場所に適当なサイズでファイルシステムをマウントしておく。 ここでは /tmp/tmpfs ディレクトリに 256MB のサイズでマウントした。

$ mkdir -pv /tmp/tmpfs
mkdir: created directory '/tmp/tmpfs'
$ sudo mount -t tmpfs -o size=256m tmpfs /tmp/tmpfs

df コマンドを使うと /tmp/tmpfs にちゃんとマウントされていることが分かる。

$ df
Filesystem                   1K-blocks     Used Available Use% Mounted on
... (省略) ...
tmpfs                           262144        0    262144   0% /tmp/tmpfs

同様に -i オプションをつけて実行すると inode の状況が確認できる。

$ df -i
Filesystem                    Inodes IUsed   IFree IUse% Mounted on
... (省略) ...
tmpfs                         256019     1  256018    1% /tmp/tmpfs

今はファイルシステム上に何もないので inode は 1 しか消費されていない。 IFree の列にある 256018 という数字が、あとどれだけ inode が作れるかを示している。

枯渇させる

それでは、実際に inode を枯渇させてみよう。

まずはファイルシステムをマウントしているディレクトリに移動する。

$ cd /tmp/tmpfs/

あとは seq コマンドで生成した数字を touch コマンドでファイルとして作っていく。 ファイル一つあたり inode も 1 消費されるので、こうすればディスク上のサイズは消費せずに inode だけ減らせる。

$ seq 256019 | xargs -L1 -P2 -I% touch %

xargs コマンドの -P オプションはプロセス並列度を表す。 なので、試すシステムの論理 CPU コア数にあわせて指定すると早く終るはず。

inode が減っていく過程は、次のように watch kお万度で確認すると良い。

$ watch -n 1 df -i

さて、しばらくしてコマンドが最後まで実行できると次のようなメッセージが出る。

$ seq 256019 | xargs -L1 -P2 -I% touch %
touch: cannot touch '256019': No space left on device

確認すると inode の残りが 0 になっており枯渇している。

$ df -i
Filesystem                    Inodes  IUsed   IFree IUse% Mounted on
... (省略) ...
tmpfs                         256019 256019       0  100% /tmp/tmpfs

ただし、サイズで見ると 0% なのでディスクスペースは全く消費されていない。

$ df
Filesystem                   1K-blocks     Used Available Use% Mounted on
... (省略) ...
tmpfs                           262144        0    262144   0% /tmp/tmpfs

しかしながら、もうこのファイルシステム上にはファイルなどを作ることはできない。 これが inode が枯渇したときに起こる状況となる。

$ touch greeting.txt
touch: cannot touch 'greeting.txt': No space left on device

いじょう。

まとめ

  • inode が枯渇するとスペースが空いているように見えて新しくファイルなどが作れなくなる

詳解UNIXプログラミング 第3版

詳解UNIXプログラミング 第3版

Python: pandas の DataFrameGroupBy#agg() には関数も渡せる

今回は pandas で DataFrame#groupby() したときに得られるオブジェクト DataFrameGroupBy が持つメソッド agg() について。 これまであんまり使ってこなかったけど、関数が渡せることを知って色々と便利に使えそうだなと感じた。 ちょっと前置きが長くなるので知っているところに関しては飛ばしながら読むと良いかも。

使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.13.4
BuildVersion:   17E199
$ python -V
Python 3.6.5

下準備

ひとまず pandas` をインストールしておく。

$ pip install pandas
$ pip list --format=columns | grep pandas
pandas          0.22.0

Python の REPL を起動する。

$ python

そして、ユーザの購買履歴っぽいサンプルデータを DataFrame オブジェクトで用意しておく。

>>> import pandas as pd
>>> columns = ['name', 'purchase_price']
>>> data = [
...     ('alice', 2000),
...     ('alice', 3000),
...     ('alice', 4000),
...     ('alice', 5000),
...     ('bob', 1000),
...     ('bob', 2000),
...     ('bob', 3000),
...     ('bob', 4000),
... ]
>>> df = pd.DataFrame(data, columns=columns)

これで下準備が終わった。

DataFrameGroupBy について

最初に、今回の主役となるオブジェクト DataFrameGroupBy について。

まず、先ほど作成した DataFrame オブジェクトに対して groupby() メソッドを使う。 これによって、メソッドで指定したカラム単位で値を集約できる。

>>> dfg = df.groupby('name')

得られるオブジェクトは次の通り DataFrameGroupBy というものになる。

>>> dfg
<pandas.core.groupby.DataFrameGroupBy object at 0x103637780>

このオブジェクトには max()min(), mean(), sum() といった代表的な統計量を計算するためのメソッドが用意されている。 次の通り、集約した値の単位で統計量が計算される。

>>> dfg.max()
       purchase_price
name                 
alice            5000
bob              4000
>>> dfg.min()
       purchase_price
name                 
alice            2000
bob              1000
>>> dfg.sum()
       purchase_price
name                 
alice           14000
bob             10000
>>> dfg.mean()
       purchase_price
name                 
alice            3500
bob              2500

ただまあ、それぞれを単独で呼び出すのは結構めんどくさい。

そこで、代わりに agg() メソッドが使える。 このメソッドは、辞書型のオブジェクトを渡すことでカラムに対して特定の集計をするように指示できる。 それも、次のように値をリストにしておけば複数の集計が一度にできる。

>>> dfg.agg({
...     'purchase_price': ['max', 'min', 'sum', 'mean'],
... })
      purchase_price                   
                 max   min    sum  mean
name                                   
alice           5000  2000  14000  3500
bob             4000  1000  10000  2500

まあ、ただ上記の集計だけに関していえば describe() メソッドを使った方が楽かも。 四分位数まで含めた代表的な統計量を一通り出力してくれる。

>>> dfg.describe()
      purchase_price                                                       \
               count    mean          std     min     25%     50%     75%   
name                                                                        
alice            4.0  3500.0  1290.994449  2000.0  2750.0  3500.0  4250.0   
bob              4.0  2500.0  1290.994449  1000.0  1750.0  2500.0  3250.0   

               
          max  
name           
alice  5000.0  
bob    4000.0  

とはいえ agg() の本領は関数を渡せるところにあるんだと思う。 具体的には、一つの引数を受け取る関数を辞書の値として渡すことができる。 こうすると、辞書のキーに指定したカラムが Series オブジェクトとして関数に渡される。

例えば次のコードでは、引数の Series をソートした上で最大値と最小値を除いた合計値を計算している。 まあ、サンプルの実用性は別として agg() に関数を渡すと、このように柔軟な集計が可能となる。

>>> dfg.agg({'purchase_price': lambda s: sum(sorted(s)[1: -1])})
       purchase_price
name                 
alice            7000
bob              5000

まあ Python 的には lambda を使うと可読性が犠牲になるので、ちゃんと関数を定義した方が好ましいかな。

>>> def sum_middle(series):
...     """最大値と最小値の要素を除いた合計を返す"""
...     sorted_series = sorted(series)
...     # 最大値と最小値を取り除く
...     middle_elements = sorted_series[1: -1]
...     # 合計値を返す
...     return sum(middle_elements)
... 
>>> dfg.agg({'purchase_price': sum_middle})
       purchase_price
name                 
alice            7000
bob              5000

いじょう。

前処理大全[データ分析のためのSQL/R/Python実践テクニック]

前処理大全[データ分析のためのSQL/R/Python実践テクニック]

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Python: ロギング設定をファイルから読み込むときの注意点

大昔にハマった問題を忘れていて、またやってしまったので備忘録として残しておく。 結論から先に書いておくと、まず Python でロギングの設定をファイルから読み込むときは logging.config.fileConfig() という関数を使う。 そのとき disable_existing_loggers というオプションに False を指定するのを忘れないようにしましょうね、という話。

使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.13.4
BuildVersion:   17E199
$ python -V
Python 3.6.5

下準備

次のようにロギングの設定ファイルを用意しておく。 出力するときの行フォーマットとかファイルのローテーティングとか色々と設定できる。 ログ自体は /var/tmp/example.log というパスに出力されるようになっている。

$ cat << 'EOF' > logging.conf
[loggers]
keys=root

[handlers]
keys=timedRotatingFileHandler

[logger_root]
level=DEBUG
handlers=timedRotatingFileHandler

[handler_timedRotatingFileHandler]
class=logging.handlers.TimedRotatingFileHandler
level=DEBUG
formatter=simpleFormatter
args=('/var/tmp/example.log', 'D', 1, 100)

[formatters]
keys=simpleFormatter

[formatter_simpleFormatter]
format=%(asctime)s %(process)d %(threadName)s %(name)s %(levelname)s %(message)s
datefmt=
EOF

上記設定ファイルの書式については、次の公式ドキュメントに書かれている。

16.7. logging.config — ロギングの環境設定 — Python 3.6.5 ドキュメント

上手くいくパターン

まずは上手くいくパターンについて。 Python の REPL を起動しておく。

$ python

いきなりだけど logging.config.fileConfig() を使ってロギングの設定ファイルを読み込む。

>>> from logging import config
>>> config.fileConfig('logging.conf')

それから logging.getLogger() を使ってロガーを取得する。

>>> import logging
>>> LOG = logging.getLogger(__name__)

取得したロガーを使ってログを書き出す。

>>> LOG.error('something went wrong')

一旦 Python の REPL から抜けよう。

>>> exit()

そして、ログファイルの内容を cat コマンドで確認する。

$ cat /var/tmp/example.log
2018-04-12 23:21:33,273 16027 MainThread __main__ ERROR something went wrong

ちゃんとログが書き出されていることが分かる。

上手くいかないパターン

続いては上手くいかないパターンを確認してみよう。 もう一度 Python の REPL を起動する。

$ python

続いては、先ほどと実行するコードの順序を入れ替える。 先に logging.getLogger() を使ってロガーを取得しておく。

>>> import logging
>>> LOG = logging.getLogger(__name__)

そして、ロガーを取得した後からロギングの設定を logging.config.fileConfig() を使って読み込む。

>>> from logging import config
>>> config.fileConfig('logging.conf')

それでは、最初に取得したロガーを使ってログを書き出してみよう。

>>> LOG.error('something went wrong')

別のターミナルからログが書き出されるはずのファイルの内容を確認する。

$ cat /var/tmp/example.log 
2018-04-12 23:21:33,273 16027 MainThread __main__ ERROR something went wrong

すると、なんと前回の内容から何も変わっていない! つまりログが書き出されていない。

ここで、試しにロガーを取得し直してみよう。 同じ名前を指定すると、同じオブジェクトが返されてしまうので何らか別のものにする。

>>> LOG = logging.getLogger('some.other.name')
>>> LOG.error('something went wrong')

もう一度ログファイルを確認すると、今度はちゃんとログが書き出されている。

$ cat /var/tmp/example.log 
2018-04-12 23:21:33,273 16027 MainThread __main__ ERROR something went wrong
2018-04-12 23:29:32,561 16343 MainThread some.other.name ERROR something went wrong

上記のような現象が起きる理由としては logging.config.fileConfig() のオプションが関係している。 この関数には disable_existing_loggers という真偽値のオプションがあって、デフォルトでは True になっている。 このオプションが True のままだと、ロギング設定ファイルを読み込むタイミングで過去に取得したロガーが使えなくなってしまう。 ロガー自体はロギング設定ファイルを読み込むよりも前にモジュール変数として静的に読み込むことも多い。 そのため、この問題にハマってログが出力されず悩むということが起こる。

解決策

この問題の解決策としては logging.config.fileConfig() を呼ぶときに disable_existing_loggers=False を忘れずにつけようね、という点に尽きる。

$ python

先ほどと同じように、先にロガーを取得しておく。

>>> import logging
>>> LOG = logging.getLogger(__name__)

そして logging.config.fileConfig() を呼ぶときに disable_existing_loggers=False を指定する。

>>> from logging import config
>>> config.fileConfig('logging.conf', disable_existing_loggers=False)

最初に取得したロガーでログを書き出してみよう。

>>> LOG.error('something went wrong')

ログファイルを確認すると、今度はちゃんとログが書き出されている。

$ cat /var/tmp/example.log 
2018-04-12 23:21:33,273 16027 MainThread __main__ ERROR something went wrong
2018-04-12 23:29:32,561 16343 MainThread some.other.name ERROR something went wrong
2018-04-12 23:35:46,950 16383 MainThread __main__ ERROR something went wrong

めでたしめでたし。

Docker Compose を使って複数のコンテナを管理する

今回は Docker Compose を使って複数のコンテナをまとめて管理する方法について。 docker run コマンドを使ってチマチマとやるよりもぐっと楽にできる。 コンテナオーケストレータを使うほどでもないけど複数台コンテナを扱いたい…っていうシチュエーションかな?

今回使った環境は次の通り。 Docker のディストリビューションとしては Docker for Mac を使う。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.13.3
BuildVersion:   17D102

インストール

Docker Compose は Docker for Mac をインストールすれば一緒についてくる。

まずは Homebrew Cask を使って Docker をインストールする。

$ brew cask install docker

初期設定とサービスの起動をするために、インストールした Docker アプリケーションを実行する。

$ open /Applications/Docker.app

しばらくして Docker のサービスが立ち上がったら、次のように docker version コマンドを実行する。 クライアントとサーバがエラーなく表示されれば上手くいっている。

$ docker version
Client:
 Version:   18.03.0-ce
 API version:   1.37
 Go version:    go1.9.4
 Git commit:    0520e24
 Built: Wed Mar 21 23:06:22 2018
 OS/Arch:   darwin/amd64
 Experimental:  false
 Orchestrator:  swarm

Server:
 Engine:
  Version:  18.03.0-ce
  API version:  1.37 (minimum version 1.12)
  Go version:   go1.9.4
  Git commit:   0520e24
  Built:    Wed Mar 21 23:14:32 2018
  OS/Arch:  linux/amd64
  Experimental: true

Docker Compose を使わずに複数のコンテナを管理する場合

まずは、もし Docker Compose を使わないで複数のコンテナを管理しようとした場合について考えてみる。 ここでは二つのコンテナを用意して、ネットワーク的な疎通があるようにしたい状況を考えてみよう。

まずは一つ目のコンテナを docker run コマンドで起動する。

$ docker run --name container1 -it centos:7 /bin/bash

続いて二つ目のコンテナを起動する。 その際に --link オプションを使って、先ほど起動したコンテナの名前を解決できるようにする。 ここでは一つ目のコンテナを c1 という名前で名前解決できるようにしている。

$ docker run --name container2 --link container1:c1 -it centos:7 /bin/bash

二つ目のコンテナのシェルから、一つ目のコンテナに向けて ping を打ってみよう。

# ping -c 3 c1
PING c1 (172.17.0.2) 56(84) bytes of data.
64 bytes from c1 (172.17.0.2): icmp_seq=1 ttl=64 time=0.120 ms
64 bytes from c1 (172.17.0.2): icmp_seq=2 ttl=64 time=0.096 ms
64 bytes from c1 (172.17.0.2): icmp_seq=3 ttl=64 time=0.098 ms

--- c1 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2090ms
rtt min/avg/max/mdev = 0.096/0.104/0.120/0.016 ms

ちゃんと疎通がとれた。

とはいえ、このやり方には色々と問題がある。 例を挙げると、それぞれのコンテナを起動するときのオプションを毎回正しく入力しなければ上手く動作しない。 正直そんなもの覚えていられないので、きっとそのうちシェルスクリプトを書き始めることになるだろう。

Docker Compose を使って複数のコンテナを管理する場合

先ほどは Docker Compose を使わずに複数のコンテナを協調させていた。 続いては Docker Compose を使った場合について書く。

Docker Compose では docker-compose.yml という設定ファイルを基本にしてコンテナを管理する。 これで、先ほどの問題点だったオプションを毎回覚えておくような必要はなくなる。

$ cat << 'EOF' > docker-compose.yml 
version: '3'

services:
  c1:
    image: centos:7
    command: /usr/sbin/init
  c2:
    image: centos:7
    command: /usr/sbin/init
EOF

起動するコマンドとして /usr/sbin/init を指定しているのは、コンテナをすぐに終了させないようにするため。

設定ファイルの書式については次の公式サイトに詳しく記載されている。

docs.docker.com

設定ファイルができたら docker-compose up コマンドを使ってコンテナを起動する。

$ docker-compose up
Creating network "compose_default" with the default driver
Creating compose_c2_1 ... done
Creating compose_c1_1 ... done
Attaching to compose_c1_1, compose_c2_1

別のターミナルから docker ps コマンドを使うと、それぞれのコンテナが起動していることが分かる。

$ docker ps                                          
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
a434cf41dc96        centos:7            "/usr/sbin/init"    20 seconds ago      Up 19 seconds                           compose_c2_1
a946853ecf00        centos:7            "/usr/sbin/init"    20 seconds ago      Up 20 seconds                           compose_c1_1

ちなみに docker-compose ps コマンドを使えば設定ファイルに含まれるコンテナだけの状態を確認できる。 これは docker-compose.yml ファイルがあるディレクトリで実行する。

$ docker-compose ps
    Name          Command       State   Ports
---------------------------------------------
compose_c1_1   /usr/sbin/init   Up           
compose_c2_1   /usr/sbin/init   Up

同じく、コンテナでコマンドを実行したいときは docker-compose exec コマンドを使う。 この場合 docker-compose.yml ファイルにある名称でコンテナを指定できる。

$ docker-compose exec c1 /bin/bash

もちろん docker exec コマンドを使っても構わない。

$ docker exec -it compose_c1_1 /bin/bash

シェルで ping コマンドを使ってコンテナ間で名前が解決できることを確認しよう。

[root@a946853ecf00 /]# ping -c 3 c2
PING c2 (172.18.0.3) 56(84) bytes of data.
64 bytes from compose_c2_1.compose_default (172.18.0.3): icmp_seq=1 ttl=64 time=0.184 ms
64 bytes from compose_c2_1.compose_default (172.18.0.3): icmp_seq=2 ttl=64 time=0.101 ms
64 bytes from compose_c2_1.compose_default (172.18.0.3): icmp_seq=3 ttl=64 time=0.110 ms

--- c2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2107ms
rtt min/avg/max/mdev = 0.101/0.131/0.184/0.039 ms

ばっちり。

コンテナの作成順序を指定する

先ほどの例ではコンテナを作成する順序関係は関係なかった。 とはいえ、データベースなどを扱う場合には特定のコンテナを先に作って欲しいといったことがある。 次はそのような場合について扱う。

今度はデータベースを扱うシステムを想定してコンテナの一つを mysql イメージにしてみた。 コンテナの作成に関係する記述は clientdepends_on になる。 これを記述しておくと、そのコンテナは依存するコンテナよりも後に作られることになる。 また、ports では外部に公開するポートを指定している。

$ cat << 'EOF' > docker-compose.yml 
version: '3'

services:
  db:
    image: mysql
    environment:
      - MYSQL_ROOT_PASSWORD
    ports:
      - 3306:3306
  client:
    image: centos:7
    depends_on:
      - db
    command: /usr/sbin/init
EOF

上記の設定ファイルでは db コンテナに environment も指定していた。 これはコンテナに渡す環境変数を指定する書式になっている。 一般的には <key>=<value> という形で書くものの、上記では <key> のみになっている。 これは Docker ホストで定義されている環境変数をそのまま渡すことを意味している。 パスワードなど設定ファイルに書けない秘密情報は、このようにしておくと良い。

$ export MYSQL_ROOT_PASSWORD=rootpasswd

docker-compose up コマンドを使ってコンテナを起動すると、作成順序が必ず client -> db の順になっているはず。

$ docker-compose up

ただし、この機能には注意すべき点が一つある。 それは、作成順序自体は指定できるもののコンテナの起動までは待ってくれないというところ。 実際のところはコンテナが起動するまで待ってほしいというニーズの方が大きいと思う。 なので、おそらくはシェルスクリプトなどを使ってサービスが起動するまで待つ (リトライする) ようなコードが必要になるだろう。

コンテナイメージも一緒に管理する

続いてはカスタマイズした Docker イメージを Docker Compose で管理する方法について。 Docker Compose ではコンテナ自体だけでなくコンテナイメージまで設定ファイルで管理できる。

先ほどの例ではデータベースのクライアントに相当するコンテナは作ったものの、実際に操作することはできなかった。 これは MySQL クライアントがインストールされていなかったため。 そこで、試しに MySQL クライアントをインストールした Docker イメージに変更してみよう。

まずは Docker イメージをビルドするのに必要な Dockerfile を用意する。 場所は docker-compose.yml の下に client というディレクトリを作って、そこに配置した。

$ mkdir client
$ cat << 'EOF' > client/Dockerfile
FROM centos:7

RUN yum -y update \
 && yum -y install mysql
EOF

ひとまず単体でイメージがビルドできることを確認しておこう。 単体で成功しないと docker-compose.yml に組み込む以前の問題になってしまうため。

$ docker build -t example/client client 
...
Successfully tagged example/client:latest

イメージからコンテナを起動して mysql コマンドが実行できることも確認しておく。

$ docker run -it example/client mysql --help
mysql  Ver 15.1 Distrib 5.5.56-MariaDB, for Linux (x86_64) using readline 5.1
Copyright (c) 2000, 2017, Oracle, MariaDB Corporation Ab and others.

Usage: mysql [OPTIONS] [database]
...

よさそうだ。

確認できたので、次は上記のコンテナイメージの情報を docker-compose.yml に組み込む。 具体的には、次のように image をビルド後のビルドイメージの名前にする。 その上で build を指定して Dockerfile のある client ディレクトリを指定すれば良い。

$ cat << 'EOF' > docker-compose.yml 
version: '3'

services:
  db:
    image: mysql
    environment:
      - MYSQL_ROOT_PASSWORD
    ports:
      - 3306:3306
  client:
    image: example/client
    build:
      context: client
    depends_on:
      - db
    command: /usr/sbin/init
EOF

こうしておけば docker-compose build コマンドで設定ファイルに含まれるコンテナイメージが一気にビルドできる。

$ docker-compose build                      
db uses an image, skipping
Building client
Step 1/2 : FROM centos:7
 ---> e934aafc2206
Step 2/2 : RUN yum -y update  && yum -y install mysql
 ---> Using cache
 ---> f82342f62e31
Successfully built f82342f62e31
Successfully tagged example/client:latest

あとは先ほどと同じように docker-compose up でコンテナたちを起動する。

$ docker-compose up

クライアントに対応するコンテナのシェルをつかもう。

$ docker-compose exec client /bin/bash 

データベースのコンテナに MySQL クライアントで接続してみる。

# mysql -u root -prootpasswd -h db
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MySQL connection id is 2
Server version: 5.7.21 MySQL Community Server (GPL)

Copyright (c) 2000, 2017, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MySQL [(none)]> 

ちゃんと接続できた!

ディスクを永続化する

先ほどの設定では MySQL がデータベースの情報を保存する先がコンテナのファイルシステム上になっていた。 これではコンテナが起動するごとにデータが消えてしまう。 そこで、次は MySQL のデータディレクトリとして Docker ホストのディレクトリをマウントさせることにしよう。

やることは単純で volumes を指定するだけ。 あとはマウントする対応関係を <host-dir>:<container-dir> という形で記述する。

$ cat << 'EOF' > docker-compose.yml 
version: '3'

services:
  db:
    image: mysql
    environment:
      - MYSQL_ROOT_PASSWORD
    ports:
      - 3306:3306
    volumes:
      - ./db-data:/var/lib/mysql
  client:
    image: example/client
    build:
      context: client
    depends_on:
      - db
    command: /usr/sbin/init
EOF

ディレクトリのマウント周りは前のコンテナの情報が残っているといまいち上手くいかない感じみたい。 なので、一旦先ほどのコンテナを終了した上で全て削除しておこう。

$ docker system prune -f

そして設定ファイルを元にコンテナを起動する。

$ docker-compose up

Docker ホスト側でディレクトリを確認すると MySQL のデータディレクトリが指定した名前で作成されていることが分かる。

$ ls db-data 
auto.cnf        ib_logfile0     private_key.pem
ca-key.pem      ib_logfile1     public_key.pem
ca.pem          ibdata1         server-cert.pem
client-cert.pem     ibtmp1          server-key.pem
client-key.pem      mysql           sys
ib_buffer_pool      performance_schema

ちゃんとデータが永続化されているか確認しておこう。 まずはクライアントのシェルをつかむ。

$ docker-compose exec client /bin/bash

適当にデータを投入しておく。

# mysql -u root -prootpasswd -h db
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MySQL connection id is 2
Server version: 5.7.21 MySQL Community Server (GPL)

Copyright (c) 2000, 2017, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MySQL [(none)]> CREATE DATABASE mydb;
Query OK, 1 row affected (0.01 sec)

MySQL [(none)]> USE mydb;
Database changed

MySQL [mydb]> CREATE TABLE users (
    ->   name TEXT,
    ->   age INTEGER
    -> );
Query OK, 0 rows affected (0.03 sec)

MySQL [mydb]> INSERT INTO users VALUES
    ->   ('Alice', 20),
    ->   ('Bob', 25),
    ->   ('Carol', 30);
Query OK, 3 rows affected (0.01 sec)
Records: 3  Duplicates: 0  Warnings: 0

一旦コンテナを全て終了してから立ち上げ直す。

$ docker-compose down
$ docker-compose up

もう一度クライアントのシェルをつかむ。

$ docker-compose exec client /bin/bash

確認すると、前回の内容が残っていることが分かる。 これで、コンテナを終了してもデータは Docker ホスト側に残り続けるので消えなくなった。

# mysql -u root -prootpasswd -h db

# mysql -u root -prootpasswd -h db
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MySQL connection id is 2
Server version: 5.7.21 MySQL Community Server (GPL)

Copyright (c) 2000, 2017, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MySQL [(none)]> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mydb               |
| mysql              |
| performance_schema |
| sys                |
+--------------------+
5 rows in set (0.03 sec)

MySQL [(none)]> USE mydb;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed

MySQL [mydb]> SHOW TABLES;
+----------------+
| Tables_in_mydb |
+----------------+
| users          |
+----------------+
1 row in set (0.01 sec)

MySQL [mydb]> SELECT * FROM users;
+-------+------+
| name  | age  |
+-------+------+
| Alice |   20 |
| Bob   |   25 |
| Carol |   30 |
+-------+------+
3 rows in set (0.02 sec)

とりあえず、これくらい覚えておけば大丈夫そうかな。

Google Cloud SDK の CLI で GCP を操作する

今回は Google Cloud SDK を使うことで CLI から Google Cloud Platform を操作してみる。

使った環境は次の通り。

$ sw_vers                           
ProductName:    Mac OS X
ProductVersion: 10.13.3
BuildVersion:   17D102

インストール

macOS であれば Google Cloud SDK は Homebrew Cask からインストールできる。

$ brew cask install google-cloud-sdk

初期設定

インストールが終わったら、最初に認証情報などを設定するために gcloud init コマンドを実行する。 コマンドを実行すると Web ブラウザが開くはずなので、そこから GCP にログインする。

$ gcloud init

上記の初期化をする過程でプロジェクトの作成か既存からの選択を促されるはず。 後から実行するときは次のように gcloud projects create コマンドで作成する。

$ gcloud projects create gcloud-cli-helloworld

GCP では、このように何かを始めるときに、まずはプロジェクトを作成することになる。

$ gcloud projects list
PROJECT_ID             NAME                   PROJECT_NUMBER
gcloud-cli-helloworld  gcloud-cli-helloworld  XXXXXXXXXXXX

BigQuery を操作してみる

試しにプロジェクト内で BigQuery を使ってみることにしよう。 まずはデータセットを作成する。 これは RDBMS でいえばデータベースに相当する。

$ bq mk gcloud-cli-helloworld:example_bq

次の通りデータセットができた。

$ bq ls gcloud-cli-helloworld   
  datasetId   
 ------------ 
  example_bq  

続いてデータセット内にテーブルを作る。 これは RDBMS でもお馴染みの概念なので特に説明はいらなさそう。

$ bq mk gcloud-cli-helloworld:example_bq.users

これでテーブルができた。 RDBMS との違いは、この状態ではまだテーブルのスキーマが指定されていないこと。

$ bq show gcloud-cli-helloworld:example_bq.users
Table gcloud-cli-helloworld:example_bq.users

   Last modified    Schema   Total Rows   Total Bytes   Expiration   Time Partitioning   Labels  
 ----------------- -------- ------------ ------------- ------------ ------------------- -------- 
  10 Apr 17:57:46            0            0                                                      

スキーマや中身についてはこれから設定していく。

続いては、次のように JSON 形式でスキーマを用意しよう。

$ cat << 'EOF' > schema.json
[
  {"name": "name", "type": "string", "mode": "required"},
  {"name": "age", "type": "integer", "mode": "required"}
]
EOF

肝心の中身は次のように CSV で用意した。

$ cat << 'EOF' > users.csv
Alice,20
Bob,25
Carol,30
EOF

あとは bq load コマンドを使ってスキーマとデータをテーブルにアップロードする。

$ bq load --project_id gcloud-cli-helloworld example_bq.users users.csv schema.json

これでテーブルにスキーマとデータが入った。

$ bq show gcloud-cli-helloworld:example_bq.users
Table gcloud-cli-helloworld:example_bq.users

   Last modified              Schema             Total Rows   Total Bytes   Expiration   Time Partitioning   Labels  
 ----------------- ---------------------------- ------------ ------------- ------------ ------------------- -------- 
  10 Apr 18:04:05   |- name: string (required)   3            43                                                     
                    |- age: integer (required)                                                                       

先程の操作で --project_id の指定が毎回うっとおしいときは、次のようにデフォルトのプロジェクトに指定すると手っ取り早い。

$ gcloud config set project gcloud-cli-helloworld

あとは bq query でテーブルに対してクエリを投げることができる。

$ bq query "SELECT * FROM example_bq.users"
Waiting on bqjob_r72120da59a09c346_00000162aecd87d0_1 ... (0s) Current status: DONE   
+-------+-----+
| name  | age |
+-------+-----+
| Bob   |  25 |
| Alice |  20 |
| Carol |  30 |
+-------+-----+

ばっちり。

Python: pandas でダミー変数を生成する

今回は pandas を使ってダミー変数を生成する方法について書く。 ダミー変数というのは、例えば国籍や性別といった名義尺度の説明変数を数値に変換する手法のこと。 名義尺度は順序関係を持たないので、単純に取りうる値に対して連番を振るようなやり方では上手くいかない。 そこで、特定の状態や状況について「あり・なし」や「Yes/No」を 01 で表現することになる。 具体例については後述する。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.13.3
BuildVersion:   17D102
$ python -V
Python 3.6.4
$ pip list --format=columns | grep pandas
pandas          0.22.0 

下準備

まずは pandas をインストールして Python の REPL を起動する。

$ pip install pandas
$ python

続いてサンプルとなる DataFrame オブジェクトを用意する。 内容としてはユーザ情報を模したものにした。

>>> import pandas as pd
>>> import numpy as np
>>> data = [
...     ('Alice', 10, 'F'),
...     ('Bob', 20, 'M'),
...     ('Carol', 30, 'O'),
...     ('Daniel', 40, 'M'),
...     ('Eric', 50, np.nan),
... ]
>>> columns = ['name', 'age', 'gender']
>>> df = pd.DataFrame(data, columns=columns)

できあがった DataFrame は次の通り。

>>> df
     name  age gender
0   Alice   10      F
1     Bob   20      M
2   Carol   30      O
3  Daniel   40      M
4    Eric   50    NaN

性別からダミー変数を生成する

先ほど確認した通りサンプルの DataFrame には性別が格納されている。 値については M が男性で F が女性、O はその他で NaN は不明を表している。 まずは、この性別からダミー変数を生成してみよう。

pandas には get_dummies() という、そのものずばりな関数が用意されている。 この関数に Series オブジェクト (カラム) を渡せばダミー変数を自動で作ってくれる。

>>> pd.get_dummies(df['gender'])
   F  M  O
0  1  0  0
1  0  1  0
2  0  0  1
3  0  1  0
4  0  0  0

ちゃんとカラムに格納されている内容を元に、それに該当する行だけ 1 になるダミー変数ができた。

生成されたダミー変数は DataFrame オブジェクトになっている。

>>> dummy_df = pd.get_dummies(df['gender'])
>>> type(dummy_df)
<class 'pandas.core.frame.DataFrame'>

生成したダミー変数を元に DataFrame と一緒に使いたいときは、次のようにして連結すれば良い。

>>> pd.concat([df, dummy_df], axis=1)
     name  age gender  F  M  O
0   Alice   10      F  1  0  0
1     Bob   20      M  0  1  0
2   Carol   30      O  0  0  1
3  Daniel   40      M  0  1  0
4    Eric   50    NaN  0  0  0

ところで、先ほどの例でどのダミー変数にもビットが立っていないものがあることに気づいただろうか? 具体的にはインデックス番号が 4 の Eric の行で、性別が NaN になっていた。 get_dummies() 関数はデフォルトでは NaN についてはダミー変数を作らない。 もし作りたいときは dummy_na オプションを True に指定しよう。

>>> pd.get_dummies(df['gender'], dummy_na=True)
   F  M  O  NaN
0  1  0  0    0
1  0  1  0    0
2  0  0  1    0
3  0  1  0    0
4  0  0  0    1

ダミー変数の名前に特定のプレフィックスを付与したいときは prefix オプションを指定する。

>>> pd.get_dummies(df['gender'], prefix='gender')
   gender_F  gender_M  gender_O
0         1         0         0
1         0         1         0
2         0         0         1
3         0         1         0
4         0         0         0

名前についてはダミー変数を生成した後からカラム名を変更しても構わない。

>>> dummy_df.columns = ['gender_f', 'gender_m', 'gender_o']
>>> dummy_df
   gender_f  gender_m  gender_o
0         1         0         0
1         0         1         0
2         0         0         1
3         0         1         0
4         0         0         0

自分でダミー変数を作ってみる

先ほどの例では get_dummies() 関数を使うことでカラムから自動でダミー変数を作る方法を紹介した。 とはいえ get_dummies() 関数を使うより自分で作ったほうが手っ取り早いこともある。

例えば 20 歳以上かを表すダミー変数 adult を作ってみることにしよう。 この場合、次のように年齢のカラムから真偽値を作って整数に変換すると楽にできる。

>>> adult = (df['age'] >= 20).astype(np.int64)

名前さえ直してやれば、ちゃんとお目当てのダミー変数ができている。

>>> adult.name = 'adult'
>>> adult
0    0
1    1
2    1
3    1
4    1
Name: adult, dtype: int64

あとは元の DataFrame と連結するだけ。

>>> pd.concat([df, adult], axis=1)
     name  age gender  adult
0   Alice   10      F      0
1     Bob   20      M      1
2   Carol   30      O      1
3  Daniel   40      M      1
4    Eric   50    NaN      1

ばっちり。

めでたしめでたし。

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Python: pandas の DataFrame から不要なカラムを削除する

今回は pandas の DataFrame オブジェクトから不要なカラムを取り除く方法について書く。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.13.3
BuildVersion:   17D102
$ python -V
Python 3.6.4

下準備

まずは環境に pandas をインストールしておく。

$ pip install pandas

続いてサンプルとなる DataFrame を用意する。 今回は、次のようにユーザを模したデータを用意してみた。

>>> import pandas as pd
>>> from datetime import date
>>> columns = ['id', 'name', 'gender', 'birth', 'country']
>>> data = [
...     (1, 'Alice', 'F', date(1980, 1, 1), 'US'),
...     (2, 'Bob', 'M', date(2000, 3, 3), 'UK'),
...     (3, 'Carol', 'M', date(1990, 2, 2), 'AU'),
... ]
>>> df = pd.DataFrame(data, columns=columns)

できあがった DataFrame はこんな感じ。

>>> df
   id   name gender       birth country
0   1  Alice      F  1980-01-01      US
1   2    Bob      M  2000-03-03      UK
2   3  Carol      M  1990-02-02      AU

非破壊的にカラムを取り除く

まずは非破壊的にカラムを取り除く方法から。 非破壊的というのは、元々の DataFrame には変更を加えることがないということ。 つまり、特定のカラムを取り除いた新しい DataFrame オブジェクトを取得することになる。

非破壊的にカラムを取り除くときは DataFrame#drop() メソッドを用いる。 例えば country カラムを取り除いてみよう。

>>> df.drop('country', axis=1)
   id   name gender       birth
0   1  Alice      F  1980-01-01
1   2    Bob      M  2000-03-03
2   3  Carol      M  1990-02-02

うまくいった。

DataFrame#drop() メソッドにはリストを渡して複数のカラム名を指定することもできる。 例えば countrybirth という二つのカラムを取り除いてみよう。

>>> df.drop(['country', 'birth'], axis=1)
   id   name gender
0   1  Alice      F
1   2    Bob      M
2   3  Carol      M

ばっちり。

破壊的にカラムを取り除く

続いては破壊的にカラムを取り除く方法について。 破壊的ということは、つまり元々の DataFrame オブジェクト自体が変更されるということ。

破壊的にカラムを削除するには次のようにする。 具体的には del 文で DataFrame のカラムを指定すれば良い。

>>> del df['country']

確認すると、元々の DataFrame オブジェクトからカラムが削除されていることが分かる。

>>> df
   id   name gender       birth
0   1  Alice      F  1980-01-01
1   2    Bob      M  2000-03-03
2   3  Carol      M  1990-02-02

ちなみに、このやり方では複数のカラムを同時に削除することはできないようだ。

>>> del df[['birth', 'gender']]
...
TypeError: '['birth', 'gender']' is an invalid key

いじょう。

めでたしめでたし。

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理

Pythonによるデータ分析入門 ―NumPy、pandasを使ったデータ処理