ClojureのSTMは使い物にならない


0×00. Clojureがいけてる件について


ここ数ヶ月でClojureをどんどん実戦投入してみているが、その成果は素晴らしいの一言に尽きる。Javaでは考えられなかったほどスマートかつ柔軟にデータ処理が可能であり、「あれ、こんなに短い記述でできちゃうのか!」と驚かされることが多い。そんなわけで、何でもかんでもJavaで片付けてきた筆者はここにきてClojureにかなり惚れ込んでおり、電子書籍やらウェブサイトやらで本格的に情報収集を進めているのだが…


0×01. Clojureの並列プログラミング


現時点では、Clojureを実戦投入したのは、ちょっとした処理に使うツール的なものだけである。理由は単に、筆者がまだClojureの初心者だからだ。しかしそろそろメインの仕事であるサーバアプリケーションやウェブアプリケーションでも使いたくてウズウズしてきており、そのような視点からさらに調査を進めている。

サーバアプリケーションやウェブアプリケーションでは並列プログラミング的なことをよくやるのだが、ClojureではこれはSTMを使って処理するのが王道のようだ。Clojureの解説を行う本などでは、STMは従来のJavaのロック地獄から解放してくれる救世主的な扱いをされており、筆者としては非常に期待していた。Javaでのマルチスレッドプログラミングは確かになかなか難しい部分があり、コードレビューなどで問題点を見つけ出す技術には経験に基づく勘が要求される。単に並列プログラミングを行いたいだけなのにメモリバリアーが〜とか言われるため、ある種バッドノウハウ的な側面がある(筆者は個人的に大好きだが)。

ClojureのSTMはぱっと見た感じでは拍子抜けするほどシンプルであり、「これで本当に動くなら、今までのJavaの並列プログラミングは何だったんだ…」と思う仕上がりになっている。


0×02. 遅いらしい


筆者の見落としである可能性もあるが、Clojureの解説を行う本(Programming ClojureとPractical Clojure。ついでに現在The Joy of Clojureを読んでいるところ)では、STMのパフォーマンスに関する記述はなかったように思う。そのため、普通に読んでいる読者は、間違いなく「並列プログラミングが必要とされる場面ではSTMを使えばよい」と考えるだろう。筆者もそう思っていたのだが、ある日Clojureに的を絞っているわけではない書籍である「Programming Concurrency on the JVM」を読んでいたところ、「STMはReadが多く、Writeが少ない場面だけにしておくべし」的な記述があって驚いた。Clojureでは基本的に並列プログラミングにはSTMしか選択肢がない(もちろんClojureからJavaのスレッドを生成したりすることもできるが、それならばsynchronized等の文法がサポートされている生のJavaを使った方が百倍開発しやすいため、却下)のに、書き込み競合が多い場面ではSTMが使えないのでは、開発が成り立たないのではと思ってしまうが…。この書籍内では実際にSTMが遅くて使えないケースをソースコード付きで掲載している。

そんなわけで、単純な例でベンチマークを取ってみることにした。


0×03. ベンチマークの内容


マップをひとつ生成する。このマップに対して多数のスレッドから書き込み競合が多く発生するようなアクセスを行う。具体的には、各スレッド内でランダムにキー(100種類のうちの1つ)を生成し、そのキーに対応する値を取得する。値は数値とする。この数値をひとつインクリメントし、再びマップにセットする。マップ内にキーが存在していない場合には1をセットする。スレッドは300個生成し、上記のインクリメント動作はそれぞれのスレッドで10万回ずつおこなう。これをJavaとClojureでそれぞれ記述したものが以下である。

Test2.java

package test;

import java.util.*;

public class Test2
{
public static final int _keyRange = 100;
public static final int _threadCount = 300;
public static final int _repeat = 100000;
//--------------------------------------------------------------------------------
public static void main( String[] args )
throws Exception
{
final long start = System.currentTimeMillis();
final Map map = new HashMap();

Runtime.getRuntime().addShutdownHook( new Thread( new Runnable()
  {
  public void run()
    {
    System.out.println( System.currentTimeMillis() - start );
    System.out.println( map );
    }
  } ) );

for( int i = 0; i < _threadCount; ++i )
  {
  new Thread( new Runnable()
    {
    public void run()
      {
      for( int k = 0; k < _repeat; ++k )
        {
        Random random = new Random();
        String key = "key" + random.nextInt( _keyRange );
        
        synchronized( map )
          {
          Integer value = ( Integer )map.get( key );
          if( value != null )
            {
            int oldValue = value.intValue();
            map.put( key, new Integer( oldValue + 1 ) );
            }
          else
            {
            map.put( key, new Integer( 1 ) );
            }
        
          }
        
        }
      }
    } ).start();
  }
}
//--------------------------------------------------------------------------------
}

bench.clj(注:間違いあり。修正版は0×06項目を参照)

(ns bench)
(import '(java.util Random))

(def _keyRange 100)
(def _threadCount 300)
(def _repeat 100000)
(def mapref1 (ref {}))
(def _start (System/currentTimeMillis))
(def _agents (seq (repeat _threadCount (agent nil))))

(defn fn1 []
  (let
    [
     _random (new Random )
     _key (str "key" (.nextInt _random _keyRange))
    ]
    
    (dosync
      (let [ _oldValue (get @mapref1 _key) ]
        (if (nil? _oldValue)
          (alter mapref1 assoc _key 1)
          (alter mapref1 assoc _key (+ 1 _oldValue))
        )
      )
    )
  )
)

(defn fn2 [ dummy ]
  (dotimes [ _index _repeat ]
    (fn1)
  )
)

(doseq [ _agent _agents ]
  (send-off _agent fn2)
)

(defn fn3 []
  (println (- (System/currentTimeMillis) _start))
  (println @mapref1)
)

(doseq [ _agent _agents ]
  (await _agent)
)

(fn3)
(shutdown-agents)
(System/exit 0)


0×04. ベンチマーク結果


この2つのプログラムを実行してみると、Java版は6〜7秒程度で終わるが、Clojure版は60秒程度かかる。Clojure版はざっと10倍程度遅いことになる。(これは書き込み競合が極端に多い例であり、ClojureのSTMが最も苦手とするケースではあることに注意が必要だ。Writeが少なく、Readが多いケースでは、Javaより速い場合もあるかもしれない。)


0×05. STMは使えないと思う理由


筆者はこの結果を受けて、「ClojureのSTMは使えない」と判断した。

「Readが多いケースならば、STMもよいのでは」という意見もあるかもしれない。しかし開発している最中に、いちいち「この部分の競合ではReadが多いか?」などと考えるのはナンセンスだ。

また、サーバアプリケーションに予想以上のアクセスが集中するなどのケースで、「読み込みが大部分だろう」と思っていた箇所で書き込み競合が多く発生してしまったら悲惨なことになってしまうかもしれない。そして、そもそも書き込み競合が多い場面で使えないのでは、別の技術も勉強する必要があり、無駄である。

さらにもう一つの理由として、「正しいコードかどうか」の判断を付けることができないという点があげられる。Javaのロックベースのマルチスレッドプログラミングは、確かに落とし穴が多い。しかしFindBugsのようなツールやコードレビューなどによって、デッドロック等の問題が発生する可能性などを見つけることができる。また、実際にデッドロックが起こってしまった場合などにも、問題点がはっきりする。問題点が見つかれば、それを修正していくことで、「正しいコード」に近づく。そしてある時点で、ほぼ確実に正しく動作するコードになった、と自信を持つことができるようになる。そしてそのコードは速い。

ClojureのSTMでは、実際にアプリケーションを完成させてテストするまで、「性能がでるかどうか」つまり「正しいコードかどうか」を判断することができない。サーバアプリケーションではしばしばテスト時点では想像もできない複雑なパターンのアクセス集中が発生することが予想されるため、いつまでたっても「このコードでいける!」と自信を持つことができなくなるだろう。また、問題があった場合の修正はアプリケーションの作り自体の変更になる可能性があり、Javaでのデッドロックの修正よりもよほど大がかりになる可能性がある。これはかなりの茨の道になるだろう。

この筆者の判断は現時点でのものなので、数年後にSTMが爆速になっていたりすれば、もちろん使ってみるつもりだ。また、性能が問題にならないようなちょっとした処理であればもちろんSTMは便利に使えるだろう。


0×06. 追記(コードのミスを修正)


コメント欄でTakahiro Hozumiさんより非常に有意義なコメントをいただいた(ありがとうございます!)。やはりSTMのパフォーマンスには問題があるらしい。また、上記の筆者のClojureコードにはミスがあり、実際には300スレッドを生成できていないことがわかった。

修正版を作成したので以下に掲載する。

bench.fixed.clj

29行目をコメントアウトすると、確かに300スレッド生成できていることが確認できる(また、ps -eLf等のコマンドでも確認済み)。

問題のパフォーマンスだが、修正後のコードを使って300スレッド生成してみるとさらに遅くなることがわかり、Javaより10倍どころではなく、恐ろしく遅くなることがわかった。そのため、0×05で示した本稿の結論的なものは変わらない形となる。


0×07. さらに追記


Clojureでは(今回のテストのように)単純に多くのスレッドを生成したいだけの場合にはagentの使用は不適切であるとコメント欄で指摘していただいた。このような場合にはagentの使用には大きなオーバーヘッドがあるようだ。The Joy of Clojureの11章がこのあたりに触れた説明となっており、とても参考になる。

本稿で目的としているテストを正しく行うためのコードはTakahiro Hozumiさんが作成してくれたhttps://gist.github.com/3048d90328d3118583a4の(ref-bench)であり、パフォーマンスはJavaの10倍程度遅いということになるようだ。

少し話題が反れるが、Programming ClojureやPractical Clojureを読んだ印象はまさに「Clojure=シンプル」であったのだが、The Joy of Clojureの11章の印象は(悪い意味で)かなり違う。現実的にはそれほどシンプルな記述で並行プログラミングを実現できる、というわけではなさそうだ。lockingなどは普通にデッドロックを起こす可能性があるように思えるためClojureらしくないと感じる。


0×08. またまた追記


詳しくはコメント欄を参照していただきたいが、最終的には以下のようなことになるようだ。

  • Agentを使ってスレッドを作ること自体のオーバーヘッドはそれほど大きくない
  • (書き込み競合が多い場合の)STMは非常に重く、Javaの10倍どころではない時間を要する

  • Eclipseからexコマンド(いわゆるviのコマンド)を実行するプラグインをリリース


    0×00. exコマンドとは


    Eclipse上で開発する場合、悲しいのはviエディタが使えないことである。過去にviに似せた操作を可能とするプラグインを試したことがあるが、満足行く出来ではなかった。そのため、いつしか筆者は「Eclipse上ではごく普通のエディタ操作を行い、ターミナル上ではviを使う」という使い分けに慣れていった。

    EclipseのJavaエディタ等も、別に慣れてしまえばそれほど問題はない。筆者はThinkpadタイプの赤ぽっち付きキーボード愛用派であることから、カーソル操作についても手のポジションを変更せずに行えるというのが大きいのかもしれない。EclipseのJavaエディタではコード補完等の便利さが際だつため、特にviじゃなくてもいいな…と思いながらいつのまにか時は過ぎていた。

    しかし、このように使い分けることを決めた後でも、いつも「ぬぁ〜、これが使えればナァ(;´Д`)」と不満に思うのが、exコマンドだ。

    「exコマンド」と言われてもぴんと来ないかもしれない。プログラマの中には「三次元は面倒くさい。二次元萌え〜」などと言うモノがいるようだが、これをさらに突き詰めると、一次元萌えとなる。つまり線である。このようなプログラマは通常よく使われる二次元(複数行を面で表示する)のエディタではなく、1次元(1行しか表示しない)のエディタを好む。これがexエディタである。実はあなたのシステムにも普通にexエディタはインストールされているだろう。通常/usr/bin/exにある(Windowsを除く)。

    冗談はよいとして、要するにexはviエディタでコロンに続いてコマンドを実行するやつの実体である。詳しくはWikipediaを参照のこと。例えばviエディタで編集中に、3行目から5行目まで削除したい場合などには

    :3,5d
    

    などとする。これが慣れると病みつきになる便利さである。筆者はviの覚え立ての頃は「viエディタの良さは、カーソルの移動がキーボードでできることだ」と思っていたのだが、慣れてくるとexコマンドが使えることこそがviの良さであるという風に認識が変化した。


    0×01. Eclipseプラグインからexコマンドを呼び出す


    Eclipseのエディタでもexコマンドが使えれば便利になると考え、プラグイン(名前はExEdit)を作成した。閉店間際のスターバックスコーヒー東戸塚店で1時間、さらに帰宅後に1時間で動くモノができた。使用イメージは以下のようになる。

    以下の画像で、Javaエディタ部分に記されているコードには特に意味がない。ここでは3行目からhoge、fuga、gyoeというコメントが書かれていることに注目してほしい。

    まず、3行目を5行目に移動させてみる。以下のように、ExEditという小さなViewのテキスト入力欄にexコマンド(ここでは「3m5」となる)を入力し、Enterキーを押す。すると次の画像のように3行目(hogeと記述されている行)が移動する。

    2番目の例として、3行目から5行目までを削除してみる。exコマンド(ここでは「3,5d」となる)を入力し、Enterキーを押すと、以下のようになる。

    このようにEclipse内部でexコマンドが非常に自然に使えるようになり、とても便利である。


    0×02. ダウンロードとインストール


    ダウンロードは以下のURLから。

    http://www.jumperz.net/tools/net.jumperz.app.ExEdit_0.9.0.201104162218.jar

    このファイルをEclipseのpluginsディレクトリ以下に直接コピーし、Eclipseを再起動することでインストールされる。「General」に分類されているExEditというviewを開けば使えるようになる。

    jarファイル内にはソースコードも含まれている。ライセンスは(とかいうほどのものでもないのだが)GPLとする。


    0×03. 使用上の注意など


    このプラグイン実装は内部でexコマンドを実際に呼び出しているため、exコマンドがインストールされていない環境では動作しない。つまりWindowsではほぼ動かないと思ってもらった方がよい。UbuntuとMacOSでは動作確認済みである。

    Eclipse側でファイルを編集した後に、かならず保存してからExEditを使う必要がある(編集中の内容が保存されていない場合は、エラーダイアログが出るようになっている)。

    ExEditは裏側でexコマンドのプロセスを起動して直接対象のファイルを書き換える。そのため、Eclipseは外部でファイルが書き換えられたことを検知してダイアログ(変更された内容をワークスペース側に反映させるか?Yes/No)を出し、ユーザがyesを選択する(単にEnterキーを押せばいい)、という動作が正常な動作となる。上の例ではシンプルに動作を示すためにこのダイアログが出てくることを省略している。

    ファイルを直接書き換えるため、万が一の事故には注意して頂きたい。当然ながら当方は使用に当たって一切の責任を負わないものとする(Eclipseが普通に履歴を保存してくれるので万が一データが消えても問題ないと思うが…)。

    また、ExEditは非常に小さなスペースで済むので、viewのサイズは最初に開いた際に小さくするのが吉である。


    0×04. その他お願いなど


    なかなか普通に便利なプラグインだと思うので、是非使って頂き、@kinyukaまでフィードバックしてもらえればうれしい。また、アイコンが超手抜き(2秒くらいで作った)なので、作っていただければありがたい。

    それではみなさま、素敵な一次元ライフを。


    AndroidアプリケーションのSSL通信をプロキシで解析する(2)


    0×00. はじめに


    前回の記事ではURLConnectionクラスを使ったアクセスについて調査した。今回はSSLSocketFactoryを使い、生のSSLソケットを生成する場合について同様のアプローチを用いる。


    0×01. SSLSocketを用いてSSLウェブサイトにアクセス


    一般的に、AndroidアプリケーションからHTTPやHTTPSを使ったアクセスを行いたい場合、URLConnectionクラスを用いると思われる。しかしより詳細なコントロールを行いたい場合には、SSLSocketクラスのインスタンスを生成することになるだろう。例えば次のようなコードを使えば、ベリサイン(日本)のウェブサイトにアクセスすることができる。

    private String socketTest()
    {
    try
    {
    SocketFactory sf = SSLSocketFactory.getDefault();
    Socket s = sf.createSocket( "www.verisign.co.jp", 443 );
    s.getOutputStream().write( "GET / HTTP/1.0\r\nHost: www.verisign.co.jp\r\n\r\n".getBytes() );
    byte[] buf = new byte[ 1024 * 10 ];
    s.getInputStream().read( buf );
    s.close();
    String res = new String( buf );
    Log.i( TAG, res );
    return res.substring( 0, 30 );
    }
    catch( Exception e )
    {
    return logException( e );
    }
    }

    前回と同様に、エミュレータ起動時のコマンドラインオプションでHTTP(HTTPS)プロキシを使うように指定し、Doormanなどのローカルプロキシを経由させる場合、このコードは証明書が不正であるとし、例外を送出してしまう。スタックトレースは以下のようになる。

    03-08 02:10:02.704: INFO/HA(6902): javax.net.ssl.SSLException: Not trusted server certificate
    03-08 02:10:02.704: INFO/HA(6902):     at org.apache.harmony.xnet.provider.jsse.OpenSSLSocketImpl.startHandshake(OpenSSLSocketImpl.java:371)
    03-08 02:10:02.704: INFO/HA(6902):     at org.apache.harmony.xnet.provider.jsse.OpenSSLSocketImpl$SSLOutputStream.(OpenSSLSocketImpl.java:564)
    03-08 02:10:02.704: INFO/HA(6902):     at org.apache.harmony.xnet.provider.jsse.OpenSSLSocketImpl.getOutputStream(OpenSSLSocketImpl.java:479)
    03-08 02:10:02.704: INFO/HA(6902):     at net.jumperz.app.android.test1.HelloActivity.socketTest(HelloActivity.java:66)
    03-08 02:10:02.704: INFO/HA(6902):     at net.jumperz.app.android.test1.HelloActivity.onKey(HelloActivity.java:109)
    03-08 02:10:02.704: INFO/HA(6902):     at android.view.View.dispatchKeyEvent(View.java:3735)
    03-08 02:10:02.704: INFO/HA(6902):     at android.view.ViewGroup.dispatchKeyEvent(ViewGroup.java:788)
    03-08 02:10:02.704: INFO/HA(6902):     at android.view.ViewGroup.dispatchKeyEvent(ViewGroup.java:788)
    03-08 02:10:02.704: INFO/HA(6902):     at android.view.ViewGroup.dispatchKeyEvent(ViewGroup.java:788)
    03-08 02:10:02.704: INFO/HA(6902):     at android.view.ViewGroup.dispatchKeyEvent(ViewGroup.java:788)
    03-08 02:10:02.704: INFO/HA(6902):     at com.android.internal.policy.impl.PhoneWindow$DecorView.superDispatchKeyEvent(PhoneWindow.java:1667)
    03-08 02:10:02.704: INFO/HA(6902):     at com.android.internal.policy.impl.PhoneWindow.superDispatchKeyEvent(PhoneWindow.java:1102)
    03-08 02:10:02.704: INFO/HA(6902):     at android.app.Activity.dispatchKeyEvent(Activity.java:2063)
    03-08 02:10:02.704: INFO/HA(6902):     at com.android.internal.policy.impl.PhoneWindow$DecorView.dispatchKeyEvent(PhoneWindow.java:1643)
    03-08 02:10:02.704: INFO/HA(6902):     at android.view.ViewRoot.deliverKeyEventToViewHierarchy(ViewRoot.java:2471)
    03-08 02:10:02.704: INFO/HA(6902):     at android.view.ViewRoot.handleFinishedEvent(ViewRoot.java:2441)
    03-08 02:10:02.704: INFO/HA(6902):     at android.view.ViewRoot.handleMessage(ViewRoot.java:1735)
    03-08 02:10:02.704: INFO/HA(6902):     at android.os.Handler.dispatchMessage(Handler.java:99)
    03-08 02:10:02.704: INFO/HA(6902):     at android.os.Looper.loop(Looper.java:123)
    03-08 02:10:02.704: INFO/HA(6902):     at android.app.ActivityThread.main(ActivityThread.java:4627)
    03-08 02:10:02.704: INFO/HA(6902):     at java.lang.reflect.Method.invokeNative(Native Method)
    03-08 02:10:02.704: INFO/HA(6902):     at java.lang.reflect.Method.invoke(Method.java:521)
    03-08 02:10:02.704: INFO/HA(6902):     at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:868)
    03-08 02:10:02.704: INFO/HA(6902):     at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:626)
    03-08 02:10:02.704: INFO/HA(6902):     at dalvik.system.NativeStart.main(Native Method)
    03-08 02:10:02.704: INFO/HA(6902): Caused by: java.security.cert.CertificateException: java.security.cert.CertPathValidatorException: TrustAnchor for CertPath not found.
    03-08 02:10:02.704: INFO/HA(6902):     at org.apache.harmony.xnet.provider.jsse.TrustManagerImpl.checkServerTrusted(TrustManagerImpl.java:168)
    03-08 02:10:02.704: INFO/HA(6902):     at org.apache.harmony.xnet.provider.jsse.OpenSSLSocketImpl.startHandshake(OpenSSLSocketImpl.java:366)
    03-08 02:10:02.704: INFO/HA(6902):     ... 24 more
    03-08 02:10:02.704: INFO/HA(6902): Caused by: java.security.cert.CertPathValidatorException: TrustAnchor for CertPath not found.
    03-08 02:10:02.704: INFO/HA(6902):     at org.bouncycastle.jce.provider.PKIXCertPathValidatorSpi.engineValidate(PKIXCertPathValidatorSpi.java:149)
    03-08 02:10:02.704: INFO/HA(6902):     at java.security.cert.CertPathValidator.validate(CertPathValidator.java:202)
    03-08 02:10:02.704: INFO/HA(6902):     at org.apache.harmony.xnet.provider.jsse.TrustManagerImpl.checkServerTrusted(TrustManagerImpl.java:164)
    03-08 02:10:02.704: INFO/HA(6902):     ... 25 more
    


    0×02. 書き換え


    今回はわかりやすい箇所としてTrustManagerImplクラスのcheckServerTrustedメソッドを選択する。このメソッドは戻り値を持たないメソッドであり、証明書の検証を行う。もし証明書が正しいと判断できない場合には例外を送出するという動作をする。そこで、このメソッドを何もしない実装に書き換えてしまう。

    143     public void checkServerTrusted(X509Certificate[] chain, String authType)
    144             throws CertificateException {
    145             }
    

    145行目以降をばっさり削除し、このように中身のない関数にする。対象となるファイルは、Froyoでは ./dalvik/libcore/x-net/src/main/java/org/apache/harmony/xnet/provider/jsse/TrustManagerImpl.javaとなる。

    makeを行えば、SSLSocketクラスからのアクセスについて証明書を一切検証しないDalvikを載せたFroyoが出来上がる。コマンドラインオプションでプロキシを使うように指定すれば、先述したコードからプロキシを通してベリサインのウェブサイトにアクセスすることが可能となる。


    AndroidアプリケーションのSSL通信をプロキシで解析する(1)



    0×00. はじめに


    Androidアプリケーションの解析の際に、それがどのようなSSL通信を行っているかが重要となる場面がある。そのようなとき、Doormanのようなローカルプロキシでその通信をフックすることができれば目的が達成できる。

    しかし通常の(PC上の)ウェブブラウザのSSL通信と同じように、Androidにも元々「信頼できるもの」として扱われるルート証明書群がインストールされており、これらの証明書を元にSSL通信が実施されてしまう。ただ単にローカルプロキシでSSL通信をフックしようとしても、当然「偽物の証明書である」としてエラーとなってしまうため、プロキシでのフックを実施するためには少し工夫が必要となる。いくつかの方法が考えられるが、このエントリではまず筆者が一番はじめに試した方法を紹介する。


    0×01. 対象


    まず、今回はURLConnectionクラスを使ったSSLのアクセスを対象とする。なぜならば、ふつうAndroidアプリケーションの開発者がインターネット上のウェブサイトと通信を行いたい場合には、このクラスを使うと想定されるからだ(実は筆者自身は殆どこのクラスを使ったことがないのでよく知らないのだが…)。

    URLConnectionクラスはAndroid上では以下のようなクラス階層となっており、URLがHTTPSの場合には自動的にSSL通信を行ってくれるようになっている。

    パッケージ名も含めたクラス名は以下のようになっている。

  • java.lang.Object
  • java.net.URLConnection
  • java.net.HttpURLConnection
  • javax.net.ssl.HttpsURLConnection
  • org.apache.harmony.luni.internal.net.www.protocol.https.HttpsURLConnectionImpl
  • org.apache.harmony.luni.internal.net.www.protocol.http.HttpURLConnectionImpl
  • org.apache.harmony.luni.internal.net.www.protocol.https.HttpsURLConnectionImpl.HttpsEngine
  • 上記パッケージ名を見てわかるとおり、AndroidのJava実装はApacheのHarmonyが使われている。わざわざこんなマニアックな実装(失礼)を使っている理由はどうやらライセンスにあるらしい。うーむ…。

    OracleのJREではSSLの実装はすべてJavaで完結しているのに対し、AndroidではJNIを通じて下層のOpenSSLのネイティブコードを呼び出す形になっているようだ(つまり、OpenSSLの脆弱性の影響をもろに受けるということである)。


    0×02. エミュレータを選択する


    筆者ははじめに簡単なアプリケーションを作成し、プロキシサーバを経由せずにURLConnectionクラスを使ってSSLのサイトにアクセスできるようにした。次にプロキシサーバを経由してアクセスするようにしようとしたのだが、ここでなんと衝撃の事実に直面した。AndroidではWiFi使用時にプロキシサーバを使用できないのだ。この問題はずいぶん前から大騒ぎになっていたようなのだが(当然だ)、未だに対応されておらず、また対応されない理由も不明のようだ。普通に考えて、オフィスのWiFiからは必ずプロキシを使用しないとインターネットに出ることができないような環境はたくさんあると思うのだが、いったい何がどうなっているのだろうか。そもそもオープンソースなのになぜこの点が改善できないのか?なかなか謎が深い。該当スレッドのコメントはすでに1000を超えており、「Fuck Google」などの建設的な意見で埋め尽くされている。

    さて今回の目的はあくまでも対象となるAndroidアプリケーションの通信内容の解析であるため、実機でのプロキシ設定についてはひとまずあきらめ、エミュレータで動作させるという妥協をすることにした。エミュレータでは起動の際のコマンドライン引数に、以下のように-http-proxyを渡すことでプロキシが使用されるようになる。

    -http-proxy http://192.168.1.20:8080/
    

    先述したように普通にMITMしようとしても証明書のエラーとなる。少し長いが、このときのスタックトレースを以下に示す。

    02-23 12:13:57.144: INFO/HA(274): https://www.verisign.co.jp/
    02-23 12:13:57.315: INFO/HA(274): javax.net.ssl.SSLException: Not trusted server certificate
    02-23 12:13:57.315: INFO/HA(274):     at org.apache.harmony.xnet.provider.jsse.OpenSSLSocketImpl.startHandshake(OpenSSLSocketImpl.java:371)
    02-23 12:13:57.315: INFO/HA(274):     at org.apache.harmony.luni.internal.net.www.protocol.http.HttpConnection.getSecureSocket(HttpConnection.java:168)
    02-23 12:13:57.315: INFO/HA(274):     at org.apache.harmony.luni.internal.net.www.protocol.https.HttpsURLConnectionImpl$HttpsEngine.connect(HttpsURLConnectionImpl.java:399)
    02-23 12:13:57.315: INFO/HA(274):     at org.apache.harmony.luni.internal.net.www.protocol.http.HttpURLConnectionImpl.getInputStream(HttpURLConnectionImpl.java:1152)
    02-23 12:13:57.315: INFO/HA(274):     at org.apache.harmony.luni.internal.net.www.protocol.https.HttpsURLConnectionImpl.getInputStream(HttpsURLConnectionImpl.java:253)
    02-23 12:13:57.315: INFO/HA(274):     at net.jumperz.app.android.test1.HelloActivity.http(HelloActivity.java:113)
    02-23 12:13:57.315: INFO/HA(274):     at net.jumperz.app.android.test1.HelloActivity.onKey(HelloActivity.java:89)
    02-23 12:13:57.315: INFO/HA(274):     at android.view.View.dispatchKeyEvent(View.java:3735)
    02-23 12:13:57.315: INFO/HA(274):     at android.view.ViewGroup.dispatchKeyEvent(ViewGroup.java:788)
    02-23 12:13:57.315: INFO/HA(274):     at android.view.ViewGroup.dispatchKeyEvent(ViewGroup.java:788)
    02-23 12:13:57.315: INFO/HA(274):     at android.view.ViewGroup.dispatchKeyEvent(ViewGroup.java:788)
    02-23 12:13:57.315: INFO/HA(274):     at android.view.ViewGroup.dispatchKeyEvent(ViewGroup.java:788)
    02-23 12:13:57.315: INFO/HA(274):     at com.android.internal.policy.impl.PhoneWindow$DecorView.superDispatchKeyEvent(PhoneWindow.java:1667)
    02-23 12:13:57.315: INFO/HA(274):     at com.android.internal.policy.impl.PhoneWindow.superDispatchKeyEvent(PhoneWindow.java:1102)
    02-23 12:13:57.315: INFO/HA(274):     at android.app.Activity.dispatchKeyEvent(Activity.java:2063)
    02-23 12:13:57.315: INFO/HA(274):     at com.android.internal.policy.impl.PhoneWindow$DecorView.dispatchKeyEvent(PhoneWindow.java:1643)
    02-23 12:13:57.315: INFO/HA(274):     at android.view.ViewRoot.deliverKeyEventToViewHierarchy(ViewRoot.java:2471)
    02-23 12:13:57.315: INFO/HA(274):     at android.view.ViewRoot.handleFinishedEvent(ViewRoot.java:2441)
    02-23 12:13:57.315: INFO/HA(274):     at android.view.ViewRoot.handleMessage(ViewRoot.java:1735)
    02-23 12:13:57.315: INFO/HA(274):     at android.os.Handler.dispatchMessage(Handler.java:99)
    02-23 12:13:57.315: INFO/HA(274):     at android.os.Looper.loop(Looper.java:123)
    02-23 12:13:57.315: INFO/HA(274):     at android.app.ActivityThread.main(ActivityThread.java:4627)
    02-23 12:13:57.315: INFO/HA(274):     at java.lang.reflect.Method.invokeNative(Native Method)
    02-23 12:13:57.315: INFO/HA(274):     at java.lang.reflect.Method.invoke(Method.java:521)
    02-23 12:13:57.315: INFO/HA(274):     at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:868)
    02-23 12:13:57.315: INFO/HA(274):     at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:626)
    02-23 12:13:57.315: INFO/HA(274):     at dalvik.system.NativeStart.main(Native Method)
    02-23 12:13:57.315: INFO/HA(274): Caused by: java.security.cert.CertificateException: java.security.cert.CertPathValidatorException: TrustAnchor for CertPath not found.
    02-23 12:13:57.315: INFO/HA(274):     at org.apache.harmony.xnet.provider.jsse.TrustManagerImpl.checkServerTrusted(TrustManagerImpl.java:168)
    02-23 12:13:57.315: INFO/HA(274):     at org.apache.harmony.xnet.provider.jsse.OpenSSLSocketImpl.startHandshake(OpenSSLSocketImpl.java:366)
    02-23 12:13:57.315: INFO/HA(274):     ... 26 more
    02-23 12:13:57.315: INFO/HA(274): Caused by: java.security.cert.CertPathValidatorException: TrustAnchor for CertPath not found.
    02-23 12:13:57.315: INFO/HA(274):     at org.bouncycastle.jce.provider.PKIXCertPathValidatorSpi.engineValidate(PKIXCertPathValidatorSpi.java:149)
    02-23 12:13:57.315: INFO/HA(274):     at java.security.cert.CertPathValidator.validate(CertPathValidator.java:202)
    02-23 12:13:57.315: INFO/HA(274):     at org.apache.harmony.xnet.provider.jsse.TrustManagerImpl.checkServerTrusted(TrustManagerImpl.java:164)
    02-23 12:13:57.315: INFO/HA(274):     ... 27 more
    

    余談だがスタックトレースの下の方を見ると、bouncycastleのライブラリも使用されているらしいこともわかる。

    さてこのスタックトレースの中で、どこかで例外が発生しないようにしてしまえばいい。つまり実行されるJavaのコードのどこかをあらかじめ書き換えておくというアプローチだ。androidアプリケーションのリバースエンジニアリングで示したように、アプリケーション自身の書き換えを行ってもよいのだが(この方法については別エントリで書くかもしれない)、せっかくのオープンソースのOSなので、OS側を書き換えてしまうことにする。なぜなら、OS側でSSLのエラーが出ないようにしてしまえば、その上で動作するあらゆるアプリケーションの通信をフックできるようになるからだ。そこで、今回はAndroidをソースコードからビルドする環境を整えた上で、OS側(Dalvik側)のコードを書き換えることにした。


    0×03. 関数の追加


    まず、先述のスタックトレースの5行目である

    org.apache.harmony.luni.internal.net.www.protocol.https.HttpsURLConnectionImpl$HttpsEngine.connect(HttpsURLConnectionImpl.java:399)
    

    に注目する。ソースコードは以下のようになっている。

    sslSocket = connection.getSecureSocket(getSSLSocketFactory(), getHostnameVerifier());
    

    getSecureSocket関数を呼び出す際の2つの引数は、それぞれSSLのソケットファクトリのインスタンスと、ホスト名が証明書のCommonNameとマッチしているかどうかを判定するHostnameVerifierのインスタンスとなっている。この2つのインスタンスがきちんと仕事をしてくれるおかげで正しいSSL通信が実現される。そこで、今回はこの2つのインスタンスとして、仕事をしないだめだめなソケットファクトリとHostnameVerifierを渡すように書き換えてみる。

    HttpsURLConnectionImpl.javaに、以下の2つの関数を追加する。都合がよいことにこれらの関数はsuperクラスで定義されているものなので、このクラスに追加することで自動的にこの実装が呼び出されるようになる。

    public javax.net.ssl.SSLSocketFactory getSSLSocketFactory()
    {
    try
    {
    javax.net.ssl.SSLContext ctx = javax.net.ssl.SSLContext.getInstance( "TLS" );
    javax.net.ssl.X509TrustManager bogusTm = new javax.net.ssl.X509TrustManager(){
    public java.security.cert.X509Certificate[] getAcceptedIssuers(){return null;}
    public void checkClientTrusted( java.security.cert.X509Certificate[] arg0, String arg1 )
    throws java.security.cert.CertificateException{}
    public void checkServerTrusted( java.security.cert.X509Certificate[] certs, String arg1 )
    throws java.security.cert.CertificateException{}
    };
    ctx.init( null, new javax.net.ssl.TrustManager[]{ bogusTm }, null );
    return ctx.getSocketFactory();
    }
    catch( Exception e )
    {
    return getDefaultSSLSocketFactory();
    }
    }

    public javax.net.ssl.HostnameVerifier getHostnameVerifier()
    {
    javax.net.ssl.HostnameVerifier hv = new javax.net.ssl.HostnameVerifier()
    {
    public boolean verify( String hostname, javax.net.ssl.SSLSession session )
    {
    return true;
    }
    };
    return hv;
    }

    内容の詳しい解説は省略するが、とにかく一切証明書やホスト名等を評価しない、やる気なしのインスタンスを返す実装となっている。実に無駄のない、美しいソースコードだと言える。

    上記の2つの関数を追加したら、Androidをビルドする。するとURLConnectionクラスを使っている部分については、SSLの例外が発生しなくなるため、プロキシを使ってフックできるようになる。

    筆者はFroyoでテストした。Froyoの場合、書き換える対象のファイルは

    dalvik/libcore/luni/src/main/java/org/apache/harmony/luni/internal/net/www/protocol/https/HttpsURLConnectionImpl.java
    

    となる。


    0×04. Kindle for Androidでテスト


    実際にこのように書き換えたAndroidエミュレータ上でKindle for Androidを動作させてみたところ、見事にAmazonのサーバとの間の通信がフックできるようになった(すべての通信がSSLなところはさすがといったところである)。


    0×05. まとめ


    今回はAndroid上で動作するアプリケーションのSSL通信の内容を、あらかじめ書き換えたDalvik上で動作させることにより、プロキシサーバを使って解析する方法を1つ紹介した。この方法は下記のような特徴がある。

  • エミュレータ上で動作するアプリケーションのみ対象となる
  • URLConnectionクラスを使った操作のみが対象となる
  • エミュレータさえ準備できれば、すべてのアプリケーションをインストールして動作させるだけで解析することができる
  • 筆者の個人的な目的はKindle for Androidの通信を見てみたいというものだったので、既に目的は達成されてしまったのだが、別の方法についても探ってみるつもりである。


    0×06. 余談


    HTTPプロキシを経由させるためにはAndroidエミュレータ(QEMU)の起動時にコマンドラインオプションを渡すわけだが、この実装には下記2点のクセがあることがわかったのでここに記しておく。

  • chunkedエンコーディングされたレスポンスをまともに扱えないバグがある(新しいバージョンでは修正済みらしいが、筆者が落としてきたFroyoのソースコードでは直っていなかった)。そのため、プロキシ側で可能であればchunkedエンコーディングを扱わないようにしておくのがよい。Doormanであれば(.chunkToNormal response)というHookを定義すればOKである。
  • CONNECTリクエストの引数で、普通はホスト名を渡してくるものだが、QEMUは自身で名前解決を行った後のIPアドレスを渡してくる。MITMしたいプロキシの立場からすると接続したいホスト名を教えて欲しいところだろう。

  • HTTPサーバにJava NIOは必要か


    0×00. はじめに


    筆者はJava製のWAF(Web Application Firewall)、Guardian@JUMPERZ.NETの開発とメンテナンスを行っている。元は自社のシステムを守るために(そして半分趣味で)作ったものだが、数年前にこれをコアのエンジンとしてさらに拡張し、SaaS型の商用サービス「Scutum(スキュータム)」を立ち上げた。

    その後順調に顧客を獲得することができ、システムリソース的にも増強が必要となる段階などを経験した。Google、mixiやはてな等、さまざまな大規模サイトのインフラエンジニアの方々がインフラ設計に関する考え方などをインターネット上で公開してくれているおかげで、初期のシステム設計時に「将来的にスケールアウト可能なシステム構成にしておくこと」が重要であるということがわかっていた。その教えに従っていたおかげで、リソースの逼迫(ちなみに今回はCPUリソースだった)に遭遇した際にも、単にサーバを追加することでこれを乗り切ることができた。先人の知恵に感謝したい。

    WAFというのはわかりやすく言えばSQLインジェクションのようなウェブアプリケーションに対する攻撃を防ぐものである。技術的にはそれはHTTP/HTTPSのプロキシサーバであり、一度リクエストをパースして中身をチェックする。チェックの際には(大量の)既知の攻撃パターンとの比較を行うため、文字列マッチングによって多くのCPUサイクルを消費する。

    Guardian@JUMPERZ.NETのアーキテクチャはシンプルなスレッドプール+ブロッキングI/Oである(キューも使っているのでスレッドプールの数以上のクライアントが押し寄せた場合にはそれらのタスクはキューに溜まる)。昔のApacheのようなマルチプロセス+ブロッキングI/Oほどではないが、やや古き良きアーキテクチャと言ったところである。1台のハードウェアで同時に処理できるコネクション数はスレッド数と同等となる(キューにタスクとして溜めておくことができるので、一時的に受け付けることができるクライアント数はスレッド数より多くすることが可能ではある)。Linux上でOracleのJRE6系列を使う場合、64bitでメモリをたっぷり積んだマシンであれば1〜2万スレッドほど生成して動作させることも可能である。この場合ボトルネックはスレッド数の増加に伴うメモリ消費量となる(Linuxのスレッドは非常に優秀であるため、コンテキストスイッチのオーバーヘッドは筆者がベンチマークしてみた感覚では全く問題にならない)。

    Javaでは一般的にこのようなアーキテクチャ(スレッドプール+ブロッキングI/O)ではC10K問題をクリアできないとされており、C10K問題の向こう側に行くためにはNIOパッケージを使用した非同期I/Oによるアーキテクチャに切り替える必要があるとされている。筆者としてもこの問題が非常に気になっており、「JavaのNIOパッケージは速い」「NIOでスケーラブルなソケット」などの文句を目にするたびに自分が使っているアーキテクチャは過去の遺物なのかと心配になっていた。もしやGuardian@JUMPERZ.NETをNIOで書き直せば爆速になるのでは…?という期待と、非同期プログラミング(その難しさには定評がある)に手を出す怖さの間で悶々とした日々を過ごしていたのである。


    0×01. C10K問題


    2011年の2月に、Infoq.comに掲載されていたDeftと呼ばれるソフトウェアの記事などを目にしたことがきっかけで、これまで敬遠していたJavaNIOに少し手を出してみることにした。積ん読となっていたJavaネットワークプログラミングの真髄とKindle版のJava Network Programming(20$以下で買える!)を参考にNIOを使った簡単なコードを書いてみた。Selectorを中心にループするという、いわゆるselect()を使ったソケットプログラミングのJava版という感じである。筆者は王道的な(UNIX系OS上における)C言語のソケットプログラミングについてもブロッキングI/Oを使ったものしか経験がないため、JavaNIOを使ったプログラミングはかなりハードルが高かった。

    NIOを使ったアプリケーションのコードは通常のIOを使ったコードとはまったく異なるものになるため、Guardian@JUMPERZ.NETをNIOで書き換えるのはかなり困難だということがわかった。そこで本当にNIOで書き直す価値があるのかを再検討することにした。

    NIOがIOに比べて明らかに優れているのは、スレッド数の上限(=メモリの上限)を超える数のコネクションを同時に処理できるということである。そのため、Guardian@JUMPERZ.NETにおいて、同時に処理したいクライアントの数が現在のボトルネックであれば、NIOにすることでこれを解消できる可能性がある。しかし現在のシステムにおいて実際にボトルネックとなっているのはCPUリソースであり、その主な原因は先述した文字列マッチングによるものである。同時接続数としては数百〜数千程度であり、従来のIOで(そしてスレッドモデルで)処理できる数だ。そもそも同時に処理するクライアントの数を増やしてしまうと、より多くの文字列マッチングが行われることでCPUリソースがさらに不足する。そのため、一時的にアクセスが集中する際などには、処理しきれないクライアント(ソケット)はacceptした後にタスクとしてキューに待機させておき、実際に同時処理を行うスレッドプールのスレッド数は一定の数にしておく方がよいといえる。このとき重要なのは、Guardian@JUMPERZ.NETはごく一般的なウェブサーバの前にリバースプロキシとして位置するHTTPプロキシサーバであるため、クライアントはFIFOで先に接続してきたものから順番に処理すればそれでよいということだ。この点がNIOを検討する際の非常に大きなポイントとなる。(もちろんウェブサーバ側のアプリケーションがCometなどを使っている場合にはまずいことになるが、ここではそのようなケースは想定しない)

    Cometなどを使用するような、数万オーダのクライアントが同時接続するチャットアプリケーションなどの場合、実際にその数万のソケットを絶え間なく監視する必要がある。いわゆる、多くのソケットが待機時間が長い(ロングポーリングなどと呼ばれる)状態で使用され、アプリケーション自体のCPU使用率としては低いというシステムである。このようなケースでは、先述したGuardian@JUMPERZ.NETのように、クライアントをFIFOで処理すればよい、というわけにはいかない。そのためNIOが必須となり、旧来のIOでは対応できないということになる。これがC10K問題である(この議論は既に数年前にインターネット上では完結していたもので、知っている人には今さら、という感じだろう。筆者は不勉強だったため、ここにきてようやくこの問題の切り分けができた)。

    つまり結論としては、必ずしもGuardian@JUMPERZ.NETはNIOにする必要はないということだ。Guardian@JUMPERZ.NETのアプリケーションの処理ではCPUサイクルを多く消費する。そのためより多くのクライアントからのアクセスを同時に処理するためにはCPUリソースを追加する必要があり、それはサーバ数の増加によって行うのが適切である。


    0×02. NIO vs IO


    次に気になるのは、比較的少なめの、数百〜数千のクライアントを処理する場合に、NIOにすることでパフォーマンスが上がるのかということだ。この程度の数であればスレッド+ブロッキングIOで十分に処理できる。しかしNIOにすることでパフォーマンスが大きく異なるのであれば、それはNIOで書き直すモチベーションになる可能性がある。

    個人的な実感を伴って本当に「NIOが速い!」と感じたのは、TomcatのNIOコネクタのベンチマークテストを行ったときである。Tomcatについては、デフォルトのコネクタのパフォーマンスはひどいもので、そもそも大量の並列度でベンチマークを取ろうとしてもエラーが多発してしまいまともにレスポンスを返せない(もしかしたらチューニングすればマシになるのかもしれない。要調査)。しかしNIOコネクタはまったく異なり、CPUの使用率を低めに抑えながら素晴らしいパフォーマンスを見せる。また、クライアントの同時接続数とスレッド数が独立しているため、クライアントの並列度を1000としてベンチマークを行った際にもスレッドの数は220前後となっており、メモリ使用量が抑制されている。

    そこで、「NIOは爆速なのか?」を念頭に、ギガビットLAN環境でベンチマークテストを行った。対象はTomcatなどのJavaNIOサーバと、スレッド+ブロッキングIOであるGuardian@JUMPERZ.NETである。ベンチマークテストの方法についてはここでは省略するが、基本的にはGuardian@JUMPERZ.NETも十分なパフォーマンスを見せるという結論に達した。スレッドの数が数千から1万程度でもコンテキストスイッチのオーバーヘッドは気にならないレベルであり、スレッドの生成も問題にならないコストであった。

    その後ウェブ上で情報を漁ってみた。JavaのNIOはどのくらい速いのか?本当に速いのか?というのが気になっていたエンジニアは当然ながら大勢いたようで、下記のような興味深い記事が見つかった。

    http://stackoverflow.com/questions/4057853/java-i-o-vs-java-new-i-o-nio-with-linux-nptl
    http://www.thebuzzmedia.com/java-io-faster-than-nio-old-is-new-again/
    http://blog.uncommons.org/2008/09/03/avoid-nio-get-better-throughput/
    http://paultyma.blogspot.com/2008/03/writing-java-multithreaded-servers.html
    http://monkeypatched.blogspot.com/2008/09/java-multi-threaded-blocking-io-wins.html

    これらの記事からわかったのは、Linux2.6ではスレッド実装が非常に優秀であり、従来のモデル(スレッドプール+IO)でもパフォーマンスが問題ない、あるいはNIOよりも速いということだ。これは筆者自身がテストしたベンチマーク結果とも一致する。この段階で筆者としては調査の目的を達成し、ひとまず満足した。


    0×03. Java NIOのバグ?


    ここからは少々本題から外れる。今回の調査で気になったのが、TomcatのNIOコネクタのベンチマークを取っていると、たまにTCPコネクションが張れないというエラーが発生することである。負荷をかける(大量のリクエストを生成する)クライアント側アプリケーションが筆者自作のJavaアプリケーションの場合も、Apache Bench(abコマンド)の場合も同様であった。おそらくこれはTomcat独自のバグだろうと思い、JavaNIOを使っている別のHTTPサーバ実装についてもいくつか同じベンチマークテストを行ってみることにした。対象は以下である(リストにはTomcatも含める)。

  • Jboss Netty
  • Apache HttpComponents
  • Deft
  • Apache Tomcat
  • JavaNIOにはエンジニアを熱狂させる何かがあるらしく、多くのアプリケーションはウェブサイト等においてJavaNIOを使っていること、非同期I/Oであることをアピールしていたのが印象的だった。さて結果だが、驚いたことに全てのアプリケーションでTCPコネクションのエラーが発生した。abはこのエラーが出るとベンチマークテストを中断してしまうため、リクエスト数をある程度多くしてしまうと毎回テストが完走できないという状態であった。一方で筆者自作のベンチマークツールではエラーがあってもログに記録するだけでテストそのものは中断しない。300万リクエストを並列度1000でテストした結果、だいたい10〜20回程度のTCPコネクションエラーが発生するようだった。また、一応書いておくと、Guardian@JUMPERZ.NETではエラーはまったく発生しない。

    コネクションエラーが発生する原因はよくわからないが、JavaNIOを使っているアプリケーションのみに特徴的に発生することを考えると、おそらくJavaNIOそのもののバグなのではないかと思う。上記リストにあるアプリケーションは、(Deftを除き)それなりに多くのユーザが使っているものだと思われるため、アプリケーションのコードに問題があるとは考えにくい。筆者以外でもこの現象に遭遇したユーザがいるようでメーリングリストに投稿があったのだが、結局原因は特定されていないようだ。また、Javaとは関係ないのだが、ついでにnode.jsについても同じ負荷をかけてみたところ、TCPコネクションエラーはまったく発生せずに300万リクエストを処理することができた。そのため原因はOS設定やネットワークではないと考えられる。

    また、Deftに対してTelnetでHTTPリクエストを送ろうとしたところ、HTTPリクエストヘッダの終わりを意味するCR LF CR LFを送る前にレスポンスが返ってくる状態だった。まともにプロトコルを解釈できておらず、アプリケーションとしては非常に未熟なものであることが伺えた。また、少し前の話だがTomcatのNIOコネクタに対してHTTPリクエストを1バイトずつ間を開けて送った際も、正しく動作しないことがあった。JavaNIOのコーディングの難しさがこのような点に現れているように思える。これらのことから筆者自身は当分の間NIOをメインで使うことはないだろうと考えている。


    0×04. スケーラブルな…?


    さてここまで調べてみてあらためて感じるのは、NIOは騒がれすぎているということだ。「スケーラブルなソケット!」「スケーラブルなI/O!」などという文句を目にするが、そもそもスケーラブルの定義は何なのか。筆者の感覚では、新たにハードウェアリソースを投入すればそれだけ処理能力が上がるように設計されたシステムが「スケーラブルなシステム」である。スケーラブルなソケットということは、つまりメモリか何かを増やせばポート番号が10万まで使えるようになるとかそういうことなのだろうか。

    結局、NIOが解決するのは同時に扱えるクライアントの数がスレッド数に引っ張られずに済む、ということでしかない。つまり今まではスレッドの数(=メモリの量)がボトルネックだったが、そのボトルネックは解消できる、ということだ。ひとつボトルネックを解消すれば次は別の部分がボトルネックになるわけであって、結局NIOを使っている場合でもそこで壁に当たるだろう。NIOはまるで魔法のように何でも解決してくれるソリューションではない。また、仮にスレッドモデルで動いているシステムがあり、そこにメモリを追加することでさらに多くのスレッドを生成できるのであれば、そのシステムもスケーラブルだと言えるだろう(「スケーラブルなスレッド」といったところだろうか?)。NIOを「スケーラブルな…」と表現することは問題をわかりにくくするだけのように感じる。


    0×05. ハードウェアはボトルネックではない?


    C10K問題を調べる際に、「ハードウェアはもはやボトルネックではない」という表現を目にしたが、これは違うのではないだろうか(C10K問題を提起した記事に書かれている記述なので突っ込むのは野暮なのかもしれないが)。ハードウェアがボトルネックでないということは、そのハードウェアのCPUをいくら速くし、メモリをいくら足しても(そしてストレージの速度をいくら上げても)システムの性能が変わらないということである。マルチスレッドモデルやマルチプロセスモデルはCPUコアが増えることで性能が上がることが考えられるため、ハードウェアはボトルネックになっていると言えるだろう。本当にハードウェアがボトルネックではないケースというのは、例えばポート番号が足りないなどのパラメータの制限によるものになるだろう。


    0×06. まとめ


    今回の調査で筆者が得たことを簡単にまとめると以下のようになる。

  • アクセスしてくるクライアントをFIFO的に処理すればよいシステム(多くのHTTPサーバがこれに当たる)では、必ずしもJavaNIOを使う必要はない
  • 特にアプリケーションの処理自体がそれなりに重いシステムではスレッドプールの数によって同時に処理するタスクの数をコントロールすることが適切である
  • 数万以上のクライアントを同時に相手にする必要があるシステムでは、NIOを使う必要がある
  • 特にアプリケーションの処理自体が軽いものの場合NIOが適切である
  • JavaNIOの実装にはバグがありそう
  • JavaNIOを使用しているHTTP実装にもまだまだバグがありそう
  • 数千から1万くらいのスレッドは十分に速く、IOもNIOより速い場合が多い

  • Eclipseプラグイン開発の感想

    2010年後半に、拙作のHTTPデバッグ用ソフトウェアであるDoorman@JUMPERZ.NETをEclipseプラグインに移植した(詳しくはこちら)。これは筆者にとって初めてのEclipseプラグイン開発であった。そのとき感じたことを簡単にこのエントリにまとめてみたい。

    Doorman@JUMPERZ.NETというソフトウェアは、2007年に筆者が拙著「ウェブアプリケーションセキュリティ」執筆の際に作成したものである。ローカルマシンで動作するGUIのHTTPプロキシサーバであり、ウェブブラウザとウェブサーバの間に位置し、そこを通り過ぎるHTTPリクエストとレスポンスをユーザが自由に見たり書き換えたりすることで、ウェブ開発の際のデバッガとして使用することができる。筆者はウェブアプリケーション開発者でもあるので、自分自身の開発の際などにも便利に使っている。

    2007年のリリース時、このソフトウェアはSWTという技術をプラットフォームとして採用した。SWTはStandard Widget Toolkitの略で、JavaでのGUIアプリケーションの際に利用できるオープンソースのライブラリの1つである。SWTはJava標準のGUIライブラリであるSwingとは異なり、クロスプラットフォーム開発(WindowsやMacOS,Linux)が可能であるにもかかわらず、動作時のルック&フィールはネイティブアプリケーションのそれとまったく同じとなる(JNIを通じて下層でネイティブコードを呼び出している)。筆者は個人的にSwingのルック&フィールがまったく好きになれず、JavaでGUIアプリケーションを開発したいがSwingはあり得ないと考えていたため、SWTはまさに福音であった。SWTは元々IBMが強力なIDEを開発するための基礎として開発したものであり、その血脈は現在もEclipseに力強く受け継がれている。SWTをオープンソース化してくれたIBMにここで感謝の意を表しておきたい。

    開発開始当初はEclipseのように柔軟なGUIアプリケーションにしたいと考えていたので、Eclipse RCP(Rich Client Platform)として開発を開始した。しかしEclipseフレームワークが想像していたよりもかなり大規模であり、またRCPの配布形式が当時の筆者にとってはわかりにくいものに見えたため、少し勉強してみたものの諦めたという経緯がある。とにかく早く動くものを開発したかったため、結局GUIのライブラリとしてのSWTのみを使用し、アプリケーションのベースとなる部分は単にシンプルなJavaのアプリケーションとして開発したのである。このような経緯があったため、開発がとりあえず完了してからも、心の中ではいつかEclipseのプラグインかRCPアプリケーションにしてみたいと考えていた。

    2010年に筆者は35回目の誕生日を迎え、いわゆるプログラマーの定年に達することとなった。ちょうどプログラマーとして独立してから10年が経過したこともあり、個人的なキャリアの振り返りを行っていたタイミングでもあった。二ヶ月ほどゆっくり時間をかけ、これから自分がどのような技術にどんな風に触れていくべきか考えをまとめた。そして出した結論は、「今まで手を出していなかった様々なプラットフォームにも、浅く広く触れていこう」というものである。それまでの筆者はどちらかというとかなり限定した狭い範囲(Javaでの開発やHTTP、セキュリティなどの分野)を深く掘り下げるのが好きであり、反面、手を出さないジャンルには徹底的に手を出していなかった。ちょうどKindleやiPadなどの新しい形のデバイスを手にすることで新しい刺激を受けていたこともあり、開発者としての自分ももっと積極的に新しいジャンルに踏み出していこうと考えたのである。

    そこで、とりあえずは自分の技術の拠点であるJavaを中心に、今まで触っていなかった方面に手を伸ばしてみることにした。10年ぶりに新しいプログラミング言語の勉強を始めることにし、その対象としてClojureを選択した。ここで勉強の対象をRubyやPythonにしなかった理由は、ClojureがJavaとはとても異なる性質を持っているからだ。RubyやPythonは所詮C言語の流れをくんだ王道的なOOPの言語であり、C++、JavaScript、Perlを少し、そしてJavaを十分に経験している筆者にとってはそれほど違和感を感じる言語ではない。せっかく新しい言語を覚えるのであれば、今までに経験した言語とはまったく性質の異なるものにしようと考えた。Clojureは言語としてJavaとはまったく異なっている反面、JVM上で動作するという非常に大きなアドバンテージがある。安定性、速度、マルチコアへの対応、クロスプラットフォーム性、そして既存のJavaライブラリ(筆者は10年間かけてそれなりに自分で使いやすいと思うライブラリを構築してしまっている)を活かせることなど、実案件で使えそうな条件も整っている。また「ハッカーと画家」を読んだ経験と、筆者がベッチーこと苫米地氏のファンであることから、LISPをいつか勉強していたいと思っていたことなどもClojureへのモチベーションを後押ししてくれた。Clojureは(かなり独自色はあるが)JVM上で動作するLISPである。

    その後Clojureの学習は順調に進めることができ、今ではデータ処理などを行う際の書き捨てツールなどはすべてClojureで書くようになった。「新しい技術に積極的に手を出していく」という目標に対する第一歩が順調に踏み出せたことでうまくモチベーションが回るようになり、次は以前からやりたいと思っていたEclipseプラグイン(あるいはRCP)に手を出すことにした。

    Eclipseプラットフォームはこの数年の間にIDE環境として不動の地位を築き上げた(既にIDE市場の製品の70%はEclipseベースであるという記述も見たことがある)。筆者は2002年頃からEclipseを使っているので、そこそこ古株の利用者である。当時はPCのパワーが現在よりかなり劣っていたため、Eclipseは起動するだけで1分かかるようなこともあり、非常に重いという評判だった。しかしEclipseでのJava開発ではエディタでの編集やコードの保存に合わせてコンパイルがさくさくと進み、C++での開発のように、アプリケーション全体のコンパイルに時間がかかるようなことがなかった。そのため、筆者がそれ以前にやっていたBorland C++ Builderでの開発よりも、よほど軽い感触で開発できるという印象を持っていた。

    Eclipseは初期はJava開発環境として知られていたが、少しずつ着実に汎用IDEプラットフォームとして成長していたようだ。数十から数百といった数のモジュールを破綻することなく扱うためにOSGiをベースとして選択し、プラグインベースで拡張可能なプラットフォームとして独自の路線を歩み始めた。その結果、筆者が出会っただけでもAdobeのFlex、AptanaのAptana Studio、(元)BorlandのJBuilderなどのIDEがEclipseをベースとして開発されるようになった。このことからEclipseプラットフォームは今後も長く安定して提供されるだろうと確信することができた。10年間ただ利用するだけだったEclipseプラットフォームだが、せっかくJavaプログラマーであるので、プラグインによって自分の好きなように機能を拡張できるようになればそれはきっと素晴らしいことになるだろう。そのような想いで、2007年に一度は挫折したEclipseプラットフォームでの開発に再びチャレンジすることにした。

    移植前のDoorman@JUMPERZ.NETはスタンドアローンのJavaアプリケーションであり、GUIライブラリとしてSWTを使用している形となる。そのためロードされるクラスは最低限アプリケーションの動作に必要なものだけとなっており、起動は2〜5秒程度で済む(それでもJavaアプリケーションなので、ネイティブのバイナリで起動するアプリケーションよりよほど遅いが)。また、必要となるファイルもjarファイルとswtのライブラリのみで、数MBだけである。しかしEclipseプラグインにしてしまうと、起動するためには当然Eclipseを起動する必要があり、これは遅いマシンでは(2011年現在でも)20秒近くかかる可能性がある。また、使うためにはEclipse本体のパッケージをインストールする必要があり、ファイルの数もサイズも膨大なものとなる。これらのポイントはEclipseプラグインにすることのデメリットだと言える。一方でメリットとしてはクロスプラットフォームでのインストールの容易さがあり、またViewやPerspectiveといった優れたインターフェースを使えるという点がある(これらの点については後述)。

    筆者はAmazonが提供するKindleのサービスが非常に気に入っており、Eclipseプラグインの開発やOSGi、RCP関連などの洋書を電子書籍として購入し、基本的に開発に必要となる知識を英語(洋書の電子書籍と英語のウェブサイト)で得ることを決めた。このようなルールを決めてしまえば、いやでも英語力が高まることになる。また、元々これらのジャンルは日本ではそれほど人気がなく、日本語での情報が不足しているという面もあった。当初は慣れない英語の電子書籍ということで疲れることもあったが、次第に順応することができた。その省スペース性や可搬性の高さなど、電子書籍、そしてKindleの魅力に徐々にハマっていくことになった。

    Eclipseプラットフォームの魅力はなんといってもその柔軟なウィンドウ(View)配置能力である。それぞれのウィンドウはViewと呼ばれ、マウスによって自由に(本当に自由に)各View間のレイアウトを変更することができる。複数のViewを同じ位置に配置すれば上部のタブで管理できるようになるため、画面が狭い場合でも工夫して作業しやすいレイアウトを作成することができる。複数のViewの配置によって決定されるアプリケーション全体のレイアウトはPerspectiveと呼ばれる概念で捉えられ、Perspectiveに名前を付けて保存しておくことができる。そのため開発時やデバッグ時などに違うレイアウトを使いたい場合でもワンタッチでアプリケーション全体のレイアウトを変更することができる。この15年の間にさまざまなGUI環境に触れてきたが、Eclipseの、このViewとPerspectiveからなる仕組みはかなり完璧に近いGUIインターフェースであると考えている。

    Doorman@JUMPERZ.NETの移植を開始する際にまず迷ったのが、これをEclipseプラグインにするか、あるいは独立したRCPアプリケーションとするかという点である。もともと独立したアプリケーションであったのでRCPアプリケーションにするつもりだったのだが、最終的にはEclipseプラグインとして開発することに決めた。これは次のような理由からである。

  • プラグインは単体のjarファイルにまとめることができるため、配布やインストール・アンインストールの方法がシンプルでわかりやすい
  • Eclipseがインストールされていれば、ファイルをコピーしてEclipseを再起動するだけでインストールができる
  • Windows/MacOS/Linux/32bit/64bit等の環境を考慮せずにjarファイルのみ配布できる(各環境に合わせたEclipseのパッケージはeclipse.orgで配布されており、誰でも利用可能である)
  • RCPの配布がやや複雑でわかりにくい
  • 元々はRCPという形態は存在せず、プラグイン開発が元祖である
  • RCPではなくプラグインとして開発することを決め、Kindleで購入したAddison-Wesleyの「Eclipse Plug-ins Third Edition」「OSGi and Equinox」などを読むところからスタートした。長らくEclipseを使っていたが、それがOSGiという基盤を持っていたことはこの段階まで知らなかった。OSGiではBundleという刺激的な概念が登場する。単なるjarファイルやパッケージ名によるモジュール化だけではエンタープライズ級の巨大なアプリケーション開発には不十分であると考え、自らが外部に提供する機能や依存する他のBundle、あるいはそのバージョンなどを明記するというアプローチである。以下に例を示す。

    Manifest-Version: 1.0
    Bundle-ManifestVersion: 2
    Bundle-Name: DoormanHook
    Bundle-SymbolicName: net.jumperz.app.MDoormanHook;singleton:=true
    Bundle-Version: 1.0.0.qualifier
    Bundle-Activator: net.jumperz.app.MDoormanHook.Activator
    Bundle-Vendor: JUMPERZ.NET
    Require-Bundle: org.eclipse.ui,
     org.eclipse.core.runtime,
     net.jumperz.app.MDoorman;bundle-version="1.0.0"
    Bundle-RequiredExecutionEnvironment: J2SE-1.5
    Bundle-ActivationPolicy: lazy
    Export-Package: net.jumperz.app.MDoormanHook,
     net.jumperz.app.MDoormanHook.view
    Bundle-ClassPath: .,
     lib/clojure.jar

    これはDoorman@JUMPERZ.NETの移植後に作成された2つのプラグインのうち、片方のMANIFEST.MFファイルの中身である。自らのBundleの名前(Bundle-Name)やバージョン(Bundle-Version)、依存する外部のBundle(Require-Bundle)、実行時に最低限必要となるJavaのバージョン(Bundle-RequiredExecutionEnvironment)などが記述されている。これらのメタ情報がコンパイルされたクラスファイル群と一緒になっていることで、単なるパッケージやjarファイルによるアーカイブよりも明確に、Javaコードの管理されたモジュール化が実現されることになる。Eclipseプラグインはどれもこのようなメタ情報を持っている。Eclipseがインストールされているディレクトリの下のplugins/の中から興味があるプラグインを探し、メタ情報を見てみるだけでもなかなか興味深いかもしれない。

    また、プラグイン(=Bundle)がどのようなViewを持っているか、等のプラグイン内部のメタ情報もXMLファイルの形で管理されており、こちらもコードとは別に明確に宣言されている。以下に例を示す。

    <?xml version="1.0" encoding="UTF-8"?>
    <?eclipse version="3.4"?>
    <plugin>
       <extension
             point="org.eclipse.ui.views">
          <view
                category="Doorman.category1"
                class="net.jumperz.app.MDoormanHook.view.MHookView"
                icon="src/net/jumperz/app/MDoormanHook/resource/doorman.gif"
                id="net.jumperz.app.MDoormanHook.view.MHookView"
                name="Hook"
                restorable="true">
          </view>
       </extension>
       <extension
             point="net.jumperz.app.MDoorman.Hook">
          <client
                class="net.jumperz.app.MDoormanHook.MHookFactoryImpl">
          </client>
       </extension>
    </plugin>

    これらのメタ情報について、開発者は直接ファイルを編集する必要はない。プラグイン開発の際に必ず使用するであろうGUIツールであるEclipseプラグインエディタを使用することで、自動的にXMLファイルの中身が編集されていく。プラグインエディタの使い方は独自の作法が多く、開発者はまずこれを覚えないと開発を進めることができない。そのため初めての場合はチュートリアル的な参考書などが必須となるだろう。基本的にはプラグインエディタはわかりやすいツールだが、たまにプラグインエディタが何をやっているのか不明な場合などもある。このような場合、プラグインエディタによって変更が自動的に反映されたXMLファイル側を直接読むことで、その意味が明確になる場合などもある。

    このようにメタ情報がXMLファイル等で管理される開発では、Javaのクラス名が要素として重要なものとして扱われることが多い。これはある意味仕方がないことではあるが、メインとなるクラスだけではなく、Viewなどについてもすべてクラス名がXMLファイル中に記述されるようになっている。クラス名そのものがアプリケーションにおいて重要な意味を持ってしまうというのは、筆者にとっては少し抵抗があることだったが、これも作法だと思って受け入れるしかないだろう。クラス名そのものが意味を持ってしまうことは、ClojureのようにJVM上で動作する別の言語を使ってEclipseプラグインを開発しようとする場合に邪魔になることが考えられる(わざわざJavaのクラスを生成しなければならないため)。筆者はDoorman@JUMPERZ.NETの移植に際してClojureでの開発(移植)にチャレンジしてみたかったのだが、Eclipseプラグイン開発がプラグインエディタというツールやJavaのクラスファイル名への依存が大きかったため、これは諦めることとなった。ただし、これは手間をかければ不可能ではないかもしれないので、興味がある人にはぜひチャレンジして欲しい。

    Eclipseプラットフォームにおける開発において、とても良い印象をうけたのは、Preference(設定)関係である。Eclipseでは設定情報をPreferenceダイアログで管理できる。設定情報にはツリー構造を持たせることが可能であり、項目の分類を適切にすることで使いやすいインターフェースを実現できる。また、項目数が多くなってもテキスト入力欄から気軽に検索して目的の設定を見つけることができるため、多数のプラグインをインストールした後でも非常に使いやすいものになっている。Eclipseプラグイン開発では、このPreferenceまわりについての作法に従うことで、非常に簡潔に、使いやすい設定ダイアログを開発することができる。データのセーブ・ロードなどをプラットフォーム任せにすることができるため、殆どコードを記述する必要がない。この部分の出来の良さにはとても感銘を受けた。

    しかし一方で、メニューに関する作法には疑問を持った。メニューというのはアプリケーションの使用状況に合わせて表示する項目を変えたり、あるいはEnable/Disableを切り替えたりすることが多い。この切り替えのルーチンは、結局の所コードを書くことで管理するのがプログラマーにとってはもっともわかりやすいと感じる。プラグインエディタにはGUIベースでこれをやろうとする仕組みが存在しているのだが、これがどうにも使いにくい。しばらく格闘したのだが、結局諦めて自分のコードで片付けてしまった。このように作法に従うと楽な場面もあり、一方で作法が難しすぎると感じる場面もあった。

    また、Eclipse自体が既に長い歴史を持っていることから、参照しているプラグイン開発用の情報が既に古くなってしまっているケースがある。特にウェブサイトを参考にする際にはそのページがいつごろ書かれたものなのかに注意が必要だ。このような意味では、(電子)書籍は記述された時期が特定でき、書籍内を通じて統一されているため使いやすい。

    先述したようにEclipseプラグインはOSGiのBundleであり、高度なモジュール化がウリの一つである。そこで移植作業が順調に進んできた頃、プラグイン間の連携機能についても学んでみることにした。Doorman@JUMPERZ.NETが以前持っていた機能はほぼすべて1つのプラグイン(いわば本体)として移植し、さらに追加で開発した新機能を別のプラグインとして実装することとした。本体の名前はMDoorman、別プラグインの名前がMDoormanHookとなる。

    MDoormanは自身のHTTPプロキシサーバにおいて、リクエストあるいはレスポンスを受信したタイミングで特別な処理(Hook)を実装するための拡張機能を持つことにする。この拡張機能はMDoormanプラグインに拡張ポイント(ExtensionPoint)として定義される。他のプラグインはこの拡張ポイントに定められた実装を行うことでMDoormanプラグインと連携した動作を行うことができるようになる。筆者自身が両方のプラグインを実装することで、Eclipseプラグインにおいて拡張ポイントを提供する側と、それを使用する側の両方の実装を学ぶことができると考えた。

    拡張ポイントの作法は筆者が予想していたよりもはるかに複雑であり、結局こちらのサイトのチュートリアルをほぼそのまま実装することで何とかやりたい動きを実現することができるようになった。拡張ポイントまわりは(あくまで予想だが)より多くのパターンの拡張をサポートするために多機能となっているのだと考えている。完全に把握するためにはかなりの学習が必要になりそうだと感じた。

    今回拡張ポイントを利用する側のプラグインであるMDoormanHookはユーザに対して「Hook」という名前のViewを提供する。ユーザは自身のコードをこのViewを使って登録することで、プロキシサーバを通過するリクエストやレスポンスを書き換えることができるようになる。MDoorman本体は(もちろん当然なのだが)MDoormanHookなしでも動作することができる。MDoormanHookプラグインをインストールすればHookのViewと連携し、アンインストールすれば何事もなかったかのように本体だけで動作する。筆者はGUIのツールにおいてこのようにきれいに機能を拡張可能な機構を使った経験がなかったため、はじめに動作を確認した際にはなかなかに感動させられた。モジュール間の区切りが鮮明になるため、エンタープライズ級のアプリケーションでは特に役に立つだろう。

    先述したように、MDoormanHookを使うことで、ユーザはプロキシサーバ上を通過するリクエストやレスポンスを変更することができる。わかりやすい例として、User-Agentの書き換えなどがある。この書き換えはClojureのコードを記述することで実現される。

    元々のアイデアではJavaとClojureのコードの両方をサポートするつもりだったのだが、Javaについてはうまく行かなかった。拡張ポイントの向こうとこちらが異なるプラグインであることから、クラスローダが異なっていることがあり、結果としてJavaのコードを動的にコンパイルして動作させることができなかった(MDoorman本体側で記述すれば可能なのだが、それでは今回の目的である「Hookの機能は別プラグインで実装する」ということから外れてしまう)。こちらのプラグインで動的に生成したクラスを向こうのプラグインに渡す、ということができないのだ。このことから、クラスローダが直接影響するような仕組みをEclipseプラグインで操る場合には注意が必要であることがわかった。またClojureそのものについても、このクラスローダがプラグインごとに異なっているという問題が影響を及ぼすケースがあり、例えばClojure開発用のEclipseプラグインであるcounterclockwiseはMDoormanHookと共存させようとした場合にエラーが出てしまうケースがあるようだ。

    拡張ポイントの実装を終えた後には、アップデート機能の検討に取り組んだ。ここではFeatureという概念が登場する。Eclipseプラットフォームが提供するアップデート機能を使うためには、アップデート用のウェブサイトや、Featureを使ったプラグインの管理が必要となる。標準的な手続きにしたがってアップデート用のウェブサイトまで構築し、実際にインストールやアップデート作業を行ってみたのだが、最終的にはこれは使わないことにした。理由は下記のようなものである。

  • いくつかの環境で、アップデートのテスト中に、ネットワークの通信中のステータスのままEclipseが固まってしまうことがあった
  • すべてがうまく行く場合でも、手順があまりシンプルでなく、ユーザにとって本当にわかりやすいかどうか疑問が残った(単にjarファイルのコピーでインストールできた方が簡単では?)
  • このように様々な経緯を経て移植作業を完了させることができた。WindowsでもMacOSでもLinuxでも何の問題もなくアプリケーションが動作する様子は圧巻である。すべての環境でネイティブアプリケーションとまったく同じLook&Feelとなり、またインストールもEclipseさえ入っていれば簡単に終了する。特に筆者にとって開発経験がまったくないMacOS上できちんと自分が開発したアプリケーションが動作したことは軽いカルチャーショックであり、クロスプラットフォーム性を持つJava、あるいはEclipseの凄さを再認識させられることとなった。

    今回開発したアプリケーションは「Doorman Eclipse Plugin」と名付けた。2011年にはSSL関連の作業に便利な新機能も実装した。また、近いうちにClojureだけでなく、JavaScriptでも拡張コードが書けるようにする予定である(実装にはJavaに含まれているrhinoを使う予定)。

    また、この後筆者はAndroidアプリケーションの開発にも触れてみることにしたのだが、その際にこのEclipseプラグインの開発経験が役に立った。Android開発はEclipseプラグイン開発と同様に、IDE(もちろんEclipseだ)にヘビーに依存する開発手法となっている。また、GUIのエディタツールでの操作がXMLファイルを更新する部分などもそっくりである。おそらくAndroidの開発環境はEclipseのプラグイン開発を参考に設計されたのではと思う。そしてAndroidの開発環境はまさにEclipseプラグインそのものとなっており、その動作の仕組みなどを把握する際にも今回のEclipseプラグイン開発経験が役に立ちそうだ。

    このように、筆者にとっては得るところの非常に多い開発経験となった。最近ではあまり「Eclipseプラグインを開発しよう!」のような記事を見かけなくなってしまったが、2011年現在でもまったく問題なくおすすめできる最新の技術であると思う。


    VMware Serverのパフォーマンス劣化を防ぐ

    ※注
    この記事は2009年末にWizardBible Vol.49に寄稿したものである。
    VMWareがクソ重くて殺したくなる時に読むべきものという記事で取り上げていただいたところ反響が大きかったようなので、こちらのブログにも掲載しておく。WizardBibleは拡張子.txtのためかSEO的に弱いようなので…

    — 第1章: もう憂鬱じゃないVMware Server管理者 —

    著者:金床

    x0xXx0xx0xXx0xx0xXx0xx0xXx0xx0xXx0xx0xXx0xx0xXx0xx0xXx0xx0xXx0xx0xXx0xx0xXx0x
    x0xXx0xx0xXx0xx0xXx0xx0xXx0xx0xXx0xx0xXx0xx0xXx0xx0xXx0xx0xXx0xx0xXx0xx0xXx0x

    ■0×00.) はじめに

    Wizard Bible vol.43にて、VMware ServerをLinux上で走らせる際の苦労につい
    ての記事を書いた。その後さらに試行錯誤を繰り返していくうちにかなり納得で
    きるレベルまでたどり着くことができたので、今回はWB43の続編といった位置づ
    けでの記事としたい。

    「どんなに暗い夜でも、必ず朝が来る」

    パフォーマンスが上がらず四苦八苦し続けたVMware Serverの管理者たちに、こ
    の言葉を贈りたい。

    ちなみに筆者が使っているバージョンはLinux版のVMware Server 1.0系列で、
    ホストOSはX86_64のCentOS5.2である。他のバージョンを使っている人には参考に
    ならない点が多々あるかと思うがご了承願いたい。

    ■0×01.) 前回の結論と残った課題

    WB43での筆者の結論は以下のようになっていた。

    ・すべてのゲストOSがフルにメモリを消費しても大丈夫な量のメモリをホストOSに載せておく
    ・ホストのメモリが実際にいくつ消費されているのかはぶっちゃけわからないので、
    気にしないwww
    ・どうしても気になる場合は/proc/meminfoを見る
    ・mainMem.useNamedFile = “FALSE”は使わない
    ・そのほかは普通にやる(Fit all virtual…やDisable memory page trimming
    など)

    これらの方針通りにしばらくサーバー運用を続けたのだが、どうにも状態がよ
    ろしくない。数週間から1ヶ月以上立ち上げっぱなしにしているゲストOSのパフォ
    ーマンスが思わしくなく、徐々に調子が悪くなってくるのだ。特に具合が悪くな
    る(あらゆる場面で遅くなる)のが、ホストOS上で巨大なファイルのコピーなど
    の作業を行った後だ。つまり、ホストOSのディスクキャッシュが大きな影響を与
    えているものと予想される。

    ゲストOS(筆者の場合は主にLinuxだが、Windowsも同様に調子が悪くなる)を
    再起動すれば調子は良くなるのだが、それではせっかくのLinuxサーバーがまるで
    Windows NT4.0のようで、精神安定上よろしくない。

    とはいえ再起動すれば調子よくなってしまうため、いったいどのような現象が
    起こっているのかつかめず月日は流れていった。

    ■0×02.) .vmemファイルの読み込みが遅い

    あるときゲストOSのリジュームがいつまでたっても終わらないので、.vmemファ
    イルの読み込みに問題があるのでは?と見当を付けた。そこで1ヶ月以上連続稼働
    しているゲストOSの.vmemファイルをddで読み込み、その読み込みの速度を測定し
    てみる。するとなぜか2MB/sしか速度がでない。ハードディスクには特に問題はな
    く、シーケンシャルの読み込みでは80MB/sくらいは出るのに、である。

    なぜこんなに読み込みが遅いのだろうか?と考えた結果、このファイルがひど
    く断片化しているのではないかという仮説にたどり着いた。というのは、起動直
    後のゲストOSをサスペンド・リジュームさせた場合、比較的スムーズにリジュー
    ムが完了するのに対し、長い間稼働した後のゲストOSをサスペンド・リジューム
    させるとひどいことになるからだ。

    また、ホストOSのディスクキャッシュの状態がリジュームのパフォーマンスに
    大きな影響を与えているように感じていたが、この点について、この断片化した
    .vmemファイルがディスクキャッシュにのっていない場合にひどくパフォーマン
    スが悪くなるのではないかと予想した。

    ■0×03.) 1つめの解(/dev/shmに置く)に到達

    そこで、ハードディスク上での断片化が問題なのだろうという予測のもとに、
    .vmemファイルがメモリ上に作成されるようにしてみた。ホストOSにおいて/tmpを
    /dev/shmへのリンクに変更した上で、vmxファイルでmainMem.useNamedFile = “F
    ALSE”を記述する。すると.vmemファイルはメモリ上に隠しファイルとして作成さ
    れる。メモリ上でもファイルとして断片化が発生するのかどうかはわからないが、
    HDDよりもランダムアクセスが圧倒的に速いため、これで状況が改善するのでは?
    と期待した。

    結果は期待通りで、長い間稼働させてもパフォーマンスは安定し、サスペンド
    とリジュームもうまく動いてくれるようになった。この方法の欠点は常にホスト
    OSのメモリを消費するということだが、メモリをたっぷり積んでいる場合は問題
    ない。手軽に実行できるのでおすすめの方法である。

    この方法は/etc/vmware/configにtmpDirectory = “/dev/shm”と書き、さらにv
    mxファイルにmainMem.useNamedFile = “FALSE”と書くことでも実現可能である。

    後に検索によってたどり着いた多くのVMware関連のフォーラムなどをのぞいた
    感じでは、この手法は一定数のユーザから支持されているようだった。

    ■0×04.) 断片化の度合を調べる

    今回の調査では某社の気鋭のエンジニアであるINB氏に多大なる協力を頂いた。
    筆者が「ファイルがどのくらい断片化しているかを調べる方法を探している」と
    伝えたところ、hdparmの–fibmapオプションで可能であることを教えてくれた。
    また、前項のtmpDirectoryという項目の存在を教えてくれたのも彼である。この
    場を借りてお礼を申し上げる。

    hdparmの–fibmapオプションは比較的新しいバージョンのhdparmに実装されて
    いる。筆者の環境であるCentOS5.2に入っていたhdparmではサポートされていなか
    ったので、最新版のソースをダウンロードしてコンパイルした(makeするだけで
    無事にコンパイルすることができた)。

    .vmemファイルに対して以下のように–fibmapオプションを使用する。

    hdparm --fibmap 'Windows XP Professional.vmem'
    

    すると以下のように結果が出力される。場合によっては非常に大量の出力が発
    生するので注意が必要である。

    Windows XP Professional.vmem: underlying filesystem begins at LBA 63; assuming 512 byte sectors.
    byte_offset  begin_LBA    end_LBA    sectors
    0  679035215  679035310         96
    49152  679035319  679043510       8192
    4243456  679043527  679051326       7800
    8237056  679051391  679051782        392
    8437760  679051791  679059982       8192
    12632064  679059991  679067710       7720
    (略)
    

    数ヶ月稼働させていたゲストOSの.vmemファイルを調べてみたところ永遠に出力
    が止まらないw感じだったので、wc -lにパイプしてhdparmの出力の行数だけを調
    べてみることにした。このゲストに割り当てていたメモリは約650MBだったので.v
    memファイルのサイズもそのくらいだったのだが、なんとhdparm –fibmapの出力
    は140万行以上に及んだ。ddでシーケンシャルに同じくらいのサイズのファイルを
    作成して調べてみたところわずか359行だった。…断片化ってレベルじゃねーぞ!
    (`Д´)ノゴラァ

    とにかくこれで「.vmemファイルがひどく断片化することがパフォーマンス劣化
    の原因である」ということはほぼ確定した。そこで「vmware vmem fragmentation」
    などでググってみると、案の定たくさんのウェブサイトがヒットした。このことに
    もっと早く気づいていれば…。

    ちなみにこの検索の段階で「filefrag」というそのまんま断片化の具合を調査
    するコマンドもあることを知った。手元のUbuntuではデフォルトで入っているよ
    うである。

    ■0×05.) 断片化の理由

    なぜ.vmemファイルはここまでひどく断片化するのだろうか。WB43でも書いたよ
    うに、.vmemファイルはスパースファイルとして生成される。そしてゲストOSを長
    い時間稼働させていくにつれて、徐々に実際のサイズが大きくなっていく。

    どうやらvmware-vmxプロセスは.vmemファイルを1MBずつの領域にわけてmmapを
    行っているらしい。これは、/proc/PID/smapsをlessなどで見ることで確認するこ
    とができる(PIDはvmware-vmxプロセスのプロセスID)。該当する.vmemファイル
    について、Size項目が1024KBの領域が大量に見つかる。

    おそらくvmware-vmxプロセスは、「しばらくアクセスされていない」等の何ら
    かの基準に従って、これらの1MBの領域を個別にファイルに同期させているのだろ
    う。全体で数Gにもなるようなスパースファイルである.vmemファイルに、ばらば
    らの順番で1MBずつ書き込みを行っているために断片化が発生しているのだと予想
    される。

    ■0×05.) 2つめの解(.vmemファイルをコピーして入れ替える)に到達

    筆者と同じく.vmem問題にはまっている人たちのエントリが大量に見つかったの
    で適当に目を通してみると、「一度サスペンドさせ、.vmemファイルをコピーして
    入れ替え、リジュームする」という作戦が見つかった。断片化が起こる前に、フ
    ァイル自体をきっちりHDD上に作成してしまおうという作戦である。

    見た目のサイズが大きいにもかかわらず、実際には小さなスパースファイルが
    あるとする。このファイルをddで読み込むことで別のファイルとしてコピーする
    と、スパースファイルではない通常の(巨大な)ファイルが生成される。コピー
    の際に、スパースファイル中のまだ書き込みが行われていなかった部分は、コピ
    ー先のファイルではすべて0×00となる。

    こんなことをして何がうれしいのかというと、コピー時に一気に大きなファイ
    ルを作成するので、ファイルがHDD上にほぼシーケンシャルに作成され、断片化が
    非常に少ない形で.vmemファイルを作成することができるのだ。また、このファイ
    ルはすでに全体をディスク上に割り当てられているため、これ以上肥大化するこ
    とがない。つまり断片化も進まない。この後いくら長くゲストOSを稼働させ続け
    ようとも、まったく断片化は進行せず、安定したパフォーマンスを発揮するので
    ある。

    筆者の手元ではcpコマンドでのコピーではスパースファイルのままコピーされ
    てしまうようだった。そのためddを使ったが、インターネット上で見た感じでは
    このあたりは環境依存があるようだ(cpコマンドでうまくいっている人もいるよ
    うである)。

    この方法を簡単にまとめると次のようになる

    ・この方法は、mainMem.useNamedFile = “FALSE”を指定しない場合に使用できる
    方法である。つまり、.vmemファイルが/tmp等ではなく、.vmxファイルと同じディ
    レクトリに作成される場合に使う方法となる
    ・ゲストOSを起動したら、すぐにサスペンドさせる。
    ・この段階で、実際のサイズは非常に小さいが、見た目のサイズは割り当てたメ
    モリの量に等しい.vmemファイルが作成される(仮にサスペンドせずこのまま使用
    を続けると、ひどく断片化しながら肥大することになる)。
    ・ddなどを使ってこの.vmemファイルをコピーする。ここでコピー先のファイルは
    実際に巨大なサイズのものになる。コピーには数十秒かかる場合もある。
    ・コピーが終了したら、コピー元のスパースファイルを削除する
    ・コピー先のファイルをリネームし、元の.vmemファイルと同じ名前にする
    ・ゲストOSをリジュームする
    ・安定稼働(*´Д`)長期実現ポワワ

    この方法を使えば長期稼働させた後にも安心してサスペンド・リジュームさせ
    ることができる。欠点は毎回起動後にシェルから操作が必要であることと、コピ
    ーの時間(数十秒)が掛かることである。

    ■0×06.) 3つめの解(LD_PRELOADで.vmemファイル生成)

    前項で説明した方法でほぼ問題ないのだが、毎回シェルからファイルコピーな
    どの操作するのはメンドクチイと思ったので、もう少し踏み込んでみた。WBらしく、リ
    バースエンジニアリングの領域に突入である。ゲストOSの実体であるvmware-vmxプロセス
    の動作を一部動的にコントロールする。そして、.vmemファイルをスパースファイ
    ルではなく、通常のファイルとして作成させてしまうのだ。

    幸いなことにWB43にてvmware-vmxプロセスをstraceで追った際に、スパースフ
    ァイルが作成される箇所は特定済みである。以下はstraceのログからの抜粋だ。

    pwrite64(106, "", 1, 3774873599) = 1
    

    このように、起動からまもなく、かなり癖のある引数でpwrite64が実行される。
    4つめの引数(オフセット)が極端に大きいのに対し、たった1バイトしか書き込
    みを行わないことで、この呼び出しがスパースファイルの生成であることが容易
    に確認できる。この時点でファイルをシーケンシャルにきっちり作成してやれば、
    前項で説明した「2つめの解」と同じ動きを実現できるはずだ。

    Linuxではライブラリの呼び出しをLD_PRELOADを使いフックすることで、任意の
    関数の内容を書き換え、アプリケーションの動作を変更することができる。ここ
    ではpwrite64の呼び出しをフックし、スパースファイルの作成を阻止しつつ、実
    際に大きなファイルを生成するようにすればいい。ただしpwrite64はvmware-vmx
    の動作中で何度も使用されるため、スパースファイルの作成を行っている場合の
    み、挙動を書き換えるようにする。上に書いたように4番目の引数であるオフセッ
    トが極端に大きく、かつ1バイトしか書き込まない場合に挙動を変更する。このと
    きpwrite64ではなく普通にwriteを呼び出し、大きな.vmemファイルをきっちり作
    成する。そうでない場合はそのまま本物のpwrite64にフォワードし、戻り値もそ
    のまま返すようにする。

    ソースコードは以下のようになる。

    
    
    
    
    
    #include <dlfcn.h>
    #include <syslog.h>
    #include <fstream>

    #if defined(RTLD_NEXT)
    #define REAL_LIBC RTLD_NEXT
    #else
    #define REAL_LIBC ((void *) -1L)
    #endif

    using namespace std;

    static int flag1 = 0;

    // pointer to the original function
    static int (*original_pwrite64) (int file_descriptor, const void *buf, size_t nbyte, off64_t offset) = NULL;

    // for logging
    #define LOG(...) do {                 \
      syslog(LOG_INFO, __VA_ARGS__);      \
    } while(0)

    #define SADDR_B(target,shift)  (((target) >>  (shift)) & 0x000000ff)

    static void __attribute__ ((constructor))
    _constructor()
    {
      // for syslog
      openlog(NULL, LOG_CONS | LOG_NDELAY | LOG_PID, LOG_USER); 

      //get address of the original function
      original_pwrite64 = (int(*)(int,const void*,size_t, off64_t)) dlsym(REAL_LIBC, "pwrite64");
    }

    static void __attribute__ ((destructor))
    _destructor()
    {
      closelog();
    }

    ssize_t pwrite64(int fd, const void *buf, size_t nbyte, off64_t offset)
    {
    int rv;
    if( flag1 == 0
     && offset > ( 1024 * 1024 * 200 ) //works only if offset is larger than 200MB
     && nbyte == 1
      )
        {
        flag1 = 1;
        LOG( "pwrite64 is hooked. ARGS: nbyte=%d, offset=%d\n", nbyte, (int)offset );
        LOG( "creating .vmem file...\n" );
        int bufsize = 1024 * 1024;
        char buffer[ bufsize ];
        off64_t remain = offset + 1;

        while( remain > 0 )
            {
            if( remain > bufsize )
                {
                rv = write( fd, buffer, bufsize );
                }
            else
                {
                rv = write( fd, buffer, remain );
                }
            if( rv == -1 )
                {
                LOG( "write failed.\n" );
                return -1;
                }
            remain -= rv;
            }
        return 1;
        }
    else
        {
        rv = (*original_pwrite64)(fd, buf, nbyte, offset );
        return rv;
        }
    }

    ソースのダウンロードはhttp://www.jumperz.net/tools/nosparse.cppから、バ
    イナリのダウンロードはhttp://www.jumperz.net/tools/nosparse.soから行うこ
    とができる。

    このコードはチームチドリのスーパーハカーyoggy氏が作成したhook_tcp.cppをベースに
    している。オリジナルのファイルはチームチドリのサイトからダウンロードできる
    (http://www.t-dori.net/?hook_tcp.so)。スペシャルサンクスコ!!>Yoggyさん&チドリ

    このコードをnosparse.cppという名前で保存し、次のようにコンパイルして共
    有ライブラリ(.soファイル)を作成する。

    g++ -Wall -fPIC  -shared -o nosparse.so nosparse.cpp -ldl
    

    ここで注意したいのは、このコンパイルは32bitのLinuxマシンで行う必要があ
    るということだ(g++の-m32オプションを使用してもよいのかもしれないが、筆者
    の環境ではさまざまなヘッダファイル等が足りずうまくいかなかった)。筆者は
    最初X86_64のLinux上でコンパイルしたのだが、vmware-vmxのバイナリが32bit用
    のものだったためにうまくPRELOADすることができずハマった(VMware Server 1.0
    系列は32bitバイナリなのである)。

    32bitのマシンでコンパイルしたnosparse.soファイルをX86_64マシンに持って
    くることで、問題なく動かすことができる。もちろんホストOSが32bitの場合はそ
    のままで問題なくうまく動くと思われる。

    コンパイルがうまくできたら、以下のようにしてvmware-vmxプロセスをシェル
    から起動する。ここではnosparse.soは/root/vmware/に、vmware-vmxは/usr/lib
    /vmware/bin/に、そして起動したいゲストOSのvmxファイルは/vmware/Linux/以下
    にあるものと仮定する。

    LD_PRELOAD=/root/vmware/nosparse.so /usr/lib/vmware/bin/vmware-vmx -x -C /vmware/Linux/Linux.vmx -@ \"\"
    

    通常はゲストOSの起動はVMware Server Consoleか行う場合がほとんどだと思う
    が、今回のテクニックを用いる場合にはこのようにシェルからゲストOS(vmware
    -vmxプロセス)を起動する。このとき、CD-ROMが存在しない等のようなエラーな
    どがある場合にはVMware Server Consoleに対してダイアログがポップアップする
    ケースがあるため、VMware Server Consoleからも接続した状態で別ウィンドウの
    シェルから起動する方法がおすすめである。

    うまく起動した場合(上のコマンドを実行し、何もエラー等が出力されない場
    合)、まずはじめに.vmemファイルがディスク上にほぼシーケンシャルな状態で作
    成される。例えばゲストOSのメモリを2GB程度割り当てた場合には数十秒の時間を
    要するので、じっと待つ。ここで時間がかかるのが欠点だが、起動後のパフォー
    マンスはすこぶる安定するのでじっと待つ価値はある。

    .vmemファイルを生成する際に、syslogに(通常は/var/log/messagesに)以下
    のようなログが出力される。

    Nov 17 18:27:06 raptor vmware-vmx[21321]: pwrite64 is hooked. ARGS: nbyte=1, offset=2017460223
    Nov 17 18:27:06 raptor vmware-vmx[21321]: creating .vmem file...
    

    .vmemファイルが無事生成されると、続いて通常と同じようにVMware Server C
    onsoleにBIOS画面が表示され、続いてゲストOSの起動が始まる。ここで試しにhd
    parm –fibmapしてみると、出力はわずか1000〜1500行程度に抑えられており、断
    片化の防止に成功したことがわかる。

    この後は普通にゲストOSを使用できる。サスペンド・リジュームを行う場合は
    再度PRELOADする必要はないので(.vmemファイルはすでに完成しているので)、
    コンソールから普通に操作すればOKである。

    ■0×07.) まとめ

    今回は、LinuxをホストOSとして使うVMware Server 1.0.x系列について、サス
    ペンド&リジュームや長期使用時のパフォーマンス劣化の原因が.vmemファイルの
    断片化であることを特定し、またその対策として3つの案を提示した。.

    vmemファイルの断片化を防止すれば、VMware Serverはすこぶる良好な使用感と
    なり、非常に満足度の高い仮想化技術を提供してくれる。筆者はここ数年長い間
    苦労してパフォーマンス劣化と戦ってきたが、ついに問題点を解決することがで
    き、非常に晴れ晴れとした気分である。VMwareにはESXiなどもあるのでServerの
    1系列にこだわる必要はないと思うかもしれないが、筆者はホストOSとしてのLin
    uxの機能(iSCSI、ソフトウェアRAID、リモートから使用できる充実した管理機能
    など)を必要としているのでServerにこだわっていたりする。今後もしばらくの
    間は使い続けることになるだろう。

    ■0×08.) おまけでQ&A

    なんでこのファッキンVMwareはこんなに具合悪いのマダファッカ?
    .vmemファイルが断片化しているから

    .vmemファイルの実際のサイズの確認方法は?
    ls -lsuhあるいはduで確認が可能

    .vmemファイルはいつ作成されるの?
    ゲストOS起動直後

    .vmemファイルはどこに作成されるの?
    通常.vmxファイルと同じディレクトリだが、vmxファイルの設定でuseNamedFile
    をfalseにしておくと/tmp以下の隠しファイルになる。この/tmpの位置は変更可能(本
    文参照)

    .vmemファイルはいつ書き込まれるの?
    vmware-vmxプロセスがマターリとメモリと同期させる。数日がかりで観察する
    と徐々に大きくなることが確認できる

    .vmemファイルはいつ読み込まれる(役に立つ)の?
    レジュームするとき。あるいはmemory page trimmingが有効な場合には、vmwa
    re-vmxプロセスが判断して随時読み込む

    .vmemファイルの断片化の程度はどうやって確認するの?
    hdparmの最近のバージョンで hdparm –fibmap *.vmem | wc -lすればどの程度
    かをつかめる。あるいはfilefragコマンドを使う

    .vmemファイルの断片化を防ぐ方法は?
    起動直後にサスペンドさせ、ddでファイルをコピーして入れ替え、リジューム
    するか、LD_PRELOADしてスパースファイル生成を阻止する(詳細は本文参照)

    .vmemファイルを使用しない方法はないの?
    ない

    .vmemファイルがなくなったみたいだけど?
    /tmp以下に隠しファイルとして存在している。lsofでvmware-vmxプロセスを見
    ると見つけることができる

    サスペンドは使わない方がいい?
    断片化しないように対策してあれば普通に使える

    memory page trimmingは使わない方がいい?
    ホストOSのメモリが足りなくなる可能性があるなら(かつ断片化を防止してい
    るなら)便利に使える。メモリに余裕があるなら使わない方がいい

    useNamedFile=”False”の意味は?
    .vmemファイルを/tmp以下の隠しファイルにするという意味。理解していないと
    地雷

    ゲストOSが消費しているメモリ量の確認は?
    vmware-vmxプロセスについて、/proc/PID/statusのRSSを見る。あるいはtopで
    SHR項目を確認


    Androidアプリケーションのリバースエンジニアリング

    [PR]株式会社セキュアスカイ・テクノロジーのAndroidアプリケーション診断

    ReversingAndroidApp

    結論を先に書くと、Androidアプリケーションのリバースエンジニアリングは非常に簡単である。理由は大きく2つあり、一つはそれがJavaアプリケーションであること、もうひとつはAndroidがオープンソースであることだ(ただしJNI等を使ってC++やCのコードなどを呼び出している場合には、下層のモジュールの解析は通常のCアプリケーション同様に面倒ではないかと考えられる)。

    Androidアプリケーションは.apkという拡張子でファイル単体で配布されるので、まずそれを用意する。筆者はAppMonsterというツールを使っている。このツールだと簡単にSDカードにapkファイルを保存してくれる。このエントリでは例としてテスト用のアプリケーションであるandroid1.apkを使用する。

    apkファイルはZIP形式の圧縮されたファイルとなっているので、unzipコマンドで任意の場所に展開する。

    root@kaldi:/android/tmp# unzip android1.apk
    Archive:  android1.apk
      inflating: res/layout/main.xml     
      inflating: AndroidManifest.xml     
     extracting: resources.arsc          
     extracting: res/drawable-hdpi/icon.png  
     extracting: res/drawable-ldpi/icon.png  
     extracting: res/drawable-mdpi/icon.png  
      inflating: classes.dex             
      inflating: META-INF/MANIFEST.MF    
      inflating: META-INF/CERT.SF        
      inflating: META-INF/CERT.RSA
    root@kaldi:/android/tmp# ls -l
    total 36
    -rw-r--r-- 1 root root  1504 Jan 20 16:45 AndroidManifest.xml
    drwxr-xr-x 2 root root  4096 Jan 20 17:58 META-INF
    -rw-r--r-- 1 root root 14515 Jan 20 16:46 android1.apk
    -rw-r--r-- 1 root root  3780 Jan 20 16:45 classes.dex
    drwxr-xr-x 6 root root  4096 Jan 20 17:58 res
    -rw-r--r-- 1 root root  1488 Jan 20 16:45 resources.arsc

    AndroidアプリケーションではActivityをはじめさまざまな情報がAndroidManifest.xmlにまとめられている。そのため解析対象のアプリケーションの内部構造を俯瞰するには、まずこのファイルから手を付けるのがよい。しかしAndroidでは極力モバイルデバイスの負荷を減らすためにXMLファイルはあらかじめパースされたバイナリ形式となって格納されており、このままではAndroidManifest.xmlをエディタで開いても中身を読むことができない。そこでバイナリ形式から通常のXML形式に戻すためにAXMLPrinter2.jarというツールを使用する。jarという拡張子からわかるようにこのツールはJavaアプリケーションであり、java -jarして引数にファイル名を与えると標準出力にXMLを吐いてくれる。

    root@kaldi:/android/tmp# java -jar ../AXMLPrinter2.jar AndroidManifest.xml 
    <?xml version="1.0" encoding="utf-8"?>
    <manifest
     xmlns:android="http://schemas.android.com/apk/res/android"
     android:versionCode="1"
     android:versionName="1.0"
     package="net.jumperz.app.android.test1"
     >
     <application
      android:label="@7F040001"
      android:icon="@7F020000"
      android:debuggable="true"
      >
      <activity
       android:label="@7F040001"
       android:name=".HelloActivity"
       >
       <intent-filter
        >
        <action
         android:name="android.intent.action.MAIN"
         >
        </action>
        <category
         android:name="android.intent.category.LAUNCHER"
         >
        </category>
       </intent-filter>
      </activity>
     </application>
     <uses-sdk
      android:minSdkVersion="4"
      >
     </uses-sdk>
    </manifest>
    

    このようにAndroidManifest.xmlを解析することで、このアプリケーションはActivityをひとつしか持たないことがすぐにわかる。

    続いて実際のアプリケーションの細かな動作をコントロールしている、中心となる部分を解析する。Androidアプリケーションでは、アプリケーションの細かな挙動は開発時にはJavaのソースコード(拡張子.javaのテキストファイル)として存在する。それがOracleのJDKなどでコンパイルされ、クラスファイル(拡張子.classのバイナリファイル)となる。Androidアプリケーションとしてデプロイされる際には、アプリケーション内部で使用されるクラスファイルはすべてAndroid独自のdex形式として1つのファイルにまとめられる。このファイルはclasses.dexという名前でapkファイル内に存在している。

    classes.dexファイルはバイナリファイルなので、そのままでは中身を読むことができない。そこでbaksmaliというツールを使う。baksmaliはsmaliと対となるツールであり、それぞれアイスランド語でディスアセンブラ/アセンブラという意味を持つらしい。AndroidのJVMであるDalvikという単語がアイスランド由来であることから、このような名前を付けたようだ。

    baksmali/smaliともJavaで作成されたアプリケーションであり、jarファイルとして存在している。次の例のように引数にclasses.dexファイルを渡すと、out/というディレクトリを生成してその中にsmali形式のファイルを吐いてくれる。

    root@kaldi:/android/tmp# mkdir smali
    root@kaldi:/android/tmp# cd smali
    root@kaldi:/android/tmp/smali# java -Xmx1G -jar ../../baksmali-1.2.6.jar ../classes.dex
    root@kaldi:/android/tmp/smali# find . -type f
    ./out/net/jumperz/app/android/test1/R$drawable.smali
    ./out/net/jumperz/app/android/test1/R.smali
    ./out/net/jumperz/app/android/test1/R$id.smali
    ./out/net/jumperz/app/android/test1/R$layout.smali
    ./out/net/jumperz/app/android/test1/R$attr.smali
    ./out/net/jumperz/app/android/test1/R$string.smali
    ./out/net/jumperz/app/android/test1/HelloActivity.smali

    拡張子.smaliのsmali形式のファイルはテキストファイルであり、エディタで編集することができる。以下にHelloActivity.smaliの一部を掲載する。

    # virtual methods
    .method public onCreate(Landroid/os/Bundle;)V
        .registers 5
        .parameter "savedInstanceState"
    
        .prologue
        .line 28
        invoke-super {p0, p1}, Landroid/app/Activity;->onCreate(Landroid/os/Bundle;)V
    
        .line 29
        const/high16 v0, 0x7f03
    
        invoke-virtual {p0, v0}, Lnet/jumperz/app/android/test1/HelloActivity;->setContentView(I)V
    
        .line 30
        const v0, 0x7f050001
    
        invoke-virtual {p0, v0}, Lnet/jumperz/app/android/test1/HelloActivity;->findViewById(I)Landroid/view/View;

    smaliファイルの中身はDalvik VMのバイトコードをディスアセンブルしたものである。最初に見たときは「わけがわからない」と投げ出したくなるかもしれないが、Javaの開発者であれば大部分はすぐに読めるようになる(難しいアルゴリズムの実装部分などはそのまま読むのは厳しいかもしれないが)。smaliファイルに慣れるためには、まず自分で簡単なAndroidアプリケーションをJavaで書き、それを上記の方法でsmali形式に変換する。そしてJavaソースコードとsmaliファイルを行ごとに付き合わせて読んでいけばいい。おそらく30分もあれば基本的な構造についてはかなり読めるようになっているだろう。Dalvikのバイトコードやその他の説明は以下のURLが参考になる。

    http://code.google.com/p/smali/w/list
    http://www.netmite.com/android/mydroid/dalvik/docs/dalvik-bytecode.html

    今回、例として使用しているアプリケーションは筆者が作成したものなので、もちろん元となるJavaのソースコードが手に入る。簡単に以下の行について説明を行う。

        .line 28
        invoke-super {p0, p1}, Landroid/app/Activity;->onCreate(Landroid/os/Bundle;)V

    .line 28とあることから、元のJavaソースコードの28行目であることがわかる。該当するJavaソースコードは以下となっている。

    27:    public void onCreate(Bundle savedInstanceState) {
    28:        super.onCreate(savedInstanceState);

    invoke-superは親クラスのメソッドを呼び出すことを意味している。次のp0とp1は呼び出しの際に使うレジスタを表している。pではじまる名前のレジスタは実行中の関数の引数(パラメータ)であり、p0が0番目(this)、p1がひとつめの引数だ。Landroid/app/Activity;はandroid.app.Activityクラスのことだ(頭のLは続く文字列がクラス名であることを意味する)。続くonCreateは呼び出す関数の名前であり、引数が(Landroid/os/Bundle;なので)android.os.Bundleクラスのインスタンスであるという意味になる。つまりthisオブジェクト(p0)の親のonCreateを、実行中の関数のひとつめの引数(p1)であるsavedInstanceStateを引数として実行する、ということになる。最後のVは戻り値がvoidであることを意味する。

    実際のアプリケーション解析では、元となるJavaのソースコードは手に入らないだろう。しかしbaksmaliを使用することで、このようにある程度読みやすい形のディスアセンブルリストは手にすることができる。smali形式を読み解くことができれば、アプリケーションはあなたの前で丸裸になったようなものだ。

    しかし、もっと楽をしたいと考える人もいるだろう。実はdex2jarと呼ばれるツールが存在しており、このツールを使うとclasses.dexをjar形式に変換することができる。jarファイルはJavaのクラスファイル群をZIP形式で固めたものなので、unzipすることでJavaのクラスファイルを手に入れることができる。知っている人も多いかと思うが、Javaのクラスファイルはディスアセンブルが非常に容易であり、JDやjadなどのツールを使うことでほぼ完全なJavaソースコードを手にすることができる。しかし残念なことに、Androidアプリケーションではこの前段の変換を行うdex2jarの変換精度はあまり良くないようだ。これはDalvik VMと従来のJavaVMの仕様があまりにも異なっているため仕方がないことなのだと考えられる。

    それでもdex2jarとJDなどを組み合わせてJavaソースコードを手に入れることには意味がある。読みやすさではsmali形式よりもJavaソースコードの方が圧倒的に優れているので、解析する際に、目的の処理がアプリケーション全体においてだいたいどの辺り(どのクラス)に存在しているのかを探すのには非常に便利なのだ。

    dex2jarは以下のようにして使用する。

    root@kaldi:/android/tmp# mkdir dex2jar
    root@kaldi:/android/tmp# cd dex2jar
    root@kaldi:/android/tmp/dex2jar# ../../dex2jar/dex2jar.sh ../classes.dex 
    version:0.0.7.8-SNAPSHOT
    2 [main] INFO pxb.android.dex2jar.v3.Main - dex2jar ../classes.dex -> ../classes.dex.dex2jar.jar
    Done.
    root@kaldi:/android/tmp/dex2jar# jar -x < ../classes.dex.dex2jar.jar 
    root@kaldi:/android/tmp/dex2jar# find . -type f
    ./net/jumperz/app/android/test1/R.class
    ./net/jumperz/app/android/test1/R$drawable.class
    ./net/jumperz/app/android/test1/R$string.class
    ./net/jumperz/app/android/test1/HelloActivity.class
    ./net/jumperz/app/android/test1/R$layout.class
    ./net/jumperz/app/android/test1/R$attr.class
    ./net/jumperz/app/android/test1/R$id.class

    クラスファイルが生成されているので、後はJDやjadなどのツールを使ってJavaソースコードに変換すればいい。

    Androidアプリケーションのリバースエンジニアリングをする場合には、目的として「解析だけしたい」場合と、「解析した上で、さらに動作を自分好みに変更する」、つまりアプリケーションの改造までを行いたい場合があるだろう。前者の場合、JDによるJavaソースコード形式への(ときに不完全な)変換で十分な場合もあるだろう。この場合、読みにくいsmali形式のファイルと格闘する必要がないかもしれない。しかし後者、つまり改造までを行いたい場合、アプリケーション内の目的の箇所を自分の意図を達成するように書き換え、ふたたびAndroidアプリケーションとして動作するよう、正しくアセンブルしなおす必要がある。smaliはこれを可能にしてくれる。非常に精度が高いディスアセンブル・アセンブルが可能なので、classes.dex -> smali -> classes.dexという変換が可能なのだ。

    ここでは筆者がAndroidの参考書からコピペで作成したサンプルアプリケーションを例に、改造する例を見ていく。このアプリケーションはテキスト入力欄をひとつ持ち、そこに文字列を入力してEnterキーを押すと、その下にあるリストに文字列が追加されていく、というだけのものだ。ソースコードは以下のようになる。

    package net.jumperz.app.android.test1;
    
    import android.app.Activity;
    import android.os.Bundle;
    import android.widget.*;
    import android.view.KeyEvent;
    import android.view.View;
    import android.view.View.OnKeyListener;
    import android.text.*;
    import android.content.Context;
    
    import java.util.*;
    
    public class HelloActivity
    extends Activity
    implements OnKeyListener
    {
    private List todoItems = new ArrayList();
    private ListView lv;
    private EditText et;
    private ArrayAdapter aa;
    //----------------------------------
    
        /** Called when the activity is first created. */
        @Override
        public void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView( R.layout.main );
            lv = ( ListView )findViewById( R.id.ListView01 );
            et = ( EditText )findViewById( R.id.EditText01 );
    
            aa = new ArrayAdapter( this, android.R.layout.simple_list_item_1, todoItems );
            lv.setAdapter( aa );
    
            et.setOnKeyListener( this );
        }
    //----------------------------------
    public boolean onKey( View view, int keyCode, KeyEvent event )
    {
    if( event.getAction() == KeyEvent.ACTION_DOWN )
    	{
    	if( keyCode == KeyEvent.KEYCODE_ENTER )
    		{
    		String str = et.getText().toString();
    		todoItems.add( 0, str );
    		aa.notifyDataSetChanged();
    		et.setText( "" );
    		return true;
    		}
    	}
    return false;
    }
    //----------------------------------
    }

    このアプリケーションを改造し、Enterキーが押された際に、そのときの文字列がクリップボードにコピーされるよう動作を変更(追加)してみる。改造する際にはsmali形式のファイルを直接編集することになる。ここでは「文字列をクリップボードにコピーする」という処理をsmali形式で記述しなければならない。

    先述したようにこのような場合、ゼロからsmali形式で記述する必要はなく、自分で文字列をクリップボードにコピーするアプリケーションを作成して、該当部分がどのようにsmali形式に変換されるのかを確認すればよい。処理を追加する際には、その部分をわかりやすい引数を持つ関数として独立させるのがおすすめだ。ここでは以下のような関数を使用する。

    private void copyToClipboard( Activity activity, String str )
    {
    ClipboardManager cm =  (ClipboardManager)activity.getSystemService( Context.CLIPBOARD_SERVICE );
    cm.setText( str );
    }

    この関数はsmali形式では次のようになる。

    .method private copyToClipboard(Landroid/app/Activity;Ljava/lang/String;)V
        .registers 5
        .parameter "activity"
        .parameter "str"
    
        .prologue
        .line 62
        const-string v1, "clipboard"
    
        invoke-virtual {p1, v1}, Landroid/app/Activity;->getSystemService(Ljava/lang/String;)Ljava/lang/Object;
    
        move-result-object v0
    
        check-cast v0, Landroid/text/ClipboardManager;
    
        .line 63
        .local v0, cm:Landroid/text/ClipboardManager;
        invoke-virtual {v0, p2}, Landroid/text/ClipboardManager;->setText(Ljava/lang/CharSequence;)V
    
        .line 64
        return-void
    .end method

    この部分をまるごとout/net/jumperz/app/android/test1/HelloActivity.smaliファイル内に追記することで、HelloActivityクラスにcopyToClipboardという関数が追加されることになる。非常にシンプルで簡単だ。

    次に、この追加した関数を呼び出す必要がある。Javaの以下の部分で目的の文字列がstrという変数に格納されていることに注目する。

    String str = et.getText().toString();

    対応するsmaliファイル内の記述は以下の部分だ。

        invoke-interface {v1}, Landroid/text/Editable;->toString()Ljava/lang/String;
        move-result-object v0

    ここで、対象の文字列がv0というレジスタに格納されているので、この処理の後に関数呼び出しを追記すればよい。追記する内容は以下となる。

    invoke-direct {p0, p0, v0}, Lnet/jumperz/app/android/test1/HelloActivity;->copyToClipboard(Landroid/app/Activity;Ljava/lang/String;)V

    HelloActivityクラス自身のメソッドを呼び出すため、invoke-directを使う。thisインスタンス(p0)の関数を、thisインスタンス(p0)と対象文字列(v0)を引数として呼び出すため、{p0, p0, v0},という記述になる。

    smaliファイルをこのように書き換えたら、アセンブラであるsmaliを使って再びclasses.dexの形に変換する。

    root@kaldi:/android/tmp/smali# ls
    out
    root@kaldi:/android/tmp/smali# java -jar ../../smali-1.2.6.jar -o classes.dex out/
    root@kaldi:/android/tmp/smali# ls -l
    total 8
    -rw-r--r-- 1 root root 3768 Jan 21 00:21 classes.dex
    drwxr-xr-x 3 root root 4096 Jan 21 00:05 out

    apkファイル内のclasses.dexを、zipコマンドで入れ替える。

    root@kaldi:/android/tmp/smali# zip ../android1.apk classes.dex 
    updating: classes.dex (deflated 50%)

    apkファイルは署名する必要があるので、jarsignerコマンドを使って署名する。まず署名関連のファイルを削除する。

    root@kaldi:/android/tmp# zip -d android1.apk META-INF/*
    deleting: META-INF/MANIFEST.MF
    deleting: META-INF/CERT.SF
    deleting: META-INF/CERT.RSA

    そして再び署名する。jarsignerコマンドを使うためには.keystoreファイルの用意などが必要だが、本題ではないのでここでは細かいプロセスは省略する。Javaのキーストア関連の知識がないと少し面倒かもしれない。

    root@kaldi:/android/tmp# jarsigner -verbose android1.apk test  
    Enter Passphrase for keystore: 
       adding: META-INF/MANIFEST.MF
       adding: META-INF/TEST.SF
       adding: META-INF/TEST.RSA
      signing: res/layout/main.xml
      signing: AndroidManifest.xml
      signing: resources.arsc
      signing: res/drawable-hdpi/icon.png
      signing: res/drawable-ldpi/icon.png
      signing: res/drawable-mdpi/icon.png
      signing: classes.dex

    これでアプリケーションの改造は終了だ。android1.apkをインストールして文字列を入力し、Enterキーを押すと、その文字列はクリップボードにコピーされる。

    以下参考リンク

    JD Java Decompiler
    http://java.decompiler.free.fr/

    AXMLPrinter2.jar
    http://code.google.com/p/android4me/downloads/detail?name=AXMLPrinter2.jar&can=2&q=

    Smali/Baksmali
    http://code.google.com/p/smali/

    dex2jar
    http://code.google.com/p/dex2jar/

    冒頭のイメージで、実線は頼りになる変換、破線は頼りにならない変換を示す(リバースエンジニアリング時)。この画像はcacoo.comで作成した。cacoo.com最高すぎる。Ubuntuに移行してから、SmartDrawが使えなくなったのが悩みだったのだが、完全に解消された!


    ブログ開始

    今年からブログをやることにする。

    本当はブログ自体のウェブアプリを自作したいのだが、多忙なのでそれは来年以降の課題とする。


    Follow

    Get every new post delivered to your Inbox.