CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: LightGBM を使ってみる

LightGBM は Microsoft が開発した勾配ブースティング決定木 (Gradient Boosting Decision Tree) アルゴリズムを扱うためのフレームワーク。 勾配ブースティング決定木は、ブースティング (Boosting) と呼ばれる学習方法を決定木 (Decision Tree) に適用したアンサンブル学習のアルゴリズムになっている。 勾配ブースティング決定木のフレームワークとしては、他にも XGBoostCatBoost なんかがよく使われている。 調べようとしたきっかけは、データ分析コンペサイトの Kaggle で大流行しているのを見たため。

使った環境は次の通り。

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

もくじ

インストール

(2019-1-28 追記)

macOS でのインストール方法がバージョン 2.2.1 から変わった。 ビルド済みバイナリが配布されるようになったので、これまでのように gcc をインストールしてビルドする必要はない。 代わりに OpenMP のライブラリが実行時に必要となった。

$ brew install libomp

あとは PyPI からバイナリのパッケージをインストールするだけで良い。

$ pip install lightgbm
$ pip list --format=columns | grep -i lightgbm
lightgbm        2.2.2

過去のインストール方法 (バージョン 2.2.1 以前)

LightGBM は並列計算処理に OpenMP を採用しているので、まずはそれに必要なパッケージを入れておく。

$ brew install cmake gcc@7

あとは pip を使ってソースコードから LightGBM をビルドする。

$ export CXX=g++-7 CC=gcc-7
$ pip install --no-binary lightgbm lightgbm
$ pip list --format=columns | grep -i lightgbm
lightgbm        2.1.1  

多値分類問題 (Iris データセット)

それでは早速 LightGBM を使ってみる。

次のサンプルコードでは Iris データセットを LightGBM で分類している。 ポイントとしては LightGBM に渡すパラメータの目的 (objective) に multiclass (多値分類) を指定するところ。 そして、具体的なクラス数として num_class3 を指定する。 scikit-learn や numpy は LightGBM の依存パッケージとして自動的にインストールされるはず。

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

import lightgbm as lgb

from sklearn import datasets
from sklearn.model_selection import train_test_split

import numpy as np

"""LightGBM を使った多値分類のサンプルコード"""


def main():
    # Iris データセットを読み込む
    iris = datasets.load_iris()
    X, y = iris.data, iris.target

    # 訓練データとテストデータに分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y)

    # データセットを生成する
    lgb_train = lgb.Dataset(X_train, y_train)
    lgb_eval = lgb.Dataset(X_test, y_test, reference=lgb_train)

    # LightGBM のハイパーパラメータ
    lgbm_params = {
        # 多値分類問題
        'objective': 'multiclass',
        # クラス数は 3
        'num_class': 3,
    }

    # 上記のパラメータでモデルを学習する
    model = lgb.train(lgbm_params, lgb_train, valid_sets=lgb_eval)

    # テストデータを予測する
    y_pred = model.predict(X_test, num_iteration=model.best_iteration)
    y_pred_max = np.argmax(y_pred, axis=1)  # 最尤と判断したクラスの値にする

    # 精度 (Accuracy) を計算する
    accuracy = sum(y_test == y_pred_max) / len(y_test)
    print(accuracy)


if __name__ == '__main__':
    main()

細かい精度の検証が目的ではないので、交差検証はざっくり訓練データとテストデータに分けるだけに留めた。

上記に適当な名前をつけて実行してみよう。

$ python mc.py | tail -n 1
0.9736842105263158

今回の実行では精度 (Accuracy) として 97.36% が得られた。 ちなみに、この値は訓練データとテストデータの分けられ方に依存するので毎回異なったものになる。

scikit-learn インターフェース

LightGBM には scikit-learn に準拠したインターフェースも用意されている。 ネイティブな API と好みに合わせて使い分けられるのは嬉しい。

次のサンプルコードでは、先ほどと同じコードを scikit-learn インターフェースを使って書いてみる。

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

import lightgbm as lgb

from sklearn import datasets
from sklearn.model_selection import train_test_split

import numpy as np

"""LightGBM を使った多値分類のサンプルコード (scikit-learn interface)"""


def main():
    # Iris データセットを読み込む
    iris = datasets.load_iris()
    X, y = iris.data, iris.target

    # 訓練データとテストデータに分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y)

    # 上記のパラメータでモデルを学習する
    model = lgb.LGBMClassifier()
    model.fit(X_train, y_train)

    # テストデータを予測する
    y_pred = model.predict_proba(X_test)
    y_pred_max = np.argmax(y_pred, axis=1)  # 最尤と判断したクラスの値にする

    # 精度 (Accuracy) を計算する
    accuracy = sum(y_test == y_pred_max) / len(y_test)
    print(accuracy)


if __name__ == '__main__':
    main()

適当な名前でファイルに保存して実行してみよう。

$ python mcs.py | tail -n 1
0.9473684210526315

ちゃんと動いているようだ。

交差検証 (Cross Validation)

また、LightGBM にはブーストラウンドごとの評価関数の状況を交差検証で確認できる機能もある。

次のサンプルコードでは、先ほどと同じ Iris データセットを使った多値分類問題において、どのように学習が進むのかを可視化している。

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

import lightgbm as lgb

from sklearn import datasets

import numpy as np

from matplotlib import pyplot as plt

"""LightGBM を使った多値分類のサンプルコード (CV)"""


def main():
    # Iris データセットを読み込む
    iris = datasets.load_iris()
    X, y = iris.data, iris.target

    # データセットを生成する
    lgb_train = lgb.Dataset(X, y)

    # LightGBM のハイパーパラメータ
    lgbm_params = {
        # 多値分類問題
        'objective': 'multiclass',
        # クラス数は 3
        'num_class': 3,
    }

    # 上記のパラメータでモデルを学習〜交差検証までする
    cv_results = lgb.cv(lgbm_params, lgb_train, nfold=10)
    cv_logloss = cv_results['multi_logloss-mean']
    round_n = np.arange(len(cv_logloss))

    plt.xlabel('round')
    plt.ylabel('logloss')
    plt.plot(round_n, cv_logloss)
    plt.show()


if __name__ == '__main__':
    main()

上記に適当な名前をつけたら実行してみよう。

$ python mcv.py

すると、次のようなグラフが得られる。 f:id:momijiame:20180501065447p:plain

上記は各ブーストラウンドごとの評価関数の値を折れ線グラフでプロットしている。 ブーストラウンド数はデフォルトで 100 になっており評価関数は LogLoss 損失関数になっている。 グラフを見ると損失が最も小さいのはラウンド数が 40 付近であり、そこを過ぎるとむしろ増えていることが分かる。

つまり、汎化性能を求めるにはブーストラウンド数を 40 あたりで止めたモデルにするのが望ましいことが分かる。 次のサンプルコードでは lightgbm.train() 関数のオプションとして num_boost_round に 40 を指定している。 これによって最適なブーストラウンド数で学習を終えている。

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

import lightgbm as lgb

from sklearn import datasets

"""LightGBM を使った多値分類のサンプルコード"""


def main():
    # Iris データセットを読み込む
    iris = datasets.load_iris()
    X, y = iris.data, iris.target

    # データセットを生成する
    lgb_train = lgb.Dataset(X, y)

    # LightGBM のハイパーパラメータ
    lgbm_params = {
        # 多値分類問題
        'objective': 'multiclass',
        # クラス数は 3
        'num_class': 3,
    }

    # 上記のパラメータでモデルを学習する
    model = lgb.train(lgbm_params, lgb_train, num_boost_round=40)


if __name__ == '__main__':
    main()

最適なブーストラウンド数を自動で決める

といっても、最適なブーストラウンド数を毎回確認して調整するのは意外と手間だったりもする。 そこで LightGBM には自動で決めるための機能として early_stopping_rounds というものが用意されている。 これは、モデルの評価用データを渡した状態で学習させて、性能が頭打ちになったところで学習を打ち切るというもの。

次のサンプルコードでは LightGBM.train()early_stopping_rounds オプションを渡して機能を有効にしている。 数値として 10 を渡しているので 10 ラウンド進めても性能に改善が見られなかったときは停止することになる。 この数値は、あまり小さいと局所最適解にはまりやすくなってしまう恐れもあるので気をつけよう。 学習ラウンド数は最大で 1000 まで回るように num_boost_round オプションで指定している。 注意点としては、前述した通りこの機能を使う際は学習用データとは別に評価用データを valid_sets オプションで渡す必要がある。

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

import lightgbm as lgb

import numpy as np

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score

"""LightGBM を使った多値分類のサンプルコード"""


def main():
    # Iris データセットを読み込む
    iris = datasets.load_iris()
    X, y = iris.data, iris.target

    # データセットを学習用とテスト用に分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)
    # テスト用のデータを評価用と検証用に分ける
    X_eval, X_valid, y_eval, y_valid = train_test_split(X_test, y_test, random_state=42)

    # データセットを生成する
    lgb_train = lgb.Dataset(X_train, y_train)
    lgb_eval = lgb.Dataset(X_eval, y_eval, reference=lgb_train)

    # LightGBM のハイパーパラメータ
    lgbm_params = {
        # 多値分類問題
        'objective': 'multiclass',
        # クラス数は 3
        'num_class': 3,
    }

    # 上記のパラメータでモデルを学習する
    model = lgb.train(lgbm_params, lgb_train,
                      # モデルの評価用データを渡す
                      valid_sets=lgb_eval,
                      # 最大で 1000 ラウンドまで学習する
                      num_boost_round=1000,
                      # 10 ラウンド経過しても性能が向上しないときは学習を打ち切る
                      early_stopping_rounds=10)

    # 学習したモデルでホールドアウト検証する
    y_pred_proba = model.predict(X_valid, num_iteration=model.best_iteration)
    # 返り値は確率になっているので最尤に寄せる
    y_pred = np.argmax(y_pred_proba, axis=1)

    # 精度 (Accuracy) を計算する
    accuracy = accuracy_score(y_valid, y_pred)
    print(accuracy)


if __name__ == '__main__':
    main()

また、ホールドアウト検証するときは評価用データとはまた別に検証用データを用意する必要がある点にも注意しよう。 評価用データで得られた精度はパラメータの調整に使ってしまっているので、それで確認しても正しい検証はできない。

上記を実行すると最大 1000 ラウンドまでいくはずが性能が頭打ちになって 55 で停止していることがわかる。

$ python mes.py 
[LightGBM] [Info] Total Bins 89
[LightGBM] [Info] Number of data: 112, number of used features: 4
...(snip)...
[LightGBM] [Warning] No further splits with positive gain, best gain: -inf
[65]   valid_0's multi_logloss: 0.060976
Early stopping, best iteration is:
[55]  valid_0's multi_logloss: 0.0541825
1.0

最終的にホールドアウト検証で精度が 100% として得られている。 ただ、これはちょっと分割を重ねすぎてデータが少なくなりすぎたせいかも。

特徴量の重要度の可視化

LightGBM では各特徴量がどれくらい予測に寄与したのか数値で確認できる。

次のサンプルコードでは lightgbm.plot_importance() 関数を使って特徴量の重要度を棒グラフでプロットしている。

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

import lightgbm as lgb

from sklearn import datasets
from sklearn.model_selection import train_test_split

from matplotlib import pyplot as plt

"""LightGBM を使った特徴量の重要度の可視化"""


def main():
    # Iris データセットを読み込む
    iris = datasets.load_iris()
    X, y = iris.data, iris.target

    # 訓練データとテストデータに分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y)

    # データセットを生成する
    lgb_train = lgb.Dataset(X_train, y_train, feature_name=iris.feature_names)
    lgb_eval = lgb.Dataset(X_test, y_test, reference=lgb_train)

    # LightGBM のハイパーパラメータ
    lgbm_params = {
        # 多値分類問題
        'objective': 'multiclass',
        # クラス数は 3
        'num_class': 3,
    }

    # 上記のパラメータでモデルを学習する
    model = lgb.train(lgbm_params, lgb_train, valid_sets=lgb_eval, num_boost_round=40)

    # 特徴量の重要度をプロットする
    lgb.plot_importance(model, figsize=(12, 6))
    plt.show()


if __name__ == '__main__':
    main()

上記に適当な名前をつけて実行しよう。

$ python mci.py

すると、次のようなグラフが得られる。 f:id:momijiame:20180501073242p:plain

上記を見ると Iris を識別するための特徴量としては Petal Width と Petal Length が重要なことが分かる。

回帰問題 (Boston データセット)

続いては Boston データセットを使った回帰問題に取り組んでみよう。 データセットには住宅価格を予測する Boston データセットを用いた。

早速だけどサンプルコードは次の通り。 多値分類問題とは、学習時に渡すパラメータしか違わない。 具体的には目的 (Objective) に regression を渡して、評価関数に rmse (Root Mean Squared Error) を指定している。

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

import lightgbm as lgb

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error

import numpy as np

"""LightGBM を使った回帰のサンプルコード"""


def main():
    # Boston データセットを読み込む
    boston = datasets.load_boston()
    X, y = boston.data, boston.target

    # 訓練データとテストデータに分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y)

    # データセットを生成する
    lgb_train = lgb.Dataset(X_train, y_train)
    lgb_eval = lgb.Dataset(X_test, y_test, reference=lgb_train)

    # LightGBM のハイパーパラメータ
    lgbm_params = {
        # 回帰問題
        'objective': 'regression',
        # RMSE (平均二乗誤差平方根) の最小化を目指す
        'metric': 'rmse',
    }

    # 上記のパラメータでモデルを学習する
    model = lgb.train(lgbm_params, lgb_train, valid_sets=lgb_eval)

    # テストデータを予測する
    y_pred = model.predict(X_test, num_iteration=model.best_iteration)

    # RMSE を計算する
    mse = mean_squared_error(y_test, y_pred)
    rmse = np.sqrt(mse)
    print(rmse)


if __name__ == '__main__':
    main()

上記に適当な名前をつけて実行しよう。

$ python rb.py | tail -n 1
2.686275348587732

テストデータに対して RMSE が約 2.68 として得られた。 尚、訓練データとテストデータの分けられ方によって毎回出る値が異なる点については最初の問題と同じ。

scikit-learn インターフェース

続いては回帰問題を scikit-learn インターフェースで解いてみる。

サンプルコードは次の通り。 scikit-learn インターフェースにおいて回帰問題を解くときは lightgbm.LGBMRegressor クラスを使う。

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

import lightgbm as lgb

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error

import numpy as np

"""LightGBM を使った回帰のサンプルコード (scikit-learn interface)"""


def main():
    # Boston データセットを読み込む
    boston = datasets.load_boston()
    X, y = boston.data, boston.target

    # 訓練データとテストデータに分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y)

    # 上記のパラメータでモデルを学習する
    model = lgb.LGBMRegressor()
    model.fit(X_train, y_train)

    # テストデータを予測する
    y_pred = model.predict(X_test)

    # RMSE を計算する
    mse = mean_squared_error(y_test, y_pred)
    rmse = np.sqrt(mse)
    print(rmse)


if __name__ == '__main__':
    main()

実行結果の内容については先ほどと変わらないので省略する。

二値分類問題 (Breast Cancer データセット)

続いては Breast Cancer データセットを使った二値分類問題について。

サンプルコードは次の通り。 これまでの内容からも分かる通り、学習において変更すべき点は渡すパラメータ部分のみ。 今度は目的として binary を指定する。

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

import lightgbm as lgb

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn import metrics

"""LightGBM を使った二値分類のサンプルコード"""


def main():
    # Breast Cancer データセットを読み込む
    bc = datasets.load_breast_cancer()
    X, y = bc.data, bc.target

    # 訓練データとテストデータに分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y)

    # データセットを生成する
    lgb_train = lgb.Dataset(X_train, y_train)
    lgb_eval = lgb.Dataset(X_test, y_test, reference=lgb_train)

    # LightGBM のハイパーパラメータ
    lgbm_params = {
        # 二値分類問題
        'objective': 'binary',
        # AUC の最大化を目指す
        'metric': 'auc',
    }

    # 上記のパラメータでモデルを学習する
    model = lgb.train(lgbm_params, lgb_train, valid_sets=lgb_eval)

    # テストデータを予測する
    y_pred = model.predict(X_test, num_iteration=model.best_iteration)

    # AUC (Area Under the Curve) を計算する
    fpr, tpr, thresholds = metrics.roc_curve(y_test, y_pred)
    auc = metrics.auc(fpr, tpr)
    print(auc)


if __name__ == '__main__':
    main()

上記に名前をつけて実行しよう。

$ python bc.py | tail -n 1
0.9920212765957447

AUC として 99.2% という結果が得られた。

scikit-learn インターフェース

同様に scikit-learn インターフェースからも使ってみる。 とはいえ、これに関しては最初に紹介した Iris データセットを使った多値分類問題と変わらない。 lightgbm.LGBMClassifier を使えば二値問題も多値問題も同じように扱うことができる。

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

import lightgbm as lgb

from sklearn import datasets
from sklearn.model_selection import train_test_split
from sklearn import metrics

"""LightGBM を使った二値分類のサンプルコード (scikit-learn interface)"""


def main():
    # Breast Cancer データセットを読み込む
    bc = datasets.load_breast_cancer()
    X, y = bc.data, bc.target

    # 訓練データとテストデータに分割する
    X_train, X_test, y_train, y_test = train_test_split(X, y)

    # 上記のパラメータでモデルを学習する
    model = lgb.LGBMClassifier()
    model.fit(X_train, y_train)

    # テストデータを予測する
    y_pred = model.predict(X_test)

    # AUC (Area Under the Curve) を計算する
    fpr, tpr, thresholds = metrics.roc_curve(y_test, y_pred)
    auc = metrics.auc(fpr, tpr)
    print(auc)


if __name__ == '__main__':
    main()

実行結果の内容については先ほどと変わらない。

まとめ

今回は勾配ブースティングアルゴリズムを扱うフレームワーク LightGBM を試してみた。 使ってみて、勾配ブースティングによる性能の高さはもちろん、細部まで使いやすさに配慮されている印象を受けた。 同じアルゴリズムを分類にも回帰にも応用できる上、CV や特徴量の重要度まで確認できる。 計算量も Deep Learning ほど大きくないし、最近のコンペで Winning Solution を獲得した実績も多い。 これは Kaggle で流行る理由もうなずけるね。

Kaggleで勝つデータ分析の技術

Kaggleで勝つデータ分析の技術

vagrant-hosts プラグインを使ってホスト名を名前解決する

Vagrant では一つの Vagrantfile を使って複数の仮想マシンを管理することもできる。 そういったとき、それぞれの仮想マシンでお互いのホスト名が解決できると扱いやすくなる。 それを実現するには、自前でプロビジョニングを設定する以外にも vagrant-hosts というプラグインを使うと楽ができる。 今回は、これまでやっていた自前のプロビジョニングと、それを自動化してくれる vagrant-hosts プラグインについて紹介する。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.13.4
BuildVersion:   17E202
$ vagrant --version    
Vagrant 2.0.4
$ vboxmanage | head -n 1
Oracle VM VirtualBox Command Line Management Interface Version 5.2.10

一つの Vagrantfile で複数の仮想マシンを扱う上での問題点

複数のマシンで構成されるシステムの検証で Vagrant を使うときは、次のように一つの Vagrantfile で複数の仮想マシンを取り扱う。 この Vagrantfile を使えば vm1 と vm2 という名前の二つの仮想マシンが Vagrant で管理できる。 ポイントとしては Private Network の設定を入れるところで、こうするとお互いにその IP アドレスを使って通信できるようになる。

$ cat << 'EOF' > Vagrantfile 
# -*- mode: ruby -*-
# vi: set ft=ruby :

# All Vagrant configuration is done below. The "2" in Vagrant.configure
# configures the configuration version (we support older styles for
# backwards compatibility). Please don't change it unless you know what
# you're doing.
Vagrant.configure("2") do |config|

  config.vm.define :vm1 do |vm1|
    vm1.vm.box = "bento/ubuntu-16.04"
    vm1.vm.network "private_network", ip: "192.168.33.10"
  end

  config.vm.define :vm2 do |vm2|
    vm2.vm.box = "bento/ubuntu-16.04"
    vm2.vm.network "private_network", ip: "192.168.33.11"
  end

end
EOF

それでは上記の Vagrantfile を使って実際に仮想マシンを起動してみよう。

$ vagrant up

起動し終わったら、試しに vm1 から vm2 に向けて ping を打ってみる。 宛先は Private Network で vm2 に割り振られた IP アドレスの 192.168.33.11 を使う。

$ vagrant ssh vm1 -c "ping -c 3 192.168.33.11"
PING 192.168.33.11 (192.168.33.11) 56(84) bytes of data.
64 bytes from 192.168.33.11: icmp_seq=1 ttl=64 time=0.678 ms
64 bytes from 192.168.33.11: icmp_seq=2 ttl=64 time=0.664 ms
64 bytes from 192.168.33.11: icmp_seq=3 ttl=64 time=0.566 ms

--- 192.168.33.11 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2003ms
rtt min/avg/max/mdev = 0.566/0.636/0.678/0.049 ms
Connection to 127.0.0.1 closed.

ちゃんと届いた。

ただし、この状態では vm1 から vm2 に対してホスト名を使った通信はできない。 これは vm1 の DNS リゾルバに vm2 に関する情報が登録されていないため。

$ vagrant ssh vm1 -c "ping -c 3 vm2"
ping: unknown host vm2
Connection to 127.0.0.1 closed.

まあ、これでも問題はないんだけどやっぱりホスト名を使って通信をしたいよねと思う。

従来の解決方法 (自前でのプロビジョニング)

続いては、これまでにやっていた自前でのプロビジョニングについて。 先ほどからの変更点は Shell を使ったプロビジョニングを登録しているところ。

$ cat << 'EOF' > Vagrantfile 
# -*- mode: ruby -*-
# vi: set ft=ruby :

# All Vagrant configuration is done below. The "2" in Vagrant.configure
# configures the configuration version (we support older styles for
# backwards compatibility). Please don't change it unless you know what
# you're doing.
Vagrant.configure("2") do |config|

  config.vm.define :vm1 do |vm1|
    vm1.vm.box = "bento/ubuntu-16.04"
    vm1.vm.network "private_network", ip: "192.168.33.10"
    vm1.vm.provision "shell" do |s|
      s.path = "setup.sh"
    end
  end

  config.vm.define :vm2 do |vm2|
    vm2.vm.box = "bento/ubuntu-16.04"
    vm2.vm.network "private_network", ip: "192.168.33.11"
    vm2.vm.provision "shell" do |s|
      s.path = "setup.sh"
    end
  end

end
EOF

続いて上記で参照している setup.sh を用意する。 内容としては /etc/hosts に vm1 と vm2 の情報を書き込むものになっている。

$ cat << 'EOF_' > setup.sh 
#!/bin/sh

set -x

: "Add hostname to /etc/hosts" && {
  grep vm1 /etc/hosts >/dev/null
  if [ $? -ne 0 ]; then
      cat << 'EOF' | sudo tee -a /etc/hosts
192.168.33.10 vm1
192.168.33.11 vm2
EOF
  fi
}
EOF_

/etc/hosts に IP アドレスとホスト名を登録することで DNS リゾルバが名前を解決できるようになる。

実際にプロビジョニングを実行してみよう。

$ vagrant provision

プロビジョニングが終わったら vm1 から vm2 に対して ping を打ってみる。

$ vagrant ssh vm1 -c "ping -c 3 vm2"
PING vm2 (192.168.33.11) 56(84) bytes of data.
64 bytes from vm2 (192.168.33.11): icmp_seq=1 ttl=64 time=0.772 ms
64 bytes from vm2 (192.168.33.11): icmp_seq=2 ttl=64 time=0.618 ms
64 bytes from vm2 (192.168.33.11): icmp_seq=3 ttl=64 time=0.587 ms

--- vm2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2004ms
rtt min/avg/max/mdev = 0.587/0.659/0.772/0.080 ms
Connection to 127.0.0.1 closed.

ちゃんと名前解決できて疎通がとれている。

ただ、これだと環境に合わせて setup.sh を作らないといけないしめんどくさい。

vagrant-hosts プラグインを使った場合

vagrant-hosts プラグインを使うと前述した問題点を解消できる。

まずはプラグインをインストールしよう。

$ vagrant plugin install vagrant-hosts

vagrant-hosts プラグインに対応させた Vagrantfile を次に示す。 ポイントは vagrant-hosts を使ったプロビジョニングが登録されているところ。

$ cat << 'EOF' > Vagrantfile 
# -*- mode: ruby -*-
# vi: set ft=ruby :

# All Vagrant configuration is done below. The "2" in Vagrant.configure
# configures the configuration version (we support older styles for
# backwards compatibility). Please don't change it unless you know what
# you're doing.
Vagrant.configure("2") do |config|

  config.vm.define :vm1 do |vm1|
    vm1.vm.box = "bento/ubuntu-16.04"
    vm1.vm.network "private_network", ip: "192.168.33.10"
    vm1.vm.provision :hosts, :sync_hosts => true
  end

  config.vm.define :vm2 do |vm2|
    vm2.vm.box = "bento/ubuntu-16.04"
    vm2.vm.network "private_network", ip: "192.168.33.11"
    vm2.vm.provision :hosts, :sync_hosts => true
  end

end
EOF

先ほど作った仮想マシンには既に /etc/hosts にホスト名が登録されてしまっているので、一旦作り直そう。

$ vagrant destroy -f && vagrant up

名前解決はキャッシュが効くので、上手く動いていたと思ったらキャッシュでした、というオチが起こりやすい。 ちなみに解決に失敗した情報もキャッシュされることがある。(ネガティブキャッシュという)

仮想マシンを作り直したら、先ほどと同じように vm1 から vm2 に ping を打ってみよう。

$ vagrant ssh vm1 -c "ping -c 3 vm2"
PING vm2 (192.168.33.11) 56(84) bytes of data.
64 bytes from vm2 (192.168.33.11): icmp_seq=1 ttl=64 time=0.655 ms
64 bytes from vm2 (192.168.33.11): icmp_seq=2 ttl=64 time=0.781 ms
64 bytes from vm2 (192.168.33.11): icmp_seq=3 ttl=64 time=0.658 ms

--- vm2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2005ms
rtt min/avg/max/mdev = 0.655/0.698/0.781/0.058 ms
Connection to 127.0.0.1 closed.

ちゃんと名前解決できた。

自前の仮想マシン以外の名前を登録する

先ほどの例では Vagrantfile に含まれる仮想マシンだけに限った名前解決をした。 とはいえ、それ以外にも IP アドレスに名前をつけたいといった場面はあるはず。 続いてはそれを vagrant-hosts プラグインで扱ってみることにしよう。

早速、サンプルとなる Vagrantfile を以下に示す。 この中では Google Public DNS (Primary) の IP アドレスである 8.8.8.8 を google-dns という名前で解決できるようにしている。

$ cat << 'EOF' > Vagrantfile 
# -*- mode: ruby -*-
# vi: set ft=ruby :

# All Vagrant configuration is done below. The "2" in Vagrant.configure
# configures the configuration version (we support older styles for
# backwards compatibility). Please don't change it unless you know what
# you're doing.
Vagrant.configure("2") do |config|

  config.vm.define :vm1 do |vm1|
    vm1.vm.box = "bento/ubuntu-16.04"
    vm1.vm.network "private_network", ip: "192.168.33.10"
    vm1.vm.provision :hosts do |provisioner|
      provisioner.autoconfigure = true
      provisioner.sync_hosts = true
      provisioner.add_host '8.8.8.8', ['google-dns']
    end
  end

  config.vm.define :vm2 do |vm2|
    vm2.vm.box = "bento/ubuntu-16.04"
    vm2.vm.network "private_network", ip: "192.168.33.11"
    vm2.vm.provision :hosts, :sync_hosts => true
    vm2.vm.provision :hosts do |provisioner|
      provisioner.autoconfigure = true
      provisioner.sync_hosts = true
      provisioner.add_host '8.8.8.8', ['google-dns']
    end
  end

end
EOF

追加したのは新しく解決するホスト名なので仮想マシンは使いまわすことにしよう。 vagrant-hosts プラグインを使ったプロビジョニングを実行する。

$ vagrant provision

まずは Google Public DNS (Primary) サーバに ping を打ってみよう。

$ vagrant ssh vm1 -c "ping -c 3 google-dns"
PING google-dns (8.8.8.8) 56(84) bytes of data.
64 bytes from google-dns (8.8.8.8): icmp_seq=1 ttl=63 time=4.83 ms
64 bytes from google-dns (8.8.8.8): icmp_seq=2 ttl=63 time=5.52 ms
64 bytes from google-dns (8.8.8.8): icmp_seq=3 ttl=63 time=8.03 ms

--- google-dns ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2004ms
rtt min/avg/max/mdev = 4.836/6.132/8.034/1.376 ms
Connection to 127.0.0.1 closed.

ちゃんと解決できた。

仮想マシンについても再度確認しておく。

$ vagrant ssh vm1 -c "ping -c 3 vm2" 
PING vm2 (192.168.33.11) 56(84) bytes of data.
64 bytes from vm2 (192.168.33.11): icmp_seq=1 ttl=64 time=0.322 ms
64 bytes from vm2 (192.168.33.11): icmp_seq=2 ttl=64 time=0.750 ms
64 bytes from vm2 (192.168.33.11): icmp_seq=3 ttl=64 time=0.710 ms

--- vm2 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2001ms
rtt min/avg/max/mdev = 0.322/0.594/0.750/0.193 ms
Connection to 127.0.0.1 closed.

ちゃんと解決できているね。ばっちり。

めでたしめでたし。

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 が枯渇したときに起こる状況となる。 新たにファイルを作りたいのであれば、既存のファイルを削除することで 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 |
+-------+-----+

ばっちり。