2013年2月5日火曜日

S3ってなんじゃ?(s3cmd syncではなくinotify+s3cmd sync (or put))

追記 2012/02/06:1ファイルずつなので普通にアップするだけならinotify + s3cmd putでいいかもと思い、題名変更しました。

以前に s3cmdの記事 を書きましたが、先日s3cmdで困った場面に遭遇したので、そのことを書きたいと思います。


問題



WEBサーバーからS3へのファイル同期にs3cmdを使ったプロジェクトがありました。
linuxのサーバーからS3へファイルを同期するのにs3cmdのsyncコマンドを使用しており、その同期がとても遅いというのです。ローカルで1ファイルを追加したとしても同期の度に数時間かかっているようでした。

状況を確認すると、同期対象のS3バケットの中には数百万ファイル存在していて、どうやらそれが問題のようです。
今回はそれを調べてみたいと思います。


再現


まず、手元のEC2環境でそれを再現してみます。
適当なディレクトリにgitからある程度のサイズのあるプロジェクトをいくつかcloneします。
# mkdir /root/dist
# cd /root/dist
# git clone https://github.com/mirrors/perl.git
# git clone https://github.com/apache/cassandra.git
# git clone https://github.com/v8/v8.git
# git clone https://github.com/symfony/symfony.git
# git clone https://github.com/torvalds/linux.git
# ls -lR | wc -l
72694


ファイル数がこの位あれば、ある程度のシミュレートができそうです。
s3cmdをインストールします。今回はyumで入れます。
# cd /etc/yum.repos.d
# curl -OL http://s3tools.org/repo/RHEL_6/s3tools.repo
# sed -i  -e s/enabled=1/enabled=0/ s3tools.repo
# yum install s3cmd --enablerepo=s3tools
# s3cmd --configure

configureの内容は前回と同じです。


次にテスト用のバケットを作成します。




それではsyncしてみます。
# /usr/bin/s3cmd sync -P /root/dist/ s3://memorycraft-sync/
.....

続々と同期のログが標準出力に吐かれていきますが、それが30分たっても全然終わる気配がありません。
まぁ7万ファイルもあるのだから仕方ない。結局全部同期させるのに1時間30分以上かかりました。

さらにその後、1ファイル追加して再度syncしてみます。
# echo "hoge" > /root/dist/symfony/src/Symfony/Component/Process/hoge.txt
# /usr/bin/s3cmd sync -P /root/dist/ s3://memorycraft-sync/
.....

まったく何の反応もありません。。。
--skip-existingをつけてみたら、3分ほどで同期が終了しました。
1ファイルの同期に3分かかるのでは使いものにならなそうです。原因を探ってみます。


調査


今回s3cmdはyumで入れてみましたが、結局ソースを落として見てみます。
# cd /usr/local/src/
# curl -OL http://ftp.jaist.ac.jp/pub/sourceforge/s/s3/s3tools/s3cmd/1.0.1/s3cmd-1.0.1.tar.gz
# tar xzvf s3cmd-1.0.1.tar.gz
# cd s3cmd-1.0.1
$ tree .
.
├── INSTALL
├── NEWS
├── PKG-INFO
├── README
├── S3
│   ├── ACL.py
│   ├── AccessLog.py
│   ├── BidirMap.py
│   ├── CloudFront.py
│   ├── Config.py
│   ├── Exceptions.py
│   ├── PkgInfo.py
│   ├── Progress.py
│   ├── S3.py
│   ├── S3Uri.py
│   ├── SimpleDB.py
│   ├── SortedDict.py
│   ├── Utils.py
│   └── __init__.py
├── s3cmd
├── s3cmd.1
├── setup.cfg
└── setup.py

中身をみてみると、S3配下がS3への接続とユーティリティ部分。s3cmdがコマンドのロジック的な部分のようです。

s3cmdをみてみると、各コマンドのメソッドがcmd_...となっており、ローカルからリモートのsyncはcmd_sync_remote2remoteというメソッドで処理されているようです。

def cmd_sync_local2remote(args):
  ##〜略〜
  s3 = S3(cfg)

  if cfg.encrypt:
    error(u"S3cmd 'sync' doesn't yet support GPG encryption, sorry.")
    error(u"Either use unconditional 's3cmd put --recursive'")
    error(u"or disable encryption with --no-encrypt parameter.")
    sys.exit(1)

  ## Normalize URI to convert s3://bkt to s3://bkt/ (trailing slash)
  destination_base_uri = S3Uri(args[-1])
  if destination_base_uri.type != 's3':
    raise ParameterError("Destination must be S3Uri. Got: %s" % destination_base_uri)
  destination_base = str(destination_base_uri)

  local_list, single_file_local = fetch_local_list(args[:-1], recursive = True)
  remote_list = fetch_remote_list(destination_base, recursive = True, require_attribs = True)                          
  ##〜略〜
                    
どうやらdst_list = fetch_remote_listというところで同期先S3バケットの何かのリストを再帰的に持ってきているようです。

さらにfetch_remote_listメソッドをみてみると、

def fetch_remote_list(args, require_attribs = False, recursive = None):
  ##〜略〜
  if recursive:
    for uri in remote_uris:
      objectlist = _get_filelist_remote(uri)
      for key in objectlist:
        remote_list[key] = objectlist[key]
  ##〜略〜


def _get_filelist_remote(remote_uri, recursive = True):
  ##〜略〜
  s3 = S3(Config())
  response = s3.bucket_list(remote_uri.bucket(), prefix = remote_uri.object(), recursive = recursive)
  ##〜略〜

S3.pyをみると、bucket_listは、特定パス配下のオブジェクトリストをS3のAPIで取得しているようです。 つまり、syncでは同期しようとしているフォルダ配下のオブジェクトすべてを取得しているということになります。 S3のオブジェクトリストの取得にはページネーションも発生するので、これでは配下にS3オブジェクトが数百万あれば、同期の前にファイルリストの取得の時点でものすごい時間がかかってしまいます。

対策


せめて変更があったファイルだけローカル→リモートで同期できたら、ということで、inotifyを使ってみました。
inotifyはファイルシステムのイベント監視のためのツールで、それを利用したinotify-toolsというものがあります。
ファイルの変更などを検知して、それをトリガーにプログラムを実行出来ます。

さっそくインストールします。

# cat /etc/yum.repos.d/dag.repo
[dag]
name=Dag RPM Repository for Red Hat Enterprise Linux
baseurl=http://ftp.riken.jp/Linux/dag/redhat/el$releasever/en/$basearch/dag
gpgcheck=1
gpgkey=http://ftp.riken.go.jp/pub/Linux/dag/RPM-GPG-KEY.dag.txt
enabled=0

# yum install inotify-tools
# yum -y --enablerepo=dag install inotify-tools


ここでは、inotify-toolsのinotifywaitというコマンドを使います。
inotifywaitはこのサイトで詳しく説明されていますが、指定したパス配下でイベントが起こると標準出力にイベントとそれが発生したファイルパスが出力されます。監視するイベントは指定することができます。

上のサイトにあるサンプルを参考にして、先ほどのソースファイル群のディレクトリの変更を検知してS3にsyncするようにしてみました。
ポイントは下記のとおりです。

  • 変更のあったファイルたちを5秒間バッファしてリスト化
  • ファイル追加時に属性変更のイベントも同時に発生したりするので、リストから重複を除去
  • S3でフォルダ内を検索しないように、s3cmd syncはファイル単位で1ファイルごとに呼び出し
  • 通常の呼び出しだとシリアルになってしまうので、&でs3cmdを非同期呼び出し


inotify.sh
#!/bin/sh
TIMEOUT=5
NOTIFYPATH=/root/dist/
BUCKETPATH=s3://memorycraft-sync/
/usr/bin/inotifywait -e create,delete,modify,move,attrib \
  -mrq $NOTIFYPATH | while [ 1 ]; do
  paths="";
  while read -t $TIMEOUT line; do
    path=`echo $line | /usr/bin/awk '{print $1$3}'`
    paths="${paths}$path@RET@"
  done
  if [ -n "$paths" ]; then
     echo $paths | sed 's/@RET@/\n/g' | sort | uniq | while read path; do
     if [ $path -a -f $path ]; then
       filename="`basename $path`"
       dpath="`dirname $path`/"
       rpath=`echo $dpath | sed "s:$NOTIFYPATH::"`
       if [ "$rpath" == $NOTIFYPATH ]; then
         rpath=""
       fi
       echo "/usr/bin/s3cmd sync -P $path $BUCKETPATH$rpath$filename"
       /usr/bin/s3cmd sync -P $path $BUCKETPATH$rpath$filename &
     fi
    done
  fi
done
※1ファイルずつ行うので、普通にアップするだけならs3cmd putで良いかもしれません。

これを実行します。
# cd  /root/bin/
# chmod 755 inotify.sh
# ./inotify.sh

別ターミナルで、監視配下の適当な場所にファイルをつくってみます。
# echo "moge" > /root/dist/symfony/src/Symfony/Component/Process/moge.txt

するとバッファタイムアウトで設定した5秒後にinotifyのターミナルで、同期が実行された旨のログがすぐ出力されました。

# ./inotify.sh
start 2013年  2月  5日 火曜日 19:11:04 JST
path=
end 2013年  2月  5日 火曜日 19:11:04 JST
path=/root/dist/symfony/src/Symfony/Component/Process/moge.txt
/usr/bin/s3cmd sync -P /root/dist/symfony/src/Symfony/Component/Process/moge.txt s3://memorycraft-sync/symfony/src/Symfony/Component/Process/moge.txt
end 2013年  2月  5日 火曜日 19:11:04 JST
/root/dist/symfony/src/Symfony/Component/Process/moge.txt -> s3://memorycraft-sync/symfony/src/Symfony/Component/Process/moge.txt  [1 of 1]
 5 of 5   100% in    0s    72.34 B/s  done
Done. Uploaded 5 bytes in 0.1 seconds, 70.15 B/s



S3をみると、無事にアップされています。


また1ファイルずつsyncするので、一度に追加や変更するファイル量が大量になる場合や空のバケットに対する初期同期の場合は通常のsyncの方が良いと思います。ただ、今回のように同期先のS3にファイルが大量にあったり、更新ファイル数がすくない場合などはinotifyで1つずつファイルを指定してsyncまたはputするほうがはるかに高速です。

S3の困った場面に遭遇する度に思いますが、やはりディレクトリ/ファイルを模したサービスなので、やはりファイルシステムとしての振る舞いを求めてしまうのが人情ってものかと思いました。

以上です。