2010/12/28

DTI VPSのsshポート番号変更メンテナンス後にやった事

Apache2: default-sslファイルの削除

sshdのポート番号を22から変更したDTIのVPSサービスですが、その作業自体はポートスキャン等々のアタックに対する負荷軽減のためには必要だったと思います。

しかし27日13時過ぎのタイムスタンプで /etc/apache2/sites-enabled/default-ssl が作成されているのに気がつきました。

ファイルの内容は更新されていませんでしたが、default-sslを使わずにSSLを有効化していたためにapache2を再起動する前にファイルは削除しました。

ServersManを使わない向きには問題になる可能性のある変更かもしれません。 まぁ可能性だけですが。

ajaxterm: デーモンの停止

次はajaxtermというデーモンが起動している点です。

負荷にはなっていませんが、システム全体で仮想メモリの使用率が大幅に増えている点がちょっと気になります。

ps auxwww| grep ajaxterm の出力

USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
ajaxterm 21742  0.0  1.1  73764  6052 ?        Sl   Dec27   0:00 python /usr/share/ajaxterm/ajaxterm.py --daemon --port=8022 --serverport=xxxx --uid=ajaxterm

ajaxtermは使わないので6MB程度のRSSは変化しないでしょうし、自分の使い方なら問題にはならないレベルです。 8022番ポートは127.0.0.1に対して開いているのでセキュリティ的にも問題があるとは思えません。

Muninで記録しているMemory usage by weekの結果は次の通りで、27日の正午頃からメモリ使用率が上がっています。

27日12時頃からメモリ使用率が150MBから200MBに上昇したグラフ

まぁ、気になるので /etc/init.d/ajaxterm stop を実行した後で、次のコマンドを打って再起動時にajaxtermが起動しないように/etc/rc2.d/S20ajaxtermなどを削除しました。

$ sudo update-rc.d -f ajaxterm remove
Removing any system startup links for /etc/init.d/ajaxterm ...
   /etc/rc0.d/K20ajaxterm
   /etc/rc1.d/K20ajaxterm
   /etc/rc2.d/S20ajaxterm
   /etc/rc3.d/S20ajaxterm
   /etc/rc4.d/S20ajaxterm
   /etc/rc5.d/S20ajaxterm
   /etc/rc6.d/K20ajaxterm

まとめ

今回の作業は必要でしたし、ServersManを売りの一つに据えている事を考えれば当然の対応でしょう。

問題は何もなかったのですが、できればテクニカルな変更点もメールで教えて欲しかったかなと思います。

2010/12/22

ブロードバンドルータにしているalixをDebian squeezeにして、ipsetを導入す る

マルウェアに感染していたりボットネットのものといわれているIPアドレス群が公開されていたりします。 そういったノードとの通信を拒否するためには、数千のIPアドレス/ネットワークを制御する必要があります。

普通にiptablesを使ってルールを追加するとルールを設定するタイミングでハングアップしそうになるため、ipsetを導入することにしました。

いままでDebian lenny(5.0.x)を使ってきましたが、ipset-sourceパッケージを利用するために Debian squeeze(6.0.x)に移行しようというのが今回の作業です。

Debian squeezeへのアップグレード

Gatewayとなるalixへはgccなどの開発環境をインストールしていないため、別のalixでまずsqueezeに移行し、ipset用のdebモジュールを開発する事にしました。

開発機から本番機にipset debパッケージを転送する様子

基本的にはetchからlennyに移行した時と方法は同じですが、googleでも他の事例を検索してみました。 だいたいはapt-get dist-upgradeではなく、aptitude full-upgradeを使っているようでした。

もしalixのような最小構成で使っているのでなければ、間違いなくaptitudeがお勧めですが、今回はいつもどおりapt-getを使いました。

書き換えたsources.listは以下のとおりです。

/etc/apt/sources.lstファイル全体

deb http://ftp.jp.debian.org/debian squeeze main contrib non-free
deb-src http://ftp.jp.debian.org/debian squeeze main contrib non-free

deb http://security.debian.org/debian-security squeeze/updates main contrib non-free
deb-src http://security.debian.org/debian-security squeeze/updates main contrib non-free
$ sudo apt-get update
$ sudo apt-get install apt
$ sudo apt-get dist-upgrade

途中で新しい設定ファイルと置き換えるかどうかは、 /etc/init.d/openvpn以外は全て差分(Diff)を確認して、デフォルトの'N'を選択しました。

grub2への移行

今回の方法でsqueezeへ移行するとgrub2が導入され、とりあえず従来のgrub1からgrub2を経由してカーネルが起動する状態になります。

grub2の設定は/boot/grub/grub.cfgファイルで行ないますが、serial行は次のようにword,parityを設定する必要がありました。

serial --speed=38400 --word=8 --parity=no
terminal_input serial
terminal_output serial
set timeout=5

/boot/grub/grub.cfgファイルを直接編集するのではなく、/etc/default/grubを編集し、$ sudo grub-mkconfig を実行します。

/etc/default/grubは次のようになっています。

# This file is sourced by update-grub, and its variables are propagated
# to its children in /etc/grub.d/
GRUB_DEFAULT=0
GRUB_TIMEOUT=5
GRUB_DISTRIBUTOR=`lsb_release -i -s 2> /dev/null || echo Debian`
GRUB_CMDLINE_LINUX="console=ttyS0,38400n8"
GRUB_SERIAL_COMMAND="serial --speed=38400 --word=8 --parity=no"

# Uncomment to disable graphical terminal (grub-pc only)
GRUB_TERMINAL=serial

# Uncomment if you don't want GRUB to pass "root=UUID=xxx" parameter to Linux
#GRUB_DISABLE_LINUX_UUID=true

grub1を経由して無事に起動する事を確認した後は、コマンド、upgrade-from-grub-legacyによって直接grub2のブートローダがMaster Boot Record(MBR)あるいはパーティションの先頭セクタに導入することができ、再起動すればgrub1に代わりgrub2が起動することになります。

grub2には変更なく移行できたものの…

手元の環境では grub1 が /boot/grub/menu.lst から起動する時に、 default 0の設定から /boot/vmlinuz-2.6.26-2-686 が起動していました。

"title Chainload into GRUB 2"と書かれている2番目のtitle行を指すために、 default 1に変更して再起動し、無事に grub2 から起動する事を確認しました。

アップグレードに伴なうディスクの増加

もろもろデータをいれて8GBのCompactFlashのうち、約半分の4GBほどを使用していましたが、導入によって一時的に800MBほど増加しました。

Gatewayにするalixは2GBのCompact Flashを使っていて、こちらも約半分の900MBほどを使っていましたが、導入時には400MBほど増加しました。

$ sudo apt-get dist-upgrade
...
アップグレード: 212 個、新規インストール: 53 個、削除: 4 個、保留: 0 個。
167 MB のアーカイブを取得する必要があります。
この操作後に追加で 230 MB のディスク容量が消費されます。

導入後のdf出力は次のとおりです。

Filesystem           1K-ブロック    使用   使用可 使用% マウント位置
/dev/sda1              1942036   1468316    375848  80% /
tmpfs                   127432         0    127432   0% /lib/init/rw
udev                    123088        60    123028   1% /dev
tmpfs                   127432         4    127428   1% /dev/shm

IPSetモジュールの作成と導入

Squeezeにしたのはipsetのモジュールをm-a(module-assistantコマンド)を使って導入するためでした。

m-aはdebパッケージを作成してくれるため、本番機に開発環境を入れることなく、他の開発機でdebパッケージを作成します。

$ sudo apt-get install ipset-source module-assistant

これで/usr/src/ipset.tar.bz2ファイルが導入されるはずです。 次にコンパイルに必要なファイル群を導入します。

$ sudo m-a prepare

prepareを実行すると、kernel moduleをコンパイルするために必要なパッケージが一式導入されます。 既に開発環境は整っているため、現在使っているsqueezeの標準kernel 2.6.32-5-common に対応するヘッダファイル群だけが導入される事になりました。

以下の特別パッケージがインストールされます:
  linux-headers-2.6.32-5-common linux-kbuild-2.6.32
以下のパッケージが新たにインストールされます:
  linux-headers-2.6.32-5-686 linux-headers-2.6.32-5-common linux-kbuild-2.6.32

次にipsetのmoduleを作成します。

$ sudo m-a build ipset

終ると /usr/src/ipset-modules-2.6.32-5-686_2.5.0-1+2.6.32-29_i386.deb が作成されるので、ブロードバンドルータにしているalixに転送して導入しました。

2010/12/21

Ubuntu 10.04 LTSでsysstatの実行に失敗している状況を改善する

sarコマンドを実行しようとして、気がつくと sysstatが正しく動いていませんでした。

ローカルのメールボックスにはcronからのエラーメールが大量に届いています。

Subject: Cron <root@athlon> command -v debian-sa1 > /dev/null && debian-sa1 1 1
Content-Type: text/plain; charset=ANSI_X3.4-1968
X-Cron-Env: <PATH=/usr/lib/sysstat:/usr/sbin:/usr/sbin:/usr/bin:/sbin:/bin>
X-Cron-Env: <SHELL=/bin/sh>
X-Cron-Env: <HOME=/root>
X-Cron-Env: <LOGNAME=root>
Date: Tue, 21 Dec 2010 14:45:01 +0900 (JST)

Usage: /usr/lib/sysstat/sadc [ options ] [ <interval> [ <count> ] ] [ <outfile> ]
Options are:
[ -C <comment> ] [ -F ] [ -L ] [ -V ]
[ -S { INT | DISK | IPV6 | POWER | SNMP | XDISK | ALL | XALL } ]

原因は/etc/default/sysstatファイルの設定

packageが更新された時に、オプションの指定が新しくなったにもかかわらず/etc/default/sysstatファイルを更新しなかった事が原因でした。

新しいファイルとのdiffは次のとおりです。

--- /etc/default/sysstat	2009-10-27 12:21:42.000000000 +0900
+++ /etc/default/sysstat.ucf-dist	2010-04-26 11:24:18.000000000 +0900
@@ -10,9 +10,9 @@
 
 # Additional options passed to sa1 by /etc/init.d/sysstat
 # and /etc/cron.d/sysstat
-# By default contains the `-d' option responsible for 
+# By default contains the `-S DISK' option responsible for 
 # generating disk statisitcs.
-SA1_OPTIONS="-d"
+SA1_OPTIONS="-S DISK"
 
 # Additional options passed to sa2 by /etc/cron.daily/sysstat.
 SA2_OPTIONS=""

/etc/default/sysstatファイルを編集して、"-d"を指定していた部分を"-S DISK"に変更して無事にcronが情報を集め始めました。

大抵は設定ファイルは現状キープで問題ない事が多いんですけどね。今回はちゃんと差分(diff)を確認せず失敗でした。気をつけないとなぁ…。

2010/12/20

Rubyアプリケーションでの設定ファイルのフォーマット

Jabberクライアントを作成した時の設定ファイルにJSON形式を使いましたが、少し後悔しています。

それはJSON形式では、最後の要素の後ろに','(カンマ)を置くとエラーになってしまうからです。

エンドユーザ向けの設定ファイルであれば、1行1命令であるべきで、各行相互の関係性を把握させるのは難しいと思います。

今回はそんなアプリケーションで利用する設定ファイルの書式について考えてみました。

手動で '=' で区切られた要素を分解し、Hashオブジェクトにする

テキストファイルで書ける設定ファイルは便利で、YAMLやJSONなどいくつか候補はあります。

しかし適当に '=' で区切った文字列を受領するような形式も手軽で使い易かったりします。

# filename: config.txt
jabber.name = Foo bar
jabber.pass = xxxxxx
jabber.server = jabber.org
...

これをHashに分解するコードはすぐに書けます。

=で区切られた設定ファイルを読み込み conf変数を準備する

#!/usr/local/bin/ruby
# -*- coding: utf-8 -*-
conf = Hash.new
open("config.txt","r:utf-8").each_line do |line|
  next if line.empty? or line =~ /^#|^$/
  key,value = line.split(/=/,2) 
  conf[key.strip] = value.strip
end
p conf
出力:
{"jabber.name"=>"Foo bar", "jabber.pass"=>"xxxxxx", "jabber.server"=>"jabber.org"}

この処理の中で受け付ける設定ファイル名だけを確認するなどすれば、エラーハンドリングも手軽でユーザにも優しいスクリプトになるでしょう。

Structクラスを使った設定ファイルを使用する

もう少し柔軟性が必要なら、設定できる項目を指定してstructを使う事もできます。

# -*- coding: utf-8 -*-
# filename: config.txt
@conf.name = '漢字の名前'
@conf.current_date = DateTime.now.ctime

@confオブジェクトに設定できる内容はStructによって制限する事ができます。

設定ファイルを読み込み @conf変数を書き換える

#!/usr/local/bin/ruby
# -*- coding: utf-8 -*-
Config = Struct.new(:name, :current_date)
@conf = Config.new
require 'date'
load 'config.txt'
p @conf
出力:
#<struct Config name="漢字の名前", pass="Mon Dec 20 15:48:46 2010">

スコープが異なるので、変数名は@で始まるインスタンス変数にする必要があります。

loadlの代りにrequireを使うこともできますが、ファイル名のサフィックスが".rb"である事や$:変数を適切に設定するなどの手続きが必要になることから、設定ファイルの読み込みにはloadが最適です。

また、この方法だとconfig.txtは完全なRubyスクリプトになるので、名前空間が汚染されるなど、一定の危険性はあります。

常に使える方法ではありませんが、もっと制御をユーザに開放したければDSL(Domain Specific Language)の手法が参考になるでしょう。

まとめ

YAML形式とかJSON形式はプログラマには便利で、アプリケーション同士がやりとりするにはデバッグ含めて最適だと思います。

一般ユーザに設定ファイルを書いてもらう場合には、検証と適切なエラーメッセージが出せるかどうかがポイントになります。

その点ではJSONパーサに処理をまかせてしまうよりは、手動で一行を読み込みsplitで分割する手法が案外役に立つ場面が多いでしょう。

RubyによるJabberクライアントの作成とパスワードの管理

普通のJabberクライアントをRubyで作成するためには、 xmpp4rライブラリを使って数行のコードで完結します。

今回は外部のサーバにコードを配置するので、少し要件を加える事にしました。

  • プログラマとパスワードを管理する人間は別であるという前提が存在する
  • JabberIDのパスワードはスクリプトに埋め込まない
  • パスワードが書かれたファイル単体が流出しても問題ないよう暗号化する
  • 各ファイルは別の名前に変更して使う事ができるようにする

さらにTest::Unitフレームワークでテストができるように、コードの共通部分はモジュール(module YaJabber)の中に埋め込んでいます。

ファイルの構造

今回はRuby 1.9.2で標準ライブラリに加わっている点を考えて、ファイルフォーマットにJSONを使用しました。

ディレクトリ構造は以下のとおりで、各ファイルへは相対パスでアクセスするようになっています。


  ./sendmsg2j.rb            ## コマンド本体
  conf/sec_config.json      ## デフォルトの共通鍵ファイル
  conf/config.json          ## デフォルトの設定ファイル
  conf/gmail_config.json    ## Gmail接続用見本
  conf/jabber_config.json   ## Jabber.org接続用見本
  lib/xmpp4r                ## jabberクライアント用ライブラリ
共通鍵ファイルの形式

暗号化に使用する共通鍵データファイル: sec_config.json

{
  "sec_text" : "5ee6f7e62b8b1de083bb2617738a4ade620f9c09"
}
接続用ID設定ファイルの形式

パスワードやJabberサーバへの接続情報は別ファイルとして扱っています

Jabberクライアントの接続情報 Gmail版: gmail_config.json

{
  "user_name":"xxxxx@gmail.com/Batch",
  "user_pass":"357150946dd3950f96af22f2f639bc4d",
  "user_salt":"4e6c13d4aac544b2",
  "server_name":"talk.google.com",
  "server_port":"5222",
  "remote_user":"xxxxx@gmail.com/Home",
  "msg_subject":"Post message from VPS"
}

Jabberクライアントの接続情報 Jabber.org版: jabber_config.json

{
  "user_name":"xxxxx@jabber.org/Batch",
  "user_pass":"fcf53684962bc19f273e5a7c9bad257f",
  "user_salt":"bf6123bf1e2c52b6",
  "server_name":"jabber.org",
  "server_port":"5222",
  "remote_user":"xxxxx@jabber.org/Home",
  "msg_subject":"Post message from VPS"
}

この2つの設定ファイルを送信したいIDによってコマンドオプションで設定ファイルを切り替えるか、デフォルトの設定ファイル config.json を上書きして使っています。

想定する使い方

初期化時のシナリオ

sec_config.jsonファイルの"sec_text" : の右辺を任意の文字列で上書きする。

{ "sec_text" :   "この中を任意の文字列で書き換える" }
パスワード設定/変更時のシナリオ
  • 新しいパスワードを準備する
  • ./sendmsg2j.rb -eを実行する
  • パスワードを入力し、出力された2行をconfig.jsonファイルの内容と差し替える
  • テスト用メッセージを送信する
メッセージの送信のシナリオ

送りたいメッセージは標準入力から読み込むので、コマンドの出力などをそのまま送信できます。

echo "message" | ./sendmsg2j.rb

外部ファイルからメッセージを読み込む場合はリダイレクトで行ないます。

./sendmsg2j.rb < message_file.txt
設定ファイルの変更方法

'-c'オプションを使って、別のID設定情報を使う。

echo "message" | ./sendmsg2j.rb -c conf/gmail_config.json
共通鍵ファイル名を変更したい

'-s'オプションを使って、別の共有鍵データファイルを使う。

echo "message" | ./sendmsg2j.rb -c conf/gmail_config.json

まとめ

Jabberクライアント自体はちゃんと動いているのと、オンラインでいるユーザにメッセージを送信する手段としてはおもしろいと思います。

重要な情報であればメールと組み合せれば、メールサーバの滞留などに対する回避策にもなるでしょう。

問題だと感じたのは、設定ファイルの形式です。 けれど、これは別の話なので記事を分けました。

Appendix. スクリプト本体

いろいろコードをいじったので無駄に焼け太った感じがあります。

module内部のメソッドは関数型言語のようなイメージですが、綺麗に切り取られた印象もなく、かなりごちゃごちゃしています。

パスワードファイルとの分離のためにこうなってしまっただけで、xmpp4rの使い方としては特別な事はしていないのでご注意ください。

#!/usr/local/bin/ruby
# -*- coding: utf-8 -*-
#
#= Yadiary Jabber Client Script
#
# This script assumes the following directory structures.
#
#   ./sendmsg2j.rb          ## main and module file
#   ./conf/sec_config.json  ## the '-s' option changes the file name.
#   ./conf/config.json      ## the '-c' option changes the file name.
#
# This is tested with talk.google.org and jabber.org servers.
#
#== Encryption mode
#
# Your jabber password should be encrypted this script at first.
#
#   ./sendmsg2j.rb -e [-s <sec_config.json>]
#
# After enter your password, then the script shows you 'user_pass' and 'user_salt' parameters.
# These parameters are copied to your 'config.json' file.
# The data structure of the 'config.json' file will be described later.
#
#== Send message mode
# The message body is read by the standard input stream by following command line.
#   ./sendmsg2j.rb [-s <sec_config.json>] [-c <config.json>]
#
# In practical use, you will use this script with following idioms.
#
# 1. ./sendmsg2j.rb [-s <sec_config.json>] [-c <config.json>] < message_file
# 2. echo message | ./sendmsg2j.rb [-s <sec_config.json>] [-c <config.json>]
#
# First case (#1), the 'message_file' contains the entire message body.
# Last case (#2), the 'message' string is your choice.
#
#== Unit Tests
# The 'unittest/test_sendmsg2j.rb' is the executable Test::Unit script file.
# It will test all methods except
#
#== 'sec_config.json file
# The 'sec_config.json' file contains the essential password string.
# It should have the following data structure.
#
#   { "sec_text" : ".. password string like sha1sum hash value ..." }
#
# You can change the filename and location by the '-s' option.
#
#== 'config.json file
# The 'config.json' file is the following data structure.
#   {
#     "user_name":"daemon@example.org/Batch",
#     "user_pass":"5208502be38983bbf6847c0f7554f7f7",
#     "user_salt":"63c9240d88705faf",
#     "server_name":"example.org",
#     "server_port":"5222",
#     "remote_user":"user01@example.org/Home",
#     "msg_subject":"post message from daemon@example.org/Batch"
#   }
# You can change the filename and location by the '-c' option.
#
$:.unshift File.dirname($0)
$:.unshift File.join([File.dirname($0),"lib"])
require 'optparse'
require 'xmpp4r'
require 'json'

# 
# This module is for Yadiary Jabber Client.
#
# These methods should be tested by the unittest/test_sendmsg2j.rb script.
module YaJabber
  
  # It should have the following data structure.
  #
  #   { "sec_text" : ".. password string like sha1sum hash value ..." }
  #
  SEC_CONFIG_FILE = File.join([File.dirname($0), "conf", "sec_config.json"])
  
  # The 'config.json' file is the following data structure.
  #   {
  #     "user_name":"daemon@example.org/Batch",
  #     "user_pass":"5208502be38983bbf6847c0f7554f7f7",
  #     "user_salt":"63c9240d88705faf",
  #     "server_name":"example.org",
  #     "server_port":"5222",
  #     "remote_user":"user01@example.org/Home",
  #     "msg_subject":"post message from daemon@example.org/Batch"
  #   }
  #
  CONFIG_FILE = File.join([File.dirname($0), "conf", "config.json"])
  
  # A wrapper method to read json file.
  #
  # Example:
  #   conf = json_read(SEC_CONFIG_FILE)
  #
  def json_read(file)
    conf = nil
    begin
      conf = JSON.parse(open(file).read)
    rescue
      conf = nil
    end
    return conf
  end
  
  #
  # Get your password string from the given json file.
  #
  # Example:
  #   pass = get_password(sec_conf_file)
  #
  def get_password(sec_conf_file)  # sec_conf_file - test
    conf = json_read(sec_conf_file)
    res = ""
    label = 'sec_text'
    res = conf[label] if conf.kind_of?(Hash) and conf.has_key?(label)
    return res
  end

  # 
  # Example:
  #   plain_text = STDIN.gets.strip
  #   enc_text,salt = encrypt(pass, plain_text)
  #
  def encrypt(pass, plain_text)
    begin
      salt = OpenSSL::Random.random_bytes(8)
      enc = OpenSSL::Cipher::Cipher.new('aes-256-cbc')
      enc.encrypt
      enc.pkcs5_keyivgen(pass.to_s, salt.to_s, 1)
      e_text = enc.update(plain_text) + enc.final
      return e_text.unpack("H*").join, salt.unpack("H*").join
    rescue
      p $!
      return "",""
    end
  end
  #
  # Example:
  #   user_rawpass = decrypt(pass, enc_text, salt)
  #
  def decrypt(pass, text, salt)
    begin
      n_salt = [salt].pack("H*")
      n_text = [text].pack("H*")
      enc = OpenSSL::Cipher::Cipher.new('aes-256-cbc')
      enc.decrypt
      enc.pkcs5_keyivgen(pass.to_s, n_salt.to_s, 1)
      return enc.update(n_text) + enc.final
    rescue
      p $!
      return ""
    end
  end
  
  #
  # It returns the existing filename.
  #
  # Example:
  #   begin
  #     @sec_conf_file = check_file(@option['sec_conf_file'], SEC_CONFIG_FILE)
  #   rescue
  #     printf("[error] security conf file '%s' not found.\n", @sec_conf_file)
  #     exit(1)
  #   end
  #
  def check_file(candidate_file=nil, default_file=nil)
    conf_file = candidate_file
    conf_file = default_file if conf_file == nil or not FileTest.exist?(conf_file)
    raise if not FileTest.exist?(conf_file)
    return conf_file
  end

  #
  # This is a domain specific method.
  # The 'conf' argument must be the CONFIG_FILE format.
  #
  def check_conf(conf={})
    ## remove empty value
    conf.reject! { |k,v|
      true if v == nil or v == ""
    }
    ## set the default value for optional parameters
    conf['server_name'] = "jabber.org" if not conf.has_key?('server_name')
    conf['server_port'] = "5222" if not conf.has_key?('server_port')
    conf['msg_subject'] = format("Messages from %s", conf['user_name']) if not conf.has_key?('msg_subject')
    ## check the config key/value pairs.
    if not conf.has_key?('user_name') or not conf.has_key?('user_pass') or 
        not conf.has_key?('user_salt') or not conf.has_key?('remote_user')
      return false ## means BAD
    end
    return true ## means GOOD
  end
  
  # parse command line options
  def option_parser
    res = { 
      "encrypt_mode" => false,
      "conf_file" => nil,
      "sec_conf_file" => nil
    }
    OptionParser.new do |opts|
      opts.banner = 'Usage: ' + File.basename($0) + '\tinput: read messages from stdin.'
      opts.separator ''
      opts.on('-e', '--encrypt', 'Interactive password encrypt mode') {
        res['encrypt_mode'] = true
      }
      opts.on('-c', '--config filename', 'Set a config file') do |c|
        res['conf_file'] = c if FileTest.exist?(c)
      end
      opts.on('-s', '--sec_config filename', 'Set a security config file') do |c|
        res['sec_conf_file'] = c if FileTest.exist?(c)
      end
      opts.on_tail('-h', '--help', 'Show this message') {
        puts opts
        exit
      }
      opts.parse!(ARGV)
    end
    return res
  end

  # aggreate method which will send a message.
  def send_message(user_name, user_pass, remote_user,msg_subject, server_name, server_port)
    client = Jabber::Client.new(Jabber::JID.new(user_name))
    client.connect(server_name, server_port)
    client.auth(user_pass)
    body = STDIN.readlines.join
    m = Jabber::Message.new(remote_user, body).set_type(:normal).set_id('1').set_subject(msg_subject)
    client.send(m)
    client.close
  end
end

##########
## main ##
##########
if $0 == __FILE__
  include YaJabber

  ###################################
  ## load encrypt/decrypt password ##
  ###################################
  @option = option_parser
  begin
    @sec_conf_file = check_file(@option['sec_conf_file'], SEC_CONFIG_FILE)
  rescue
    printf("[error] security conf file '%s' not found.\n", @sec_conf_file)
    exit(1)
  end
  @crypt_pass = get_password(@sec_conf_file)
  if @crypt_pass.empty?
    printf("[error] Your security config file, %s, doesn't have the 'sec_text' parameter or non-empty value.\n", @sec_conf_file)
    printf("  Please check your security config file.\n")
    exit(1)
  end
  
  #####################
  ## encryption mode ##
  #####################
  if @option['encrypt_mode']
    ## confirm the input
    print "** password encryption mode **\n"
    print "enter string: "
    line = STDIN.gets.strip
    if line.empty?
      print "Please enter the non-empty string as your password.\n"
      exit
    end

    ## ok, let's start.
    enc_text,salt = encrypt(@crypt_pass, line)
    if enc_text.empty? or salt.empty?
      printf(<<-EOF, @sec_conf_file)
  Please check your "sec_text" parameter in the security config json file, %s.
EOF
    else
      printf(<<-EOF, enc_text, salt)
  "user_pass":"%s",
  "user_salt":"%s",
exit.
EOF
    end
    exit
  end
  
  #################
  ## normal mode ##
  #################
  ## load config file
  begin
    @conf_file = check_file(@option['conf_file'], CONFIG_FILE)
  rescue
    printf("[error] conf file, %s, not found.\n", @conf_file)
    exit(1)
  end
  conf = json_read(@conf_file)
  
  if not check_conf(conf)
    printf(<<-EOF, @conf_file)
[error] Config key/value pairs are not enough to start.
The config file %s should have like following values.
{
  "user_name":"sysuser01@jabber.org/Batch",
  "user_pass":"19570e028a958d0cbc91add9c1516e05",
  "user_salt":"e42489150b1bed52",
  "remote_user":"user01@jabber.org/Home",
  "server_name":"jabber.org (option)",
  "server_port":"5222 (option)",
  "msg_subject":"System message from sysuser01@jabber.org/Batch. (option)"
}
EOF
    exit(1)
  end
  
  ##################
  ## send message ##
  ##################
  user_pass = decrypt(@crypt_pass, conf['user_pass'], conf['user_salt'])
  if user_pass.empty?
    printf("[error] Please check 'user_pass' or 'user_salt' fields on config file, %s\n", @conf_file)
    exit(1)
  end
  send_message(conf['user_name'], user_pass, conf['remote_user'],
               conf['msg_subject'], conf['server_name'], conf['server_port'])
end
#################
## end of main ##
#################

Test::Unitを考慮したRubyスクリプトの書き方

DTIのVPSサーバからの連絡をjabber.orgに作ったアカウントで受けるために、RubyでJabberクライアントを作成しました。

単一のRubyスクリプトでクライアントを作成しましたが、その時に少し要件を加えてコードのボリュームが膨らんだので、RubyのTest::Unitフレームワークを使ってテストケースを作成しました。

RubyでTest::Unitを使う場合に、クラス/モジュールファイルにテスト用のコードを加える方法はよくみますが、今回は単体コマンドとしてのスクリプト側にモジュールを寄せて、単体テスト用のコードは別ファイルにしています。

今回使ったRubyのバージョンは1.9.2-p0です。

なおRuby 1.9.xからは単体テスト環境として附属するのはminitestモジュールです。

豊富なアサーションメソッドを使うために、2.1.x系列のTest::Unitモジュールの導入がお勧めです。 → Test::Unit フレームワーク公式サイト

ファイル構造

今回はJabberクライアントの本体をとは別に単体テスト用のスクリプトを作成していて、次のように配置しています。

  • sendmsg.rb
  • ut/test_sendmsg.rb
  • lib/xmpp4r
  • lib/test

lib/testはgemsからインストールした後に gems/gems/test-unit-2.1.2/lib/test/ ディレクトリをコピーしています。

Jabberクライアント本体 スクリプト

sendmsg.rb スクリプトファイル

#!/usr/local/bin/ruby
# -*- coding: utf-8 -*-
$:.unshift File.join([File.dirname($0), "lib"])
require 'xmpp4r'

module YaJabber
  ## ステートレスなメソッド群
  def parse_options
  end
end

if $0 == __FILE__
  include YaJabber
  ## 処理本体が続く
end
単体テスト スクリプト

ut/test_sendmsg.rb スクリプトファイル

#!/usr/local/bin/ruby
# -*- coding: utf-8 -*-
$:.unshift File.join([File.dirname($0), "..", "lib"])
require 'test/unit'

class JabberClientTest < Test::Unit::TestCase
  $:.unshift File.join([File.dirname($0), ".."])
  require 'sendmsg'
  include YaJabber

  def test_parse_options
  end
end

抽象化できる部分は外部ライブラリの形でまとめるべきだとは思いますが、複数のファイルを管理するのはいろいろ面倒な場合もあります。

今回はそこまで難しくない単一のスクリプトファイルを相手にしつつ、Test::Unitを使ってみようとしている場面を想定しています。

規模が小さいとスクリプトをいくつかのシナリオの元で直接実行しても確認はできるので、こういう形式が必要なのか微妙なラインだとは思います。

ただ、こうやって単体スクリプトを作りつつ、他のスクリプトでも使えそうな共通部分があれば、自分用のモジュールライブラリを作れるかなぁ、とか思ってたりしてます。

外部ライブラリの読み込みについて

本当はxmpp4rライブラリやtest/unitライブラリはsite_rubyの中に入れているので、 $:変数を設定する必要はありませんでした。

ただ単体テスト用のスクリプトが"ut"サブディレクトリに入っていたので、スクリプト本体からの相対パスで設定している場合は、単体テストのスクリプトを実行した場合には相対パスの基準がut/サブディレクトリになってしまうため、$:変数を適切に設定する必要があることを忘れないために加えています。

2010/12/15

CouchDB: Implementation of SELECT COUNT(DISTINCT field)...

In CouchDB, the operation with group=true provides separate values for each unique key, like SELECT DISTINCT SQL query.

Next, we can get the total number of unique keys from the above results, like SELECT COUNT(DISTINCT ...) SQL query.

However, if the first results set is quite big, then the second count-up operation will spend a long time. It might be a problem.

Some examples ...

My ruby client can calculate the number of unique keys like following operation;

A example of ruby couchdb client.

couch = Couch::Server.new(@host, @port)
json = JSON.parse(couch.get(URI.escape('/example/_design/all/_view/test?group=true')).body)
total_keys = json['rows'].length

The processing time of the json to array conversion is so trivial, but the resident memory size and network traffic will be increased by this operation.

I implemented the operation with group_numrows=true like group=true, it returns just the length of the 'rows' array.

As an example, a curl command line returns the following results.

$ curl 'http://localhost:5984/example/_design/all/_view/all?group=true'
{"rows":[
{"key":["bar","35"],"value":3},
{"key":["foo","25"],"value":3},
{"key":["somebody","20"],"value":8},
{"key":["yasu","32"],"value":4}
]}

The operation with gorup_numrows=true returns the following line.

$ curl 'http://localhost:5984/example/_design/all/_view/all?group=true&group_numrows=true'
{"group_numrows":"4"}

If you use the limit=2 operation with above example, the results will be {"group_numrows":"2"}. So it's just return the total number which should be returned.

System Information

The patch file is available from the following link.

This patch was developed;

  • Ubuntu 10.04.1 LTS x86_64
  • Erlang R13B03

I'm not sure this implementation is robust enough to any production use, but I've been testing it with the jQuery flexbox plugin at http://www.yadiary.net/postal/main.fcgi.

Sorry it's Japanese only, but if you use pull-down selection boxes, you can see the results will be changed by your privious box's selection. Each selection boxes desn't have an entire results set, the group_numrows=true operation is used to show you the total number.

About performance

In my last blog post, the group_numrows=true operation is 8.5 times faster than first json['rows'].length case.There are almost 100K result lines (almost 3MB json string.)

My implementation is not smart, it just replaces the string output function to my count-up function.

If you have huge results set and just needs the total number and small subset of the results, this might be useful.

Thank you.

CouchDB: group_numrows拡張のパフォーマンス

修正したコードを使ってVMWare上のCouchDBサーバに対して、118,924件の郵便番号を数えた場合の速度を比較してみました。

元々のDBの構造はCSVファイルを1文書に変換したもので、詳細は 以前の記事にあります。

その他のmap/reduce関数の定義、データ取得用スクリプトは次のとおりです。

viewを作成するためのmap,reduce関数

json['views']['code']['map'] = <<-MAP
function(doc) {
  if(doc._id.length == 40) {
    emit(doc.code, 1)
  }
}
MAP
json['views'][label]['reduce'] = "_sum"

使用したRubyスクリプト

#!/usr/local/bin/ruby
# -*- coding: utf-8 -*-

require 'csv'
require 'couchdb'
require 'json'
require 'uri'

couch = Couch::Server.new("localhost","5984")

def show(couch, uri)
begin
  res = couch.get(URI.escape(uri))
  json = JSON.parse(res.body)
p json['group_numrows'] if json.has_key?('group_numrows')
p json['rows'].length if json.has_key?('rows')
rescue
  p $!
end
end
#uri = YaCouch::DBname + '/_design/all/_view/code?group=true'
#show(couch, uri)
uri = YaCouch::DBname + '/_design/all/_view/code?group=true&group_numrows=true'
show(couch, uri)

これにtimeコマンドを使って実行速度を測ってみました。

VMWareを使うとディスクアクセスは特にホストOSのファイルキャッシュの影響を強く受ける傾向があると感じているので、何回か両方のスクリプトを実行した後に計測を始め、各2回目の結果を載せています。

Array.lengthを使って重複を取り除いたキーの数を数える方法

配列の数を数え上げるわけですが、各要素は、 {"key"=>"9998524", "value"=>1}、のような形式になっています。

118924

real	0m8.183s
user	0m4.188s
sys	0m3.812s

group_numrowsパラメータでCouchDBから取得する方法

この戻り値は単純で、クライアントが受け取るのは、 {"group_numrows":"118924"}の一行だけです。 スクリプトの最後の4行のコメントを変更して、同じように実行しています。

"118924"

real	0m0.952s
user	0m0.132s
sys	0m0.052s

Array.lengthを計算しない、JSON.parseを実行しない場合の速度

スクリプトをちょっと変更して、res = couch.get(URI.escape(uri))行の次にreturnを挿入して、すぐに戻るようにしてみました。

real	0m7.796s
user	0m3.784s
sys	0m3.836s

今回の環境では、ほとんどネットワークトラフィックの影響が時間に大きな影響を与えていて、JSON.parse()自体が時間を取っているわけではない事がわかります。

まとめ

この後に実環境でも試しましたが、おおむね同じような結果になりました。

ただし、group_numrowsを使った場合の実時間が最大1.4[s]程度、配列を取得する場合の時間が最小5.7[s]程度となり、その差は縮まっています。

erlangの軽量プロセスについて、もう少し勉強して何か不備はないか確認しようと思います。

2010/12/14

CouchDBでSELECT COUNT(DISTINCT ...)をするために必要なこと

CouchDBを使うためのネタとして 郵便番号検索 (http://www.yadiary.net/postal/)を作成しています

ここで入力をサポートするためにjQueryプラグインのFlexBoxから、候補を検索するためのQueryを投げて、CouchDBから候補となるkeyを取り出しています。

このFlexBoxは必要に応じてリクエストを投げていて、全件数を内部的に持つことはしていません。

町村名、その他地名をすべて数え上げた時に9万5千件の先頭10個が表示されている画面イメージ

この画像ではFlexBoxに表示されるべき候補が9万5千件あって、その先頭10件が表示されている様子を示しています。 この数字は、各ページの先頭に編集中の文字列が追加されているので、実数からページ数分だけ水増しされています。

この9万5千件という数字は、FlexBoxからの10件毎のQueryへの返信に加える必要があります。

つまり通常のCouchDBで普通に作成すると、毎回の問い合わせの返信内容は10件の内容なのに、全件数を追加するために、内部的にはこの9万5千件のデータを取得して件数の数字だけを作る事になります。

もちろんサーバ側で9万5千という数字をキャッシュする事は可能で、今回も9万5千件という数字もFastCGIの起動時に計算して保持しています。

他の数字についてもキャッシュする事は可能です。 しかし、今回はホスティング環境(DTIのVPS - Entry)でのメモリの制約があります。

RubyスクリプトでHashオブジェクトによる試作では、FastCGI RubyスクリプトのResident Memoryのサイズ上昇が許容範囲を越えていました。

そこでCouchDBだけに負荷を押し付けて、件数を返す機能を追加することにしました。

前書きの後の前書き 〜 技術的背景の説明

CouchDBに関連するドキュメントでは、いわゆる重複を取り除いた結果を取り出すためのSQLでいうところの SELECT DISTINCT に相当する方法として、VIEWについてmap/reduceの両関数を定義して、 group=trueを引数に加える方法を紹介しています。

この時のreduce関数は組み込みの"_sum"や"_count"にするのが一般的でしょう。

大抵はこれで問題ありませんが、SELECT DISTINCTした結果が、それでも比較的大きかった場合に、その全数を数え上げるための方法が問題になります。

ここでは一度JSONをHashオブジェクト等に変換して、その長さを求めることを考えます。

クライアント例:/examples/_design/allに、View名allを設定している場合に、allの結果を数え上げる


@couth = Couch::Server.new("localhost", "5984")
uri = '/examples/_design/all/_view/all?group=true'
json = JSON.parse(@couch.get(URI.escape(uri)).body)
printf "num of distinct rows: %d\n", json['rows'].length
num of distinct rows: 1039

この時にクライアント側のHashオブジェクト(json)は大量のメモリを消費します。 戻り値(@couch.get(...).body)をテキストとしてパースする事もできますが、それでも、ネットワーク帯域とレイテンシの観点からみて、大量のStringオブジェクトを経由する処理は効率的とはいえません。

今回はSQLでいうところの SELECT COUNT(DISTINCT field) FROM tableに相当する処理をCouchDBにさせてみる事を考えました。

まず結論から

今回の方法は軽量プロセスを生成して、Group化された結果(e.x. {"key"=>"三重県", "value"=>2473})を表示する部分で回数だけを軽量プロセスにカウントさせて、最後にその結果だけを受け取って軽量プロセスを終了させる方法をとりました。

タイムアウトやらのエラー処理は十分ではないですし、linkしていないためにプロセスが滞留する可能性もあるかもしれません。

結果はおもしろくなりましたが、かなりad-hocなパッチになっている事はご理解ください。

また軽量プロセスのプログラミングについては、 Erlang Worldを参考にさせて頂き、ほぼその内容に依っています。

基本事項の確認 - group=trueな時のCouchDBの内部動作

CouchDBへViewを設定すると、保持しているドキュメントの一部をキーとしてまとめて表示したり(map関数)、そのまとめたドキュメントの数を数えたり(reduce関数)することができます。

さらにgroup=trueを追加してリクエストを投げると、そのキーの重複を排除して表示することができます。

しかし、この重複を排除したキーの合計数を取り出す仕組みがないために、今回のようにメモリが潤沢でなかったり、reduceしてもまだ結果が大きい場合に問題が起こります。

CouchDBがgroup=true時にやっていること、と解決へのアプローチ

内部での処理は、ほぼ apache-couchdb-1.0.1/src/couchdb/couch_httpd_view.erl で完結しています。

先頭からいくつかの関数の連鎖を通って、send_json_reduce_row(Resp, {Key, Value}, RowFront)関数が出力する文字列(e.x. {"key"=>"三重県", "value"=>2473})を作っています。

send_json_reduce_row関数

send_json_reduce_row(Resp, {Key, Value}, RowFront) ->
    send_chunk(Resp, RowFront ++ ?JSON_ENCODE({[{key, Key}, {value, Value}]})),
    {ok, ",\r\n"}.

本質的にはこの関数を呼び出しているDatabase Engine部分に手を入れ、件数をカウントさせて、関数の戻り値に入れれば良さそうですが、いまのsend_json_reduce_rowの引数をみると、戻り値を受けられるような余地はありません。

内部的には出力を素早く行なうための機能に特化しているようにみえて、今回は、このsend_json_reduce_row関数が呼び出された回数を数えることにしました。

内部的にグローバル変数のようなものを持たせるわけにはいかないので、処理の前半で軽量プロセスを作成して、その後に呼ばれる関数引数を増やしてそのPidを適当な処理まで渡しています。

変数"Pid"に注目すればコードを追うのは簡単だと思います。パッチ本体とファイルへのリンクはこの記事の最後に載せました。

使い方 - 追加したオプションパラメータ

今回はgroup=trueと併用する group_numrowsというパラメータを増やしました。

例えば 郵便番号検索Databaseに入っている都道府県(pref)について、group=trueをした場合の件数(47)を数えると次のようになります。

$ curl -u reader:xxxxxx 'http://localhost:5984/postal/_design/all/_view/pref?group=true&group_numrows=true'

この出力は次の通りです。

{"group_numrows":"47"}

group_numrows=falseとした時の出力(の一部)は次の通りです。 "value"の数字は郵便番号DBの全レコード12万件中の何件分かを表していることになります。

{"rows":[
{"key":"\u4e09\u91cd\u770c","value":2473},
{"key":"\u4eac\u90fd\u5e9c","value":6658},
... ## 47都道府県分のデータが続く 

さらに郵便番号的に、第二フィールドの市区郡(city)の全数はいくつあるのか数えると…

$ curl -u reader:xxxxxx 'http://localhost:5984/postal/_design/all/_view/city?group=true&group_numrows=true'

この出力は次の通りです。

{"group_numrows":"1899"}

これが役に立つのはgroup=trueで返される文字列が、その環境では大き過ぎる場合です。

CouchDB内部で節約できている処理は出力用文字列を生成する部分だけですから、何かB-Treeをトリッキーな方法でtraverseしているわけではありません。

この他のアプロートとしては、RDBMSが中間結果を保持する一時テーブルのように、あらかじめオリジナルの文書群から、中間処理用の文書群を生成しておくこともできるはずです。

まとめ

SQLでいうところのSELECT COUNT(DISTINCT field)が使えないのは、CouchDBのskip, limitパラメータの威力を弱めてしまっていると思います。

jQueryプラグインのFlexBoxとCouchDBは組み合せると、かなり大きなデータも扱う事ができそうだという事が実感できました。

次はFlexBoxのcache更新のタイミングが問題でしょうか…。

Appendix. CouchDB 1.0.1用パッチ

diff -ur apache-couchdb-1.0.1.orig/src/couchdb/couch_db.hrl apache-couchdb-1.0.1/src/couchdb/couch_db.hrl

2010


--- apache-couchdb-1.0.1.orig/src/couchdb/couch_db.hrl	2010-07-20 07:59:53.000000000 +0900
+++ apache-couchdb-1.0.1/src/couchdb/couch_db.hrl	2010-12-14 09:54:37.000000000 +0900
@@ -190,6 +190,7 @@
     skip = 0,
 
     group_level = 0,
+    group_numrows = false,
 
     view_type = nil,
     include_docs = false,
diff -ur apache-couchdb-1.0.1.orig/src/couchdb/couch_httpd_view.erl apache-couchdb-1.0.1/src/couchdb/couch_httpd_view.erl
--- apache-couchdb-1.0.1.orig/src/couchdb/couch_httpd_view.erl	2010-08-08 11:25:40.000000000 +0900
+++ apache-couchdb-1.0.1/src/couchdb/couch_httpd_view.erl	2010-12-14 10:08:16.000000000 +0900
@@ -155,15 +155,22 @@
         group_level = GroupLevel
     } = QueryArgs,
     CurrentEtag = view_group_etag(Group, Db),
+    Pid = case get_group_numrows_type(Req) of 
+            true -> spawn(fun() -> group_numrows_server() end);
+              _  -> false
+          end,
     couch_httpd:etag_respond(Req, CurrentEtag, fun() ->
         {ok, GroupRowsFun, RespFun} = make_reduce_fold_funs(Req, GroupLevel,
                 QueryArgs, CurrentEtag, Group#group.current_seq,
-                #reduce_fold_helper_funs{}),
+                #reduce_fold_helper_funs{}, Pid),
         FoldAccInit = {Limit, Skip, undefined, []},
         {ok, {_, _, Resp, _}} = couch_view:fold_reduce(View,
                 RespFun, FoldAccInit, [{key_group_fun, GroupRowsFun} |
                 make_key_options(QueryArgs)]),
-        finish_reduce_fold(Req, Resp)
+        case get_group_numrows_type(Req) of
+          true -> finish_reduce_fold(Req, Resp, [], Pid);
+             _ -> finish_reduce_fold(Req, Resp)
+        end
     end);
 
 output_reduce_view(Req, Db, View, Group, QueryArgs, Keys) ->
@@ -173,10 +180,14 @@
         group_level = GroupLevel
     } = QueryArgs,
     CurrentEtag = view_group_etag(Group, Db, Keys),
+    Pid = case get_group_numrows_type(Req) of 
+            true -> spawn(fun() -> group_numrows_server() end);
+              _  -> false
+          end,
     couch_httpd:etag_respond(Req, CurrentEtag, fun() ->
         {ok, GroupRowsFun, RespFun} = make_reduce_fold_funs(Req, GroupLevel,
                 QueryArgs, CurrentEtag, Group#group.current_seq,
-                #reduce_fold_helper_funs{}),
+                #reduce_fold_helper_funs{}, Pid),
         {Resp, _RedAcc3} = lists:foldl(
             fun(Key, {Resp, RedAcc}) ->
                 % run the reduce once for each key in keys, with limit etc
@@ -190,7 +201,10 @@
                 {Resp2, RedAcc2}
             end,
         {undefined, []}, Keys), % Start with no comma
-        finish_reduce_fold(Req, Resp, [{update_seq,Group#group.current_seq}])
+        case get_group_numrows_type(Req) of
+          true -> finish_reduce_fold(Req, Resp, [{update_seq,Group#group.current_seq}], Pid);
+             _ -> finish_reduce_fold(Req, Resp, [{update_seq,Group#group.current_seq}])
+          end
     end).
 
 reverse_key_default(?MIN_STR) -> ?MAX_STR;
@@ -203,6 +217,9 @@
 get_reduce_type(Req) ->
     list_to_existing_atom(couch_httpd:qs_value(Req, "reduce", "true")).
 
+get_group_numrows_type(Req) ->
+    list_to_existing_atom(couch_httpd:qs_value(Req, "group_numrows", "false")).
+
 load_view(Req, Db, {ViewDesignId, ViewName}, Keys) ->
     Stale = get_stale_type(Req),
     Reduce = get_reduce_type(Req),
@@ -303,6 +320,8 @@
     [{reduce, parse_bool_param(Value)}];
 parse_view_param("include_docs", Value) ->
     [{include_docs, parse_bool_param(Value)}];
+parse_view_param("group_numrows", Value) ->
+    [{group_numrows, parse_bool_param(Value)}];
 parse_view_param("list", Value) ->
     [{list, ?l2b(Value)}];
 parse_view_param("callback", _) ->
@@ -385,6 +404,8 @@
 % Use the view_query_args record's default value
 validate_view_query(include_docs, _Value, Args) ->
     Args;
+validate_view_query(group_numrows, _Value, Args) ->
+    Args;
 validate_view_query(extra, _Value, Args) ->
     Args.
 
@@ -393,7 +414,7 @@
         start_response = StartRespFun,
         send_row = SendRowFun,
         reduce_count = ReduceCountFun
-    } = apply_default_helper_funs(HelperFuns),
+    } = apply_default_helper_funs(HelperFuns, Req, Req),
 
     #view_query_args{
         include_docs = IncludeDocs
@@ -425,10 +446,13 @@
     end.
 
 make_reduce_fold_funs(Req, GroupLevel, _QueryArgs, Etag, UpdateSeq, HelperFuns) ->
+  make_reduce_fold_funs(Req, GroupLevel, _QueryArgs, Etag, UpdateSeq, HelperFuns, nil).
+
+make_reduce_fold_funs(Req, GroupLevel, _QueryArgs, Etag, UpdateSeq, HelperFuns, Pid) ->
     #reduce_fold_helper_funs{
         start_response = StartRespFun,
         send_row = SendRowFun
-    } = apply_default_helper_funs(HelperFuns),
+    } = apply_default_helper_funs(HelperFuns, Req, Pid),
 
     GroupRowsFun =
         fun({_Key1,_}, {_Key2,_}) when GroupLevel == 0 ->
@@ -488,7 +512,7 @@
         #view_fold_helper_funs{
             start_response = StartResp,
             send_row = SendRow
-        }=Helpers) ->
+        }=Helpers, _Req, _Pid) ->
     StartResp2 = case StartResp of
     undefined -> fun json_view_start_resp/6;
     _ -> StartResp
@@ -504,19 +528,21 @@
         send_row = SendRow2
     };
 
-
 apply_default_helper_funs(
         #reduce_fold_helper_funs{
             start_response = StartResp,
             send_row = SendRow
-        }=Helpers) ->
+        }=Helpers, Req, Pid) ->
     StartResp2 = case StartResp of
     undefined -> fun json_reduce_start_resp/4;
     _ -> StartResp
     end,
 
     SendRow2 = case SendRow of
-    undefined -> fun send_json_reduce_row/3;
+    undefined -> case get_group_numrows_type(Req) of
+                   true -> gen_send_json_reduce_row(Pid);
+                      _ -> fun send_json_reduce_row/3
+                 end;
     _ -> SendRow
     end,
 
@@ -586,6 +612,24 @@
     send_chunk(Resp, RowFront ++ ?JSON_ENCODE({[{key, Key}, {value, Value}]})),
     {ok, ",\r\n"}.
 
+gen_send_json_reduce_row(Pid) ->
+  fun(_Req, _KV, _RowFront) ->
+    Pid ! {countup},
+    {ok, ",\r\n"}
+  end.
+
+group_numrows_server() ->
+  group_numrows_server(0).
+group_numrows_server(X) ->
+  receive
+    {status, From} ->
+      From ! X, group_numrows_server(X);
+    {countup} ->
+      group_numrows_server(X+1);
+    {stop, From} ->
+      From ! X
+  end.
+
 view_group_etag(Group, Db) ->
     view_group_etag(Group, Db, nil).
 
@@ -651,6 +695,25 @@
 finish_reduce_fold(Req, Resp) ->
     finish_reduce_fold(Req, Resp, []).
 
+get_group_numrows_final_results(Pid) ->
+  Pid ! {stop, self()},
+  receive
+    X -> X
+  end.
+
+finish_reduce_fold(Req, Resp, Fields, Pid) ->
+    case Resp of
+    undefined ->
+        send_json(Req, 200, {[
+            {rows, []},
+            {group_numrows, 0}
+        ] ++ Fields});
+    Resp ->
+        X = get_group_numrows_final_results(Pid),
+        send_chunk(Resp, "{\"group_numrows\":\"" ++ integer_to_list(X) ++ "\"}"),
+        end_json_response(Resp)
+    end.
+ 
 finish_reduce_fold(Req, Resp, Fields) ->
     case Resp of
     undefined ->

2010/12/11

FlexBoxの検索を絞るためにQueryにキーを追加してみる

前回は 郵便番号検索の中で、町村名などを入力する際に jQueryプラグインのFlexBoxを使って動的に検索結果を表示させました。

配列などで静的に候補を指定する場合には、対応するautocomplete系のjQueryプラグインは複数あります。 都道府県レベルでは、これでも十分に対応できます。

しかし郵便番号データベースに登録された市区郡以下の町村レベルになると、9万5千件を越えるアイテムがあります。 前回からjQueryプラグインのFlexBoxを使うことで、page, sizeの指定による部分的な結果を要求するFlexBoxは対象数が大きな場合に効果的である事がわかりました。

とはいえ、他にキーとなる入力があれば、それを一緒に渡す事でさらに検索対象を狭めることが期待できます。 これはエンドユーザへのより適切な選択肢の提供を目的としたものです。

今回は完全一致が期待できる都道府県名に注目をして、ここに値が入力されていた場合には、その都道府県名をキーとして町村名の検索範囲を狭めることにしました。

これによって大量の候補を扱う事によるメモリの消費も抑える事ができ、全体的な印象はかなり改善されたと思います。

変更したFlexBoxの動きについて

FlexBoxが動的に候補を表示する場合に使うQUERY_STRINGのkeyは、"s","p","q","contentType"の4つだけでした。 今回はこのkeyに kを追加します。

"k"に対応する値には $(QueryKey).val()の結果を渡すようにしていて、この"QueryKey"自体はオプションとして指定された文字列です。

QueryKeyの値としては、"#input-pref"のようなセレクタを想定しています。

アクセスログに残っているリクエス行は次のようなものでした。

... "GET /postal/street.1.4.fcgi?q=&p=1&s=10&contentType=application%2Fjson%3B+charset%3Dutf-8&k= HTTP/1.1" 200 50 ...

この"k"に指定された値をCouchDBのViewのstartkeyとendkeyに渡すことで検索を行なっています。

変更したFlexBoxの使いどころ

Google Toolbarの検索ボックスのように、単独で入力文字を補完するような場合にはノーマルなFlexBoxが向いていると思います。

今回の変更は、同じ画面で別のフォームに入力された従業員番号などの情報も一緒にした検索を可能にします。 より適切な対象を候補として表示する事が可能になることを期待しています。

自分の目的には2つの値をキーとして渡す事も考えていて、汎用的な拡張は難しいですが、各種の目的に応じて検索候補を要求するqueryを変更するのは良いアイデアのように思えます。

とりあえずjQuery + FlexBoxを使って、いろいろな方法で入力補完を試してみようと思います。

修正した後のアプリケーションの動きについて

現在は 郵便番号検索のトップページから開発版に進むと、FlexBoxを使ったautocompleteを試す事ができます。

いま現在の動きは「都道府県名」を完全に入力した場合に、町村名が補完されるようになります。

サーバはメモリが256MBのプランで、最大512MBまで使えるとはいっても、いろいろキャッシュする事はできません。 とはいえCouchDBと補完用候補検索用のRubyスクリプトはよく動いてくれています。

CouchDBが常に使っている常駐メモリ(RSS)分は23MB、FastCGIのメモリは検索対象の大きな町村名を扱う部分で15MBほどです。 仮想メモリ全体でも100MBほどですから、DTIのVPSを使っている分には正解な感じです。

Alixで使う分にも問題はないのですが、大きなViewを一気に作る時にはCPUパワーが足りない印象です。それでも稼働確認としての動き自体にはやはり問題ありません。

修正したコードのdiff出力

diffの出力は下記のとおりですが、なんというか、小さい変更で良い結果を手に入れることができました。

$ diff -u jquery.flexbox.js.orig jquery.flexbox.js

flexbox 0.9.6に対するdiffの結果

--- jquery.flexbox.js.orig	2010-11-24 13:03:02.000000000 +0900
+++ jquery.flexbox.js	2010-12-11 11:57:56.000000000 +0900
@@ -1,3 +1,9 @@
+/*
+ * This file contains some modification written by Yasuhiro ABE <yasu@yasundial.org>
+ * Copyright (c) 2010 Yasuhiro ABE (http://www.yasundial.org)
+ * Original copyright is following;
+ */
+
 /*!
 * jQuery FlexBox $Version: 0.9.6 $
 *
@@ -53,6 +59,7 @@
         scrolling = false,
         pageSize = o.paging && o.paging.pageSize ? o.paging.pageSize : 0,
 		retrievingRemoteData = false,
+	queryKey = o.queryKey, // added by Yasuhiro ABE
         $div = $(div).css('position', 'relative').css('z-index', 0);
 
         // The hiddenField MUST be appended to the div before the input, or IE7 does not shift the dropdown below the input field (it overlaps)
@@ -252,7 +259,9 @@
                     showPaging(p, cached.t);
                 }
                 else {
-                    var params = { q: q, p: p, s: pageSize, contentType: 'application/json; charset=utf-8' };
+                    //var params = { q: q, p: p, s: pageSize, contentType: 'application/json; charset=utf-8' };
+                    var kQueryString = $(queryKey).val();
+		    var params = { q: q, p: p, s: pageSize, contentType: 'application/json; charset=utf-8', k: kQueryString };
                     var callback = function(data, overrideQuery) {
                         if (overrideQuery === true) q = overrideQuery; // must compare to boolean because by default, the string value "success" is passed when the jQuery $.getJSON method's callback is called
                         var totalResults = parseInt(data[o.totalProperty]);
@@ -848,7 +857,8 @@
             showSummary: true, // whether to show 'displaying 1-10 of 200 results' text
             summaryClass: 'summary', // class for 'displaying 1-10 of 200 results', prefix with containerClass
             summaryTemplate: 'Displaying {start}-{end} of {total} results' // can use {page} and {pages} as well
-        }
+        },
+	queryKey: '' // will be added to the k=$(QueryKey).val() to the outer query url
     };
 
     $.fn.setValue = function(val) {

まとめ

もともと町村名には最大で9万5千件の候補が表示されるようになっていましたが、これ自体はFlexBoxが範囲を区切って候補を要求するためパフォーマンス上の問題ではありませんでした。

しかしユーザには常に9万5千件の一部が表示される状態だったため、検索対象を絞るための情報を有効に活用するのが利便性を向上させるだろうと考えたのが今回のFlexBox改造の動機でした。

CouchDBをバックエンドに持っていて、FastCGIやCouchDBの再起動時にこそ表示が素早く行えないなどの問題がありますが、通常はほぼ問題のないレスポンスを返しているようです。

もちろん利用者が自分だけという理由もありますが、パフォーマンスの改善にはまだ余地があります。 VPSの256MBのプランですし、テストベンチにしてはよく動いていると思います。

JavaScriptについて

関数型言語として興味はありますが、ブラウザのDOMを操作する事にはあまり熱心になれないので、JavaScriptは得意ではありません。

そのためGoogleで多くのJavaScriptについての検索を行ない、いろいろ参考にさせて頂きました。

しかしいくつかの解説の中には"id"値が 文書全体で唯一の要素を指定するために使われるってことをどれぐらい意識しているのかと疑問に思う場面がありました。

JavaScriptを使うと入力をサポートすることができますが、強制する事は何一つできません。

ユーザからの入力は適切な範囲に収まっているかサーバ側で検証する必要があるのは、まず大切なことです。

2010/12/08

データ処理用のJSONアプリを作って、FlexBoxを使ってみた

jQueryプラグインのFlexBoxを使ってみた結果のまとめです。

Fairwayが提供するサンプル、デモへのリンクはたくさんあったんですが、FlexBoxを使った具体例はなかなかみつかりませんでした。

jQueryのプラグイン紹介ページからダウンロードできるバージョンは少し古いので、Fairwayのリンク先から CodePlexのFlexBoxページに飛んで、0.9.6をダウンロードして使っています。

背景

CouchDBの検証用にDTIのVPSサービスを利用して、インターネットに実サーバを借りたのでCouchDB、その他もろもろをインストールしてアプリケーションを作る準備をしていました。

ネタはOpenLDAPの時と同じで、 郵便番号の検索システムです。

CouchDBを使うのが目的なので、単純に番号から住所が分かっても、それほどおもしろくなさそう。 それに、似たようなシステムはたくさんあるから、これを元ネタにいろいろ検索できたり、新しい方法を試すのがおもしろいんじゃないかと思い機能を追加しています。

本当は全文検索システムにデータを入れて、探せばいいと思うんですけどね。 それでも整理された項目について条件を絞り込む、こういうシステムもおもしろいと思います。

今回はその中で都道府県名欄への入力をサポートする仕組みを使った時の作業メモです。

他の欄にも応用できそうですが、まずは47都道府県の漢字、カタカナに空文字を加えた95通りを検索させています。

検索リスト候補の表示

JavaScriptが動く環境でどれくらい入力をサポートできるものか試したいと思い、jQueryベースのリストボックスを探して、みつかったのがFairwayの FlexBoxでした。

サンプルをみた感じはちゃんと動きそうですが、検索をしても実例の紹介はあまりみつからず、裏側のデータを取得する部分の仕組みについてのガイドがない状態でした。

とりあえず使うだけなら、JSON的な内容のファイルを準備するだけで、動きそうだと思ったのが間違いでした…。 そんなに大変じゃなかったですけどね。

まずはテキストファイルを元に静的なリストを表示する

Fairwayのサイトにある FlexBoxのデモページの先頭には、"json.txt"というファイルを使って動かす例が載っています。

よくよく試すと分かりますが、これは検索結果を絞り込む機能はありません。 単純に入力した文字が強調されるだけです。最初はこれに気がつかず、json.txtファイルを準備しました。

静的検索用のjson.txtデータを作成する

最初は適当に都道府県データをRubyでHashに入れて、.to_jsonメソッドで変換しました。

rubyと郵便番号データを使った都道府県リストを生成するスクリプト

#!/usr/local/bin/ruby
# -*- coding: utf-8 -*-
##
require 'csv'
require 'json'
entry = Hash.new

tmp = Hash.new
id = 1
CSV.open(ARGV[0], 'r').each do |row|
  ## prepare output format
  pref = row[6]
  pref_kana = row[3]

  tmp[pref] = 0
  tmp[pref_kana] = 0
end

res = Hash.new
res["results"] = [{"id"=>"0","name"=>""}]
id = 1

tmp.keys.each do |pref|
  res["results"] << {"id" => id.to_s, "name" => pref}
  id += 1
end

print res.to_json

引数にUTF-8に変換した郵便番号のCSVファイルを指定して実行すると、画面に結果が出力されるので、適当にリダイレクトしてjson.txtファイルを作成します。 出力結果はおもしろくないので省略。

$ ./show_all_pref_list4flexbox.rb ../initdb/ken_all.utf8.csv

このjson.txtファイルを検索ページと同じディレクトリに配置して、検索ページの中にJavaScriptを埋め込みます。

これは動きますが、100近い候補が単純にリスト表示されるのは、まったく便利ではありません。うれしくもないし、楽しくもない。

最初はこれで絞り込みもできると思ったんですけどね、そんなに甘くはありませんでした。

動的に検索結果を絞り込む検索ボックスにする

Example 2:から先のデモは、/flexbox/results.aspx からJSON形式の検索結果をもらって表示している例があります。

問題は検索に使っているプロトコルが書かれていないところです。

ブラウザから/flexbox/results.aspxにアクセスしてみるとエラーになってしまいます。

とりあえず準備したjson.txtの内容を返信するCGIアプリを作成してみました。

json.txtの内容をそのまま返すCGI用bashスクリプト

#!/bin/bash

BASEDIR="$(dirname $0)"

echo "Content-type: application/json"
echo ""

cat "${BASEDIR}/pref_json.txt"

結局はこのスクリプトをポイントするログをみて、 q, p, s, contentTypeをキーにしていることが分かりました。それを元に検索をしてみると、stackoverflowのサイトに「 jQuery FlexBox: how to retrieve user's query & process submitted form?」などがひっかかります。

ここまでわかれば実際に動いているアプリケーションを観察することができるようになります。

$ curl 'http://www.fairwaytech.com/flexbox/results.aspx?q=&s=10&p=10'
{"results":[{"id":90,"name":"taxonomy"},{"id":91,"name":"tectonic"},{"id":92,"name":"tempestuous"},{"id":93,"name":"thermodynamics"},{"id":94,"name":"totalitarian"},{"id":95,"name":"unctuous"},{"id":96,"name":"usurp"},{"id":97,"name":"vacuous"},{"id":98,"name":"vehement"},{"id":99,"name":"vortex"}],"total":"105"}

いくつかパラメータを変更すると、このWebサービスの動きがわかってきます。

  • "p"は1から始まって、0だとエラーを返す
  • "p"が適切な範囲を越えた場合、全件数分を値として返す
  • "s"は1から始まって、0だと全件数分を値として返す
  • "s"が適切な範囲を越えた場合、全件数分を値として返し、特に問題はない
  • "id"は0から始まる
  • "results"と平行に"total"が設定されていて全件数を常に含めている

"p"が適切な範囲を越えた場合には値は帰らない方が適切な気はしますが、他は妥当そうな動きです。

"total"を結果に含めて、決められた件数だけを送信するようにしないと、flexboxが"s"や"p"のパラメータを変更してURLにアクセスすることはしませんでした。

細かい動きはコードを読むしかないようですが、いままでの結果を踏まえてjson.txtを読み込みFastCGI(fcgi.rb)を使って都道府県名のリストを返すようにしました。

flexboxに指定する pref.fcgi Rubyスクリプト

#!/usr/local/bin/ruby
# -*- coding: utf-8 -*-

ENV['GEM_HOME'] = "/opt/lib"
require 'rubygems'
require 'fcgi'
require	'json'

Basedir = File.dirname($0)

pref_file = File.join(Basedir, "pref_json.txt")
json = JSON.parse(open(pref_file,"r:utf-8").read)
json_total_rows = json['results'].length

FCGI.each {|request|
  out = request.out
  out.print "Content-type: application/json\r\n\r\n"

  q = CGI.parse(request.env['QUERY_STRING'])
  page = q['p'][0].to_i
  size = q['s'][0].to_i
  query = q['q'][0]
  ## check the parsed queries
  size = json_total_rows if size == 0
  size = 1 if size < 0
  page = 1 if page < 1

  ## ok, go ahead  
  start = page * size - size
  res = {'results' => [], 'total' => "0"}
  if query == nil or query == ""
    res['results'] = json['results'][start,size] if json['results'][start,size] != nil
    res['total'] = json_total_rows.to_s
  else
    json['results'].each do |item|
      if item['name'].index(query.to_s) == 0
        res['results'] << item
      end
      res['total'] = res['results'].length.to_s
    end

    res['results'] = res['results'][start,size]
  end

  out.print res.to_json + "\n"
  request.finish
}

次に一覧を表示して、">>"ボタンで最後に飛んだところ、ちゃんと10個づつ値を取得する様子が観察できました。


2001:3ex:xxx:x:xxx:xx:xx:xxxx - - [08/Dec/2010:23:07:55 +0900] "GET /postal/pref.fcgi?q=&p=1&s=10&contentType=application%2Fjson%3B+charset%3Dutf-8 HTTP/1.1" 200 348
2001:3ex:xxx:x:xxx:xx:xx:xxxx - - [08/Dec/2010:23:07:59 +0900] "GET /postal/pref.fcgi?q=&p=10&s=10&contentType=application%2Fjson%3B+charset%3Dutf-8 HTTP/1.1" 200 212

curlを使ったコマンドラインで動きを確認する事もできます。

$ curl 'http://www.yadiary.net/postal/pref.fcgi?s=1&p=98'

まとめ

とりあえずFlexBoxを使った 郵便番号の検索システムの補完機能はちゃんと動いています。

紹介されているサイトは多かったですが、少し敷居が高いのかもしれません。 でもCouchDBとは相性が良さそうな動きをしています。

このパラメータようにCouchDBに新しいViewを定義するか、他の方法を考えてみようと思っています。

みばえに頼るのは考えもの…

もっともJavaScriptで入力をサポートする仕組みは諸刃の剣で、JavaScriptが安定して動かなければ悲惨な結果になるのは目にみえています。 そのためJavaScriptをoffにしても通常のformがvisibleになるだけで、使えるようにしています。

JavaScriptをoffにしてもCSSは有効のままな場合の方が多いのかなと思っています。

反対にCSSでdisplay: noneを設定してJavaScriptでvisibleにする使い方は、JavaScriptとCSSが同時にオフになった場合を考えると想定していないformが表われて悲惨そうです。

でもJavaScriptでDOMオブジェクトをいじる様をGoogle ChromeのInspectorでみたら、よく考えるなぁ、という気持ちになりました。

2010/12/06

pdumpfsをruby 1.9.2-p0に対応させてみた

DTIのVPSサーバを契約してからバックアップを取るためにpdumpfsを使っています。 このために余計なパッケージは入れたくなかったので、自前で入れたruby-1.9.2-p0で動くようにpdumpfsを修正しました。

pdumpfsは ~/.gvfsでエラーになる対応の投稿のコードで、これをベースとしてruby 1.9.2-p0で動かしてみました。

オリジナルのライセンス

今回修正したpdumpfsは手元にあるものでおそらくpdumpfs-1.3だと思います。 ライセンスは以下のとおりで、もちろん私の修正もこのライセンスに準じます。

# Copyright (C) 2001-2004 Satoru Takabayashi <satoru@namazu.org>
#     All rights reserved.
#     This is free software with ABSOLUTELY NO WARRANTY.
#
# You can redistribute it and/or modify it under the terms of
# the GNU General Public License version 2.

パッチファイル

作成したファイルです。

大部分の方には上のdiffファイルが良いのだろうと思います。 offsetはかかりますが、手元では無事にpatchで修正できています。

以下に、また別の方法で作成したdiffを載せておきますが、これは私が~/.gvfsを無視するように修正したコードを含んでいます。

$ tar xvzf ~/pdumpfs-1.3.tar.gz
$ cd pdumpfs-1.3/
$ make
$ cp pdumpfs ../pdumpfs.orig
$ cd ..
$ diff -u pdumpfs.orig pdumpfs

コマンドの出力結果 (自分用)

--- pdumpfs.orig	2010-12-06 14:13:14.000000000 +0900
+++ pdumpfs	2010-12-06 14:12:47.000000000 +0900
@@ -1,4 +1,4 @@
-#! /usr/bin/env ruby
+#! /usr/local/bin/ruby
 #
 #  pdumpfs - a daily backup system similar to Plan9's dumpfs.
 #
@@ -48,21 +48,21 @@
 #
 
 require 'find'
-require 'ftools'
 require 'getoptlong'
 require 'date'
+require 'fileutils'
 
 class File
   def self.real_file? (path)
-    File.file?(path) and not File.symlink?(path)
+    FileTest.file?(path) and not FileTest.symlink?(path)
   end
 
   def self.anything_exist? (path)
-    File.exist?(path) or File.symlink?(path)
+    FileTest.exist?(path) or FileTest.symlink?(path)
   end
 
   def self.real_directory? (path)
-    File.directory?(path) and not File.symlink?(path)
+    FileTest.directory?(path) and not FileTest.symlink?(path)
   end
 
   def self.force_symlink (src, dest)
@@ -79,7 +79,7 @@
   end
 
   def self.readable_file? (path)
-    File.file?(path) and File.readable?(path)
+    FileTest.file?(path) and FileTest.readable?(path)
   end
 
   def self.split_all (path)
@@ -129,7 +129,7 @@
   GetVolumeInformation = Win32API.new("kernel32", "GetVolumeInformation",
                                       "PPLPPPPL", "I")
   def get_filesystem_type (path)
-    return nil unless(File.exist?(path))
+    return nil unless(FileTest.exist?(path))
 
     drive = File.expand_path(path)[0..2]
     buff = "\0" * 1024
@@ -807,12 +807,14 @@
     end
 
     def exclude? (path)
+      if @patterns.find {|pattern| pattern.match(path) }
+        return true
+      end
+      
       stat = File.lstat(path)
 
       if @size >= 0 and stat.file? and stat.size >= @size
         return true
-      elsif @patterns.find {|pattern| pattern.match(path) }
-        return true
       elsif stat.file? and
           @globs.find {|glob| File.fnmatch(glob, File.basename(path)) }
         return true
@@ -868,7 +870,7 @@
       today  = File.join(dest, datedir(start_time), base)
 
       File.umask(0077)
-      File.mkpath(today) unless @dry_run
+      FileUtils.mkpath(today) unless @dry_run
       if latest
         update_snapshot(src, latest, today)
       else
@@ -1018,7 +1020,7 @@
 
       case type
       when "directory"
-        File.mkpath(today)
+        FileUtils.mkpath(today)
       when "unchanged"
         File.force_link(latest, today)
       when "updated"
@@ -1052,7 +1054,7 @@
 
       Find.find(src) do |s|      # path of the source file
         if @matcher.exclude?(s)
-          if File.lstat(s).directory? then Find.prune() else next end
+          if FileTest.directory?(s) then Find.prune() else next end
         end
         r = make_relative_path(s, src)
         l = File.join(latest, r)  # path of the latest  snapshot
@@ -1089,7 +1091,7 @@
 
           case type
           when "directory"
-            File.mkpath(t)
+            FileUtils.mkpath(t)
           when "new_file"
             copy(s, t)
           when "symlink"

これは自分が後から別のデスクトップ環境を使う時のための保存用ですが、似たような問題があれば使ってください。

2010/12/04

SSLを有効にして、コンテンツの警告を修正してみた

ServersMan@VPS上のApacheにRapidSSLからCertificationを購入して、SSLを有効にしてみました。

SSL証明書の購入自体は簡単で、海外のリセラーからPaypalで決済した後に、RapidSSLのSSL購入サイトに飛ばされてすぐに処理が終りました。

海外のリセラーを使ったのは、国内の代理店だと普通すぎるのと、海外でディスカウントセールをやっていたのが、その理由。

昔と違って特別なApacheや特別なプラグインは必要ないので、普通に/usr/lib/ssl/misc/CA.pl -newreqを使って作成したnewreq.pemを貼り付けて、証明書が返送されてきて、/etc/apache2/sites-available/default-sslの設定をコピーして完了。

ポイントは証明書の返送されるメールアドレスがWhoisに登録されているアドレスか、ドメイン名か、ホストのFQDNの3つからしか選べないところ。

Whoisにはお名前.comの代理サービスを使っているので、最初の選択肢は外れて、ServersMan@VPSでメールを受け取れないと困った事になります。 ちゃんとDNSのMX設定をしてキャッシュが更新された頃を見計らって手続きをしないとメールが行方不明になってしまいそうです。

だいたい待ち時間を入れて1時間ぐらいの作業だったけれど、自分の知識ベースだと、結局参考にしなかったCSRを生成する国内代理店のガイドは判りづらいと感じました。 海外リセラーは作り方のガイドを分かりやすい場所には載せていなかったけれど、CSRのチェックページがあって確認をすることができました。国内の事業者も掲載していますね。

ファイルはkey, csr, certの3種類だけのはずなんだけど、なんでこんなに複雑に感じるんだろう。

手順はシンプルに越したことはないけれど、チェックするべきポイントをちゃんと書かないといけないなぁ、と認識を新たにした作業でした。

SSLを有効にするとブラウザがちょっとした警告を出す

ページの中にSSLを経由しない画像ファイルなどが埋め込まれていると警告を出してきます。

まぁW3CのCSS Validator等の画像はローカルにコピーしても良い事が明記されているので、そういう対応でいいとして、問題だったのはGoogleのカスタム検索のロゴ用画像ファイルでした。

カスタム検索のダイアログを埋め込むと、www.google.co.jp/cse/brandにform=cse-search-boxを指定して、JavaScriptファイルを読み込みます。

このサイトへのアクセスをSSLにしても、この中に書かれている画像ファイルが'http://'固定なので警告になります。

Google Analytics用に埋め込むコードはSSLに対応して変更されたので、それをそのまま使ってdocument.location.protocolに応じてURLを変更するようにしました。

変更前 var b = function(){..}

    var b = function() {
      if (q.value == '') {
        q.style.background = '#FFFFFF url(http:\x2F\x2Fwww.google.com\x2Fcse\x2Fintl\x2Fen\x2Fimages\x2Fgoogle_custom_search_watermark.gif) left no-repeat';
      }
    };

変更後 var b = function(){..}

    var b = function() {
      if (q.value == '') {
        var ga = ('https:' == document.location.protocol ? 'https://' : 'http://') + 'www.google.com\x2Fcse\x2Fintl\x2Fja\x2Fimages\x2Fgoogle_custom_search_watermark.gif';
        q.style.background = '#FFFFFF url(' + ga + ') left no-repeat';
      }
    };

ちゃんとした対応としては、 似たような質問への回答をみると、該当JavaScriptファイルへのアクセスをコメントアウトするしかなさそうです。

今回のケースだと本来GoogleがメンテナンスするJavaScriptファイルはローカルに配置せざるを得ないので、自分の責任で管理する必要があります。

またいろいろなコンテンツの更新をチェックする仕組みを自分で作るかなぁ…。

2010/12/02

ServersMan@VPSをDNSサーバにしてIPv6の名前解決をする

ドメインをお名前.comで取得して、プライマリDNSサーバをServersman@VPSに構築しました。 お名前.comの標準機能にはDNSのレコード管理がありますが、AAAAレコードに対応していません。

そこでお名前.comのセカンダリDNSの機能を使ってAAAAレコードを持つDNSサーバのセカンダリとして動いてもらう事にしました。

なおOSは Debian Squeezeを使っていて、パッケージで導入したBINDのバージョンは9.7.1です。

重要! 制限事項 〜できること、できないこと〜

今回は新規に取得したドメイン名の管理を自分の手で行なう事、特にVPSサーバ以外の IPv6接続に使う複数のAAAAレコードの管理が目的です。

取得したドメイン名(example.org)下のホスト名からIPアドレスを検索する正引きについては自由に制御する事が可能です。

しかしPTRレコード(*.ip6.arpa, *.in-addr.arpa)の名前解決は、IPアドレスを管理する事業者でないとできません。

ServersMan@VPSでは、UbicName管理下のドメインでないとPTRレコードの管理ができないところがポイントです。

とはいえTCPWrapperでparanoia設定を使うわけでもないし、自分の目的の範囲では逆引きは必要ないので、今回は値段と興味のバランスを取ってお名前.comでドメインを新規に取得しました。

このトレードオフはバランスが悪いので、ServersMan@VPSだけを使っているのであれば、(たかだか1800円ですし)UbicNameを使ってドメインを取得しないと後悔すると思います。

自分でDNSサーバを管理する理由

IPv6に対応したDNS管理機能を提供する事業者は、それほど多くはありません。 また何かしらのサービスに含まれている場合もありますが、それらの機能を使うために支払う金額は、比較的高額です。

海外の事業者もいくつかチェックしましたが、AAAAレコードに対応しているかどうか明記していないところや、信用できなさそうなところばかりでした。

今回ドメインを取得したお名前.comも、標準のDNSレコード管理機能ではAAAAレコードの登録はできません。

そういう意味ではUbicNameは問題ないですよね。普通は。ServersMan側で逆引きにもちゃんと対応しているし。 個人的に問題なのは自由にCNAMEとAAAAレコードが追加できないところでした。

IPv6対応は早急に進むと思われるので、あと1,2年の間には、ほとんどのドメイン管理事業者は無償提供のDNS管理機能でのAAAAレコードの管理に対応するでしょう。

けれど、どうせ1、2レコードの追加ができるだけだろうし、いろいろ制限も多そうなので、自分でDNSサーバを立てることにしました。

そこで問題になるのは、ドメイン保持者としてDNSサーバを登録する場合には、最低でも2台のDNSサーバ名が必要です。 そこで追加料金なしに利用できるお名前.comの標準機能、セカンダリDNSサーバを利用することにしました。

とりあえずServersMan@VPSでDNSサーバを構築する

パッケージを使ってしまえば、debianをDNSサーバにするのは、それほど難しくありません。

難しいのは「正しく」使うことです。その点では、まだまだ修行中です…。

パッケージの導入

既に一般ユーザで作業をしていますが、hostnameが変わると使えなくなるsudoは怖いので、しばらくrootで作業をしました。

# apt-get install bind9 dnsutils
簡単に設定

設定するドメイン名を example.orgだとすると、まずは

/etc/bind/named.conf.localに追加したzone設定

zone "example.org" {
        type master;
        file "/etc/bind/db.example.org";
        allow-transfer { 210.172.129.81; };
        notify yes;
        forwarders {};
};

次にfile行に追加したファイルを作成します。

/etc/bind/db.example.orgファイルの全体

$TTL	1800
@	IN	SOA	example.org. root.example.org. (
			      1		; Serial (updated: 2010/12/2 08:37)
			 604800		; Refresh
			  86400		; Retry
			2419200		; Expire
			   1800)	; Negative Cache TTL
;
@	IN	NS	www.example.org.
@	IN	NS	2nd.dnsv.jp.
@	IN	MX	20	www.example.org.
@	IN	A	183.1xx.xx.xxx
@	IN	AAAA	2001:2e8:601:x:x:x:x:x
www	IN	A	183.1xx.xx.xxx
www	IN	AAAA	2001:2e8:601:x:x:x:x:x

安定して動くようになったらファイル先頭のTTLとSOAセクション最後のNegativeCache TTLの値を604800秒(48時間)程度に増やします。 頻繁に設定を変更する可能性のある間は短かめの時間にしておいた方が便利だと思います。

この設定ファイルで重要なのは2行目で、root@example.orgのメールアドレスでメールを受け取ることができると表明している点です。

しかも"@ IN MX"行で、そのメールは"www.example.org"が受け付けるとなっています。

この"www.example.org"がServersMan@VPSで作成したホスト名になるので、名前を変更したいなら"www"全てを適当な名前に変更します。

debianにはexim4が導入されていましたが、外部からメールを受け取る25番ポート(smtp)はlocalhostだけに開いていました。

このためにexim4の再設定が必要になります。

MTAの再設定

"exim4"パッケージを再設定しようとすると、「"exim4-config"でやってくれ」といわれるので、そちらのパッケージを指定してlocalesのように再設定をします。

# dpkg-reconfigure exim4-config

最初に選択する項目は先頭の「internet site; mail is sent and received directly using SMTP」です。 はぁ。

次にmail nameを入力しますが…。用途に応じてホスト名だったり、ホスト名+ドメイン名なFQDNだったり、ドメイン名だったりを入力します。

今回はホスト名と関係なく、foo@example.org形式のメールアドレスを作成したかったので、 example.orgと入力しました。

次に25番ポートを開くIPアドレスを入力します。 デフォルトでは、IPv4とIPv6のloopbackアドレスが入っているので、追加する形でVPSサーバに割り当てられたIPv4とIPv6アドレスを追加します。

127.0.0.1 ; ::1 ; 183.1xx.xx.xxx ; 2001:2e8:601:x:x:x:x:x

次にlocal domainを入力するので、local host名とexample.orgを入力しておきます。

example.org ; www

受け取るrelay mailのアドレスはないので、ここと次のsmarthostについての欄は空欄。

次にdial-upだったら頻繁に発生しないようにするけど…、と聞かれるので、そんな気づかいは無用と"No"を選択。 だんだん質問がマニアックに…。

qmailは最近流行ってるわけでもないしMaildir形式はパス。ファイルサイズが大きくなるのが心配ですが、伝統のmbox形式に変更。

そして設定ファイルの形式が単一ファイルなのか、50ぐらいに分割したファイル群なのかを選択。 これって決められるのは嬉しいような混乱を招くだけのような…。

rootとpostmaster宛てのメールはforwardして欲しくないんだけど、どのユーザに届ける?と聞かれたので、唯一の作業用アカウント名を指定。

ここまで質問に答えると、無事にexim4が再起動して終了。 期待通りに動いているかテストします。

MTAの動作テスト

とりあえず外部からのメールについての応答をするか25番ポートの開き具合いを確認しておきます。

# netstat -naW | grep :25
tcp        0      0 183.1xx.xx.xxx:25      0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:25            0.0.0.0:*               LISTEN
tcp6       0      0 2001:2e8:601:x:x:x:x:x:25 :::*                    LISTEN
tcp6       0      0 ::1:25                  :::*                    LISTEN

とりあえずはVPSに割り当てられたIPv4とIPv6アドレスが加わっていれば大丈夫です。

次は、いきなりメールがバウンスしても困るので、とりあえずはローカルユーザからexample.orgユーザ宛てのメールが出せるかテストします。

# mail -s 'test from localhost' root@example.org

このメールは先ほど指定した /var/spool/mail/user1 などのローカルユーザ名のファイルになっているはず。

一般ユーザでメールを読むのもmailコマンドでできますが、/var/spool/mail以下のファイルを直接確認した方が早いです。

# su - user1
$ mail

これぐらいの設定が終ったら、DNSサーバの設定を続けます。

DNSサーバ側の残作業

/etc/bind/named.conf.localと/etb/bind/db.example.orgの2つのファイルの設定が終りました。

後から問題がみつかったので、 /etc/bind/named.conf.optionsファイルを編集します。

options {
	directory "/var/cache/bind";
	auth-nxdomain no;    # conform to RFC1035
	listen-on-v6 { any; };
	recursion no;
};

ServersMan@VPSでは/etc/resolv.confは再起動の度に初期化されるようですし、これを変更する意味はないので、forwardersは空のままでキャッシュサーバとしては働かないようにしておきます。

最後に再起動をして設定を反映させます。

# /etc/init.d/bind9 restart
基本的な稼働確認

digコマンドを使って名前が引けることと、外部からのaxfrリクエストを拒絶するところは確認しておきます。

まずはVPS上で、digコマンドを使って名前を引いてみます。

# dig @::1 www.example.org
...
;; ANSWER SECTION:
www.example.org.	1800	IN	A	183.1xx.xx.xxx
...

次に外部のサイトからaxfrリクエストを送ってみます。

$ dig @183.1xx.xx.xxx example.org axfr
; (1 server found)
;; global options: +cmd
; Transfer failed.

とりあえず、これぐらできたらお名前.comのドメインNaviに移動して設定をしていきます。

  • 「ホスト登録・変更・削除」から"www.example.org"と"183.1xx.xx.xxx"のIPアドレスの組を登録する
  • 「セカンダリDNS設定」から、「他のネームサーバを利用」を選択し、プライマリ:"www.example.org"、セカンダリ:"2nd.dnsv.jp"としてDNSサーバを登録する

まずホスト登録を行なう必要があるところが、ひっかかりやすそうな感じがします。

とりあえず手元の環境では、ここまで終っていますが、自分のプロバイダのDNSサーバに反映されるのはお名前.comでドメインを取得した後に設定されたTTL設定の48時間後のようです。 更新が終わらないので、しばらく様子をみることにします。

待ち時間で、その他の確認作業

お名前.comでの作業が無事に終ると、数分後には2nd.dnsv.jpでzoneファイルが転送されているようでした。

ここで2nd.dnsv.jpに登録されている情報を確認しておきます。 家のLinuxからnslookupを使います。

$ nslookup -type=AAAA www.example.org 2nd.dnsv.jp
Server:		2nd.dnsv.jp
Address:	210.172.129.81#53

www.example.org	has AAAA address 2001:2e8:601:x:x:x:x:x
VPSの/etc/resolv.confにあるDNSサーバで同じ事をしてみる

たまたま確認したら反映が終っていたので、同じようなqueryを投げてみました。

$ nslookup -type=AAAA www.example.org 202.2xx.2xx.xx
Server:		202.2xx.2xx.xx
Address:	202.2xx.2xx.xx#53

Non-authoritative answer:
www.example.org	has AAAA address 2001:2e8:601:x:x:x:x:x

Authoritative answers can be found from:
example.org	nameserver = www.example.org.
example.org	nameserver = 2nd.dnsv.jp.
2nd.dnsv.jp	internet address = 210.172.129.81
外部からのスキャン

あとは外部からスキャナを使っていろいろ確認してみるのが良さそうです。 例えば www.mxtoolbox.com は、SMTPサーバの簡単な応答チェックもできます。

例えば黄色になって"Warning"となっている項目は例えば次のようになっています。

250 dti-vps-srv02 Hello please-read-policy.mxtoolbox.com [64.20.227.133] [156 ms]

このSMTPの応答をみているとExim4の動きとして、/etc/hostsのIPアドレスのすぐ次に書いてあるホスト名を、そのまま応答名に使うようです。

そこで/etc/hostsを次のように変更しました。

127.0.0.1 localhost
::1 localhost
2001:2e8:601:x:x:x:x:x v6-2001-2e8-601-x-x-x-x-x.ub-freebit.net dti-vps-srv02 www www.example.org
183.1xx.xx.xxx v-183-1xx-xx-xxx.ub-freebit.net dti-vps-srv02 www www.example.org

sudoを正しく動かすためには、uname -nの出力になるホスト名が、この中に含まれていなければなりません。 また/etc/hostnameにwwwを一行で書くなどしてホスト名の変更を恒久的にします。

設定を反映させるためにexim4のリスタートをします。

# /etc/init.d/exim4 restart

これで先ほどのチェックを再び行ないます。

250 v6-2001-2e8-601-x-x-x-x-x.ub-freebit.net Hello please-read-policy.mxtoolbox.com [64.20.227.133] [156 ms]

こんな事を繰り返しながら問題がないか確認していきます。

とりあえず限定された外部とのメールのやり取りもできるので、あとはDNSの変更がプロバイダまで反映されるのを待とうと思います。

メールサーバは無料版のGoogleAppsに統合してしまうというのも手かとは思いますが、既にプロバイダからそういうアドレスを貰っていて、重複してもつまらないので、今回はパスしました。

そうそう今回はいわゆるコンテンツDNSサーバを構築したので、直接関連しないですが、キャッシュDNSサーバのセキュリティについては IPA DNS Security、などのドキュメントを参照してください。

2010/12/2 23:55反映:
この文書にあるhttp://recursive.iana.org/などでチェックすると、IPv6のアドレスでだけrecursionを許可している設定が見つかったので、named.conf.optionsにrecursion no;の設定を本文に反映させています。

DTIのServersMan@VPSでdebianを選択して最初にやったこと

以前から気になっていたDTIのServersMan@VPSでdebianがOSとして選択できるようになってしばらく経ちましたが、Alixより少しパワーが欲しかったのと、Debian Squeezeのテスト環境を兼ねて使ってみることにしました。

Debian LennyをSqueezeにする手順はまとめている方がいらっしゃるのと、それほど難しくないので、それ以前にやった事のメモを残しておきます。

初回ログインの第一印象

ssh経由とはいえ、rootユーザでのログインはなんだか斬新な感じです。

世界中からパスワードが分かればログインできるというのも、なんとなく不安な感じです。

ゆくゆくは作業用の一般ユーザを作るとして、それ以前にやった方がいい事がいくつかあったのでまとめておきます。

localesの設定

apt-getなどPerlを呼び出す場面ではロケール関連のエラーが表示されます。

perl: warning: Setting locale failed.
perl: warning: Please check that your locale settings:
	LANGUAGE = "ja_JP.UTF-8",
	LC_ALL = (unset),
	LANG = "ja_JP.UTF-8"
    are supported and installed on your system.
perl: warning: Falling back to the standard locale ("C").

ロケールが適切に設定されていない事が原因なので再設定します。

# dpkg-reconfigure locales

一覧からlocaleを選択しますが、おすすめは次の3つです。

  • en_US.UTF-8
  • ja_JP.EUC-JP
  • ja_JP.UTF-8

EUCは時代遅れですが、まだ古いrubyを使っていてEUCで動いているアプリケーションもあるので、念のため選択しています。

最終的に選択した3つのロケール

デフォルトロケールとしてen_US.UTF-8を選択しています。

パッケージ更新の前に/etc/apt/sources.listの調整

デフォルトの設定ではftp.debian.orgにアクセスしようとします。

日本国内にあるサーバから海外にアクセスするのは遅い上に無駄なので、これを修正します。

修正後の/etc/apt/sources.listファイル

deb http://ftp.jp.debian.org/debian/ lenny main
deb-src http://ftp.jp.debian.org/debian/ lenny main

deb http://ftp.riken.jp/Linux/debian/debian-security lenny/updates main
deb-src http://ftp.riken.jp/Linux/debian/debian-security lenny/updates main
#deb http://security.debian.org/ lenny/updates main
#deb-src http://security.debian.org/ lenny/updates main

deb http://ftp2.jp.debian.org/debian-volatile lenny/volatile main
deb-src http://ftp2.jp.debian.org/debian-volatile lenny/volatile main
#deb http://volatile.debian.org/debian-volatile lenny/volatile main
#deb-src http://volatile.debian.org/debian-volatile lenny/volatile main

volatileやsecurityパッケージは国内のミラーサーバからだと失敗した経験があるので、コメントアウトしたものも残しておきます。必要に応じて切り替えてください。

サーバ名は volatileパッケージのミラーsecurityパッケージのミラー(ftp.riken.jpを参照)の情報を参照して変更することもできます。

パス名が変更になる場合もあるので動いている設定とリストの記述をよくみてください。

不要なパッケージの削除

自分の目的にはdbusやlprngは不要なので、まとめて削除しました。

# apt-get remove lprng dbus sasl2-bin libnss-ldap
apacheを起動しないように設定

開いているポートの確認はnetstat -naなどで行ないますが、apacheはしばらく使わないので止めておきます。

# update-rc.d -f apache2 remove

起動時や停止時にapache2を操作する/etc/init.d/apache2スクリプトへのシンボクックリンクを/etc/rc?.d/*apache2シンボリックリンクを削除してくれます。

SSH経由でのログインを制限する

できればLinuxなどのクライアントの方が良いですが、ssh-keygenを使ってVPSログイン用のSSHの公開鍵と秘密鍵のペアを作ってSSHのパスワードログインは使わないようにします。

# ssh-keygen -t rsa
Generating public/private rsa key pair.
Enter file in which to save the key (/root/.ssh/id_rsa): /root/id_rsa_vpslogin
Enter passphrase (empty for no passphrase): xxxxxxxxxx
Enter same passphrase again: xxxxxxxxxx
Your identification has been saved in /root/id_rsa_vpslogin.
Your public key has been saved in /root/id_rsa_vpslogin.pub.
The key fingerprint is:
c7:a1:a3:ac:61:9b:1d:d0:d8:26:32:fe:0e:d7:aa:2e root@dti-vps-srv02
The key's randomart image is:
+--[ RSA 2048]----+
|                 |
|                 |
|          .      |
|     +   o .     |
|  o + + S o      |
| . o * . o       |
|  o + =          |
|E  = B .         |
| oooB .          |
+-----------------+

これで2つのファイルが手に入ります。

  • /root/id_rsa_vpslogin - 秘密鍵
  • /root/id_rsa_vpslogin.pub - 公開鍵
/root/id_rsa_vpslogin - 秘密鍵の取り扱い

VPSサーバでkeygenを実行した場合は、このファイルをクライアントマシンにコピーしてサーバからは削除します。

/root/id_rsa_vpslogin.pub - 公開鍵の取り扱い

このファイルは、VPSサーバ上の /root/.ssh/authorized_keys として配置します。

VPSサーバ上でkeygenを実行していればmvするだけです。

# mkdir -p /root/.ssh
# chmod 0700 /root/.ssh
# mv /root/id_rsa_vpslogin.pub /root/.ssh/authorized_keys
# chmod 600 /root/.ssh/authorized_keys
鍵ファイルを使ったログインテスト

クライアントマシンにある id_rsa_vpslogin ファイルを使って、VPSサーバにログインします。

$ ssh -i id_rsa_vpslogin root@127.x.x.x

パスワードを聞かれずにログインできれば終了です。

SSHでパスワードを使ったログインを禁止する前に

VPSの特徴として、リモートでログインできなくなったら終りというのがあります。

一般ユーザでログインして、sudoを使おうとした場合には、ホスト名とIPアドレスの変換が最低でも/etc/hostsを経由して行なえなければいけません。

DNSにも/etc/hostsにも記述のないホスト名を/etc/hostnameに記述すると、次回の再起動後にsudoが使えなくなります。

パスワードを使ったログインを禁止するのは簡単ですが、少なくともrootと一般ユーザの2つに別々のキーを作成しておき、ログインできるかどうかのテストを入念に行なう必要があります。

一般ユーザでログインできればsuコマンドでrootになれますが、いろいろ調子に乗って変更すると落とし穴にはまる可能性があるので注意が必要です。

最後にパスワードを使ったログインを禁止する

いろいろテストを入念にやった最後の最後にパスワードを使ったログインを禁止しておきます。

この作業は自信がなければ行なわずに、確信が持ててから行なう事が必要です。

ま、始めたばかりですから初期化すればいいんですけどね。

変更後の/etc/ssh/sshd_configファイル編集個所

# Change to no to disable tunnelled clear text passwords
PasswordAuthentication no

いろいろなオーケストレーションツールはrootでのログインとか、公開鍵を使ったssh経由でのログインを必要としますが、パスワードを設定できなかったりすると嫌ですね。

SSHを使えば安全というものでもないので、ssh-keygenで鍵を作成する時にはパスフレーズを必ず適当なフレーズで設定してあげてください。

さいごに

ServersMan@VPSでのdebianの選択は、経験というよりも責任を自分で取る覚悟がないと、お勧めできないですね。 パッケージの多さは魅力的なんですが、しばらくすると時代遅れになりますし、いざという時には自分の腕が頼りです。

自習目的ならいいですが、不用意にポートを開けて不幸を呼び込まないか心配な面もあります。

CentOSはパッケージの数が少ないですが、ビジネスユースならまずはRedHatベースのCentOSに慣れるべきだと思います。 まぁビジネスのためには「慣れるべき」とはいえ、CentOSを勧める理由は常に○×養成ギブス的な意味合いが強いのが、ちょっと悲しいところです。

ServersMan@VPSには、いろいろな変更を加えていますが、とりあえず最低限はこれぐらい必要でしょう。 できれば推測されにくい名前の一般ユーザでログインして、rootでのログインも禁止( PermitRootLogin yes on /etc/ssh/sshd_config)してください。

Alix上のCouchDBに郵便番号データを入力してみた

LDAPの時も使ったCSV形式で配布されている郵便番号データをCouchDBに入力してみました。

今回の使い方は初期に大量のデータを入力して、使用フェーズではもっぱら参照だけになる、という使い方になるので、CouchDBらしくないとは思ったのですが、Viewを定義してListやShowをテストするのに使おうと思います。

準備したデータの量やハードウェアなどについて

郵便番号データは12万2千件余りで、CouchDBに入力した後のデータサイズはおよそ100MBです。

まずは結論、困った事や思った事

_bulk_docsを使って大量のデータを入力しようとしたのですが、30件程度、サイズで4KB以内程度でないと失敗しました。サイズに上限があるのかどうかは、はっきりしていません。

Alixは256MBしかメモリがないですし、少し特殊なハードウェアですからVMWare上のUbuntu Server 10.04 LTSでメモリを増やして確認しましたが、やはり似たような挙動になりました。

その他にはApacheをSSLで接続するReverse Proxyにした場合のデータスループットは、Stunnelを利用した場合と比較すると、およそ2倍ちょっと遅いという結果になりました。(15分→35分)

これは単純にシングルコア、かつ256MBのメモリで動くAlixにSSLとReverse Proxyが負荷を与えたのかなぁと考えています。 ただ手元のデータでは、ここら辺の考えを証明できてはいません。

最後に全文書を対象に郵便番号をキー(Key)に、文書を値(Value)にするシンプルなViewを追加した場合に、100MBのデータに対して80MB程度のファイルが作成されています。

ここら辺の動きはLDAPと比べても、あまり変わりのないところかなと思いました。

データの加工とCouchDBへの入力

以前LDAPを相手にした時のようなスキーマは必要ないので適当なデータ構造をでっちあげて、次のようなスクリプトを組みました。

カレントディレクトリに置いたcouchdb.rbには前回も使ったCouchDB Wikiにあるサンプルを改造したものを使っています。

あらかじめ /postalにDBを作成しておき、次のようにスクリプトを実行しています。

$ nkf -w ken_all.csv > ken_all.utf8.csv
$ ./initdb.rb ken_all.utf8.csv

initdb.rbスクリプトファイル

#!/usr/bin/env ruby1.9
# -*- coding: utf-8 -*-
##
## CouchDB Wiki : Transactional Semantics with Bulk Updates
## http://wiki.apache.org/couchdb/HTTP_Bulk_Document_API
##

$:.unshift File.dirname($0)

require 'csv'
require 'couchdb'
require 'json'

couch = Couch::Server.new("couchdb.example.org","5984",
         {'user' => 'admin','password' => "xxxxxxxxxxxx"})

uri = '/postal/_bulk_docs'
num = 0
bulk_docs = Hash.new
bulk_docs['non_atomic'] = true
bulk_docs['docs'] = Array.new
entry = Hash.new
CSV.open(ARGV[0], 'r').each do |row|
  ## prepare output format
  entry = Hash.new
  entry['_id'] = "entry." + num.to_s
  entry['num'] = num
  entry['id'] = row[0]
  entry['pref'] = row[6]
  entry['pref_kana'] = row[3]
  entry['city'] = row[7]
  entry['city_kana'] = row[4]
  entry['street'] = row[8]
  entry['street_kana'] = row[5]
  entry['code'] = row[2]
  entry['code_prefix'] = row[1]
  entry['other'] = [row[9],row[10],row[11],row[12],row[13],row[14]]

  bulk_docs['docs'] << entry
  num += 1

  if num % 10 == 9
    res = ""
    while not res.kind_of?(Net::HTTPSuccess)
      begin
        res = couch.post(uri, bulk_docs.to_json)
        if res.kind_of?(Net::HTTPUnauthorized)
          print "num: #{num}, #{res.to_s}\n"
        end
      rescue
        p $!
        p bulk_docs.to_json
        res = ""
      end
    end
    bulk_docs['docs'] = Array.new
  end
end

res = ""
while not res.kind_of?(Net::HTTPSuccess)
  begin
    res = couch.post(uri, bulk_docs.to_json)
    print "num: #{num}, #{res.to_s}\n"
  rescue
    p $!
    res = ""
  end
end

先頭部分は#!/usr/bin/rubyやら/usr/local/bin/rubyやら適当に変更してください。

それとcouch = Couch::Server.new("couchdb.example.org","5984",の行は、それぞれの環境に合せて修正してください。

ここまで終って適当にデータが入っているか確認だけしておきます。

$ curl -u admin:xxxxxxxxxxx http://couchdb.example.org:5984/postal/_all_docs?limit=10
{"total_rows":122971,"offset":0,"rows":[
{"id":"_design/all","key":"_design/all","value":{"rev":"1-5dfb6015c7dde046e055d54f02a2b8d3"}},
{"id":"entry.0","key":"entry.0","value":{"rev":"1-f53d3fc4f2e20aa9a26a642248d442d6"}},
...

出力1行目の total_rowsが12万件を越えているところを確認します。

Viewの追加

テストのために、いくつかの値をキーにする /postal/_design/all文書を加えました。

#!/usr/bin/env ruby1.9
# -*- coding: utf-8 -*-

$:.unshift File.dirname($0)

require 'csv'
require 'couchdb'
require 'json'
require 'uri'

couch = Couch::Server.new("couchdb.example.org","5984",
         {'user' => 'admin','password' => "xxxxxxxxxxxx"})

uri = '/postal/_design/all'

json = Hash.new
## check existing document
begin
  res = couch.get(URI.escape(uri))
  json = JSON.parse(res.body)
rescue
end
json = Hash.new if json.has_key?("error")
p json

## override existing document or write new document
json['language'] = 'javascript'
json['views'] = Hash.new if not json['views'].kind_of?(Hash)
json['views']['by-code'] = Hash.new if not json['views']['by-code'].kind_of?(Hash)
json['views']['by-code']['map'] = <<-MAP
function(doc) {
  if(doc._id.indexOf('entry.') == 0) {
    emit(doc.code, null)
  }
}
MAP
json['views']['by-pref'] = Hash.new if not json['views']['by-pref'].kind_of?(Hash)
json['views']['by-pref']['map'] = <<-MAP
function(doc) {
  if(doc._id.indexOf('entry.') == 0) {
    emit(doc.pref, null)
  }
}
MAP
json['views']['by-street'] = Hash.new if not json['views']['by-street'].kind_of?(Hash)
json['views']['by-street']['map'] = <<-MAP
function(doc) {
  if(doc._id.indexOf('entry.') == 0) {
    emit(doc.street, null)
  }
}
MAP
json['views']['by-code_prefix'] = Hash.new if not json['views']['by-code_prefix'].kind_of?(Hash)
json['views']['by-code_prefix']['map'] = <<-MAP
function(doc) {
  if(doc._id.indexOf('entry.') == 0) {
    emit(doc.code_prefix, null)
  }
}
MAP
res = couch.put(uri, json.to_json)
puts res

ここでも、検索が成功するか確認しておきます。

$ curl -u admin:xxxxxxxxxxx http://couchdb.example.org:5984/postal/_design/all/_view/by-code?key="9650000"&include_docs=true'
{"total_rows":122970,"offset":115550,"rows":[
{"id":"entry.20279","key":"9650000","value":null,"doc":{"_id":"entry.20279","_rev":"1-efbdc6e3516306c14e784eacf30412b8","num":20279,"id":"07202","pref":"\u798f\u5cf6\u770c","pref_kana":"\u30d5\u30af\u30b7\u30de\u30b1\u30f3","city":"\u4f1a\u6d25\u82e5\u677e\u5e02","city_kana":"\u30a2\u30a4\u30c5\u30ef\u30ab\u30de\u30c4\u30b7","street":"\u4ee5\u4e0b\u306b\u63b2\u8f09\u304c\u306a\u3044\u5834\u5408","street_kana":"\u30a4\u30ab\u30cb\u30b1\u30a4\u30b5\u30a4\u30ac\u30ca\u30a4\u30d0\u30a2\u30a4","code":"9650000","code_prefix":"965  ","other":["0","0","0","0","0","0"]}}
]}

このviewを登録すると、だいたい160MBほどの中間ファイルが/usr/local/var/lib/couchdb/.postal_design/に生成されます。

問題なのは時間で、これで1時間半ぐらいでしょうか。 もっともファイルが出来てしまえば動きには問題なくて、検索結果を素早く得る事ができています。

これを元ネタに簡単なWebページを作ってみようと思います。

この記事で取り上げた品々