300行足らずで書けるJavaによるOpenFlowコントローラ
276行(ライセンス宣言を除いた222行)という数値にはインパクトがないような気もするが、OpenFlowコントローラをJavaで書いたみたので、その事をネタに久しぶりにブログを更新してみようかと。
いくつかのコントローラの実装を見たところ、エラー処理、例外処理とかを除けば基本的な部分を作成するのは自分でも出来る気がしたので、OpenFlowに興味もあるし、勉強がてら単純なOpenFlowコントローラを実装してみた。OpenFlowは今のところver. 1.1まで仕様が公開されているが、実装としてはver. 1.0までしか公開されていないようなので、ver. 1.0ベースでの実装ということにした。
言語の選択
個人的には、最近、Pythonをよく使っている(NetworkXが便利というのがその理由だが)のもあって、Pythonが一番慣れているのだが、動的型付け言語なので型チェックがないとか補完がないのが不便に思うことがあって、静的型付け言語から選ぶことにした。
自分の使える言語を考えると、候補としては、C, C++, Java, Scalaから選ぶことになり、無難に(?)Javaを選択。Javaを選択した一番の理由は、OpenFlowプロトコルのメッセージのJavaのライブラリが存在しているからというもの。意外に、ここら辺を一から実装するのは面倒なので、そこは車輪の再発明を避けて、他の人の成果に乗っかろうという方針。
本当は、Scalaの勉強ついでにScalaでやるという選択肢もあったのだろうけど、NIO周りも勉強しなくてはいけないので、両方一緒に勉強するのは結局先に進まなそうだなと思ってScalaは選択せず。Scala版は次回のお楽しみということで(笑)
方針
作成するに当たっての方針は以下の通り。
- OpenFlow ver. 1.0ベース
- NIOを使う
- なるべくシンプルに
- 外部ライブラリを使えるところは使う
OpenFlow ver. 1.0ベースにした理由は前述の通り。
次にNIOを使うのはOpenFlowコントローラはサーバプログラムだし、それなりにパフォーマンスに気をつけたいという気分から来ているだけで、特に深い意味は無いし、パフォーマンスの目標値もない。あと、ソースを見たらいくつかのプログラムがNIOというか非同期IOを使っていたので、一度使ってみたいという気分になったというだけの理由。
なるべくシンプルにということで、エラー処理・例外処理とかははしょり気味。また、スイッチがコントローラに接続/切断されたときにイベントが発生する実装は行わず、単純にコントローラにメッセージが届いたときにイベントが発生するような仕組みだけを実装。
シンプルにということとも関係する部分だが、OpenFlowコントローラを実装するに当たっての本質的な処理に集中したいので、面倒な部分で外部ライブラリを使うことで楽できる部分があるなら積極的に使う方針でやる。この方針に従って、OpenFlowプロトコル周りはopenflowjを面倒そうなNIO周りの処理はNettyを使うことにした。
コントローラ上で動くアプリケーションは、とりあえずリピータハブ(通称、バカハブ)を実装する。
コード解説
コードは、githubにアップしています。全てを1ファイル上にまとめてしまったのはJava的ではないのだがお許しを。
公開先:http://github.com/oshothebig/simple-controller コードとしては、SimpleController.javaだけ。
これを試すには、openflowjが必要ですが、(ちゃんとは探していませんが)Mavenのレポジトリはないようなので別途ダウンロードする必要があります。
# git clone git://openflow.org/openflowj.git
# cd openflowj
# mvn install
ChannelPipelineの構成
OpenFlowプロトコルのメッセージはTCPコネクションを通じてやり取りされるため、受信したストリームをメッセージ単位に切り出さなければならない。また、送信時にはメッセージオブジェクトをOpenFlowプロトコルに従ってシリアライズする必要がある。NettyではChannelPipelineという仕組みを使って、受信したストリームを切り出してPOJOに変換したり、POJOをシリアライズしたり、プロトコルのビジネスロジックを実装したり出来る。
今回は、framer, decoder, encoder, handlerでChannelPipelineを構成する。受信時には、framer, decoder, handlerの順でパイプラインの処理が行われ、送信時にはencoderだけで処理される(上記スニペットの5〜11行目)
メッセージの切り出し(Framer)
まずは、受信したストリームをOpenFlowプロトコルのヘッダに記述されたlengthフィールドの値に従って、メッセージ単位に切り出す必要がある。OpenFlowプロトコルのヘッダ情報は、スペックによると
であり、3バイト目4バイト目の計16ビットのフィールド値がメッセージの長さになるということと、バイナリ型のプロトコル用にNettyに用意されているメッセージ切り出しのためのクラスであるLengthFieldBasedFrameDecoderを用いると簡単にメッセージ単位の切り出しを行える。
LengthFieldBasedFrameDecoderのコンストラクタの各フィールドの値の意味は、APIドキュメントに詳細な例とともに載っているのでそちらを参考にして欲しい。
メッセージのパース(Decode)
メッセージ単位で切り出されたChannelBufferを受けて、OpenFlowプロトコルのメッセージを表現するOFMessageオブジェクトに変換する。
openflowjのOFMessageFactory#parseMessage()を使うことでByteBufferからOFMessageのリストに変換できるのでそれを利用する。ただし、OFMessageFactory#parseMessage()はByteBufferを引数として受け取るので、ChannelBufferと同じ内容のByteBufferを作成して渡すところがポイント(上記スニペットの9行目)
Framerによってdecodeメソッドに渡されるmsgはメッセージ単位に切り出せたChannelBufferであるので、リストの最初の要素だけをパイプラインのさらに上位に渡すようにする(上記スニペットの10行目)
メッセージのシリアライズ(Encode)
メッセージの送信時には、OFMessageオブジェクトからOpenFlowプロトコルの仕様に従ってシリアライズする(NettyでいうとChannelBufferに変換する)必要があるが、OFMessage#writeTo()を使うことで簡単に実現可能。
メッセージの長さだけのByteBufferを割り当て(上記スニペットの7行目)、割り当てたByteBuffer(response)を引数にしてOFMessage#writeTo()を呼び出すことで、responseにシリアライズされた内容が格納される。
9行目はハマリポイント。ここで、しばしハマった。ByteBuffer#flip()を呼ばないと、これ以上読み込めないと判断されてChannelに書き込まれないので注意。
ByteBufferの割り当て、つまり、ByteBuffer#allocate()を送信時に毎回行うのはパフォーマンス上の悪影響がありそうなのだが、割り当てを一度だけ行って使い回すとスレッドセーフじゃない気がするので、今回はあえてByteBufferを毎回割り当てている。
OpenFlowプロトコルの動作
OpenFlow ver. 1.0の仕様によると、コントローラとスイッチ間のコネクションが確立した時のハンドシェイク動作は以下の通り。
- コネクションが確立したら、コントローラ、スイッチともに対向側にHELLOメッセージを送信する
- スイッチが送信したHELLOメッセージをコントローラが受信したら、コントローラはスイッチに対してFEATURES_REQUESTメッセージを送信する
- FEATURES_REQUESTメッセージを受信したスイッチは、コントローラに対してFEATURES_REPLYを返信する
- スイッチからのFEATURES_REPLYメッセージの受信をもって、ハンドシェイクが完了
ということで、まず、コネクション確立時にHELLOメッセージの送信。これは、以下の記述でOK。
それ以降の動作は、OpenFlowSimpleControllerHandler#messageReceived()に記述している。
- HELLOメッセージを受信したら、FEATURES_REPLYメッセージを送信(上記スニペットの7行目)
- ECHO_REQUESTメッセージを受信したら、ECHO_REPLYメッセージを返信(上記スニペットの9行目〜13行目)
- FEATURES_REPLYメッセージを受信したら、ハンドシェイクの完了をログに出力(上記スニペットの16行目)
- HELLO, FEATURE_REPLY, ECHO_REQUEST, ERROR以外のメッセージを受信したらリスナーを呼び出し(上記スニペットの23行目)
ECHO_REQUESTメッセージに返信を行わないと、相手側から死んでいると見なされて切断されるので9〜13行目は追加で必要となる。本来なら、ECHO_REQUESTをコントローラ側から定期的にスイッチに対して送信してLivenessのチェックをする必要があるのだが、無くてもOpen vSwitch相手の場合は問題ないので今回は入れていない。
リピータハブとしての動作
MessageListenerを実装した無名クラスを作成して、SimpleController#addMessageListener()に渡している。
スイッチに到着したパケットが、スイッチに設定されているフローテーブルの条件にどれも一致しない場合にPACKET_INメッセージがコントローラに送られてくるのだが、今回はリピータハブの実装なので、送られてきたPACKET_INメッセージの内容に従って、元のパケットの内容のままでフラッディングでの送信を実行するPACKET_OUTメッセージをスイッチに対して発行すればよい。
上記スニペットの12〜15行目でフラッディングでの動作を指定し、18〜25行目で元のパケットの内容を指定する。PACKET_INメッセージのbuffer_idが0xffffffffの時は、バッファリングされていないことを示し、その時は、PACKET_OUTメッセージのデータ領域にPACKET_INメッセージに書かれている内容を設定する。buffer_idに有効な値が設定されている場合には、PACKET_OUTメッセージのデータ領域にデータが設定されていなくても、スイッチ内でbuffer_idを元に解決される(みたい)
動作
手元の環境では、Open vSwitchをVMware上に入れて試すことぐらいしか出来ないため、ちゃんと動作確認を行えているわけではないのだが、少なくともOpen vSwitchからコントローラに接続でき、切断されないことは確認した。
以下のコマンドで、リピータハブとして動作するアプリケーションが今回作成したOpenFlowコントローラ上で実行できる。
# mvn exec:java
cbenchという、OpenFlowコントローラの性能のベンチマークプログラムを接続して、今回作成したコントローラのベンチマークも行うことが出来る。cbench(というか、cbenchを含んだプログラムであるoflops)はgitで以下のコマンドで入手できる。
# git clone git://gitosis.stanford.edu/oflops.git
手元でVMwareでcbenchを動かしてみたところ、20,000 req/secぐらいの性能だった。自作のベンチマークツールでVMwareを使わずにローカルでベンチマーク、コントローラ両方とも動かした時には、33,000 req/sec程度だった。公開されている、コントローラのベンチマークと比較すると大分スループットが小さいが、比較環境も違うしなんともいえないが。Nettyを使っているだけあって、ベンチマークで負荷をかけたときにはコアを使い切っているみたいだし、恐らくそんなにパフォーマンスは悪くない気がする。
というわけで、ざっと作ってみたものなので、アラもあると思います。ここがおかしいとか、こうした方がいいとか気がついた人はコメント頂けると幸いです。