Raccoon Tech Blog [株式会社ラクーン 技術戦略部ブログ]

株式会社ラクーン 技術戦略部より、tipsやノウハウなど技術的な話題を発信いたします。

fluentd(td-agent) の導入

はじめまして。開発チームの yuzuki です。

7/1に弊社の 決済サービスPaid(ペイド) のサーバー群へ
ログ集約の改善を目的として導入した fluentd(td-agent) の導入手順などをまとめてみました。

ログ集約を改善する動機

弊社ではこれまで(今も大部分は) cron + rsync を使い、週次バッチでWebサーバー上のログファイルをファイルサーバーへ転送することで一応の集約をさせてきました。
(集約というよりはバックアップといった意味合いの方が強いかもしれません)
サーバー台数が少なかった頃はこの仕組みでも特に大きな問題はなかったと思いますが、
サービスの成長にあわせて、サーバー台数が増え、リリース時に運用系と待機系を相互に切り替える運用へ変わったことで、
ログファイルから問合せや障害などの調査を行う際に、まずログファイルを取得することに手間が掛かる状況になっていました。

下記は弊社のもう1つのサービス ファッション・雑貨向けB2Bサイトスーパーデリバリー
(以下 SUPER DELIVERY)のサーバー構成です。

sd-network


当日分のログファイルについては、まず1系と2系のどちらが運用系なのかを調べてから、
片系で4台あるサーバー全てからログファイルをscp/sftpでせっせと取得し調査していました。

過去分のログファイルについては、各サーバーでローテーションされ週次バッチでの集約を待つ状態と、
週次バッチで各サーバーからファイルサーバーへ集約された状態の2つの状態があります。
※この集約は1台のサーバーに集まるというだけで、1ファイルにまとまるわけではありません。

過去分は、運用系/待機系の切り替えも考慮すると、両方のログを調査対象とするのが確実であるため
  • 集約待ちであれば全8台のサーバーからログファイルを取得
  • 集約済みであれば1台のサーバーの8つのディレクトリからログファイルを取得
といった手間が調査の度に発生していました。

「ここ、何とかならないかな?」と、ずっと思っていたところに現れたのが fluentd です。
fluentd を各サーバーへインストールして、ログの転送を任せてしまえば
1日のログを1台のファイルサーバーの1つのファイルにまとめることが出来てしまいます。


余談:
SUPER DELIVERYが動機になっているのに、なぜPaidへの導入記事なのかというと、
先に導入のコンセンサスが取れたのがPaidだったから、というだけの理由です。
もうPaidで実績ができましたし、SUPER DELIVERYへの導入ハードルも下がったように思います。

fluentd(td-agent) とは?

"Fluentd" is an open-source tool to collect events and logs.
http://fluentd.org/

fluentd は、ほぼリアルタイムでログの読み込み、加工、転送、さらには書き込みまでも行ってくれる非常に素晴らしい Ruby ベースのミドルウェアです。
ログ以外にも、メッセージと表現されることもあります。
※cron + rsync と異なり、扱えるのはログファイルだけではありません!


fluentd はプラグインを書くことで拡張も可能で、既に多くのプラグインが開発・公開されています。
http://fluentd.org/plugin/

fluentd は標準プラグインがバンドルされた Ruby の gem です。
http://rubygems.org/gems/fluentd

td-agent は fluentd 専用の Ruby や jemalloc などがバンドルされ、
安定感のある fluentd の配布パッケージ(rpm, deb など)です。
http://docs.treasuredata.com/articles/td-agent

導入対象のシステムには Ruby 1.9.3 が入っていたこともあり、
既存機能への干渉を避けるため、td-agent を選びました。

td-agent のインストール

執筆時点の弊社では CentOS 5.x on x86_64 が主流なので、
下記のページを参考にインストールしました。
http://help.treasuredata.com/customer/portal/articles/1246904-installing-td-agent-on-rhel-and-centos
※以下、x86_64環境を前提として書いています。

まずはroot権限を持っているユーザーで下記のコマンドを実行し、ファイルを作成します。
# touch /etc/yum.repos.d/td.repo

次に、お好みのエディタで下記の通りに編集して保存します。
[treasuredata]
name=TreasureData
baseurl=http://packages.treasure-data.com/redhat/$basearch
gpgcheck=0

最後に下記のコマンドを実行し、td-agent をインストールします。
# yum install td-agent

インストール手順は上記のページに書かれている内容に従っています。

td-agent のファイル配置場所など

■td-agent の起動スクリプト
/etc/rc.d/init.d/td-agent

■td-agent の設定ファイル テンプレート
/etc/td-agent/td-agent.conf.tmpl

■ td-agent にバンドルされた Ruby のインストール先
/usr/lib64/fluent/ruby/

■ td-agent のログ出力先
/var/log/td-agent/

■ td-agent の.pid出力先
/var/run/td-agent/

fluentd 内部でのログのルーティング

プラグインについて触れる前に、タグについて簡単に説明します。

現在の fluentd v10 は内部で、タグと呼ばれる文字列でログのルーティングを行っています。
タグはピリオードで区切られた文字列で、プラグインで処理を行う度に remove_tag_prefix, add_tag_prefix などでタグの書き換えを行い、
いくつかのプラグインを経由していくのが一般的かと思います。

基本的なタグの書き換えは下記のような設定になっています。
#ログ読み込み時のタグは raw.apache.access
<source>
  type tail  #標準のin_tailプラグイン
  tag raw.apache.access
  ...
</source>

#タグが filtered.apache.access と完全一致する場合、ログをファイルに出力
#※本来は一番下に書くべき設定ですが、この位置に書いても動作することの説明のため
# あえてここに書いています
<match filtered.apache.access>
  type file  #標準のout_fileプラグイン
  ...
</match>

#タグが raw.apache.access と完全一致する場合、先頭の raw を除去し apache.access とする
#そして新しいタグ apache.access で先頭からタグのルーティングをやり直す
<match raw.apache.access>
  type my_remove_tag_prefix_filter  #このようなプラグインは存在しません
  remove_tag_prefix raw
</match>

#タグが apache.access と完全一致する場合、先頭に filtered を追加し filtered.apache.access とする
#そして新しいタグ filtered.apache.access で先頭からタグのルーティングをやり直す
<match apache.access>
  type my_add_tag_prefix_filter     #このようなプラグインは存在しません
  add_tag_prefix filtered
</match>

この例では完全一致のみ説明していますが、パターンによるマッチもサポートされています。
詳しくは下記のページにある 「*」 「**」 「{X,Y,Z}」 の3ヶ所を参照してください。
http://docs.fluentd.org/articles/config-file#2-ldquomatchrdquo-tell-fluentd-what-to-do

なお、次のメジャーバージョンである fluentd v11 ではラベルなるとか・・・?

fluentd プラグインのインストール

今回は fluent-plugin-route と fluent-plugin-rewrite-tag-filter の2つのプラグインを追加でインストールして使用しました。
この2つのプラグインを簡単に説明します。

fluent-plugin-route は、上記で説明した remove_tag_prefix, add_tag_prefix を使い
柔軟なログのルーティングを実現することが出来ます。
今回は残念ながら大した使い方をしていないため、詳しくはプラグインのページを参照してください。
https://github.com/tagomoris/fluent-plugin-route

fluent-plugin-rewrite-tag-filter は、apache httpdのmod_rewriteのようなプラグインです。
ログ内の項目に対して正規表現によるパターンマッチングを試行し、
マッチした場合、またはマッチしなかった場合に、タグの書き換えを行うことで
柔軟かつ強力なログのルーティングを実現することが出来ます。
なお、フィルタリングの用途にも適しています。
https://github.com/fluent/fluent-plugin-rewrite-tag-filter

では、上記のプラグインをインストールします。
root権限を持っているユーザーで下記のコマンドを実行します。
(※gemに関する環境変数などは設定していません)
# /usr/lib64/fluent/ruby/bin/gem install fluent-plugin-route
# /usr/lib64/fluent/ruby/bin/gem install fluent-plugin-rewrite-tag-filter

エラーが何も出なければ、
/usr/lib64/fluent/ruby/lib/ruby/gems/1.9.1/gems/
にプラグインがインストールされるはずです。
(バンドルされる Ruby のバージョンが上がれば、パスの1.9.1の部分も変わるでしょう)

導入時は /usr/lib64/fluent/ruby/bin/gem を使いましたが、
他にも /usr/lib64/fluent/ruby/bin/fluent-gem を使う方法や、
gem を /etc/td-agent/plugin/ へ配置してインストールする方法もあるようです。

サーバーの構成

Paidのユーザー向けサービスが動いているWebサーバーは2台の運用系(pd01, pd02)と、
1台の待機系(pd04)で構成されています。
残りの1台(pd03)は社内向けサービスが動いているため、今回のログ集約の対象からは除外しました。

このうち運用系をログ転送元、待機系をログ転送(集約)先としてセットアップしました。
(残念ながら、この時点ではまだログ転送先がSPOFです・・・)


paid-network

■ログ集約の構成変更前
ログ集約・変更前

■ログ集約の構成変更後
ログ集約・変更後

起動スクリプトと設定ファイルの構成

Paidのサーバー群には、原則として全てのサーバーが全ての役割を果たせるようセットアップしておくというルールがあるため、ログ転送元とログ転送先に同じファイルを配置しておく必要があります。
他のサービスへの展開なども含め、色々な試行錯誤を繰り返した結果、下記の構成に落ち着きました。
(production環境以外に、staging環境と、test環境用の設定ファイルも作成しました)

■ 起動スクリプト
/etc/rc.d/init.d/
├ td-agent-paid-web-aggregator
└ td-agent-paid-web-forwarder

■ 設定ファイル
/etc/td-agent/
├ config.d/
│ └ paid/
│    ├ forward/
│    │ ├ production/
│    │ │ ├ aggregate.conf
│    │ │ └ forward.conf
│    │ ├ staging/
│    │ │ ├ aggregate.conf
│    │ │ └ forward.conf
│    │ └ test/
│    │    ├ aggregate.conf
│    │    └ forward.conf
│    ├ input/
│    │ ├ java-pd.conf
│    │ └ java-pd-web-service.conf
│    └ output/
│       ├ java-pd.conf
│       └ java-pd-web-service.conf
├ td-agent-common.conf
├ td-agent-paid-web-aggregator-production.conf
├ td-agent-paid-web-aggregator-staging.conf
├ td-agent-paid-web-aggregator-test.conf
├ td-agent-paid-web-forwarder-production.conf
├ td-agent-paid-web-forwarder-staging.conf
└ td-agent-paid-web-forwarder-test.conf

起動スクリプト


■ ログ転送元の起動スクリプト
/etc/rc.d/init.d/td-agent-paid-web-forwarder

サービスへの登録はroot権限を持っているユーザーで下記を実行します。
# chkconfig --add td-agent-paid-web-forwarder
# chkconfig --level 345 td-agent-paid-web-forwarder on

起動スクリプトは、実行環境毎に読み込む設定ファイルを変えたかったため、
/etc/rc.d/init.d/td-agent を少々改変しています。主な改変箇所はスクリプト内の赤字部分です。

※fluentd の新しいバージョンでは、configtest オプションが実装されていますが、
 導入時のバージョンは td-agent 1.1.14(fluentd 0.10.35) であったため
 ここに書いてある起動スクリプトには configtest オプションが含まれていません。


■■ ログ転送元の起動スクリプトの内容
#!/bin/bash
#
# /etc/rc.d/init.d/td-agent-paid-web-forwarder
#
# chkconfig: - 80 20
# description: td-agent
# processname: td-agent-paid-web-forwarder
# pidfile: /var/run/td-agent/td-agent-paid-web-forwarder.pid
#
### BEGIN INIT INFO
# Provides:          td-agent
# Default-Stop:      0 1 6
# Required-Start:    $local_fs
# Required-Stop:     $local_fs
# Short-Description: td-agent's init script
# Description:       td-agent is a data collector
### END INIT INFO

# Source function library.
. /etc/init.d/functions

name="td-agent-paid-web-forwarder"
prog=$name
hostname=`hostname -s`
if [[ $hostname =~ ^pd[0-9]{2}$ ]]; then
    mode=production
elif [[ $hostname =~ ^stg\-pd[0-9]{2}$ ]]; then
    mode=staging
else
    mode=test
fi

fluentd=/usr/lib/fluent/ruby/bin/fluentd
if [ -f "/usr/lib64/fluent/ruby/bin/fluentd" ]; then
    fluentd=/usr/lib64/fluent/ruby/bin/fluentd
fi

if [ -f /etc/sysconfig/$prog ]; then
    . /etc/sysconfig/$prog
fi

RUN_USER=td-agent
RUN_GROUP=td-agent
PIDFILE=/var/run/td-agent/$prog.pid
CONF_FILE=/etc/td-agent/$prog-$mode.conf
LOG_FILE=/var/log/td-agent/$prog.log
FLUENTD_ARGS="--group $RUN_GROUP -d $PIDFILE -c $CONF_FILE -o $LOG_FILE -q"

if [ -n "${PIDFILE}" ]; then
    mkdir -p $(dirname ${PIDFILE})
    chown -R $RUN_USER:$RUN_GROUP $(dirname ${PIDFILE})
fi

# use jemalloc to avoid fragmentation
if [ -f "/usr/lib/fluent/jemalloc/lib/libjemalloc.so" ]; then
    export LD_PRELOAD=/usr/lib/fluent/jemalloc/lib/libjemalloc.so
fi
if [ -f "/usr/lib64/fluent/jemalloc/lib/libjemalloc.so" ]; then
    export LD_PRELOAD=/usr/lib64/fluent/jemalloc/lib/libjemalloc.so
fi

RETVAL=0

start() {
    echo -n "Starting $name[$mode]: "
    daemon --pidfile=$PIDFILE --user=$RUN_USER $fluentd "$FLUENTD_ARGS"
    RETVAL=$?
    echo
    [ $RETVAL -eq 0 ] && touch /var/lock/subsys/$prog
    return $RETVAL
}

stop() {
    echo -n "Shutting down $name[$mode]: "
    if [ -e "${PIDFILE}" ]; then
        killproc -p ${PIDFILE} || killproc $prog
    else
        killproc $prog
    fi
    RETVAL=$?
    echo
    [ $RETVAL -eq 0 ] && rm -f $PIDFILE && rm -f /var/lock/subsys/$prog
    return $RETVAL
}

restart() {
    stop
    start
}

reload() {
    echo -n "Reloading $name[$mode]: "
    killproc -p $PIDFILE $prog -HUP
    echo
}

case "$1" in
    start)
        start
        ;;
    stop)
        stop
        ;;
    restart)
        restart
        ;;
    reload)
        reload
        ;;
    condrestart)
        [ -f /var/lock/subsys/$prog ] && restart || :
        ;;
    status)
        status -p $PIDFILE $prog
        ;;
    *)
        echo "Usage: $prog {start|stop|reload|restart|condrestart|status}"
        exit 1
        ;;
esac
exit $?


■ログ転送先の起動スクリプト
/etc/rc.d/init.d/td-agent-paid-web-aggregator

サービスへの登録はroot権限を持っているユーザーで下記を実行します。
# chkconfig --add td-agent-paid-web-aggregator
# chkconfig --level 345 td-agent-paid-web-aggregator on

■■ログ転送先の起動スクリプトの内容
   forwarder を aggregator へ置換しただけです。
#!/bin/bash
#
# /etc/rc.d/init.d/td-agent-paid-web-aggregator
#
# chkconfig: - 80 20
# description: td-agent
# processname: td-agent-paid-web-aggregator
# pidfile: /var/run/td-agent/td-agent-paid-web-aggregator.pid
#
### BEGIN INIT INFO
# Provides:          td-agent
# Default-Stop:      0 1 6
# Required-Start:    $local_fs
# Required-Stop:     $local_fs
# Short-Description: td-agent's init script
# Description:       td-agent is a data collector
### END INIT INFO

# Source function library.
. /etc/init.d/functions

name="td-agent-paid-web-aggregator"
prog=$name
hostname=`hostname -s`
if [[ $hostname =~ ^pd[0-9]{2}$ ]]; then
    mode=production
elif [[ $hostname =~ ^stg\-pd[0-9]{2}$ ]]; then
    mode=staging
else
    mode=test
fi

fluentd=/usr/lib/fluent/ruby/bin/fluentd
if [ -f "/usr/lib64/fluent/ruby/bin/fluentd" ]; then
    fluentd=/usr/lib64/fluent/ruby/bin/fluentd
fi

if [ -f /etc/sysconfig/$prog ]; then
    . /etc/sysconfig/$prog
fi

RUN_USER=td-agent
RUN_GROUP=td-agent
PIDFILE=/var/run/td-agent/$prog.pid
CONF_FILE=/etc/td-agent/$prog-$mode.conf
LOG_FILE=/var/log/td-agent/$prog.log
FLUENTD_ARGS="--group $RUN_GROUP -d $PIDFILE -c $CONF_FILE -o $LOG_FILE -q"

if [ -n "${PIDFILE}" ]; then
    mkdir -p $(dirname ${PIDFILE})
    chown -R $RUN_USER:$RUN_GROUP $(dirname ${PIDFILE})
fi

# use jemalloc to avoid fragmentation
if [ -f "/usr/lib/fluent/jemalloc/lib/libjemalloc.so" ]; then
    export LD_PRELOAD=/usr/lib/fluent/jemalloc/lib/libjemalloc.so
fi
if [ -f "/usr/lib64/fluent/jemalloc/lib/libjemalloc.so" ]; then
    export LD_PRELOAD=/usr/lib64/fluent/jemalloc/lib/libjemalloc.so
fi

RETVAL=0

start() {
    echo -n "Starting $name[$mode]: "
    daemon --pidfile=$PIDFILE --user=$RUN_USER $fluentd "$FLUENTD_ARGS"
    RETVAL=$?
    echo
    [ $RETVAL -eq 0 ] && touch /var/lock/subsys/$prog
    return $RETVAL
}

stop() {
    echo -n "Shutting down $name[$mode]: "
    if [ -e "${PIDFILE}" ]; then
        killproc -p ${PIDFILE} || killproc $prog
    else
        killproc $prog
    fi
    RETVAL=$?
    echo
    [ $RETVAL -eq 0 ] && rm -f $PIDFILE && rm -f /var/lock/subsys/$prog
    return $RETVAL
}

restart() {
    stop
    start
}

reload() {
    echo -n "Reloading $name[$mode]: "
    killproc -p $PIDFILE $prog -HUP
    echo
}

case "$1" in
    start)
        start
        ;;
    stop)
        stop
        ;;
    restart)
        restart
        ;;
    reload)
        reload
        ;;
    condrestart)
        [ -f /var/lock/subsys/$prog ] && restart || :
        ;;
    status)
        status -p $PIDFILE $prog
        ;;
    *)
        echo "Usage: $prog {start|stop|reload|restart|condrestart|status}"
        exit 1
        ;;
esac
exit $?

設定ファイル

■ログ転送(送信)用の設定ファイル
/etc/td-agent/td-agent-paid-web-forwarder-production.conf
/etc/td-agent/td-agent-paid-web-forwarder-staging.conf
/etc/td-agent/td-agent-paid-web-forwarder-test.conf
ログ転送元の起動スクリプト /etc/rc.d/init.d/td-agent-paid-web-forwarder から
実行環境毎に、上記の3ファイルのうち1つが読み込まれます。

■■ ログ転送(送信)用の設定ファイルの内容
include config.d/paid/input/*.conf
include config.d/paid/forward/production/forward.conf
include td-agent-common.conf
上記は /etc/td-agent/td-agent-paid-web-forwarder-production.conf の内容ですが
staging, test環境用はproductionの部分を置換しただけです。

これは include ディレクティブで外部の設定ファイルを読み込むだけの設定です。

■ ログ転送(受信)用の設定ファイル
/etc/td-agent/td-agent-paid-web-aggregator-production.conf
/etc/td-agent/td-agent-paid-web-aggregator-staging.conf
/etc/td-agent/td-agent-paid-web-aggregator-test.conf
ログ転送先の起動スクリプト /etc/rc.d/init.d/td-agent-paid-web-aggregator から
実行環境毎に、 上記の3ファイルのうち1つが読み込まれます。

■■ ログ転送(受信)用の設定ファイルの内容
include config.d/paid/forward/production/aggregate.conf
include config.d/paid/output/*.conf
include td-agent-common.conf
上記は /etc/td-agent/td-agent-paid-web-aggregator-production.conf の内容ですが
これもstaging, test環境用はproductionの部分を置換しただけです。

同じく include ディレクティブで外部の設定ファイルを読み込むだけの設定です。

■共通の設定ファイル
/etc/td-agent/td-agent-common.conf
全体で共通して読み込む設定ファイルです。commonというネーミングにマサカリが飛んできそうですね。

■■共通の設定ファイルの内容
<match null>
  type null
</match>

<match fluent.**>
  type file
  path /var/log/td-agent/fluent
</match>

<match **>
  type file
  path /var/log/td-agent/unmatched
  compress gz
</match>

上から順に解説します。

<match null>
  type null
</match>
上記は、out_nullプラグインを使い、"null"というタグを持つログを破棄するための設定です。
意図的に破棄したいログに対して使おうとしていましたが、導入時は使いませんでした。
/dev/null のようなものですね。

<match fluent.**>
  type file
  path /var/log/td-agent/fluent
</match>
上記は、out_fileプラグインを使い、fluentd 内部のログを
/var/log/td-agent/fluent.20131118_0.log
のような名前のファイルに出力するための設定です。

<match **>
  type file
  path /var/log/td-agent/unmatched
  compress gz
</match>
上記は、同じくout_fileプラグインを使い、いずれにもマッチしなかったタグを持つログを
/var/log/td-agent/unmatched.20131118_0.log
のような名前のファイルに出力するための設定です。
どれだけ出力されるのかが不明だったため、ローテート時にgzip圧縮し、拡張子に.gzが追加される設定を有効にしています。

※失敗談: 実は7/1の導入時は(実は現在も・・・)全体的に
 path /var/log/td-agent/fluent.log
 path /var/log/td-agent/unmatched.log
 のように、path の末尾に.logを書いてしまっていたために、作成されるログファイル名が
 fluent.log.20130701_0.log
 のように.logが重なってしまっていました・・・

 下記のページに path の仕様がちゃんと書かれています。真面目に読まなきゃダメですね・・・
 http://docs.fluentd.org/articles/out_file
 > The Path of the file. The actual path is path + time + ”.log”.


Paidのログファイル
弊社が運営するサービスの多くはJavaで開発されています。
フレームワークはSeasar2, S2Dao, Hibernate, S2Struts, Tilesといったところで、
Apache Tomcat上で動作しています。
(最近はPlay Framework、Ruby on Railsを使った開発もあります)

このため、今回はアプリケーション内で出力しているアクセスログファイルを対象とします。
以下、「Paidのログファイル」とは、このアクセスログファイルのことを指します。

アクセスログファイルは下記のようなフォーマットになっています。
(実際にはユーザーIDや、その他の情報も含まれていますが、ここでは省略しています)
2013-07-01 16:35:02,665 HEAD http://paid.jp/v/contents/index.jsp 7
2013-07-01 16:36:01,675 HEAD http://paid.jp/v/contents/index.jsp 6

Paidのログファイルを読み込みための設定ファイル
/etc/td-agent/config.d/paid/input/java-pd.conf

■■Paidのログファイルを読み込みための設定ファイルの内容
   ※path, formatは改変しています。
# input ( -> raw.paid.tomcat-pd.tomcat.access)
<source>
  type tail
  path /var/log/tomcat6-pd/access.log
  pos_file /var/log/td-agent/paid_tomcat-pd_tomcat_access.pos
  tag raw.paid.tomcat-pd.tomcat.access
  format /^(?<time>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}) (?<method>[A-Z]+) (?<url>.+) (?<response_millis>\d+)$/
  time_format %Y-%m-%d %H:%M:%S,%L
</source>

# filter (raw.paid.tomcat-pd.tomcat.access -> forward.paid.tomcat-pd.tomcat.access.${hostname})
<match raw.paid.tomcat-pd.tomcat.access>
  type rewrite_tag_filter
  remove_tag_prefix raw
  hostname_command hostname -s

  rewriterule1 method .* forward.${tag}.${hostname}
</match>

上から順に解説します。

# input ( -> raw.paid.tomcat-pd.tomcat.access)
<source>
  type tail
  path /var/log/tomcat6-pd/access.log
  pos_file /var/log/td-agent/paid_tomcat-pd_tomcat_access.pos
  tag raw.paid.tomcat-pd.tomcat.access
  format /^(?<time>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}) (?<method>[A-Z]+) (?<url>.+) (?<response_millis>\d+)$/
  time_format %Y-%m-%d %H:%M:%S,%L
</source>
上記は、in_tailプラグインを使い、tail -F コマンドのように
ログファイルに追記される度に書き込まれたログ(1行単位)を読み込むための設定です。
ログファイルがローテートされても、もちろん追従してくれます。

# filter (raw.paid.tomcat-pd.tomcat.access -> forward.paid.tomcat-pd.tomcat.access.${hostname})
<match raw.paid.tomcat-pd.tomcat.access>
  type rewrite_tag_filter
  remove_tag_prefix raw
  hostname_command hostname -s

  rewriterule1 method .* forward.${tag}.${hostname}
</match>
上記は、fluent-plugin-rewrite-tag-filterプラグインを使い、最初にタグの先頭の raw を除去し、
次にタグの先頭に forward を、タグの末尾に ${hostname} を付加しています。
${hostname} は hostname_command で指定した hostname -s コマンドの実行結果に置き換わります。
この設定はログの転送後に、そのログがどのサーバーから転送されてきたのかを残すための準備です。

これは /etc/td-agent/config.d/paid/input/java-pd.conf と大して変わらないので省略します。
/etc/td-agent/config.d/paid/input/java-pd-web-service.conf

Paidのログファイルを転送(送信)するための設定ファイル
/etc/td-agent/config.d/paid/forward/{production,staging,test}/forward.conf

■■Paidのログファイルを転送(送信)するための設定ファイルの内容
   ※hostは改変しています。
# filter (forward.paid.*.*.access.${hostname} -> production.forward.paid.*.*.access.${hostname})
<match forward.paid.*.*.access.*>
  type route

  <route forward.paid.*.*.access.{pd01,pd02,pd03,pd04}>
    add_tag_prefix production
  </route>
</match>

# forward-output (production.forward.paid.*.*.access.${hostname} -> )
<match production.forward.paid.*.*.access.*>
  type forward
  heartbeat_type tcp

  buffer_type file
  buffer_path /var/log/td-agent/forward-paid.buf

  <server>
    name pd04
    host 192.168.2.4
  </server>
  #<server>
  #  name pd03
  #  host 192.168.2.3
  #  standby
  #</server>

  <secondary>
    type file
    path /var/log/td-agent/forward-paid.err
    compress gz
  </secondary>
</match>
上記は /etc/td-agent/config.d/paid/forward/production/forward.conf の内容ですが
staging, test環境用はproductionの部分、route内のホスト名、hostの部分を置換しただけです。

上から順に解説します。

# filter (forward.paid.*.*.access.${hostname} -> production.forward.paid.*.*.access.${hostname})
<match forward.paid.*.*.access.*>
  type route

  <route forward.paid.*.*.access.{pd01,pd02,pd03,pd04}>
    add_tag_prefix production
  </route>
</match>
上記は、fluent-plugin-routeプラグインを使い、転送前にホスト名の簡易チェックを行った後に、
チェックがOKであればタグの先頭に production を付加しています。
チェックがNGであればunmatchedのログファイルに出力されます。

※転送時の送信側と受信側で、タグに付加された実行環境毎に異なる文字列をチェックすることで
 誤ってテスト環境から本番環境の fluentd にログを転送するリスクを減らそうとする目論見です。
 本来は iptables などで接続制限を加えるべきなのでしょうが、現在はそこまでしていません。

# forward-output (production.forward.paid.*.*.access.${hostname} -> )
<match production.forward.paid.*.*.access.*>
  type forward
  heartbeat_type tcp

  buffer_type file
  buffer_path /var/log/td-agent/forward-paid.buf

  <server>
    name pd04
    host 192.168.2.4
  </server>
  #<server>
  #  name pd03
  #  host 192.168.2.3
  #  standby
  #</server>

  <secondary>
    type file
    path /var/log/td-agent/forward-paid.err
    compress gz
  </secondary>
</match>
上記は、out_forwardプラグインを使い、ログを転送(送信)するための設定です。
パフォーマンスはかなり落ちますが、転送(送信)時にファイルバッファを使いログ消失を防止したり、
転送(送信)エラーとなったログをファイルへ書き込んで残したりしています。
コメントアウトされているログ転送先のスタンバイは、まだ稼働していません・・・

Paidのログファイルを転送(受信)するための設定ファイル
/etc/td-agent/config.d/paid/forward/{production,staging,test}/aggregate.conf

■■Paidのログファイルを転送(受信)するための設定ファイルの内容
# forward-input ( -> production.forward.paid.*.*.access.${hostname})
<source>
  type forward
</source>

# filter (production.forward.paid.*.*.access.${hostname} -> paid.*.*.access.${hostname})
<match production.forward.paid.*.*.access.*>
  type route

  <route production.forward.paid.*.*.access.*>
    remove_tag_prefix production.forward
  </route>
</match>
上記は /etc/td-agent/config.d/paid/forward/production/aggregate.conf の内容ですが
staging, test環境用はproductionの部分を置換しただけです。

上から順に解説します。

# forward-input ( -> production.forward.paid.*.*.access.${hostname})
<source>
  type forward
</source>
上記は、in_forwardプラグインを使い、ログを転送(受信)するための設定です。

# filter (production.forward.paid.*.*.access.${hostname} -> paid.*.*.access.${hostname})
<match production.forward.paid.*.*.access.*>
  type route

  <route production.forward.paid.*.*.access.*>
    remove_tag_prefix production.forward
  </route>
</match>
上記は、転送されてきたログのタグに、実行環境が同一であることを示す文字列が含まれているかチェックしています。
チェックがOKであれば転送(送信)前にタグの先頭に付加された production.forward を除去します。
チェックがNGであればunmatchedのログファイルに出力されます。

■転送されてきたPaidのログファイルを集約して書き込むための設定ファイル
/etc/td-agent/config.d/paid/output/java-pd.conf

■■転送されてきたPaidのログファイルを集約して書き込むための設定ファイルの内容
# output (paid.tomcat-pd.tomcat.access.${hostname} -> )
<match paid.tomcat-pd.tomcat.access.*>
  type file
  path /var/log/td-agent/aggregate_paid_tomcat-pd_tomcat_access
  compress gz
</match>
上記は、out_fileプラグインを使い、ログをファイルへ書き込む設定です。
だいたい上の方で説明済みですね。

ファイルへは、タイムスタンプ、タグ、JSONの順で、タブ文字区切りで書き込まれます。
2013-07-01T16:35:02+09:00    paid.tomcat-pd.tomcat.access.pd01    {"method":"HEAD","url":"http://paid.jp/v/contents/index.jsp","response_millis":"7"}
2013-07-01T16:36:01+09:00    paid.tomcat-pd.tomcat.access.pd02    {"method":"HEAD","url":"http://paid.jp/v/contents/index.jsp","response_millis":"6"}

これも /etc/td-agent/config.d/paid/output/java-pd.conf と大して変わらないので省略します。
/etc/td-agent/config.d/paid/output/java-pd-web-service.conf

まとめ

7/1の導入から4ヶ月以上経ちましたが、fluentd(td-agent) はノートラブルで動き続けています。
現在はPaidだけですが、次はSUPER DELIVERYへの導入を考えています。

IE8でjQueryが重いなと思ったら、やるべき3つのこと

こんにちは。なべです。

入社半年の新人 ですが、このような場をいただきましたので、入社してすぐに取り組んだInternetExplorer(以下、IE) 8のjQueryのパフォーマンス対策について書いてみたいと思います。

なぜIE8か?

このブログにたどり着くような方はHTML5をいじってみたり、普段使用するブラウザもFirefoxやChromeという場合が多いと思います。
そんな中、この記事のタイトルを見て、なぜ今さらIE8・・・と思ったのではないでしょうか?

というわけで、まずは、なぜ今IE8のパフォーマンス対策なのかを説明したいと思います。
 
sd_brws_share
右のグラフは、スーパーデリバリーにおける訪問者のブラウザのシェアを簡略化したグラフです。
グラフにもあるようにIE8はスーパーデリバリーではおよそ15.4%のユーザーが利用しています。
※2013/07時点のデータです。

IE8が多い理由としては以下のことが考えられます。
・スーパーデリバリーはBtoBサイトで、出展企業様や小売店様が仕事ととして利用されるため、会社やお店などにある仕事用のPCから利用されている。
・その場合、やはりWindowsが多く、さらに購入時にプレインストールされているブラウザを利用するケースが多いためIEが多い。
・会社内のPCは家庭にくらべて新陳代謝が緩やかなので、数年内にリリースされたバージョンが混在する。(IE8,9,10がほぼ同じ割合)
ちなみにIE6もまだ1%強使われています。
 
その前提に立つと、今後、Windows XPのサポート切れに伴って企業内で利用するPCの買い替えが進み、Windows8のシェアが上がってくるにつれて、IE10のシェアが増えてくることが予想されますが、それまでしばらくはIE8は主要ブラウザとして扱う必要があります。

さて、IE8サポートの重要性がわかったところで本題に入りたいと思います。

問題

「IE8のjQueryのパフォーマンス対策」というのは、IE8でスーパーデリバリーの商品一覧ページを開くと、画面が利用できるようになるまでに時間がかかるという問題への対応でした。
プロファイラなどで調査した結果、原因は$(".hoge")のようにクラスをセレクタに指定してjQueryオブジェクトを操作している部分で処理が重くなり、時間がかかっていることがわかりました。

それはなぜでしょうか? 

この現象の説明の前に、jQueryの仕組みを少しお話しておきます。

jQueryはブラウザ間の違いを吸収して共通のインターフェースを提供してくれるJavaScriptのライブラリです。
jQueryはどのブラウザでも同じように利用することができますが、内部的にはブラウザによって処理を分岐し、微妙な違いを吸収して、ブラウザの機能を呼び出したり、jQueryで実装したりして、ドロドロした部分を隠し、我々に使いやすいようにしてくれています。

この実装の違いに落とし穴があります。
ブラウザの機能を使える場合には、ネイティブメソッドを呼び出すことができますが、jQueryで実装となった場合は、ブラウザ上で動くJavaScriptの処理になります。
当然JavaScriptよりもネイティブメソッドのほうが速いためここでパフォーマンスの差がでます。
よって、できるだけjQueryの独自実装部分に入らないように実装することがパフォーマンス向上の一つの施策になります。

さて、IE8の話に戻ります。
ここまでの説明で想像がついているかと思いますが、IE8でクラスをセレクタに指定した処理が重いのは、IE8まではクラスセレクタで要素を取得する機能がブラウザの機能として提供されていない(=ネイティブメソッドにない)ためです。
これはJavaScriptのコンソールなどで以下をそれぞれ打つと確認することができます。
document.getElementById("aaa")
document.getElementsByTagName("aaa")
document.getElementsByClassName("aaa")
よって、jQueryではIE8でクラスをセレクタに指定された場合、JavaScriptにより、対象となる全要素をループ処理で走査するため遅くなっています。

ちなみに、このjQueryの検索ロジックのライブラリをSizzleといいます。
SizzleはjQueryの内部で使われていて、意識して利用することはほとんどないはずですが、github上で公開されているオープンソースのプロジェクトなので、より深く知りたい場合にはこちらをご覧ください。

対策

クラスセレクタを指定してjQueryオブジェクトを取得すると検索処理が実行されて重くなるわけですから、対策としては、クラスをセレクタに指定してノードを検索する処理をできるだけ避ける必要があります。
そこで、以下の3つの対策が考えられます。
     
1. そもそもクラスセレクタを使わない
2. クラスセレクタによる検索対象を減らす
3. クラスセレクタによる検索回数を減らす

それでは個々の対策を説明していきたいと思います。

1. そもそもクラスセレクタを使わない

クラスセレクタを使わずにID("#xxx")で指定できるところはIDで指定します。
IDを指定することにより、ネイティブメソッドでサポートされているgetElementByIdが使用されますので、これだけで速度は改善します。
注意しなければならないのは、IDはDocument上で一意でなければならない点です。
この制約が守れるのであれば、IDに置き換えるのが良いでしょう。

2. クラスセレクタによる検索対象を減らす

とはいえ、IDに単純に置き換えられるところばかりではありません。むしろ、少ないでしょう。
だからといって、
jQueryを使う上で、クラスを使って複数の要素をまとめて操作する機能を使わないというのは考えられません。
ですので、以降はクラスセレクタを使いつつ負荷を最小限にする手段をご紹介します。
 
まずはクラスセレクタで検索する対象を減らし、走査にかかる負荷を減らすことを考えます。
例えばbarクラスを操作したい場合、     
$(".bar")
と書くと、全ての要素が検索対象になってしまいます。
そこで、barクラスの前にかならずIDをもつ親要素があるならば、そこを基点にして     
$("#foo").find(".bar")
と指定します。こうすることで、ID(foo)の以下にある要素だけを検索対象にすることができます。
さらに必ず親要素がID:fooであることがわかっているなら、     
$("#foo").children(".bar")
と指定します。こうすることで、子要素だけに対象を絞り、孫要素より下の階層を検索対象から外すことができます。
ほかにも、next(兄弟要素の次)やsiblings(兄弟要素)、parent(親要素)など基点を中心に周辺を指定できますので活用すると良いと思います。

次に、タグ名を指定することでも負荷の軽減になります。
getElementsByTagNameもネイティブメソッドに存在するため、いきなりクラスを指定するよりも高速になります。    
$("div.bar")
と.いうようにタグと一緒にクラスを指定したり    
$("li").find(".bar")
のようにタグで親になる要素を絞ってから、その下のクラスを指定することで対象を絞ることができます。

このように、できるだけ検索対象の母数を絞ることで、負荷を軽減します。

3. クラスセレクタによる検索回数を減らす

つぎに、クラスセレクタによる重い検索処理を何度も実行して負荷が上がっている場合があります。
例えば以下のコードはどうでしょうか。
$(".bar").css("color","#fff");
$(".bar").css("border","thin solid #000")
$(".bar").width(100);
負荷の高い$(".bar")の検索を3度も実施しています。
この場合は検索をした結果を一度変数に格納し使いまわすことを考えます。
var bar=$(".bar");
bar.css("color","#fff");
bar.css("border","thin solid #000");
bar.width(100);
このように変更することで検索を1回にすることができます。

ちなみに、今回の例のように設定系の処理だけであれば、ドット(.)でつなげて以下のようにも書くことができます。       
$(".bar").css("color","#fff").css("border","thin solid #000").width(100);

以上が3つの対策になります。

まとめ

スーパーデリバリーでは、これら3つの対策をすることで「IE8でjQueryが重い」現象に対応し、パフォーマンス問題を改善することができました。
もし、いまお困りであれば試してみてください!
 
まとめのまとめとして、今回の対策からの学びを、本質的な観点からまとめたいと思います。

今回最も大きな原因は、IE8の機能不足によるものでした。
ただし、使用するライブラリや実行環境の問題というのは多くの場合制御不能なので、”特徴”と捉えてうまく付き合うしかありません。
今回の現象も、「重い」という問題になる前に特徴を把握して、対策をしておけば、顕在化することなく防げていたと思います。

ただ、そもそも、「対策」にあげている、2.検索対象を減らしたり、3.検索回数を減らすといった対策は、通常のプログラムを書く場合や、SQLを考える場合などでも考慮すべき点です。
ひと昔前はJavaScriptというとプログラミング言語というよりはHTMLの一部みたいに考えていた頃もありましたが、改めてプログラミング言語だということを意識して、設計・実装を行うことが必要です。
これらを意識していれば、もし仮に特徴を知らなかったとしても、パフォーマンスが問題にならなかったかもしれません。

また、パフォーマンスの改善の効果の他に、検索回数を減らすために要素の取得処理をまとめ、複数箇所に分散させないことで、修正箇所が絞られ、スクリプト言語にありがちなスペル違いや修正漏れを防ぐことができ、メンテナンス性も改善できます。

日頃からシンプルで保守性の高いコードを心がけたいですね!
Don't repeat yourself!

最後に

スーパーデリバリーのTOPページを2013年7月3日にリニューアルしています。
デザイナーとプログラマのこだわりがぎっしり詰まったNewTOPページをぜひご覧ください
担当したログイン後のスライドショーはスーパーデリバリーの会員様だけにしかお見せできないのが少し残念ですが、、、


 

ラクーンのCI/CDへの取組みの現状とこれから(2)

こんにちは。たむらです。

今回は前回に引き続き「ラクーンのCI/CDへの取組みの現状とこれから」と題した第2回目です。第1回は「Capistrano」によるデプロイの効率向上のお話でした。第2回目は「Selenium」によるテスト自動化の話とシステムとツールを統合管理してくれるCIツール「Jenkins」のお話です。それではいってみましょう!

サイト監視ってカッタルイ ~「Selenium」登場~

弊社はサイトを通してビジネスをしているのでサイトがダウンするとビジネスが成り立たないことになってしまいます。その為、当然のことですがサーバの死活監視、プロセス監視、HTTP通信監視等色々な監視を複合で行なっています。その監視の1つとして、「お客様が実際に利用するメインの導線となる機能が正しく機能しているかを確認する」監視という結構泥臭いことを行なっています。例えばスーパーデリバリーで言えば、以下の様なフローを実施することで確認をしています。

1. 小売店様のスーパーデリバリーサイトへのログイン
2. 商品検索~閲覧
3. 商品Aの注文
4. 出展企業様の管理画面へのログイン
5. 商品Aの受注処理~出荷処理
6. 商品Aの返品・返金処理

さてこの監視ですが、以前はその他の監視業務等と併せて外部委託していましたが、現在はSeleniumを利用して行なっています。
SeleniumはWebブラウザを使ってWebアプリケーションをテストするツールとしてこれまたメジャーなツールです。FireFoxアドオンのSeleniumIDEにて簡単にテストシナリオを作成することができます。また、Selenium2(WebDriver)となってからは各種ブラウザをネイティブに操作できる様な作りとなりだいぶ様変わりしました。
正直、2008年頃は挙動が安定しなかったり動的なページのテストが上手くできなかったりして余り使えてなかったのですが、Selenium2が発表された頃に再度利用してみて「これはイケそう!」とまた使ってみる気になった経緯があります。
 
現時点ではまだSelenium1系のSeleniumRCを使ってテストを運用しています。その為、テストシナリオはSeleniumIDEを利用してベースを作成した後、記述を見直した上でテストケースとしています。というのは、利用したことがある人はご存知だと思いますが、SeleniumIDEの記録するシナリオはロケーションの指定の仕方が微妙なものがある為です。また、表示の度にidが変わる様なものもSeleniumIDEだとほぼ確実にidでロケーションを指定してしまうため、それらは汎用的な形に指定を変更してあげる必要があります。

  ■TestCaseの例(上記「3.商品Aの注文」の一部)■
~~~
<!-- 商品リストの左上商品をクリック -->
<tr>
  <td>clickAndWait</td>
  <td>xpath=(//a[starts-with(@href, '/p/r/pd_p/')][1])</td>
  <td></td>
</tr>
<!-- ログイン済み検証 -->
<tr>
  <td>assertElementNotPresent</td>
  <td>xpath=(//img[@alt='ログイン'])</td>
  <td></td>
</tr>
<!-- 数量ボタンを押下 -->
<tr>
  <td>click</td>
  <td>id=input_1_up</td>
  <td></td>
</tr>
<!-- カートに入れるボタンを押下 -->
<tr>
  <td>click</td>
  <td>css=a.vmiddle.add-cart-item > img[value="カートに入れる"]</td>
  <td></td>
</tr>
<!-- Ajax処理表示待ち -->
<tr>
  <td>waitForVisible</td>
  <td>link=カートへ進む</td>
  <td></td>
</tr>
~~~

ちなみにSelenium2は現在まだ評価中ですが、最終的にはクロスブラウザチェック等も行なっていきたいと考えている為いずれ置換えて行く予定です。ただ、上記の例でも使っているような "waitForVisible" 等のメソッドは自作する必要があり、一通りのベースを作りこんで検証した上でと考えています。


ルーチン業務に時間を取られるのはしんどい! ~「Jenkins」登場~

さて、上述のSeleniumによる監視ですが、自動定期実行のために何かしかの仕組みが欲しいところです。勿論cronでも機能的には満たせるのですが、今後のSelenium利用シーンの拡大を考慮して、より運用や管理が行い易いものを望んでいました。
そこで Jenkins の登場です。Jenkinsは言わずと知れたオープンソースの継続的インテグレーションツールの代表格です。コミットトリガによるビルドやスケジューリング、タスクの依存関係構築等がGUI上から簡単に行うことができ、更にプラグインの追加により他にもたくさんの機能を管理対象とすることができます。

Seleniumを用いたテストの自動定期実行もプラグインを利用することで簡単に設定が可能です。手順はたったコレだけです。
(1) Jenkinsのプラグインで、「Hudson SeleniumHq plugin」をインストール
(2) 「新規ジョブ作成」からジョブを登録し、実行間隔等を定義
(3) 「ビルド手順の追加」-「SeleniumHq htmlSuite Run」を選択し実行するTestSuiteを指定

■SeleniumHQ htmlSuite Run の定義例■
WS000000
プラグインとして用意されている割には、細かな設定もかなりの範囲で行えて痒い所に手が届く作りになっています。
例えば、上記では[browser]で起動すべき実行形式の指定を変更していたり、デバッグモード設定やログ出力等の指定ができる様に[other]にパラメタを積み込んだりしています。

必要に応じてレポート出力ができる「Selenium HTML report」やエラー通知用に「Jenkins Email Extension Plugin」等のプラグインを入れるのも良いでしょう。
尚、SeleniumをJenkinsサーバ自身で動かそうとしていて、且つJenkinsサーバがLinuxでGUIが無い場合、実行にはXwindowやXvnc等の設定をしておく必要があります。

この様にJenkinsはインストール後、ほぼ選択と入力をするだけで簡単に始めることができます。基本的にはビルドを中心にしてその前後処理を肉付けしていく感じで徐々にやれる範囲を拡大して行くような流れで構成を作っていくことになると思います。

■Jenkinsの画面イメージ■
WS000002


ラクーンのCI/CDのこれから

ここ迄現時点で行なっている幾つかの取組みを書いてきましたが、ん~・・・まだまだ全然不十分ですね。ワンクリックデプロイやテスト自動化という言葉には程遠い様に見えます。Jenkinsの強みも有効に利用できているとは言えません。
ただ、第1回の冒頭でも書きましたが、既存である程度の規模のシステムができ上がっている状態からこれらを適用していくのは、一朝一夕では行えません。また、継続的インテグレーションや継続的デリバリはここ迄やれば完了というものではなく、「継続的」と付いている通り常に改善/改良を加えていくものだと認識しています。
そんな訳で、「千里の道も一歩から!」。今後も一歩ずつ着実に成果を積み上げて作り上げていこうと思っています。
ちなみに今構想しているこれからの取組みはこんなことです。

1. デプロイの完全自動化
 ・バージョン管理システムとの完全な連携
 ・ロールバックをワンクリックでできる仕組みの構築
 ・ビルド環境の確立
 ・リリース単位のバッティングによるデグレードを抑止する仕組み
2. テスト自動化
 ・クロスブラウザ検証の(ある程度の)自動化
 ・テストシナリオの充実
3. システム構成管理の簡易化(環境の構築がより簡便に実施できる様にする)
4. Jenkinsによるデプロイやルーチン業務の統合
5. QAの半自動化

今回取り上げたツールの他にも色々なCI/CDツールが選択できる状態にあり、それらも上手く取り入れていくことでどれも実現が可能なものだと思っています。
 

終わりに

CIやCDについての取組みは直接システムの利用者の満足に繋がるものではありません。ですが、開発チームのサポートとして目に見えた改善を感じることができるところですし、何より新しいツール等を駆使して、いかに自分で考えながら最適解を探すかという楽しみもあり、とてもやり甲斐を感じることができるものです。

自ら考えて構築することやCI/CDに興味をお持ちの方、是非一緒に働いてみませんか??

・・・と、最後に求人を入れつつ、以上、2回に渡ってラクーンのCI/CDについてのお話でした。

ラクーンのCI/CDへの取組みの現状とこれから(1)

こんにちは。技術戦略部で今度から部門長をやることになったたむらです。
 
「お前も技術部なんだからブログの一本や二本は書け!」との鬼編集長からのプレッシャー(?)があり、頑張ってブログを書いてみます。よろしくお願いします!

今回は「ラクーンのCI/CDへの取組みの現状とこれから」と題して2回に分けて書いてみたいと思います。
アジャイル開発の界隈で良く取り上げられ、昨今メジャーな話題となっているCI(継続的インテグレーション)CD(継続的デリバリ)という用語ですが、これらで謳われている方法論はラクーンでも採り入れるべき多くのメリットを持っています。
ただ、ラクーンのメインビジネスとなっている「スーパーデリバリー」は10年以上続いている大きなシステムであり、既存のシステムにCI/CDのポリシーを適用していくのは大きなコストと時間が掛かります。そんな意味で、今回のテーマである取組みの実際の内容の他に、ラクーンがどの様にシステムに対して改善のアプローチを図っているかという一例が紹介できればいいなと考えています。

ということで、第1回は Capistrano によるデプロイの効率向上のお話です。


デプロイって面倒くさい ~「Capistrano」登場~

まず、弊社のサービスである「スーパーデリバリー」や「Paid」は以下の様なシステム構成となっています。
 
WS000005


1系サーバグループと2系サーバグループはそれぞれ複数台のサーバから構成されていて、すべてのサーバは同じ構成になっています。そして、1系と2系のグループが正副の関係となっていて、リリース毎に稼動系サーバが切り替わるという様な運用を行なっています。
さて、この様な構成のサーバ群に対してリリースする際、1年半程前迄はリリース手順書を用意してそれに沿って手動でデプロイを行なっていました。リリースの手順は以下の様なものです。

(リリースの手順の概要) 
1. リリース物の準備/バックアップ
2. ロードバランサーの振分け状況チェック(ここで1系/2系どちらが稼動系かを確認する)
3. 稼動系サーバ群でのファイル同期チェックジョブの停止
4. ファイル共有サーバへのリリース物のアップロード
5. 待機系Webサーバ群でのリリースファイルの同期
6. 待機系Webサーバ群でのプロセスの再起動
7. 待機系Webサーバそれぞれのログ確認とプロセス稼働確認
8. ロードバランサーの切替(待機系を稼働系に)
9. 旧稼動系サーバ群でのファイル同期チェックジョブの再開
 
当然、これらのオペレーションのすべてをマニュアルで行っているとミスが発生することもあり、本番サービスがダウンすることも過去に何度か発生していました。


またデプロイには以下の様な問題が絡んで、オペレーションは属人化する傾向にありました。
(デプロイに関する問題)
a. 操作対象サーバが多く作業負荷が高い
b. プロセスの再起動等のオペレーションにより、リリースユーザ権限がほぼ管理者権限になってしまう
c. ロードバランサーの操作が別物になっており、作業負荷・リスクが高い
d. リリース手順自体が複雑

そこで採用したのがCapistranoです。Capistrano はオープンソースのソフトウェアデプロイメントツールで、デプロイツールの中では非常にメジャーなものです。複数サーバへの作業の効率化・自動化が簡単に行えます。デフォルトで一通りのデプロイタスクのひな形が用意されているのですが、ラクーンでは上記のリリース手順に沿ったものにする必要があったため独自のタスクを用意して実装することにしました。


では上記で挙げたデプロイに関する問題に対してそれぞれどんなアプローチをしたのかを説明していきましょう。
 
まず、「a. 操作対象サーバが多く作業負荷が高い」についてです。
これはCapistrano の基本的な機能を使えばそれだけで解決できます。

独自タスクはrakeタスクを書く要領で簡単に実装できます。例えばこんな感じです。
(rubyやrakeタスクについての説明はここでは省略します) 
■tomcatプロセスの再起動の例(上記「手順6」の一部)■
~ deploy.rb の抜粋 ~
set :application, "deploy"
set :user, "deployuser"

if togroup == "group1"
 # 1系統のサーバーグループを定義
  role :web, "XXX.XXX.XXX.XX1"
  role :web, "XXX.XXX.XXX.XX2"
  role :web, "XXX.XXX.XXX.XX3"
elsif togroup == "group2"
 # 2系統のサーバーグループを定義
  role :web, "YYY.YYY.YYY.YY1"
  role :web, "YYY.YYY.YYY.YY2"
  role :web, "YYY.YYY.YYY.YY3"
end

desc "cap -f deploy.rb tomcat_action -S togroup=[group1|group2] -s repos=[serviceA|serviceB]"
task :tomcat_action, :roles => [:web] do
  # パラメタで指定されたサービスの停止
  run "sudo \/etc\/init.d\/#{repos} stop"
  run "sleep 10"
  # プロセス停止を確認後・・・
  run "test `ps -ef | grep \"#{repos}\" | grep -v \"grep\" | wc -l` -eq 0"
  # 起動
  run "sudo \/etc\/init.d\/#{repos} start"
  run "sleep 15"
  # 起動ログを出力し確認
  run "tail -n 10 \/var\/log\/#{repos}.log"
  # プロセス状況を出力し確認
  run "ps -ef | grep \"#{repos}\" | grep -v \"grep\""
end

Capistrano では、リモートサーバにsshで接続しコマンドを実行します。その為、事前に Capistrano を実行するマシンとリモート実行する対象のサーバではsshで接続できる設定をしておく必要があります。

上記で定義したtomcatプロセス再起動タスク(tomcat_action)を実行する際は以下の様にコマンドラインから実行します。
$ cap -f deploy.rb tomcat_action -S togroup=group1 -s repos=serviceA
パラメタは"-S"または"-s"の後にname=valueの形で指定します。オプションの"-S"と"-s"の違いは、前者は初期化時から使用したい場合、後者はタスク内でのみ使用する場合に利用します。その為、サーバーグループの選択を行う"togroup"は"-S"、タスク内のみの"repos"は"-s"と使い分けています。
 
この様にCapistrano ではパラメタにより複数サーバグループを切替えつつ、同一の操作を実行することが簡単に実装できます。


次に、「b. プロセスの再起動等のオペレーションにより、リリースユーザ権限がほぼ管理者権限になってしまう」についてです。
これは、Capistrano 経由でデプロイのコマンドを発行するユーザを新規に作成し、そのユーザに対してsudoers のコマンドエイリアスを定義することで解消させました。

■/etc/sudoersの記載例■
Cmnd_Alias PROCCMD=/etc/init.d/serviceA,/etc/init.d/serviceB
deployuser ALL=NOPASSWD:PROCCMD
この様にすることで、管理者権限が必要な特定のコマンドのみをリリース用アカウントにノーパスワードにて実行させることができます。
直接的には Capistrano とは関わりませんが、Capistrano 実行環境の構築に合わせて環境周りも整備した結果選択できた対処となります。


続いて「c. ロードバランサーの操作が別物になっており、作業負荷・リスクが高い」についてです。
これは、利用しているロードバランサーのプロダクト「BigIP」がI/Fとして提供している bigpipe shell をCapistranoから実行することにより作業を一本化&タスク化することができました。
BigIPはGUIとCUIそれぞれI/Fを持っているのですが、CUIである bigpipe shell はその名の通りLinuxのシェルと変わらない作りの為、問題なく Capistrano から実行することができます。

■ロードバランサーの切替タスクの例(上記「手順8」の一部)■
set :application, "deploy"
set :user, "deployuser"

role :lb, "ZZZ.ZZZ.ZZZ.ZZZ"

desc "cap -f deploy_lb.rb exchange_balancing -S togroup=[group1|group2]"
task :exchange_balancing, :roles => [:lb] do
  grp = togroup == "group1" ? 1 : 2
  # 指定したプール(1系/2系に分けたサーバグループ)に本番アクセスを切替
  run "bigpipe profile httpclass httpclass_serviceA pool pool_serviceA_#{grp}"
  # 切替えられたプールを表示
  run "bigpipe profile httpclass_serviceA list | grep pool"
  # ロードバランサの待機系に設定変更を反映
  run "bigpipe config sync all"
end

尚、BigIPはv10.0.0からCUIとして bigpipe shell の他に tmsh というCisco風なシェルがサポートされています。当初ラクーンではBigIPのデフォルトシェルを tmsh にしていたのですが、Capistrano はデフォルトだと tmsh 形式のコマンド実行ができません。そこでBigIP側のデフォルトシェルを bigpipe shell に切り替えて対応することにしました。これにより、「c.」の問題も解消させることができました。


最後に、「d. リリース手順自体が複雑」についてです。
そもそも、何故リリース手順が複雑になるかというと、スーパーデリバリーではコンテンツの更新などで開発案件以外でもサイト更新が頻繁に行われている為、その運用を壊さずにリリースする必要がある事が1つの大きな理由となっています。
手順自体の簡略化はプログラム化することで概ね解消します。しかし、別のマニュアルリリース作業が一部残存してしまうことから、上で挙げたリリース手順「7」の最終的なサイト確認などは現時点では自動化するより目視により行う方が適切だという判断に至りました。
そこで、最終的にはCapistrano のタスクをシェルでラップしてCUIの対話形式ツールとし、適宜確認を行えるところ迄を一つの到達点としました。

■シェルでラップしたデプロイツールのイメージ■
WS000006


以上の様にラクーンの持っていたデプロイの問題に対して、Capistrano という強力なツールを軸にデプロイの改善を進めたのでした。
色々と不完全な部分もあり完全自動化迄は至っていませんが、これによりオペレーションミスの抑制や作業の省力化はかなり進めることができました。

今回はここ迄になります。
次回は Selenium を用いた自動テストと Capistrano や Selenium 等のツールを統合してくれる Jenkins の話題に続きます。

rake+sequelで組み立てる実践的データベースタスク

開発を担当している松尾です。
花粉飛び回るこの時期に集中力を掻き乱される度合いが年々ひどくなっている気がしてしょうがない昨今ですが、頑張って書いてみます。

イレギュラーなデータベースタスク

弊社では企業間向けサービスを提供しているという性質上、多岐にわたるバックオフィス業務からの要請としてイレギュラーな業務パターンが日々発生します。
全てのイレギュラーパターンに合わせて律儀にシステムを改修していくというのは費用も時間も掛かり過ぎるため、必然的に「過渡的」だったり「小規模」なものについては最小のコストで解決する手法が必要になります。
とはいえ、「人力でメールの大量送信」や「人力でSQLを使ったDB操作」は考えるまでもなく危険極まりないものです。
ここでは私の担当範囲で起こりがちな「システム化するには時間がかかるからすぐには無理」とはいえ「すぐに実施したい」ビジネス要件について、最小のコストと適度な安全性を担保しつつ「ちょっとだけ作って解決する」ことを目指した実践例の解説をしてみます。

rake

rakeとはrubyのビルドツールとして構築されたツールおよびライブラリのことです。

もともとはmakeに代わる高機能なビルドツールとして設計されたという経緯があり、シンプルに依存関係のあるタスクを記述できる使い勝手の良い枠組みが提供されています。

ruby-1.9.1より標準添付されるようになったため、比較的最近のrubyがインストールされている環境であればrakeコマンドが実行できます。

$ rake --version

rake, version 10.0.3

rakeは実行されるとカレントディレクトリ内のRakefileという名称のファイルを読み込みます。
意図的に別のファイル名を指定することも可能ではあるのですが、そんな酔狂な設定を喜ぶ人はいないのでやめておきましょう。

rakeで定義するタスク群を全てRakefile内に記述した場合はrakeタスク定義はひとつのファイルに完結します。
$ cat > Rakefile
task :hello do
puts "Hello"
end
$ rake hello
Hello
上記は"hello"という名前のタスクを定義して, rakeにタスクを指定して実行させるという最小の実行例になります。

Sequel

SequelはJeremy Evans氏によって開発されているrubyのためのデータベースツールキットです。
簡潔かつ多機能なDSLを備えており多くの種類のデータベースに対応しています。
また、SQLを抽象化するためのデータアクセス層とORM層が明解にモジュールで分離されているところが特徴的です。
$ gem install sequel
上記のようにgemを使ってインストールします。
sequel自身はデータベースのドライバを含まないため、rubyからデータベースへ接続するための各ドライバ(MySQLであればmysql2、Postgresqlであればpg、Oracleであればruby-oci8など)は別途インストールされている必要があります。

今回は私が現実に相手をする機会が多いからということと、MySQLやPostgresqlといったオープンソースのデータベースとはひと味ちがう商用データベースで使いこなすという目的から操作対象がOracleデータベースであるという前提になっています。

sequelの機能は多岐に渡るため、あえてここでその機能についての詳細は割愛します。
Cheat Sheetというドキュメントを眺めて貰えればひと通りの感触は掴んでもらえるのではないかと思います。

ファイル構成

データベースのタスクを定義するためのファイルは下記のような構成にしています。
また、参考例は架空のサービス「piyo」を運用するためのタスク定義であると仮定の上で作成しています。
  • config.yml
  • Rakefile
  • logs/
    • xxxxxx.log
    • ...
  • tasks/
    • piyo/
      • task1.rake
      • task2.rake
      • ...

config.yml

YAMLで記述された設定ファイルです。
log:
dir: /path/to/logs
databases:
piyo_db:
user: piyo
password: xxxxxxxx
database: //xxx.xxx.xxx.xxx:xxxx/piyo.oracle.local
例としてログの出力ディレクトリとデータベースへの接続情報をYAML形式で記述しています。
運用とともに複数データベースへの接続や他の設定値が必要になった場合は適宜この設定ファイルに追記していきます。

Rakefile

rakeコマンドが実行されると読み込まれるファイルです。
仕組みとして全てのタスクをこのRakefile内に記述することもできるのですが、内容が増大するにつれて間違いなくメンテナンスに苦労するハメになるのでタスク定義は別ファイルに分割してRakefileそのものはシンプルにしておきます。
# -*- coding: utf-8 -*-
# requires
%w(fileutils logger yaml time date csv sequel).each {|lib| require lib }
# tasks
Dir['tasks/**/*.rake'].each {|path| load path }
3行目ではタスクで頻繁に利用するであろうライブラリをまとめて読み込んでいます。
少々乱暴な気もしないでは無いですが、いちいち指定するのも煩わしいのでここで読み込みます。
5行目でtasksディレクトリ以下に配置されたタスク定義を読み込んでいます。
タスク定義のためのrubyスクリプトは、他のファイルとの区別がつくように*.rakeのような形式が良いでしょう。

logs

名前の通りログファイルの出力用ディレクトリで中身は空になっています。
rakeタスクで実行した全てのデータベース操作の内容がログとして出力されるように構成します。

tasks

このディレクトリ以下にタスク定義を配置していきます。
後々のタスクの分類や整理に困らないように、もう1階層グループ名のようなディレクトリを配置してrakeのnamespace機能と整合させるために、ここではtasks/piyo/*.rakeのように配置することにします。

基本タスク

tasks/piyo/configuration.rake

namespace :piyo do
  task :configuration do
    # YAML設定ファイルの読み込み
    CONTEXT = YAML.load_file('config.yml')
  end
end
まずは、設定ファイルを読み込むタスクを定義します。
YAML形式で記述されたファイルを読み込むだけの単純極まりない内容です。
他のタスクから簡便に参照できるようにCONTEXTという名前の定数に放り込んでいます。

またtaskの定義自体をnamespaceで囲っていますが、rakeではnamespace機能を利用してネストしたタスクを定義することができます。
ネストしたタスクは階層化された名前を「:」で区切った文字列がタスク名となります。
上記の例でいえば「piyo:configuration」がタスク名となります。

tasks/piyo/logging.rake

namespace :piyo do
task :logging => :configuration do
raked_at = Time.now.strftime('%Y-%m-%d_%H%M%S')
log_dir = CONTEXT['log']['dir']

# ログ出力ディレクトリが存在しなければ作成しておく
FileUtils.mkdir_p(log_dir) unless FileTest.directory?(log_dir)

# CONTEXTへlogger生成メソッドを追加する
CONTEXT.instance_eval do
self.class.send :define_method, :logger do |name, level=Logger::INFO|
logger = Logger.new(File.join(log_dir,
[raked_at, name, 'log'].join('.')))
logger.level = level
logger
end
end
end end
次はloggingのためのタスク定義です。
task :logging => :configuration do
rubyの文法としてはHashリテラルに該当する上記の書き方でタスクの依存関係を定義します。
上記の場合は「configuration」タスクに依存した「logging」タスクの定義、という意味になります。
ちなみに依存するタスクが複数存在する場合は下記のように複数記述することもできます。
task :new_task => [:dependent1, :dependent2, dependent3] do
タスク内で実行されたデータベース操作を記録したログを集約しやすくするために、「configuration」タスクで定義したCONTEXTオブジェクト(単なるHashのインスタンスですが)にlogger生成用の特異メソッドを定義しています。
rubyの特異メソッド定義は下記のように、
s = "string"
class << s
def hoge
"hoge"
end
end
puts s.hoge
# => "hoge"
classキーワードを利用して簡潔に書く方が簡潔なのですが、タスク内のローカル変数であるraked_atとlog_dirを特異メソッド内にバインディングしたかったという事情から、少々黒魔術っぽくなっています。

tasks/piyo/connect_piyo.rake

namespace :piyo do
task :connect_piyo => :logging do
PIYO_DB = Sequel.oracle(CONTEXT['databases']['piyo_db'])
PIYO_DB.loggers << CONTEXT.logger('piyo')

class << PIYO_DB
def literal(s)
Sequel::LiteralString.new(s.to_s)
end
end
end
end
さて、本丸に近いデータベースへの接続タスクです。
ここでは設定ファイルと同様に定数PIYO_DBに、設定ファイル内の接続情報から作成したSequel::Oracle::Databaseオブジェクトを保存しています。
また、sequelのDatabaseオブジェクトは複数のLoggerを保持できるようになっているのですが、ここに「logging」タスクで定義したCONTEXT#loggerメソッド経由で作成したLoggerを放り込んでおきます。
これでsequelによって発行されるSQLなどの情報は全てログに出力されるようになります。

さらに、このタスクではPIYO_DBにliteralという特異メソッドを定義しています。
与えられたStringオブジェクトをSequel::LiteralStringにラップしているだけの単純な内容ですが、これはデータベース特有のSQLリテラルを簡便に使用したいという欲求から定義しています。

仮に接続先データベースがMySQLであれば、
PIYO_DB[:users].select(:name, Sequel.function(:now))
#=> SELECT name, now() FROM users
上記のようにSequel.functionを利用してMySQLの関数呼び出しを自然に組み込むことができるのですが、リテラルやFunctionの呼び出し記法に独特なところを持つOracleデータベースの場合は上手く動作しないことがあります。
(引数ゼロの関数やプロシージャ呼び出しに空カッコが書けない、などなど)

Sequel::LiteralStringを使用することで、
PIYO_DB[:users].select(:name, PIYO_DB.literal(:sysdate))
#=> SELECT name, sysdate FROM users
上記のようにデータベース特有のリテラル表現を含んだSQLの生成に役立ちます。
特に大きな使い所としては、INSERT文を実行する場合にOracleのSequenceを利用して連番のプライマリキーを発行する下記のようなパターンでしょうか。
PIYO_DB[:users].insert(:id => PIYO_DB.literal('users_seq.nextval'), :name => 'Matsuo')
#=> INSERT INTO users (id, name) VALUES (users_seq.nextval, 'Matsuo')

tasks/piyo/available_users.rake

さて、データベースへ接続するタスク定義まで準備が整ったので、あとはsequelの機能を駆使して様々なタスクを定義していくだけです。
下記のコードはPIYO_DBよりavailable=1であるレコーをSELECTして各レコードをカンマ区切りで出力するだけの簡単なサンプルです。

namespace :piyo do
desc 'ログイン可能であるユーザの一覧を出力する'
task :available_users => :connect_piyo do
# SELECT * FROM users WHERE available = 1
PIYO_DB[:users].where(:available => 1).each do |row|
puts row.values_at(:id, :name, :email).join(',')
end
end
end
実行すると下記のような出力が得られるイメージです。
$ rake piyo:available_users
(in /home/piyo/tasks)
1,Taro Yamada,yamada@example.com
2,Hanako Tanaka,tanaka@example.com
3,Mitsu Dan,dan@example.com
...
ログファイルの出力イメージは下記の通り。
# Logfile created on 2013-03-26 16:44:33 +0900 by logger.rb/31641
I, [2013-03-26T16:44:33.887404 #3899] INFO -- : (0.002629s) SELECT * FROM "USERS" WHERE ("AVAILABLE" = 1)
発行されたSQLの内容と実行にかかった時間が記録されていることが確認できます。

このタスクで初めてdescというメソッドが登場して来ましたが、ここにはタスクの説明を付加することができます。
descを付加することで何が嬉しいかというと、
$ rake -T
(in /home/piyo/tasks)
rake piyo:available_users # ログイン可能であるユーザの一覧を出力する
このようにrakeコマンドに-Tオプションを付加することで定義済みのタスクの一覧を説明付きで出力できるようになります。
内部的な共通タスクにはdescをつけず、実際に目的をもって実行するタスクについてはdescを付加して公開タスクとするというルールがオススメです。

単純なSELECT文にとどまらず、トランザクション制御+CRUDといった局面も例示してみたいのは山々ながら、長くなるので割愛しちゃいますが、特にトランザクションの取扱いについてはrubyの柔軟性を活かして非常にシンプルな記述ができる、とだけ叫んでおきます。

適用範囲の拡張のために

さて、ここまでの準備でデータベースタスク定義の枠組みは最低限揃いました。
あとは必要に応じたタスクを追加していくことで日々の業務に役立てていけると思います。

最後に細かいtipsをいくつか提示したいと思います。

HighLineによるパスワードの隠匿

今回の例では設定ファイル内にデータベース接続のためのパスワードを直書きしていますが、こういったパスワードを設定ファイルに持たずに実行時に入力させたいといった状況も十分に考えられます。
こういう場合にはHighLineがピッタリです。
$ gem install highline
sequelと同様にgemでインストール出来ます。
ask("Enter your password:  ") { |q| q.echo = false }
ask("Enter your password:  ") { |q| q.echo = "x" }
一般にパスワード入力中の内容は画面上に表示しないことが望ましいため、上記のどちらかのパターンを使ってタスク中の任意のタイミングでパスワード入力を求めることができます。

Net::SSHを利用したサーバタスク

ruby上でsshセッションを気軽に扱えるNet::SSHNet::SCPといったgemを導入すると、サーバリソースの操作も含めたタスクの作成に役立ちます。
Net::SSHを利用すればサーバにログインしてコンソールから実行できることの大部分が自動化できます。

JRuby+JDBCを利用する

sequelを使う利点のひとつがJRubyとJDBCへの対応です。
DB = Sequel.connect(:adapter => 'jdbc',
:url => 'jdbc:mysql://localhost/test',
:user => 'piyo',
:password => 'xxxxxxxx')
adapterにjdbcを指定することで簡単にJRuby環境からJDBCデータソースを利用することができます。
(ちなみにJDBCドライバの配置場所は$JRUBY_HOME/lib直下になります。)

JRuby+sequelの構成が素晴らしいのは「真のマルチスレッドが実現できる」ところにあります。
いわゆるCRuby(MRI)では外部ライブラリも含む環境全体でThread-safeが保証できないという前提からGIL(Global Interpreter Lock)機構によってプロセス内で同時に実行されるスレッド数が1つに制限されます。
JVMを基盤にしたJRubyにおいてはこの制限が無いため、CPUのコア数を活かした並列処理が容易に実現可能です。
つまるところ、Thread-safeに設計されているsequelと組み合わせることでデータベースへの並列処理を組み立てることが可能になります。

おわりに

たまたまrubyと知り合って10年以上愛用していますが、railsのようなフレームワークの材料として使うよりも、こういった小さなタスクを簡潔にこなすために利用する局面の方が、なんとなくrubyらしいと感じてしまうのは私だけでしょうか?
DRYでもなければRESTFULでもなく、ちょっと泥臭い(けれども現実にはありふれた)課題をスクリプト言語の足回りを活かして手早く解決していく、rubyをスーパーカブのように乗りこなすスタイルも悪く無いと思う昨今です。


記事検索