はじめに

Tremaを使ったOpenFlowプログラミングを解説するフリー書籍です。対応スイッチの多いOpenFlowバージョン1.3.xを対象にしています。プログラミング言語はRubyバージョン2.0以降です。

書籍版

各種書店で入手可能です。もちろん、プロによる組版なので高品質です。

本のカバー

正誤表など技術評論社のサポートページは こちらです

フリー版

Build Status Dependency Status

次のようにしてソースから本をビルドできます。

git clone git@github.com:yasuhito/trema-book.git
cd trema-book
bundle install
bundle exec rake

執筆に参加する

誰でもいろいろな形でTrema本の執筆に参加できます。

本に関する雑談は Gitterのチャットでどうぞ。

もし誤字脱字を見つけたら、GitHubでイシューを切ってもらうか、ソースコードを直接修正してPull Requestを出してもらえると助かります。もちろん、内容についてのコメントや追加内容も歓迎します。

原稿の書式は Asciidocです。詳しい書き方についてはAsciidocのコンパイラである Asciidoctorのユーザマニュアルを参照してください。

貢献していただいた方は書籍版の「謝辞」にもれなくお名前が載ります。

謝辞

ライセンス

This book is released under the GNU General Public License version 3.0:

1. OpenFlow の仕組み

ネットワークを OpenFlow で構築すると、どんな利点があるのでしょうか。その答えは、ソフトウェアによる自動化です。まずは身近な自動化の例を見ていきましょう。

incredible machine

1.1. ソフトウェアで楽をする

無精 (Laziness): エネルギーの総支出を減らすために、多大な努力をするように、あなたをかりたてる性質。こうして労力を省くために書いたプログラムは他人も使うようになり、そのプログラムに関する質問にいちいち答えずに済ますためにドキュメントを書くようになる。それゆえ、プログラマにとってもっとも重要な素質である。またそれゆえ、この本が存在するのである。
— Larry Wall
『プログラミング Perl』(オーム社)

優れたプログラマが持つハッカー気質の 1 つに無精があります。「大好きなコンピュータの前から一時も離れずに、どうやってジャンクフードにありつこう。そうだ、ソフトウェアを書けばできるじゃないか!」普通の人からするとただの横着に見えるかもしれません。しかし、ハッカーにとってはいつでも大きな問題なのです。

ソフトウェアによる横着は、ハッカーがもっとも創造性を発揮する分野の 1 つです。時間のかかる面倒な仕事も、ハッカーにかかれば気の利いたスクリプトひとつで自動化してしまいます。ハッカーによる次の 3 つの伝説的な逸話は、いずれもただ横着のためだけに高い技術力を駆使したといういい例です。

ピザ注文コマンド

ハッカーの巣窟として有名な MIT の AI ラボにはかつて、コンピュータからオンラインでピザを注文できる UNIX コマンドが存在しました[1]。ハックしていて腹が減ったらコマンドを叩いてピザを取る。なんとも横着です。

自販機のリモート監視

コンピュータサイエンスの名門、カーネギーメロン大学にはコーク・マシンという変わったコーラ自販機がかつてあり、UNIX コマンド一発でコーラの冷え具合を確認できるようになっていました[2]。わざわざ遠くの自販機まで行ったのにぬるいコーラをつかまされた、なんてことが起きないようにするための工夫です。

コーヒーポットプロトコル

RFC (Request For Comment) 2324 のコーヒーポットプロトコルは、遠隔地にあるコーヒーポットのコーヒーの量を監視したり、コーヒーを自動的にいれたりするための半分冗談の HTTP メッセージを定義しています[3]。いわゆるジョーク RFC にもかかわらず、本当に実装してしまった人もいたそうですから驚きです。

こうしたソフトウェアで楽をするハックの中でも、もっとも大規模な例が最新鋭のデータセンターです。クラウドサービスの裏で動く巨大なデータセンターは、大部分の管理作業をソフトウェアによって極限まで自動化しています。このおかげで、極めて少人数のエンジニアによる運用を可能にしています。

このように、ピザの注文やコーラ自販機、コーヒーポットといったお遊びから、データセンターのように一筋縄ではいかない相手まで、ソフトウェアを書けばその大部分を自動化できます。そして何より、ソフトウェアでモノを思いどおりにコントロールするのは楽しく、かつ実際に役立ちます。

こうした最新鋭データセンターでのネットワーク管理自動化の仕組みは、16 章「たくさんのスイッチを制御する」および17 章「ネットワークを仮想化する」で詳しく解説します。

1.2. ネットワークもソフトウェア制御

ネットワークをソフトウェア制御する技術の 1 つが OpenFlow です。より正確に言えば、OpenFlow とはネットワークスイッチの動作を制御するための標準プロトコルの 1 つです。OpenFlow を使えばスイッチ 1 つひとつの動作をソフトウェアから自由に書き換えられるので、究極的にはネットワーク全体の動作をソースコードとして記述できます。これを Software Defined Networking (ソフトウェアで定義されるネットワーク。以下 SDN と略す) と呼び、OpenFlow は SDN を実現する代表的な技術として注目を集めています。

OpenFlow の登場によって、これからはネットワークインフラもプログラミングの対象になります。「いまだに手で管理してるの? そんなのソフトウェアで自動化しようぜ!」ハッカーのこんな声が聞こえてきそうです。たしかに、今までネットワーク管理と言えば専門のオペレータによる手作業がメインでした。横着できる部分はまだまだたくさんあるはずです。

OpenFlow を使えば、次のような究極の自動化も夢ではなくなります。

  • スイッチの障害やネットワーク構成の変化など、あらゆる情報を自動収集するネットワーク

  • ユーザ/サーバ/スイッチの追加や削除に応じて、自動的に構成を変更するネットワーク

  • 追加投資をしなくても、既存のインフラを目一杯まで使ってスケールするネットワーク

本書はこれらすべてのトピックを扱います。自宅や職場のような中小規模ネットワークからデータセンターのような超大規模ネットワークまで、実例を交じえながら「OpenFlow ってどんなもので、具体的に何に使えるのだろう?」という素朴な疑問に答えていきます。そして実際に動かしながら理解できるように、各章では実用的なソースコードを解説しています。

本書を読み進めるにあたって、ネットワークやプログラミングの深い知識は不要です。基本から 1 つひとつ説明しますので、ネットワークの専門家はもちろん、プログラマやシステムエンジニア、そして営業職や管理職などなど OpenFlow に興味を持つ方であれば誰でもすんなり理解できるように構成してあります。

ではさっそく、OpenFlow で構築したネットワークがどう動くかを見て行きましょう。

1.3. OpenFlow の動作モデル

OpenFlow の仕組みを理解するために、ちょっとしたたとえ話から始めます。みなさんもきっと利用したことがある、電話のカスタマーサポートサービスを思い浮かべてください。そう、テレビとかパソコンの調子が悪くなったときに、フリーダイヤルで相談するアレです。でもそれって、OpenFlow とどう関係するのでしょう?

実は OpenFlow の基本的な仕組みはカスタマーサポートにとてもよく似ているのです。これからお話しするストーリーがわかれば、OpenFlow の 95% を理解できたも同然です。

それでは、このストーリーの主人公の友太郎 (ゆうたろう) 君と、カスタマーサポートセンターで働く青井さん、そして上司の宮坂部長の 3 人に登場してもらいましょう。

1.3.1. ストーリー 1: エアコンが壊れた

今年もエアコンの活躍する季節がやってきました。

ところが友太郎君のエアコンはどうにも調子がよくありません。取扱説明書に載っていたカスタマーサポートに電話し、自動音声に従ってしばし自分で直そうとしてみたものの、いっこうに解決しません。

結局、自動音声はあきらめて電話サポートに相談することにしました。

「はい、こちらカスタマーサポートセンターです。担当はわたくし青井がうけたまわります。ご要件は何でしょうか?」

青井さんはヨーヨーダイン・エアコン社で働く電話オペレータです。青井さんの普段のオペレータ業務は、主に次の 2 つです (図1-1)。

  1. お客さんから不具合の症状を聞き出す

  2. 症状の内容に応じてそれぞれの担当技術サポートに電話をつなぐ

yoyodyne support
図 1-1: 電話オペレータはお客さんからの問い合わせを適切な技術サポートへ転送

友太郎君は聞きます。

「なんだかリモコンの調子が悪いんです。温度表示がずっと点滅してるんですけど、どうしたら直りますか?」

青井さんは手元の対応マニュアルを開きます (表 1-1)。対応マニュアルには 3 つの項目があり、お客さんからの「問い合わせ内容」、電話オペレータの「対応方法」、そしてお客さんからの「問い合わせ件数」を調べられるようになっています。

Table 1. 表 1-1: 電話オペレータ用対応マニュアル
問い合わせ内容 対応方法 問い合わせ件数

リモコンの不調

周辺機器担当の技術サポートに転送

8 件

エアコン本体の不調

エアコン担当の技術サポートに転送

6 件

室外機の不調

周辺機器担当の技術サポートに転送

4 件

いたずら電話

電話を切る

2 件

青井さんはちょうどマニュアルの先頭に、探していた「リモコンの不調」の項目を見つけました。

「ご不便をおかけしました。リモコン担当の技術サポートにただいまお繋ぎいたします」

電話の転送を終えると、青井さんはリモコン不調の問い合わせ件数を 8 件から 9 件にアップデートしました (表 1-2)。

Table 2. 表 1-2: 対応マニュアルの「問い合わせ件数」をアップデートする
問い合わせ内容 対応方法 問い合わせ件数

リモコンの不調

周辺機器担当の技術サポートに転送

9 件

エアコン本体の不調

エアコン担当の技術サポートに転送

6 件

室外機の不調

周辺機器担当の技術サポートに転送

4 件

いたずら電話

電話を切る

2 件

このように問い合わせ件数を控えておくことで、どんな故障が多いかを上司にフィードバックできます。たとえばリモコンに関する問い合わせが多ければ、上司は次の製品開発で「リモコンを改良せよ」という指示を飛ばせます。あるいは、周辺機器担当の技術サポートメンバーをもっと増やそうという判断もできます。

1.3.2. OpenFlow に置き換えると

OpenFlow の世界では、パケットを送信するホストがお客さんの友太郎君、パケットを転送する OpenFlow スイッチが電話オペレータの青井さんに対応します (図 1-2)。ホストがパケットを送ると、OpenFlow スイッチはパケットの中身に応じてパケットを適切に処理します。これはちょうど、青井さんが友太郎君からの問い合わせ内容に応じ、適切な技術サポートに電話を転送するのと同じです。

openflow host switch
図 1-2: OpenFlow ではホストがお客さん、スイッチが電話オペレータ、そしてフローテーブルがマニュアルに対応

OpenFlow スイッチは、動作がマニュアル化されています。カスタマーサポートの例では、青井さんはマニュアルから対応方法を調べました。いっぽう OpenFlow スイッチでは、スイッチ内のフローテーブルからパケットの処理方法を調べます。フローテーブルとは一種のデータベースで、パケットごとの処理方法が入っています。青井さんの業務がすべてマニュアル化されているのと同じく、OpenFlowスイッチの動作はすべてこのフローテーブルの内容によって決まります。

1.3.3. フローテーブルとフローエントリ

フローテーブルには、「こういうパケットが届いたら、こう処理する」というルールがいくつか入っています。このルールをフローエントリと呼びます。フローエントリはちょうど「リモコンの故障に関する問い合わせがきたら、リモコン担当の技術サポートに電話を転送する」といったマニュアルの各項目に対応します。

実際のフローテーブルの例を見てみましょう。表 1-3 はあるスイッチのフローテーブルで、各行がフローエントリです。フローエントリは主に、マッチフィールド・アクション・カウンタの 3 つの要素からなります[4]

Table 3. 表 1-3: フローテーブルとフローエントリの例
マッチフィールド アクション カウンタ

送信元 IP アドレス = 192.168.1.0

ポート 8 番に転送

80 パケット

VLAN ID = 10

ポート 10 番に転送

64 パケット

送信元 MAC アドレス = 00:50:56:c0:00:08

VLAN ID = 2 を付けてポート 8 番に転送

24 パケット

送信元 IP アドレス = 203.0.113.0/16

パケットを破棄

10 パケット

マッチフィールド

届いたパケットに対応するフローエントリを探すための条件です。たとえば「リモコンの調子がおかしい」という問い合わせ内容と同じく、マッチフィールドには「送信元 IP アドレス = 192.168.1.0」などと指定します。

アクション

届いたパケットをどう処理するかという処理方法にあたります。たとえば「リモコン担当の技術サポートへ引き継ぎ」という対応方法と同じく、アクションには「スイッチのポート 8 番に転送」などと指定します。

カウンタ

フローエントリごとのパケット処理量を記録します。たとえば「リモコン関連の問い合わせ数は 9 件」とマニュアルに記録したように、「このフローエントリに従って処理したパケットは 80 個」といった情報が入ります。

このように、実は OpenFlow はとても単純で理解しやすい仕組みです。

1.3.4. ストーリー 2: エアコンがまたまた故障

エアコンもしばらくは順調でしたが、1 ヶ月後また調子が悪くなってしまいました。友太郎君は再びカスタマーサポートへダイヤルします。

「エアコンの排水ホースがすぐ詰まっちゃうんです」

どうやらまったく新しい不具合のようです。青井さんはいつものように手元の対応マニュアルを調べましたが、困ったことに排水ホースの項目は載っていません。

「申し訳ございませんが少々お待ちください。対応可能な技術サポートがいるかどうか確認いたします」

そして電話口にはどこか軽快な音楽と、「しばらくお待ちください」のメッセージが繰り返し流れはじめました。

yoyodyne support miyasaka
図 1-3: 対応マニュアルに対処法が見つからなかった場合、上司に聞く

こういうとき、青井さんがいつも頼るのは上司の宮坂部長です (図1-3)。

「宮坂さん、排水ホースについての問い合わせがきたのですが、どの技術サポートにつなげばよいですか?」

「それだったら消耗品技術サポートだよ」

転送先がわかった青井さんは、友太郎君の待つ電話に戻ります。

「大変お待たせいたしました。担当の技術サポートに転送いたします」

一度目の問い合わせと比べてかなり時間がかかってしまいましたが、これでようやく一件落着です。青井さんは忘れないうちに、宮坂部長から教わった消耗品技術サポートの連絡先をマニュアルに追加します (表 1-4)。もしも同じ問い合わせがきた場合には、素早く答えられるようにするためです。

Table 4. 表 1-4: マニュアルに新しい項目を追加してアップデートする
問い合わせ内容 対応方法 問い合わせ件数

リモコンの不調

周辺機器担当の技術サポートに転送

9 件

エアコン本体の不調

エアコン担当の技術サポートに転送

6 件

室外機の不調

周辺機器担当の技術サポートに転送

4 件

いたずら電話

電話を切る

2 件

排水ホースの不調

消耗品担当の技術サポートに転送

1 件

OpenFlow に置き換えると

OpenFlow でこの上司にあたるのが、コントローラと呼ばれるソフトウェアです (図 1-4)。フローテーブルに載っていないパケットがスイッチに届くと、スイッチは「このパケットはどうすればよいですか」とコントローラに指示をあおぎます。コントローラはパケットの中身を調べ、どうすべきかという指示、つまり新しいフローエントリをフローテーブルに書き込みます。

openflow host switch controller
図 1-4: フローテーブルにエントリーが見つからなかった場合、コントローラに問い合わせる

当然ながら、コントローラへの問い合わせが発生するとパケット転送が遅くなります。そこで、あらかじめ必要とわかっているフローエントリは、スイッチの起動時に書き込んでおくようにします。そうすれば、スイッチ側でパケットを素早く処理できます。

OpenFlow でネットワークインフラをプログラミングする場合、プログラマが書くのはこのコントローラです。頭脳であるコントローラをソフトウェアとして記述することで、ネットワークを自由自在に制御できるというわけです。ただし、スイッチからの問い合わせをあまり発生させずに効率良くパケット転送できるかどうかは、すべてコントローラの設計にかかっています。

1.4. OpenFlow のうれしさ

OpenFlow の大枠が理解できたところで、OpenFlow の利点を具体的に見ていきましょう。

1.4.1. 自動化やシステム連携がしやすい

カスタマーサポートセンターでは、お客さん対応はすべて電話オペレータがやってくれます。上司があらかじめ適切なマニュアルを作っておけば、あとはほとんどの仕事を電話オペレータにおまかせできるのです。これによって、電話オペレータが対応している間、管理職は他の部署との連携に集中できます。

OpenFlow では上司であるコントローラ自体をソフトウェアとして書けるので、ネットワークだけでなくその管理も自動化できます。さらにコントローラが Ruby や Python、Java などの汎用言語で書いてあれば、既存のシステムやサービスとの連携も簡単です。たとえば、アプリケーションからの要求やビジネスポリシーの変更、問題発生などさまざまなトリガーに応じてネットワークの設定を変更するといった、一歩進んだ自動化もできます。

システム連携の一例として、コントローラに REST API を実装する方法を17 章「ネットワークを仮想化する」で解説します。また、実際のデータセンターでのコントローラと各種サービスの連携については、18 章「OpenVNet で本格的な仮想ネットワーク」で紹介します。

1.4.2. ネットワークトラフィックを集中制御しやすい

カスタマーサポートセンターでは問い合わせ件数の情報はすべて上司に上がってくるため、混み具合の把握や全体の交通整理が楽です。もし特定の技術サポートに問い合わせが集中しても、問い合わせがうまくバラけるようにマニュアルを通じて電話オペレータの全員に指示できます。反対にもし各オペレータが個々に判断してしまうと、おなじ技術サポートに問い合わせが偏ることは避けられません。

OpenFlow でもすべての情報はコントローラに上がってくるため、全体を見たトラフィックの最適化が可能です。フローエンントリ内のカウンタを集計し、検出したスイッチの接続関係 (ネットワークトポロジ) と突き合わせることで、コントローラはネットワーク全体のトラフィックを把握できます。そしてその情報をもとに各スイッチのフローテーブルを更新することで、全体的に見て最適となるパケットの通り道を引けます。反対に、もし個々のスイッチが判断してしまうと、効率的にトラフィックを分散できません。

各種カウンタの収集方法については4 章「スイッチ監視ツール」で、ネットワークトポロジの検出方法については15 章「ネットワークトポロジを検出する」で、またトラフィックの分散方法については16 章「たくさんのスイッチを制御する」で解説します。

1.4.3. ソフトウェア開発のテクニックやツールが使える

コントローラはソフトウェアの一種なので、ソフトウェア開発で長年培われているさまざまなテクニックやツールをネットワーク構築に応用できます。

たとえば近年主流のアジャイル開発手法でコントローラを開発すれば、反復的な機能追加が可能です。ユーザからのフィードバックを受けながら少しずつバージョンアップしてくことで、ネットワークを段階的に構築できます。

またコントローラのテストコードを書くことで、ネットワーク全体を自動的にテストできます。テストコードやテスト結果の出力は、そのまま仕様書の一部として使えます。もう Excel や Word で書いた仕様書を別個に管理する必要はありません。

アジャイル開発手法やソフトウェアテストによるコントローラ開発については、9 章「Trema でテスト駆動開発」で解説します。

1.4.4. アップグレード方法の選択肢が広がる

従来のネットワーク機器を OpenFlow コントローラで置き換えれば、アップグレード方法の選択肢が広がります。従来のスイッチ・ルータ・ファイアウォールといったネットワーク機器では、ポート数を増やしたい場合にはワンランク上のハイエンドな機器との入れ換えが必要でした。これは、コストのかかる垂直方向のアップグレードです。しかし、ネットワーク機器を OpenFlow のコントローラとして汎用サーバ上にソフトウェア実装すれば、並べるサーバを増やすだけでポート数を増やせます。こうした水平方向へのアップグレードは垂直方向のアップグレードと比べて低コストで実現できます。

さらに、ネットワーク機器の機能アップグレードも、OpenFlow ではソフトウェアの書き換えで済みます。従来のようにワンランク上の高機能なネットワーク機器を購入するかわりに、新機能をコントローラにソフトウェアとして実装すればよいのです。

ただし、これらはもちろん自分で実装しなければならないという前提付きです。たとえば水平方向にサーバを増やす場合には、サーバ間での設定情報の同期や、一部のサーバがダウンした場合の障害復旧といった機能を自分で実装しなければなりません。また、ハイエンドなネットワーク機器の機能の中には、ソフトウェアによる実現がむずかしい複雑な機能もあるでしょう。これらを実現するには、既存の分散データベースといったミドルウェアを利用したり、OpenFlow で実装しやすい機能に置き換えたり、といった工夫が必要になります。

OpenFlowは回転ずし

従来のファイアウォールやルータ、スイッチといった専用機器は、ベンダが提供する機能をそのまま使うしかありませんでした。たとえば、100 個ある機能のうち、本当に使いたい機能は 10 個だけだったとしても、100 機能付きのルータを買うしかありません。これではある意味、フルコースしか頼めないフレンチレストランのようなものです。一部の機能しか利用していないのに障害ポイントが無数にあるので、切り分けやデバッグが難航することもままあります。

OpenFlow は回転ずしです。フランス料理の味に近づけるのは大変ですが、必要な機能だけをチョイスしてがんばって実装すれば、思いどおりの機器が手に入るのです。

1.5. OpenFlowで気をつけること

もちろん、OpenFlow はうれしいことばかりではありません。コントローラで制御を一手に引き受けるため、コントローラの過負荷に気をつける必要があります。たとえばもし、フローテーブルに載っていないパケットが一気にコントローラへ到着すると、パケットの配送が遅延するか、最悪の場合にはコントローラが停止してしまいます。

そこで、OpenFlow の使いどころにはとくに注意する必要があります。たとえばフローエントリの入っていない OpenFlow スイッチをインターネットのような多種多様のパケットが流れる環境につなげると、すぐにコントローラへの問い合わせが殺到し破綻してしまいます。しかしデータセンターなどの閉じた環境では、トラフィックの特徴や流れるパケットの種類はあらかじめ見当を付けておけます。そこで最低限のパケットのみがコントローラへ上がってくるようにうまくフローエントリを設計することで、スイッチが増えてもうまくスケールできます。

1.6. まとめ

本章では SDN を実現する部品である OpenFlow を解説しました。OpenFlow で構築したネットワークは、フローテーブルを持つスイッチと、スイッチを集中制御するソフトウェアであるコントローラからなります。このようにネットワークの制御をソフトウェア化することによって、次の恩恵があります。

  • 自動化やさざまななシステムとの連携

  • トラフィック制御のしやすさ

  • ソフトウェア開発テクニックの適用

  • 水平方向へのアップグレード

次章では OpenFlow の仕様をもう少し詳しく紹介します。

2. OpenFlow の仕様

OpenFlow の概要がわかったところで、もう少し細かい仕様に進みましょう。実用重視で OpenFlow バージョン 1.3 仕様のポイントとよく使う用語を押さえます。

2.1. 説明する範囲とバージョン

OpenFlow標準仕様が主に定義するのは次の 2 つです。

  1. コントローラとスイッチの間の通信プロトコル

  2. 書き込んだフローエントリに対するスイッチの動作

本章ではこの 2 つの中でも、特によく使う機能のみを重点的に説明します。主に対象とするバージョンは、執筆時の安定バージョンであるバージョン 1.3 です。なお、バージョン 1.3 と以前の安定バージョンである 1.0 では、考え方やモデルに大きな違いはありません。そのため 1.3 を理解すれば 1.0 も理解しやすいでしょう。

なお OpenFlow が初めての方は、最初は難しい部分を読み飛ばしても構いません。後からより詳しく知りたくなった時に、いつでも読み直してください。

2.2. スイッチとコントローラ間のやりとり

OpenFlow スイッチとコントローラは、OpenFlow 仕様の規定するメッセージをやりとりしながら動作します。ここでは具体的にどのようなメッセージを送受信するか、順を追って説明します。1 章「OpenFlow の仕組み」で見た、カスタマーサポートセンターでのやりとりを思い出しながら読んでください。

2.2.1. スイッチ・コントローラ間の接続

最初にコントローラとスイッチは TCP 接続を確立します。これを OpenFlow チャンネルと呼びます。この TCP 接続は、仕様によるとスイッチとコントローラのどちらが始めてもよいことになっています。しかし多くの場合、図 2-1 のようにスイッチからコントローラへと接続する実装となっています。

openflow channel
図 2-1: スイッチがコントローラに接続し OpenFlow チャンネルを確立

なお OpenFlow チャンネルには普通の TCP 接続だけでなく、よりセキュアな TLS (Transport Layer Security) も使えます。ただし、コントローラとスイッチの両方が TLS に対応している必要があります。また、TLS は暗号化・復号化に多くのリソースを必要とするので、大量にメッセージをやりとりする場合には性能が低下します。

2.2.2. バージョンの確認

次にスイッチとコントローラは、使う OpenFlow バージョンをお互いに確認します。これを一般にバージョンネゴシエーションと呼びます。OpenFlow チャンネルの確立後、スイッチとコントローラは自分のしゃべれるバージョン番号を乗せた Hello メッセージをお互いに出し合います (図 2-2)。

version negotiation
図 2-2: Hello メッセージを出し合うことで、お互いの OpenFlow プロトコルバージョンを確認

もしここで、相手と同じバージョンを話せるようであればネゴシエーションに成功です。成功すると、Hello 以外のメッセージもやりとりできるようになります。

2.2.3. スイッチの Datapath ID の確認

次にコントローラは接続したスイッチの Datapath ID を確認します。コントローラがスイッチに Features Request メッセージを送ると、スイッチは Datapath ID とスペックを乗せた Features Reply メッセージを返答します。

features request reply
図 2-3: Features Request メッセージでスイッチの Datapath ID を確認

Features Reply メッセージには Datapath ID に加えて、主に次のスペック情報が入っています。

  • 一度にバッファできるパケットの数

  • サポートするテーブルの数

  • サポートする機能の一覧

2.2.4. コントローラへの受信パケットの通知

スイッチは、受信したパケットと関連情報を Packet In メッセージでコントローラへ通知できます。たとえば、フローテーブルに登録していない通信を検知した場合など、Packet In メッセージを使ってパケットの情報をコントローラへ送ります (図 2-4)。

packet in
図 2-4: 受信パケットとその情報を Packet In メッセージとしてコントローラに上げる

2.2.5. パケットの出力

Packet Out メッセージは Packet In メッセージの逆で、スイッチからパケットを出力するためのメッセージです (図 2-5)。

packet out
図 2-5: Packet Out メッセージでパケットをスイッチから出力

Packet Out の典型的な利用例は、Packet In でコントローラへ届いたパケットを宛先に届ける場合です。もしも Packet In の後に Packet Out をやらないと、パケットはコントローラに残ったままで宛先には届きません。

2.2.6. フローテーブルの更新

Flow Mod メッセージはスイッチのフローエントリを追加・削除・変更するためのメッセージです (図 2-6)。Flow Mod メッセージを受け取ったスイッチは、メッセージ内容に従って自身のフローテーブルを更新します。

flow mod
図 2-6: Flow Mod メッセージでフローテーブルを更新

OpenFlow 仕様によると、スイッチはフローテーブルの更新完了をコントローラに通知しません。その理由は、もしも Flow Mod メッセージごとに応答メッセージを返すことにすると、多くのフローエントリを設定する場合に時間がかかってしまうためです。

禁じ手: Flow ModとPacket Outを同時にやる方法

実は OpenFlow の仕様には、1つの Flow Mod メッセージで同時に Packet Out もまとめてやってしまう方法があります。しかし、これは危険なプログラミングスタイルです。

この Flow Mod & Packet Out は図 2-7 のように動作します。スイッチは Packet In を起こすと、スイッチのバッファ領域に Packet In を起こしたパケットの中身をバッファします。そしてコントローラに送る Packet In メッセージに、このバッファ領域の ID (Buffer ID と呼ぶ) 情報を入れて送ります。コントローラは Flow Mod のときにこの Buffer ID を指定すると、スイッチがフローテーブルの更新と同時に、コントローラの代わりに Packet Out してくれます。

flow mod and packet out
図 2-7: Flow Mod に Buffer ID を指定することで同時に Packet Out する

しかし、この方法は禁じ手です。これは次の 3 つの理由によります。

  • スイッチのバッファにパケットが残っているかどうかはスイッチの外からわからない。つまり指定した Buffer ID のパケットがまだバッファに残っているかどうかは、イチかバチかである

  • もしスイッチのバッファに残っているとわかったとしても、Flow Mod を打った時には消えているかもしれない

  • 格安のスイッチには、そもそもバッファがないかもしれない

というわけで、やはり Packet Out は Flow Mod と独立して打つのが良い方法です。

2.2.7. フローテーブル更新完了の確認

Flow Mod メッセージによるフローテーブルの更新完了を確認するには Barrier メッセージを使います (図 2-8)。コントローラが Barrier Request メッセージを送ると、それ以前に送った Flow Mod メッセージの処理が全て完了した後、スイッチは Barrier Reply メッセージを返します。

barrier
図 2-8: Barrier Request/Reply メッセージによってフローテーブルの更新完了を確認

2.2.8. フローエントリ削除の通知

フローエントリが消えると、消えたフローエントリーの情報は Flow Removed メッセージとしてコントローラに届きます。Flow Removed メッセージには、消えたフローエントリの内容とそのフローエントリにしたがって処理したパケットの統計情報が入っています。これを使えば、たとえばネットワークのトラフィック量の集計ができます。

flow removed
図 2-9: フローエントリが消えると、フローエントリの内容と転送したパケットの統計情報が Flow Removed としてコントローラへ上がる

2.3. フローエントリの中身

1章で見たようにフローエントリは次の 6 要素から成ります。

  • 優先度

  • カウンタ (統計情報)

  • タイムアウト (寿命)

  • クッキー

  • マッチフィールド

  • インストラクション

2.3.1. 優先度

フローエントリには、優先度 (0 〜 65535) が設定できます。受信パケットが、フローテーブル中に複数のフローエントリにマッチする場合、この優先度の値が高いフローエントリが優先されます。

2.3.2. カウンタ (統計情報)

OpenFlow 1.3 ではフローエントリごとにカウンタを持っており、次の統計情報を取得できます。

  • 受信パケット数

  • 受信バイト数

  • フローエントリが作られてからの経過時間 (秒)

  • フローエントリが作られてからの経過時間 (ナノ秒)

2.3.3. タイムアウト (寿命)

フローエントリにはタイムアウト (寿命) を設定できます。寿命の指定には次の 2 種類があります。

  • アイドルタイムアウト: 参照されない時間がこの寿命に逹すると、そのフローエントリを消す。パケットが到着し、フローエントリが参照された時点で 0 秒にリセットされる。

  • ハードタイムアウト: 参照の有無を問わず、フローエントリが書き込まれてからの時間がこの寿命に逹すると、そのフローエントリを消す。

どちらのタイムアウトも 0 にして打ち込むと、そのフローエントリは明示的に消さない限りフローテーブルに残ります。

2.3.4. クッキー

フローエントリには、クッキーを設定できます。クッキーに設定された値は、スイッチにおけるパケット処理には全く影響を与えません。例えば、フローエントリを管理するために、コントローラがクッキーフィールドに管理用の ID を付与するといった使い方ができます。

2.3.5. マッチフィールド

マッチフィールドとは、OpenFlow スイッチがパケットを受け取ったときにアクションを起こすかどうかを決める条件です。たとえば「パケットの宛先が http サーバだったら」とか「パケットの宛先がブロードキャストアドレスだったら」などという条件に適合したパケットにだけ、スイッチがアクションを起こすというわけです。

OpenFlow 1.3 では、40 種類の条件が使えます。主な条件を 表2-1 に示します。これらの条件はイーサネットや TCP/UDP でよく使われる値です。

コラム マッチフィールドの別名

OpenFlow が使われ始めたころ、フローエントリの要素の1つであるマッチフィールドには、"OpenFlow 12 タプル"、"ヘッダフィールド" 等、さまざまな別の呼び方がありました。混乱を避けるため、本書の前版では "マッチングルール" という呼び方に統一しました。パケットがきたときにルールに従ってマッチする、という役割をすなおに表現していて、いちばんわかりやすい名前だったからです。

その後、OpenFlow バージョン 1.3 で正式な呼び名が "マッチフィールド" に決まりました。そのため、本書では仕様に従い "マッチフィールド" という呼び方を使っています。

Table 5. マッチフィールドで指定できる主な条件
名前 説明

In Port

スイッチの論理ポート番号

In Phy Port

スイッチの物理ポート番号

Ether Src

送信元 MAC アドレス

Ether Dst

宛先 MAC アドレス

Ether Type

イーサネットの種別

VLAN ID

VLAN ID

VLAN Priority

VLAN PCP の値 (CoS)

IP DSCP

DiffServ コードポイント

IP ECN

IP ECN ビット

IP Src

送信元 IP アドレス

IP Dst

宛先 IP アドレス

IP Proto

IP のプロトコル種別

TCP Src Port

TCP の送信元ポート番号

TCP Dst Port

TCP の宛先ポート番号

UDP Src Port

UDP の送信元ポート番号

UDP Dst Port

UDP の宛先ポート番号

ICMPv4 Type

ICMP 種別

ICMPv4 Code

ICMP コード

IPv6 Src

送信元 IPv6 アドレス

IPv6 Dst

宛先 IPv6 アドレス

IPv6 Flowlabel

IPv6 フローラベル

ICMPv6 Type

ICMPv6 種別

ICMPv6 Code

ICMPv6 コード

MPLS Label

MPLS ラベル

MPLS TC

MPLS トラフィッククラス

PBB ISID

PBB ISID

OpenFlow の世界では、このマッチフィールドで指定できる条件を自由に組み合わせて通信を制御します。たとえば、

  • スイッチの物理ポート 1 番から届く、宛先が TCP 80 番 (= HTTP) のパケットを書き換える

  • MAC アドレスが 02:27:e4:fd:a3:5d で宛先の IP アドレスが 192.168.0.0/24 は遮断する

などといった具合です。

OSI ネットワークモデルが壊れる?

あるネットワークの経験豊富な若者がこんな事を言っていました。

「OpenFlow のようにレイヤをまたがって自由に何でもできるようになると、OSI ネットワークモデル(よく「レイヤ 2」とか「レイヤ 3」とか呼ばれるアレのこと。正確には ISO によって制定された、異機種間のデータ通信を実現するためのネットワーク構造の設計方針)が壊れるんじゃないか?」

その心配は無用です。OSI ネットワークモデルは正確に言うと「OSI 参照モデル」と言って、通信プロトコルを分類して見通しを良くするために定義した "参照用" の階層モデルです。たとえば自分が xyz プロトコルというのを作ったけど人に説明したいというときに、どう説明するか考えてみましょう。「これはレイヤ 3 のプロトコルで、…」という風に階層を指して (参照して) 説明を始めれば相手に通りがよいでしょう。つまり、OSI ネットワークモデルはネットワーク屋同士で通じる「語彙」として使える、まことに便利なものなのです。

でも、これはあくまで「参照」であって「規約」ではないので、すべてのネットワークプロトコル、ネットワーク機器がこれに従わなければいけない、というものではありません。さっき言ったように「この ○○ は、仮に OSI で言うとレイヤ4 にあたる」のように使うのが正しいのです。

そして、OpenFlow はたまたまいくつものレイヤの情報が使える、ただそれだけのことです。

2.3.6. インストラクション

インストラクションには、そのフローエントリにマッチしたパケットを、次にどのように扱うかを指定します。OpenFlow 1.3 では主に、以下のインストラクションを利用可能です。

  • Apply-Actions: 指定されたアクションを実行します。

  • Write-Actions: 指定されたアクションをアクションセットに追加します。

  • Clear-Actions: アクションセット中のアクションをすべてクリアします。

  • Write-Metadata: 受信したパケットに、メタデータを付与します。

  • Goto-Table: 指定のフローテーブルに移動します。

これらのうち Write-Actions, Clear-Actions, Write-Metadata, Goto-Table は、マルチプルテーブルを使う際に用いるインストラクションです。そのため、マルチプルテーブルを説明する際に、合わせて詳しく説明します。

Apply-Actions にて指定するアクションとは、スイッチに入ってきたパケットをどう料理するか、という 動詞 にあたる部分です。よく「OpenFlow でパケットを書き換えて曲げる」などと言いますが、こうした書き換えなどはすべてアクションで実現できます。OpenFlow 1.3 では、次の 7 種類のアクションがあります。

  • Output: パケットを指定したポートから出す

  • Group: パケットに対し、指定したグループテーブルの処理を適用する

  • Drop: パケットを捨てる

  • Set-Queue: ポートごとに指定されたスイッチのキューに入れる。QoS 用

  • Push-Tag/Pop-Tag: パケットに対し MPLS/VLAN タグの付与/除去を行う

  • Set-Field: 指定のフィールドの中身を書き換える

  • Change-TTL: TTL フィールドの値を書き換える

アクションは動詞と同じく指定した順番に実行されます。「おにぎりを作って、食べて、片付ける」といったふうに。たとえば、パケットを書き換えて指定したポートから出したいときには、

[Set-Field, Output]

と、複数のアクション並べて指定します。この実行順に並べられた複数のアクションのことを、アクションリストと呼びます。Apply-Actions インストラクションや Write-Actions インストラクションには、アクションリストを用いることで、複数のアクションを指定できます。

ここで、アクションリストは指定された順番に実行されることに注意してください。アクションリストの順番を変えてしまうと、違う結果が起こります。たとえば「おにぎりを食べてから、おにぎりを作る」と最後におにぎりが残ってしまいます。同様に先ほどの例を逆にしてしまうと、まず先にパケットがフォワードされてしまいます。その後 Set-Field が実行されても、書き換えられた後、そのパケットは破棄されるだけです。

# パケットを書き換える前にフォワードされてしまう。
[Output, Set-Field]

同じ動詞を複数指定することもできます。

[Set-Field A, Set-Field B, Output A, Output B]

この場合は、フィールド A と B を書き換えて、ポート A と B へフォワードする、と読めます。このように、複数のフィールドを書き換えたり、複数のポートにパケットを出したりする場合には、アクションを複数連ねて指定します[5]

Drop は特殊なアクションで、実際に Drop アクションというものが具体的に定義されているわけではありません。アクションのリストに Output アクションを1つも入れなかった場合、そのパケットはどこにもフォワードされずに捨てられます。これを便宜的に Drop アクションと呼んでいるわけです。

それでは、もっともよく使われる Output アクションと Set-Field アクションで具体的に何が指定できるか見て行きましょう。

2.3.7. Output アクション

Output アクションでは指定したポートからパケットを出力します。出力先にはポート番号を指定しますが、特殊用途のために定義されている論理ポートを使うこともできます。

  • ポート番号: パケットを指定した番号のポートに出す。

  • IN_PORT: パケットを入ってきたポートに出す。

  • ALL: パケットを入ってきたポート以外のすべてのポートに出す。

  • FLOOD: パケットをスイッチが作るスパニングツリーに沿って出す。

  • CONTROLLER: パケットをコントローラに明示的に送り、Packet In を起こす。

  • NORMAL: パケットをスイッチの機能を使って転送する。

  • LOCAL: パケットをスイッチのローカルスタックに上げる。ローカルスタック上で動作するアプリケーションにパケットを渡したい場合に使う。あまり使われない。

この中でも FLOOD や NORMAL は OpenFlow スイッチ機能と既存のスイッチ機能を組み合わせて使うための論理ポートです。

2.3.8. Set-Field アクション

Set-Field アクションでは、パケットのさまざまな部分を書き換えられます。パケットで書き換えられるフィールドは、マッチフィールドで指定可能なフィールドと同じです (表2-1)。例えば、以下に示す書き換えが可能です。

  • 送信元/宛先 MAC アドレスの書き換え

  • 送信元/宛先 IP アドレスの書き換え

  • ToS フィールドの書き換え

  • TCP/UDP 送信元/宛先ポートの書き換え

  • VLAN ID/プライオリティの書き換え

それでは Set-Field アクションの代表的な使い道を順に見ていきましょう。

MAC アドレスの書き換え

MAC アドレス書き換えの代表的な例がルータです。OpenFlow はルータの実装に必要な、送信元と宛先 MAC アドレスの書き換えをサポートしています。

rewrite mac
図 2-10: ルータでの送信元と宛先 MAC アドレスの書き換え

ルータは 2 つのネットワークの間で動作し、ネットワーク間で行き交うパケットの交通整理を行います。ホスト A が異なるネットワークに属するホスト B にパケットを送ると、ルータはそのパケットを受け取りその宛先 IP アドレスから転送先のネットワークを決定します。そして、パケットに記述された宛先 MAC アドレスを次に送るべきホストの MAC アドレスに、送信元を自分の MAC アドレスに書き換えてデータを転送します。

IP アドレスの書き換え

IP アドレス書き換えの代表的な例が NAT (Network Address Transition) です。OpenFlow は NAT の実装に必要な、送信元と宛先 IP アドレスの書き換えをサポートしています。

rewrite ip address
図 2-11: NAT での送信元と宛先 IP アドレスの書き換え

インターネットと接続するルータでは、プライベート/グローバルネットワーク間での通信を通すために IP アドレスを次のように変換します。プライベートネットワーク内のクライアントからインターネット上のサーバに通信をする場合、ゲートウェイはプライベートネットワークから届いたパケットの送信元 IP アドレスを自分のグローバルな IP アドレスに変換して送信します。逆にサーバからの返信は逆の書き換えを行うことによりプライベートネットワーク内のクライアントに届けます。

ToS フィールドの書き換え

ToS フィールドは通信のサービス品質 (QoS) を制御する目的でパケットを受け取ったルータに対して処理の優先度を指定するために使われます。OpenFlow はこの ToS フィールドの書き換えをサポートしています。

TCP/UDP ポート番号の書き換え

TCP/UDP ポート番号書き換えの代表的な例が IP マスカレードです。OpenFlow は IP マスカレードの実装に必要な、送信元と宛先の TCP/UDP ポート番号の書き換えをサポートしています。

rewrite port
図 2-12: IP マスカレードでの送信元と宛先 TCP/UDP ポート番号の書き換え

ブロードバンドルータなど 1 つのグローバルアドレスで複数のホストが同時に通信を行う環境では、NAT だけだと TCP/UDP のポート番号が重複する可能性があります。そこで、IP マスカレードではプライベートネットワーク側のポート番号をホストごとに適当に割り当て、通信のつどポート番号を変換することで解決します。

VLAN ヘッダの書き換え

既存のタグ付き VLAN で構築したネットワークと OpenFlow で構築したネットワークを接続するという特別な用途のために、VLAN ヘッダの書き換えができます。VLAN をひとことで説明すると、既存のスイッチで構成されるネットワーク (ブロードキャストが届く範囲のネットワーク) を複数のネットワークに分割して使用するための仕組みです。この分割したネットワーク自体を VLAN と呼ぶ場合もあります。どの VLAN に所属するかを区別するのが VLAN ID で、パケットに付与される VLAN タグがこの VLAN ID を含みます。Set-Field アクションを用いることで、以下に示す 2 種類の VLAN ヘッダ操作ができます。

strip vlan
図 2-13: VLAN ヘッダを書き換えるアクションの使い道
VLAN ID の書き換え

VLAN パケットが属する VLAN の ID を書き換えます。たとえば VLAN ID を 3 に書き換えるといったアクションを指定できます。また、VLAN ヘッダがついていないパケットに 指定した VLAN ID を持つ VLAN ヘッダを付与することもできます。

VLAN プライオリティの書き換え

VLAN 上でのパケットを転送する優先度を変更します。このプライオリティはトラフィックの種類 (データ、音声、動画など) を区別する場合などに使います。指定できる値は 0 (最低) から 7 (最高) までです。

2.3.9. Change-TTL アクション

Chante-TTL アクションは、パケット中の TTL (Time-To-Live) の値を変更するためのアクションです。TTL は、なんらかの不具合によりネットワーク中でパケットがループすることを防ぐための仕組みです。パケットを受信したネットワーク機器は、ヘッダ中の TTL の値を一つ減らしてからパケットを転送します。もし、受信したパケットの TTL の値が 0 だった場合、そのパケットを破棄します。このようにすることで、パケットがループ中を転送され続けることを防ぎます。Change-TTL アクションでは、以下に示す TTL の書き換えが可能です。

  • MPLS ヘッダの TTL に指定の値を設定 (Set MPLS TTL)

  • IP ヘッダの TTL に指定の値を設定 (Set IP TTL)

  • MPLS ヘッダの TTL の値を一つ減算 (Decrement MPLS TTL)

  • IP ヘッダの TTL の値を一つ減算 (Decrement IP TTL)

  • 内側ヘッダの TTL の値を外側ヘッダの TTL のフィールドにコピー (Copy TTL outwards)

  • 外側ヘッダの TTL の値を内側ヘッダの TTL のフィールドにコピー (Copy TTL inwards)

例えば、内側が IP ヘッダで外側が MPLS ヘッダである時、Copy TTL outwards では、IP ヘッダの TTL 値を MPLS ヘッダの TTL のフィールドに設定します。一方、Copy TTL inwards では、MPLS ヘッダの TTL 値を IP ヘッダの TTL のフィールドに設定します。

2.4. マルチプルテーブル

OpenFlow バージョン 1.3 では、OpenFlow スイッチがフローテーブルを複数持てます。この複数のフローテーブルのことを、マルチプルテーブルと呼びます。マルチプルテーブルをうまく活用することで、複雑なパケット処理を行えます。

宮坂部長グループの社内ネットワーク運用について考えてみましょう(図 2-14)。

multiple table example
図 2-14: 宮坂部長グループの社内ネットワーク
  • 宮坂部長 (192.168.0.1) の端末からのパケットは、MailとWebのトラフィックの場合のみ、ポート 2 に出力したい。

  • 一般社員の端末 (192.168.0.2 ~ 192.168.0.254) からは、すべてのトラフィックを、ポート 2 に出力したい。

Mail のトラフィックを許可する場合には、TCP で Destination Port 番号が 25 と 110 のパケットを通過するようにします。同様に Web では、TCP で Destination Port 番号 80 と 443 を許可します。

これをフローテーブルに設定すると、表2-2 の様になります。先頭の 5 つのが宮坂部長の端末 (192.168.0.1) からのパケット向けのフローエントリです。

Table 6. マルチプルテーブルを使わない場合のフローテーブルの例1
マッチ インストラクション 優先度 備考

src_ip = 192.168.0.1/32, dst_port = 25

Apply-Actions (Output 2)

10000

宮坂部長用

src_ip = 192.168.0.1/32, dst_port = 110

Apply-Actions (Output 2)

10000

src_ip = 192.168.0.1/32, dst_port = 80

Apply-Actions (Output 2)

10000

src_ip = 192.168.0.1/32, dst_port = 443

Apply-Actions (Output 2)

10000

src_ip = 192.168.0.1/32

Apply-Actions (Drop)

5000

src_ip = 192.168.0.0/24

Apply-Actions (Output 2)

1000

一般社員用

宮坂部長の仕事が多くなったため、事務員を雇うことになりました。事務員は宮坂部長の業務を手伝う必要があるため、事務員に割り当てられた端末 (192.168.0.2) は宮坂部長の端末と同じポリシーで運用することとします。この場合、フローテーブルを 表2-3 のように書き換える必要があります。

Table 7. マルチプルテーブルを使わない場合のフローテーブルの例2
マッチ インストラクション 優先度 備考

src_ip = 192.168.0.1/32, dst_port = 25

Apply-Actions (Output 2)

10000

宮坂部長用

src_ip = 192.168.0.1/32, dst_port = 110

Apply-Actions (Output 2)

10000

src_ip = 192.168.0.1/32, dst_port = 80

Apply-Actions (Output 2)

10000

src_ip = 192.168.0.1/32, dst_port = 443

Apply-Actions (Output 2)

10000

src_ip = 192.168.0.1/32

Apply-Actions (Drop)

5000

src_ip = 192.168.0.2/32, dst_port = 25

Apply-Actions (Output 2)

10000

事務員用

src_ip = 192.168.0.2/32, dst_port = 110

Apply-Actions (Output 2)

10000

src_ip = 192.168.0.2/32, dst_port = 80

Apply-Actions (Output 2)

10000

src_ip = 192.168.0.2/32, dst_port = 443

Apply-Actions (Output 2)

10000

src_ip = 192.168.0.2/32

Apply-Actions (Drop)

5000

src_ip = 192.168.0.0/24

Apply-Actions (Output 2)

1000

一般社員用

wildcard

Apply-Actions (Drop)

0

表2-2表2-3 を比較すると、フローエントリが 5 つ増えているのがわかります。もし事務員をもう一人雇うことになった場合、さらに 5 つのエントリを追加する必要があります。

このようにフローテーブルの内容が複雑になるケースでも、マルチプルテーブルを使うことですっきりできます。まず Table1 の内容は 表2-4 の様になります。宮坂部長および事務員の端末からのパケットを、TCP のポート番号を見てからどのように処理するか判断すべきです。そのための判断をするために、これらのパケットは次に Table 2 を見るように Goto-Table インストラクションが指定されています。

Table 8. マルチプルテーブルを使う場合のフローテーブルの例 1 (Table1)
マッチ インストラクション 優先度 備考

src_ip = 192.168.0.1/32

Goto-Table 2

10000

宮坂部長用

src_ip = 192.168.0.2/32

Goto-Table 2

10000

事務員用

src_ip = 192.168.0.0/24

Apply-Actions (Output 2)

1000

一般社員用

wildcard

Apply-Actions (Drop)

0

Table2 の内容は、表2-5 のようになっています。このテーブルを参照するのは、宮坂部長、事務員の端末からのパケットが到着した場合のみなので、あとは Mail, Web のトラフィックのみ通過できるようなエントリを記述すれば良いことになります。

Table 9. マルチプルテーブルを使う場合のフローテーブルの例 2 (Table2)
マッチ インストラクション 優先度 備考

dst_port = 25

Apply-Actions (Output 2)

10000

宮坂部長、事務員用

dst_port = 110

Apply-Actions (Output 2)

10000

dst_port = 80

Apply-Actions (Output 2)

10000

dst_port = 443

Apply-Actions (Output 2)

10000

wildcard

Apply-Actions (Drop)

5000

マルチプルテーブルを使ったほうが、図2-3 と比べ、シンプルになることがわかります。もし、事務員をもう一人雇うことになった場合でも、Table1 に一つエントリを追加するだけで済みます。

2.4.1. Write-Actions と Clear-Actions

Apply-Actions に指定されたアクションは、フローテーブルが参照された段階で即座に実行されます。一方で、Write-Actions を使うと、一旦アクションセットに格納されます。そしてフローテーブルの参照が全て終わった段階で、アクションセットに格納されたアクションが実行されます。

例えば、表2-6表2-7 のようにフローエントリが格納されていたとします。宛先ポート番号 25 のパケットを受信した時、このパケットは Table1 の 1 番目のエントリにマッチします。そのため、Write-Actions インストラクションで指定されている Set-Field A というアクションがアクションセットに格納されます。1 番目のエントリには、Goto-Table インストラクションも指定されていますので、次に Table2 の参照を行います。受信パケットは Table2 の 1 番目のエントリにもマッチしますので、同様にアクションセットに Output 2 というアクションが格納されます。最終的にアクションセットには、Set-Field A および Output 2 という二つのアクションが格納されている状態になります。

Table 10. Write-Actions を含むフローテーブルの例 1 (Table1)
マッチ インストラクション 優先度

dst_port = 25

Write-Actions (Set-Field A), Goto-Table 2

10000

dst_port = 110

Write-Actions (Set-Field B), Goto-Table 2

10000

Table 11. Write-Actions を含むフローテーブルの例 2 (Table2)
マッチ インストラクション 優先度

wildcard

Write-Actions (Output 2)

10000

アクションセットに格納された複数のアクションは、次の優先順位に従って実行されます。格納された順に実行されるわけではない点に注意が必要です。

  1. copy TTL inwards : 外側ヘッダの TTL を内側ヘッダの TTL へコピーするアクションを実行します。

  2. pop : 指定されたタグを除去するアクションを実行します。

  3. push-MPLS : MPLS tag をパケットに付与するアクションを実行します。

  4. push-PBB : PBB tag をパケットに付与するアクションを実行します。

  5. push-VLAN : VLAN tag をパケットに付与するアクションを実行します。

  6. copy TTL outwards : 内側ヘッダの TTL を外側ヘッダの TTL へコピーするアクションを実行します。

  7. decrement TTL : TTL を 1 減らすアクションを実行します。

  8. set : Set-Field アクションを実行します。

  9. qos : Set-Queue アクションを実行します。

  10. group : Group アクションを実行します。

  11. output : group の指定がない場合のみ、Output アクションを実行します。

表2-6表2-7 で示した例の場合、Output アクションより優先度が高い Set-Field アクションが先に実行され、その後 Output アクションが実行されます。

アクションセットは、一連の処理が終わった後にクリアされます。前に受信したパケットのアクションがアクションセットに入ったままになり、次のパケットの処理に用いられることは起こりません。

マルチプルテーブルを使ったパケットの処理中であっても、Clear-Actions インストラクションを使うことで、アクションセットの中身をクリアできます。Clear-Actions インストラクションを使えば、Write-Actions で格納したアクションをアクションセット中から全て消去できます。

2.4.2. メタデータの利用

Write-Metadata インストラクションを使って、メタデータを付与できます。付与されたメタデータは、Goto-Table インストラクションで次のフローテーブルを参照する際に、マッチフィールドの一部として利用できます。

例えば、送信元 IP アドレスが 192.168.1.101, 102 の場合、宛先ポート番号が 25, 110 のパケットのみをポート 2 から出力し、また送信元 IP アドレスが 192.168.1.103, 104 の場合、宛先ポートが 80, 443 のパケットのみをポート 2 から出力することを考えます。この例をメタデータを使って実現したのが 表2-8表2-9 です。

表2-8 には、送信元 IP アドレスをマッチとしたフローエントリが格納しています。表2-9 には、宛先ポートをマッチとしたフローエントリが格納されています。このように、メタデータを用いることで、複雑な条件であっても、シンプルなフローエントリの組み合わせで設定できます。

Table 12. メタデータを含むフローテーブルの例 1 (Table1)
マッチ インストラクション 優先度

src_ip = 192.168.1.101

Write-Metadata 1, Goto-Table 2

10000

src_ip = 192.168.1.102

Write-Metadata 1, Goto-Table 2

10000

src_ip = 192.168.1.103

Write-Metadata 2, Goto-Table 2

10000

src_ip = 192.168.1.104

Write-Metadata 2, Goto-Table 2

10000

Table 13. メタデータを含むフローテーブルの例 2 (Table2)
マッチ インストラクション 優先度

metadata = 1, dst_port = 25

Apply-Actions (Output 2)

10000

metadata = 1, dst_port = 110

Apply-Actions (Output 2)

10000

metadata = 2, dst_port = 80

Apply-Actions (Output 2)

10000

metadata = 2, dst_port = 443

Apply-Actions (Output 2)

10000

メタデータは 64bit 長のビット列で、初期値は All 0 です。Write-Matadata インストラクションは、各ビットの値を変更します。Write-Metadata インストラクションを使うときは、値とマスクの組を指定します。マスクで指定されたビットの値がメタデータに反映されます。

例を使って説明します。実際にはメタデータは 64bit ですが、ここでは 8bit であるとします。メタデータの現在の値が 11111111 であり、Write-Metadata インストラクションでの指定した値は 00001010、マスクは 00001111 であったとします。マスクは下位 4bit が 1 であるため、値の下位 4bit 分だけをメタデータに反映します。その結果、メタデータは 11111010 となります。

また、メタデータをマッチフィールドで用いる場合にも、値とマスクを指定します。マスクで指定されたビットのみ、マッチに用います。

2.5. まとめ

OpenFlow 仕様の中でもとくにポイントとなる部分を見てきました。ここまでの章で学んできた内容だけで、すでに OpenFlow 専門家と言ってもよいほどの知識が身に付いたはずです。次の章では OpenFlow コントローラを開発するためのプログラミングフレームワークである Trema (トレマ) に触れてみましょう。

3. Hello, Trema!

Trema(トレマ)を使うと楽しくSDNの世界が味わえます。これでいよいよあなたもOpenFlowプログラマの仲間入りです!

izakaya

3.1. 作ってわかるOpenFlow

いよいよOpenFlowを使ってネットワークを実際にプログラムしていきます。職場や自宅のような小規模ネットワークでもすぐに試せるコードを通じ、OpenFlowの世界を体験しましょう。実際に手を動かし実行してみれば「OpenFlowってどんな場面で使えるの?」というよくある疑問も徐々に氷解していくでしょう。

実装はステップバイステップで進みます。最初はOpenFlowやプログラミングの基礎から始めます。そしてパッチパネルやイーサネットスイッチ、ファイアウォール、ルータの実装など徐々に複雑な機能へとステップアップしていきます。そして最終的には、データセンターでも動く本格的なネットワーク仮想化の実装を目標とします。

Hello Trema (本章)

OpenFlow 版 Hello World

スイッチ監視ツール (4章)

スイッチの死活監視ツール

Cbenchベンチマーク (5章)

OpenFlow のマイクロベンチマークツール

パッチパネル (6章)

ソフトウェアとして実装したインテリジェント・パッチパネル

ラーニングスイッチ (7章)

イーサネットスイッチをエミュレートするコントローラ

ラーニングスイッチ OpenFlow1.3 (8章)

ラーニングスイッチの OpenFlow1.3 による実装

テスト駆動開発 (9章)

コントローラのテスト駆動開発

ブリッジ (10章)

レガシーなネットワークとOpenFlowネットワークのブリッジ

ファイアウォール (11章)

透過型ファイアウォール

ルータ (12章,13章,14章)

基本的なレイヤ3スイッチ (ルータ)

トポロジ (15章)

中規模〜大規模ネットワークのトポロジ検知

ルーティングスイッチ (16章)

中規模〜大規模ネットワーク用の仮想レイヤ2スイッチ

ネットワークスライス (17章)

ルーティングスイッチに仮想ネットワーク機能を追加

OpenVNet (18章)

Tremaベースの商用SDN

まずは、OpenFlowプログラミングのためのフレームワーク、Tremaを改めて紹介します。

3.2. Tremaとは

TremaはOpenFlowコントローラを開発するためのフリーソフトウェアです。GitHub上でオープンに開発を進める、GPL2ライセンスのフリーソフトウェアです。その強力な機能や使いやすさから、国内外の企業・大学・研究機関などの幅広い組織が採用しています。

Tremaの情報はおもに次のURLから入手できます。

Tremaホームページ

https://trema.github.com/trema/

GitHubのプロジェクトページ

https://github.com/trema/

メーリングリスト

http://groups.google.com/group/trema-dev/

Twitterアカウント

https://twitter.com/trema_news

Tremaの特徴はRuby on Rails[6]と同じく「プログラミングフレームワーク」を謳っていることです。でも、プログラミングフレームワークとはいったい何でしょうか。

Webサービスの世界では、90年代半ばには原始的なプログラミングが開発の主流でした。HTTPプロトコルを意識した低レベルなCGIをCやPerlで書かねばならず、ごく単純な掲示板サービスを作るのにも大量のコーディングが伴いました。

しかし2000年代に入り状況は一変します。より生産性の高い開発手法の登場 — プログラミングフレームワークによるアジャイル開発 — によって一気にWebサービスは「カンブリア爆発」を迎えました。Railsを代表とするWebプログラミングフレームワークは、HTTPプロトコルの詳細を抽象化した高レベルなAPIを提供します。また、RubyやPythonをはじめとするスクリプティング言語の採用や、開発全体をラップトップPC1台で完結できる数々の開発支援ツールの提供によって、生産性を劇的に向上します。

この流れをOpenFlow界にも吹き込んだのがTremaです。Tremaは「OpenFlow版Rails」を合言葉として、2011年に初のOpenFlowプログラミングフレームワークとして登場しました。開発言語にはRailsと同じくRubyを採用し、また高レベルなOpenFlow APIを提供することで、プログラマはごく短いコードでOpenFlowコントローラを実装できます。また強力なOpenFlow開発ツール群を提供することで、ソフトウェアテストを中心とした反復的で段階的なアジャイル開発を可能にします。

こうした強力なツールの一つがTremaの仮想ネットワーク機能です。OpenFlowスイッチを持っていない開発者でも、Tremaを使えばラップトップPC1台の中に仮想的なOpenFlowネットワークを作り、そこで自分の開発したコントローラを実行できます。この「作ったものをすぐに実行できる」という利点は、生産性の向上だけでなくSDNやOpenFlowのような新しい技術の習得にもつながります。正しい理解のためには概念の理解に加えて実践、つまり実際に手を動かすことが欠かせないからです。

ここからは実際にTremaを使ってOpenFlowコントローラを作り、そして動かしていきます。まずはTremaの実行環境をセットアップしましょう。

Tremaの由来は?

Tremaの名前は、著者の一人がファンである「とれまレコード」(http://www.fumiyatanaka.com/toremarecords/) という大阪の小さなレコードレーベルの名前から来ています。とれまレコードの楽曲は国内だけでなく海外でも人気があり、海外のクラブチャートにもよくランクインします。

この「とれまレコード」の名前には面白い由来があります。日本がバブルの頃、道路上の「とまれ」という標示がよく「とれま」と間違えて描かれており、これに目をつけたレーベルオーナーが「とれまレコード」と名付けたのだそうです。

このありえないミスの原因は、バブル景気時代にまでさかのぼります。当時の景気に乗って急増した外国人労働者達は、日本語もままならないまま工事現場で働いていました。そのおかげで道路に「とれま」と描いてしまう珍事が発生したのだそうです。

この逸話にのっとって、Tremaの公式ロゴも図 3-Aのとおり道路標識の写真になっています。……ちなみに、こんな道路標識は日本中どこを探してもありません! 本書の編集者が画像編集ソフトで試しに作ってみたところ評判が良かったので、そのまま公式ロゴになりました。

3.3. Trema実行環境のセットアップ

TremaはLinux用のソフトウェアです。次のLinuxディストリビューションでの動作を確認しています。

  • Ubuntu Linux

  • Debian GNU/Linux

  • CentOS 6 系, 7 系

Tremaに必要なソフトウェアは次の4つです。

sudo

Trema が root 権限でコマンドを実行するのに使います。あらかじめ、sudo コマンドを使って root 権限でコマンドを実行できるかどうか、sudo の設定ファイルを確認しておいてください。

Ruby

Trema の実行には Ruby のバージョン 2.0 以降が必要です。Trema を使ったコントローラの開発にも Ruby を使います。

Bundler [7]

Ruby ライブラリのインストーラです。Trema 本体と実行に必要なライブラリ一式を自動的にインストールするのに使います。

Open vSwitch [8]

OpenFlow に対応したソフトウェアスイッチの一種です。Trema の仮想ネットワーク機能で使用します。

3.3.1. Rubyのインストール

Rubyのインストール方法は、Linuxディストリビューションごとに異なります。

Ubuntu/Debianにインストールする場合

標準のパッケージマネージャ apt で以下のようにRuby関連パッケージをインストールします。

$ sudo apt-get update
$ sudo apt-get install ruby2.0 ruby2.0-dev build-essential

なお build-essential パッケージは Trema が依存する外部ライブラリのインストールに必要な gcc コンパイラなどを含んでいます。

CentOS にインストールする場合

標準のパッケージマネージャ yum で以下のようにRuby関連パッケージをインストールします。

$ sudo yum update
$ sudo yum install ruby ruby-devel gcc gcc-c++

なお gcc と gcc-c++ パッケージは Trema が依存する外部ライブラリのインストールに必要です。

3.3.2. Bundler のインストール

Bundler は次のコマンドでインストールできます。

$ gem install bundler

なお gem は Ruby の標準ライブラリ形式 .gem をインストールするコマンドです。ここでは最新版の Bundler の .gem を自動的にダウンロードしてインストールしています。

3.3.3. Open vSwitchのインストール

Open vSwitchのインストール方法も、Linuxディストリビューションごとに異なります。

Ubuntu/Debian にインストールする場合

Open vSwitchも apt-get コマンドで簡単にインストールできます。

$ sudo apt-get install openvswitch-switch
CentOS にインストールする場合

yum コマンドでOpen vSwitchをインストールします。RDO(RPM Distribution of OpenStack)[9]というRedHat系Linux用のOpenStackパッケージリポジトリを使うと、簡単にインストールできます。

$ sudo yum update
$ sudo yum install https://rdoproject.org/repos/rdo-release.rpm
$ sudo yum install openvswitch
$ sudo systemctl start openvswitch.service

以上でTremaを使うための準備が整いました。それでは早速、入門の定番Hello, Worldを書いて実行してみましょう。

3.4. Hello, Trema!

Hello Trema!はもっとも簡単なOpenFlowコントローラです。その唯一の機能は、スイッチと接続して Hello, 0xabc! (0xabc はスイッチのユニーク ID) と表示するだけです。このように機能は単純ですが、そのソースコードはTremaでコントローラを作るのに必要な基本知識をすべて含んでいます。

3.4.1. Hello Tremaを書く

コントローラの実装はプロジェクト用ディレクトリを作ることから始めます。まずは次のように、Hello Trema!用の空のディレクトリ hello_trema/ と、ソースコード用ディレクトリ hello_trema/lib/mkdir -p コマンドで新たに作ってください。

$ mkdir -p hello_trema/lib
$ cd hello_trema
プロジェクトディレクトリの中身

プロジェクトディレクトリには、コントローラに関連するすべてのファイルを置きます。コントローラのソースコードをはじめ、README.mdやLICENSEといったドキュメント類、コントローラの動作をテストするためのテストファイル、そして各種設定ファイルがここに入ります。

プロジェクトディレクトリのお手本として、GitHub の trema/hello_trema リポジトリ (https://github.com/trema/hello_trema) を見てみましょう。このリポジトリは、標準的な Ruby プロジェクトのファイル構成に従っています。次に主要なファイルを挙げます。

README.md

メインのドキュメント

LICENSE

配布ライセンスの指定

CHANGELOG.md

開発履歴

Gemfile

実行に必要なgemパッケージの定義

Rakefile

開発用タスク

lib/

コントローラの実装

features/

受け入れテスト

spec/

ユニットテスト

tasks/

開発用タスク定義

このうち受け入れテスト関連の features/ ディレクトリについては、9 章「Trema でテスト駆動開発」で詳しく説明します。

コントローラ本体の実装

エディタで hello_trema ディレクトリ内の lib/hello_trema.rb を開き、次の Ruby コードを入力してください。.rb は Ruby プログラムの標準的な拡張子です。Ruby の文法は必要に応じておいおい説明しますので、もしわからなくても気にせずそのまま入力してください。

lib/hello_trema.rb
# Hello World!
class HelloTrema < Trema::Controller
  def start(_args)
    logger.info 'Trema started.'
  end

  def switch_ready(datapath_id)
    logger.info "Hello #{datapath_id.to_hex}!"
  end
end
スイッチの定義

Hello Trema! の実行には OpenFlow スイッチが 1 台必要です。さきほどインストールした Open vSwitch を Hello Trema コントローラに接続することにしましょう。次の設定ファイル trema.conf をエディタで hello_trema/ ディレクトリ直下に作成してください。

trema.conf
vswitch { datapath_id 0xabc }

コントローラを実行する際にこの設定ファイルを指定することで、Open vSwitch を起動しコントローラに接続できます。

この設定ファイルでは1台のソフトウェアスイッチを定義しています。vswitch で始まる行が1台の仮想スイッチに対応します。続く波括弧({ })内で指定している datapath_id (0xabc) は、仮想スイッチを識別するための16進数の値です。

この Daptapath ID とはちょうどMACアドレスのような存在で、スイッチを一意に特定するIDとして使います。OpenFlowの仕様では、この値には64ビットの一意な整数値を割り振ることになっています。仮想スイッチでは好きな値を設定できるので、もし複数台の仮想スイッチを作る場合にはお互いがぶつからないように注意してください。

Datapath ってどういう意味?

実用的には「Datapath = OpenFlowスイッチ」と考えて問題ありません。”データパス”で検索すると、「CPUは演算処理を行うデータパスと、指示を出すコントローラから構成されます」というハードウェア教科書の記述が見つかります。つまり、ハードウェアの世界では一般に

  • 筋肉にあたる部分 = データパス

  • 脳にあたる部分 = コントローラ

という分類をするようです。

OpenFlowの世界でも同じ用法を踏襲しています。OpenFlowのデータパスはパケット処理を行うスイッチを示し、その制御を行うソフトウェア部分をコントローラと呼びます。

Trema のインストール

Hello Trema の実行にはもちろん Trema が必要です。実行に必要な Ruby のアプリケーションやライブラリの gem を hello_trema/ ディレクトリ直下の Gemfile というファイルに次のように書くことで、Hello Trema の実行環境として Trema を使うということを指定します。

Gemfile
source 'https://rubygems.org/' (1)

gem ‘trema’ (2)
1 gem の取得元として標準的な https://rubygems.org を指定する
2 実行環境に Trema を追加する

Gemfile に記述した実行環境のセットアップには Bundler を使います。hello_trema ディレクトリ直下で次の bundle install --binstubs コマンドを実行すると、Gemfile に記述した Trema と Trema が依存する .gem ファイル一式を自動的にインストールし、Trema の実行コマンド tremabin/ ディレクトリに生成します。

$ bundle install --binstubs
$ ./bin/trema --version
trema version 0.9.0

実行に最低限必要なコードはこれだけです。それでは細かい部分は後で説明するとして「習うより慣れろ」でさっそく実行してみましょう。

実行してみよう(trema run)

作成したコントローラは trema run コマンドですぐに実行できます。Rubyはインタプリタ言語なので、コンパイルの必要はありません。ターミナルで次のように入力し、コントローラを起動してみてください。

$ ./bin/trema run ./lib/hello_trema.rb -c trema.conf
Trema started.
Hello, 0xabc! (1)
$
1 Ctrl+c でコントローラを終了

このように Trema started. Hello, 0xabc! と出力できたら成功です。

ここまで見てきたように、trema コマンドを使うと、とても簡単にコントローラを実行できます。trema コマンドには他にもいくつかの機能がありますので、ここで簡単に紹介しておきましょう。

3.5. trema コマンド

trema コマンドは Trema 唯一のコマンドラインツールであり、コントローラの起動やテストなどさまざまな用途に使います。

たとえばHello, Trema!で見たように、trema run はコントローラの起動コマンドです。起動したコントローラは OpenFlow スイッチと接続しメッセージをやりとりします。また、trema run コマンドに -c (--conf) オプションを指定することで、コントローラを仮想ネットワークのスイッチとも接続できます (図 3-1)。

trema overview
図 3-1: trema runコマンドの実行イメージ

trema コマンドは gitsvn コマンドと似たコマンド体系を持っています。trema に続けて run などのサブコマンドを指定することで、さまざまな機能を呼び出します。こうしたコマンド体系を一般に「コマンドスイート」と呼びます。

一般的なコマンドスイートと同じく、サブコマンドの一覧は trema help で表示できます。また、サブコマンド自体のヘルプは trema help サブコマンド名 で表示できます。以下に、trema help で表示されるサブコマンド一覧をざっと紹介しておきます。それぞれの使い方は続く章で説明していきますので、今は目を通すだけでかまいません。

trema run

コントローラをフォアグラウンドまたはバックグラウンド (デーモンモード) で実行する

trema killall

バックグラウンドで起動している Trema プロセス全体を停止する

trema stop

指定した仮想ホストまたは仮想スイッチを停止する

trema start

指定した仮想ホストまたは仮想スイッチを再び有効にする

trema send_packets

仮想ネットワーク内でテストパケットを送信する

trema show_stats

仮想ホストで送受信したパケットの統計情報を表示する

trema reset_stats

仮想ホストで送受信したパケットの統計情報をリセットする

trema port_down

仮想スイッチのポートを落とす

trema port_up

仮想スイッチのポートを上げる

trema delete_link

仮想ネットワーク内の仮想リンクを切る

trema netns

仮想ホストのネットワークネームスペースでコマンドを実行する

trema dump_flows

仮想スイッチのフローテーブルを表示する

では、気になっていた Ruby の文法にそろそろ進みましょう。今後はたくさん Ruby を使いますが、その都度必要な文法を説明しますので心配はいりません。しっかりついてきてください。

3.6. 即席Ruby入門

Rubyを習得する一番の近道は、コードを構成する各要素の種類(品詞)を押さえることです。これは、外国語を習得するコツに近いものがあります。ただし外国語と違い、Rubyの構成要素にはその品詞を見分けるための視覚的なヒントがかならずあります。このためRubyのコードはずいぶんと読みやすくなっています。

品詞 視覚的ヒント

定数

HelloTrema, Trema::Controller

大文字で始まる

インスタンス変数

@switches, @name

@ で始まる

シンボル

:match, :actions

: で始まる

インスタンス変数とシンボルについては4 章「スイッチ監視ツール」で詳しく説明します。

このように最初の文字を見れば、それがどんな品詞かすぐにわかります。たとえば、大文字で始まる名前はかならず定数です。品詞がわかれば、そのRubyコードがどんな構造かも見えてきます。これからそれぞれの品詞について順に説明していきますが、最初からすべてが理解できなくとも構いません。しばらくすればRubyコードのあらゆる部分が識別できるようになっているはずです。

3.6.1. 定数

HelloTremaTrema::Controller など、大文字で始まる名前が定数です。Rubyの定数は英語や日本語といった自然言語における固有名詞にあたります。

lib/hello_world.rb
# Hello World!
class HelloTrema < Trema::Controller (1)
  def start(_args)
    logger.info 'Trema started.'
  end

  def switch_ready(datapath_id)
    logger.info "Hello #{datapath_id.to_hex}!"
  end
end
1 HelloTremaTrema::Controller が定数

英語でも固有名詞は大文字で始めることになっています。たとえば英語のTokyo Tower(東京タワー)がそうです。東京タワーは動かすことができませんし、何か別なものに勝手に変えることもできません。このように、固有名詞は時間とともに変化しないものを指します。そして固有名詞と同様、Rubyの定数は一度セットすると変更できません。もし変更しようとすると、次のように警告が出ます。

$ irb
> TokyoTower = "東京都港区芝公園4丁目2-8"
> TokyoTower = "増上寺の近く"
(irb):2: warning: already initialized constant TokyoTower
(irb):1: warning: previous definition of TokyoTower was here
=> "東京都港区芝公園4丁目2-8"

ここで使っている irb (Interactive Ruby) は Ruby のインタラクティブな実行ツールです。ちょっとしたサンプルコードを試したり、Rubyの挙動を調べるのに便利です。

class に続く定数はクラス定義です。Hello, Trema!の例では HelloTrema がクラス名です。「class +クラス名」から始まるクラス定義は、同じ字下げレベルの end までの範囲です。

lib/hello_trema.rb
class HelloTrema < Trema::Controller (1)
  def start(_args)
    logger.info "Trema started."
  end

  def switch_ready(datapath_id)
    logger.info "Hello #{datapath_id.to_hex}!"
  end
end (2)
1 HelloTremaクラス定義の始まり
2 クラス定義の終わり
コントローラクラスの継承

Tremaではすべてのコントローラはクラスとして定義し、Tremaの提供する Trema::Controller クラスをかならず継承します。クラスを継承するには、class クラス名 < 親クラス名 と書きます.

lib/hello_trema.rb
class HelloTrema < Trema::Controller (1)
  ...
end
1 Trema::Controller クラスを継承した HelloTrema クラスを定義

Trema::Controller クラスを継承することで、コントローラに必要な基本機能が HelloTrema クラスに追加されます。たとえば次に説明するハンドラもその基本機能の一つです。

3.6.2. ハンドラの定義

さて、こうして定義した HelloTrema クラスはどこから実行が始まるのでしょうか。C言語で言う main() 関数に当たるものがどこにも見あたりません。

その答はTremaの動作モデルであるイベントドリブンモデルにあります。Tremaのコントローラは、さまざまなイベントに反応するイベントハンドラ (以下、ハンドラと呼びます) をいくつも持ちます。コントローラが動作するのは、さまざまなイベントに対してハンドラが反応したときです。

ハンドラの定義は def に続く名前から end までの部分で、実際にはメソッド定義です。たとえば HelloTrema の例では start ハンドラと switch_ready ハンドラを定義しています。ハンドラ名の後のカッコで囲まれた部分 (_argsdatapath_id) はそれぞれのメソッドに渡される引数です。

lib/hello_trema.rb
class HelloTrema < Trema::Controller
  def start(_args) (1)
    logger.info "Trema started."
  end

  def switch_ready(datapath_id) (2)
    logger.info "Hello #{datapath_id.to_hex}!"
  end
end
1 start ハンドラの定義
2 switch_ready ハンドラの定義
start ハンドラ

コントローラの起動イベント発生時、つまり trema run でコントローラを起動したときに自動で呼び出します。

switch_ready ハンドラ

スイッチがコントローラに接続し、初期化が完了したときに自動で呼び出します。

Rubyのイディオム: アンダーバー (_) で始まる引数名

メソッドの中で使わない引数は、_args のようにアンダーバーで始めます。これによって、この引数はメソッドの中で使われていないことが一目でわかります。

# メソッド内で _args は使っていない
def start(_args)
  logger.info "Trema started."
end
# メソッド内で args を使っている
def start(args)
  logger.info "Arguments = #{args.join ', '}"
end

このほかにTremaでよく使うハンドラをリストアップしておきます。

switch_disconnected ハンドラ

スイッチがコントローラから切断したときに呼び出します。

packet_in ハンドラ

Packet In メッセージ (5 章「マイクロベンチマークCbench」で紹介) がコントローラへ到着したときに呼び出します。

flow_removed ハンドラ

フローが消えたときのFlow Removedメッセージ到着時に呼び出します。

ハンドラの自動呼び出し

「ハンドラを定義しただけで、なぜ自動的に呼び出せるんだろう?」と不思議に思う人もいるでしょう。コード中にどんなメソッドがあるか、というコンパイル時情報をプログラム自身が実行時に知るためには、言語処理系の助けが必要です。

Rubyではオブジェクトが自らの持つメソッドを実行時に調べられます。これをイントロスペクション(リフレクションや自己反映計算などとも言う)と呼びます。たとえばPacket Inメッセージが到着したとき、コントローラはイントロスペクションして自分が packet_in メソッドを持っているかどうかを実行時に調べます。そしてもし見つかればそのメソッドを呼ぶというわけです。

この仕組みは Trema::Controller クラスを継承したとき、自動的にコントローラへ導入されます。

3.6.3. キーワード

Rubyにはたくさんの組込みの語 (キーワード) があり、それぞれに意味があります。これらのキーワードを変数として使ったり、自分の目的に合わせて意味を変えたりはできません。

alias and BEGIN begin break case class def defined do else elsif END
end ensure false for if in module next nil not or redo rescue retry
return self super then true undef unless until when while yield

このうち、Hello, Trema!では classdefend の 3 つのキーワードを使いました。先ほど説明したように、class キーワードは続く名前のクラスを定義します。そして def キーワードは続く名前のメソッドを定義します。

この defclass で始まって end で終わる領域のことをブロックと呼びます。すべてのRubyプログラムはこのブロックがいくつか組み合わさったものです。

さて、ここまででHello Trema!に必要なRubyの文法は学びました。再びHello Trema!のソースコードに戻りましょう。

3.6.4. スイッチの起動を捕捉する

新しくスイッチが起動すると switch_ready ハンドラが起動します。switch_ready ハンドラは、接続したスイッチのDatapath IDを16進形式(0xで始まる文字列)でログに出力します。

HelloTrema#switch_ready (lib/hello_trema.rb)
def switch_ready(dpid)
  logger.info "Hello #{dpid.to_hex}!"
end
switch_readyの中身

実は OpenFlow の仕様には switch_ready というメッセージは定義されていません。実は、これは Trema が独自に定義するイベントなのです。switch_ready の裏では図 3-Bに示す一連の複雑な処理が行われていて、Trema がこの詳細をうまくカーペットの裏に隠してくれているというわけです。

switch ready
図 3-B: switch_ready イベントが起こるまで

最初に、スイッチとコントローラがしゃべる OpenFlow プロトコルが合っているか確認します。OpenFlow の Hello メッセージを使ってお互いにプロトコルのバージョンを知らせ、うまく会話できそうか判断します。

次は、スイッチを識別するための Datapath ID の取得です。Datapath IDのようなスイッチ固有の情報は、スイッチに対して OpenFlow の Features Request メッセージを送ることで取得できます。成功した場合、Datapath IDや最大テーブル数などの情報が Features Reply メッセージに乗ってやってきます。

最後にスイッチを初期化します。スイッチに以前の状態が残っているとコントローラが管理する情報と競合が起こるので、スイッチを初期化することでこれを避けます。

これら一連の処理が終わると、ようやく switch_ready がコントローラに通知されるというわけです。

Datapath IDを16進形式にする

to_hex は整数を16進形式の文字列に変換するメソッドです。switch_ready ハンドラの引数 dpid の値は64ビットの正の整数で、OpenFlowでは慣習的に 0xfffb などと16進で表します。この慣習に従って、ターミナルやログに出力する場合には to_hex で16進形式に変換しておいたほうがよいでしょう。

ログメッセージを出力する

ログメッセージを出力するには、logger を使います。

HelloTrema#start (lib/hello_trema.rb)
def start(_args)
  logger.info 'Trema started.'
end

logger はTrema標準のロガーで、ログメッセージの出力はこれを通じて行います。ログメッセージの重要度に応じて、unknown (重要度 最高) から debug (重要度 最低) までの次の6種類のメソッドを選べます。

unknown

不明なエラー。重要度にかかわらず常にロギングする

fatal

回復不能なエラー

error

エラー

warn

警告

info

通常レベルの情報

debug

デバッグ出力

trema run のオプションでロギングレベルを指定できます。たとえば次のコードを実行するとしましょう。

ロギングレベルの確認用コード (try_logging.rb)
class TryLogging < Trema::Controller
  def start(_args)
    logger.unknown 'UNKNOWN'
    logger.fatal 'FATAL'
    logger.error 'ERROR'
    logger.warn 'WARN'
    logger.info 'INFO'
    logger.debug 'DEBUG'
  end
end

このコードをたとえば次のようにロギングレベル warn で実行すると、infodebug メッセージは出力されません。

$ ./bin/trema run try_logging.rb --logging_level warn
UNKNOWN
FATAL
ERROR
WARN

ログメッセージはログファイルにも記録されます。ログファイルのデフォルトパスは /tmp/[コントローラのクラス名].log です。たとえばHelloTremaの場合には /tmp/HelloTrema.log になります。ログファイルの出力先ディレクトリを変更するには、trema run--log_dir または -L オプションを指定します。たとえば次のようにすると、/var/log/HelloTrema.log が作られます。

$ ./bin/trema run try_logging.rb --log_dir /var/log/
文字列に式を組込む

logger.info に渡している文字列中の #{} は、文字列内にRubyの式を組込みます。

logger.info "Hello #{dpid.to_hex}!"
#=> Hello 0xabc!

これは次のコードと同じです。

logger.info 'Hello ' + dpid.to_hex + '!'
#=> Hello 0xabc!

どちらを使っても構いませんが、文字列を + でつなげすぎると最終的な出力がコードからはわかりにくくなることがあります。その場合、このように #{} で組み込んだほうがよいでしょう。

これでHello, Trema!の説明はおしまいです。Tremaで作るコントローラは基本的にこのHello, Trema!と同じ構成をしています。これをベースにいくつか必要なハンドラメソッドを追加していけば、より複雑で実践的なコントローラも作れます。

3.7. まとめ

この章ではTremaの開発環境をセットアップし、すべてのコントローラのテンプレートとなるHello, Trema!コントローラを書きました。この章で学んだことを簡単にまとめてから、より実用的なコントローラの開発に入っていくことにしましょう。

  • コントローラはクラスとして定義し、Trema::Controller クラスを継承することでコントローラの基本機能を取り込む

  • コントローラに機能を追加するには、各種イベントに対応するハンドラをコントローラクラスに定義する

  • コントローラは trema run コマンドでコンパイルなしにすぐ実行できる

  • 仮想ネットワーク機能を使うと、OpenFlowスイッチを持っていなくてもコントローラを実行できる

これでTremaの基礎知識は充分に身に付きました。次の章では、OpenFlowコントローラのためのマイクロベンチマークツール、Cbenchを計測するためのコントローラを書きます。

3.8. 参考文献

Rubyプログラミングが初めてという人達のために、この章では入門に役立つサイトや本をいくつか紹介します。

『Ruby 2.2.0 リファレンスマニュアル』(http://docs.ruby-lang.org/ja/2.2.0/doc/)

Ruby の完全なリファレンスです。Ruby でプログラミングする際は参照しましょう。

『メタプログラミングRuby 第2版』(Paolo Perrotta 著/角 征典 訳/オライリージャパン)

Ruby プログラムを短く簡潔に書くためのテクニックをたくさん紹介しています。「プロっぽい」 Ruby コードを書きたい人は必読です。

『Why’s (Poignant) Guide to Ruby』(http://mislav.uniqpath.com/poignant-guide/) [10]

Ruby界の伝説的ハッカー_why氏による風変わりで楽しいRuby入門です。この章のRubyの品詞の説明は、このドキュメントを参考にしました。

4. スイッチ監視ツール

OpenFlowの特長の一つは、たくさんのスイッチを集中管理できることです。その雰囲気を簡単なOpenFlowコントローラを書いて体験してみましょう。

scope

4.1. ネットワークを集中管理しよう

OpenFlowではたくさんのスイッチを1つのコントローラで集中制御できます。スイッチにはフローテーブルに従ったパケットの転送という1つの仕事だけをやらせ、頭脳であるコントローラが全体のフローテーブルを統括するというわけです。これによって1 章「OpenFlow の仕組み」で見てきたように、自動化やさまざまなシステム連携・トラフィック制御のしやすさ・ソフトウェア開発のテクニック適用・水平方向へのアップグレード、といったさまざまなメリットが生まれるのでした。

本章ではこの集中制御の一例として、スイッチ監視ツールを作ります。このツールは「今、ネットワーク中にどんなスイッチが動いていて、それぞれがどんな状態か」をリアルタイムに表示します。OpenFlowでの集中制御に必要な基本テクニックをすべて含んでいます。

スイッチ監視ツールは図 4-1のように動作します。コントローラはスイッチの接続を検知すると、起動したスイッチの情報を表示します。逆にスイッチが予期せぬ障害など何らかの原因で接続を切った場合、コントローラはこれを検知して警告を表示します。

switch monitor overview
図 4-1: スイッチ監視ツールの動作

4.2. インストール

スイッチ監視ツールのソースコードは GitHub から次のようにダウンロードできます。

$ git clone https://github.com/trema/switch_monitor.git

ダウンロードしたソースツリー上で bundle install --binstubs を実行すると、Tremaの ./bin/trema コマンドなど必要な実行環境一式を自動的にインストールできます。

$ cd switch_monitor
$ bundle install --binstubs

以上でスイッチ監視ツールとTremaのセットアップは完了です。

4.3. 実行してみよう

試しに仮想スイッチ3台の構成でスイッチ監視ツールを起動してみましょう。次の内容の設定ファイルを switch_monitor.conf として保存してください。なお、それぞれの datapath_id がかぶらないように 0x1, 0x2, 0x3 と連番を振っていることに注意してください。

switch_monitor.conf
vswitch { datapath_id 0x1 }
vswitch { datapath_id 0x2 }
vswitch { datapath_id 0x3 }

この構成でスイッチ監視ツールを起動するには、この設定ファイルを trema run-c オプションに渡すのでした。スイッチ監視ツールの出力は次のようになります。

$ ./bin/trema run ./lib/switch_monitor.rb -c switch_monitor.conf
SwitchMonitor started.
All =
0x3 is up (All = 0x3) (1)
0x3 manufacturer = Nicira, Inc. (2)
0x3 hardware info = (3)
0x3 software info = (4)
0x3 serial number = (5)
0x3 description = (6)
0x1 is up (All = 0x1, 0x3)
0x1 manufacturer = Nicira, Inc.
0x1 hardware info =
0x1 software info =
0x1 serial number =
0x1 description =
0x2 is up (All = 0x1, 0x2, 0x3)
0x2 manufacturer = Nicira, Inc.
0x2 hardware info =
0x2 software info =
0x2 serial number =
0x2 description =
All = 0x1, 0x2, 0x3
All = 0x1, 0x2, 0x3
1 スイッチ 0x3 がコントローラに接続
2 スイッチの製造者情報
3 スイッチのハードウェア情報 (空)
4 スイッチのソフトウェア情報 (空)
5 スイッチのシリアル番号 (空)
6 スイッチの詳細情報 (空)

0x1 is up などの行から、仮想ネットワーク設定ファイルに定義したスイッチ3台をコントローラが検出していることがわかります。続く行では、スイッチの製造者といった詳細情報や、スイッチ一覧 (All = 0x1, 0x2, 0x3 の行) も確認できます。

このように実際にスイッチを持っていなくても、設定ファイルを書くだけでスイッチを何台も使ったコントローラの動作テストができます。設定ファイルの vswitch { …​ } の行を増やせば、スイッチをさらに5台、10台、…と足していくことも思いのままです。

4.3.1. 仮想スイッチを停止/再起動してみる

それでは、スイッチの切断をうまく検出できるか確かめてみましょう。仮想スイッチを停止するコマンドは trema stop です。trema run を実行したターミナルはそのままで別ターミナルを開き、次のコマンドで仮想スイッチ 0x3 を落としてみてください。

$ ./bin/trema stop 0x3

すると、trema run を実行したターミナルで新たに 0x3 is down の行が出力されます。

$ ./bin/trema run ./switch_monitor.rb -c ./switch_monitor.conf
SwitchMonitor started.
All =
0x3 is up (All = 0x3)
0x3 manufacturer = Nicira, Inc.
0x3 hardware info =
0x3 software info =
0x3 serial number =
0x3 description =
……
All = 0x1, 0x2, 0x3
All = 0x1, 0x2, 0x3
All = 0x1, 0x2, 0x3
0x3 is down (All = 0x1, 0x2) (1)
1 スイッチ 0x3 が停止したことを示すログメッセージ

うまくいきました! それでは逆に、さきほど落した仮想スイッチを再び起動してみましょう。仮想スイッチを起動するコマンドは trema start です。

$ ./bin/trema start 0x3

0x3 is up の行が出力されれば成功です。

$ ./bin/trema run ./switch_monitor.rb -c ./switch_monitor.conf
SwitchMonitor started.
All =
0x3 is up (All = 0x3)
0x3 manufacturer = Nicira, Inc.
0x3 hardware info =
0x3 software info =
0x3 serial number =
0x3 description =
……
All = 0x1, 0x2, 0x3
All = 0x1, 0x2, 0x3
0x3 is down (All = 0x1, 0x2)
All = 0x1, 0x2
……
All = 0x1, 0x2
All = 0x1, 0x2
0x3 is up (All = 0x1, 0x2, 0x3) (1)
1 スイッチ 0x3 が再び起動したことを示すログメッセージ

このように、trema stoptrema start は仮想ネットワークのスイッチを制御するためのコマンドです。引数にスイッチのDatapath IDを指定することで、スイッチを停止または起動してコントローラの反応を確かめられます。

trema stop [Datapath ID]

指定した仮想スイッチを停止する

trema start [Datapath ID]

指定した仮想スイッチを再び起動する

スイッチ監視ツールの動作イメージがわかったところで、そろそろソースコードの解説に移りましょう。

4.4. ソースコード解説

まずはざっとスイッチ監視ツールのソースコード(lib/switch_monitor.rb)を眺めてみましょう。今までに学んできたRubyの品詞を頭の片隅に置きながら、次のコードに目を通してみてください。

lib/switch_monitor.rb
# Switch liveness monitor.
class SwitchMonitor < Trema::Controller
  timer_event :show_all_switches, interval: 10.sec

  def start(_args)
    @switches = []
    logger.info "#{name} started."
  end

  def switch_ready(dpid)
    @switches << dpid
    logger.info "#{dpid.to_hex} is up (All = #{all_switches_in_string})"
    send_message dpid, DescriptionStats::Request.new
  end

  def switch_disconnected(dpid)
    @switches -= [dpid]
    logger.info "#{dpid.to_hex} is down (All = #{all_switches_in_string})"
  end

  def description_stats_reply(dpid, desc)
    logger.info "Switch #{dpid.to_hex} manufacturer = #{desc.manufacturer}"
    logger.info "Switch #{dpid.to_hex} hardware info = #{desc.hardware}"
    logger.info "Switch #{dpid.to_hex} software info = #{desc.software}"
    logger.info "Switch #{dpid.to_hex} serial number = #{desc.serial_number}"
    logger.info "Switch #{dpid.to_hex} description = #{desc.datapath}"
  end

  private

  def show_all_switches
    logger.info "All = #{all_switches_in_string}"
  end

  def all_switches_in_string
    @switches.sort.map(&:to_hex).join(', ')
  end
end

新しい品詞や構文がいくつかありますが、今までに学んだ知識だけでこのRubyソースコードの構成はなんとなくわかったはずです。まず、スイッチ監視ツールの本体は SwitchMonitor という名前のクラスです。そしてこのクラスにはいくつかハンドラメソッドが定義してあるようです。おそらくそれぞれがスイッチの接続や切断、そして統計情報イベントを処理しているんだろう、ということが想像できれば上出来です。

4.4.1. スイッチの起動を捕捉する

switch_ready ハンドラでは、スイッチ一覧リスト @switches に新しく接続したスイッチのDatapath IDを追加し、接続したスイッチの情報を画面に表示します。

SwitchMonitor#switch_ready (lib/switch_monitor.rb)
def switch_ready(dpid)
  @switches << dpid
  logger.info "#{dpid.to_hex} is up (All = #{all_switches_in_string})"
  send_message dpid, DescriptionStats::Request.new
end

@switchesstart ハンドラで空の配列に初期化されます。

SwitchMonitor#start (lib/switch_monitor.rb)
def start(_args)
  @switches = []
  logger.info "#{name} started."
end

4.4.2. インスタンス変数

アットマーク(@)で始まる語はインスタンス変数です。インスタンス変数はたとえば人間の歳や身長などといった、属性を定義するときによく使われます。アットマークはアトリビュート (属性) を意味すると考えれば覚えやすいでしょう。

インスタンス変数は同じクラスの中のメソッド定義内であればどこからでも使えます。具体的な例として次の Human クラスを見てください。

class Human
  def initialize
    @age = 0 (1)
  end

  def birthday (2)
    @age += 1
  end
end
1 インスタンス変数を初期化。生まれたときは 0 歳
2 一年に一度、歳をとる

Human クラスで定義される Human オブジェクトは、初期化したときにはそのインスタンス変数 @age は0、つまり0歳です。birthday を呼び出すたびに歳を取り、@age が 1 増えます。このように @ageinitialize および birthday メソッドのどちらからでもその値を変更できます。

配列

配列は角カッコで囲まれたリストで、カンマで区切られています。

  • [] は空の配列

  • [1, 2, 3] は数字の配列

  • ["バナナ", "みかん", "りんご"] は文字列の配列

Rubyの配列はとても直感的に要素を足したり取り除いたりできます。たとえば配列の最後に要素を加えるには << を使います。

fruits = ["バナナ", "みかん", "りんご"]
fruits << "パイナップル"
#=> ["バナナ", "みかん", "りんご", "パイナップル"]

配列から要素を取り除くには -= を使います。これは左右の配列同士を見比べ、共通する要素を取り除いてくれます。

fruits = ["バナナ", "みかん", "テレビ", "りんご", "たわし"]
fruits -= ["テレビ", "たわし"]
#=> ["バナナ", "みかん", "りんご"]

配列はRubyで多用するデータ構造で、この他にもたくさんのメソッドがあらかじめ定義されています。もし詳しく知りたい人は3 章「Hello, Trema!」の参考文献で紹介したRubyのサイトや書籍を参照してください。

4.4.3. スイッチの切断を捕捉する

switch_disconnected ハンドラでは、スイッチ一覧リストから切断したスイッチのDatapath IDを削除し、切断したスイッチの情報を画面に表示します。

SwitchMonitor#switch_disconnected (lib/switch_monitor.rb)
def switch_disconnected(dpid)
  @switches -= [dpid]
  logger.info "#{dpid.to_hex} is down (All = #{all_switches_in_string})"
end

ここでは switch_ready とは逆に、配列の引き算 (-=) で切断したスイッチのDatapath IDを @switches から除いていることに注意してください。

4.4.4. スイッチ一覧を一定時間ごとに表示する

スイッチの一覧を一定時間ごとに表示するには、Tremaのタイマー機能を使います。次のように timer_event に続いて一定間隔ごとに呼び出したいメソッドと呼び出し間隔を指定しておくと、指定したメソッドが指定した間隔ごとに呼ばれます。

# 1 年に一度、年をとるクラス
class Human < Trema::Controller
  timer_event :birthday, interval: 1.year  (1)
  ...

  private  (2)

  def birthday  (3)
    @age += 1
  end
1 1 年ごとに birthday メソッドを呼ぶ
2 この行から下はプライベートメソッド
3 タイマーから呼ばれる birthday メソッド

この定義は Human クラス定義の先頭に書けるので、まるで Human クラスの属性としてタイマーをセットしているように読めます。このようにTremaを使うとタイマー処理も短く読みやすく書けます。

タイマーから呼び出すメソッドは、クラスの中だけで使うのでよくプライベートなメソッドとして定義します。Rubyでは private と書いた行以降のメソッドはプライベートメソッドとして定義され、クラスの外からは見えなくなります。

これを踏まえてスイッチ監視ツールのソースコードのタイマー部分を見てみましょう。

class SwitchMonitor < Trema::Controller
  timer_event :show_all_switches, interval: 10.sec
  ...

  private

  def show_all_switches
    logger.info "All = #{all_switches_in_string}"
  end

クラス名定義直後のタイマー定義より、10秒ごとに show_all_switches メソッドを呼んでいることがわかります。

シンボル

シンボルは文字列の軽量版と言える品詞です。:a:number:show_all_switches のように必ずコロンで始まり、英字・数字・アンダースコアを含みます。シンボルは定数のように一度決めると変更できないので、文字列のようにいつの間にか書き変わっている心配がありません。このため、ハッシュテーブル (6 章「インテリジェントなパッチパネル」参照) の検索キーとしてよく使われます。

また、シンボルは誰かにメソッドを名前で渡すときにも登場します。これだけですとわかりづらいと思うので、具体的な例を見ていきましょう。リスト switch_monitor.rb には、次のようにシンボルを使っている箇所がありました。

timer_event :show_all_switches, interval: 10.sec

この :show_all_switchesSwitchMonitor クラスのメソッド名をシンボルで書いたものです。

もしここでシンボルを使わずに、直接次のように指定するとどうなるでしょうか。

# まちがい!
timer_event show_all_switches, interval: 10.sec

これではうまく動きません。なぜならば、ソースコードの中に show_all_switches とメソッドの名前を書いた時点でそのメソッドが実行されてしまい、その返り値が timer_event へと渡されてしまうからです。

もしメソッド名を何かに渡すときにはかならずシンボルにする、と覚えましょう。

4.4.5. スイッチの詳細情報を表示する

スイッチの情報を取得するには、取得したい情報をリクエストするメッセージを send_message でスイッチに送信し、そのリプライメッセージをハンドラで受け取ります。たとえば、今回のようにスイッチの詳細情報を取得するには、DescriptionStats::Request メッセージを送信し、対応するハンドラ description_stats_reply でメッセージを受け取ります。

SwitchMonitor#switch_ready, SwitchMonitor#description_stats_reply (lib/switch_monitor.rb)
def switch_ready(dpid)
  @switches << dpid
  logger.info "#{dpid.to_hex} is up (All = #{all_switches_in_string})"
  send_message dpid, DescriptionStats::Request.new
end

def description_stats_reply(dpid, desc)
  logger.info "Switch #{dpid.to_hex} manufacturer = #{desc.manufacturer}"
  logger.info "Switch #{dpid.to_hex} hardware info = #{desc.hardware}"
  logger.info "Switch #{dpid.to_hex} software info = #{desc.software}"
  logger.info "Switch #{dpid.to_hex} serial number = #{desc.serial_number}"
  logger.info "Switch #{dpid.to_hex} description = #{desc.datapath}"
end

スイッチの詳細情報のほかにも、さまざまな統計情報を取得できます。OpenFlow 1.0がサポートしている統計情報の一覧は次のとおりです。

取得できる情報 スイッチへ送るメッセージ ハンドラ名

スイッチの詳細情報

DescriptionStats::Request

description_stats_reply

単一フローエントリの統計情報

FlowStats::Request

flow_stats_reply

複数フローエントリの統計情報

AggregateStats::Request

aggregate_stats_reply

フローテーブルの統計情報

TableStats::Request

table_stats_reply

スイッチポートの統計情報

PortStats::Request

port_stats_reply

キューの統計情報

QueueStats::Request

queue_stats_reply

4.5. まとめ

この章ではスイッチの動作状況を監視するスイッチ監視ツールを作りました。また、作ったスイッチ監視ツールをテストするため Trema の仮想ネットワーク機能を使いました。

  • スイッチの起動と切断を捕捉するには、switch_readyswitch_disconnected ハンドラメソッドを定義する

  • スイッチの詳細情報を取得するには、DescriptionStats::Request メッセージをスイッチへ送信し description_stats_reply ハンドラでリプライを受信する

  • タイマー (timer_event) を使うと一定間隔ごとに指定したメソッドを起動できる

  • trema starttrema stop コマンドで仮想ネットワーク内のスイッチを起動/停止できる

続く章では、いよいよ OpenFlow の最重要メッセージである Packet In と Flow Mod を使ったプログラミングに挑戦です。

5. マイクロベンチマークCbench

本格的なOpenFlowプログラミングの第一歩として、スイッチのフローテーブルを書き換えてみましょう。マイクロベンチマークツールCbenchを題材に、Packet InとFlow Modメッセージの使い方を学びます。

5.1. Cbenchベンチマークとは

CbenchはOpenFlow1.0コントローラのためのベンチマークです。このベンチマークの内容は、1秒あたりにコントローラが出せるFlow Modの数を計測するというものです。これはOpenFlowプロトコル全体のうちのごく一部の性能だけを対象にしているので、ベンチマークの中でもマイクロベンチマークに分類できます。

Cbenchは図 5-1のように動作します。まずcbenchプロセスはOpenFlowスイッチのふりをしてコントローラに接続し、コントローラにPacket Inを連続して送ります。コントローラはPacket Inを受け取るとcbenchプロセスにFlow Modを返します。cbenchプロセスは決められた時間の間に受け取ったFlow Modの数をカウントし、ベンチマークのスコアとします。つまり大量のPacket Inに反応し素早くFlow Modを返せるコントローラほど「速い」とみなします。

cbench overview
図 5-1: cbenchプロセスとコントローラの動作

5.2. インストール

Cbenchの実行にはopenflow.org [11] の配布するベンチマークスイートOflopsを使います。GitHubのtrema/cbenchリポジトリには、Oflops一式とCbenchコントローラのソースコードが入っています。次のコマンドでダウンロードしてください。

$ git clone https://github.com/trema/cbench.git

ダウンロードしたソースツリー上で bundle install --binstubs を実行すると、Tremaの ./bin/trema コマンドと Cbench の実行コマンド ./bin/cbench など必要な実行環境一式を自動的にインストールできます。

$ cd cbench
$ bundle install --binstubs

以上でCbenchとTremaのセットアップは完了です。

5.3. 実行してみよう

さっそくCbenchを実行してみましょう。まず、コントローラを次のように起動します。

$ ./bin/trema run ./lib/cbench.rb

そして別ターミナルを開き、次のコマンドでcbenchプロセスを実行しベンチマークを開始します[12]

$ ./bin/cbench --port 6653 --switches 1 --loops 10 --ms-per-test 10000 --delay 1000 --throughput
cbench: controller benchmarking tool
   running in mode 'latency'
   connecting to controller at localhost:6653
   faking 1 switches :: 10 tests each; 10000 ms per test
   with 100000 unique source MACs per switch
   starting test with 1000 ms delay after features_reply
   ignoring first 1 "warmup" and last 0 "cooldown" loops
   debugging info is off
1   switches: fmods/sec:  807   total = 0.080652 per ms
1   switches: fmods/sec:  797   total = 0.079694 per ms
1   switches: fmods/sec:  799   total = 0.079730 per ms
1   switches: fmods/sec:  797   total = 0.079698 per ms
1   switches: fmods/sec:  801   total = 0.080003 per ms
1   switches: fmods/sec:  800   total = 0.079965 per ms
1   switches: fmods/sec:  802   total = 0.080159 per ms
1   switches: fmods/sec:  802   total = 0.080182 per ms
1   switches: fmods/sec:  806   total = 0.080549 per ms
1   switches: fmods/sec:  801   total = 0.080082 per ms
RESULT: 1 switches 9 tests min/max/avg/stdev = 79.69/80.55/80.01/0.26 responses/s

この例では、10秒間のベンチマークを10回実行しその結果を表示しています。fmods/sec の右側の数字が、実際に 1 秒間に打った Flow Mod の数です。実行環境によって値は変わりますが、Trema は秒間に数百回のFlow Modを打てることがわかります。

5.4. ソースコード解説

Cbenchが正しく実行できたところで、CbenchのソースコードからPacket InとFlow Modメッセージの処理方法を見ていきましょう。ファイルは lib/cbench.rb です。

lib/cbench.rb
# A simple openflow controller for benchmarking.
class Cbench < Trema::Controller
  def start(_args)
    logger.info "#{name} started."
  end

  def packet_in(datapath_id, packet_in)
    send_flow_mod_add(
      datapath_id,
      match: ExactMatch.new(packet_in),
      buffer_id: packet_in.buffer_id,
      actions: SendOutPort.new(packet_in.in_port + 1)
    )
  end
end

Cbench のソースコードを眺めると、いくつか見慣れない品詞や構文が登場していることに気付きます。この節では順にそれぞれを紹介していきますが、最初からすべてを覚える必要はありません。もし後でわからなくなったときには見直すようにしてください。

5.4.1. メソッド呼び出し

Cbench のソースコードにはいくつかのメソッド呼び出しがあります。

  • logger.info(…​) (3 章「Hello, Trema!」で解説)

  • ExactMatch.new(…​)

  • packet_in.buffer_id

  • SendOutPort.new(…​)

  • packet_in.in_port

このようにメソッドは普通、変数や定数の後にドットでつなげます。定数や変数が名詞なら、メソッドはちょうど動詞と同じです。

door.open

上の例では open がメソッドです。英語のopenは動詞なので、当然メソッドであるとも言えます。

ふつう、メソッド呼び出しによって何らかの動作をすると新しい結果が得られます。

'redrum'.reverse
#=> "murder"

この場合、文字が逆順になった新しい文字列が返ってきました。

メソッドは引数を取るものもあります。次の例は配列の各要素の間に指定した文字をはさんで連結 (join) します。

['M', 'A', 'S', 'H'].join('')
#=> "M★A★S★H"

Rubyにはこのようなメソッドが何百種類もあります。それぞれの動作は名前から大体想像できるものがほとんどです。

5.4.2. startハンドラ

Cbench#start (lib/cbench.rb)
def start(_args)
  logger.info "#{name} started."
end

前章と同じく、start ハンドラでコントローラの起動をログに書き込みます。引数は今回も使っていないので、名前を _args のようにアンダースコアで始めます。

5.4.3. packet_inハンドラ

コントローラに上がってくる未知のパケットを拾うには、Packet Inハンドラをコントローラクラスに実装します。Packet Inハンドラは次の形をしています。

def packet_in(datapath_id, packet_in)
  ...
end

packet_in ハンドラはその引数として、Packet Inを起こしたスイッチ(cbenchプロセス)の Datapath ID とPacket Inメッセージを受け取ります。

PacketIn クラス

packet_in ハンドラの2番目の引数はPacket Inメッセージオブジェクトで、PacketIn クラスのインスタンスです。この PacketIn クラスは主に次の3種類のメソッドを持っています。

  • Packet Inを起こしたパケットのデータやその長さ、およびパケットが入ってきたスイッチのポート番号などOpenFlowメッセージ固有の情報を返すメソッド

  • Packet Inを起こしたパケットの種別 (TCPかUDPか。VLANタグがついているかどうか、など)を判定するための ? で終わるメソッド

  • 送信元や宛先のMACアドレスやIPアドレスなど、パケットの各フィールドを調べるためのアクセサメソッド

PacketIn クラスは非常に多くのメソッドを持っており、またTremaのバージョンアップごとにその数も増え続けているためすべては紹介しきれません。そのかわり、代表的でよく使うものを表5-1に紹介します。

Table 14. PacketIn クラスのメソッド (一部)
メソッド 説明

:raw_data

パケットのデータ全体をバイナリ文字列で返す

:in_port

パケットが入ってきたスイッチのポート番号を返す

:buffered?

Packet Inを起こしたパケットがスイッチにバッファされているかどうかを返す

:buffer_id

バッファされている場合、そのバッファ領域の ID を返す

:total_length

パケットのデータ長を返す

:source_mac_address

パケットの送信元MACアドレスを返す

:destination_mac_address

パケットの宛先MACアドレスを返す

:ipv4?

パケットがIPv4である場合 true を返す

:ipv4_protocol

IPのプロトコル番号を返す

:ipv4_source_address

パケットの送信元IPアドレスを返す

:ipv4_destination_address

パケットの宛先IPアドレスを返す

:ipv4_tos

IPのToSフィールドを返す

:tcp?

パケットがTCPである場合 true を返す

:tcp_source_port

パケットのTCPの送信元ポート番号を返す

:tcp_destination_port

パケットのTCP宛先ポート番号を返す

:udp?

パケットがUDPである場合 true を返す

:udp_source_port

パケットのUDPの送信元ポート番号を返す

:udp_destination_port

パケットのUDPの宛先ポート番号を返す

:vlan?

パケットにVLANヘッダが付いている場合 true を返す

:vlan_vid

VLANのVIDを返す

:vlan_priority

VLANの優先度を返す

:ether_type

イーサタイプを返す

このようなメソッドは他にもたくさんあります。完全なメソッドのリストや詳しい情報を知りたい場合には、3 章「Hello, Trema!」で紹介した Trema ホームページを参照してください。

5.4.4. Flow Mod の送り方

Cbenchの仕様によると、コントローラからCbenchへと送るFlow Modメッセージは、次の内容にセットする必要があります。

  • マッチフィールド: Packet In メッセージのExactMatch (後述)

  • アクション: Packet In メッセージのin_portに+1したポートへ転送

  • バッファID: Packet In メッセージのバッファID

それぞれの指定方法を順に見ていきましょう。

マッチフィールド (OpenFlow 1.0)

マッチフィールドを指定するには、send_flow_mod_add の引数に match: オプションとしてマッチフィールドオブジェクト (Match.new(…​) または ExactMatch.new(…​)) を渡します。

send_flow_mod_add(
  datapath_id,
  match: Match.new(...), (1)
  ...
)
1 マッチフィールドを指定する match: オプション

マッチフィールドを作るには、Match.new に指定したい条件のオプションを渡します。たとえば、送信元 MAC アドレスが 00:50:56:c0:00:08 で VLAN ID が 3 というルールを指定したマッチフィールドを Flow Mod に指定するコードは、次のようになります。

send_flow_mod_add(
  datapath_id,
  match: Match.new(
           source_mac_address: '00:50:56:c0:00:08'
           vlan_vid: 3
         )
  ...

OpenFlow 1.0 において指定できるマッチフィールドは 12 種類です。Match.new のオプションには、以下の12種類の条件を指定できます(表5-2)。

Table 15. マッチフィールドを作る Match.new のオプション
オプション 説明

:in_port

スイッチの物理ポート番号

:source_mac_address

送信元MACアドレス

:destination_mac_address

宛先MACアドレス

:ether_type

イーサネットの種別

:source_ip_address

送信元IPアドレス

:destination_ip_address

宛先IPアドレス

:ip_protocol

IPのプロトコル種別

:tos

IPのToSフィールド

:transport_source_port

TCP/UDPの送信元ポート番号

:transport_destination_port

TCP/UDPの宛先ポート番号

:vlan_vid

VLAN IDの値

:vlan_priority

VLANのプライオリティ

2章「OpenFlow の仕様」で説明したように、OpenFlow 1.3 でマッチフィールドは 40 種類に増えました。しかし、OpenFlow 1.3 での Match オブジェクトのオプションの指定方法は、OpenFlow 1.0 の場合と変わりません。詳しくは OpenFlow 1.3 を扱う 8章「OpenFlow1.3版ラーニングスイッチ」14章「ルータ (マルチプルテーブル編)」を参照してください。

Exact Matchの作り方 (OpenFlow 1.0)

マッチフィールドの中でもすべての条件を指定したものをExact Matchと呼びます。たとえばPacket Inとしてコントローラに入ってきたパケットとマッチフィールドが定義する12個の条件がすべてまったく同じ、というのがExact Matchです。

マッチフィールドを作る構文 Match.new にこの12種類の条件すべてを渡せば、次のようにExact Matchを作れます。

def packet_in(datapath_id, packet_in)
  ...
  send_flow_mod_add(
    datapath_id,
    match: Match.new(
             in_port: packet_in.in_port,
             source_mac_address: packet_in.source_mac_address,
             destination_mac_address: packet_in.destination_mac_address,
             ...

しかし、マッチフィールドを1つ作るだけで12行も書いていたら大変です。そこで、TremaではPacket InメッセージからExact Matchを楽に書ける次のショートカットを用意しています。

def packet_out(datapath_id, packet_in)
  send_flow_mod_add(
    datapath_id,
    match: ExactMatch.new(packet_in),
    ...

たった1行で書けました! Tremaにはこのようにコードを短く書ける工夫がたくさんあります。

ExactMatch が使えるのは OpenFlow 1.0 のみです。OpenFlow 1.3 ではマッチフィールドの種類が増えたため、ExactMatch は廃止されました。

アクション (OpenFlow1.0)

アクションを指定するには、send_flow_mod_add の引数に actions: オプションとして単体のアクションまたはアクションのリストを渡します[13]

send_flow_mod_add(
  datapath_id,
  ...
  actions: アクション (1)
)

または

send_flow_mod_add(
  datapath_id,
  ...
  actions: [アクション0, アクション1, アクション2, ...] (2)
)
1 actions: オプションでアクションを 1 つ指定
2 actions: オプションにアクションを複数指定

たとえば、「VLAN ヘッダを除去しポート2番に転送」というアクションを Flow Mod に指定するコードは、次のようになります。

send_flow_mod_add(
  datapath_id,
  ...
  actions: [StripVlanHeader.new, SendOutPort.new(2)] (1)
)
1 アクションを 2 つ指定

アクションには表5-3の13種類のアクションを単体で、または組み合わせて指定できます。

Table 16. 指定できるアクション
アクション 説明

SendOutPort

指定したスイッチポートにパケットを出力する

SetEtherSourceAddress

送信元MACアドレスを指定した値に書き換える

SetEtherDestinationAddress

宛先MACアドレスを指定した値に書き換える

SetIpSourceAddress

送信元のIPアドレスを指定した値に書き換える

SetIpDstinationAddress

宛先のIPアドレスを指定した値に書き換える

SetIpTos

IPのToSフィールドを書き換える

SetTransportSourcePort

TCP/UDPの送信元ポート番号を書き換える

SetTransportDestinationPort

TCP/UDPの宛先ポート番号を書き換える

StripVlanHeader

VLANのヘッダを除去する

SetVlanVid

指定したVLAN IDをセットする、または既存のものがあれば書き換える

SetVlanPriority

指定したVLANプライオリティをセットする、または既存のものがあれば書き換える

Enqueue

指定したスイッチポートのキューにパケットを入れる

VendorAction

ベンダ定義の独自拡張アクションを実行する

まだ使っていないアクションについては、続く章で具体的な使い方を見ていきます。

send_flow_mod_add のオプション

バッファIDを指定するには、buffer_id: オプションを send_flow_mod_add の引数に指定します。たとえば次のコードは、バッファ ID に Packet Inメッセージのバッファ ID を指定する典型的な例です。

send_flow_mod_add(
  datapath_id,
  match: ...,
  actions: ...,
  buffer_id: packet_in.buffer_id (1)
)
1 Flow Mod のオプションにバッファ ID を指定

send_flow_mod_add で指定できるすべてのオプションは表5-4の通りです。

Table 17. send_flow_mod_addで指定できるオプション
オプション 説明

:match

フローエントリのマッチフィールドを指定する。本章で紹介した Match オブジェクトまたは ExactMatch オブジェクトを指定する

:actions

フローエントリのアクションを指定する。アクションには単体のアクションまたは複数のアクションを配列 (4章で解説) によって指定できる

:buffer_id

アクションが参照するパケットがバッファされている領域の ID を指定する

:idle_timeout

フローエントリが一定時間参照されなかった場合に破棄されるまでの秒数を指定する。デフォルトは0秒で、この場合フローエントリは破棄されない

:hard_timeout

フローエントリの寿命を秒数で指定する。デフォルトは0秒で、この場合フローエントリは破棄されない

:priority

フローエントリの優先度(符号なし16ビット、大きいほど優先度高)。Packet Inメッセージはこの優先度順にフローエントリのマッチフィールドと照らし合わされる。デフォルトは 0xffff (最高優先度)

:send_flow_removed

タイムアウトでフローエントリが消えるときに、Flow Removedメッセージをコントローラに送るかどうかを指定する。デフォルトは true

:check_overlap

true にセットすると、フローテーブルの中に同じ優先度で競合するものがあった場合、フローエントリを追加せずにエラーを起こす。デフォルトは false

:emerg

true にセットすると、フローエントリを緊急エントリとして追加する。緊急エントリはスイッチが何らかの障害でコントローラと切断したときにのみ有効となる。デフォルトは false

:cookie

任意の用途に使える64ビットの整数。使い道としては、同じクッキー値を持つフローエントリ同士をまとめて管理するなどといった用途がある

こうしたオプションも、続くいくつかの章で具体的な使い方を紹介します。

OpenFlow1.0 では2章「OpenFlow の仕様」で解説したインストラクションは使いません。そのためインストラクションの代わりに、アクションを直接フローエントリに指定します。OpenFlow 1.3 でのインストラクションの使い方は、8章「OpenFlow1.3版ラーニングスイッチ」にて詳しく説明します。

5.5. マルチスレッド化する

Tremaはシングルスレッドで動作するので、実のところ Cbench の結果はあまり速くありません。シングルスレッドとはつまり、同時に1つの packet_in ハンドラしか実行できないという意味です。たとえ cbench プロセスがたくさん Packet In メッセージを送ってきても、Trema は順に 1 つひとつ処理するため遅くなります。

Trema がシングルスレッドである理由は、マルチスレッドプログラミング由来のやっかいなバグを避けるためです。たとえば次のようなマルチスレッドで動作する multi_threaded_packet_in というハンドラがあったとして、この中でスレッドセーフでない変数の内容を変更すると、タイミングや環境に起因するやっかいなバグが発生してしまいます。

def start(_args)
  @db = DB.new  (1)
end

# マルチスレッド版 packet_in ハンドラ
def multi_threaded_packet_in(datapath_id, packet_in)  (2)
  # !!! ここで @db の読み書きは危険 !!!
  return if @db.lookup(packet_in.in_port)
  @db.add packet_in.source_mac_address, packet_in.in_port
end
1 スレッドセーフでないインスタンス変数
2 独立したスレッドで動く Packet In ハンドラ

マルチスレッドプログラミングでは、スレッド間で共有するリソースに競合が起こらないように、注意深くコードを書く必要があるのです。

5.5.1. 排他制御

スレッド間の競合を解決する代表的な方法が Mutex による排他制御です。スレッド間で競合の起こる箇所を Mutex で囲むことで、その箇所へは同時に 1 つのスレッドしか入れなくなります。

def start(_args)
  @db = DB.new
  @mutex = Mutex.new  (1)
end

def multi_threaded_packet_in(datapath_id, packet_in)
  @mutex.synchronize do  (2)
    # この中で@dbを読み書きすれば安全
    return if @db.lookup(packet_in.in_port)
    @db.add packet_in.source_mac_address, packet_in.in_port
  end
end
1 排他制御用の Mutex
2 do…​end の中には同時に 1 つのスレッドしか入れない

これでひとまず競合は回避できたので、packet_in をスレッド化してみましょう。次のように高速化したいハンドラメソッドの中身を別スレッドで起動し、インスタンス変数へのアクセスを Mutex で排他制御してやります。

def start(_args)
  @db = DB.new
  @mutex = Mutex.new
end

def packet_in(datapath_id, packet_in)
  Thread.start do  (1)
    @mutex.synchronize do
      return if @db.lookup(packet_in.in_port)
      @db.add packet_in.source_mac_address, packet_in.in_port
    end
  end
end
1 packet_in ハンドラの中身をスレッドで起動

しかし、これでもまだ問題は残ります。Thread.start によるスレッド起動はそれなりにコストのかかる処理なので、Packet In が届くたびに Tread.new でスレッドを作っていては速くなりません。

そこで、次のようにあらかじめいくつかワーカースレッドを作って待機させておく、いわゆるスレッドプールという手法が使えます。そして packet_in ハンドラが呼ばれるたびに待機中のスレッドに packet_in の処理をまかせます。

def start(_args)
  @db = DB.new
  @mutex = Mutex.new
  @work_queue = Queue.new  (1)
  10.times { start_worker_thread }  (2)
end

def packet_in(datapath_id, packet_in)
  @work_queue.push [datapath_id, packet_in]  (3)
end

private

# ワーカースレッドを開始
def start_worker_thread  (4)
  Thread.new do
    loop do
      datapath_id, packet_in = @work_queue.pop  (5)
      @mutex.synchronize do
        next if @db.lookup(packet_in.in_port)
        @db.add packet_in.source_mac_address, packet_in.in_port
      end
    end
  end
end
1 スレッドにまかせたい仕事を入れておくキュー
2 スレッドプールに 10 個のスレッドを追加
3 Packet In が届いたら datapath_id と Packet In をキューに入れる
4 ワーカースレッドを起動するプライベートメソッド
5 スレッドの中でキューから datapath_id と Packet In を取り出す。Queue クラスはスレッドセーフなので、@mutex.synchronize do …​ end の中に入れる必要はないことに注意

このスレッドプールを使った、最終的なマルチスレッド版 Cbench コントローラは次のようになります。

lib/multi_threaded_cbench.rb
# A simple openflow controller for benchmarking (multi-threaded version).
class MultiThreadedCbench < Trema::Controller
  def start(_args)
    @work_queue = Queue.new
    10.times { start_worker_thread }
    logger.info 'Cbench started.'
  end

  def packet_in(datapath_id, packet_in)
    @work_queue.push [datapath_id, packet_in]
  end

  private

  def start_worker_thread
    Thread.new do
      loop do
        datapath_id, packet_in = @work_queue.pop
        send_flow_mod_add(datapath_id,
                          match: ExactMatch.new(packet_in),
                          buffer_id: packet_in.buffer_id,
                          actions: SendOutPort.new(packet_in.in_port + 1))
      end
    end
  end
end

実際に性能を計測してみましょう。

$ ./bin/trema run lib/multi_threaded_cbench.rb

別ターミナルで Cbench を起動します。

$ ./bin/cbench --port 6653 --switches 1 --loops 10 --ms-per-test 10000 --delay 1000 --throughput
cbench: controller benchmarking tool
   running in mode 'throughput'
   connecting to controller at localhost:6653
   faking 1 switches :: 10 tests each; 10000 ms per test
   with 100000 unique source MACs per switch
   starting test with 1000 ms delay after features_reply
   ignoring first 1 "warmup" and last 0 "cooldown" loops
   debugging info is off
1   switches: fmods/sec:  748   total = 0.074746 per ms
1   switches: fmods/sec:  714   total = 0.071319 per ms
1   switches: fmods/sec:  705   total = 0.070448 per ms
1   switches: fmods/sec:  704   total = 0.070376 per ms
1   switches: fmods/sec:  718   total = 0.071747 per ms
1   switches: fmods/sec:  734   total = 0.073346 per ms
1   switches: fmods/sec:  739   total = 0.073763 per ms
1   switches: fmods/sec:  736   total = 0.073487 per ms
1   switches: fmods/sec:  732   total = 0.073146 per ms
1   switches: fmods/sec:  730   total = 0.072917 per ms
RESULT: 1 switches 9 tests min/max/avg/stdev = 70.38/73.76/72.28/1.25 responses/s

おや?シングルスレッド版よりも若干遅くなってしまいました。これには 2 つの原因があります。まず、Ruby のスレッドは OS のネイティブスレッドであるため、スレッド切り替えのオーバーヘッドが大きくかかります。しかも、Packet In 処理は一瞬で終わるため、マルチスレッド化しても並列性はあまり上がりません。これらの原因から、マルスレッド化によって新たにスレッド切り替えのオーバーヘッドがかかった分、元のバージョンより遅くなってしまったのです。

5.6. 無理やり高速化する

ほかに高速化の方法はないでしょうか。実は cbench プロセスが送ってくる Packet In はすべて同じ中身なので、cbench プロセスへ送る Flow Mod メッセージを何度も使いまわすことで簡単に高速化できます。最初のコードでは Packet In ハンドラの中で send_flow_mod_add で毎回 Flow Mod メッセージを作り直していました。この無駄な処理をなくすために、一度作った Flow Mod メッセージをキャッシュしておいて、2 回目以降はキャッシュした Flow Mod を send_message で送るのです。

このキャッシュによって高速化したものがこちらです。ただしこれはただ Cbench のために無理やり高速化したコードなので、すべてを理解する必要はありません。

lib/fast_cbench.rb
# A simple openflow controller for benchmarking (fast version).
class FastCbench < Trema::Controller
  def start(_args)
    logger.info "#{name} started."
  end

  def packet_in(dpid, packet_in)
    @flow_mod ||= create_flow_mod_binary(packet_in) (1)
    send_message dpid, @flow_mod (2)
  end

  private

  def create_flow_mod_binary(packet_in)
    options = {
      command: :add,
      priority: 0,
      transaction_id: 0,
      idle_timeout: 0,
      hard_timeout: 0,
      buffer_id: packet_in.buffer_id,
      match: ExactMatch.new(packet_in),
      actions: SendOutPort.new(packet_in.in_port + 1)
    }
    FlowMod.new(options).to_binary.tap do |flow_mod| (3)
      def flow_mod.to_binary
        self
      end
    end
  end
end
1 最初は @flow_modnil なので、最初だけ create_flow_mod_binary で Flow Mod メッセージを作る。二回目以降は呼ばれない
2 キャッシュした Flow Mod メッセージを cbench プロセスに送る
3 send_message のときに Trema が呼び出す FlowMod#to_binary を軽くするため、キャッシュしたバイナリを返す特異メソッドを定義

実行結果は次のようになります。秒間約 6000 発の Flow Mod が打てており、元のバージョンに比べて 10 倍近く高速化できました!

cbench: controller benchmarking tool
   running in mode 'throughput'
   connecting to controller at localhost:6653
   faking 1 switches :: 10 tests each; 10000 ms per test
   with 100000 unique source MACs per switch
   starting test with 1000 ms delay after features_reply
   ignoring first 1 "warmup" and last 0 "cooldown" loops
   debugging info is off
1   switches: fmods/sec:  6741   total = 0.674018 per ms
1   switches: fmods/sec:  6400   total = 0.639859 per ms
1   switches: fmods/sec:  6508   total = 0.650710 per ms
1   switches: fmods/sec:  6334   total = 0.633349 per ms
1   switches: fmods/sec:  6325   total = 0.632465 per ms
1   switches: fmods/sec:  6293   total = 0.629207 per ms
1   switches: fmods/sec:  6276   total = 0.627579 per ms
1   switches: fmods/sec:  6332   total = 0.633133 per ms
1   switches: fmods/sec:  6219   total = 0.621860 per ms
1   switches: fmods/sec:  6293   total = 0.629266 per ms
RESULT: 1 switches 9 tests min/max/avg/stdev = 621.86/650.71/633.05/7.77 responses/s
Cbenchの注意点

Cbench のスコアを盲信しないようにしてください。現在、OpenFlow コントローラの多くがその性能指標として Cbench のスコアを使っているのをよく見掛けます。たとえば Floodlight (http://www.projectfloodlight.org/) は 1 秒間に 100 万発の Flow Mod を打てると宣伝しています。実際にこれはなかなかすごい数字です。スレッドを駆使してめいっぱい I/O を使い切るようにしなければなかなかこの数字は出ません。でも、この数字はほとんど無意味です。

Flow Mod を 1 秒間に 100 万発打たなければならない状況を考えてみてください。それは、Packet In が 1 秒間に 100 万回起こるということになります。Packet In が 1 秒間に 100 万発起こるとはどういうことでしょうか。スイッチで処理できないパケットがすべてコントローラへやってくる、これが 1 秒間に 100 万回も起こるということです。明らかにフローテーブルの設計がうまく行っていません。

コントローラが Packet In を何発さばけるかという性能は、極端に遅くない限りは重要ではありません。データセンターのように、どこにどんなマシンがありどういう通信をするか把握できている場合は、フローテーブルをちゃんと設計していれば Packet In はそんなに起こらないからです。力技で Packet In をさばくよりも、いかに Packet In が起こらないフローテーブル設計をするかの方がずっと大事です。

Cbench のようなマイクロベンチマークでは、測定対象が何でその結果にはどんな意味があるかを理解しないと、針小棒大な結論を招きます。Cbench のスコアは参考程度にとどめましょう。

5.7. まとめ

Packet InとFlow Modの最初の一歩として、ベンチマークツールCbenchと接続できるコントローラを書きました。

  • フローエントリを追加するための send_flow_mod_add を使って、スイッチのフローテーブルを書き換える方法を学んだ

  • マッチフィールドの作り方と、指定できるルールを学んだ

  • SendOutPort アクションによるパケットの転送と、その他のアクションを学んだ

  • コントローラをマルチスレッド化する方法を学んだ

OpenFlow プログラミングの基礎はできたので、そろそろ実用的なツールを作ってみましょう。続く章では、遠隔操作可能なソフトウェアパッチパネルを作ります。もう、ネットワークケーブルを挿し替えるためだけにサーバルームまで出向く必要はなくなります。

6. インテリジェントなパッチパネル

日々のネットワーク管理に役立ち、さらにネットワーク仮想化の入門にもなるのがこのOpenFlowで作るパッチパネルです。そのうえソースコードも簡単とくれば、試さない手はありません。

cables

6.1. 便利なインテリジェント・パッチパネル

無計画にネットワークを構築すると、ケーブルの配線は悲惨なまでにごちゃごちゃになります。からみあったケーブルのせいで見通しが悪くなり、そのままさらにスイッチやサーバを増築していくと配線のやり直しとなります。こうなってしまう一番の原因は、スイッチとスイッチ、スイッチとサーバをケーブルで直接つないでしまうことです。これでは、つなぐものを増やせば増やすほどごちゃごちゃになっていくのは当然です。

これを解消するのがパッチパネルという装置です (図 6-1)。パッチパネルの仕組みはシンプルで、ケーブルを挿すためのコネクタがずらりと並んでいて、配線をいったんパッチパネルで中継できるようになっています。スイッチやサーバをいったん中継点となるパッチパネルにつなぎ、パッチパネル上の変更だけで全体の配線を自由に変更できるので、ケーブルがすっきりし拡張性も向上します。

patch panel
図 6-1: ごちゃごちゃした配線をパッチパネルですっきりと

パッチパネルをさらに便利にしたのが、いわゆるインテリジェント・パッチパネルです。インテリジェント・パッチパネルとは、パッチパネルをネットワーク経由で操作できるようにしたものです。従来のパッチパネルでは、メンテナンス性は向上できるとしても、配線を変更するたびにサーバ室まで足を運ぶという面倒さがありました。インテリジェント・パッチパネルを使えば、居室にいながらリモートでパッチパネルの配線を変更できるようになります。

6.2. OpenFlow版インテリジェント・パッチパネル

インテリジェント・パッチパネルはOpenFlowで簡単に実装できます。パッチパネルでの中継のように、パケットを指定したコネクタからコネクタへ転送するというのは、フローエントリの代表的な使い方の一つだからです。

OpenFlowで実装したパッチパネルは図 6-2 のようになります。OpenFlowスイッチをパッチパネルに見立てて、接続を中継したいデバイス(ホストまたはスイッチ)をつなげます。コントローラはパケット転送のルールをフローエントリとしてOpenFlowスイッチに書き込むことで、仮想的なパッチを作ります。

openflow patch panel
図 6-2: OpenFlowで実現するパッチパネルの仕組み

たとえば図 6-2 のように、ポート1番と5番をつなげる場合を考えましょう。必要なフローエントリは次の2つです。

  • ポート1番に入力したパケットをポート5番に出力する

  • ポート5番に入力したパケットをポート1番に出力する

フローエントリの構成要素には、「こういうパケットが届いたとき」というマッチフィールドと、「こうする」というアクションがあるのでした。パッチパネルの場合、「ポートx番に入力」がマッチフィールドで、「ポートy番に出力」がアクションです。

それでは仕組みがわかったところで、パッチパネルコントローラを動かしてみましょう。

6.3. 実行してみよう

パッチパネルのソースコードはGitHubのtrema/patch_panelリポジトリ (https://github.com/trema/patch_panel) からダウンロードできます。

$ git clone https://github.com/trema/patch_panel.git

ダウンロードしたソースツリー上で bundle install --binstubs を実行すると、Tremaなどの実行環境一式を自動的にインストールできます。

$ cd patch_panel
$ bundle install --binstubs

以上でパッチパネルとTremaのセットアップは完了です。

パッチパネルのソースコードで主なファイルは次の 3 つです。

  • lib/patch_panel.rb: パッチパネル本体

  • patch_panel.conf: 仮想ネットワーク設定ファイル

  • bin/patch_panel: パッチパネルの操作コマンド

6.3.1. 仮想ネットワークの設定

仮想ネットワーク設定ファイル patch_panel.conf では、パッチパネルの動作テストのためにパケットを送受信できる仮想ホストを定義しています。vhost で始まる行が仮想ホスト、そして link で始まる行がスイッチやホストをつなげるための仮想リンクです。

patch_panel.conf
vswitch('patch_panel') { datapath_id 0xabc }

vhost ('host1') { ip '192.168.0.1' }
vhost ('host2') { ip '192.168.0.2' }
vhost ('host3') { ip '192.168.0.3' }

link 'patch_panel', 'host1'
link 'patch_panel', 'host2'
link 'patch_panel', 'host3'

この設定ファイルでは仮想スイッチ 0xabc に 3 つの仮想ホスト host1, host2, host3 を接続しています (図 6-3)。仮想スイッチと仮想ホストの接続は、仮想リンク (link で始まる行) によって記述できます。link を書いた順で、それぞれのホストはスイッチのポート 1 番、ポート 2 番、ポート 3 番、…​ に接続されます。

configuration
図 6-3設定ファイル patch_panel.conf の仮想ネットワーク構成

パッチパネルをこの仮想ネットワーク内で実行するには、仮想ネットワーク設定ファイルを trema run-c オプションに渡します。次のように trema run コマンドでパッチパネルコントローラを起動してください。

$ ./bin/trema run ./lib/patch_panel.rb -c patch_panel.conf

パッチパネルは起動しただけではまだパッチングされていないので、ホスト間でのパケットは通りません。これを確認するために、trema send_packets コマンドを使ってhost1とhost2の間でテストパケットを送ってみましょう。

$ ./bin/trema send_packets --source host1 --dest host2
$ ./bin/trema send_packets --source host2 --dest host1

正常に動いていれば、それぞれのホストでの受信パケット数は0になっているはずです。これを確認できるのが trema show_stats コマンドです。

$ ./bin/trema show_stats host1
Packets sent:
  192.168.0.1 -> 192.168.0.2 = 1 packet
$ ./bin/trema show_stats host2
Packets sent:
  192.168.0.2 -> 192.168.0.1 = 1 packet

trema show_stats コマンドは引数として渡したホストの送受信パケットを表示します。host1 と host2 の両ホストともパケットを 1 つ送信していますが、どちらにもパケットは届いていません。

パッチパネルの設定は ./bin/patch_panel コマンドで指定できます。たとえば、スイッチ 0xabc のポート 1 番とポート 2 番をつなぐには次のコマンドを実行します。

$ ./bin/patch_panel create 0xabc 1 2

これで、host1 と host2 が通信できるはずです。もういちどパケットの送受信を試してみましょう。

$ ./bin/trema send_packets --source host1 --dest host2
$ ./bin/trema send_packets --source host2 --dest host1
$ ./bin/trema show_stats host1
Packets sent:
  192.168.0.1 -> 192.168.0.2 = 2 packets
Packets received:
  192.168.0.2 -> 192.168.0.1 = 1 packet
$ ./bin/trema show_stats host2
Packets sent:
  192.168.0.2 -> 192.168.0.1 = 2 packets
Packets received:
  192.168.0.1 -> 192.168.0.2 = 1 packet

たしかにパケットが届いています。パッチパネルの動作イメージがわかったところで、ソースコードを見ていきます。

6.4. ソースコード解説

パッチパネルのソースコードはlib/patch_panel.rbです。

lib/patch_panel.rb
# Software patch-panel.
class PatchPanel < Trema::Controller
  def start(_args)
    @patch = Hash.new([].freeze)
    logger.info "#{name} started."
  end

  def switch_ready(dpid)
    @patch[dpid].each do |port_a, port_b|
      delete_flow_entries dpid, port_a, port_b
      add_flow_entries dpid, port_a, port_b
    end
  end

  def create_patch(dpid, port_a, port_b)
    add_flow_entries dpid, port_a, port_b
    @patch[dpid] += [port_a, port_b].sort
  end

  def delete_patch(dpid, port_a, port_b)
    delete_flow_entries dpid, port_a, port_b
    @patch[dpid] -= [port_a, port_b].sort
  end

  private

  def add_flow_entries(dpid, port_a, port_b)
    send_flow_mod_add(dpid,
                      match: Match.new(in_port: port_a),
                      actions: SendOutPort.new(port_b))
    send_flow_mod_add(dpid,
                      match: Match.new(in_port: port_b),
                      actions: SendOutPort.new(port_a))
  end

  def delete_flow_entries(dpid, port_a, port_b)
    send_flow_mod_delete(dpid, match: Match.new(in_port: port_a))
    send_flow_mod_delete(dpid, match: Match.new(in_port: port_b))
  end
end

今までに学んだ知識で、このコードをできるだけ解読してみましょう。

  • パッチパネルの本体はPatchPanelという名前の小さなクラスである

  • このクラスには3 章「Hello, Trema!」で学んだ switch_ready ハンドラが定義してあり、この中で delete_flow_entriesadd_flow_entries いうプライベートメソッドを呼んでいる。どうやらこれがパッチ処理の本体だ

  • create_patchdelete_patch というメソッドが定義してある。これらがパッチの作成と削除に対応していると予想できる

  • add_flow_entries メソッドでは send_flow_mod_add を2回呼んでいる。1つのパッチを作るのに2つのフローエントリが必要なので、2回呼んでいるのだろうと推測できる

ここまでわかればしめたものです。あらかじめパッチパネルの仕組みを押さえていたので、ソースコードを読むのも簡単です。それでは、各部分のソースコードを詳しく見ていきましょう。

6.4.1. startハンドラ

startハンドラではコントローラを初期化します。

PatchPanel#start (lib/patch_panel.rb)
def start(_args)
  @patch = Hash.new([].freeze)
  logger.info "#{name} started."
end

@patch は現在のパッチング情報を入れておくハッシュテーブル (後述) です。このハッシュテーブルは、キーにスイッチの Datapath ID、バリューに現在のパッチ情報を持ちます。たとえば、スイッチ 0x1 のポート 1 番と 4 番をパッチングし、スイッチ 0x2 のポート 1 番と 2 番、および 3 番と 4 番をパッチングした場合、@patch の中身は次のようになります。

Datapath ID (キー)

パッチ情報 (バリュー)

0x1

[[1, 4]]

0x2

[[1, 2], [3, 4]]

Rubyのイディオム Hash.new([].freeze)

Hash.new の引数 (バリューの初期値) である [].freeze はハッシュテーブルの初期値が変わらないようにするための Ruby のイディオムです。もしも、.freeze していない初期値 [] に対して << などの破壊的な操作をすると、次のように初期値が壊れてしまいます。

hash = Hash.new([])

p hash[1]          #=> []
p hash[1] << "bar" #=> ["bar"]
p hash[1]          #=> ["bar"]

p hash[2]          #=> ["bar"] #初期値が ["bar"] になってしまった

そこで、初期値を .freeze することで破壊的操作を禁止できます。

hash = Hash.new([].freeze)
hash[0] += [0] #破壊的でないメソッドはOK
hash[1] << 1
# エラー `<<': can't modify frozen array (TypeError)

6.4.2. ハッシュテーブル

ハッシュテーブルは中カッコで囲まれた ({}) 辞書です。辞書とは「言葉をその定義に対応させたデータベース」です。Rubyでは、この対応を : という記号で次のように表します。

animals = { armadillo: 'アルマジロ', boar: 'イノシシ' }

たとえば ”boar” を日本語で言うと何だろう? と辞書で調べたくなったら、次のようにして辞書を引きます。

animals[:boar] #=> "イノシシ"

この辞書を引くときに使う言葉 (この場合は :boar) をキーと言います。そして、見つかった定義 (この場合はイノシシ) をバリューと言います。

新しい動物を辞書に加えるのも簡単です。

animals[:cow] = 'ウシ'

Rubyのハッシュテーブルはとても高機能なので、文字列だけでなく好きなオブジェクトを格納できます。たとえば、パッチパネルでは Datapath ID をキーとして、パッチング情報 (配列) をバリューにします。

@patch[0x1] = [[1, 2], [3, 4]]

実は、すでにいろんなところでハッシュテーブルを使ってきました。たとえば、send_flow_mod_add などの省略可能なオプションは、コロン (:) を使っていることからもわかるように実はハッシュテーブルなのです。Rubyでは、引数の最後がハッシュテーブルである場合、その中カッコを次のように省略できます。

def flow_mod(message, port_no)
  send_flow_mod_add(
    message.datapath_id,
    match: ExactMatch.new(message),
    actions: SendOutPort.new(port_no)
  )
end

# これと同じ

def flow_mod(message, port_no)
  send_flow_mod_add(
    message.datapath_id,
    { match: ExactMatch.new(message),
      actions: SendOutPort.new(port_no) }
  )
end

6.4.3. switch_readyハンドラ

switch_ready ハンドラは、起動してきたスイッチに対してパッチング用のフローエントリを書き込みます。すでにパッチ情報 @patch にフローエントリ情報が入っていた場合(スイッチがいったん停止して再接続した場合など)のみ、フローエントリを入れ直します。

PatchPanel#switch_ready (lib/patch_panel.rb)
def switch_ready(dpid)
  @patch[dpid].each do |port_a, port_b| (1)
    delete_flow_entries dpid, port_a, port_b (2)
    add_flow_entries dpid, port_a, port_b (3)
  end
end
1 @patch[dpid].each はパッチング設定を 1 つずつ処理するイテレータ (後述)。仮引数は port_aport_b の2つで、それぞれにパッチでつなぐポート番号が 1 つずつ入る
2 プライベートメソッド delete_flow_entries は古いフローエントリを消す。
3 プライベートメソッド add_flow_entries がパッチング追加処理の本体。起動してきたスイッチのDatapath ID、およびパッチングするポート番号2つを引数に取る

6.4.4. イテレータ

イテレータとは繰り返すものという意味で、繰り返し処理を短く書きたいときに使います。イテレータは一般に次の形をしています。

よくあるイテレータの使用例
fruits = ["バナナ", "みかん", "りんご"]

fruits.each do |each|
  puts each
end

実行結果:
バナナ
みかん
りんご

ここでは配列 fruitseach というイテレータで fruits の各要素をプリントアウトしています。do のあとにある each は fruits の各要素が入る仮引数です。この each にバナナ・みかん・りんごが順にセットされ、続くブロック (do…​end) が呼び出されます。

イテレータの利点は、同じ繰り返し処理をループで書いた場合よりもずっと簡単に書けることです。

C 言語っぽい for ループで書いた場合
for (int i = 0; i < 3; i++) {
  puts fruits[i];
}

このように for ループで書くと、配列の要素にアクセスするための変数 i やループの終了条件 i < 3 などが必要になります。一方、イテレータはこうした煩雑なものを抽象化で見えなくしてくれるので、プログラマは各要素についてやりたいことだけをブロックに書けば動きます。

実は、5 章「マイクロベンチマークCbench」で登場した次のコードもイテレータです。

# start_worker_thread メソッドを 10 回実行
10.times { start_worker_thread }

# または、下のようにも書ける

# n には 1〜10 が入る。ただしここでは n は使っていない
10.times { |n| start_worker_thread }

この times がイテレータで、続くブロックの内容を 10 回実行します。もし仮引数を使わない場合は書かなくても OK です。これによって、「10.times { start_worker_thread } (10 回 start_worker_thread を呼ぶ)」といったふうに繰り返しを非常に直感的に書けます。

6.4.5. add_flow_entriesメソッド

1つのパッチ(2つのフローエントリ)を実際に書き込むのが add_flow_entries プライベートメソッドです。

PatchPanel#add_flow_entries (lib/patch_panel.rb)
def add_flow_entries(dpid, port_a, port_b)
  send_flow_mod_add(dpid,
                    match: Match.new(in_port: port_a),
                    actions: SendOutPort.new(port_b))
  send_flow_mod_add(dpid,
                    match: Match.new(in_port: port_b),
                    actions: SendOutPort.new(port_a))
end

add_flow_entries の中で2回呼び出している send_flow_mod_add のうち、最初の呼び出し部分を詳しく見てみましょう。

PatchPanel#add_flow_entries (lib/patch_panel.rb)
send_flow_mod_add(dpid,
                  match: Match.new(in_port: port_a),
                  actions: SendOutPort.new(port_b))

ここでは、ポート port_a 番へ上がってきたパケットをポート port_b 番へ出力するフローエントリを書き込んでいます。ここでは次の2つのオプションを指定しています。

  • match: 「入力ポート(:in_port)が port_a であった場合」という Match オブジェクト

  • actions: 「ポート port_b 番へ出力する」という SendOutPort アクション

6.4.6. delete_flow_entriesメソッド

delete_flow_entries は古いフローエントリを消すメソッドです。add_flow_entries でフローエントリを足す前に、いったん delete_flow_entries で古いフローエントリを消すことでフローエントリが重複しないようにします。

PatchPanel#delete_flow_entries (lib/patch_panel.rb)
def delete_flow_entries(dpid, port_a, port_b)
  send_flow_mod_delete(dpid, match: Match.new(in_port: port_a))
  send_flow_mod_delete(dpid, match: Match.new(in_port: port_b))
end

ここで呼び出している send_flow_mod_deletesend_flow_mod_add とは逆のメソッドで、match: に対応するフローエントリを削除します。

6.4.7. create_patch, delete_patchメソッド

create_patchdelete_patch メソッドは、bin/patch_panel コマンドからパッチの作成と削除を行うためのAPIです。

create_patch メソッドは、add_flow_entries メソッドでフローエントリを追加し、パッチ設定 @patch にパッチ情報を追加します。

PatchPanel#create_patch (lib/patch_panel.rb)
def create_patch(dpid, port_a, port_b)
  add_flow_entries dpid, port_a, port_b
  @patch[dpid] += [port_a, port_b].sort
end

逆に delete_patch メソッドはフローエントリを削除しパッチ設定からパッチ情報を削除します。

PatchPanel#delete_patch (lib/patch_panel.rb)
def delete_patch(dpid, port_a, port_b)
  delete_flow_entries dpid, port_a, port_b
  @patch[dpid] -= [port_a, port_b].sort
end

6.4.8. bin/patch_panel コマンド

PatchPanel クラスの操作コマンドが bin/patch_panel です。PatchPanel クラスの create_patchdelete_patch メソッドを呼び出します。patch_panel createpatch_panel delete という 2 つのサブコマンドを持っています。

サブコマンドの実装には gli (https://github.com/davetron5000/gli) というRubyライブラリを使っています。gli を使うと、patch_panel createpatch_panel delete といったサブコマンド体系、いわゆるコマンドスイートを簡単に実装できます。詳細は gli のドキュメントにゆずりますが、書きかたを簡単に紹介しておきます。

bin/patch_panel
#!/usr/bin/env ruby

require 'rubygems'
require 'bundler'
Bundler.setup :default

require 'gli'
require 'trema'

# patch_panel command
module PatchPanelApp
  extend GLI::App

  desc 'Creates a new patch' (1)
  arg_name 'dpid port#1 port#2'
  command :create do |c|
    c.desc 'Location to find socket files'
    c.flag [:S, :socket_dir], default_value: Trema::DEFAULT_SOCKET_DIR

    c.action do |_global_options, options, args|
      dpid = args[0].hex
      port1 = args[1].to_i
      port2 = args[2].to_i
      Trema.trema_process('PatchPanel', options[:socket_dir]).controller.
        create_patch(dpid, port1, port2)
    end
  end

  desc 'Deletes a patch' (2)
  arg_name 'dpid port#1 port#2'
  command :delete do |c|
    c.desc 'Location to find socket files'
    c.flag [:S, :socket_dir], default_value: Trema::DEFAULT_SOCKET_DIR

    c.action do |_global_options, options, args|
      dpid = args[0].hex
      port1 = args[1].to_i
      port2 = args[2].to_i
      Trema.trema_process('PatchPanel', options[:socket_dir]).controller.
        delete_patch(dpid, port1, port2)
    end
  end

  exit run(ARGV)
end
1 create サブコマンドの実装
2 delete サブコマンドの実装

gli を使ったサブコマンドの実装は、command サブコマンド名 do …​ end のブロックを記述するだけです。それぞれのブロック内で、サブコマンドに渡されたオプションの処理と実際の動作を記述します。

create サブコマンドの実装
desc 'Creates a new patch' (1)
arg_name 'dpid port#1 port#2' (2)
command :create do |c|
  c.desc 'Location to find socket files' (3)
  c.flag [:S, :socket_dir], default_value: Trema::DEFAULT_SOCKET_DIR (4)

  c.action do |_global_options, options, args|
    dpid = args[0].hex (5)
    port1 = args[1].to_i (5)
    port2 = args[2].to_i (5)
    Trema.trema_process('PatchPanel', options[:socket_dir]).controller.
      create_patch(dpid, port1, port2) (6)
  end
end
1 create サブコマンドの説明
2 オプションの説明
3 -S (--socket_dir) オプションの説明
4 -S (--socket_dir) オプションとデフォルト値の定義
5 オプションのパース
6 PatchPanel クラスの create_patch メソッドの呼び出し

ポイントは、サブコマンド定義内での PatchPanel クラスのメソッド呼び出し部分です。

Trema.trema_process('PatchPanel', options[:socket_dir]).controller.create_patch(dpid, port1, port2)

この Trema.trema_process.controller メソッドは、現在動いているコントローラオブジェクト(PatchPanel クラスオブジェクト)を返します。そしてその返り値に対して create_patch などのメソッドを呼び出すことで、コントローラのメソッドを呼び出せます。

6.5. まとめ

フローを使ってパケットを転送する方法の入門編として、OpenFlowで実現するインテリジェント・パッチパネルを書きました。

  • 仮想スイッチに仮想ホストを接続してテストパケットを送信する方法を学んだ

  • フローエントリの削除方法を学んだ

  • コントローラ操作用の外部コマンドの書き方を学んだ

実は、今回作ったOpenFlow版パッチパネルはSDNの一種です。なぜならば、OpenFlow版パッチパネルを使えばホストの所属するネットワークをソフトウェア的に切り替えられるからです。これは、物理ネットワークの上にそれぞれ独立したネットワークをいくつも作れるという意味で、もっとも単純なネットワーク仮想化に他なりません。

続く章では、これまで使ってきた 3 つの重要な OpenFlow メッセージである Flow Mod, Packet In, Packet Out を組み合わせてイーサネットスイッチ作りに挑戦です。

7. すべての基本、ラーニングスイッチ

データセンターのような複雑に入り組んだネットワークも、もしケーブルを抜いてバラバラにできたなら、スイッチやサーバなどの意外とシンプルな部品に分解できます。

learn

7.1. ラーニングスイッチとは何か

OpenFlowの世界では、コントローラとしてソフトウェア実装したイーサネットスイッチをよくラーニングスイッチと呼びます。なぜ、ラーニング(学習)スイッチと呼ぶのでしょうか。それは、イーサネットスイッチが次のように動くからです。

  • 学習:ホストから出たパケットに含まれる、ネットワーク上でのホストの位置情報を学習する

  • 転送:今まで学習してきた位置情報を使って、パケットを宛先のホストまで転送する

この「学習し、転送する」というラーニングスイッチの仕組みは応用が広く効きます。たとえば後半で紹介するいくつかのデータセンターネットワークも、基本はラーニングスイッチと同じく「学習し、転送する」という動作をします。自宅ネットワークであろうが最新鋭のデータセンターであろうが、基本的な考え方は同じなのです。

ラーニングスイッチを作れるようになれば、それをベースに改造することでいろいろなアプリケーションを作れるようになります。

ではさっそく、ネットワークの基本部品であるラーニングスイッチをTremaで実装してみましょう。まずは一般的なイーサネットスイッチの動作原理を理解し、次にOpenFlowでの実現方法を見ていきます。

7.2. イーサネットスイッチの仕組み

簡単なネットワークを例にしてイーサネットスイッチの動作を説明します(図 7-1)。

switch network
図 7-1: イーサネットスイッチ1台とホスト2台からなるネットワークとFDBの内容

イーサネットスイッチのポート1番と5番に、ホスト1と2をそれぞれ接続しています。また、それぞれのホストのネットワークカードは図に示したMACアドレスを持つとします。

イーサネットスイッチはホストから届いたパケットを宛先のホストまで転送するために、イーサネットスイッチにつながる各ホストの位置情報をためておくデータベースを持っています。これをフォワーディングデータベース(FDB)と呼び、FDBは「ホストのMACアドレス」+「ポート番号」の組を保持します。

ここでホスト2がホスト1へパケットを送信すると、イーサネットスイッチは図 7-2のようにパケットをホスト1まで転送します。

  1. 届いたパケットの宛先MACアドレス(00:11:11:11:11:11)を見て、このMACアドレスを持つホストがつながるポート番号をFDBから探す

  2. FDBには「MACアドレス00:11:11:11:11:11=ポート1」と学習しているので、ポート1にパケットを出力する

host2to1
図 7-2: FDBの情報からパケットをホスト1に届ける

ここまでの仕組みがわかれば、イーサネットスイッチの機能を実現するコントローラ (ラーニングスイッチ) をOpenFlowで実現するのは簡単です。

7.3. OpenFlow版イーサネットスイッチ(ラーニングスイッチ)の仕組み

OpenFlowによるイーサネットスイッチの構成は図 7-3のようになります。一般的なイーサネットスイッチとの違いは次の2つです。

  • FDBをソフトウェアとして実装し、コントローラがFDBを管理する

  • パケットの転送は、コントローラがフローテーブルにフローエントリを書き込むことで制御する

switch network openflow
図 7-3: OpenFlowによるイーサネットスイッチ(ラーニングスイッチ)の構成

なお、初期状態でのFDBとフローテーブルの中身はどちらも空です。

7.3.1. Packet Inからホストの位置情報を学習

この状態でホスト1がホスト2へパケットを送信すると、コントローラは次のようにホスト1のネットワーク上での位置情報を学習します(図 7-4)。

  1. フローテーブルはまだ空なので、パケットはPacket Inとしてコントローラまで上がる

  2. コントローラはPacket Inメッセージからパケットの送信元MACアドレスとパケットの入ってきたポート番号を調べ、「ポート1番にはMACアドレスが00:11:11:11:11:11のホストがつながっている」とFDBに保存する

host1to2 openflow
図 7-4: Packet Inの送信元MACアドレスとスイッチのポート番号をFDBに学習する

7.3.2. Packet Outでパケットを転送(フラッディング)

学習が終わると次はパケットの転送です。もちろん、パケットの宛先はまだ学習していないので、コントローラは次のようにパケットをばらまくことで宛先まで届けます。このばらまく処理をフラッディングと呼びます(図 7-5)。

  1. コントローラはPacket Inメッセージの宛先MACアドレスを調べ、FDBから送出先のポート番号を探す。しかし、ホスト2のMACアドレスとポート番号はまだFDBに入っていないのでわからない

  2. コントローラは出力ポートをフラッディングに指定したPacket Outメッセージでパケットをばらまくようにスイッチに指示する。その結果、ポート5につながるホスト2にパケットが届く

host1to2 flood openflow
図 7-5: 出力ポートがFDBから見つからないため、出力ポートをフラッディングに指定したPacket Outメッセージでパケットをばらまく

7.3.3. 再び学習と転送(Flow ModとPacket Out)

この状態でホスト2がホスト1へパケットを送信すると次のようになります(図 7-6)。

  1. フローテーブルが空なためコントローラまで再びPacket Inメッセージが上がる

  2. コントローラはこのPacket Inメッセージから「ポート5番にはMACアドレスが00:22:22:22:22:22のホストがつながっている」とFDBに保存する

  3. Packet Inの宛先MACアドレスとFDBを照らし合わせ、出力先のポート番号を探す。すでに「ポート1=MACアドレス00:11:11:11:11:11」と学習しているので、出力ポートは1と決定できる

  4. 「ホスト2からホスト1へのパケットはポート1へ出力せよ」というフローエントリをFlow Modメッセージでフローテーブルに書き込む。加えて、Packet Outメッセージ(出力ポート = 1)でPacket Inを起こしたパケットをポート1へ出力する

host2to1 openflow
図 7-6: ホスト2のMACアドレスとポート番号をFDBに学習し、フローエントリを書き込むとともにパケットをホスト1に届ける

さて、ここまでの段階でフローテーブルには「ホスト2からホスト1へのパケットはポート1へ出力せよ」というフローエントリが入りました。もし、ホスト2がホスト1へ再びパケットを送信すると、今度はPacket Inがコントローラまで上がることはなく、スイッチ側だけでパケットを転送します。

残りのホスト1からホスト2へのフローエントリはどうでしょう。すでにFDBはすべてのホストのMACアドレスとポート番号を学習しています。もし、再びホスト1からホスト2へパケットを送信すると、図 7-6と同様にコントローラが「ホスト1からホスト2へのパケットはポート5へ出力せよ」というフローエントリを書き込みます。もちろん、それ以降の通信ではPacket Inはまったく上がらずにすべてスイッチ側だけでパケットを処理します。

7.4. 実行してみよう

今回も仮想ネットワークを使ってラーニングスイッチを起動してみます。ソースコードと仮想ネットワークの設定ファイルはGitHubのtrema/learning_switchリポジトリ (https://github.com/trema/learning_switch) からダウンロードできます。

$ git clone https://github.com/trema/learning_switch.git

ダウンロードしたソースツリー上で bundle install --binstubs を実行すると、Tremaなどの実行環境一式を自動的にインストールできます。

$ cd learning_switch
$ bundle install --binstubs

GitHubから取得したラーニングスイッチのソースリポジトリ内に、仮想スイッチ1台、仮想ホスト2台の構成を持つ設定ファイル trema.conf が入っています。

trema.conf
vswitch('lsw') {
  datapath_id 0xabc
}

vhost ('host1') {
  ip '192.168.0.1'
}

vhost ('host2') {
  ip '192.168.0.2'
}

link 'lsw', 'host1'
link 'lsw', 'host2'

次のように trema run-c オプションにこの設定ファイルを渡してラーニングスイッチを実行します。

$ ./bin/trema run ./lib/learning_switch.rb -c trema.conf

別ターミナルを開き、trema send_packets コマンドを使ってhost1とhost2の間でテストパケットを送ってみます。

$ ./bin/trema send_packets --source host1 --dest host2
$ ./bin/trema send_packets --source host2 --dest host1

trema show_stats コマンドでhost1とhost2の受信パケット数をチェックし、それぞれでパケットを受信していれば成功です。

$ ./bin/trema show_stats host1
Packets sent:
  192.168.0.1 -> 192.168.0.2 = 1 packet
Packets received:
  192.168.0.2 -> 192.168.0.1 = 1 packet
$ ./bin/trema show_stats host2
Packets sent:
  192.168.0.2 -> 192.168.0.1 = 1 packet
Packets received:
  192.168.0.1 -> 192.168.0.2 = 1 packet

ラーニングスイッチの動作イメージがわかったところで、ソースコードの解説に移りましょう。

7.5. ラーニングスイッチのソースコード

ラーニングスイッチのソースコードは lib/learning_switch.rblib/fdb.rb の 2 つからなります。まずはメインのソースコード (lib/learning_switch.rb) をざっと眺めてみましょう。 とくに、private の行よりも上のパブリックなメソッドに注目してください。

lib/learning_switch.rb
require 'fdb'

# An OpenFlow controller that emulates an ethernet switch.
class LearningSwitch < Trema::Controller
  timer_event :age_fdb, interval: 5.sec

  def start(_argv)
    @fdb = FDB.new
    logger.info "#{name} started."
  end

  def switch_ready(datapath_id)
    # Drop BPDU frames
    send_flow_mod_add(
      datapath_id,
      priority: 100,
      match: Match.new(destination_mac_address: '01:80:C2:00:00:00')
    )
  end

  def packet_in(_datapath_id, packet_in)
    @fdb.learn packet_in.source_mac, packet_in.in_port
    flow_mod_and_packet_out packet_in
  end

  def age_fdb
    @fdb.age
  end

  private

  def flow_mod_and_packet_out(packet_in)
    port_no = @fdb.lookup(packet_in.destination_mac)
    flow_mod(packet_in, port_no) if port_no
    packet_out(packet_in, port_no || :flood)
  end

  def flow_mod(packet_in, port_no)
    send_flow_mod_add(
      packet_in.datapath_id,
      match: ExactMatch.new(packet_in),
      actions: SendOutPort.new(port_no)
    )
  end

  def packet_out(packet_in, port_no)
    send_packet_out(
      packet_in.datapath_id,
      packet_in: packet_in,
      actions: SendOutPort.new(port_no)
    )
  end
end

今までの知識だけでも、このソースコードからいろいろなことがわかります。

  • ラーニングスイッチの本体は LearningSwitch という名前のクラス

  • 起動時に呼ばれる start ハンドラでFDBのインスタンス変数を作っている。FDBの実装は別ファイル lib/fdb.rb に分かれている

  • スイッチ接続時に呼ばれる swtich_ready ハンドラでは、宛先 MAC アドレスが 01:80:C2:00:00:00 のパケットを落とすフローエントリを打ち込んでいる

  • packet_in ハンドラで呼ぶ flow_mod_and_packet_out メソッドの中では、@fdb を使ってポート番号を調べたり、flow_modpacket_out メソッドでそれぞれFlow ModとPacket Outメッセージを送っている。また、先述した「パケットをばらまく(フラッディング)」処理に対応する :flood も見つかる

ラーニングスイッチの心臓部は packet_in ハンドラだけで、その中身もたった 3 行のみと単純です。ラーニングスイッチの仕組みを思い出しながら、ソースコードを詳しく読み解いていきましょう。今回のポイントとなるのは、Packet In ハンドラでの次の処理です。

  • FDBの更新とポート番号の検索

  • ポート番号が見つかった場合の、Flow ModとPacket Outの処理

  • ポート番号が見つからなかった場合の、フラッディング処理

それでは、最初にPacket Inハンドラの内容から見ていきましょう。

7.5.1. 未知のパケット(Packet In)の処理

知らないパケットがPacket Inとして入ってきたとき、ラーニングスイッチは次のようにFDBにホストの位置情報を学習し、宛先のポート番号を調べます。

  1. パケットの送信元MACアドレスとパケットが入ってきたポート番号をPacket Inメッセージから取り出し、FDB (@fdb) に保存する

  2. パケットの宛先MACアドレスとFDBから、パケットを出力するポート番号を調べる (@fdb.lookup メソッド)

LearningSwitch#packet_in, LearningSwitch#flow_mod_and_packet_out (lib/learning_switch.rb)
def packet_in(_datapath_id, packet_in)
  @fdb.learn packet_in.source_mac, packet_in.in_port
  flow_mod_and_packet_out packet_in
end

def flow_mod_and_packet_out(packet_in)
  port_no = @fdb.lookup(packet_in.destination_mac)
  flow_mod(packet_in, port_no) if port_no
  packet_out(packet_in, port_no || :flood)
end
宛先ポート番号が見つかった場合(FlowModとPacket Out)

もし宛先ポートが見つかった場合、以降は同じパケットは同様に転送せよ、というフローエントリをスイッチに書き込みます (flow_mod メソッド)。また、Packet Inを起こしたパケットも忘れずにそのポートへ出力します (packet_out メソッド)。

LearningSwitch#flow_mod_and_packet_out (lib/learning_switch.rb)
def flow_mod_and_packet_out(packet_in)
  port_no = @fdb.lookup(packet_in.destination_mac)
  flow_mod(packet_in, port_no) if port_no
  packet_out(packet_in, port_no || :flood)
end

この flow_mod メソッドと packet_out メソッドはそれぞれ send_flow_mod_add (5 章「マイクロベンチマークCbench」で紹介) および send_packet_out (Packet Outの送信) メソッドを次のように呼び出します。

LearningSwitch#flow_mod, LearningSwitch#packet_out (lib/learning_switch.rb)
def flow_mod(packet_in, port_no)
  send_flow_mod_add(
    packet_in.datapath_id,
    match: ExactMatch.new(packet_in),
    actions: SendOutPort.new(port_no)
  )
end

def packet_out(packet_in, port_no)
  send_packet_out(
    packet_in.datapath_id,
    packet_in: packet_in,
    actions: SendOutPort.new(port_no)
  )
end

7.5.2. Packet Out API

Packet OutはOpenFlowメッセージの1つで、スイッチの指定したポートからパケットを出力させるためのものです。TremaでPacket Outを送るためのメソッド send_packet_out は、次の2つの引数を取ります。

send_packet_out(datapath_id, options)

それぞれの引数の意味は次のとおりです。

datapath_id

Packet Outメッセージの届け先となるスイッチのDatapath ID

options

Packet Outメッセージの中身を決めるためのオプション。アクションによるパケットの書き換えや出力するポートをハッシュテーブルで指定する。それぞれのオプションにはデフォルト値が設定されているので、必要なオプションのみを指定すればよい

Packet Outの使い道は、Packet Inメッセージとして入ってきたパケットをそのままスイッチのポートから送り出す場合がほとんどです。この場合、パケットの送信にスイッチのバッファを使う場合と使わない場合とで呼び出し方が変わります。

スイッチのバッファを使ってPacket Outする場合

パケットのデータがスイッチのバッファに乗っていることが期待できる場合には、次のように buffer_id オプションでバッファに乗っているパケットデータのIDを指定してやることでPacket Outできます。

send_packet_out(
  datapath_id,
  buffer_id: packet_in.buffer_id,
  raw_data: packet_in.raw_data,
  actions: SendOutPort.new(port_number)
)

この場合コントローラからスイッチへのパケットデータのコピーが起こらないため、若干のスピードアップが期待できます。ただし、2 章「OpenFlow の仕様」のコラムで説明したとおり、バッファの中身は観測不能でデータがいつ消えるかもわからないため、この方法は推奨しません。

スイッチのバッファを使わずにPacketOutする場合

スイッチのバッファを使わずに Packet Out する場合、次のように raw_data オプションでパケットのデータを指定する必要があります。バッファに乗っているいないにかかわらず Packet Out できるので、若干遅くはなりますが安全です。

send_packet_out(
  datapath_id,
  raw_data: packet_in.raw_data,
  actions: SendOutPort.new(port_number)
)

これは、次のように packet_in オプションを使うことで若干短くできます (.raw_data を書かなくてよくなります)。

send_packet_out(
  datapath_id,
  packet_in: packet_in,
  actions: SendOutPort.new(port_number)
)

7.5.3. 主なオプション一覧

options に指定できる主なオプションは次のとおりです。

buffer_id

スイッチでバッファされているパケットの ID を指定する。この値を使うと、スイッチでバッファされているパケットを指定して Packet Out できるので効率が良くなる (ただし、スイッチにバッファされていない時はエラーになる)

raw_data

Packet Out するパケットの中身を指定する。もし buffer_id オプションが指定されておりスイッチにバッファされたパケットを Packet Out する場合、この値は使われない

packet_in

raw_data および in_port オプションを指定するためのショートカット。Packet In ハンドラの引数として渡される Packet In メッセージを指定する

actions

Packet Out のときに実行したいアクションの配列を指定する。アクションが 1 つの場合は配列でなくてかまわない

宛先ポート番号が見つからなかった場合 (フラッディング)

もし宛先ポートが見つからなかった場合、コントローラは Packet In したパケットをフラッディングしてばらまきます。これをやるのが flow_mod_and_packet_out メソッドで、ポート番号に予約ポート番号の :flood を指定して packet_out メソッドを呼び出します。:flood を指定した Packet Out メッセージをスイッチが受け取ると、Packet In したパケットをフラッディングします。

LearningSwitch#flow_mod_and_packet_out (lib/learning_switch.rb)
def flow_mod_and_packet_out(packet_in)
  port_no = @fdb.lookup(packet_in.destination_mac)
  flow_mod(packet_in, port_no) if port_no
  packet_out(packet_in, port_no || :flood)
end

7.5.4. FDB の実装

learning_switch.rb の一行目の require 'fdb' は、同じディレクトリ内の fdb.rb を読み込みます。require はちょうど、C の #include や Java の import みたいなものと思ってください。Ruby では、たとえば fdb.rb というファイルを読み込みたいときは、拡張子の .rb を外して require 'fdb' と書きます。読み込む対象のファイルは、lib/ ディレクトリを起点とした相対パスで書きます。たとえば lib/learning_switch/extensions.rb を読み込みたいときには require 'learning_switch/extensions' と書きます。

fdb.rb もざっと目を通しておきましょう。このファイルは FDB の機能をカプセル化する FDB クラスを提供します。

lib/fdb.rb
# A database that keeps pairs of a MAC address and a port number
class FDB
  # Forwarding database (FDB) entry.
  class Entry
    DEFAULT_AGE_MAX = 300

    attr_reader :mac
    attr_reader :port_no

    def initialize(mac, port_no, age_max = DEFAULT_AGE_MAX)
      @mac = mac
      @port_no = port_no
      @age_max = age_max
      @last_update = Time.now
    end

    def update(port_no)
      @port_no = port_no
      @last_update = Time.now
    end

    def aged_out?
      Time.now - @last_update > @age_max
    end
  end

  def initialize
    @db = {}
  end

  def lookup(mac)
    entry = @db[mac]
    entry && entry.port_no
  end

  def learn(mac, port_no)
    entry = @db[mac]
    if entry
      entry.update port_no
    else
      @db[mac] = Entry.new(mac, port_no)
    end
  end

  def age
    @db.delete_if { |_mac, entry| entry.aged_out? }
  end
end

FDB クラスは3つのメソッド lookuplearnage を持ちます。lookup メソッドを使うと MAC アドレスからポート番号を検索できます。逆に learn メソッドでは MAC アドレスとポート番号の組を学習できます。タイマで定期的に呼ばれる age メソッドでは、FDB に入っているすべてのエントリをエージングし、寿命を過ぎたもの (FDB::Entry#aged_out? で判定) を消します。

7.5.5. 不要なパケットを転送しない

switch_ready ハンドラでは宛先 MAC アドレスが 01:80:C2:00:00:00 のパケットを落とすフローエントリを打ち込んでいました。

lib/learning_switch.rb
def switch_ready(datapath_id)
  # Drop BPDU frames
  send_flow_mod_add(
    datapath_id,
    priority: 100,
    match: Match.new(destination_mac_address: '01:80:C2:00:00:00')
  )
end

コードコメントにもあるように、ここで落としているのはスパニングツリーの制御フレームである BPDU フレームです。OpenFlow ではスイッチを集中制御できるため、ループを防ぎたい場合には分散アルゴリズムの一種であるスパニングツリーは不要だからです。OpenFlow でループを防ぐ方法について詳しくは、16 章「たくさんのスイッチを制御する」で解説します。

7.6. まとめ

実用的なOpenFlowアプリケーションのベースとなるラーニングスイッチの動作と作り方を学びました。

  • コントローラは、Packet Inメッセージから送信元ホストのMACアドレスとホストのつながるスイッチポート番号をFDBに学習する

  • Packet Inの転送先がFDBからわかる場合、Flow Modで以降の転送情報をスイッチに書き込みPacketOutする

  • Packet Inの転送先がFDBからわからない場合は、入力ポート以外のすべてのポートにPacket Outでフラッディングする

続く章ではこのラーニングスイッチを OpenFlow 1.3 のマルチプルテーブル機能を使って実装します。パケットの処理内容ごとにフローテーブルを分けることで、コントローラをすっきりと設計できます。

8. OpenFlow1.3版ラーニングスイッチ

ラーニングスイッチを OpenFlow1.3 で実装し、OpenFlow1.0版のラーニングスイッチの欠点を解消します。

8.1. OpenFlow1.0版ラーニングスイッチの問題点

7章で実装したラーニングスイッチには、実は以下の問題点があります。

フローテーブルが煩雑になる

OpenFlow1.0では同時に使えるフローテーブルは 1 つという制限があります。このため、ラーニングスイッチのようにBPDUフレームなどのフィルタリング用のフローエントリとパケット転送用のフローエントリが一つのフローテーブルに混在すると、後から見たときに解読が大変です。

起動時の大量のPacketInを防げない

OpenFlow1.0ではフローエントリにマッチしないパケットはすべてPacket Inします。このため、switch_ready ハンドラでフィルタリング用のフローエントリを設定するよりも前にパケットがコントローラへ大量に到着すると、packet_in ハンドラの大量呼び出しによりコントローラがパンクしてしまいます。

8.2. マルチプルテーブル

フローテーブルは 1 つという OpenFlow1.0 の制限は、OpenFlow1.3 でなくなっています。OpenFlow1.3 では 1 つのパケットを処理を複数のフローテーブルを使って処理できます。このようなパケット処理をパイプライン処理と呼びます。ちょうどCPUの命令パイプラインのように、パケット処理を「フィルタリング」→「書き換え」→ …​ →「転送」とステージごとに進めていくイメージです。フローテーブルごとに役割を明確にできるので、プログラマから見てフローエントリを整理しやすいというメリットがあります。

pipeline
図 8-1: OpenFlow1.3でのマルチプルテーブルによるパイプライン処理

8.2.1. テーブルの移動

このパイプライン処理は、テーブル ID が 0 のテーブルから始まり GotoTable インストラクションによって次のテーブルに移動することで進みます。パイプライン処理の入口となるテーブル、つまり Packet In したときに最初に入るテーブルの ID は 0 と決まっています。現在のテーブルから次のテーブルへと処理を移行するには GotoTable インストラクションに次のテーブル ID を指定します。このとき指定するテーブル ID は、現在のテーブル ID よりも大きい必要があります。

pipeline goto
図 8-2: テーブル ID 0 から始まり GotoTable インストラクションで次のテーブルへ処理を移動

8.2.2. OpenFlow1.3 のアクション

さて「GotoTable インストラクション」という用語を今まで断りなく使ってきましたが、OpenFlow1.3 ではパケットに対する処理を「アクション」と「インストラクション」に分けて書きます。まずはアクションから説明しましょう。

アクションの1つの用途はパケットの書き換えです。書き換えアクションの種類は OpenFlow1.0 に比べて大幅に増えており、マッチフィールドで指定できるフィールドの書き換えや VLAN ヘッダの操作に加え、TTL や MPLS, IPv6 パケット等への操作が追加されています (表8-1)。

Table 18. OpenFlow 1.3 で使えるアクション一覧 (パケットのフィールド書き換え)
アクションのクラス名 説明

SetField

マッチ条件で指定できるフィールドをパケットにセットする

CopyTtlOut

2番目に外側のTTLの値を一番外側のTTLにコピーする

CopyTtlIn

一番外側のTTLの値を1つ内側のTTLにコピーする

SetMplsTtl

MPLSシムヘッダのTTLをセットする

DecrementMplsTtl

MPLSシムヘッダのTTLを1つ減らす

PushVlanHeader

新しいVLANヘッダをパケットに追加する

PopVlanHeader

一番外側のVLANヘッダをパケットから取り除く

PushMpls

新しいMPLSシムヘッダをパケットに追加する

PopMpls

一番外側のMPLSタグまたはシムヘッダをパケットから取り除く

Group

指定したグループテーブルでパケットを処理する

SetIpTtl

IPv4のTTLまたはIPv6のhop limitをセットする

DecrementIpTtl

IPv4のTTLまたはIPv6のhop limitを1つ減らす

PushPbb

新しいPBBサービスインスタンスヘッダ (I-TAG TCI) をパケットに追加する

PopPbb

一番外側のPBBサービスインスタンスヘッダ (I-TAG TCI) をパケットから取り除く

もう1つのアクションの用途はパケットの出力です。指定したポートへ出力したり、ポートに関連付けられたキューにパケットを追加するのに使います (表8-2)。

Table 19. OpenFlow 1.3 で使えるアクション一覧 (パケットの出力)
アクションのクラス名 説明

SendOutPort

指定したスイッチの (論理) ポートにパケットを出力する

SetQueue

SendOutPort で指定したポートの指定したキューにパケットを追加する

8.2.3. インストラクション

インストラクションはアクションよりも一段上の処理で、フローテーブルの移動とアクションの実行方法を記述できます。たとえば GotoTable インストラクションは、次のように Flow Mod の instructions パラメータに指定しておくことで、マッチしたパケットが到着するとそのパケット処理を指定したフローテーブルへと続けます。

GotoTable インストラクションの指定方法
# テーブル 0 番から 1 番へ GotoTable
send_flow_mod_add(
  datapath_id,
  table_id: 0,
    ...
  instructions: GotoTable.new(1)
)

インストラクションのもう1つの用途は、アクションを適用するタイミングの指定です。指定方法は次の 2 通りです。

  • Apply 指定したアクションを直ちにパケットへ適用する

  • WriteActions 指定したアクションを後で適用するために、パケットに関連付ける

Apply を使うと指定したアクションを直ちにパケットへ適用できます。これはちょうど、OpenFlow1.0 の Flow Mod で actions を指定した場合と同じ効果を持ちます。

Apply で指定したアクションをパケットへ直ちに適用
# ポート 1 番へ出力
send_flow_mod_add(
  datapath_id,
    ...
  instructions: Apply.new(SendOutPort.new(1))
)

WriteActions は指定したアクションを後でまとめて適用するために使います。GotoTable でテーブルを移動しながら、パケットに WriteActions で指定したアクションを「後で適用するアクション」に追加していきます。そして GotoTable を含まないフローエントリにパケットがマッチしたタイミングで、そのパケットの「後で適用するアクション」をまとめて適用します。

pipeline write actions
図 8-3: WriteActions でアクションを後でまとめて適用

「この後で適用するアクション」をアクションセットと呼びます。アクションセットはいわゆる集合なので、同じアクションを複数入れることはできません。WriteActions 以外にも、アクションセットを空にする Clear インストラクションがあります。ここまでのインストラクションを含めてインストラクション一覧を紹介しましょう。

Table 20. OpenFlow 1.3 で使えるインストラクション一覧
インストラクションのクラス名 説明

GotoTable

マッチしたパケットの処理を指定したテーブルに引き継ぐ

Apply

指定したアクションを実行する

WriteActions

アクションセットに指定したアクションを追加する

Clear

アクションセットを空にする

WriteMetadata

テーブル間で引き継げる 64bit のメタデータをセット

Meter

パケットを指定したメーターに適用する

8.3. OpenFlow1.3 での Packet In

OpenFlow1.3では、フローエントリにマッチしないパケットはPacket Inしません。このため OpenFlow1.0 で問題となった、フローエントリの設定前にパケットが大量に到着しうるという問題を解決できます。OpenFlow1.3でPacketInを起こすためには、アクションに SendOutPort.new(:controller) (コントローラへパケットを送り PacketIn を起こす) を指定したフローエントリを明示的に追加します。

8.4. OpenFlow1.3版ラーニングスイッチの仕組み

OpenFlow1.3版ラーニングスイッチでは、役割の異なる2つのフローテーブルを用いてイーサネットスイッチを実現します。

フィルタリングテーブル

転送しないパケットをドロップする。それ以外のパケットは転送テーブルに送る

転送テーブル

学習したMACアドレスを使ってパケットを転送する。宛先MACアドレスが見つからない場合にはフラッディングする

8.5. ソースコード解説

OpenFlow1.3版パッチパネルのソースコードはlib/learning_switch13.rbになります。

lib/learning_switch13.rb
require 'fdb'

# An OpenFlow controller that emulates an ethernet switch.
class LearningSwitch13 < Trema::Controller
  timer_event :age_fdb, interval: 5.sec

  INGRESS_FILTERING_TABLE_ID = 0
  FORWARDING_TABLE_ID = 1

  AGING_TIME = 180

  def start(_args)
    @fdb = FDB.new
    logger.info "#{name} started."
  end

  def switch_ready(datapath_id)
    add_bpdu_drop_flow_entry(datapath_id)
    add_default_broadcast_flow_entry(datapath_id)
    add_default_flooding_flow_entry(datapath_id)
    add_default_forwarding_flow_entry(datapath_id)
  end

  def packet_in(_datapath_id, packet_in)
    @fdb.learn(packet_in.source_mac, packet_in.in_port)
    add_forwarding_flow_and_packet_out(packet_in)
  end

  def age_fdb
    @fdb.age
  end

  private

  def add_forwarding_flow_and_packet_out(packet_in)
    port_no = @fdb.lookup(packet_in.destination_mac)
    add_forwarding_flow_entry(packet_in, port_no) if port_no
    packet_out(packet_in, port_no || :flood)
  end

  def add_forwarding_flow_entry(packet_in, port_no)
    send_flow_mod_add(
      packet_in.datapath_id,
      table_id: FORWARDING_TABLE_ID,
      idle_timeout: AGING_TIME,
      priority: 2,
      match: Match.new(in_port: packet_in.in_port,
                       destination_mac_address: packet_in.destination_mac,
                       source_mac_address: packet_in.source_mac),
      instructions: Apply.new(SendOutPort.new(port_no))
    )
  end

  def packet_out(packet_in, port_no)
    send_packet_out(
      packet_in.datapath_id,
      packet_in: packet_in,
      actions: SendOutPort.new(port_no)
    )
  end

  def add_default_broadcast_flow_entry(datapath_id)
    send_flow_mod_add(
      datapath_id,
      table_id: FORWARDING_TABLE_ID,
      idle_timeout: 0,
      priority: 3,
      match: Match.new(destination_mac_address: 'ff:ff:ff:ff:ff:ff'),
      instructions: Apply.new(SendOutPort.new(:flood))
    )
  end

  def add_default_flooding_flow_entry(datapath_id)
    send_flow_mod_add(
      datapath_id,
      table_id: FORWARDING_TABLE_ID,
      idle_timeout: 0,
      priority: 1,
      match: Match.new,
      instructions: Apply.new(SendOutPort.new(:controller))
    )
  end

  def add_bpdu_drop_flow_entry(datapath_id)
    send_flow_mod_add(
      datapath_id,
      table_id: INGRESS_FILTERING_TABLE_ID,
      idle_timeout: 0,
      priority: 2,
      match: Match.new(destination_mac_address: '01:80:C2:00:00:00')
    )
  end

  def add_default_forwarding_flow_entry(datapath_id)
    send_flow_mod_add(
      datapath_id,
      table_id: INGRESS_FILTERING_TABLE_ID,
      idle_timeout: 0,
      priority: 1,
      match: Match.new,
      instructions: GotoTable.new(FORWARDING_TABLE_ID)
    )
  end
end

8.5.1. switch_ready ハンドラ

switch_ready ハンドラでは、まだ学習していないパケットのデフォルト処理を新しく起動したスイッチのフローテーブルに書き込みます。

LearningSwitch13#switch_ready (lib/learning_switch13.rb)
def switch_ready(datapath_id)
  add_bpdu_drop_flow_entry(datapath_id)
  add_default_broadcast_flow_entry(datapath_id)
  add_default_flooding_flow_entry(datapath_id)
  add_default_forwarding_flow_entry(datapath_id)
end

最初に呼び出す add_bpdu_drop_flow_entry では、不要なスパニングツリーの BPDU フレームをドロップするフローエントリを書き込みます。

LearningSwitch13#add_bpdu_drop_flow_entry (lib/learning_switch13.rb)
def add_bpdu_drop_flow_entry(datapath_id)
  send_flow_mod_add(
    datapath_id,
    table_id: INGRESS_FILTERING_TABLE_ID,
    idle_timeout: 0,
    priority: 2,
    match: Match.new(destination_mac_address: '01:80:C2:00:00:00')
  )
end

Flow Mod に指定するパラメータのうち、ポイントとなるのは次の 3 つです。

table_id

スイッチに入ってきたパケットの種類を見てドロップするかどうかを最初にフィルタリングする必要があるので、table_id には 0 (INGRESS_FILTERING_TABLE_ID) を指定します。

idle_timeout

BPDU フレームのドロップはスイッチの起動中はずっと有効なので、idle_timeout には 0 (フローエントリを消さない) を指定します。

priority

ドロップ処理は入ってきたパケットに対して最初に行うフィルタリングなので、テーブルID = 0 のフローエントリのうち最大優先度にします。ここでは 2 を指定します。

続く add_default_forwarding_flow_entry では、BPDU フレーム以外のパケットを FORWARDING_TABLE_ID で処理します。

LearningSwitch13#add_default_forwarding_flow_entry (lib/learning_switch13.rb)
def add_default_forwarding_flow_entry(datapath_id)
  send_flow_mod_add(
    datapath_id,
    table_id: INGRESS_FILTERING_TABLE_ID,
    idle_timeout: 0,
    priority: 1,
    match: Match.new,
    instructions: GotoTable.new(FORWARDING_TABLE_ID)
  )
end

ここで重要なパラメータは次の 3 つです。

priority

優先度を 1 に設定することで、より優先度の高いBPDUフレーム処理 (優先度 = 2) が終わったあとにこの処理を行う

match

空のマッチを指定することで、BPDUフレームでないパケットをすべてこのフローエントリで拾う

instructions

GotoTable(FORWARDING_TABLE_ID) を指定することで、以降の処理をテーブル 1 に移す

最後の add_default_flooding_flow_entry では、宛先 MAC アドレスをまだ学習していない場合のデフォルト処理をフローテーブルに書き込みます。

LearningSwitch13#add_default_flooding_flow_entry (lib/learning_switch13.rb)
def add_default_flooding_flow_entry(datapath_id)
  send_flow_mod_add(
    datapath_id,
    table_id: FORWARDING_TABLE_ID,
    idle_timeout: 0,
    priority: 1,
    match: Match.new,
    instructions: Apply.new(SendOutPort.new(:controller))
  )
end
table_id

ここで追加するフローエントリは、直前の GotoTable でテーブル ID INGRESS_FILTERING_TABLE_ID から FORWARDING_TABLE_ID に移動した後に処理さる。このため、table_id には FORWARDING_TABLE_ID を指定する

priority

フラッディング処理は宛先 MAC アドレスをまだ学習していなかった場合のデフォルト処理なので、優先度は低めの 1 を指定する

instructions

フラッディングのための SendOutPort.new(:flood) アクションと、Packet In を起こするための SendOutPort.new(:controller)Apply インストラクションで適用する

8.5.2. packet_in ハンドラ

packet_in ハンドラでは、Packet In したパケットの送信元 MAC アドレス + In Port の組を学習します。学習した組はテーブル ID が FORWARDING_TABLE_ID であるフローテーブルにフローエントリとして追加します。

LearningSwitch13#switch_ready (lib/learning_switch13.rb)
def packet_in(_datapath_id, packet_in)
  @fdb.learn(packet_in.source_mac, packet_in.in_port)
  add_forwarding_flow_and_packet_out(packet_in)
end

private

def add_forwarding_flow_and_packet_out(packet_in)
  port_no = @fdb.lookup(packet_in.destination_mac)
  add_forwarding_flow_entry(packet_in, port_no) if port_no
  packet_out(packet_in, port_no || :flood)
end

def add_forwarding_flow_entry(packet_in, port_no)
  send_flow_mod_add(
    packet_in.datapath_id,
    table_id: FORWARDING_TABLE_ID,
    idle_timeout: AGING_TIME,
    priority: 2,
    match: Match.new(in_port: packet_in.in_port,
                     destination_mac_address: packet_in.destination_mac,
                     source_mac_address: packet_in.source_mac),
    instructions: Apply.new(SendOutPort.new(port_no))
  )
end

ここでの Flow Mod パラメータのポイントは次のとおりです。

priority

優先度を FORWARDING_TABLE_ID の他のフローエントリ (フラッディング) よりも高くすることで、このフローエントリにマッチしない場合だけフラッディングするようにする

idle_timeout

フローエントリの寿命を指定しておくことで、OpenFlow1.0 版のラーニングスイッチで行ったタイマによるエイジングと同じ効果を出せる

match, instructions

宛先が Packet In の送信元 MAC アドレスと同じだったら、Packet In の in_port から入ったパケットをそちらに送る、というエントリを入れる

8.6. まとめ

ラーニングスイッチを OpenFlow1.3 で実装することで、OpenFlow1.0 版での問題点を解決しました。

  • マルチプルテーブルを使うことで、フローテーブルごとにパケット処理を分けデバッグしやすくできる

  • GotoTable インストラクションを使うことで、1つのパケットを複数のフローテーブルで処理できる

  • OpenFlow1.3 ではデフォルトで Packet In が起こらない。このため、OpenFlow1.0 で問題となるフローエントリ設定前の packet_in ハンドラの大量呼び出しが起こらない

続く章ではアジャイル開発手法を使って、コントローラを反復的に開発する手法を紹介します。テストコードを書きながら徐々に機能を追加していくことで、バグの少ないコントローラを着実に開発できます。

9. Trema でテスト駆動開発

ソフトウェアテストは総合的なスキルを必要とする最高峰の奥義です。「テストを書き、コードを直す」この正確なくりかえしを身に付ければ、将来的にプロジェクトに豊富な見返りをもたらします。

yutaro test

9.1. 仕様書としてのテストコード

きちんと整備したテストコードは、元のコードの仕様書のようなものです。ふつうの仕様書は読むだけですが、テストコードは実行してみることでソフトウェアの動作をチェックできます。

OpenFlowネットワークとコントローラの保守をまかされたとしましょう。もし前任者からテストコードをもらえなければ、コントローラを何度も実行しながら苦労して解読しなければなりません。逆に、テストさえもらえればコード本体を理解しやすくなりますし、気楽にリファクタリングや修正ができます。とくにOpenFlowではスイッチとコントローラが複雑に絡み合い、しかもそれぞれがステートを持つので、ソフトウェアで自動化したテストがないとやってられません。

TremaはOpenFlowコントローラ開発のためのテストツールが充実しています。たとえばアジャイル開発者の大事な仕事道具、テスト駆動開発もTremaはサポートしています。本章ではテスト駆動を使ったコントローラの開発風景を紹介します。要点をつかみやすくするため、動作の単純なリピータハブを取り上げます。ではさっそく実際のテスト駆動開発の流れを見て行きましょう。

テスト駆動開発とテストファーストの違いは?

テスト駆動開発やテストファーストなど似たような用語に混乱している人も多いと思います。この2つの違いは何でしょうか。

テストファーストはテスト駆動開発のステップの一部なので、テスト駆動開発のほうがより大きな概念になります。テスト駆動開発では、まずは失敗する見込みでテストを書き (このステップがテストファースト)、次にこのテストを通すためのコードを書きます。最後にコードをリファクタリングして、クリーンにします。この3ステップを数分間隔で何度も回しながら開発するのがテスト駆動開発です。

9.2. リピータハブの動き

まずは、リピータハブがどのように動くか見て行きましょう。リピータハブにホスト 3 台をつなげた図 9-1のネットワークを考えてください。ホスト 1 からホスト 2 へパケットを送信すると、リピータハブは入ってきたパケットを複製し他のすべてのホストにばらまきます。つまり、通信に関係のないホスト 3 もホスト 2 宛のパケットを受信します。このように、リピータハブはラーニングスイッチ (7 章「すべての基本、ラーニングスイッチ」) のような MAC アドレスの学習は行わず、とにかくすべてのホストへパケットを送ってしまうので、バカハブとかダムハブとも呼びます。

repeater hub
図 9-1: ホスト 3 台をつなげたリピータハブの動作

これを OpenFlow で実装すると図 9-2のようになります。ホスト 1 がパケットを送信すると、スイッチからコントローラに Packet In が起こります。ここでコントローラは「今後は同様のパケットを他の全ポートへばらまけ (フラッディング)」という Flow Mod を打ちます。また、Packet In を起こしたホスト 1 からのパケットを他の全ポートへ Packet Out でフラッディングします。

repeater hub openflow
図 9-2: OpenFlow 版リピータハブ

9.3. どこまでテストするか?

おおまかな仕組みはわかったので、テストを書き始める前にテスト戦略を決めます。テスト戦略とは言い換えると「どこまでテストするか?」ということです。これは経験が必要なむずかしい問題なので、ソフトウェアテスト界の賢人達の言葉を借りることにしましょう。

テスト駆動開発の第一人者、ケント・ベックは stackoverflow.com の「どこまでテストするか?」というトピック [14] に次の投稿をしています。

私はテストコードではなく動くコードに対してお金をもらっているので、ある程度の確信が得られる最低限のテストをするというのが私の主義だ (このレベルは業界水準からすると高いのではと思うが、ただの思い上がりかもしれない)。ふつうある種のミスを自分は犯さないとわかっていれば (コンストラクタで間違った変数をセットするとか)、そのためのテストはしない。

Ruby on Rails の作者として有名な David Heinemeier Hansson 氏 (以下、DHH) は、彼の勤める Basecamp 社のブログ [15] で次のように語っています。

コードのすべての行にはコストがかかる。テストを書くのにも、更新するのにも、読んで理解するのにも時間がかかる。したがってテストを書くのに必要なコストよりも、テストから得られる利益を大きくしなければいけない。テストのやりすぎは当然ながら間違っている。

2人の言葉をまとめるとこうなります。

  • 目的はテストコードではなく、コードが正しく動くこと

  • 正しく動くと確信が得られる、最低限のテストコードを書こう

リピータハブのテスト戦略もこれに従いましょう。最低限のテストシナリオはこうなるはずです。

  1. ホスト 1・ホスト 2・ホスト 3 をスイッチにつなげ、

  2. リピータハブのコントローラを起動したとき、

  3. ホスト 1 がホスト 2 へパケットを送ると、

  4. ホスト 2・ホスト 3 がパケットを受け取る

それぞれのステップを順にテストコードに起こしていきます。

9.4. テストに使うツール

コントローラのテストには次の 3 つのツールを使います。

Cucumber[16]

受け入れテストの定番ツール。ブラックボックステストをシナリオ形式で簡潔に記述できる

Aruba[17]

コマンドラインツールのテストツール。コマンドの起動と出力、終了ステータスなどのテストができる

trema/cucumber_step_definitions[18]

ArubaのTrema用ライブラリ。コントローラの起動やパケットの送受信といった、コントローラのテストを記述できる

9.5. パケット受信をテストする

では、リピータハブの動作を Cucumber の受け入れテストにしていきます。最初のテストシナリオを思い出してください。

  1. ホスト 1・ホスト 2・ホスト 3 をスイッチにつなげ、

  2. リピータハブのコントローラを起動したとき、

  3. ホスト 1 がホスト 2 へパケットを送ると、

  4. ホスト 2・ホスト 3 がパケットを受け取る

テストシナリオを Cucumber の受け入れテストに置き換えるには、シナリオの各ステップをGiven(前提条件)When(〜したとき)Then(こうなる)の3つに分類します。

  • Given: ホスト 1・ホスト 2・ホスト 3 をスイッチにつなげ、リピータハブのコントローラを起動したとき、

  • When: ホスト 1 がホスト 2 へパケットを送ると、

  • Then: ホスト 2・ホスト 3 がパケットを受け取る。

では、まずは最初の Given ステップを Cucumber のコードに直します。

9.5.1. Given: 仮想ネットワークでリピータハブを動かす

シナリオの前提条件 (Given) には、まずはコントローラにつなげるスイッチとホスト 3 台のネットワーク構成 (図 9-1) を記述します。Cucumber のテストファイル features/repeater_hub.feature はこうなります:

Given a file named "trema.conf" with:
  """
  vswitch('repeater_hub') { datapath_id 0xabc }

  vhost('host1') {
    ip '192.168.0.1'
    promisc true
  }
  vhost('host2') {
    ip '192.168.0.2'
    promisc true
  }
  vhost('host3') {
    ip '192.168.0.3'
    promisc true
  }

  link 'repeater_hub', 'host1'
  link 'repeater_hub', 'host2'
  link 'repeater_hub', 'host3'
  """

最初の行 Given a file named "trema.conf" with: …​ は、「…​ という内容のファイル trema.conf があったとき、」を表すテストステップです。このように、Cucumber では英語 (自然言語) でテストステップを記述できます。

それぞれの仮想ホストで promisc オプション (プロミスキャスモード。自分宛でないパケットも受け取れるようにするモード) を true にしていることに注意してください。リピータハブはパケットをすべてのポートにばらまくので、こうすることでホストがどんなパケットでも受信できるようにしておきます。

続いて、この仮想ネットワーク上でコントローラを起動する Given ステップを次のように書きます。

And I trema run "lib/repeater_hub.rb" with the configuration "trema.conf"

これは、シェル上で次のコマンドを実行するのと同じです。

$ ./bin/trema run lib/repeater_hub.rb -c trema.conf -d

Given が書けたところですぐに実行してみます。まだ lib/repeater_hub.rb ファイルを作っていないのでエラーになることはわかりきっていますが、エラーを確認するためにあえて実行します。次のコマンドを実行すると、受け入れテストファイル features/repeater_hub.feature を実行しテスト結果を表示します。

$ ./bin/cucumber features/repeater_hub.feature
Feature: Repeater Hub example
  @sudo
  Scenario: Run
    Given a file named "trema.conf" with:
      """
      vswitch('repeater_hub') { datapath_id 0xabc }

      vhost('host1') {
        ip '192.168.0.1'
        promisc true
      }
      vhost('host2') {
        ip '192.168.0.2'
        promisc true
      }
      vhost('host3') {
        ip '192.168.0.3'
        promisc true
      }

      link 'repeater_hub', 'host1'
      link 'repeater_hub', 'host2'
      link 'repeater_hub', 'host3'
      """
<<-STDERR
/home/yasuhito/.rvm/gems/ruby-2.2.0/gems/trema-0.7.1/lib/trema/command.rb:40:in `load': cannot load such file -- ../../lib/repeater_hub.rb (LoadError)
        from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/trema-0.7.1/lib/trema/command.rb:40:in `run'
        from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/trema-0.7.1/bin/trema:54:in `block (2 levels) in <module:App>'
        from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/gli-2.13.2/lib/gli/command_support.rb:126:in `call'
        from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/gli-2.13.2/lib/gli/command_support.rb:126:in `execute'
        from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/gli-2.13.2/lib/gli/app_support.rb:296:in `block in call_command'
        from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/gli-2.13.2/lib/gli/app_support.rb:309:in `call'
        from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/gli-2.13.2/lib/gli/app_support.rb:309:in `call_command'
        from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/gli-2.13.2/lib/gli/app_support.rb:83:in `run'
        from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/trema-0.7.1/bin/trema:252:in `<module:App>'
        from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/trema-0.7.1/bin/trema:14:in `<module:Trema>'
        from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/trema-0.7.1/bin/trema:12:in `<top (required)>'
        from /home/yasuhito/.rvm/gems/ruby-2.2.0/bin/trema:23:in `load'
        from /home/yasuhito/.rvm/gems/ruby-2.2.0/bin/trema:23:in `<main>'
        from /home/yasuhito/.rvm/gems/ruby-2.2.0/bin/ruby_executable_hooks:15:in `eval'
        from /home/yasuhito/.rvm/gems/ruby-2.2.0/bin/ruby_executable_hooks:15:in `<main>'

STDERR
    And I trema run "lib/repeater_hub.rb" with the configuration "trema.conf"
      expected "trema run ../../lib/repeater_hub.rb -c trema.conf -d" to be successfully executed (RSpec::Expectations::ExpectationNotMetError)
      ./features/step_definitions/trema_steps.rb:41:in `/^I trema run "([^"]*)"( interactively)? with the configuration "([^"]*)"$/'
      features/repeater_hub.feature:27:in `And I trema run "lib/repeater_hub.rb" with the configuration "trema.conf"'

Failing Scenarios:
cucumber features/repeater_hub.feature:5 # Scenario: Run as a daemon

1 scenario (1 failed)
3 steps (1 failed, 1 skipped, 1 passed)
0m8.113s

予想通り、trema run の箇所でエラーになりました。エラーメッセージによると lib/repeater_hub.rb というファイルが無いと言っています。このエラーを直すために、とりあえず空のファイルを作ります。

$ mkdir lib
$ touch lib/repeater_hub.rb
$ ./bin/cucumber features/repeater_hub.feature

再びテストを実行すると、今度は次のエラーメッセージが出ます。

$ ./bin/cucumber features/repeater_hub.feature
(中略)
<<-STDERR
error: No controller class is defined.

STDERR
    And I trema run "lib/repeater_hub.rb" with the configuration "trema.conf" # features/step_definitions/trema_steps.rb:30
      expected "trema run ../../lib/repeater_hub.rb -c trema.conf -d" to be successfully executed (RSpec::Expectations::ExpectationNotMetError)
      ./features/step_definitions/trema_steps.rb:41:in `/^I trema run "([^"]*)"( interactively)? with the configuration "([^"]*)"$/'
      features/repeater_hub.feature:27:in `And I trema run "lib/repeater_hub.rb" with the configuration "trema.conf"'

repeater_hub.rb にコントローラクラスが定義されていない、というエラーです。エラーを修正するために、RepeaterHub クラスの定義を追加してみます。エラーを修正できればいいので、クラスの中身はまだ書きません。

lib/repeater_hub.rb
class RepeaterHub < Trema::Controller
end

再びテストを実行してみます。今度はパスするはずです。

$ ./bin/cucumber features/repeater_hub.feature
(中略)
1 scenario (1 passed)
3 steps (3 passed)
0m18.207s

やりました! これで Given ステップは動作しました。

このようにテスト駆動開発では、最初にテストを書き、わざとエラーを起こしてからそれを直すためのコードをちょっとだけ追加します。テスト実行結果からのフィードバックを得ながら「テストを書き、コードを直す」を何度もくりかえしつつ最終的な完成形に近づけていくのです。

9.5.2. When: パケットの送信

When には「〜したとき」というきっかけになる動作を記述します。ここでは、Given で定義したホスト host1 から host2 にパケットを送る処理を書きます。パケットを送るコマンドは、trema send_packets でした。Cucumber (Aruba) では、実行したいコマンドを次のように I run …​ で直接書けます。

When I run `trema send_packets --source host1 --dest host2`

テストを一行追加しただけですが、念のため実行しておきます。

$ ./bin/cucumber features/repeater_hub.feature
(中略)
1 scenario (1 passed)
4 steps (4 passed)
0m21.910s

問題なくテストが通りました。次は Then に進みます。

9.5.3. Then: 受信したパケットの数

Then には「最終的にこうなるはず」というテストを書きます。ここでは、「ホスト 2・ホスト 3 がパケットを受け取るはず」というステップを書けばよいですね。これは次のように書けます。

Then the number of packets received by "host2" should be:
  |      source | #packets |
  | 192.168.0.1 |        1 |
And the number of packets received by "host3" should be:
  |      source | #packets |
  | 192.168.0.1 |        1 |

このステップはテーブル形式をしており、ホスト 2・ホスト 3 それぞれについて、送信元 IP アドレス 192.168.0.1 からパケットを 1 つ受信するはず、ということを表しています。

ではさっそく実行してみます。

$ ./bin/cucumber features/repeater_hub.feature
(中略)
    When I run `trema send_packets --source host1 --dest host2`
<<-STDERR

STDERR
    Then the number of packets received by "host2" should be:
      | source      | #packets |
      | 192.168.0.1 | 1        |

      expected: 1
           got: 0

      (compared using ==)
       (RSpec::Expectations::ExpectationNotMetError)
      ./features/step_definitions/show_stats_steps.rb:52:in `block (2 levels) in <top (required)>'
      ./features/step_definitions/show_stats_steps.rb:50:in `each'
      ./features/step_definitions/show_stats_steps.rb:50:in `/^the number of packets received by "(.*?)" should be:$/'
      features/repeater_hub.feature:30:in `Then the number of packets received by "host2" should be:'
    And the number of packets received by "host3" should be:
      | source      | #packets |
      | 192.168.0.1 | 1        |

Failing Scenarios:
cucumber features/repeater_hub.feature:5 # Scenario: Run as a daemon

1 scenario (1 failed)
6 steps (1 failed, 1 skipped, 4 passed)
0m20.198s

host2 に 1 つ届くはずだったパケットが届いておらず、失敗しています。RepeaterHub クラスはまだ何も機能を実装していないので当然です。

フラッディングをする Flow Mod を打ち込むコードを RepeaterHub クラスに追加して、もう一度テストしてみます。

class RepeaterHub < Trema::Controller
  def packet_in(datapath_id, packet_in)
    send_flow_mod_add(
      datapath_id,
      match: ExactMatch.new(packet_in),
      actions: SendOutPort.new(:flood)
    )
  end
end
$ ./bin/cucumber features/repeater_hub.feature
(中略)
    Then the number of packets received by "host2" should be:
      | source      | #packets |
      | 192.168.0.1 | 1        |

      expected: 1
           got: 0

失敗してしまいました。まだ host2 がパケットを受信できていません。そういえば、Flow Modしただけではパケットは送信されないので、明示的に Packet Out してやらないといけないのでした。そこで次のように Packet Out を追加します。

class RepeaterHub < Trema::Controller
  def packet_in(datapath_id, packet_in)
    send_flow_mod_add(
      datapath_id,
      match: ExactMatch.new(packet_in),
      actions: SendOutPort.new(:flood)
    )
    send_packet_out(
      datapath_id,
      raw_data: packet_in.raw_data,
      actions: SendOutPort.new(:flood)
    )
  end
end

再び実行してみます。

$ bundle exec cucumber features/repeater_hub.feature
Rack is disabled
Feature: "Repeater Hub" example

  @sudo
  Scenario: Run as a daemon
    Given a file named "trema.conf" with:
      """
      vswitch('repeater_hub') { datapath_id 0xabc }

      vhost('host1') {
        ip '192.168.0.1'
        promisc true
      }
      vhost('host2') {
        ip '192.168.0.2'
        promisc true
      }
      vhost('host3') {
        ip '192.168.0.3'
        promisc true
      }

      link 'repeater_hub', 'host1'
      link 'repeater_hub', 'host2'
      link 'repeater_hub', 'host3'
      """
    And I trema run "lib/repeater_hub.rb" with the configuration "trema.conf"
    When I run `trema send_packets --source host1 --dest host2`
    Then the number of packets received by "host2" should be:
      | source      | #packets |
      | 192.168.0.1 | 1        |
    And the number of packets received by "host3" should be:
      | source      | #packets |
      | 192.168.0.1 | 1        |

1 scenario (1 passed)
6 steps (6 passed)
0m20.976s

すべてのテストに通りました! 次はテスト駆動開発で欠かせないステップであるリファクタリングに進みます。

9.6. リファクタリング

リファクタリングとは、テストコードによってソフトウェアの振る舞いを保ちつつ、理解や修正が簡単になるようにソースコードを改善することです。Rubyにはリファクタリング用の便利なツールがたくさんあります。中でもよく使うツールは次の 4 つです。

reek[19]

Rubyコードの臭いを自動的に検知し、改善すべき場所を教えてくれる便利なツール

flog[20]

すべてのメソッドの複雑度 (これが大きいほど複雑でテストしづらい) を客観的なポイントで表示

flay[21]

メソッドのコピペなど、重複するコードを探して容赦なく指摘してくれる

rubocop[22]

コードが標準的な Ruby のコーディングスタイルに沿っているかチェックしてくれる

RepeaterHub クラスは十分簡潔ですが、念のためこの 4 つを使ってチェックしておきます。

$ ./bin/reek lib/repeater_hub.rb

$ ./bin/flog lib/repeater_hub.rb
     9.0: flog total
     4.5: flog/method average

     5.6: RepeaterHub#packet_in            lib/repeater_hub.rb:7

$ ./bin/flay lib/repeater_hub.rb
Total score (lower is better) = 0

$ ./bin/rubocop lib/repeater_hub.rb
Inspecting 1 file
.

1 file inspected, no offenses detected

reekflogflayrubocop コマンドすべてで、エラーメッセージは出ていません。ただし flog は複雑度を表示するだけなので、リファクタリングするかどうかは自分で判断する必要があります。今回のように、目安として複雑度が10ポイント以下であれば、リファクタリングの必要はありません。

もしもここでエラーメッセージが出た場合には、コントローラをリファクタリングします。エラーメッセージには修正のヒントが入っているので、それに従えば機械的に修正できます。動くテストコードがあるので、リファクタリングの最中に誤ってコードを壊してしまっても、すぐにミスしたことがわかります。

以上でコントローラとテストコードの一式が完成しました!

9.7. まとめ

Tremaのユニットテストフレームワークを使ってリピータハブを作り、コントローラをテスト駆動開発する方法を学びました。今回学んだことは次の2つです。

  • Cucumber・Aruba・trema/cucumber_step_definitionsを使うと、コントローラを起動して仮想ホストの受信パケット数などをテストできる

  • テストをGiven・When・Thenの3ステップに分けて分析し設計する方法を学んだ。それぞれのステップをCucumberのテストコードに置き換えることで、テストコードが完成する

  • テストが通ったら必ずリファクタリングすること。reekflogflayrubocop を使うと、コードの問題点を客観的に洗い出してくれる

本書で紹介するすべてのサンプルコードには、テストコード (features/ 以下) が付属しています。本格的にテストコードを書く人は、参考にしてください。

9.8. 参考文献

  • 『テスト駆動開発入門』(Kent Beck著/ピアソン・エデュケーション)
テスト駆動開発のバイブルです。もったいないことに日本語版は訳がまずく、意味の通らないところがたくさんあります。もし英語が苦でなければ、原著の英語版で読むことをおすすめします。

  • 『リファクタリング』(Martin Fowler著/ピアソン・エデュケーション)
この本の最大の功績は、コードのまずい兆候を「コードの臭い」と表現したことです。粗相をした赤ちゃんのおむつのように臭うコードには改善が必要で、この本にはそのためのレシピがそろっています。この本はJavaですが、Ruby版(『リファクタリング:Rubyエディション』Jay Fields、Shane Harvie、Martin Fowler、Kent Beck著/アスキー・メディアワークス)もあります。

10. 生活ネットワークをOpenFlowに移行する

あとは実践あるのみ!今までの知識を総動員し、自宅や職場のネットワークをOpenFlow化していろいろ実験してみましょう。

bicycle

10.1. どんどん規模を広げていこう

ここまでOpenFlowのプロトコルや動作モデル、そしてTremaを使ったOpenFlowプログラミングを学んできました。一人前のOpenFlowエンジニアとしてやっていくために必要な基礎知識はすでにひととおり身についたと言えます。

あとはひたすら実践するだけです。今まで手に入れた知識を実際に道具として使い、いま生活しているネットワーク上でOpenFlowを実際に動かしてみるのが一番です。まずは自宅のネットワークをOpenFlowで置き換えましょう。これがうまくいき物足りなくなったら、こんどは職場で小規模にOpenFlowネットワークを作りましょう。このように徐々に規模を広げて行くのです。

実際にその環境で暮らしてみて、はじめて見えてくるニーズやアイデア、改善案があります。初めて自転車に乗ったときのことを思い出してください。補助輪をはずしただけで最初は派手に転びますが、多少はケガをしつつもあきらめずに練習を繰り返しているうち誰でも乗りこなせるようになります。自転車に乗れれば、隣りの街やそのまた隣りと行動範囲は一気に広がります。しかし補助輪をはずさずにただ考えているだけではどこにも行けません。

10.2. 大ケガしないためのヘルメット

「でも、いきなりOpenFlowに移行してもし大失敗したら……」。そう考えるのが人情です。家のネットワークはともかく、もし職場のネットワークを止めて同僚に迷惑をかけてしまったらどうしよう……。管理者や上司に注意されたらどうしよう……。

本章ではそうした大失敗を防ぐためのヘルメットを紹介します。筆者らは、OpenFlowが登場したころから職場のネットワークで実験を始め、それこそ数え切れないほどの失敗を繰り返してきました。とにかく何度も怒られましたが、その経験からうまくやる方法をアドバイスできます。私たちは既存のネットワークを穏便にOpenFlowに移行するテクニックを持っています。ちょっとしたOpenFlowコントローラを書くだけで、移行の際に起こりがちなネットワーク障害を簡単に防げるのです。

まずは、私たちの失敗談を振り返らせてください。

10.3. 私たちの失敗談

OpenFlowが登場したばかりのころ、私たちはさっそくスイッチングハブ相当のOpenFlowコントローラを書いて小さなOpenFlowネットワークを職場に構築してみました。「おお、ちゃんと動くじゃん!」気を良くした私たちは、こともあろうにこのOpenFlowネットワークと職場ネットワークとをいきなりつないでしまいました。まあ大丈夫だろうと楽観的に考えていたのです。

しかしすぐにネットワーク障害が起こり、異常に気づいたネットワーク管理者からお叱りのメールを受け取ることになりました。障害時のセットアップを単純化すると図10-1のようになります。

buggy controller setup
図 10-1: 障害を起こしたときのネットワーク構成を単純化したもの

職場ネットワーク(レガシーネットワークとします)のスイッチにはホストを2台つないでおり、スイッチのポート3番をOpenFlowスイッチのポート1番と接続しました。このOpenFlowスイッチは、私たちが書いたスイッチングハブ相当のOpenFlowコントローラ(仮にBuggyControllerとします)で制御していました。

10.3.1. 障害報告:Host Flappingが起こっている

ネットワーク管理者から届いた障害報告メールには次のようにありました「レガシーネットワークのホストどうしが通信できなくなった。スイッチはHost Flapping警告を出している」

Host Flappingとは、1つのホストがいくつかのポートの間で高速で移動しているように見えるという障害です。「なんでそんなことが起こるんだろう?」われわれはすぐにOpenFlowネットワークを切断し、そしてもちろんネットワーク管理者にはごめんなさいメールを出してから、原因の分析にとりかかりました。

10.3.2. 障害原因が判明

分析の結果、図10-2のようなシナリオが起こっているという結論に至りました。

failure analysis
図10-2レガシーネットワークで起こった障害のシナリオ
  1. host1がhost2へパケットを送信する

  2. BuggyControllerはOpenFlowスイッチポート1番からのPacket Inを受け取り、OpenFlowスイッチのスイッチポート1番にhost1がつながっていると学習する

  3. レガシーネットワークスイッチが覚えている host1 の情報がエージアウトし消える [23]

  4. host2がhost1へパケットを送信する

  5. BuggyControllerはスイッチポート1番から宛先=host1のPacket Inを受け取る。ここで、host1はOpenFlowスイッチのスイッチポート1番にあると学習しているので、スイッチポート1番にPacket Outする

  6. 結果的に、host1はポート2と3の両方から同じパケットを受け取る。レガシーネットワークのスイッチから見ると、host2がスイッチポート2番と3番を高速に移動しているように見える

つまり、BuggyControllerがレガシーネットワークにパケットを逆流させたおかげでネットワークが大混乱し、通信できない状況が起きたのです。

10.3.3. 教訓:これをやってはいけない

振り返ると、失敗した原因は2つありました。

1つは、OpenFlowネットワークをいきなりレガシーネットワークとつないでしまったことです。OpenFlowネットワーク単体では動いていたのに、というのは言い訳にはなりません。若気の至りや経験不足から来る青いミスです。もう1つは、BuggyControllerがPacket Inと同じポートにPacket Outするという通常あり得ない動作をしていたことです。要所要所で assert を入れるといった防御的プログラミングや、ソフトウェアテスト(9章「Trema でテスト駆動開発」を参照)を徹底していれば防げるバグでしたが、当時の私たちは動かすことに精いっぱいでそこまで気が回りませんでした。

というわけで、大障害を起こして始めて気付くという最悪のパターンになってしまったわけです。

10.4. OpenFlowへの移行パターン

大失敗をやらかしてしまった筆者たちは、OpenFlow移行のための作戦を練りなおさざるを得なくなりました。いろいろな方向から考えなおしたところ、OpenFlowへの移行方法には次の3つのパターンがあることがわかりました。もちろん、それぞれでメリット/デメリットや危険度が異なります。

10.4.1. 独立ネットワークパターン

最初のパターンは、既存のレガシーネットワークにまったく手を加えずに、それとは独立したもう1つのOpenFlowネットワークを構築する方法です(図10-3)。それぞれのネットワーク間でパケットの行き来はなく、お互いに完全に独立しています。

pattern1
図 10-3: レガシーネットワークとは独立したOpenFlowネットワークを構築し、徐々に拡大する

この状態から、レガシーネットワーク内のサーバや端末を徐々にOpenFlowネットワークに移動することで移行していきます。

それぞれのネットワーク間ではパケットが行き来できないので、OpenFlowネットワークがレガシーネットワークに悪影響を及ぼす可能性はまずありません。ただし、OpenFlowネットワークに移行する際には関連する機器同士(ファイルサーバとクライアント群など)を一度に移行する必要があります。これはトラブルを起こす可能性が高いため、レガシーネットワークの規模が大きい場合には移行が難しいという問題があります。

10.4.2. いきなり接続パターン

次のパターンは、私たちがやったようにレガシーネットワークとOpenFlowネットワークをいきなりつなげてしまう方法です(図10-4)。

pattern2
図 10-4: レガシーネットワークとOpenFlowネットワークを直結してしまう

この方法だと、相互に通信できるのでネットワーク間でのサーバや端末の移動は自由にできます。このため、独立ネットワークパターンに比べて移行の手間はずっと小さいと言えます。

ただしこの方法は、私たちが失敗したようにとてもリスクの高い方法です。OpenFlowネットワークのコントローラが完璧に作られていれば、このようにいきなりつなげても問題はありませんが、完璧を期するのはなかなかむずかしいものです。というのも、実際のトラフィックをコントローラに流し込んでみて初めて見つかるバグもあるからです。よって、この方法は自宅ネットワークなど他人に迷惑のかからないネットワーク以外では推奨できません。

10.4.3. フィルタ経由で接続パターン

最後のパターンは、今までに挙げてきた2つのパターンのいいとこどりです。2つのネットワークを接続するのですが、そのときに逆流防止フィルタとなるOpenFlowスイッチを間にはさむことでパケットの逆流が起きないようにします(図10-5)。

pattern3
図 10-5: レガシーネットワークとOpenFlowネットワークの間での逆流を防止する

この逆流防止フィルタはたとえば、レガシーネットワーク→OpenFlow ネットワークのような一方向のパケットは通しますが、同じパケットがレガシー側に戻ることを防ぎます。逆方向も同様です。

この方法の利点は、逆流を防ぐだけで今回のケースも含めたかなりの障害を未然に防げることです。また、使い勝手はいきなり接続した場合と同じなのでOpenFlowへの移行も楽です。ただし、2つのネットワーク間にもう1つフィルタ用のOpenFlowスイッチをはさまなければならないという手間はかかります。

10.5. 逆流防止フィルタ

検討の結果、逆流防止フィルタを使ったパターンが一番良さそうでした。フィルタを動かすためのサーバもちょうど余っていましたし、何よりコントローラとして簡単に実装できそうだったからです。前置きが長くなりましたが、さっそくTremaで実装してみましょう。

逆流防止フィルタは1つのPacket Inに対して2つのフローエントリを設定します。1つは順方向のフローエントリで、入ってきたパケットをもう1つのスイッチポートに転送します。もう1つは逆方向のフローエントリで、同じパケットが逆方向に流れてきたときにこのパケットを落とします。

10.5.1. ソースコード

逆流防止フィルタ(OneWayBridge コントローラ)のソースコードは GitHub の trema/one_way_bridge リポジトリ (https://github.com/trema/one_way_bridge) からダウンロードできます。

$ git clone https://github.com/trema/one_way_bridge.git

ダウンロードしたソースツリー上で bundle install --binstubs を実行すると、Tremaなどの実行環境一式を自動的にインストールできます。

$ cd one_way_bridge
$ bundle install --binstubs

このコントローラは、Packet In と Flow Removed のハンドラだけを定義したとてもシンプルなものです。

lib/one_way_bridge.rb
# Safety-net controller bridging legacy and OpenFlow networks.
class OneWayBridge < Trema::Controller
  def packet_in(datapath_id, packet_in)
    out_port = { 1 => 2, 2 => 1 }.fetch(packet_in.in_port)
    add_flow datapath_id, packet_in.source_mac, packet_in.in_port, out_port
    send_packet datapath_id, packet_in, out_port
    add_drop_flow datapath_id, packet_in.source_mac, out_port
  end

  def flow_removed(datapath_id, packet_in)
    delete_flow datapath_id, packet_in.match.source_mac_address
  end

  private

  def add_flow(datapath_id, source_mac, in_port, out_port)
    send_flow_mod_add(
      datapath_id,
      idle_timeout: 10 * 60,
      match: Match.new(in_port: in_port, source_mac_address: source_mac),
      actions: SendOutPort.new(out_port)
    )
  end

  def add_drop_flow(datapath_id, source_mac, in_port)
    send_flow_mod_add(
      datapath_id,
      idle_timeout: 10 * 60,
      match: Match.new(in_port: in_port, source_mac_address: source_mac)
    )
  end

  def send_packet(datapath_id, packet_in, out_port)
    send_packet_out(
      datapath_id,
      packet_in: packet_in,
      actions: SendOutPort.new(out_port)
    )
  end

  def delete_flow(datapath_id, source_mac)
    send_flow_mod_delete(
      datapath_id,
      match: Match.new(source_mac_address: source_mac)
    )
  end
end

packet_in ハンドラでは、Packet Inしたスイッチポートとは別のポートへパケットを転送するフローエントリを設定し(add_flow メソッド)、Packet Inを起こしたパケットを転送します(send_packet メソッド)。また、同じパケットが逆向きに流れないようにするフローエントリを設定することで逆流を防ぎます(add_drop_flow メソッド)。同じパケットかどうかは送信元のMACアドレスが同じかどうかで判断します。

flow_removed ハンドラは、順方向または逆方向のフローエントリが消えたときに呼ばれます。これらのフローエントリはどちらも :source_mac_address に同じMACアドレスを指定しているので、delete_flow メソッドでもう片方の対になるフローエントリを消します。

10.5.2. 実行してみよう

逆流防止フィルタを実行するには、レガシーネットワークとOpenFlowネットワークの間にOpenFlowスイッチをはさみ、これをOneWayBridgeコントローラで制御します。でも実機のOpenFlowスイッチを準備するのは大変なので、Tremaの仮想ネットワーク機能でやってしまいましょう。NICが2枚挿さったサーバを用意し、仮想ネットワーク内で起動した仮想スイッチ(vswitch)の各ポートとそれぞれのNICを接続します(図10-6)。

one way bridge setup
図 10-6: 逆流防止フィルタ(OneWayBridgeコントローラ)を実行するための物理構成例

この物理構成をTrema設定ファイルにしたものが以下です。仮想リンク(link で始まる行)の端点にインタフェース名eth0、eth1を指定していることに注目してください。

逆流防止フィルタ(OneWayBridgeコントローラ)の設定ファイル
vswitch ('bridge') {
  datapath_id 0xabc
}

link 'bridge', 'eth0'
link 'bridge', 'eth1'

実行するには、この設定ファイルを trema run-c オプションに渡します。

$ ./bin/trema run ./lib/one-way-bridge.rb -c ./trema.conf

10.5.3. 職場で使ってみた

さっそくこの逆流防止フィルタを導入したところ、問題は起こらなくなりました。現在、OpenFlowスイッチ5台、ホスト約100台から構成されるOpenFlowネットワークを職場ネットワークと接続して運用しています。このOpenFlowネットワークは現在もどんどん拡大しつつあり、その上でOpenFlowを使ったたくさんのアプリケーションが生まれています。こうした実践あるのみという姿勢から生まれたたくさんのアプリケーション、それを支えるプログラミングフレームワークとして誕生したのがTremaです。

10.6. まとめ

職場のネットワークを安全にOpenFlowに移行するためのTipsを学びました。

  • レガシーネットワークをOpenFlowに移行するいくつかのパターンを考察。自宅など自由にできるネットワークではいきなり接続パターンで十分だが、職場ネットワークでは逆流防止パターンが最適

  • 逆流防止フィルタを実現するOpenFlowコントローラを実装。2つのフローエントリを設定するだけで、簡単に逆流を防止できる

11. ファイアウォール

ファイアウォールは、外部からの不要なパケットの通過を遮断することで、ネットワークを攻撃から守るネットワーク機器です。そのファイアウォールを OpenFlow を使って作ってみましょう。

11.1. 透過型ファイアウォール

今回実装するファイアウォールはいわゆる透過型ファイアウォールです。図 11-1 のようにルータとホストの間にブリッジとしてはさむだけでパケットのフィルタリングが可能です。既存のルータをそのまま使うため、各ホストのネットワーク設定を変更しなくてよいという利点があります。

transparent firewall
図 11-1: 透過型ファイアウォール

パケットのフィルタリングはIPv4ヘッダの情報に基づいて行います。今回はフィルタリングのルールが異なる以下の2種類ファイアウォールを実装します。

BlockRFC1918

RFC1918が定義するプライベートアドレスを送信元または宛先とするパケットを遮断するファイアウォール。外側からと内側からの両方のパケットを遮断する。

PassDelegated

グローバルアドレスからのパケットのみを通すファイアウォール。外側→内側のパケットのみをフィルタする。

11.2. BlockRFC1918コントローラ

BlockRFC1918コントローラは送信元または宛先 IP アドレスがプライベートアドレスのパケットを遮断します (図 11-2)。プライベートアドレスは RFC1918 (プライベート網のアドレス割当) が定義する次の 3 つの IP アドレス空間です。

  • 10.0.0.0/8

  • 172.16.0.0/12

  • 192.168.0.0/16

block rfc1918
図 11-2: BlockRFC1918ファイアウォールはプライベートアドレスからのパケットを遮断

11.2.1. 実行してみよう

仮想ネットワークを使って BlockRFC1918 コントローラを起動してみます。ソースコードと仮想ネットワークの設定ファイルは GitHub の trema/transparent_firewall リポジトリ (https://github.com/trema/transparent_firewall) からダウンロードできます。

$ git clone https://github.com/trema/transparent_firewall.git

ダウンロードしたソースツリー上で bundle install --binstubs を実行すると、Tremaなどの実行環境一式を自動的にインストールできます。

$ cd transparent_firewall
$ bundle install --binstubs

GitHub から取得したソースリポジトリ内に、仮想スイッチ1台、仮想ホスト3台の構成を持つ設定ファイル trema.conf が入っています (図 11-3)。

configuration
図 11-3: BlockRFC1918 を実行するための仮想ネットワーク構成
trema.conf
vswitch('firewall') { datapath_id 0xabc }

vhost('outside') { ip '192.168.0.1' }
vhost('inside') { ip '192.168.0.2' }
vhost('inspector') {
  ip '192.168.0.3'
  promisc true
}

link 'firewall', 'outside'
link 'firewall', 'inside'
link 'firewall', 'inspector'

ホスト outside は外側のネットワーク、たとえばインターネット上のホストとして動作します。ホスト inside は内側のネットワークのホストです。ホスト inspector は BlockRFC1918 ファイアウォールが落としたパケットを調べるためのデバッグ用ホストです。inspector は outside または inside 宛のパケットを受け取るので、promisc オプションを有効にすることで自分宛でないパケットも受け取れるようにしておきます。

では、いつものように trema run-c オプションにこの設定ファイルを渡して BlockRFC1918 コントローラを実行してみましょう。

$ ./bin/trema run ./lib/block_rfc1918.rb -c trema.conf
0xabc: connected
0xabc: loading finished

別ターミナルを開き、trema send_packets コマンドを使って outside と inside ホストの間でテストパケットを送ってみます。

$ ./bin/trema send_packets --source outside --dest inside
$ ./bin/trema send_packets --source inside --dest outside

outside と inside はどちらもプライベートアドレスを持つので、BlockRFC1918 コントローラがパケットを落とすはずです。落としたパケットは inspector ホストへ送られます。

trema show_stats コマンドで outside、inside そして inspector の受信パケット数をチェックしてみましょう。

$ ./bin/trema show_stats outside
Packets sent:
  192.168.0.1 -> 192.168.0.2 = 1 packet
$ ./bin/trema show_stats inside
Packets sent:
  192.168.0.2 -> 192.168.0.1 = 1 packet
$ ./bin/trema show_stats inspector
Packets received:
  192.168.0.1 -> 192.168.0.2 = 1 packet
  192.168.0.2 -> 192.168.0.1 = 1 packet

たしかに、outside と inside の show_stats には Packets received: の項目がないので、どちらにもパケットは届いていません。そして、落としたパケット 2 つはどちらも inspector に届いています。

11.3. BlockRFC1918のソースコード

BlockRFC1918のソースコードをざっと眺めてみましょう。やっていることは基本的にフローエントリの設定だけなので、難しい点はありません。

lib/block_rfc1918.rb
# A sample transparent firewall
class BlockRFC1918 < Trema::Controller
  PORT = {
    outside: 1,
    inside: 2,
    inspect: 3
  }

  PREFIX = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'].map do |each|
    IPv4Address.new each
  end

  def switch_ready(dpid)
    if @dpid
      logger.info "#{dpid.to_hex}: ignored"
      return
    end
    @dpid = dpid
    logger.info "#{@dpid.to_hex}: connected"
    start_loading
  end

  def switch_disconnected(dpid)
    return if @dpid != dpid
    logger.info "#{@dpid.to_hex}: disconnected"
    @dpid = nil
  end

  def barrier_reply(dpid, _message)
    return if dpid != @dpid
    logger.info "#{@dpid.to_hex}: loading finished"
  end

  private

  def start_loading
    PREFIX.each do |each|
      block_prefix_on_port prefix: each, in_port: :inside, priority: 5000
      block_prefix_on_port prefix: each, in_port: :outside, priority: 4000
    end
    install_postamble 1500
    send_message @dpid, Barrier::Request.new
  end

  def block_prefix_on_port(prefix:, in_port:, priority:)
    send_flow_mod_add(
      @dpid,
      priority: priority + 100,
      match: Match.new(in_port: PORT[in_port],
                       ether_type: 0x0800,
                       source_ip_address: prefix),
      actions: SendOutPort.new(PORT[:inspect]))
    send_flow_mod_add(
      @dpid,
      priority: priority,
      match: Match.new(in_port: PORT[in_port],
                       ether_type: 0x0800,
                       destination_ip_address: prefix),
      actions: SendOutPort.new(PORT[:inspect]))
  end

  def install_postamble(priority)
    send_flow_mod_add(
      @dpid,
      priority: priority + 100,
      match: Match.new(in_port: PORT[:inside]),
      actions: SendOutPort.new(PORT[:outside]))
    send_flow_mod_add(
      @dpid,
      priority: priority,
      match: Match.new(in_port: PORT[:outside]),
      actions: SendOutPort.new(PORT[:inside]))
  end
end

スイッチがコントローラに接続すると、switch_ready ハンドラが呼ばれます。switch_ready ハンドラでは、フローエントリを設定する start_loading メソッドを呼びます。

BlockRFC1918#switch_ready (lib/block_rfc1918.rb)
def switch_ready(dpid)
  if @dpid
    logger.info "#{dpid.to_hex}: ignored"
    return
  end
  @dpid = dpid
  logger.info "#{@dpid.to_hex}: connected"
  start_loading (1)
end
1 フローエントリを設定する start_loading メソッドを呼ぶ

start_loading メソッドでは、パケットのドロップと転送用のフローエントリを設定します。まず、RFC1918 が定義する 3 つのプライベートアドレス空間それぞれについて、送信元または宛先 IP アドレスがプライベートアドレスのパケットを inspector ホストに転送するフローエントリを block_prefix_on_port メソッドで設定します。

BlockRFC1918#start_loading, BlockRFC1918#block_prefix_on_port (lib/block_rfc1918.rb)
def start_loading
  PREFIX.each do |each|
    block_prefix_on_port prefix: each, in_port: :outside, priority: 4000 (1)
    block_prefix_on_port prefix: each, in_port: :inside, priority: 5000  (2)
  end
  install_postamble 1500
  send_message @dpid, Barrier::Request.new
end

def block_prefix_on_port(prefix:, in_port:, priority:)
  send_flow_mod_add( (3)
    @dpid,
    priority: priority + 100,
    match: Match.new(in_port: PORT[in_port],
                     ether_type: 0x0800,
                     source_ip_address: prefix),
    actions: SendOutPort.new(PORT[:inspect]))
  send_flow_mod_add( (4)
    @dpid,
    priority: priority,
    match: Match.new(in_port: PORT[in_port],
                     ether_type: 0x0800,
                     destination_ip_address: prefix),
    actions: SendOutPort.new(PORT[:inspect]))
end
1 スイッチのポート 1 番 (内側ネットワークと接続) で受信するパケットのフローエントリを設定
2 スイッチのポート 2 番 (外側ネットワークと接続) で受信するパケットのフローエントリを設定
3 送信元 IP アドレスがプライベートアドレスのパケットを inspector ホストに転送するフローエントリを追加
4 宛先 IP アドレスがプライベートアドレスのパケットを inspector ホストに転送するフローエントリを追加

送信元 IP アドレスがプライベートアドレスでないパケットは転送を許可します。このフローエントリは install_postamble メソッドで次のように設定します。

BlockRFC1918#install_postamble (lib/block_rfc1918.rb)
def install_postamble(priority)
  send_flow_mod_add( (1)
    @dpid,
    priority: priority + 100,
    match: Match.new(in_port: PORT[:inside]),
    actions: SendOutPort.new(PORT[:outside]))
  send_flow_mod_add( (2)
    @dpid,
    priority: priority,
    match: Match.new(in_port: PORT[:outside]),
    actions: SendOutPort.new(PORT[:inside]))
end
1 スイッチのポート 2 番 (内側ネットワーク) で受信した転送 OK なパケットはポート 1 番 (外側ネットワーク) へ転送
2 逆にスイッチのポート 1 番で受信した転送 OK なパケットはポート 2 番へ転送

最後に、すべてのフローエントリがスイッチに反映したことをバリアで確認します。スイッチへ Barrier::Request メッセージを送り、スイッチからの Barrier::Reply メッセージが barrier_reply ハンドラへ届けば、すべてフローエントリの設定は完了です。

BlockRFC1918#barrier_reply (lib/block_rfc1918.rb)
def barrier_reply(dpid, _message) (2)
  return if dpid != @dpid
  logger.info "#{@dpid.to_hex}: loading finished"
end

private

def start_loading
  PREFIX.each do |each|
    block_prefix_on_port prefix: each, in_port: :outside, priority: 4000
    block_prefix_on_port prefix: each, in_port: :inside, priority: 5000
  end
  install_postamble 1500
  send_message @dpid, Barrier::Request.new (1)
end
1 スイッチに Barrier::Request メッセージを送り、すべてのフローエントリが反映されるのを待つ
2 Barrier::Reply が届けば、完了メッセージを logger.info で出す

11.4. PassDelegatedコントローラ

PassDelegatedコントローラは、外側から内側向きのパケットのうち、送信元 IP アドレスがグローバル IP アドレスのパケットのみを通します (図 11-4)。

pass delegated
図 11-4: PassDelegatedファイアウォールは外→内側向きのグローバルアドレスからのパケットを通す

フローエントリに用いるグローバル IP アドレスには、trema/transparent_firewall リポジトリ内のグローバル IP アドレス空間の一覧リスト (*.txt ファイル) を使います。このテキストファイルは、グローバルアドレスの割り当てなどを行う地域インターネットレジストリが提供するリストから自動生成したものです。たとえば、アジアと太平洋地域を担当する Asia-Pacific Network Information Centre (APNIC) のファイルは次のような 3000 以上の IP アドレス空間からなります。

aggregated-delegated-apinic.txt
1.0.0.0/8
14.0.0.0/16
14.1.0.0/20
14.1.16.0/21
14.1.32.0/19
14.1.64.0/19
14.1.128.0/17
14.2.0.0/15
14.4.0.0/14
14.8.0.0/13
...

11.4.1. 実行してみよう

PassDelegated コントローラを図 11-3と同じ trema.conf で起動してみましょう。trema run で実行すると、次のようにすべての *.txt ファイルを読みこみ IP アドレス空間ごとにフローエントリを作ります。グローバル IP アドレス空間は全部で2万以上あるので、すべてのフローエントリの作成には数分かかります。

$ ./bin/trema run ./lib/pass_delegated.rb -c pass_delegated.conf
aggregated-delegated-afrinic.txt: 713 prefixes
aggregated-delegated-apnic.txt: 3440 prefixes
aggregated-delegated-arin.txt: 11342 prefixes
aggregated-delegated-lacnic.txt: 1937 prefixes
aggregated-delegated-ripencc.txt: 7329 prefixes
0xabc: connected
0xabc: loading started
0xabc: loading finished in 241.03 seconds

コントローラが起動したら、別ターミナルを開き trema send_packets コマンドでoutsideとinsideホストの間でテストパケットを送ってみます。

$ ./bin/trema send_packets --source outside --dest inside
$ ./bin/trema send_packets --source inside --dest outside

PassDelegated コントローラはグローバルアドレス以外の外側から内側へのパケットを遮断します。ホストoutsideはプライベートアドレスを持つので、PassDelegatedコントローラはパケットを落とします。ホストinsideもプライベートアドレスを持ちますが、insideからoutsideへのパケットは通します。trema show_stats コマンドで outside、inside、そして inspector の受信パケット数をチェックしてみましょう。

$ ./bin/trema show_stats outside
Packets sent:
  192.168.0.1 -> 192.168.0.2 = 1 packet
$ ./bin/trema show_stats inside
Packets sent:
  192.168.0.2 -> 192.168.0.1 = 1 packet
Packets received:
  192.168.0.1 -> 192.168.0.2 = 1 packet
$ ./bin/trema show_stats inspector
Packets received:
  192.168.0.1 -> 192.168.0.2 = 1 packet

たしかに、outside から inside へのパケットは遮断し、逆向きの inside から outside へのパケットは通しています。そして、outside からの遮断されたパケットは inspector に届いています。

11.5. PassDelegatedのソースコード

PassDelegated のソースコードは BlockRFC1918 と似た構造ですが、使うフローエントリの種類が増えています。次の 4 種類のフローエントリを使います。

フィルタ用 (優先度: 64000)

外側ネットワークのグローバル IP アドレスからのパケットを内側ホストに転送するフローエントリです。3 万以上のエントリがあるため、セットアップは数分かかります。

バイパス用 (優先度: 65000)

フィルタ用フローエントリをセットアップしている間の数分間だけ有効なエントリです。外側⇔内側のすべてのパケットを通します。

ドロップ用 (優先度: 1000)

外側ネットワークのグローバル IP アドレス以外からのパケットを inspector ホストに転送するフローエントリです。

IPv4以外用 (優先度: 900)

外側ネットワークからの IPv4 以外のパケットを内側ネットワークへ転送するフローエントリです。

lib/pass_delegated.rb
# A sample transparent firewall
class PassDelegated < Trema::Controller
  PORT = {
    outside: 1,
    inside: 2,
    inspect: 3
  }

  PRIORITY = {
    bypass: 65_000,
    prefix: 64_000,
    inspect: 1000,
    non_ipv4: 900
  }

  PREFIX_FILES = %w(afrinic apnic arin lacnic ripencc).map do |each|
    "aggregated-delegated-#{each}.txt"
  end

  def start(_args)
    @prefixes = PREFIX_FILES.reduce([]) do |result, each|
      data = IO.readlines(File.join __dir__, '..', each)
      logger.info "#{each}: #{data.size} prefixes"
      result + data
    end
  end

  def switch_ready(dpid)
    if @dpid
      logger.info "#{dpid.to_hex}: ignored"
      return
    end
    @dpid = dpid
    logger.info "#{@dpid.to_hex}: connected"
    start_loading
  end

  def switch_disconnected(dpid)
    return if @dpid != dpid
    logger.info "#{@dpid.to_hex}: disconnected"
    @dpid = nil
  end

  def barrier_reply(dpid, _message)
    return if dpid != @dpid
    finish_loading
  end

  private

  def start_loading
    @loading_started = Time.now
    install_preamble_and_bypass
    install_prefixes
    install_postamble
    send_message @dpid, Barrier::Request.new
  end

  # All flows in place, safe to remove bypass.
  def finish_loading
    send_flow_mod_delete(@dpid,
                         strict: true,
                         priority: PRIORITY[:bypass],
                         match: Match.new(in_port: PORT[:outside]))
    logger.info(format('%s: loading finished in %.2f second(s)',
                       @dpid.to_hex, Time.now - @loading_started))
  end

  def install_preamble_and_bypass
    send_flow_mod_add(@dpid,
                      priority: PRIORITY[:bypass],
                      match: Match.new(in_port: PORT[:inside]),
                      actions: SendOutPort.new(PORT[:outside]))
    send_flow_mod_add(@dpid,
                      priority: PRIORITY[:bypass],
                      match: Match.new(in_port: PORT[:outside]),
                      actions: SendOutPort.new(PORT[:inside]))
  end

  def install_prefixes
    logger.info "#{@dpid.to_hex}: loading started"
    @prefixes.each do |each|
      send_flow_mod_add(@dpid,
                        priority: PRIORITY[:prefix],
                        match: Match.new(in_port: PORT[:outside],
                                         ether_type: 0x0800,
                                         source_ip_address: IPv4Address.new(each)),
                        actions: SendOutPort.new(PORT[:inside]))
    end
  end

  # Deny any other IPv4 and permit non-IPv4 traffic.
  def install_postamble
    send_flow_mod_add(@dpid,
                      priority: PRIORITY[:inspect],
                      match: Match.new(in_port: PORT[:outside], ether_type: 0x0800),
                      actions: SendOutPort.new(PORT[:inspect]))
    send_flow_mod_add(@dpid,
                      priority: PRIORITY[:non_ipv4],
                      match: Match.new(in_port: PORT[:outside]),
                      actions: SendOutPort.new(PORT[:inside]))
  end
end

BlockRFC1918 と同じく、各種フローエントリの設定は start_loading メソッドから始まります。

PassDelegated#start_loading (lib/pass_delegated.rb)
def start_loading
  @loading_started = Time.now
  install_preamble_and_bypass
  install_prefixes
  install_postamble
  send_message @dpid, Barrier::Request.new
end

最初に呼び出す install_preamble_and_bypass メソッドは、外側⇔内側のすべてのパケットを通すバイパス用フローエントリを追加します。優先度を他のフローエントリよりも大きくしておくことで、フィルタリング用フローエントリを設定している数分間はすべてのパケットがこのフローエントリにマッチします。このため、フローエントリのセットアップ中でも普通に通信できるようになります。

PassDelegated#install_preamble_and_bypass (lib/pass_delegated.rb)
def install_preamble_and_bypass
  send_flow_mod_add(@dpid, (1)
                    priority: PRIORITY[:bypass],
                    match: Match.new(in_port: PORT[:inside]),
                    actions: SendOutPort.new(PORT[:outside]))
  send_flow_mod_add(@dpid, (2)
                    priority: PRIORITY[:bypass],
                    match: Match.new(in_port: PORT[:outside]),
                    actions: SendOutPort.new(PORT[:inside]))
end
1 内側→外側のパケットをすべて通すフローエントリを設定
2 外側→内側のパケットをすべて通すフローエントリを設定

バイパス用フローエントリの後、大量のフィルタ用フローエントリを設定します。PassDelegated がフィルタするのは外側→内側ネットワークだけなので、それぞれのグローバル IP アドレス空間について 1 つずつのフローエントリを作ります。

PassDelegated#install_prefixes (lib/pass_delegated.rb)
def install_prefixes
  logger.info "#{@dpid.to_hex}: loading started"
  @prefixes.each do |each|
    send_flow_mod_add(@dpid,
                      priority: PRIORITY[:prefix],
                      match: Match.new(in_port: PORT[:outside],
                                       ether_type: 0x0800,
                                       source_ip_address: IPv4Address.new(each)),
                      actions: SendOutPort.new(PORT[:inside]))
  end
end

続く install_postamble メソッドでは、ドロップ用と IPv4 以外用の 2 種類のフローエントリを設定します。ドロップ用フローエントリは、外側ネットワークのグローバル IP アドレス以外からのパケットを inspector ホストに転送します。IPv4 以外用フローエントリは、外側ネットワークからの IPv4 以外のパケットをすべて内側ネットワークへ転送します。

PassDelegated#install_postamble (lib/pass_delegated.rb)
# Deny any other IPv4 and permit non-IPv4 traffic.
def install_postamble
  send_flow_mod_add(@dpid, (1)
                    priority: PRIORITY[:inspect],
                    match: Match.new(in_port: PORT[:outside], ether_type: 0x0800),
                    actions: SendOutPort.new(PORT[:inspect]))
  send_flow_mod_add(@dpid, (2)
                    priority: PRIORITY[:non_ipv4],
                    match: Match.new(in_port: PORT[:outside]),
                    actions: SendOutPort.new(PORT[:inside]))
end
1 ドロップ用フローエントリの設定
2 IPv4 以外用フローエントリの設定

最後に、すべてのフローエントリが実際にスイッチへ反映されるのをバリアで待った後、外側→内側へのバイパス用フローエントリを削除します。これによって、外側→内側へのグローバルアドレスを持たないホストからのパケットだけをフィルタリング用エントリで遮断できます。

PassDelegated#install_postamble (lib/pass_delegated.rb)
def barrier_reply(dpid, _message)
  return if dpid != @dpid
  finish_loading
end

private

# All flows in place, safe to remove bypass.
def finish_loading
  send_flow_mod_delete(@dpid,
                       strict: true,
                       priority: PRIORITY[:bypass],
                       match: Match.new(in_port: PORT[:outside]))
  logger.info(format('%s: loading finished in %.2f second(s)',
                     @dpid.to_hex, Time.now - @loading_started))
end

11.6. まとめ

ネットワーク機器のOpenFlow実装の一環として、2種類の透過型ファイアウォールを作りました。

  • 透過型ファイアウォールはルータとホストの間にはさむだけで使え、各ホストのネットワーク設定を変更しなくてよい

  • Flow Mod がスイッチに反映されたことを保証するには Barrier::Request メッセージを使う

続く章では、インターネットを構成する重要なネットワーク機器であるルータをOpenFlowで作ります。今までに学んできたOpenFlowやRubyプログラミングの知識を総動員しましょう。

12. ルータ (前編)

今まで学んだ知識を総動員して、ラーニングスイッチよりも高度なルータの実装に挑戦しましょう。まずは、スイッチとルータの違いをきちんと理解することからスタートです。

map

12.1. ルータとスイッチの違い

ルータとスイッチの一番大きな違いは、パケット転送に使う情報にあります。スイッチはパケットの転送に MAC アドレスを使う一方で、ルータは IP アドレスを使うのです。なぜ、ルータは IP アドレスを使うのでしょうか。MAC アドレスだけでパケットが届くなら、わざわざ IP アドレスを使う必要はありません。実はこれらの違いには、技術的な理由があるのです。

MAC アドレスでパケットを転送する LAN をイーサネットと呼びます。ネットワークを実装のレベルで分類すると、イーサネットはハードウェアに近いレベルのネットワークです。なぜかと言うと、イーサネットがパケットの転送に使う MAC アドレスは、ハードウェアであるネットワークカードを識別する情報だからです。この MAC アドレスは、パケットのイーサネットヘッダと呼ばれる部分に入っています。

実は、ハードウェアに近いパケット転送方式はイーサネット以外にもいくつもあります。イーサネットは、転送方式のうちのたった 1 つにすぎないのです。

  • ADSL や光ファイバーによるインターネット接続に使う PPP (Point to Point Protocol)。身近に使われている

  • WAN で使われる ATM (Asynchronous Transfer Mode) やフレームリレー。利用は徐々に減りつつある

  • リング型のネットワークを構成する FDDI (Fiber-Distributed Data Interface)。昔は、大学などの計算機室のワークステーションをつなげるためによく使われていた

これらの異なるネットワーク同士をつなぐのが IP (インターネットプロトコル) です。インターネットはその名のとおり、ネットとネットの間 (inter) をつなぐ技術で、イーサネットやその他のネットワークの一段上に属します。ルータはインターネットプロトコルでの識別情報である IP アドレスを使って、より低いレベルのネットワーク同士をつなぐのです。

12.2. イーサネットだけならルータは不要?

ここで1つの疑問が出てきます。いろいろある転送方式のうち、もしもイーサネットだけを使うのであれば、ルータによる中継は不要なのでしょうか。

いいえ、ルータは必要です。もしもルータを使わずに大きなネットワークを作ろうとすると、次の問題が起こります。

ブロードキャストの問題

ネットワーク上の情報の発見などのためにブロードキャストパケットを送ると、ネットワーク上のすべてのホストにこのパケットがばらまかれる。もし大きいネットワーク内でみんながブロードキャストを送ってしまうと、ネットワークがパンクしてしまう

セキュリティの問題

もし全体が 1 つのネットワークでどことでも自由に通信できてしまうと、他人の家や他社のホストと簡単に接続できてしまう。こうなると、プライバシー情報や機密データを守るのが大変になる

そこで、現実的にはイーサネットだけでネットワークを作る場合にも、家や会社の部署といった単位で小さなネットワークを作り、それらをルータでつなぐ場合が大半です。ルータがネットワーク間の門番としても働き、実際にパケットを転送するかしないかを制御することで、上の 2 つの問題を解決します。

12.3. ルータの動作

ルータの存在意義がわかったところで、一般的なイーサネットでのルータの動作を詳しく見ていきましょう。おおまかに言うと、ルータは複数のイーサネットをつなぐために、1) イーサネット間でのパケットの転送と、2) 正しく届けるのに必要なパケットの書き換え、の 2 つの仕事を行います。

12.3.1. パケットを書き換えて転送する

図 12-1 のホスト A がホスト B にパケットを送る場合を考えます。ホスト A は、送信元 IP アドレスがホスト A、宛先 IP アドレスがホスト B の IP パケットを作ります。

このパケットをホスト B に届けるためには、ルータに中継してもらわなくてはなりません。ルータにパケットを渡すために、ホスト A は、パケット中の宛先 MAC アドレスをルータの MAC アドレスに、また送信元をホスト A の MAC アドレスにして、出力します。このときのパケットの中身は、次のようになります。

router forward rewrite
図 12-1: ルータはパケットを転送するために、パケットのイーサネット部分だけを書き換える

ルータは、受け取ったパケットをホスト B に届けるために、MAC アドレスの書き換えを行います。ルータは、パケットの宛先をホスト B の MAC アドレスに、送信元をルータの MAC アドレスに書き換えてから、書き換えたパケットをホスト B へと転送します。

このパケットの書き換えと転送のために必要な処理を、1 つひとつ見ていきましょう。

ルータの MAC アドレスを教える

ルータがパケットを受け取るためには、ホストはルータの MAC アドレスを知る必要があります。IPアドレスから宛先のMACアドレスの情報を知るためにはARP(Address Resolution Protocol)というプロトコルを使います。ARPにはARPリクエストとARPリプライという2種類のパケットがあります。ホスト A は、パケットを送る前にルータの MAC アドレスを ARP リクエストで調べ、これを宛先 MAC アドレスとしてパケットに指定します。ルータは ARP リクエストを受け取ると、自身の MAC アドレスを ARP リプライとして返します (図 12-2)。

router arp reply
図 12-2: ルータは ARP リクエストに対し自分の MAC アドレスを応える
宛先ホストの MAC アドレスを調べる

ルータがパケットを宛先ホストに送るためには、宛先ホストの MAC アドレスを調べる必要があります。そこでルータは、宛先であるホスト B の MAC アドレスを調べるための ARP リクエストをホスト B へ送ります。このとき、ルータは、ホスト B の MAC アドレスを知らないので、ARP リクエストの宛先 MAC アドレスにブロードキャストアドレス (FF:FF:FF:FF:FF:FF) を指定します。

router arp request
図 12-3: 宛先ホストの MAC アドレスを問い合わせる

ARP を使って調べた MAC アドレスは、再利用するためにルータ内の ARP テーブルにキャッシュしておきます。これによって、同じホストに対してパケットを繰り返し送る場合、何度も ARP リクエストを送らなくてもすみます。

12.3.2. いくつものルータを経由して転送する

ルータが複数あるネットワークでの転送は、少し複雑になります (図 12-4)。たとえば、ホスト A がホスト B にパケットを送るとします。ルータ A は受け取ったパケットを転送する必要がありますが、宛先であるホスト B はルータ A とは直接はつながっていません。そのため、ルータ A はまずルータ B にパケットを転送し、ルータ B がそのパケットをホスト B へと転送します。

router network
図 12-4: ルータが複数あるネットワークでの転送

ルータ A の次の転送先となるルータは、パケットの宛先ごとに異なります。たとえばホスト A からホスト C へパケットを送る場合には、ルータ A はそのパケットをルータ C へと転送します。

次の転送先へと正しくパケットを送るために、各ルータは、宛先と次の転送先の対応を記録したルーティングテーブルを持っています。たとえば、ルータ A のルーティングテーブルは、図 12-4 に示すようになります。

ここまでで、ルータの基本動作の説明はおしまいです。それでは、基本的なルータの機能を実装した、シンプルルータのソースコードを読んでいきましょう。

12.4. ソースコード解説

シンプルルータ (SimpleRouter) のソースコードは、いくつのファイルからなります。紙面の都合上、以下ではメインのソースコード (lib/simple_router.rb) を中心に説明します。ソースコードは GitHub の trema/simple_router リポジトリ (https://github.com/trema/simple_router) からダウンロードできます。

$ git clone https://github.com/trema/simple_router.git
$ cd simple_router
$ bundle install --binstubs

12.4.1. Packet In ハンドラ

シンプルルータの主な動作は Packet In ハンドラから始まります。ハンドラ packet_in の中身は、次のようになっています。

SimpleRouter#packet_in (lib/simple_router.rb)
def packet_in(dpid, packet_in)
  return unless sent_to_router?(packet_in)

  case packet_in.data
  when Arp::Request
    packet_in_arp_request dpid, packet_in.in_port, packet_in.data
  when Arp::Reply
    packet_in_arp_reply dpid, packet_in
  when Parser::IPv4Packet
    packet_in_ipv4 dpid, packet_in
  else
    logger.debug "Dropping unsupported packet type: #{packet_in.data.inspect}"
  end
end
自分宛のパケットかを判定する

イーサネットにはルータ以外のホストがつながっている可能性があります。そこで Packet In メッセージが上がってきたときには、まずそのパケットが自分宛かどうかを判断します (sent_to_router? メソッド)。もし自分宛でない場合にはパケットを破棄します。

SimpleRouter#sent_to_router? (lib/simple_router.rb)
def sent_to_router?(packet_in)
  return true if packet_in.destination_mac.broadcast?
  interface = Interface.find_by(port_number: packet_in.in_port)
  interface && interface.mac_address == packet_in.destination_mac
end

この sent_to_router? メソッドはパケットの宛先 MAC アドレス (packet_in.destination_mac) をチェックします。宛先 MAC アドレスがブロードキャストである場合、もしくは受信ポート (packet_in.in_port) に割り当てられている MAC アドレス (interface.mac_address) と同じである場合、自分宛と判断します。

パケットの種類によって処理を切り替え

自分宛のパケットだとわかると、次にパケットの種類を判別します。シンプルルータが処理するパケットは、ARP のリクエストとリプライ、および IPv4 パケットの 3 種類です。PacketIn#data メソッドはパケットの種類に応じたオブジェクトを返すので、この返り値に応じてハンドラメソッドを呼び出します。

ARP リクエストのハンドル

ARP リクエストパケットを受信すると packet_in_arp_request メソッドを呼びます。ここでは、ARP リプライメッセージを作って Packet Out で ARP リクエストが届いたポートに出力します。

SimpleRouter#packet_in_arp_request (lib/simple_router.rb)
def packet_in_arp_request(dpid, in_port, arp_request)
  interface =
    Interface.find_by(port_number: in_port,
                      ip_address: arp_request.target_protocol_address)
  return unless interface
  send_packet_out(
    dpid,
    raw_data: Arp::Reply.new(
      destination_mac: arp_request.source_mac,
      source_mac: interface.mac_address,
      sender_protocol_address: arp_request.target_protocol_address,
      target_protocol_address: arp_request.sender_protocol_address
    ).to_binary,
    actions: SendOutPort.new(in_port))
end
ARP リプライのハンドル

ARP リプライパケットを受信すると、ARP テーブル (@arp_table) に MAC アドレスを記録します。ここでは PacketIn#sender_protocol メソッドを使って ARP パケット中の送信元 IP アドレスを取り出しています。

SimpleRouter#packet_in_arp_reply (lib/simple_router.rb)
def packet_in_arp_reply(dpid, packet_in)
  @arp_table.update(packet_in.in_port,
                    packet_in.sender_protocol_address,
                    packet_in.source_mac)
  flush_unsent_packets(dpid,
                       packet_in.data,
                       Interface.find_by(port_number: packet_in.in_port))
end

そして、flush_unsent_packets メソッドで宛先 MAC アドレスが解決していないパケットを送ります。この処理については後述します。

IPv4 パケットのハンドル

IPv4 パケットを受信すると、packet_in_ipv4 メソッドを呼びます。ルータに届く IPv4 パケットには次の 3 種類があり、それぞれで処理が異なります。

  1. パケットの転送が必要な場合

  2. 宛先 IP アドレスが自分宛だった場合

  3. それ以外だった場合 (パケットを破棄)

SimpleRouter#packet_in_ipv4 (lib/simple_router.rb)
def packet_in_ipv4(dpid, packet_in)
  if forward?(packet_in)
    forward(dpid, packet_in)
  elsif packet_in.ip_protocol == 1
    icmp = Icmp.read(packet_in.raw_data)
    packet_in_icmpv4_echo_request(dpid, packet_in) if icmp.icmp_type == 8
  else
    logger.debug "Dropping unsupported IPv4 packet: #{packet_in.data}"
  end
end

パケットを転送するかどうかの判定は forward? メソッドです。転送が必要な場合とは、次のようにパケットの宛先 IPv4 アドレスがルータのインタフェースに割り当てた IPv4 アドレスと異なる場合です。

SimpleRouter#forward? (lib/simple_router.rb)
def forward?(packet_in)
  !Interface.find_by(ip_address: packet_in.destination_ip_address)
end

パケットの宛先 IP アドレスがルータである場合、ルータ自身が応答します。シンプルルータでは、ICMP Echo リクエスト (ping) に応答する機能だけ実装しています。packet_in_icmpv4_echo_request メソッドは次のように ICMP Echo リクエストに応答します。

SimpleRouter#packet_in_icmpv4_echo_request (lib/simple_router.rb)
def packet_in_icmpv4_echo_request(dpid, packet_in)
  icmp_request = Icmp.read(packet_in.raw_data)
  if @arp_table.lookup(packet_in.source_ip_address)
    send_packet_out(dpid,
                    raw_data: create_icmp_reply(icmp_request).to_binary,
                    actions: SendOutPort.new(packet_in.in_port))
  else
    send_later(dpid,
               interface: Interface.find_by(port_number: packet_in.in_port),
               destination_ip: packet_in.source_ip_address,
               data: create_icmp_reply(icmp_request))
  end
end

まず送信元 IP アドレス (packet_in.source_ip_address) に対応する MAC アドレスを ARP テーブルから調べます。MAC アドレスをキャッシュしている場合には、create_icmp_reply で応答メッセージを作り、Packet Out で出力します。MAC アドレスをキャッシュしていない場合には、send_later メソッドで ARP が解決したときに後で転送します。これについても詳細は後述します。

12.4.2. パケットを書き換えて転送する

ルータの動作の核心、パケットを書き換えて転送する部分です。

SimpleRouter#forward (lib/simple_router.rb)
def forward(dpid, packet_in)
  next_hop = resolve_next_hop(packet_in.destination_ip_address)

  interface = Interface.find_by_prefix(next_hop)
  return if !interface || (interface.port_number == packet_in.in_port)

  arp_entry = @arp_table.lookup(next_hop)
  if arp_entry
    actions = [SetSourceMacAddress.new(interface.mac_address),
               SetDestinationMacAddress.new(arp_entry.mac_address),
               SendOutPort.new(interface.port_number)]
    send_flow_mod_add(dpid,
                      match: ExactMatch.new(packet_in), actions: actions)
    send_packet_out(dpid, raw_data: packet_in.raw_data, actions: actions)
  else
    send_later(dpid,
               interface: interface,
               destination_ip: next_hop,
               data: packet_in.data)
  end
end

この forward メソッドは、次の 5 つの処理を行います。

  1. ルーティングテーブルを参照し、次の転送先を決める (resolve_next_hop)

  2. 次の転送先に送るための、出力インタフェースを決める (Interface.find_by_prefix)

  3. インタフェースが見つかった場合、ARP アドレスから宛先 MAC アドレスを探す (@arp_table.lookup)

  4. MAC アドレスが見つかった場合、転送用のフローエンントリを書き込み、受信パケットを Packet Out する

  5. MAC アドレスが見つからなかった場合、send_later メソッドで後で転送する

このうち重要なのは 1 と 4 の処理です。1 で次の転送先を決める resolve_next_hop メソッドの詳細については次章で見ていきます。ここでは 4 の処理を詳しく見ていきましょう。

パケットの書き換えと転送 (Flow Mod と Packet Out)

ARP テーブルから宛先の MAC アドレスがわかると、パケットを書き換えて宛先へ出力するとともに、同様のパケットをスイッチ側で転送するためのフローエントリを書き込みます。図 12-1 で説明したように、ルータによるパケットの転送では MAC アドレスを書き換えます。forward メソッド内の変数 actions はこのためのアクションリストで、送信元と宛先 MAC アドレスの書き換え、そして該当するポートからの出力というアクションの配列です。このアクションは Flow Mod と Packet Out メッセージの送信に使います。

actions = [SetSourceMacAddress.new(interface.mac_address),
           SetDestinationMacAddress.new(arp_entry.mac_address),
           SendOutPort.new(interface.port_number)]
send_flow_mod_add(dpid,
                  match: ExactMatch.new(packet_in), actions: actions)
send_packet_out(dpid, raw_data: packet_in.raw_data, actions: actions)

12.4.3. ARP の解決後にパケットを転送する

ARP が未解決のパケットは転送できないため、解決するまで待つ必要があります。この「ARP 解決後に送る」という処理を行うのが、send_later メソッドです。たとえば ICMP Echo リプライの宛先 MAC アドレスが ARP テーブルからすぐわからない場合、次のように send_later メソッドを呼び出していました。

SimpleRouter#packet_in_icmpv4_echo_request (lib/simple_router.rb)
def packet_in_icmpv4_echo_request(dpid, packet_in)
  icmp_request = Icmp.read(packet_in.raw_data)
  if @arp_table.lookup(packet_in.source_ip_address)
    send_packet_out(dpid,
                    raw_data: create_icmp_reply(icmp_request).to_binary,
                    actions: SendOutPort.new(packet_in.in_port))
  else
    send_later(dpid,
               interface: Interface.find_by(port_number: packet_in.in_port),
               destination_ip: packet_in.source_ip_address,
               data: create_icmp_reply(icmp_request))
  end
end

send_later メソッドは data: で渡したパケットデータを ARP 解決後に自動的に転送します。転送に使うルータのインタフェースは interface: オプション、また送信先 IP アドレスは destination_ip: オプションでそれぞれ指定します。

send_later メソッドでは、ARP が未解決なパケットを宛先 IP アドレスごとにキュー (queue) に入れます。キューへの追加後に ARP リクエストを送ることで宛先の MAC アドレスを解決します。

SimpleRouter#send_later (lib/simple_router.rb)
def send_later(dpid, options)
  destination_ip = options.fetch(:destination_ip)
  @unresolved_packet_queue[destination_ip] += [options.fetch(:data)]
  send_arp_request(dpid, destination_ip, options.fetch(:interface))
end

キューにためたパケットを転送するのは ARP リプライが Packet In したタイミングです。packet_in_arp_reply の最後に呼び出している flush_unsent_packets がこの処理を行います。

SimpleRouter#flush_unsent_packets (lib/simple_router.rb)
def flush_unsent_packets(dpid, arp_reply, interface)
  destination_ip = arp_reply.sender_protocol_address
  @unresolved_packet_queue[destination_ip].each do |each|
    rewrite_mac =
      [SetDestinationMacAddress.new(arp_reply.sender_hardware_address),
       SetSourceMacAddress.new(interface.mac_address),
       SendOutPort.new(interface.port_number)]
    send_packet_out(dpid, raw_data: each.to_binary_s, actions: rewrite_mac)
  end
  @unresolved_packet_queue[destination_ip] = []
end

ここでは MAC アドレスが解決したパケットそれぞれに対して、送信元と宛先 MAC アドレスを書き換えるアクションを指定し Packet Out しています。

12.5. まとめ

従来のネットワーク機器をソフトウェアで実装したシンプルなルータの仕組みを学びました。

  • ルータはイーサネットよりも一段上の IP レベルでパケットを転送する。異なるイーサネット間でパケットを中継するために、ルータはパケットの MAC アドレスを書き換える

  • 宛先ホストの MAC アドレスを調べるために、ルータは ARP エリクエストを送り結果を ARP テーブルにキャッシュする。また、ルータ経由でパケットを送るホストのために、ルータは ARP リクエストに応える必要がある

  • いくつものルータを経由してパケットを転送するために、ルータはルーティングテーブルを使って次の転送先を決める

  • Packet In したパケットの判別や ARP、そして ICMP 等の処理を行うためのヘルパメソッドを、Trema はたくさん提供している

続く13 章では、ルータの動作にとって書かせないルーティングテーブルについて詳しく見たあと、いよいよこのルータを実行してみます。

13. ルータ (後編)

ルータが持つ重要な機能であるルーティングテーブルの詳細を見ていきましょう。ルータは実に巧妙な仕組みで転送先の情報を管理します。

map

13.1. 宛先ホストをまとめる

ルータが管理するルーティングテーブルは、宛先ホストが増えるごとに大きくなります。前編の説明では、ルータは宛先ホスト1つごとにルーティングテーブルのエントリを管理していました。しかしこれでは、たとえばインターネットにホストが加わるごとに、インターネット上のルータはルーティングテーブルを更新する必要があります。しかも、インターネット上のホスト数は直線的に増え続け、2016年現在では10億台を超えています。そうなると、宛先ホストごとにエントリを管理する方法は非現実的です。

これを解決するために、ルータは同じイーサネット上にあるホストを1つのグループとしてまとめます。そして、ルーティングテーブルの宛先として、ホストではなくこのグループを指定することで、エントリ数を圧縮します。このとき、グループ情報として使うのがネットワークアドレスとネットマスク長です。

router network2
図 13-1: 同じイーサネット上にあるホストを一つの宛先にまとめる

宛先ホストのグループ化は次のように行います。たとえば、図13-1の右側のネットワークは、ネットワークアドレスが192.168.1.0でネットマスク長が24です(これを192.168.1.0/24と表現します)。このネットワーク中にあるホストX,Y,Zには、ネットワークアドレスと上位24ビットが同じとなるように、つまりIPアドレスが192.168.1で始まるようにします。こうすれば、ホストX,Y,Zは同じ1つのグループ192.168.1.0/24に属するとみなせます。

このようにアドレスを振ることで、ルータAのルーティングテーブルは、図13-1のようにシンプルに書けます。ホストX,Y,Z宛てのパケットを192.168.1.0/24グループ宛てと表現することで、エントリを1つにまとめられるのです。

このとき、ホストX(192.168.1.1)宛のパケットを受け取ったルータAは次のように動作します。ルーティングテーブルのエントリ192.168.1.0/24と、パケットの宛先192.168.1.1との上位24ビットを比較すると一致します。そこで、ルーティングテーブルから次の転送先はルータBだとわかります。ホストY,Z宛も同様に処理できるので、このエントリ1つでホスト3台分の宛先をカバーできます。

13.1.1. 宛先ホストがルータと直接つながっているかを調べる

図13-1 では、ルータが宛先ホストに直接接続していない場合について説明しましたが、つながっている/いないはどのように判断するのでしょうか?

ルータは、その判断のために、自身のインタフェースに割り当てられた IP アドレスを使います。インタフェースに割り当てる IP アドレスには、ネットワーク中のホストとネットワークアドレスが同じ IP アドレスを用います。図13-2 で、ルータ B のインタフェースには、ホスト X, Y, Z と同じネットワークアドレスになるよう、例えばアドレス 192.168.1.254 を割り当てます。

router address
図 13-2: ルータのインタフェースには、ネットワーク内のホストとネットワークアドレスが同じとなるように IP アドレスを割り当てる

ここで 図13-2 のルータ B が、ホスト X (192.168.1.1) 宛のパケットを受け取った場合について考えます。ルータ B は、パケットの宛先アドレスを参照し、ネットワークアドレスが同じインタフェースを探します。この例では、192.168.1.254 というアドレスがついたインタフェースが見つかります。あとは、このインタフェースを通じて、ARP リクエストによる MAC アドレス問い合わせを行い、ホスト X 宛にパケットを出力します。

13.2. ネットワーク宛てのエントリをまとめる

複数のホスト宛てエントリをまとめて出来たエントリは、さらにまとめられる場合もあります。

aggregation
図 13-3: 複数のネットワークへのルーティング情報をまとめる

例として、図 13-3の3つのネットワークに接続するルータBを考えてみましょう。これら3つのネットワークアドレスは、上位16ビットが172.16.0.0で共通です。ここでルータAから見ると、この3つのネットワークへの次の転送先は、いずれもルータBです。そのため、これら3つのネットワークへのルーティング情報は、172.16.0.0/16宛として1つにまとめられます。

13.2.1. 1つの宛先に複数のエントリがマッチする場合

パケットの宛先 IP アドレスに複数のエントリが該当する場合はどうなるでしょうか?図13-4 のルータ A がホスト X (172.16.3.1) にパケットを送る場合について考えてみましょう。ルータ A が持つルーティングテーブルは、ルータ B につながる 3 つのネットワーク宛のエントリはまとめることで、図13-4のように 2 つのエントリにできます。しかし、このようにまとめてしまうと、宛先 172.16.3.1 のパケットは、どちらのエントリにもマッチしてしまいます。ルータは、どちらか正しいエントリを選択しなければいけません。

longest match
図 13-4: マスク長が一番長いエントリを選択する

複数のエントリにマッチする場合には、ネットマスク長が一番長いエントリを選択するというルールがあります。これをロンゲストマッチと呼びます。ロンゲストマッチにより、ルータAは、ホストX宛のパケットをルータCへと転送し、その結果ホストXへとパケットが届きます。

13.2.2. すべての宛先にマッチするデフォルトルート

すべての宛先をまとめたルーティング情報をデフォルトルートと呼び、その宛先を 0.0.0.0/0 と表します。ネットマスク長は、ルーティング情報をまとめるとき、ネットワークアドレスの共通部分の長さを表していました。デフォルトルートでは、まとめられた宛先には共通部分が全くないため、ネットマスク長は 0 となります。

default route
図 13-5: 0.0.0.0/0 は、すべての宛先にマッチする

図13-5のように、インターネットに接続するネットワークでのルーティングテーブルについて考えてみましょう。インターネット上のホスト数は膨大なので、宛先ホストをネットワーク単位にまとめたとしても、数十万エントリを扱う必要があります。しかし、図13-5のようにインターネットへの出口が1か所だけの場合、エントリをデフォルトルート1つにまとめられます。これによって、ルーティングテーブル中のエントリ数を大きく減らせます。

図13-5 のように、インターネットとは別にネットワーク (172.16.3.0/24) があっても、デフォルトルートを使うことに問題はありません。172.16.3.0/24 宛のパケットがルータ A に届いた場合、ルータはロンゲストマッチからルータ C へのエントリを選択します。それ以外のパケットは、デフォルトルートによってルータ B へ転送し、インターネットへと転送します。

13.3. RoutingTable のソースコード

13.3.1. パケットを書き換えて転送する(再)

RoutingTable クラスのソースコードを見る前に、パケットの書き換えと転送を行う forward メソッドをもう一度見ていきましょう。前章で説明したこのメソッドが行う 5 つの処理のうち、次の転送先と出力インタフェースを決める方法を見ていきます。

SimpleRouter#forward (lib/simple_router.rb)
def forward(dpid, packet_in)
  next_hop = resolve_next_hop(packet_in.destination_ip_address)

  interface = Interface.find_by_prefix(next_hop)
  return if !interface || (interface.port_number == packet_in.in_port)

  arp_entry = @arp_table.lookup(next_hop)
  if arp_entry
    actions = [SetSourceMacAddress.new(interface.mac_address),
               SetDestinationMacAddress.new(arp_entry.mac_address),
               SendOutPort.new(interface.port_number)]
    send_flow_mod_add(dpid,
                      match: ExactMatch.new(packet_in), actions: actions)
    send_packet_out(dpid, raw_data: packet_in.raw_data, actions: actions)
  else
    send_later(dpid,
               interface: interface,
               destination_ip: next_hop,
               data: packet_in.data)
  end
end

宛先アドレス (packet_in.destination_ip_address) に対する次転送先の決定は、resolve_next_hop メソッドで行います。

SimpleRouter#resolve_next_hop (lib/simple_router.rb)
def resolve_next_hop(destination_ip_address)
  interface = Interface.find_by_prefix(destination_ip_address)
  if interface
    destination_ip_address
  else
    @routing_table.lookup(destination_ip_address)
  end
end

このメソッドでは、まず宛先アドレスと同じネットワークアドレスを持つインタフェースを探します。もし見つかった場合には、次の転送先として宛先アドレスをそのまま返します。見つからなった場合には、ルーティングテーブルから次の転送先を検索します。

その後 forward メソッドへ戻り、決定した次の転送先がルータのインタフェースに接続しているかを判定します。

SimpleRouter#forward (lib/simple_router.rb)
interface = Interface.find_by_prefix(next_hop)
return if !interface || (interface.port_number == packet_in.in_port)

この判定は、次の転送先と同一のネットワークアドレスを持つインタフェースがあるかどうかを調べればわかります。もし、該当するインタフェースがない場合、ルータはそのパケットを転送できないので、パケットを破棄して転送処理を終えます。

13.3.2. ルーティングテーブル (RoutingTable クラス) の実装

次にルーティングテーブルのソースコードを見ていきます。

lib/routing_table.rb
# Routing table
class RoutingTable
  include Pio

  MAX_NETMASK_LENGTH = 32

  def initialize(route)
    @db = Array.new(MAX_NETMASK_LENGTH + 1) { Hash.new }
    route.each { |each| add(each) }
  end

  def add(options)
    netmask_length = options.fetch(:netmask_length)
    prefix = IPv4Address.new(options.fetch(:destination)).mask(netmask_length)
    @db[netmask_length][prefix.to_i] = IPv4Address.new(options.fetch(:next_hop))
  end

  def lookup(destination_ip_address)
    MAX_NETMASK_LENGTH.downto(0).each do |each|
      prefix = destination_ip_address.mask(each)
      entry = @db[each][prefix.to_i]
      return entry if entry
    end
    nil
  end
end

インスタンス変数 @db はルーティングテーブルで、ネットマスク長ごと (0 〜 32) に経路を管理します。経路情報はネットワークアドレスをキーとし、次の転送先 IP アドレスを値とするハッシュテーブルです。

ルーティングテーブルの検索は、lookup メソッドで行います。このメソッドでは、宛先 destination_ip_address に該当する次の転送先 IP アドレスを @db 中から探します。ロンゲストマッチを行うために、ネットマスク長が長い順 (32…0) に @db から次の転送先 IP アドレスを探索します。

13.3.3. コンフィグ

ルータが動作するためには、インタフェースのアドレスとルーティングテーブルの設定が必要です。シンプルルータでは、これらの設定を simple_router.conf に記述します。

simple_router.conf
# Simple router configuration
module Configuration
  INTERFACES = [
    {
      port: 1,
      mac_address: '01:01:01:01:01:01',
      ip_address: '192.168.1.1',
      netmask_length: 24
    },
    {
      port: 2,
      mac_address: '02:02:02:02:02:02',
      ip_address: '192.168.2.1',
      netmask_length: 24
    }
  ]

  ROUTES = [
    {
      destination: '0.0.0.0',
      netmask_length: 0,
      next_hop: '192.168.1.2'
    }
  ]
end

インタフェースの設定では、そのインタフェースの MAC アドレス (:mac_address)、IP アドレス (:ip_address)、ネットマスク長 (:netmask_length) と、このインタフェースが OpenFlow スイッチのどのポート (:port) に対応しているかを指定します。

ルーティングテーブルの設定では、宛先 (:destination)、ネットマスク長 (:netmask_length) と次の転送先 (:next_hop) を指定します。

13.4. 実行してみよう

いよいよシンプルルータを動かしてみましょう。いろいろなパケットの送受信を行うために、今回は仮想ホストではなくネットワークネームスペースを使います。今まで使ってきた仮想ホストは簡単なパケット送受信機能とカウンタを備えているので、コントローラの初歩的な導通テストには便利でした。ただしコントローラに ssh や http といった通信を流したりベンチマークを計測する場合など、仮想ホストだけでは機能が不十分な場合もあります。ネットワークネームスペース機能を使えば、ping や iperf といったおなじみのツールをはじめ、任意のアプリケーションを仮想ネットワーク上で動かせます。

sample router network
図 13-6: シンプルルータを動かすための構成

たとえば図 13-6 のような IP アドレスとデフォルトルートを持つホスト 2 台をスイッチに接続するには、次のように設定ファイルで vhost の代わりに netns を指定することで、独立した仮想的なネットワーク環境であるネットワークネームスペースを作れます。それぞれの netns のルーティング情報は route で指定できます。

trema.conf
vswitch('0x1') { dpid 0x1 }
netns('host1') {
  ip '192.168.1.2'
  netmask '255.255.255.0'
  route net: '0.0.0.0', gateway: '192.168.1.1'
}
netns('host2') {
  ip '192.168.2.2'
  netmask '255.255.255.0'
  route net: '0.0.0.0', gateway: '192.168.2.1'
}
link '0x1', 'host1'
link '0x1', 'host2'

この設定ファイルを指定し trema runlib/simple_router.rb を実行すれば、図 13-6のネットワーク環境でシンプルルータが起動します。

$ ./bin/trema run ./lib/simple-router.rb -c ./trema.conf

ネットワークネームスペース内で任意のコマンドを起動するためには、trema netns コマンドを使います。たとえば、次のコマンドを実行すると host1 の環境上でシェルを起動できます。

$ ./bin/trema netns host1

起動したシェル上で、ためしに host1 環境のルーティングテーブルを確認してみましょう。

$ route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
0.0.0.0         192.168.1.1     0.0.0.0         UG    0      0        0 host1
192.168.1.0     0.0.0.0         255.255.255.0   U     0      0        0 host1

たしかに、デフォルトゲートウェイは設定ファイル通り 192.168.1.1 となっています。このように、trema netns で起動したシェル上では通常のコマンドを指定した環境上で実行できます。シェルを終了するには exit または Ctrl+d です。

13.4.1. ping で動作を確認する

最初は簡単に ping を使ってシンプルルータが正しく動作しているかを順に確認して行きましょう。まずは、シンプルルータが ping に応答するかどうかの確認です。host1 にログインし、次のようにシンプルルータの IP アドレス 192.168.1.1 に ping を打ってみます。

$ ./bin/trema netns host1
$ ping 192.168.1.1
PING 192.168.1.1 (192.168.1.1) 56(84) bytes of data.
64 bytes from 192.168.1.1: icmp_seq=1 ttl=128 time=47.4 ms
64 bytes from 192.168.1.1: icmp_seq=2 ttl=128 time=15.0 ms
64 bytes from 192.168.1.1: icmp_seq=3 ttl=128 time=15.0 ms
64 bytes from 192.168.1.1: icmp_seq=4 ttl=128 time=19.3 ms
64 bytes from 192.168.1.1: icmp_seq=5 ttl=128 time=14.8 ms
64 bytes from 192.168.1.1: icmp_seq=6 ttl=128 time=14.4 ms
64 bytes from 192.168.1.1: icmp_seq=7 ttl=128 time=15.1 ms
^C
--- 192.168.1.1 ping statistics ---
7 packets transmitted, 7 received, 0% packet loss, time 6008ms
rtt min/avg/max/mdev = 14.425/20.189/47.473/11.245 ms

ちゃんと ping が返ってきました。次に、シンプルルータをまたいだ二つのホスト間で通信できることも確認してみましょう。host2 の IP アドレス 192.168.2.2 に対して、host1 から ping を送ります。

$ ping 192.168.2.2
PING 192.168.2.2 (192.168.2.2) 56(84) bytes of data.
64 bytes from 192.168.2.2: icmp_seq=1 ttl=64 time=75.5 ms
64 bytes from 192.168.2.2: icmp_seq=2 ttl=64 time=82.3 ms
64 bytes from 192.168.2.2: icmp_seq=3 ttl=64 time=101 ms
64 bytes from 192.168.2.2: icmp_seq=4 ttl=64 time=83.3 ms
64 bytes from 192.168.2.2: icmp_seq=5 ttl=64 time=78.2 ms
64 bytes from 192.168.2.2: icmp_seq=6 ttl=64 time=76.4 ms
64 bytes from 192.168.2.2: icmp_seq=7 ttl=64 time=70.9 ms
^C
--- 192.168.2.2 ping statistics ---
7 packets transmitted, 7 received, 0% packet loss, time 6008ms
rtt min/avg/max/mdev = 70.995/81.159/101.180/9.050 ms

この場合もちゃんと ping が返ってきています。以上より、シンプルルータのパケット転送を確認できました。

13.4.2. iperf で動作を確認する

次は iperf でネットワークスループットを計測してみましょう。まずは iperf をインストールします。

$ sudo apt-get update
$ sudo apt-get install iperf

iperf はサーバ・クライアント型のアプリケーションなので、まずは host2 上で次のように iperf のサーバを起動しておきます。

$ ./bin/trema netns host2
$ iperf -s --bind 192.168.2.2
------------------------------------------------------------
Server listening on TCP port 5001
Binding to local address 192.168.2.2
TCP window size: 85.3 KByte (default)
------------------------------------------------------------

host1 上では次のように iperf のクライアントを起動し、ベンチマークを実行します。

$ ./bin/trema netns host1
$ iperf -c 192.168.2.2 --bind 192.168.1.2
------------------------------------------------------------
Client connecting to 192.168.2.2, TCP port 5001
Binding to local address 192.168.1.2
TCP window size: 85.0 KByte (default)
------------------------------------------------------------
[  3] local 192.168.1.2 port 5001 connected with 192.168.2.2 port 5001
[ ID] Interval       Transfer     Bandwidth
[  3]  0.0-16.4 sec   256 KBytes   128 Kbits/sec

仮想環境上なのでこの数字にはほとんど意味はありませんが、ネットワークネームスペースを使えばこのように iperf や ssh, httpd といったサーバ・クライアント型のアプリケーションも実行できます。

13.5. まとめ

ルータ編のまとめとして、もっとも重要な機能であるルーティングテーブルを詳しく説明しました。

  • ルーティングテーブルの複数のエントリ(宛先がホストのIPアドレス)を1つのエントリ(宛先がネットワークアドレス)にまとめることで、エントリ数を減らせる

  • こうしてまとめられたエントリは、ネットワークアドレスの一部が同じ他のエントリとまとめることで、さらにエントリ数を減らせる

  • パケットの宛先にマッチするエントリがルーティングテーブルに複数ある場合は、ネットマスクがもっとも長いエントリを優先 (ロンゲストマッチ) する

  • ルーティングテーブルの宛先に0.0.0.0/0を指定することで、パケットがエントリにマッチしなかった場合のデフォルトの宛先、つまりデフォルトルートを設定できる

  • ネットワークネームスペースを使えば、任意のアプリケーションを使ってコントローラをテストできる

14. ルータ (マルチプルテーブル編)

OpenFlow1.3 のマルチプルテーブルを使うことで、ルータの機能の大部分をフローテーブルとして実装してみましょう

14.1. マルチプルテーブル版ルータのテーブル構成

マルチプルテーブル版ルータは図 14-1の 7 つのテーブルで動作します。Ingress テーブルに入ったパケットはその種類が ARP か IPv4 かによって 2 通りのパスを通り、Egress テーブルから出力します。

router multiple tables overview
図 14-1: マルチプルテーブル版ルータのテーブル構成

それぞれのテーブルの役割とフローエントリを見て行きましょう。

14.1.1. Ingress テーブル、Protocol Classifier テーブル

パケットは最初、テーブル ID が 0 番の Ingress テーブルに入ります (図 14-2)。

ingress and protocol classifier table
図 14-2: パケットは最初にテーブル ID = 0 の Ingress テーブルに入り、そのまま Protocol Classifier テーブルへ渡される

見ての通り Ingress テーブルでは何も処理をせず、パケットをそのまま Protocol Classifier テーブルへと渡します。

Protocol Classifier テーブルは、Ingress テーブルから入ったパケットをその種類によって仕分けします。パケットの仕分けにはマッチフィールドの ether_type を使います。これが ARP の場合には ARP Responder テーブルへ、IPv4 の場合には Routing Table テーブルへとそれぞれパケット処理を引き継ぎます。

14.1.2. ARP Responder テーブル

パケットが ARP だった場合、ARP Responder テーブルがパケットを処理します。ARP Responder テーブルは ARP パケットをタイプ別にそれぞれ次のように処理します。

ホストからルータへの ARP Reply

ホストの MAC アドレスを学習する

ルータのポート宛の ARP Request

ホストの MAC アドレスを学習し、ポートの MAC アドレスを ARP Reply で返す

それ以外の ARP

パケットを書き換えて適切なポートから転送する

ARP Responder テーブルは、前記 3 種類の処理×ポート数分のフローエントリを持ちます。たとえばポートが 2 つの場合には、図 14-3のようにフローエントリ数は全部で 6 つです。それぞれのフローエントリの具体的な働きについては後述します。

arp responder table
図 14-3: ARP Responder テーブルのフローエントリ例

14.1.3. Routing Table テーブル

パケットが IPv4 だった場合、Routing Table, Interface Lookup そして ARP Table Lookup の 3 つのテーブルによってパケットを処理します。

パケットが最初に入る Routing Table テーブルは、パケットのネクストホップをロンゲストマッチで決定します。そして、決定したネクストホップをレジスタ reg0 に入れます。それぞれのフローエントリの具体的な働きについては後述します。

routing table table
図 14-4: ARP Responder テーブルのフローエントリ例
コラム: レジスタ

レジスタはパケットごとに8個まで (reg0〜reg7) の任意の値を設定できる32ビットの変数です。使い道としては、たとえば Routing Table テーブルで見たように IP アドレス (整数表現) などの即値を入れたり、パケットのフィールド値を入れたりできます。そして、セットしたレジスタの値はマッチフィールドの条件として使えるほか、SendOutPortの出力先ポート番号としても使えます。

このようにレジスタ機能は非常に柔軟で強力ですが、対応しているスイッチ実装が限られます。レジスタ機能はNicira社による独自拡張であるため、Nicira 拡張に対応した Open vSwitch などの高機能なスイッチでしか使えません。もしこうした拡張機能を使いたい場合には、スイッチのスペックをよく確認しておきましょう。

14.1.4. Interface Lookup テーブル

Interface Lookup テーブルはパケットをどのポートから出力するかを決定します (図 14-5)。Routing Table テーブルで設定した reg0 のネクストホップを元に、出力先のポート番号を決定し reg1 にセットします。そして、パケットの送信元 MAC アドレスをポートの MAC アドレスに書き換えます。この動作はポートごとに異なるため、フローエントリ数はルータのポート数と同じになります。

interface lookup table
図 14-5: Interface Lookup テーブルのフローエントリ例

14.1.5. ARP Table Lookup テーブル

ARP Table Lookup テーブルはパケットの宛先 MAC アドレスを設定します (図 14-6)。 ネクストホップ (reg0) から、対応するホストの MAC アドレスをパケットの宛先 MAC アドレスとして書き込みます。

ルータを起動した直後にはフローエントリ数は 1 つですが、ARP Reply を受け取り新しい MAC アドレスを学習するたびにフローエントリ数が増えます。それぞれのフローエントリの具体的な働きについては後述します。

arp table lookup table
図 14-6: ARP Table Lookup テーブルのフローエントリ例

14.1.6. Egress テーブル

フローテーブルから出力するパケットはすべて Egress テーブルを通ります (図 14-7)。Egress テーブルはレジスタ reg1 が指すポートにパケットを出力します。

egress table
図 14-7: Egress テーブルはパケットをポート reg1 番へ出力する

14.2. マルチプルテーブル版ルータの動作例

マルチプルテーブル版ルータの動作例をいくつか、図 14-8の構成で詳しく見て行きましょう。

sample router network openflow13
図 14-8: マルチプルテーブル版ルータを動かすためのネットワーク構成

以降の説明で参照するマルチプルテーブル版ルータのソースコードは、GitHub の trema/simple_router リポジトリに入っています。次のコマンドでソースコードを取得してください。

$ git clone https://github.com/trema/simple_router.git

依存する gem のインストールは、いつも通り bundle install コマンドです。

$ cd simple_router
$ bundle install --binstubs

これで準備は完了です。

14.2.1. ポート宛の ARP Request に応答する

host1 がルータのポート 1 番宛に ARP Request を送信した場合、フローテーブルは図 14-9の 2 つの処理を行います:

  1. host1 の MAC アドレスの学習

  2. ARP Reply を host1 へ送信

handle arp request
図 14-9: host1 がルータのポート 1 宛に ARP Request を送信した場合
host1 の MAC アドレスの学習

ポート 1 番に届いた ARP Request は、Ingress テーブルから Protocol Classifier を経て ARP Responder のフローエントリにマッチします (図 14-9 の1)。そして ARP Request を送った host1 の MAC アドレスを学習するため、SendOutPort アクションでコントローラへと Packet In します (図 14-9 の 2)。

コントローラでは、Packet In の送信元 IP アドレスと MAC アドレスを学習します。この学習は、ARP Table Lookup テーブルに host1 のフローエントリを追加することで行います (図 14-9 の 3)。

SimpleRouter13#add_arp_entry (lib/simple_router13.rb)
def add_arp_entry(ip_address, mac_address, dpid)
  send_flow_mod_add(
    dpid,
    table_id: ARP_TABLE_LOOKUP_TABLE,
    priority: 2,
    match: Match.new(ether_type: EthernetHeader::EtherType::IPV4,
                     reg0: IPv4Address.new(ip_address).to_i),
    instructions: [Apply.new(SetDestinationMacAddress.new(mac_address)),
                   GotoTable.new(EGRESS_TABLE)]
  )
end
ARP Reply を host1 へ送信

コントローラを使わずにフローテーブルだけで ARP Reply を返すために、届いた ARP Request を ARP Reply へ書き換えます。書き換えに必要なアクションは多いですが、やっていることは単純です。

  • イーサヘッダの source_mac_address の値を destination_mac_address にコピー

  • source_mac_address の値をインタフェースの MAC アドレスの MAC アドレスの値にセット

  • ARP operation の値を ARP Reply にセット

  • ARP の sender_hardware_address (送信元の MAC アドレス) の値を target_hardware_address (宛先の MAC アドレス) にコピー

  • ARP の sender_protocol_address (送信元の IP アドレス) の値を target_protocol_address (宛先の IP アドレス) にコピー

  • ARP の sender_hardware_address をインタフェースの MAC アドレスの値にセット

  • ARP の sender_protocol_address をインタフェースの IP アドレスの値にセット

そして最後に、作った ARP Reply の出力先ポート番号 1 (= host1 のつながるポート番号) を reg1 にセットし、ARP Reply を Egress テーブルへ渡します (図 14-9 の 4)。Egress テーブルはこのポート reg1 へ ARP Reply を出力します。

14.2.2. host1 から host2 へ ping する

図 14-8 においてもう少し複雑な、host1 から host2 へ ping を打った場合を考えてみましょう。まずはルータが host2 へ ICMP Echo Request を届ける動作をおさらいします。

  1. host1 が出力した ICMP Echo Request がスイッチのポート 1 番に届く

  2. ルータはルーティングテーブルから転送先ポートを 2 番と決定する

  3. host2 の MAC アドレスを調べるため、ルータはポート 2 番から ARP Request を出力する

  4. host2 は自分の MAC アドレスを乗せた ARP Reply を出力する

  5. ルータは ICMP Echo Request の送信元と宛先をそれぞれ書き換えて host2 へ転送する

これに対応するフローテーブルの動作を図 14-10 で見て行きましょう。ポート 1 番に届いた ICMP Echo Request は、Ingress テーブルから Protocol Classifier を経て Routing Table のフローエントリにマッチします (図 14-10 の 1)。Routing Table と Interface Lookup テーブルではロンゲストマッチの処理を行います。

send arp request
図 14-10: host1 が host2 へ ICMP Echo Request を送信したときに host2 の MAC アドレスを解決するまでの動作

14.2.3. ロンゲストマッチの処理

ロンゲストマッチでは、パケットの宛先 IP アドレスからネクストホップと出力ポート番号を決定します。これを Routing Table と Interface Lookup テーブルの 2 つで行います。Routing Table では、パケットの宛先 IP アドレスがポート 2 のネットワークのフローエントリにマッチします[24]。そこで、ネクストホップ 192.168.2.2 を reg0 へ入れます。そして、Interface Lookup テーブルではネクストホップに対応する出力ポート 2 を reg1 にセットします。

14.2.4. host2 へ ARP Request を送る

次に ARP Table Lookup テーブルで host2 の MAC アドレスを解決します。host2 の MAC アドレスはまだ学習していないので、ARP Request を送るためコントローラへいったんパケットを Packet In します (図 14-10 の 2)。

コントローラは Packet In を受け取ると、パケットを「ARP 解決待ちパケットキュー」に追加します。そして、host2 の MAC アドレスを解決するために ARP Request をフローテーブルへ Packet Out します (図 14-10 の 3)。その際、ARP Request には reg1 (出力先ポート) に 2 をセットしておきます。

SimpleRouter13#packet_in_ipv4 (lib/simple_router13.rb)
def packet_in_ipv4(dpid, packet_in)
  dest_ip_address = IPv4Address.new(packet_in.match.reg0.to_i)
  @unresolved_packet_queue[dest_ip_address] += [packet_in.raw_data]
  send_packet_out(
    dpid,
    raw_data: Arp::Request.new(target_protocol_address: dest_ip_address,
                               source_mac: '00:00:00:00:00:00',
                               sender_protocol_address: '0.0.0.0').to_binary,
    actions: [NiciraRegLoad.new(packet_in.match.reg1, :reg1),
              SendOutPort.new(:table)]
  )
end

フローテーブルへ入った ARP Request は、ARP Responder テーブルのフローエントリにマッチします。そして、reg1 の値から ARP Request の MAC アドレスと IP アドレスをポート 2 のものにセットしたのち、Egress テーブルから host2 へと転送します。

14.2.5. host2 からの ARP Reply の処理

host2 からの ARP Reply が届くと、コントローラに Packet In します (図 14-11 の 1, 2)。

handle arp reply
図 14-11: host2 の MAC アドレスを学習し ICMP Echo Request を host2 に届けるまでの動作

ARP Reply を受け取ったコントローラは次のように動作します。まず、ARP Reply で解決した host2 の MAC アドレス用フローエントリを ARP Table Lookup テーブルに追加します (図 14-11 の 3)。そして、MAC アドレス未解決で送信待ちになっていたパケットをすべて、Packet Out で再び Ingress テーブルに入れます (図 14-11 の 4)。

SimpleRouter13#packet_in_arp (lib/simple_router13.rb)
def packet_in_arp(dpid, packet_in)
  add_arp_entry(packet_in.sender_protocol_address,
                packet_in.sender_hardware_address,
                dpid)
  @unresolved_packet_queue[packet_in.sender_protocol_address].each do |each|
    send_packet_out(dpid, raw_data: each, actions: SendOutPort.new(:table))
  end
  @unresolved_packet_queue[packet_in.sender_protocol_address] = []
end

以上で host1 から host2 への ICMP Echo Request が届きます。戻りの host2 からの ICMP Echo Reply についても、同様の動作で host1 へと届きます。

14.3. 実行してみよう

マルチプルテーブル版ルータ (lib/simple_router13.rb) の使いかたは12 章13 章で紹介したルータと変わりません。ただし OpenFlow1.3 を使うので、trema run の起動オプションに --openflow13 を付けるのを忘れないでください。

$ ./bin/trema run ./lib/simple-router.rb -c ./trema.conf --openflow13
SimpleRouter13 started.

コントローラが起動したら、ためしに host1 から host2 へ ping を送ってみましょう。

$ bundle exec trema netns host1 "ping -c1 192.168.2.2"
PING 192.168.2.2 (192.168.2.2) 56(84) bytes of data.
64 bytes from 192.168.2.2: icmp_seq=1 ttl=64 time=132 ms

--- 192.168.2.2 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 132.738/132.738/132.738/0.000 ms

たしかに host2 へ届いています。trema dump_flows コマンドでマルチプルテーブルのフローエントリを眺めてみましょう。

$ bundle exec trema dump_flows 0x1
OFPST_FLOW reply (OF1.3) (xid=0x2):
 cookie=0x0, duration=153.160s, table=0, n_packets=21, n_bytes=1546, priority=0 actions=goto_table:1
 cookie=0x0, duration=153.160s, table=1, n_packets=6, n_bytes=296, priority=0,arp actions=goto_table:2
 cookie=0x0, duration=153.160s, table=1, n_packets=4, n_bytes=392, priority=0,ip actions=goto_table:3
 cookie=0x0, duration=153.152s, table=2, n_packets=1, n_bytes=42, priority=0,arp,in_port=1,arp_tpa=192.168.1.1,arp_op=1 actions=move:NXM_OF_ETH_SRC[]->NXM_OF_ETH_DST[],set_field:01:01:01:01:01:01->eth_src,set_field:2->arp_op,move:NXM_NX_ARP_SHA[]->NXM_NX_ARP_THA[],move:NXM_OF_ARP_SPA[]->NXM_OF_ARP_TPA[],set_field:01:01:01:01:01:01->arp_sha,set_field:192.168.1.1->arp_spa,load:0xffff->OXM_OF_IN_PORT[],load:0x1->NXM_NX_REG1[],goto_table:6
 cookie=0x0, duration=153.142s, table=2, n_packets=1, n_bytes=42, priority=0,arp,in_port=1,arp_tpa=192.168.1.1,arp_op=2 actions=CONTROLLER:65535
 cookie=0x0, duration=153.103s, table=2, n_packets=1, n_bytes=42, priority=0,arp,in_port=2,arp_tpa=192.168.2.1,arp_op=1 actions=move:NXM_OF_ETH_SRC[]->NXM_OF_ETH_DST[],set_field:02:02:02:02:02:02->eth_src,set_field:2->arp_op,move:NXM_NX_ARP_SHA[]->NXM_NX_ARP_THA[],move:NXM_OF_ARP_SPA[]->NXM_OF_ARP_TPA[],set_field:02:02:02:02:02:02->arp_sha,set_field:192.168.2.1->arp_spa,load:0xffff->OXM_OF_IN_PORT[],load:0x2->NXM_NX_REG1[],goto_table:6
 cookie=0x0, duration=153.093s, table=2, n_packets=1, n_bytes=42, priority=0,arp,in_port=2,arp_tpa=192.168.2.1,arp_op=2 actions=CONTROLLER:65535
 cookie=0x0, duration=153.130s, table=2, n_packets=1, n_bytes=64, priority=0,arp,reg1=0x1 actions=set_field:01:01:01:01:01:01->eth_src,set_field:01:01:01:01:01:01->arp_sha,set_field:192.168.1.1->arp_spa,goto_table:6
 cookie=0x0, duration=153.083s, table=2, n_packets=1, n_bytes=64, priority=0,arp,reg1=0x2 actions=set_field:02:02:02:02:02:02->eth_src,set_field:02:02:02:02:02:02->arp_sha,set_field:192.168.2.1->arp_spa,goto_table:6
 cookie=0x0, duration=153.064s, table=3, n_packets=2, n_bytes=196, priority=40024,ip,nw_dst=192.168.1.0/24 actions=move:NXM_OF_IP_DST[]->NXM_NX_REG0[],goto_table:4
 cookie=0x0, duration=153.055s, table=3, n_packets=2, n_bytes=196, priority=40024,ip,nw_dst=192.168.2.0/24 actions=move:NXM_OF_IP_DST[]->NXM_NX_REG0[],goto_table:4
 cookie=0x0, duration=153.073s, table=3, n_packets=0, n_bytes=0, priority=0,ip actions=load:0xc0a80102->NXM_NX_REG0[],goto_table:4
 cookie=0x0, duration=153.047s, table=4, n_packets=2, n_bytes=196, priority=0,reg0=0xc0a80100/0xffffff00 actions=load:0x1->NXM_NX_REG1[],set_field:01:01:01:01:01:01->eth_src,goto_table:5
 cookie=0x0, duration=153.039s, table=4, n_packets=2, n_bytes=196, priority=0,reg0=0xc0a80200/0xffffff00 actions=load:0x2->NXM_NX_REG1[],set_field:02:02:02:02:02:02->eth_src,goto_table:5
 cookie=0x0, duration=122.241s, table=5, n_packets=1, n_bytes=98, priority=2,ip,reg0=0xc0a80202 actions=set_field:1e:36:b3:90:02:e5->eth_dst,goto_table:6
 cookie=0x0, duration=122.180s, table=5, n_packets=1, n_bytes=98, priority=2,ip,reg0=0xc0a80102 actions=set_field:e6:b6:de:b6:ed:1e->eth_dst,goto_table:6
 cookie=0x0, duration=153.027s, table=5, n_packets=2, n_bytes=196, priority=1,ip actions=CONTROLLER:65535
 cookie=0x0, duration=153.022s, table=6, n_packets=6, n_bytes=408, priority=0 actions=output:NXM_NX_REG1[]

それぞれのエントリの table=数字 の項目がテーブル ID を指しています。この章のマルチプルテーブル構成と見比べて、実際にどれがどのフローエントリかを確認してみてください。ping などでパケットを送受信しながら、フローエントリごとのパケットカウンタ (n_packets=数字) の値を確認していくと、より理解が深まることでしょう。

14.4. まとめ

OpenFlow1.3 のマルチプルテーブルを使うことで、ルータの機能の大部分をフローテーブルとして実装しました。パケットの種類や処理ごとにテーブルを分割することで、ルータのように複雑な機能もマルチプルテーブルとして実装できます。

15. ネットワークトポロジを検出する

大規模なネットワークを構築する前準備として、ネットワークトポロジを OpenFlow で検出してみましょう

15.1. 美しい大規模ネットワーク

筆者はネットワーク研究者という仕事柄、よくさまざまなネットワークを目にします。その中でいつも「すごい!」とうならされるのが、ネットワークエンジニアの憧れ、ShowNet です。ShowNet はネットワーク系最大の展示会 Interop Tokyo の期間中だけ運用されるネットワークで、最新ネットワーク技術のいわばショーケースと言えます。普段は触れることのできない、ネットワーク界の F1 マシンとも言える最新機器を集めたライブデモンストレーションは圧巻の一言です。

ShowNet の魅力をもっともよく伝えてくれるのが、Interop Tokyo で毎年公開される ShowNet のトポロジ図です (図 15-1)。注目すべきは、ShowNet の複雑な情報をたった一枚の図に収めているところです。「この部分は、いったいどんなプロトコルで動いているんだろう?」「実際の詳しいトポロジはどうなっているのかな?」こうした気になる部分が、すべて一枚の図にきれいに収まっています。ネットワークが好きな人であれば、気がつくと何時間でも眺めてしまうほどの魅力を持つトポロジ図なのです。

shownet topology
図 15-1: 2015 年 Interop Tokyo の ShowNet トポロジ図。引用元: http://www.interop.jp/2015/shownet/images/topology02.pdf Copyright © Interop Tokyo 2015 ShowNet NOC Team Member and NANO OPT Media, Inc. All Rights Reserved.

ShowNet のようにいくつものスイッチやルータがつながるネットワークの動作では、トポロジ情報の把握が1つの鍵です。パケットが迷子になったりループしたりせずに正しく目的地まで届くためには、スイッチやルータ同士がどのような接続関係にあるかをお互いに把握しなければなりません。

OpenFlow では、コントローラがこのトポロジ情報を管理します。ネットワーク全体を集中管理するコントローラがトポロジを把握することで、パケットを思いのままに転送できます。たとえば、パケットの転送に、最短パスを使うだけではなく、回り道をさせたり、複数のパス (マルチパス) を使うことも自由自在です。

15.2. トポロジ検出の仕組み

コントローラがトポロジ情報を検出するには、スイッチ間のリンクをすべて発見する必要があります。ネットワーク中のスイッチとポート情報は、switch_ready ハンドラや Features Request/Reply メッセージを使えばすべて発見できます。したがって、発見したスイッチ間のリンクがすべて発見できれば、ネットワークトポロジを検出できます。

15.2.1. リンクの発見

OpenFlow でリンクを発見する方法として代表的なのは、Link Layer Discovery Protocol (LLDP) パケットを使った方法です (図 15-2)。コントローラはどこにリンクがあるかあたりをつけるために、適当なスイッチ A に LLDP パケットを試しに送ります。もし、スイッチ Aに別のスイッチ B がリンクでつながっていれば、LLDPはそこのリンクを通りスイッチ Bを経由してブーメランのようにコントローラへと戻ってきます。このように LLDP パケットが無事に戻ってくれば、スイッチ A と B はリンクでつながっているとわかります。また、LLDP パケットには通過したリンクの詳しい情報が書き込まれるので、スイッチ A と B がどのポート番号で接続しているかということまでわかります。これを繰り返していけば、最終的にはすべてのリンクを発見できるわけです。

lldp overview
図 15-2: LLDP を使ってリンクを発見する

「なぜ、LLDP パケットはきちんとリンクを通ってコントローラまで戻ってくるんだろう?スイッチに LLDP 固有のしかけが必要なのかな?」こう思った方もいるかもしれません。実は、LLDPによるリンクは今まで学んできた OpenFlow の仕組みだけを使って実現できます。つまり、OpenFlow に対応したスイッチであれば LLDPでリンクを発見できるのです。

LLDP によるリンク発見を OpenFlow で実現する方法を見ていきましょう。図 15-3 のように、スイッチ 0x1 のポート 5 とスイッチ 0x2 のポート 1 が接続されていたとします。このリンクを発見するために、コントローラは次の動作をします。

lldp openflow
図 15-3: LLDP パケットと OpenFlow の仕組みを使ってリンクを発見する
  1. コントローラは、接続関係を調べたいスイッチの Datapath ID 0x1 とポート番号 5 を埋め込んだ Link Layer Discovery Protocol (LLDP) パケットを作る

  2. ポート 5 から出力するというアクションを含む Packet Out メッセージを作り、先ほど作った LLDPパケットをスイッチ 0x1 へと送る

  3. Packet Out を受け取ったスイッチはアクションに従い、LLDPパケットを指定されたポート 5 から出力する。その結果、LLDP パケットは、ポート 5 の先につながるスイッチ 0x2 へと到着する

  4. LLDP パケットを受け取ったスイッチ 0x2 は、自身のフローテーブルを参照し、パケットの処理方法を調べる。このとき LLDP に対するフローエントリはあえて設定していないため、今回受信した LLDPパケットは、Packet In としてコントローラまで戻される

  5. コントローラは、受け取った Packet In メッセージを解析することで、リンクの発見を行う。スイッチ 0x2 からは図 15-4 の Packet In メッセージが送られてくる。この中身を見ることで、スイッチ 0x1 のポート 5 と、スイッチ 0x2 のポート 1 の間にリンクを発見できる

lldp packet in
図 15-5: スイッチ 0x2 から送られてくる Packet In メッセージ

このように、Packet Out で送られた LLDP パケットは、リンクを通過し、隣のスイッチから Packet In でコントローラへと戻ってきます。この一連の動作によりコントローラはリンクを発見できます。この方法自体は、OpenFlow 仕様でとくに規定されているわけではありません。それぞれのスイッチは OpenFlow 仕様で定められた動作を行っているだけです。つまり、Packet Out と Packet In をうまく使った “OpenFlow ならでは” のリンク発見方法だと言えます。

15.2.2. トポロジの検出

このリンク発見方法をネットワーク中のすべてのスイッチのすべてのポートに順に適用していけば、ネットワーク全体のスイッチの接続関係、つまりトポロジを検出できます。たとえば図 15-5のような 3 台の OpenFlow スイッチからなるネットワークにおいて、どうやってトポロジを検出できるかを見ていきましょう。各 OpenFlow スイッチがコントローラに接続した直後の状態では、コントローラはスイッチ同士がどのように接続されているかを知りません。

topology before
図 15-5: トポロジ検出前のコントローラ

まずスイッチ 0x1 から調べていきます。はじめに Features Request メッセージを送ることで、スイッチ 0x1 が持つポート一覧を取得します。そして、それぞれのポートに対して、前述のリンク発見手順を行います (図 15-6)。その結果、スイッチ 0x1 からスイッチ 0x2 およびスイッチ 0x3 へと至るリンクそれぞれを発見できます。

topology after
図 15-6: スイッチ 0x1 から出るリンクを発見

あとは同様の手順を、ネットワーク中の各スイッチに対して順に行なっていくだけです。スイッチ 0x2, 0x3 に接続するリンクを順に調べていくことで、ネットワークの完全なトポロジ情報を検出できます。

15.3. 実行してみよう

このトポロジ検出機能を持つ Topology コントローラを実行してみましょう。ソースコードと仮想ネットワークの設定ファイルは GitHub の trema/topology リポジトリ (https://github.com/trema/topology) からダウンロードできます。今までと同じく、git clone でソースコードを取得し bundle install で必要な gem をインストールしてください。

$ git clone https://github.com/trema/topology.git
$ cd topology
$ bundle install --binstubs

ソースコードに含まれる triangle.conf はスイッチ 3 台を三角形に接続したトライアングル型のトポロジです (図 15-7)。

triangle conf
図 15-7: triangle.confのトポロジ

これをトポロジコントローラで検出するには、次のように実行します。

$ ./bin/trema run ./lib/topology_controller.rb -c triangle.conf
Topology started (text mode).
Port 0x1:1 added: 1
Port 0x1:2 added: 1, 2
Switch 0x1 added: 0x1
Port 0x3:1 added: 1
Port 0x3:2 added: 1, 2
Switch 0x3 added: 0x1, 0x3
Port 0x2:1 added: 1
Port 0x2:2 added: 1, 2
Switch 0x2 added: 0x1, 0x2, 0x3
Link 0x1-0x2 added: 0x1-0x2
Link 0x1-0x3 added: 0x1-0x2, 0x1-0x3
Link 0x2-0x3 added: 0x1-0x2, 0x1-0x3, 0x2-0x3

先に説明したように、コントローラはまず Features Reply メッセージによってスイッチとポートの一覧を取得します。たとえば、Port 0x1:1 added の行はスイッチ 0x1 のポート 1 番をコントローラが検出したという意味です。Switch 0x1 added のメッセージも同じく Features Reply メッセージを返したスイッチのデータパス ID を表示しています。

リンクの検出は LLDP を使って一本ずつ行います。たとえば Link 0x1-0x2 added はスイッチ 0x1 から 0x2 に LLDP パケットが通り、コントローラに PacketIn したことからリンクを一本発見したという意味です。これを繰り返すことで最終的に三角形のトポロジ (Link 0x2-0x3 added: 0x1-0x2, 0x1-0x3, 0x2-0x3 のメッセージ) を発見しています。

トポロジコントローラはトポロジの変化も検出できます。

triangle port down
図 15-8: スイッチ 0x1 のポート 1 番を落としたときのトポロジ

たとえば図 15-8のようにスイッチ 0x1 のポート 1 番を落としてみましょう。

$ ./bin/trema port_down --switch 0x1 --port 1

すると、コントローラを実行したターミナルには次の表示が出ます。たしかに 0x1-0x2 間のリンクが消滅し、残りは 0x1-0x3 と 0x2-0x3 の二本になりました。

Link 0x1-0x2 deleted: 0x1-0x3, 0x2-0x3
Port 0x1:1 deleted: 2

逆に再びポートを上げると、三角形トポロジが復活します。

$ ./bin/trema port_up --switch 0x1 --port 1
Port 0x1:1 added: 1, 2
Link 0x1-0x2 added: 0x1-0x2, 0x1-0x3, 0x2-0x3

トポロジコントローラはトポロジを画像で表示することもできます。この機能を使うためには、システムに graphviz をあらかじめ apt-get でインストールしておきます。そして、trema run の引数に --graphviz トポロジ画像出力ファイル名 を指定します。

$ ./bin/trema run ./lib/topology_controller.rb -c triangle.conf -- graphviz /tmp/topology.png

実行すると、図 15-9 のようにトポロジ画像が生成されます。

graphviz triangle
図 15-9: トポロジコントローラで生成した三角形トポロジの画像

15.4. トポロジコントローラのソースコード

トポロジコントローラは大きく分けて 3 つの部品からなります (図 15-10)。

TopologyController クラス

コントローラの本体で、LLDPパケットの送信とトポロジに関する OpenFlow メッセージの処理をします

Topology クラス

収集したトポロジ情報を管理し、トポロジの変化を View クラスへ通知します

View::Text, View::Graphviz クラス

トポロジをテキストまたは画像で表示します

topology classes
図 15-10: トポロジのクラス構成

このクラス分けは、いわゆる MVC モデル (Model-View-Controller) に従っています。TopologyController クラスは MVC の Controller にあたり、OpenFlow スイッチとメッセージをやりとりしたり他のクラスをセットアップしたりといった制御を担当します。Topology クラスは Model にあたり、ネットワークのモデルすなわちトポロジ情報を管理します。View::TextView::Graphviz はその名の通り View にあたり、モデルである Topology を可視化します。

このようにクラスを MVC で構成するとそれぞれのクラスの役割りがすっきりし、拡張性も向上します。たとえばトポロジを HTML で表示したくなった場合には、新たに View::Html クラスを追加するだけで実現できます。しかも、TopologyControllerTopology クラスへの変更はほとんど必要ありません。また、次章で紹介するルーティングスイッチでは、トポロジを部品として使うことで複雑なパケット制御を可能にしています。このように比較的複雑な機能を実現したい場合には、クラスを MVC で構成できるかどうか検討するとよいでしょう。

15.4.1. モデルとビューのセットアップ

TopologyController の仕事の1つは、MVC のモデルとビューのセットアップです。次の start ハンドラでは、起動時のコマンドライン引数をパースし、トポロジ表示をテキスト表示 (View::Text) にするかまたは画像表示 (View::Graphviz) にするかを決定します。そして、決定したビューをモデル (Topology) のオブザーバとして追加 (@topology.add_observer) します。

TopologyController#start
def start(args)
  @command_line = CommandLine.new(logger)
  @command_line.parse(args)
  @topology = Topology.new
  @topology.add_observer @command_line.view
  logger.info "Topology started (#{@command_line.view})."
end

このオブザーバは、デザインパターンにおけるいわゆるオブザーバ・パターンの一例です。Topology のオブザーバとして追加されたビューのクラス (View::Text または View::Graphviz) は、トポロジに変化があった場合に変化イベントを Topology から受け取ります。そして、それぞれのビューの方法でトポロジを表示します。

オブザーバが受け取れるトポロジの変化イベントは次の通りです:

  • add_switch: スイッチの追加イベント

  • delete_switch: スイッチの削除イベント

  • add_port: ポートの追加イベント

  • delete_port: ポートの削除イベント

  • add_link: リンクの追加イベント

  • delete_link: リンクの削除イベント

オブザーバとして追加できるオブジェクトは、これらのイベントを受け取れば何でもかまいません。たとえば View::Text は次のように add_switchadd_port といったトポロジイベントハンドラを持っており、イベントに応じてトポロジをテキストベースで表示します。

lib/view/text.rb
module View
  # Topology controller's CUI.
  class Text
    def initialize(logger)
      @logger = logger
    end

    def add_switch(dpid, topology)
      show_status("Switch #{dpid.to_hex} added",
                  topology.switches.map(&:to_hex))
    end

    def delete_switch(dpid, topology)
      show_status("Switch #{dpid.to_hex} deleted",
                  topology.switches.map(&:to_hex))
    end

    def add_port(port, topology)
      add_or_delete_port :added, port, topology
    end

    def delete_port(port, topology)
      add_or_delete_port :deleted, port, topology
    end

    def add_link(port_a, port_b, topology)
      link = format('%#x-%#x', *([port_a.dpid, port_b.dpid].sort))
      show_status "Link #{link} added", topology.links
    end

    def delete_link(port_a, port_b, topology)
      link = format('%#x-%#x', *([port_a.dpid, port_b.dpid].sort))
      show_status "Link #{link} deleted", topology.links
    end

    def to_s
      'text mode'
    end

    private

    def add_or_delete_port(message, port, topology)
      ports = topology.ports[port.dpid].map(&:number).sort
      show_status "Port #{port.dpid.to_hex}:#{port.number} #{message}", ports
    end

    def show_status(message, objects)
      status = objects.sort.map(&:to_s).join(', ')
      @logger.info "#{message}: #{status}"
    end
  end
end

MVC で説明したように、未知の外部クラスと連携したい場合にオブザーバ・パターンは便利です。Topology からのイベントを受け取るには Topology#add_observer でオブザーバとして登録するだけで良く、Topology クラスにはオブザーバのクラスに依存するコードはありません。このため、ビューに限らずトポロジ情報を利用するクラスを自由にオブザーバとして追加できます。たとえば次章その次の章で実装するコントローラでは、Topology にコントローラ自身をオブザーバとして登録することで、トポロジ情報を利用してパケットの転送を制御します。

15.4.2. OpenFlow メッセージの処理

TopologyController クラスはスイッチから届く OpenFlow メッセージに応じた処理をします。

switch_ready ハンドラでは、新しく接続してきたスイッチのポート一覧をを知るために、Features Request メッセージをスイッチに投げます。そして、features_reply ハンドラでスイッチから届いた Features Reply が持つポート一覧情報のうち、物理ポートでポートが上がっているものを @topology に追加します。このポート一覧は、LLDP パケットを作って送る際に使います。

TopologyController#switch_ready, TopologyController#features_reply
def switch_ready(dpid)
  send_message dpid, Features::Request.new
end

def features_reply(dpid, features_reply)
  @topology.add_switch dpid, features_reply.physical_ports.select(&:up?)
end

そのほかのハンドラでは、届いたメッセージの種類に応じてトポロジ情報を更新します。

  • switch_disconnected: コントローラとの接続が切れたスイッチをトポロジ情報 (@topology) から削除する

  • port_modify: ポート情報の変更 (ポートのUPとDOWN) を識別し、どちらの場合も @topology に反映する

  • packet_in: 帰ってきた LLDP パケットから発見したリンク情報、または新規ホスト情報を @topology に登録する

TopologyController#switch_disconnected, TopologyController#port_modify, TopologyController#packet_in
def switch_disconnected(dpid)
  @topology.delete_switch dpid
end

def port_modify(_dpid, port_status)
  updated_port = port_status.desc
  return if updated_port.local?
  if updated_port.down?
    @topology.delete_port updated_port
  elsif updated_port.up?
    @topology.add_port updated_port
  else
    fail 'Unknown port status.'
  end
end

def packet_in(dpid, packet_in)
  if packet_in.lldp?
    @topology.maybe_add_link Link.new(dpid, packet_in)
  else
    @topology.maybe_add_host(packet_in.source_mac,
                             packet_in.source_ip_address,
                             dpid,
                             packet_in.in_port)
  end
end

15.4.3. LLDP パケットをスイッチへ送る

LLDP パケットの定期送信は、flood_lldp_frames メソッドをタイマで呼び出すことで行います。@topology が管理する発見済みポートすべて (@topology.ports) に対して、Packet Out で LLDP パケットを送信します。

TopologyController#flood_lldp_frames
class TopologyController < Trema::Controller
  timer_event :flood_lldp_frames, interval: 1.sec

  def flood_lldp_frames
    @topology.ports.each do |dpid, ports|
      send_lldp dpid, ports
    end
  end

  private

  def send_lldp(dpid, ports)
    ports.each do |each|
      port_number = each.number
      send_packet_out(
        dpid,
        actions: SendOutPort.new(port_number),
        raw_data: lldp_binary_string(dpid, port_number)
      )
    end
  end

15.4.4. トポロジ情報の管理

Topology クラスはトポロジ情報のデータベースです。TopologyController が生の OpenFlow メッセージから解釈したトポロジの変化を、ポート一覧、スイッチ一覧などのデータ構造として保存します。そして、変化イベントをオブザーバへ通知します。たとえば add_switch メソッドでは、新しいスイッチとポート一覧を登録し、オブザーバの add_switch メソッドを呼びます。

Topology#add_switch (lib/topology.rb)
def add_switch(dpid, ports)
  ports.each { |each| add_port(each) }
  maybe_send_handler :add_switch, dpid, self
end

private

def maybe_send_handler(method, *args)
  @observers.each do |each|
    if each.respond_to?(:update)
      each.__send__ :update, method, args[0..-2], args.last
    end
    each.__send__ method, *args if each.respond_to?(method)
  end
end

スイッチのポート、スイッチにつながっているリンクなど、関連するもの同士は自動的に処理します。たとえば delete_switch メソッドでは、スイッチを消すだけでなくスイッチのポートやスイッチとつながるリンクもすべて消します。

Topology#add_switch (lib/topology.rb)
def delete_switch(dpid)
  delete_port(@ports[dpid].pop) until @ports[dpid].empty?
  @ports.delete dpid
  maybe_send_handler :delete_switch, dpid, self
end

def delete_port(port)
  @ports[port.dpid].delete_if { |each| each.number == port.number }
  maybe_send_handler :delete_port, Port.new(port.dpid, port.number), self
  maybe_delete_link port
end

private

def maybe_delete_link(port)
  @links.each do |each|
    next unless each.connect_to?(port)
    @links -= [each]
    port_a = Port.new(each.dpid_a, each.port_a)
    port_b = Port.new(each.dpid_b, each.port_b)
    maybe_send_handler :delete_link, port_a, port_b, self
  end
end

15.5. まとめ

ネットワークトポロジをOpenFlowで検出できる、トポロジコントローラの仕組みを見てきました。この章で学んだことを簡単にまとめておきましょう。

  • LLDP でトポロジを検出する仕組み

  • トポロジの変化を検出する OpenFlow メッセージとその処理の実装方法

  • オブザーバーパターンを使った外部クラスとの連携方法

次の章では、ネットワーク仮想化の最初の一歩として、たくさんのスイッチを一台の L2 スイッチとして仮想化できる、ルーティングスイッチコントローラを見ていきます。

16. たくさんのスイッチを制御する

大規模なネットワークを構成するたくさんのスイッチを連携させ、うまく制御する仕組みを見て行きましょう。

16.1. 複数のスイッチを制御する

たくさんの OpenFlow スイッチがつながった環境では、パケットを目的地まで転送するためにスイッチを連携させる必要があります。複数の OpenFlow スイッチを連携し 1 台の大きな仮想スイッチにするコントローラが、本章で紹介するルーティングスイッチです (図 16-1)。同じスイッチ機能を提供するコントローラとしては、7 章8 章で紹介したラーニングスイッチがありました。ラーニングスイッチとルーティングスイッチの大きな違いは、連携できるスイッチの台数です。ラーニングスイッチは OpenFlow スイッチを独立した 1 台のスイッチとして扱います。一方で、ルーティングスイッチは複数の OpenFlow スイッチを連携させることで、仮想的な 1 台のスイッチとして扱います。

routing switch
図 16-1: ルーティングスイッチはたくさんの OpenFlow スイッチを連携し 1 台の仮想スイッチとして動作させる

注意すべきは、ルーティングスイッチはルータではなく、あくまでスイッチであるという点です。ルーティングスイッチという名前は、複数のOpenFlowスイッチを経由し、まさにルーティングするようにパケットを転送することから来ています。このようにスイッチ機能を実現するという点では、ラーニングスイッチとの機能的な違いはありません。ただし制御できるネットワーク規模の面では、ルーティングスイッチは複数の OpenFlow スイッチを扱えるという違いがあります。

16.1.1. ルーティングスイッチの動作

ルーティングスイッチではたくさんのスイッチを接続するため、パケットの転送が複雑になります。宛先のホストまでいくつものスイッチを経由してパケットを届けなくてはならないため、宛先までの道順をスイッチに指示してやる必要があります。

routing switch flow mod
図 16-2: 最短パスでパケットを転送するフローエントリを書き込む

たとえば図 16-2において、ホスト 1 からホスト 4 へとパケットを送信する場合を考えてみましょう。もしパケットを最短のパスで届けたい場合、ホスト 1 → スイッチ 1 → スイッチ 5 → スイッチ 6 → ホスト 4 の順にパケットを転送します。ルーティングスイッチはこの転送をするフローエントリを最短パス上のスイッチ 1, 5, 6 へそれぞれ書き込みます。

このとき、実際にルーティングスイッチとスイッチ間でやりとりする OpenFlow メッセージは図 16-3のようになります:

  1. ホスト 1 がホスト 4 宛てにパケットを送信すると、ルーティングスイッチはこのパケットを Packet In としてスイッチ 1 から受け取る (この Packet In の in_port をポート s とする)

  2. ルーティングスイッチはあらかじめ収集しておいたトポロジ情報 (15章) を検索し、宛先のホスト 4 が接続するスイッチ (スイッチ 6) とポート番号 (ポート g とする) を得る

  3. ポート s から宛先のポート g までの最短パスをトポロジ情報から計算する。その結果、ポート s → スイッチ 1 → スイッチ 5 → スイッチ 6 → ポート g というパスを得る

  4. この最短パスに沿ってパケットを転送するフローエントリを書き込むために、ルーティングスイッチはパス上のスイッチそれぞれに Flow Mod を送る

  5. Packet In を起こしたパケットを宛先に送るために、ルーティングスイッチはスイッチ 6 のポート g に Packet Out を送る

routing switch packetin flowmod packetout
図 16-3: ルーティングスイッチでパケットを送信したときの OpenFlow メッセージ

ここで使っている OpenFlow メッセージはいずれも、今まで使ってきた Packet In や Flow Mod, Packet Out などおなじみの物ばかりです。以下ではステップ 3 で新たに登場した、最短パスの計算方法を詳しく見て行きましょう。

16.2. 最短パスを計算する

最短パスの計算でよく登場するのがダイクストラ法というアルゴリズムです。これは、出発点から目的地までの最短パスを求める汎用アルゴリズムの 1 つで、カーナビの経路検索や鉄道の乗換案内などにも使われています。

ダイクストラ法を使った最短パス計算のアルゴリズムは、基本的には次のとおりです。まず、出発点から 1 ホップで到達できるスイッチをすべて探します。次に、見つかったスイッチから出発して 1 ホップで行けるスイッチ、つまり最初の出発点から 2 ホップで到達できるスイッチをすべて探します。これを繰り返して、出発点から 3 ホップ、 4 ホップ……というように距離を広げながら次々とスイッチを探していきます。途中で目的地のスイッチに到達したら探索完了で、そこまでのパスを最短パスとして返します。[25]

dijkstra
図 16-4: 最短パスをダイクストラ法で計算する

実際に図 16-2 のネットワークでスイッチ 1 からスイッチ 6 までの最短パスをダイクストラ法で探索する手順は図 16-4 のようになります:

  1. 始点となるスイッチ 1 を 0 ホップとする

  2. スイッチ 1 から 1 ホップで行けるすべてのスイッチを見つける。これはスイッチ 1 から出るリンクの先に繋がっているスイッチ 2, 4, 5 である

  3. 同様にステップ 2 で見つかったスイッチから 1 ホップで行けるすべてのスイッチを探し、スイッチ 3, 6 が見つかる。これらは始点からのホップ数が 2 のスイッチである

  4. ステップ 3 でゴールのスイッチ 6 が見つかったので探索を終わる。最短パスは最終的にスイッチ 1 → スイッチ 5 → スイッチ 6 とわかる

16.3. 実行してみよう

動作原理がわかったところで、実際のトポロジ検出や最短パス計算の動作をルーティングスイッチを起動し確認してみましょう。ルーティングスイッチは他のサンプルと同様、GitHub で公開しています。次のコマンドでソースコードを取得してください。

$ git clone https://github.com/trema/routing_switch.git

依存する gem のインストールは、いつも通り bundle install コマンドです。

$ cd routing_switch
$ bundle install --binstubs

これで準備は完了です。

16.3.1. ルーティングスイッチを動かす

それでは、ルーティングスイッチを動かしてみましょう。Trema のネットワークエミュレータ機能を用いて、図 16-5 のネットワークを作ります。

routing switch sample network
図 16-5: ホスト 4 台、スイッチ 6 台からなるネットワーク

この構成を実現する設定ファイルは、ルーティングスイッチのソースツリーに入っています (trema.conf)。この設定ファイルを指定して、次のようにルーティングスイッチを起動します。

$ ./bin/trema run ./lib/routing_switch.rb -c trema.conf

16.3.2. 最短パスを通すフローエントリを確認する

次に host1 と host4 の間でパケットを送受信し、最短パスを通すフローエントリがうまく設定されることを確認しましょう。ルーティングスイッチ起動直後は、まだ MAC アドレスの学習を行っていないので、host1 から host4 へとパケットを送っただけではフローエントリは設定されません。ラーニングスイッチと同じく、次のように両方向でパケットを送った段階でフローエントリが設定されます。

$ ./bin/trema send_packets --source host1 --dest host4
$ ./bin/trema send_packets --source host4 --dest host1
$ ./bin/trema send_packets --source host1 --dest host4

すると、ルーティングスイッチを起動したターミナルには host4 → host1 と host1 → host4 の 2 つの最短パスを発見した、というメッセージが表示されているはずです。

Creating path: 44:44:44:44:44:44 -> 0x6:1 -> 0x6:2 -> 0x5:5 -> 0x5:2 -> 0x1:4 -> 0x1:1 -> 11:11:11:11:11:11
Creating path: 11:11:11:11:11:11 -> 0x1:1 -> 0x1:4 -> 0x5:2 -> 0x5:5 -> 0x6:2 -> 0x6:1 -> 44:44:44:44:44:44

実際にどのようなフローエントリが設定されたか見てみましょう。フローエントリの確認は trema dump_flows コマンドです。まずは host1 から host4 への最短パスである switch1, switch5, switch6 のフローテーブルをそれぞれ見てみましょう。

$ ./bin/trema dump_flows switch1
cookie=0x0, duration=8.949s, table=0, n_packets=0, n_bytes=0, idle_age=8, priority=65535,udp,in_port=4,vlan_tci=0x0000,dl_src=44:44:44:44:44:44,dl_dst=11:11:11:11:11:11,nw_src=192.168.0.4,nw_dst=192.168.0.1,nw_tos=0,tp_src=0,tp_dst=0 actions=output:1
cookie=0x0, duration=4.109s, table=0, n_packets=0, n_bytes=0, idle_age=4, priority=65535,udp,in_port=1,vlan_tci=0x0000,dl_src=11:11:11:11:11:11,dl_dst=44:44:44:44:44:44,nw_src=192.168.0.1,nw_dst=192.168.0.4,nw_tos=0,tp_src=0,tp_dst=0 actions=output:4
$ ./bin/trema dump_flows switch5
cookie=0x0, duration=14.230s, table=0, n_packets=0, n_bytes=0, idle_age=14, priority=65535,udp,in_port=5,vlan_tci=0x0000,dl_src=44:44:44:44:44:44,dl_dst=11:11:11:11:11:11,nw_src=192.168.0.4,nw_dst=192.168.0.1,nw_tos=0,tp_src=0,tp_dst=0 actions=output:2
cookie=0x0, duration=9.320s, table=0, n_packets=0, n_bytes=0, idle_age=9, priority=65535,udp,in_port=2,vlan_tci=0x0000,dl_src=11:11:11:11:11:11,dl_dst=44:44:44:44:44:44,nw_src=192.168.0.1,nw_dst=192.168.0.4,nw_tos=0,tp_src=0,tp_dst=0 actions=output:5
$ ./bin/trema dump_flows switch6
cookie=0x0, duration=18.688s, table=0, n_packets=0, n_bytes=0, idle_age=18, priority=65535,udp,in_port=1,vlan_tci=0x0000,dl_src=44:44:44:44:44:44,dl_dst=11:11:11:11:11:11,nw_src=192.168.0.4,nw_dst=192.168.0.1,nw_tos=0,tp_src=0,tp_dst=0 actions=output:2
cookie=0x0, duration=13.723s, table=0, n_packets=0, n_bytes=0, idle_age=13, priority=65535,udp,in_port=2,vlan_tci=0x0000,dl_src=11:11:11:11:11:11,dl_dst=44:44:44:44:44:44,nw_src=192.168.0.1,nw_dst=192.168.0.4,nw_tos=0,tp_src=0,tp_dst=0 actions=output:1

たしかに switch1, switch5, switch6 それぞれについて、host1 と host4 間の 2 つの最短パス用のフローエントリが設定されています。

一方で、最短パス上にない switch2, switch3, switch4 はパケットが通らないため、次のようにフローエントリがありません。

$ ./bin/trema dump_flows switch2

$ ./bin/trema dump_flows switch3

$ ./bin/trema dump_flows switch4

16.3.3. 最短パスの再計算を確認する

トポロジ上のリンクが切れた場合、ルーティングスイッチは自動的に最短パスを作り直します。たとえば図 16-5 において、switch1 と switch5 の間のリンクが切れた場合を考えます。このときルーティングスイッチは古い最短パス (host1 ⇔ switch1 ⇔ switch5 ⇔ switch6 ⇔ host4) のフローエントリを消します。そして、再び host1 が host2 へパケットを送ったタイミングで、ルーティングスイッチは新しい最短パス (host1 → switch1 → switch4 → switch5 → switch6) を作ります (図 16-6)。

routing switch update shortest path
図 16-6: 最短パスの作り直し

この動作も実際に動かして確認してみましょう。リンクの削除は trema delete_link コマンドです。

$ ./bin/trema delete_link switch1 switch5

すると、ルーティングスイッチを起動したターミナルには host1 ⇔ host4 の 2 つの最短パスを削除したというメッセージが表示されます。

Deleting path: 44:44:44:44:44:44 -> 0x6:1 -> 0x6:2 -> 0x5:5 -> 0x5:2 -> 0x1:4 -> 0x1:1 -> 11:11:11:11:11:11
Deleting path: 11:11:11:11:11:11 -> 0x1:1 -> 0x1:4 -> 0x5:2 -> 0x5:5 -> 0x6:2 -> 0x6:1 -> 44:44:44:44:44:44

再び host1 から host4 へパケットを送ってみましょう。

$ ./bin/trema send_packets --source host1 --dest host4

すると次のように、ルーティングスイッチを起動したターミナルには host1 → host4 の新たな最短パスが表示されます。

Creating path: 11:11:11:11:11:11 -> 0x1:1 -> 0x1:3 -> 0x4:2 -> 0x4:3 -> 0x5:4 -> 0x5:5 -> 0x6:2 -> 0x6:1 -> 44:44:44:44:44:44

以上でルーティングスイッチの最短パス計算と再計算の動作を見てきました。いよいよソースコードを読んでみましょう。

16.4. ルーティングスイッチのソースコード

ルーティングスイッチは次の 4 つのクラスが協調して動作します (図 16-7)。

RoutingSwitch クラス

スイッチから届く OpenFlow メッセージを振り分けます。OpenFlow スイッチと接続し、スイッチから上がってくる OpenFlow メッセージをその種類に応じて Topology または PathManager へと振り分けます

TopologyController, Topology クラス (15 章で紹介)

トポロジの変化イベントを PathManager へ通知します。トポロジ情報の変化に関連する OpenFlow メッセージを RoutingSwitch から受け取り、ネットワークトポロジ上のイベントへ変換し PathManager へ渡します

PathManager クラス

ルーティングスイッチの本体です。RoutingSwitch から Packet In メッセージを受け取ると、Topology から受け取るトポロジ情報を元に最短パスを計算し、Path クラスを通じて新しい最短パスをスイッチに反映します

Path クラス

パスの生成と削除に必要なフローエントリの操作を一手に引き受けます。FlowMod や FlowModDelete といった OpenFlow メッセージの詳細を PathManager から隠蔽します

routing switch classes
図 16-7: ルーティングスイッチのクラス構成

複雑な機能を持つコントローラは、このように機能を小さなクラスに分割することでスッキリと書けます。LLDP の送受信といったトポロジ検出処理は Topology クラスに、FlowMod といったフローエントリの処理は Path クラスにそれぞれまかせ、そして PathManager クラスが全体をとりまとめることで見通しが良くなりテストもしやすくなります。もし新しく機能を追加したくなった場合にも、既存のコードは改造せず新機能に対応するクラスを追加するだけです[26]

16.4.1. RoutingSwitch クラスのソースコード (routing_switch.rb)

RoutingSwitch クラスは委譲パターンによって各 OpenFlow メッセージを他のクラスへと振り分けます。

lib/routing_switch.rb
delegate :switch_ready, to: :@topology
delegate :features_reply, to: :@topology
delegate :switch_disconnected, to: :@topology
delegate :port_modify, to: :@topology

def packet_in(dpid, packet_in)
  @topology.packet_in(dpid, packet_in)
  @path_manager.packet_in(dpid, packet_in) unless packet_in.lldp?
end

たとえば Topology クラスへ switch_ready イベントを転送するには、delegate メソッドを使って Topology クラスのインスタンスへ switch_ready メソッドを委譲します。なお packet_in イベントは Topology と PathManager の両方に届ける必要があるため、packet_in ハンドラの中で明示的にそれぞれの packet_in メソッドを呼び出すことで転送しています。

16.4.2. PathManager のソースコード (path_manager.rb)

PathManager は、Topology クラスとObserverパターンで連携します。TopologyクラスはRoutingSwitchクラスから上がってくる生のOpenFlowメッセージをトポロジ上の変化イベント(スイッチ・ポート・リンクの追加/削除とホストの追加)へと変換し、オブザーバである PathManager クラスのトポロジイベントハンドラを呼び出します。

PathManager クラスのトポロジイベントハンドラ (lib/path_manager.rb)
def add_port(port, _topology)
  @graph.add_link port.dpid, port
end

def delete_port(port, _topology)
  @graph.delete_node port
end

# TODO: update all paths
def add_link(port_a, port_b, _topology)
  @graph.add_link port_a, port_b
end

def delete_link(port_a, port_b, _topology)
  @graph.delete_link port_a, port_b
  Path.find { |each| each.link?(port_a, port_b) }.each(&:destroy)
end

def add_host(mac_address, port, _topology)
  @graph.add_link mac_address, port
end

PathManager はトポロジイベントを受け取ると、インスタンス変数 @graph として持つ現在のネットワークグラフを更新します。たとえばLLDPによって新しいリンクを発見すると、Topology はトポロジイベント add_link を PathManager へ送ります。そして PathManager は新しく見つかったリンクを @graph へ追加します。

PathManager は packet_in イベントに反応し、届いたパケットを宛先へと届けます。

PathManager#packet_in
def packet_in(_dpid, packet_in)
  path = maybe_create_shortest_path(packet_in)
  ports = path ? [path.out_port] : @graph.external_ports
  ports.each do |each|
    send_packet_out(each.dpid,
                    raw_data: packet_in.raw_data,
                    actions: SendOutPort.new(each.number))
  end
end

def maybe_create_shortest_path(packet_in)
  shortest_path =
    @graph.dijkstra(packet_in.source_mac, packet_in.destination_mac)
  return unless shortest_path
  Path.create shortest_path, packet_in
end

packet_in ハンドラの動作は図 16-6 の通りです:

path manager internals
図 16-6: PathManager の仕組み
  1. グラフ情報から送信元→宛先への最短パスを計算する。もし最短パスが見つかった場合には、最短パス上のスイッチにフローエントリを Path.create で打つ

  2. 最短パスが見つかった場合には、宛先ポートにPacketOutすることでPacketInを起こしたパケットを宛先へ届ける。見つからなかった場合には、パケットをすべての外部ポート (外部と接続しているポート) へPacketOutする

16.4.3. Path のソースコード (path.rb)

Path クラスはパスの生成と削除に必要なフローエントリの操作を一手に引き受けます。たとえば、パスを生成するメソッド Path.create の実装は次のようになっています:

Path.create (lib/path.rb)
def self.create(shortest_path, packet_in)
  new.save(shortest_path, packet_in).tap { |new_path| all << new_path }
end

def save(full_path, packet_in)
  @full_path = full_path
  @packet_in = packet_in
  logger.info 'Creating path: ' + @full_path.map(&:to_s).join(' -> ')
  flow_mod_add_to_each_switch
  self
end

def flow_mod_add_to_each_switch
  path.each_slice(2) do |in_port, out_port|
    send_flow_mod_add(out_port.dpid,
                      match: exact_match(in_port.number),
                      actions: SendOutPort.new(out_port.number))
  end
end

PathManager が Path.create を呼び出すと、Path クラスのインスタンスメソッド save を呼び出します。save メソッドでは最短パスに沿ってフローエントリを flow_mod_add_to_each_switch メソッドでスイッチに書き込みます。

16.5. OpenFlow を使う利点

本章のはじめで説明したように、ルーティングスイッチは OpenFlow ネットワークを 1 台の仮想的なスイッチとして動作させるコントローラです。普通のスイッチを真似るだけならば、わざわざ OpenFlow を使わなくてもよいのでは? と思うかもしれません。ここでは、OpenFlow を使った場合の利点について考えてみたいと思います。

16.5.1. リンク帯域を有効活用できる

通常のスイッチで構成されたネットワークでは、パケットのループを防ぐためにスパニングツリープロトコルでリンクの一部を遮断します。たとえば、図 16-7のようなループを含むネットワークでスパニングツリープロトコルを使うと、スイッチ2とスイッチ3間のリンクが遮断されループが解消します。このとき、たとえばホスト2からホスト3へのパケットは、この遮断されたリンクを通過できないため、スイッチ1を経由して転送します。これは明かに無駄な回り道で、せっかくのリンク帯域が無駄になっています。

spt1
図 16-7: スパニングツリーではループを避けるために一部のリンクを遮断する

一方、ルーティングスイッチではコントローラがトポロジ全体を把握しているため、ループを防ぐためのリンク遮断は必要ありません。パケットの転送経路を各スイッチにフローエントリとして明示的に指示するため、ループを含むトポロジであっても問題なく動作します。このためスパニングツリーを使う場合と比べて、ネットワーク中のリンクを有効に使えます(図 16-8)。

spt2
図 16-8: ルーティングスイッチではネットワーク中のリンクを有効に使える

16.5.2. いろいろなパス選択アルゴリズムを使える

パスの決定はコントローラで一括して行なうため、パス決定アルゴリズムを入れ替えるだけで、さまざまなパス選択を実現できます。今回、ルーティングスイッチではダイクストラ法による最短パスを使いましたが、たとえば図 16-9のようにフロー毎に異なるパスを設定することで、帯域確保のためのマルチパスを作ることも簡単にできます。

multipath
図 16-9: OpenFlowでは最短でないパスを含んだマルチパスを自由に作れる

このようなマルチパスは従来の自律分散型でも実現できますが、厳しい制限があります。IETFが標準化を行うTRILL(Transparent Interconnect of Lots of Links)や IEEE が標準化を行う SPB(Shortest Path Bridges)は、マルチパス転送に対応しています。しかし、マルチパス転送を使えるのは、最短パスが複数ある場合[27]だけです。最短ではないパスは、ループを起こす可能性があるため使えません。また最短パスが1本だけの場合にもマルチパスにできません。

16.6. まとめ

いくつものスイッチからなるネットワークを扱える、ルーティングスイッチの動作を見てきました。この章で学んだことを簡単にまとめておきましょう。

  • パケットを最短パスで宛先まで届ける方法。ダイクストラ法を使って最短パスを求め、最短パス上のスイッチにフローエントリを書き込む

  • 複数のクラスを連携しコントローラを実装する方法。メソッドの移譲やオブザーバーパターンを使い、機能ごとに分割したクラスを組み合わせる

  • OpenFlowを使う場合の利点。すべてのリンクを使うことで帯域を有効活用できるほか、マルチパスなどのパス選択アルゴリズムを自由に使える

次の章では、ネットワーク仮想化を実現する本格的なコントローラの一例として、ルーティングスイッチを発展させたスライス機能付きスイッチを見ていきます。

16.6.1. 参考文献

  • 『最短経路の本——レナのふしぎな数学の旅』(Peter Gritzmann、Rene Brandenberg 著/シュプリンガー・ジャパン)
最短経路を題材にしたストーリ仕立てのグラフ理論入門書です。本章ではネットワーク上での最短パスを求める場合のダイクストラ法を紹介しましたが、リンクに重みがある場合の一般的なダイクストラ法についてはこの本がおすすめです。

  • 『マスタリングTCP/IP 応用編』(Philip Miller 著/オーム社
とくにレイヤ3の経路制御プロトコルについて詳しく説明した本です。ダイクストラ法を用いた経路制御プロトコルの1つであるOSPFについても説明しているので、ルーティングスイッチとの違いを比べてみるのもおもしろいでしょう。

17. ネットワークを仮想化する

IaaS (Infrastructure as a Service) の構築に必要な大規模ネットワークを OpenFlow で実現しましょう。16 章で紹介したルーティングスイッチの応用です。

17.1. ネットワークをスライスに分ける

クラウドサービスの核となる機能は仮想化です。たとえばクラウドサービスの一種である IaaS は、サーバやネットワークといった物理リソースを仮想化し、まるで雲 (クラウド) のように大きな仮想リソースとしてユーザに提供します。ユーザは自分専用のリソースをこの仮想リソースプールからいつでも好きなときに借り出せます。

クラウドサービスが制御する物理リソースのうち、ネットワークの仮想化は OpenFlow の得意分野です。物理リソースのうちサーバの仮想化は、Xen などの仮想マシンモニタを使えば、一台のサーバ上に何台もの仮想マシンを起動することで多数のユーザを集約できます。もう1つの物理リソースであるネットワークの仮想化については、後に説明するように OpenFlow コントローラで同様の仕組みを実現できます。この 2 つの組み合わせにより、クラウドサービスは「仮想マシン + 仮想ネットワーク」という専用環境をユーザごとに提供します。

本章で取り上げる「スライス機能付きスイッチ」は、ネットワークを仮想化するコントローラです (図 17-1)。1つの物理ネットワークをたくさんのスライス、つまりユーザごとの論理的なネットワークに分割することで、たくさんのユーザを1つの物理ネットワーク上に集約できます。

slice
図 17-1: スライス機能付きスイッチは 1 つの物理ネットワークをたくさんの独立した仮想ネットワークに分割できる

17.2. スライスの実現方法

スライスを実現する代表的な既存技術が VLANです。VLAN はスイッチをポート単位や MAC アドレス単位でスライスに分割できます。また VLAN タグと呼ばれる ID をパケットにつけることでスイッチをまたがったスライスも作れます。

ただし、VLAN にはスライス数の上限が 4094 個というプロトコル上の制約があります。このため、オフィスなどといった中小規模ネットワークではともかく、IaaS のようにユーザ数がゆうに数万を越える場合には使えません。

一方 OpenFlow によるスライスではこの制約はありません。フローエントリをうまく使えば、既存の VLAN の仕組みを使わなくてもスライスを実現できるからです。つまり OpenFlow を使えば、「スライス数に制限のない仮想ネットワーク」を作れます。

スライス機能付きスイッチは OpenFlow によるスライスの実装です。これは15章で紹介したルーティングスイッチを改造することにより、上限なくたくさんのスライスを作れるようにしたものです。また、実際に OpenStack などのクラウド構築ミドルウェアの一部として使うことも考慮しており、REST API を通じてスライスの作成/削除などの操作ができます。

17.3. インストール

スライス機能付きスイッチを使って、ネットワーク仮想化を実際に試してみましょう。スライス機能付きスイッチのソースコードはルーティングスイッチのリポジトリに入っています。もしルーティングスイッチをまだインストールしていなければ、次のコマンドでインストールしてください。

$ git clone https://github.com/trema/routing_switch.git
$ cd routing_switch
$ bundle install --binstubs

17.3.1. スライス機能付きスイッチを起動する

スライス機能付きスイッチの動作を確認してみましょう。これまで通り Trema のネットワークエミュレータを用いて、図 17-2 のネットワークを作ります。ルーティングスイッチのソースコードに含まれる設定ファイル (trema.conf) を使えば、このネットワーク構成を実現できます。

sliceable switch network
図 17-2: スライス機能付きスイッチを実行するネットワーク

スライス機能を有効にするには、ルーティングスイッチの trema run-- --slicing オプションを付けてください。

$ ./bin/trema run ./lib/routing_switch.rb -c trema.conf -- --slicing

それでは起動したスライス機能付きスイッチを使って、さっそくいくつかスライスを作ってみましょう。

17.3.2. スライスを作る

スライスの作成には slice コマンドを使います。2 枚のスライス slice1slice2 を作り、それぞれに 2 台ずつホストを追加してみましょう (図 17-3)。

creating slices
図 17-3: スライスの作成例

スライスの追加は slice add コマンドです。

$ ./bin/slice add slice1
$ ./bin/slice add slice2

slice add_host コマンドでスライスにホストを追加します。host1host4 のポートと MAC アドレスを slice1 に、host2host3slice2 に、それぞれ追加します。

$ ./bin/slice add_host --port 0x1:1 --mac 11:11:11:11:11:11 --slice slice1
$ ./bin/slice add_host --port 0x6:1 --mac 44:44:44:44:44:44 --slice slice1
$ ./bin/slice add_host --port 0x4:1 --mac 22:22:22:22:22:22 --slice slice2
$ ./bin/slice add_host --port 0x5:1 --mac 33:33:33:33:33:33 --slice slice2

ネットワークがスライスにうまく分割できているか、パケットを送って確認してみましょう。

17.3.3. スライスを確認する

スライスが正しく動作しているか確認するには、次の 2 つを試せば OK です。

  1. 同じスライスに属するホスト間で通信できること

  2. 異なるスライスに属するホスト間で通信できないこと

これは今までやってきた通り、trema send_packettrema show_stats コマンドで簡単に確認できます。たとえば同じスライス slice1 に属するホスト host1host4 で通信できることを確認するには、お互いにパケットを 1 つずつ送信し、それぞれのホストでパケットを 1 つずつ受信できているかどうかを見ます。

$ ./bin/trema send_packet --source host1 --dest host4
$ ./bin/trema send_packet --source host4 --dest host1
$ ./bin/trema show_stats host1
Packets sent:
  192.168.0.1 -> 192.168.0.4 = 1 packet
Packets received:
  192.168.0.4 -> 192.168.0.1 = 1 packet
$ ./bin/trema show_stats host4
Packets sent:
  192.168.0.4 -> 192.168.0.1 = 1 packet
Packets received:
  192.168.0.1 -> 192.168.0.4 = 1 packet

たしかに問題なく通信できています。それでは異なるスライス間での通信はどうでしょう。同様に調べてみましょう。

$ ./bin/trema reset_stats host1
$ ./bin/trema send_packet --source host1 --dest host2
$ ./bin/trema send_packet --source host2 --dest host1
$ ./bin/trema show_stats host1
Packets sent:
  192.168.0.1 -> 192.168.0.2 = 1 packet
$ ./bin/trema show_stats host2
Packets sent:
  192.168.0.2 -> 192.168.0.1 = 1 packet

たしかに、slice1host1 から slice2host2 へのパケットは届いていません。以上から、1 つのネットワークが 2 つの独立したスライスにうまく分割できていることが確認できました。

17.4. REST API を使う

スライス機能付きスイッチは OpenStack などのミドルウェアと連携するための REST API を提供しています。REST API はプログラミング言語を問わず使えるため、スライス機能付きスイッチの持つ仮想ネットワーク機能をさまざまなミドルウェアに簡単に組込めます。

スライス機能付きスイッチの REST API は Ruby の HTTP サーバ実装である WEBrick で動作します (図17-4)。WEBrick に「スライスの作成」や「ホストの追加」といったリクエストを HTTP で送ると、WEBrick はリクエスト内容をスライス機能付きスイッチ経由でネットワークへと反映します。また、現在のスライスやホストの状態も同様に REST API 経由で取得できます。

rest overview
図17-4: スライス機能付きスイッチの REST API 構成

REST API の起動は次のコマンドです。スライス機能付きスイッチを起動した後に rackup コマンドで WEBrick を起動します。

$ ./bin/trema run ./lib/routing_switch.rb -c trema.conf -d -- --slicing
$ ./bin/rackup

それでは実際にいくつか REST API を試してみましょう。

17.4.1. REST API でスライスを作る

REST API 経由でスライスを作るには、スライスの情報が入った JSON を HTTP POST で REST サーバに送ります。たとえば yutaro_slice という名前のスライスを作る JSON は次の通りです。

{"name": "yutaro_slice"}

次にこの JSON を /slices という URI に HTTP POST メソッドで送ります。curl コマンドを使えば、次のように手軽に REST サーバとやりとりできます。なお REST サーバである WEBrick のデフォルト待ち受けポートは 9292 です。

$ curl -sS -X POST -d ’{"name": "yutaro_slice"}’ 'http://localhost:9292/slices' -H Content-Type:application/json -v

成功すると次のようにスライスの作成成功を示す HTTP ステータスコード 201 が返ってきます。

* Hostname was NOT found in DNS cache
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 9292 (#0)
> POST /slices HTTP/1.1
> User-Agent: curl/7.35.0
> Host: localhost:9292
> Accept: */*
> Content-Type:application/json
> Content-Length: 21
>
* upload completely sent off: 21 out of 21 bytes
< HTTP/1.1 201 Created (1)
< Content-Type: application/json
< Content-Length: 21
* Server WEBrick/1.3.1 (Ruby/2.0.0/2014-10-27) is not blacklisted
< Server: WEBrick/1.3.1 (Ruby/2.0.0/2014-10-27)
< Date: Mon, 30 Mar 2015 08:15:22 GMT
< Connection: Keep-Alive
<
* Connection #0 to host localhost left intact
{"name": "yutaro_slice"}
1 スライス作成成功を示す HTTP ステータスコード 201

17.4.2. スライスにホストを追加する

作ったスライスにはホストを追加できます。追加するホストを指定するには、ホストのつながっているスイッチの dpid とポート番号、そしてホストの MAC アドレスを使います。これをホスト追加の URI である /slices/:slice_id/ports/:port_id/mac_addresses に HTTP POST メソッドで送ります。たとえば、スライス yutaro_slice に dpid = 0x1, ポート番号 = 1, MAC アドレス = 11:11:11:11:11:11 のホストを追加するコマンドは次のようになります。

$ curl -sS -X POST -d ’{"name": "11:11:11:11:11:11"}’ 'http://localhost:9292/slices/yutaro_slice/ports/0x1:1/mac_addresses' -H Content-Type:application/json -v

次のようにホスト追加の成功を示す HTTP ステータスコード 201 が返ってくれば成功です。

[{"name": "11:11:11:11:11:11"}]
* Hostname was NOT found in DNS cache
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 9292 (#0)
> POST /slices/foo/ports/0x1:1/mac_addresses HTTP/1.1
> User-Agent: curl/7.35.0
> Host: localhost:9292
> Accept: */*
> Content-Type:application/json
> Content-Length: 29
>
} [data not shown]
* upload completely sent off: 29 out of 29 bytes
< HTTP/1.1 201 Created (1)
< Content-Type: application/json
< Content-Length: 31
* Server WEBrick/1.3.1 (Ruby/2.0.0/2014-10-27) is not blacklisted
< Server: WEBrick/1.3.1 (Ruby/2.0.0/2014-10-27)
< Date: Tue, 31 Mar 2015 00:20:45 GMT
< Connection: Keep-Alive
<
{ [data not shown]
* Connection #0 to host localhost left intact
1 ホスト追加成功を示す HTTP ステータスコード 201

17.4.3. スライスの構成を見る

これまでの設定がきちんと反映されているか確認してみましょう。/slices/:slice_id/ports に HTTP GET メソッドでアクセスすることで、スライスに追加したポート一覧を取得できます。先ほど作った slice_yutaro スライスの情報を取得してみましょう[28]

$ curl -sS -X GET 'http://localhost:9292/slices/yutaro_slice/ports'
[{"name": "0x1:1", "dpid": 1, "port_no": 1}]

たしかに、スライス yutaro_slice にはスイッチ 0x1 のポート 1 番が追加されています。このポートに接続した host1 の情報は /slices/:slice_id/ports/:port_id/mac_addresses で取得できます。

$ curl -sS -X GET 'http://localhost:9292/slices/yutaro_slice/ports/0x1:1/mac_addresses'
[{"name": "11:11:11:11:11:11"}]

17.5. REST API 一覧

REST API は今回紹介した以外にも API を提供しています (表 17-1)。やりとりする JSON データ等の詳しい仕様は https://relishapp.com/trema/routing-switch/docs/rest-api で公開していますので、本格的に使いたい人はこちらも参照してください。

Table 21. 表 17-1: REST API 一覧
動作 メソッド URI

スライスの作成

POST

/slices

スライスの削除

DELETE

/slices/:slice_id

スライスの一覧

GET

/slices

スライス情報の取得

GET

/slices/:slice_id

ポートの追加

POST

/slices/:slice_id/ports

ポートの削除

DELETE

/slices/:slice_id/ports/:port_id

ポートの一覧

GET

/slices/:slice_id/ports

ポート情報の取得

GET

/slices/:slice_id/ports/:port_id

MAC アドレスの追加

POST

/slices/:slice_id/ports/:port_id/mac_addresses

MAC アドレスの削除

DELETE

/slices/:slice_id/ports/:port_id/mac_addresses/:mac_address_id

MAC アドレスの一覧

GET

/slices/:slice_id/ports/:port_id/mac_addresses

17.6. スライス機能付きスイッチの実装

実はスライス機能は、15章で説明したルーティングスイッチへのほんの少しの改造だけで実現しています。コントローラとOpenFlowスイッチの視点で見ると、スライス機能付きスイッチは次のように動作します(図17-5)。

sliceable switch internals
図 17-5: スライス機能付きスイッチの動作
  1. ホスト 1 がホスト 4 宛てにパケットを送信すると、ルーティングスイッチはこのパケットを Packet In としてスイッチ 1 から受け取る (この Packet In の in_port をポート s とする)

  2. ルーティングスイッチはあらかじめ収集しておいたトポロジ情報 (14章) を検索し、宛先のホスト 4 が接続するスイッチ (スイッチ 6) とポート番号 (ポート g とする) を得る

  3. ポート s とポート g が同じスライスに属するか判定する。もし同じスライスではない場合にはパケットを捨て、以降の処理は行わない

  4. ポート s から宛先のポート g までの最短パスをトポロジ情報から計算する。その結果、ポート s → スイッチ 1 → スイッチ 5 → スイッチ 6 → ポート g というパスを得る

  5. この最短パスに沿ってパケットを転送するフローエントリを書き込むために、ルーティングスイッチはパス上のスイッチそれぞれに Flow Mod を送る

  6. Packet In を起こしたパケットを宛先に送るために、ルーティングスイッチはスイッチ 6 に Packet Out (ポート g) を送る

スライス機能付きスイッチがルーティングスイッチと異なるのは、ステップ 3 を追加した点だけです。ステップ 3 では送信元と宛先ホストがそれぞれ同じスライスに属しているかを判定し、同じスライスに所属している場合のみパケットを転送します。それ以外はルーティングスイッチとまったく同じです。

17.7. スライス機能付きスイッチのソースコード

スライス機能は、ルーティングスイッチに次の新たなクラス 2 個を追加することで実現しています (図 17-6)。

PathInSliceManager クラス

スライス内のパスを管理するコントローラの本体

Slice クラス

スライスを管理する

sliceable switch classes
図 17-6: スライス機能付きスイッチを構成するクラス

17.7.1. PathInSliceManager クラス

PathInSliceManager クラスは packet_in ハンドラでパケットの送信元と宛先が同じスライスに属するかどうかを判定します。それ以外の動作は PathManager クラスと同じなので、PathInSliceManager は PathManager を継承し packet_in ハンドラだけをオーバーライドします。

PathInSliceManager#packet_in
def packet_in(_dpid, packet_in)
  slice = Slice.find do |each|
    each.member?(packet_in.slice_source) &&
    each.member?(packet_in.slice_destination(@graph))
  end
  ports = if slice
            path = maybe_create_shortest_path_in_slice(slice.name, packet_in)
            path ? [path.out_port] : []
          else
            external_ports(packet_in)
          end
  packet_out(packet_in.raw_data, ports)
end

Packet In メッセージが PathInSliceManager へ到着すると、PathInSliceManager は次の方法でパケットを宛先へと届けます。

  1. パケットの送信元 MAC アドレスと宛先 MAC アドレスが同じスライスに属するかどうか判定する。もし同じスライスだった場合には、PathManager と同様に最短パスを作り宛先ホストへパケットを届ける

  2. もし同じスライスでなかった場合、パケットをすべての外部ポート (スライス機能付きスイッチが管理するスイッチ以外と接続した全てのポート) へ PacketOut する。つまり、スライスに属していないホストへとばらまく[29]

ステップ 1 で使っている Slice.find メソッド (パケットの送信元と宛先が同じスライスに属するかどうか) といったスライスに関わる処理は、次の Slice クラスが行います。

17.7.2. Slice クラス

Slice クラスはスライスの管理クラスです。スライスの追加・削除や検索といったクラスメソッドのほか、スライスへのポートやホストの追加・削除といった機能を提供します。

たとえば先ほど使った Slice.find メソッドは、スライスの一覧 (all) に対して同じ find メソッドを呼び出すだけです。

スライスの検索 (lib/slice.rb)
def self.find(&block)
  all.find(&block)
end

スライスの追加メソッド Slice.create は指定した名前でスライスを作成します。最初に、すでに同じ名前のスライスがないかどうかを Slice.find_by で確認します。そして、スライスオブジェクトを Slice.new で作ります。作ったスライスはスライス一覧 (all) に追加します。

スライスの追加 (lib/slice.rb)
def self.create(name)
  if find_by(name: name)
    fail SliceAlreadyExistsError, "Slice #{name} already exists"
  end
  new(name).tap { |slice| all << slice }
end

Slice.destroycreate の逆で、スライスの削除メソッドです。最初に、削除しようとした名前のスライスがあるかどうかを Slice.find_by! で確認します。そして、削除するスライスに属する最短パス (Path オブジェクト) を削除します。最後に、そのスライスをスライス一覧 all から消します。

スライスの削除 (lib/slice.rb)
def self.destroy(name)
  find_by!(name: name)
  Path.find { |each| each.slice == name }.each(&:destroy)
  all.delete_if { |each| each.name == name }
end

17.8. まとめ

Hello Trema から始めた Trema プログラミングも、いつの間にか本格的なクラウド用ネットワークを作れるまでになりました!

  • スライス機能付きスイッチが同一のスライス内の通信のみを許可する仕組み

  • クラウド構築ミドルウェアからスライスを設定するためのREST APIの使い方

次章では、Trema を使った仮想ネットワークソフトウェアであり、商用クラウドにも使われている OpenVnet を紹介します。本章で解説したスライス機能付きスイッチとはまったく異なる「分散 Trema」とも言えるスライスの実現方法は、商用クラウドの作り方として参考になります。

18. OpenVNetで本格的な仮想ネットワーク

OpenVNetはTremaで構築された本格的な仮想ネットワーク基盤です。実際のデータセンターでも使える仮想ネットワークを体験しましょう。

18.1. OpenVNetとは

OpenVNetはOpenFlowで仮想ネットワークを構築するためのフリーソフトウェアです。Tremaを使ってあらゆるパケットの挙動を自由に制御することで、既存のネットワーク上にあたかもユーザ専用のネットワークがあるかのような環境を作り出すことができます。開発はWSF(Wakame Software Foundation)が中心となっており、筆者の一人である山崎の所属する株式会社あくしゅの開発者がメインコミッターを務めています。ソフトウェアライセンスにLGPLv3を採用し、組織の枠を越えたオープンソースでの開発を行っています。

OpenVNetはもともと、WSFのプロジェクトの 1 つであるWakame-vdcからネットワーク機能を切り出したものです。Wakame-vdcはデータセンター全体を仮想化するためのソフトウェアで、すでにいくつもの企業や研究機関で商業化や実用化が進んでいます。

  • 国立情報学研究所 (NII): 分散処理の実証実験、クラウド教育教材として活用

  • 九州電力: 大規模データの分散処理基盤として

  • NTT PCコミュニケーションズ: パブリッククラウド WebARENA VPSクラウド

  • 京セラコミュニケーションシステム: パブリッククラウド GreenOffice Unified Cloud

  • TIS株式会社: OpenVNetのDocker対応とクラウド間連携の実証プロジェクト (後述)

18.1.1. エッジ仮想化による仮想ネットワーク

OpenVNetによるネットワーク仮想化の特長は、エッジ仮想化である点です。エッジ仮想化では、仮想マシンと既存のネットワークとの間にソフトウェアOpenFlowスイッチ(エッジスイッチ)を設置し、ここで全てのパケットを制御します。これによって、仮想マシンからは、あたかも独立したネットワークがあるかのように見えます。

エッジスイッチの主な仕事は、物理ネットワークと仮想ネットワーク間でのパケットの相互書き換えです。

edge translation
図 18-1: エッジ仮想化によるネットワークの仮想化
  1. 仮想マシンから仮想ネットワークに送信したパケットは、エッジスイッチが物理ネットワークを通るように書き換え、宛先のサーバへ送出する

  2. 宛先のサーバに届く直前のエッジスイッチで逆の書き換えを行う。つまり、物理ネットワークを通ってきたパケットを仮想ネットワーク内のパケットに見えるように書き換える

こうしたエッジスイッチによるパケットの書き換えは仮想マシンからは見えません。OpenVNetの作り出した仮想ネットワークが、仮想マシンからは物理ネットワークであるかのように見えます。

エッジ仮想化のもう一つの大きな利点は、OpenFlow化されていない既存のネットワーク上で動作することです。たとえば 第17章「ネットワークを仮想化する」 で紹介したスライサブルスイッチには、ネットワークスイッチがすべてOpenFlowに対応しているという前提がありました。一方エッジ仮想化では、この制御を物理サーバ上に起動したエッジスイッチだけで行います。こうすることで、既に構築されたネットワークの上で仮想ネットワークを実現できます。

18.2. エッジ仮想化の利点

OpenVNetのようなエッジ仮想化は、次の2つの場面で特に威力を発揮します。

  1. 既存ネットワークの活用

  2. ダウンサイジング

18.2.1. 既存ネットワークの活用

最小の変更だけで既存データセンター上に仮想ネットワークサービスを構築できます。エッジ仮想化によるネットワーク仮想化はほぼ物理サーバの追加だけで実現できます。このため、物理ネットワークの新たな敷設や再設定をできるだけ抑えながら、その上に新しく仮想ネットワークを構築して提供できます。

18.2.2. ダウンサイジング

古い大量の物理スイッチを仮想ネットワーク化することで一掃できます。近年のネットワーク帯域向上により、物理ネットワークの仮想環境への詰め込みが一般的になってきました。たとえば単純計算しただけでも、10Gbpsの物理ネットワークには10Mbpsの仮想ネットワークを100個ほど詰め込めます。さらに、ネットワーク利用率のばらつきを考慮し効率的に設計すれば、より多くを集約できます。

18.3. OpenVNetの全体アーキテクチャ

OpenVNetのアーキテクチャは非常にシンプルです。データセンタ全体のネットワークの構成を管理するのが、vnmgr(Virtual Network Manager)です。vnmgrはグローバルな仮想ネットワーク設定情報を元に、分散するvna(Virtual Network Agent)に対して、エッジスイッチを設定するよう指示します。個々の vna は Trema を使ったコントローラとして実装しており、それぞれが担当するエッジスイッチへとフローエントリを書き込みます。

openvnet architecture
図 18-2: OpenVNetの全体アーキテクチャ

OpenVNetはTrema以外にも、定評のあるフリーソフトウェアをコンポーネントとして利用しています。エッジスイッチとして動作するソフトウェアスイッチ、グローバルな仮想ネットワーク設定情報を管理するデータベース、そして vnmgr と vna 間のメッセージングには、それぞれ次のソフトウェアを採用しています。

コンポーネント 実装 URL

OpenFlow スイッチ

Open vSwitch

http://openvswitch.org/

設定情報データベース

MySQL

http://www.mysql.com/

メッセージング

ZeroMQ & Redis

http://zeromq.org/ http://redis.io/

18.4. OpenVNetの主な機能

OpenVNetは仮想ネットワーク以外にも、次の 4 つの強力な機能を持っています。

  1. 仮想ルータによる仮想ネットワーク接続

  2. セキュリティグループによる仮想ファイアウォール機能

  3. DHCPとDNSサービス

  4. 仮想ネットワークと既存ネットワークの接続

18.4.1. 仮想ルータによる仮想ネットワーク間接続

OpenVNet上に作成した2つ以上の仮想ネットワークの間を自由に相互接続できます。これにより、2つの異なる仮想ネットワークに接続する仮想マシン同士が通信できるようになります。これはちょうど、仮想ネットワークの間にルータを仮想的に配置するようなものです。

route between vnets
図 18-3: 仮想ルータによる仮想ネットワーク間接続

この仮想ルータ機能は、すべてエッジスイッチのフローによって実現しています。仮想マシン間のパケットは余計なネットワーク経路を辿らず、エッジスイッチ間で最適な通信をします。

18.4.2. セキュリティグループによる仮想ファイアウォール機能

エッジスイッチは各仮想マシンのトラフィック全ての関所でもあります。セキュリティグループは、この関所にパケットの受け入れ許可ルールを指定し、仮想マシンのファイアウォールとして機能させるものです。

sequrity groups
図 18-4: セキュリティグループ間の仮想ファイアウォール機能

セキュリティグループは、このファイアウォール設定を仮想的なグループ間の通信に設定できます。仮想ファイアウォールの設定をエッジスイッチのフローエントリへと自動変換することで、グループ間の適切な通信ルールを制御します。

18.4.3. DHCPとDNSサービス

DHCPやDNSなどのサービスをエッジスイッチとコントローラだけで処理できます。これにより、新たにDHCPサーバなどを立てなくてもソフトウェア的に各種ネットワークサービスを提供できます。

dhcp
図 18-5: DHCPサービスをエッジスイッチとvnaで実現

たとえばDHCPの場合、DHCP関係のパケットはエッジスイッチでマッチさせ、vnaにエスカレーションします。vnaはDHCPの返信パケットを生成し仮想マシンへ直接返答します。この機能は、仮想マシンに割り振るIPアドレスが自明である場合に利用できます。

18.4.4. 仮想ネットワークと外部ネットワークとの接続

OpenVNetで作った仮想ネットワークを、外部のネットワークと接続する機能を VNetEdge と呼びます。2つのネットワーク境界にあるエッジスイッチ上のフローを使って、ネットワーク間でパケットの相互転送を行います。

VNetEdgeでは、トランスレーションと呼ぶルールに従ってパケットの相互転送を行います。例えば、特定のVLANタグを持ったパケットを任意の仮想ネットワークへ転送したり、特定のIPアドレス宛のパケットを仮想ネットワーク内の任意のIPアドレスへ変換したりできます。

18.5. 使ってみる

OpenVNetの利用はとても簡単です。実行に必要なものは次の2つだけです。

  • CentOS 6.6以上(CentOS6系)が稼働する物理または仮想マシン

  • インターネット接続

openvnet installation overview
図 18-6: 1台のマシンで動作するOpenVNet環境

18.5.1. インストールしてみる

OpenVNetのインストールと初期設定は、以下の手順で進めます。

  1. OpenVNetのインストール

  2. RedisとMySQLのインストール

  3. エッジスイッチの設定

  4. 各種サービスの起動

OpenVNetのインストール

OpenVNetは yum パッケージとして提供されています。リポジトリの設定ファイルである openvnet.repo/etc/yum/repos.d/ ディレクトリに次のようにダウンロードします。

$ sudo curl -o /etc/yum.repos.d/openvnet.repo -R https://raw.githubusercontent.com/axsh/openvnet/master/deployment/yum_repositories/stable/openvnet.repo

次に、OpenVNetで利用するミドルウェアパッケージをまとめらたリポジトリ設定ファイル openvnet-third-party.repo/etc/yum.repos.d/ ディレクトリにダウンロードします。

$ sudo curl -o /etc/yum.repos.d/openvnet-third-party.repo -R https://raw.githubusercontent.com/axsh/openvnet/master/deployment/yum_repositories/stable/openvnet-third-party.repo

加えて、OpenVNetのインストールに必要なエンタープライズLinux用の拡張パッケージである epel-release パッケージをインストールしておきます。

$ sudo yum install -y epel-release

ここまで完了したら、OpenVNetパッケージをインストールします。openvnet パッケージはメタパッケージで、OpenVNetの動作に必要なパッケージを一度に全てインストールできます。

$ sudo yum install -y openvnet
RedisとMySQLのインストール

RedisおよびMySQL serverパッケージをインストールします。OpenVNetは、Redisをプロセス間通信ミドルウェアとして、またMySQLをネットワーク構成情報のデータベースとして利用します。

$ sudo yum install -y mysql-server redis
エッジスイッチの設定

br0 という名前のエッジスイッチを作成します。後の疎通確認では、 inst1 および inst2 という2つの仮想マシンをこのエッジスイッチに接続します。 br0 の設定ファイルとして、 /etc/sysconfig/network-scripts/ifcfg-br0 を、以下の内容で作成します。

DEVICE=br0
DEVICETYPE=ovs
TYPE=OVSBridge
ONBOOT=yes
BOOTPROTO=static
HOTPLUG=no
OVS_EXTRA="
 set bridge     ${DEVICE} protocols=OpenFlow10,OpenFlow12,OpenFlow13 --
 set bridge     ${DEVICE} other_config:disable-in-band=true --
 set bridge     ${DEVICE} other-config:datapath-id=0000aaaaaaaaaaaa --
 set bridge     ${DEVICE} other-config:hwaddr=02:01:00:00:00:01 --
 set-fail-mode  ${DEVICE} standalone --
 set-controller ${DEVICE} tcp:127.0.0.1:6633
"

なお、この設定では datapath-id0000aaaaaaaaaaaa という値に設定しています。この値はOpenVNetがエッジスイッチを認識するための一意な識別子で、16進数の値を設定できます。後ほど利用する値ですので、覚えておいて下さい。

各種サービスの起動

次のコマンドで openvswitch サービスとエッジスイッチを起動します。

$ sudo service openvswitch start
$ sudo ifup br0

ネットワーク構成情報を保持するデータベースとして、MySQL serverを起動します。

$ sudo service mysqld start

OpenVNetは、OpenVNetと同時にインストールされるRubyを利用しますので、環境変数PATHにそのパスを設定しておきます。

$ PATH=/opt/axsh/openvnet/ruby/bin:${PATH}

次に、構成情報のためのデータベースの作成を行います。

$ cd /opt/axsh/openvnet/vnet
$ bundle exec rake db:create
$ bundle exec rake db:init

OpenVNetの各サービス間の通信に使うRedisを起動します。

$ service redis start

OpenVNetのサービス群 (vnmgrwebapivna) を起動します。

$ sudo initctl start vnet-vnmgr
$ sudo initctl start vnet-webapi

vnctl ユーティリティで構成情報データベースを作成します。次のコマンドで、vna が管理するエッジスイッチの Datapath ID をOpenVNetに教えます。

$ vnctl datapaths add --uuid dp-test1 --display-name test1 --dpid 0x0000aaaaaaaaaaaa --node-id vna

vna と Datapath ID の紐付けができたので、 vna を起動してみましょう。

$ sudo initctl start vnet-vna

ovs-vsctl コマンドで、 vna が正しく動作しているかを確認できます。

$ ovs-vsctl show
fbe23184-7f14-46cb-857b-3abf6153a6d6
    Bridge "br0"
        Controller "tcp:127.0.0.1:6633"
            is_connected: true

ここで、 is_connected: true の文字列が見えていれば、 vna は正しく動作しています。

次に仮想マシンとして2つの仮想マシン( inst1inst2 )を作成し、OpenVNetの仮想ネットワークに接続してみます。起動する仮想マシンの種類として、今回は軽量なコンテナの一種であるLXCを使います。

$ sudo yum -y install lxc lxc-templates

lxc および lxc-templates パッケージのインストールが完了したら、コンテナのリソース制御を行う cgroup を設定します。

$ sudo mkdir /cgroup
$ echo "cgroup /cgroup cgroup defaults 0 0" >> /etc/fstab
$ sudo mount /cgroup

仮想マシン作成コマンドである lxc-create が利用する rsync をインストールします。

$ sudo yum install -y rsync

LXCの動作の準備が出来ましたので、いよいよ仮想マシン inst1inst2 を作成します。

$ sudo lxc-create -t centos -n inst1
$ sudo lxc-create -t centos -n inst2

lxc-create を実行すると、それぞれの仮想マシンの root ユーザのパスワードが入ったファイル名を出力します。このパスワードは後で仮想マシンにログインする際に利用しますので、覚えておいて下さい。

次に、仮想マシンのネットワークインタフェースの設定を行います。 /var/lib/lxc/inst1/config ファイルを開き、内容を以下で置き換えて下さい。

lxc.network.type = veth
lxc.network.flags = up
lxc.network.veth.pair = inst1
lxc.network.hwaddr = 10:54:FF:00:00:01
lxc.rootfs = /var/lib/lxc/inst1/rootfs
lxc.include = /usr/share/lxc/config/centos.common.conf
lxc.arch = x86_64
lxc.utsname = inst1
lxc.autodev = 0

同様に、 /var/lib/lxc/inst2/config ファイルを開き、内容を以下で置き換えます。

lxc.network.type = veth
lxc.network.flags = up
lxc.network.veth.pair = inst2
lxc.network.hwaddr = 10:54:FF:00:00:02
lxc.rootfs = /var/lib/lxc/inst2/rootfs
lxc.include = /usr/share/lxc/config/centos.common.conf
lxc.arch = x86_64
lxc.utsname = inst2
lxc.autodev = 0

設定ファイルの内容を置き換えたら、仮想マシンを起動します。

$ sudo lxc-start -d -n inst1
$ sudo lxc-start -d -n inst2

仮想マシンが起動したら、その仮想マシンのネットワークインタフェースを先程設定したエッジスイッチに手動で接続します。これは、ちょうどネットワークケーブルを物理スイッチに挿入する操作に対応します。

$ sudo ovs-vsctl add-port br0 inst1
$ sudo ovs-vsctl add-port br0 inst2

これで、OpenVNetのインストールと、OpenVNetの仮想ネットワークを体験する準備が整いました。ここまでの操作では、何もない物理ネットワークと繋がるエッジスイッチに仮想マシンが接続しただけの状態です。

openvnet connected
図 18-7: 仮想マシンがエッジスイッチに接続した状態

では、最も基本的な仮想ネットワークを1つ作成をしてみましょう。

18.5.2. CLIで仮想ネットワークを操作する

仮想ネットワークの操作はすべて vnctl コマンドで行います。まずは、1つの仮想ネットワークを作成してみましょう。

作成する仮想ネットワークのアドレスを 10.100.0.0/24 とし、 inst1 のIPアドレスを 10.100.0.10inst2 のIPアドレスを 10.100.0.11 とします。次の vnctl networks コマンドでこのネットワークを作成できます。

$ vnctl networks add \
  --uuid nw-test1 \
  --display-name testnet1 \
  --ipv4-network 10.100.0.0 \
  --ipv4-prefix 24 \
  --network-mode virtual
openvnet cli simplenetwork 1
図 18-8: 仮想ネットワークの作成

次に、どのネットワークインタフェースがどの仮想ネットワークに所属しているのかを vnctl コマンドでOpenVNetに教えます。 これは、 vnctl interfaces コマンドで実行できます。まずは、 inst1 の持つネットワークインタフェースを仮想ネットワークに設定します。

$ vnctl interfaces add \
  --uuid if-inst1 \
  --mode vif \
  --owner-datapath-uuid dp-test1 \
  --mac-address 10:54:ff:00:00:01 \
  --network-uuid nw-test1 \
  --ipv4-address 10.100.0.10 \
  --port-name inst1

同様に、 inst2 の持つネットワークインタフェースを仮想ネットワークに設定します。

vnctl interfaces add \
  --uuid if-inst2 \
  --mode vif \
  --owner-datapath-uuid dp-test1 \
  --mac-address 10:54:ff:00:00:02 \
  --network-uuid nw-test1 \
  --ipv4-address 10.100.0.11 \
  --port-name inst2

この操作により、OpenVNetは 10.100.0.0/24 の仮想ネットワークを作成し、そこにそれぞれ 10.100.0.1010.100.0.11 のIPアドレスを持つネットワークインタフェースを接続します。

openvnet cli simplenetwork 2
図 18-9: ネットワークインタフェースにIPアドレスを設定した状態

18.5.3. 疎通確認をする

作成した2つの仮想マシンが仮想ネットワークを通じて疎通できることを確認します。まず inst1 にログインし、IPアドレスを確認してみます。

$ lxc-console -n inst1
$ ip addr show

この時点ではまだ inst1eth0 にIPアドレスを設定していないため、IPアドレスが表示されません。作った仮想ネットワークではDHCPサービスを有効にしていないため、IPアドレスは手動で設定する必要があります。

inst1 のコンソールにて次のコマンドを実行し、 eth0 にIPアドレス 10.100.0.10 を設定します。これは、 vnctl で設定した inst1 のネットワークインタフェースのIPアドレスと同じにする必要があります。

$ ip addr add 10.100.0.10/24 dev eth0

もう1つ端末を開き、 inst2 に対し同じ操作を行います。inst2eth0 に設定するIPアドレスは、 10.100.0.11 です。

$ lxc-console -n inst2
$ ip addr add 10.100.0.11/24 dev eth0

これで2つの仮想マシンそれぞれに仮想ネットワーク内のIPアドレスを設定できました。

openvnet cli simplenetwork 3
図 18-10: ネットワークインタフェースにIPアドレスを設定

それでは、お互いに ping を実行してみます。まずは、 inst2 から inst1ping を実行します。

$ ping 10.100.0.10

うまく行った場合、pingは正しく動作し、疎通が確認できるはずです。もしうまく動作しない場合は、ここまでの手順を確認してみて下さい。

疎通できるようになったところで、従来のネットワークとOpenVNetの仮想ネットワークとの興味深い違いを1つ紹介しておきましょう。先ほど inst2eth0 に設定したIPアドレスを、 10.100.0.11/24 から 10.100.0.15/24 に変更してみましょう。

$ sudo ip addr del 10.100.0.11/24 dev eth0
$ sudo ip addr add 10.100.0.15/24 dev eth0

設定が終わったら、再び inst1 に対して ping を実行してみます。

$ ping 10.100.0.10

先程とは異なり、疎通ができなくなったはずです。これがもし従来のネットワークだった場合、 10.100.0.0/24 の範囲内のIPアドレスであれば疎通できます。しかしOpenVNetは設定情報に従って厳格に通信制限を行うため、inst2 のIPアドレスが 10.100.0.11 でない限り、通信を許可しません。

18.6. OpenVNetを応用した実用例

最後にOpenVNetの高度な応用事例として、OpenVNetのDockerコンテナ対応、および複数クラウド間の仮想ネットワークによる連携機能を紹介します。いずれも、著者の所属するTIS株式会社が実証実験を行い、それぞれの成果はフリーソフトウェアとして配布しています。

18.6.1. 複数サーバ上のDockerコンテナを仮想ネットワークで接続する

Docker [30] とは、dotCloud社(現Docker社)が自社のパブリックPaaSを実現するために開発したコンテナの一種です。アプリケーションの実行環境を容易に素早く、かつ他の影響を受けないようにして立ち上げるために、Dockerは他から隔離された環境(=コンテナ)を作り出します。

Dockerは様々なリソースを隔離しますが、ネットワークもその隔離すべきリソースの一つです。そのためDockerは、ネットワークネームスペースや仮想ネットワークインタフェースを用いて、サーバ内部に他から隔離された仮想ネットワークを作ります。さらに Docker は iptables を使って、この仮想ネットワークを外部と通信できるようにします。

この方式は、複数のサーバ上でDockerを動作させる場合に問題が生じます。Dockerが作り出す仮想ネットワークはサーバ内に閉じており、異なるサーバで動作しているDockerコンテナ同士が通信できないためです。

この問題は、DockerにOpenVNetを組み合わせれば、解決できます。Dockerコンテナ同士をOpenVNetの仮想ネットワークで接続すれば、サーバをまたいだDockerコンテナ間が通信できるようになります。さらに、OpenVNetを使うことで、Docker コンテナを繋ぐネットワークに対して、セキュリティグループの機能が使えます。

例えばある物理ネットワーク上のサーバ2台と、ルータで接続された別の物理ネットワーク上のサーバ1台の合計3つのサーバがあるとします。これらのサーバ上でDockerコンテナを動作させ、それらをOpenVNetの仮想ネットワークで接続することを考えてみましょう。

まず最初に、各サーバ上にエッジスイッチを立ち上げます。次に、各サーバ上でDockerコンテナを立ち上げ、それらをエッジスイッチに接続します。

さらに以下の手順でOpenVNetを設定します。

  1. 各エッジスイッチのDatapath IDをOpenVNetに設定する

  2. 各サーバが所属する物理ネットワークの情報をOpenVNetに設定する

  3. OpenVNetが敷設する仮想ネットワークを定義する

  4. 各サーバの物理ネットワークインタフェースの情報をOpenVNetに設定する

  5. 立ち上げたDockerコンテナの仮想ネットワークインタフェースの情報をOpenVNetに設定する

  6. OpenVNetが制御するセキュリティグループを定義する

  7. 各仮想ネットワークインタフェースに望みのセキュリティグループを割り当てる

  8. OpenVNet上に仮想ルータを構成して、物理ネットワークと仮想ネットワーク間のルーティングを定義する

最後に各サーバとDockerコンテナにスタティックルートを設定すれば、OpenVNetを用いたDockerネットワーキングが完成します。

各サーバ上のDockerコンテナは、OpenVNetが作った一つの仮想ネットワークに接続していますので、異なるサーバのDockerコンテナ同士が通信できます。またセキュリティグループの機能を使えば、OpenVNet が到達すべきでないパケットを遮断するため、個々のDockerコンテナにパケットフィルタルールを定義する必要がなくなります。

docker openvnet 1
図 18-11: OpenVNetを用いたDockerネットワーキング

なお、ここで説明した手順を実行するツールキットを、walfisch [31] というフリーソフトウェアとして公開しています。実際に実行したコマンドが標準出力に表示されますので、興味がある方は一度動作させてみると良いでしょう。

18.6.2. 複数のクラウドを仮想ネットワークで連結する

OpenVNetはDockerコンテナ間を連結するというミクロな機能だけでなく、複数のクラウド間を連結するというマクロな機能も提供します。

現在様々なクラウドが利用可能ですが、提供されるネットワーク機能やその利用方法はクラウドごとに大きく異なります。このため複数のクラウド間を連結したい場合、それぞれのクラウドのネットワーク機能を強く意識したネットワーク設計を行う必要があります。

OpenVNetは、OpenVNetの仮想ネットワークと外部のネットワークの間をシームレスに接続するVNetEdge機能を持っています。そのためOpenVNetを利用することで、クラウドごとに異なるネットワーク機能に依存せず、複数のクラウドをシームレスに連携できます。

例えば、プライベートクラウドとしてWakame-vdc、パブリッククラウドとしてAmazon Web Servicesのネットワークを連結するケースを考えます。

narukozaka tools
図 18-12: プライベートIaaSとパブリックIaaSの連結構成

VNetEdgeはこの連結を次のように実現しています。まず、仮想ネットワークIDとVLAN IDの変換規則をOpenVNetに登録します。そして、Wakame-vdcの仮想ネットワークと、Amazon Web ServicesのVirtual Private Cloudで構築されたネットワークの間を流れるパケットがVNetEdgeのエッジスイッチを通過する際に、この2つのネットワークが同一のネットワークであるかのようにパケット転送を制御します。

このツールキットはフリーソフトウェアとして公開しており [32]、複数のクラウド間を連結する以外にも多くの機能を持ちます。

  • wakame-vdcとパブリックIaaSの間を自動的に連結する機能

  • IaaSのネットワーク上に、VNetEdgeをスイッチとしたスター型のネットワークトポロジを構築する機能

  • Wakame-vdc側のインスタンスとAmazon Web Services側のインスタンスのVNetEdge間の通信の暗号化

  • IaaSのインスタンスイメージの作成と起動

  • IaaSのインスタンスにインストールするミドルウェアの自動設定

18.7. まとめ

Tremaで構築された本格的な仮想ネットワーク基盤である OpenVNet を紹介しました。

  • OpenVNet はエッジ仮想化であるため、既存の物理ネットワークをほぼそのまま活用して、仮想ネットワークを実現できる

  • オンプレミス環境以外にも、AWSに代表されるパブリッククラウドでも利用できる

  • 仮想マシンだけでなく、Dockerに代表されるコンテナ主体の基盤とも組み合わせて利用できる

  • LGPL3ライセンスに基づくフリーソフトウェアであり、オープンな開発コミュニティを持っている

OpenVNetは、Tremaと同じく開発者を広く募集しています。腕に覚えのある方は、ぜひOpenVNetのホームページ (http://openvnet.org/) から開発にご参加ください。

This book is released under the GNU General Public License version 3.0:


1. MIT の xpizza コマンドのマニュアル: https://stuff.mit.edu/afs/sipb/project/lnf/other/CONTRIB/ai-info
2. カーネギーメロン大学のコーク・マシンのサイト: http://www.cs.cmu.edu/~coke/
3. RFC 2324: https://www.ietf.org/rfc/rfc2324.txt
4. 実際にはアクションはインストラクションという要素の一部です。アクションとインストラクションの関係について、詳しくは2章「OpenFlow の仕様」で詳しく説明します
5. 指定できるアクション数の上限は OpenFlow スイッチとコントローラの実装に依存します。普通に使う分にはまず問題は起こらないでしょう
6. Web プログラミングフレームワークの一つ。http://rubyonrails.org/
7. https://bundler.io/
8. http://openvswitch.org/
9. https://www.rdoproject.org/Main_Page
10. 日本語版は http://www.aoky.net/articles/why_poignant_guide_to_ruby/
11. http://archive.openflow.org/
12. cbench コマンドの初回実行時には、自動的に cbench コマンドのコンパイルが始まります。二回目以降の実行ではコンパイルは起こりません
13. OpenFlow1.0 にはインストラクションはありません。そのかわりパケットに適用したいアクションを、このように Flow Mod に直接指定します
14. http://stackoverflow.com/questions/153234/how-deep-are-your-unit-tests
15. https://signalvnoise.com/posts/3159-testing-like-the-tsa
16. https://cucumber.io
17. https://github.com/cucumber/aruba
18. https://github.com/trema/cucumber_step_definitions
19. https://github.com/troessner/reek
20. http://ruby.sadi.st/Flog.html
21. http://ruby.sadi.st/Flay.html
22. https://github.com/bbatsov/rubocop
23. 厳密に言うと以下のステップが発生するには、レガシーネットワークスイッチのエージアウト間隔よりも OpenFlow スイッチのエージアウト間隔が長い、という前提条件があります
24. ここでは、ルータに直接接続したネットワークへのルーティング (いわゆる connected ルーティング) の動作のみを説明しています。ルータに直接接続していないネットワークへのルーティング (いわゆるスタティックルーティング) の実装については、lib/simple_router13.rbSimpleRouter13#add_routing_table_flow_entries メソッドを参照してください。
25. ダイクストラ法はリンクに重み(距離)がある場合の最短パスを求められるので、実際にはもう少し複雑な手順になります。ネットワーク上の最短パスではそれぞれのリンクは“重み1”として考えるので、このように単純化できます。
26. 16 章ではルーティングスイッチにクラスを追加することで仮想ネットワーク機能を実装する例を紹介します。
27. このようなパスを、イコールコストマルチパス(Equal Cost Multipath)と呼びます。
28. curl の出力を短くするために、冗長オプション (-v) は省略しています
29. この処理からわかるように、スライスに属していないホスト同士はデフォルトで通信できる仕様になっています
30. Dockerの詳細は、Dockerの公式ドキュメント(https://docs.docker.com/)を参照ください
31. https://github.com/tech-sketch/walfisch
32. https://github.com/cloudconductor-incubator/narukozaka-tools