はじめに
P4 で記述した L2 Switch に下記の機能を追加します.
- タグ VLAN(802.1Q)毎に MAC テーブル/Broadcast domain を分割
- VLAN 毎のトラヒックカウンタ
各機能の P4 での記述方法,および BMv2(P4 対応の仮想スイッチ)での機能確認方法について解説します.追加機能のコードを理解するための基礎文法についても簡単に記述しますが,詳細は公式ページを参照ください.
思いのほか内容が膨らんでしまったため,P4 の基礎文法等を説明する「準備編」と付加機能の実装を説明する「実装編」の 2 段構成としました.まずは準備編として P4 の基礎文法から説明します.
P4 の基礎文法
P4 はパケット処理に特化したプログラミング言語の一つです.特に,パケット処理を行う H/W そのものを P4 で抽象化することで,柔軟な機能追加を実現しています.
ユーザが自作機能を開発する際は,利用する H/W に対応する architecture を include し,その上に自作機能を記述していきます.architecture としては,OSS では v1model や psa 等が知られており.ベンダ製チップでは tofino が有名です.なお,psa は Portable Switch Architecture の略で,P4.org というコミュニティが標準アーキティクチャとして仕様策定を行っているようです.PSA の詳細アーキティクチャは公式ページを参照ください.今回は BMv2 にて実装されている v1model を例に P4 プログラムの記述方法を簡単に解説します.
ヘッダ定義
識別するヘッダを定義します.今回はタグ VLAN(802.1Q)に対応した L2 Switch なので Ether ヘッダ,VLAN タグが識別出来ればよいです.これらを P4 で記述すると下記のようになります.
header ethernet_t {
bit<48> dstAddr;
bit<48> srcAddr;
bit<16> etherType;
}
header vlan_t {
bit<3> priority;
bit<1> dei;
bit<12> id;
bit<16> etherType;
}
記述は非常にシンプルで,上位フィールドから順番に "ビット数" と "変数名" を列挙すればよいです.また,特定のビット数には名前を付けることが可能で,例えば MAC アドレスのビット長を表す macAddr_t を typedef bit<48> macAddr_t
のように定義することも出来ます.この場合は bit<48>
の部分を macAddr_t
で代替可能です.
これらはヘッダの"型" を定義しており,実際のヘッダはこれらの "型" を組み合わせて定義します.今回は上記2つの "型" のみを取りうるため header は下記のように記述します.
struct headers {
ethernet_t ethernet;
vlan_t vlan;
}
C 言語の構造体に似ていますね.上述の header_definition.p4 で定義した "型" をもつ変数としてヘッダの構成要素(Ether ヘッダや IP ヘッダ等)を定義するイメージです.
こちらの headers という構造体には取りうるヘッダを全て記述します.入力パケットをこちらで定義した "型" のいずれかに沿って Parse していきますが,注意すべき点としては「全てのヘッダを過不足なく Parse する必要は無い」という点です.入力パケットがその "型" で取り出された場合は isValid() が true となり,取り出されていない場合は false となります.例えば(ethernet_t という型に沿って)ethernet が Parse された場合は ethernet.isValid() == true
となります.
Parser 定義
入力パケットは上述の headers 構造体で定義した各 "型" のいずれかに沿って Parse されます.今回は下記のような条件分岐を行い,入力パケットを Parse します.
- 入力パケットはまず ethernet_t(Ether ヘッダ)の "型" に沿って Parse
- 取り出した Ether ヘッダの etherType が 0x8100(802.1Q VLAN タグ)の場合
- vlan_t(VLAN タグ)の型に沿って Parse
- 取り出した Ether ヘッダの etherType が 0x8100 以外の場合
- Parse 終了
- 取り出した Ether ヘッダの etherType が 0x8100(802.1Q VLAN タグ)の場合
こちらを P4 で記述すると下記のようになります.
state start {
transition parse_ethernet;
}
state parse_ethernet {
packet.extract(hdr.ethernet);
transition select(hdr.ethernet.etherType) {
0x8100 : parse_vlan;
default : accept;
}
}
state parse_vlan {
packet.extract(hdr.vlan);
transition accept;
}
"state" という表記が示すように,Parser は状態遷移図としてモデル化され,各 "型" に沿ってParse する部分は一つの状態として記述されます.この時,下記のルールを守る必要があります.
- 最初の状態は必ず "start" でなければならない
- 最後の状態は必ず "accept" or "reject" でなければならない
最終状態が "accept" の場合はそのまま処理が続行し, "reject" の場合は入力パケットは破棄されます.なお,最終状態を明記しなかった場合は "reject" となるようです(言語仕様より)
各状態では packet_in 型の変数(コード中では packet と記述されており,入力パケットと等価なものとして認識すれば大丈夫です)が具備する extract() method により,引数で指定した "型" に沿って Parse し,値が格納されます.extract() の引数で指定した構造体変数の型に沿って入力パケットが Parse され,その結果が引数で指定した構造体変数に書き込まれる,というイメージで認識しておけばよいかと思います.例えば,入力パケットから Ether ヘッダを取り出したい場合は,ethernet_t 型の変数である ethernet を下記のように引数に指定しpacket.extract(hdr.ethernet)
と記述します.
"hdr" という変数は headers 構造体のインスタンスのようなイメージです.こちらは Parser 定義部そのものの引数として,例えば下記のように記述されます.
parser ParseName(packet_in packet,
out headers hdr,
inout metadata_t meta,
inout standard_metadata_t standard_metadata) {
/* Parse 処理記述(上記 parser.p4 参照) */
}
こちらは architecture に依存する部分となり,利用する H/W によっては引数が若干異なる場合があります.上記は v1model を用いる場合の記述となります.
状態間の遷移は transition
あるいは transition select()
と記述します.前者は遷移先の状態が1つの場合,後者は遷移先の状態が複数の場合にそれぞれ使用します.特に後者の場合は select()
の括弧内で指定したフィールド値に基づいて遷移先の状態を選択します.例えば,Ether ヘッダの etherType に基づいて状態遷移する場合は select(hdr.ethernet.etherType)
のように記述します.
Ingress/Egress 定義
Parse された入力パケットに対する処理は Ingress/Egress にて記述します.なお,Ingress はパケットを受信(+ Parse)してキューにバッファリングするまでの処理,Egress はキューにバッファリングされて出力ポートから発出されるまでの処理を指します.
Ingress や Egress の構成は非常にシンプルで「action」「table」「apply」に大別されます.action ではコントロールプレーンから受け取った parameter を用いて,入力パケットに対して行う一連の処理を記述します.table では key と action の紐付けを定義します.apply では table や action を入力パケットにどのように適用するか,の処理シーケンスを定義します.入力パケットに適用したい処理のまとまりを action で定義し,それらをどのような key 値に基づいて match させるかを table で定義し,それらをどのような順序で入力パケットに適用していくかを apply で定義する,という感じです.
と,長々と書いてしまいましたが,実際の記述例を見たほうが理解が早いかと思います.例えば,コントロールプレーンから「出力ポート番号」を受け取って,その値を出力ポートに設定する action は下記のように記述できます.
action switching(bit<9> port) {
standard_metadata.egress_spec = port;
}
standard_metadata については発展的な内容となるため詳細は省略しますが,ここでは「スイッチ側が用意した制御変数」というイメージでよいかと思います.standard_metadata にはスイッチを制御するため or スイッチから情報を取得するための様々な変数が定義されており,その中の egress_spec は入力パケットの出力ポートを決定する制御変数となります.standard_metadata.egress_spec = port
とすることで,コントロールプレーンにより登録されたポート(port)を出力ポートとして設定しているのです.なお,standard_metadata は architecture 毎に定義されており,v1model の standard_metadata は v1model.p4 にて定義されています.
上記で定義した action を入力パケットの宛先 MAC アドレスと紐付けて table に登録するためには下記のように記述します.
table mac_exact {
key = { hdr.ethernet.dstAddr: exact; }
actions = { switching; }
}
key = { ... }
で table エントリの key を設定し,各エントリが 取りうる全ての action を actions = { ... }
に列挙します.このとき全ての action という部分は重要です.上記の table は action として switching のみを取りうる table として定義していますが,必ずしも転送するわけではなく,意図的にパケットドロップさせたい場合もあるかと思います.この場合は actions = { ... }
にパケットドロップを行う action(ここでは drop と表記)を追加します.
table mac_exact {
key = { hdr.ethernet.dstAddr: exact; }
actions = {
switching;
+ drop;
}
}
table の定義で注意すべき点としては,table の記述はあくまで各エントリが取りうる action を列挙するのみで,実際の key 値がどのような action を取りうるか,すなわち table エントリそのものはコントロールプレーンが登録する,という点です.例えば,"11:22:33:44:55:66" という宛先 MAC アドレスに対しては(switching action によって)出力ポート "3" を設定し,また "66:55:44:33:22:11" という宛先 MAC アドレスに対してはパケットをドロップするというaction(ここでは drop と表記)を設定するとします.この場合,これらの組み合わせを下記のようにコントロールプレーンが登録します.
key | action | action parameter |
---|---|---|
11:22:33:44:55:66 | switching | 3 (000000011) |
66:55:44:33:22:11 | drop | - |
action, table を定義したら,最後はこれらをどのような順序で適用していくか,を apply{} に記述します.入力パケットに VLAN タグが付いているかどうかで場合分けし,VLAN タグが付いていない場合のみ mac_exact table を適用(入力パケットの宛先 MAC アドレスを key として mac_exact のエントリを走査)する処理は下記のように記述されます.
apply{
if ( !hdr.vlan.isValid() ) {
mac_exact.apply();
}
}
非常にシンプルですね.isValid() は "そのヘッダが Parse されたかどうか" を表す bool 型の変数を返します.! は NOT(論理否定)です.従って !hdr.vlan.isValid()
は hdr の vlan が Parse されていない場合に true となります.
入力パケットに table を適用する場合は テーブル名.apply() と記述します.今回は mac_exact table を適用するため mac_exact.apply()
と記述することで,入力パケットの hdr.ethernet.dstAddr を key として mac_exact table を走査し,エントリが hit した場合は対応する action を実行します.
(おまけ)入力パケットへのヘッダ挿入
余談ですが,入力パケットに新たにヘッダを追加する場合は setValid() という method を使用します.例えば,VLAN タグが付いていないパケットに新たにVLAN タグを付与して転送する場合は下記のようにコードを追記すればよいです.
apply {
if ( !hdr.vlan.isValid() ) {
mac_exact.apply();
+ hdr.vlan.setValid();
+ hdr.vlan.priority = /* 任意の値を入力 */;
+ hdr.vlan.dei = /* 任意の値を入力 */;
+ hdr.vlan.id = /* 任意の値を入力 */;
+ hdr.vlan.etherType = /* 任意の値を入力 */;
}
}
上記の記述は,アクセスポートで VLAN パケットを受信し,トランクポートから VLAN パケットを発出する場合等に使えるかと思います.
Deparser 定義
パケット処理の最後は,Parse したヘッダの再構築(Deparse)です.出力ポートから発出するヘッダを構成します.今回の場合は Ether ヘッダ or VLAN タグを取りうるため,下記のように記述します.
control DeparserName(packet_out packet, in headers hdr) {
apply{
packet.emit(hdr.ethernet);
packet.emit(hdr.vlan);
}
}
基本的にはheaders.p4 で定義した headers 構造体のメンバ変数を packet.emit() の引数として列挙すればよいです.emit() method は引数の isValid() が true の場合に出力パケットのヘッダに当該ヘッダをスタックします.そのため,例えば VLAN タグを Parse しなかった場合(hdr.vlan.isValid() == false
の場合)は packet.emit(hdr.vlan)
と記述されていても出力ヘッダに VLAN タグをスタックしません.
なお,Deparser における "packet" は Parser における "packet" とは型が異なります.Parser で使用する "packet" は packet_in 型,Deparser で使用する "packet" は packet_out 型です.こちらも発展的な内容となるため,packet_in 型の変数は入力パケット,packet_out 型の変数は出力パケットと認識しても差し支えないかと思います(具体的な定義は core.p4 で与えられます.詳細はこちらを参照ください).
P4 による L2 Switch 実装
以上のP4プログラム(の断片)を組み合わせることで,P4 による簡易 L2 Switch が実装可能です.ここで想定する L2 switch の仕様は下記です.
- 入力パケットのヘッダには Ether ヘッダ( + 802.1Q VLAN タグ)がスタックされている.
- 入力パケットに VLAN タグがスタックされていない場合
- 宛先 MAC アドレスに基づき出力ポートを選択する.
- VLAN タグがスタックされている場合
- 入力パケットを drop する(VLAN タグも考慮して switching する実装は「実装編」にて説明します).
なお,宛先 MAC アドレスと出力ポートの組は事前にコントロールプレーンより登録するものとします.
P4 で記述すると下記のようになります.
/** INCLUDE ARCHITECTURE **/
#include <core.p4>
#include <v1model.p4>
/** HEADERS **/
header ethernet_t {
bit<48> dstAddr;
bit<48> srcAddr;
bit<16> etherType;
}
header vlan_t {
bit<3> priority;
bit<1> dei;
bit<12> id;
bit<16> etherType;
}
struct headers {
ethernet_t ethernet;
vlan_t vlan;
}
struct metadata_t {
}
/** PARSER **/
parser ParseName(packet_in packet,
out headers hdr,
inout metadata_t meta,
inout standard_metadata_t standard_metadata) {
state start {
transition parse_ethernet;
}
state parse_ethernet {
packet.extract(hdr.ethernet);
transition select(hdr.ethernet.etherType) {
0x8100 : parse_vlan;
default : accept;
}
}
state parse_vlan {
packet.extract(hdr.vlan);
transition accept;
}
}
/** INGRESS **/
control IngressName(inout headers hdr,
inout metadata_t meta,
inout standard_metadata_t standard_metadata) {
action drop() {
mark_to_drop(standard_metadata);
}
action switching(bit<9> port) {
standard_metadata.egress_spec = port;
}
table mac_exact {
key = { hdr.ethernet.dstAddr: exact; }
actions = {
switching;
drop;
}
}
apply{
if ( !hdr.vlan.isValid() ) {
mac_exact.apply();
} else {
drop();
}
}
}
/** EGRESS **/
control EgressName(inout headers hdr,
inout metadata meta,
inout standard_metadata_t standard_metadata) {
apply {}
}
/** DEPARSER **/
control DeparserName(packet_out packet, in headers hdr) {
apply{
packet.emit(hdr.ethernet);
packet.emit(hdr.vlan);
}
}
/* Verify Checksum (NOT used here) */
control verifyChecksum(inout headers_t hdr, inout metadata_t meta) {
apply { }
}
/* Update Checksum (NOT used here) */
control updateChecksum(inout headers_t hdr, inout metadata_t meta) {
apply { }
}
/* Pipeline Definition */
V1Switch(parserImpl(),
verifyChecksum(),
ingressImpl(),
egressImpl(),
updateChecksum(),
deparserImpl()) main;
簡易的な L2 Switch は概ね上記のように記述することで実装可能です.なお,Egress は特に処理が必要無いため空白としています.
おわりに
P4 の基礎文法,および簡易 L2 Switch の実装を説明しました.「実装編」では上記で実装した L2 Switch に対し下記の機能追加を行います.
- タグ VLAN(802.1Q)毎に MAC テーブル/Broadcast domain を分割
- VLAN 毎のトラヒックカウンタ