P4 で記述した簡易 L2 Switch にタグ VLAN（802.1Q）を対応させて VLAN 毎のトラヒックカウンタを実装する（実装編）

## はじめに

P4 で記述した L2 Switch に下記の機能を追加します．

- タグ VLAN（802.1Q）毎に MAC テーブル/Broadcast domain を分割
- VLAN 毎のトラヒックカウンタ

こちらは前回投稿した[「準備編」](https://qiita.com/13ryuse4/items/cb83abd80712616e0799)の続編となります．P4 の基礎文法やそれらを用いた簡易 L2 Switch の記述については前回説明したため，こちらでは P4 で記述した L2 Switch に上記機能を追加実装する方法を説明します．

## 追加機能の実装方法

本来ならば前回説明した簡易 L2 Switch に実装すべきだったかもしれませんが，L2 Switch なので ARP パケット等を処理するための Broadcast に対応させる必要があります．以下ではまず P4 で Broadcast を実装する方法を説明し，続いてタグ VLAN の実装，特に VLAN 毎の MAC テーブル/Broadcast domain の実装，そして VLAN 毎のトラヒックカウンタの実装の順に説明します．

### Broadcast の実装

Broadcast を実装するためには，今回利用する architecture である v1model の standard metadata が提供する "パケット複製のための制御パラメータ" を理解する必要があります．standard metadata とは[「準備編」](https://qiita.com/13ryuse4/items/cb83abd80712616e0799)でも少し触れましたが「スイッチ側が用意した制御変数」のことです．v1model の architecture を定義する [v1model.p4](https://github.com/p4lang/p4c/blob/master/p4include/v1model.p4) を眺めると下記のようなパラメータが見つかります．

```c:v1model.p4
struct standard_metadata_t {
    
    /* 中略 */
    
	@alias("intrinsic_metadata.mcast_grp")
	bit<16> mcast_grp;
	/// Replication ID for multicast
    
    /* 中略 */
}
```

```mcast_grp``` という制御パラメータでパケットの複製が出来そうです．では，この制御変数が今回使用する target（P4 プログラムをデプロイする H/W）である BMv2 にどのように実装されているか，を見てみます．[公式ページ](https://github.com/p4lang/behavioral-model/blob/master/docs/simple_switch.md)には下記のような記載があります．

> - mcast_grp: needed for the multicast feature. This field needs to be written in the ingress pipeline when you wish the packet to be multicast. A value of 0 means no multicast. This value must be one of a valid multicast group configured through bmv2 runtime interfaces. See the "after-ingress pseudocode" for relative priority of this vs. other possible packet operations at end of ingress.

少し分かりにくいので，もう少し読み進めていくと下記のような記載があります．

```c:After-ingress-pseudocode

  /* 中略 */
    
} else if (mcast_grp != 0) {
  // This condition will be true if your code made an assignment to
  // standard_metadata.mcast_grp during ingress processing.  There
  // are no special primitive actions built in to simple_switch for
  // you to call to do this -- use a normal P4_16 assignment
  // statement, or P4_14 modify_field() primitive action.
  Make 0 or more copies of the packet based upon the list of
  (egress_port, egress_rid) values configured by the control plane
  for the mcast_grp value.  Enqueue each one in the appropriate
  packet buffer queue.  The instance_type of each will be
  PKT_INSTANCE_TYPE_REPLICATION.
} else if (egress_spec == DROP_PORT) {
  // This condition will be true if your code called the
  // mark_to_drop (P4_16) or drop (P4_14) primitive action during
  // ingress processing.
  Drop packet.
} else {
  Enqueue one copy of the packet destined for egress_port equal to
  egress_spec.
}
```

上記より standard metadata の ```mcast_grp``` というパラメータが 0 でない場合に ```mcast_grp``` と紐付けてコントロールプレーンより登録された (egress_port, egress_rid) の組み合わせを参照してパケットを複製することが分かります．なお egress_rid については[公式ページ](https://github.com/p4lang/behavioral-model/blob/master/docs/runtime_CLI.md)にて説明されていますので，こちらも併せて参照ください．

このように P4 でデータプレーン機能を実装する際は**使用する target を理解し，それが architecture としてどのように抽象化されているか**（P4 プログラマにとってどのような形で利用可能となっているか）を把握する必要があります．これは使用する target/architecture 毎に異なる部分となるため，今回説明する Broadacst の実装はあくまで v1model on BMv2 に限定された記述となりますが，target と architecture の理解が必要である，という点はどのような target/architecture を用いる場合も一般に言える部分かと思います．

では，実際に Broadcast を実装していきましょう．Broadcast の実装で肝要となる ```mcast_grp``` の設定はコントロールプレーンと密接に関わる部分となるため，実装に先立ち下記のような仮定を置きます．

- タグ VLAN がスタックされていない場合，すなわち通常の Broadcast を行う場合の ```mcast_grp``` はスイッチ起動時等に設定し，その値は ```mcast_grp == 1``` とする．なお，対応する IF はスイッチの具備する全 IF とする．

この時，Broadcast を実行する action は下記のように記述されます．

```c:broadcast.p4
action broadcast() {
  standard_metadata.mcast_grp = 1;
}
```

非常にシンプルですね．こちらの action は宛先 MAC アドレスがブロードキャストアドレス（ff:ff:ff:ff:ff:ff）の場合に使用されます．MAC テーブルに broadcast action を登録できるよう，下記のように MAC テーブルを拡張します．

```c:table_with_broadcast.p4
table mac_exact {
    key = { hdr.ethernet.dstAddr: exact; }
    actions = {
        switching;
+       broadcast;
        drop;
    }
}
```

拡張した MAC テーブルに下記のようにエントリを登録することでブロードキャストアドレスに対して broadcast action が実行されます．

|        key        |   action  |   action parameter   |
| ----------------- | --------- | -------------------- |
| ff:ff:ff:ff:ff:ff | broadcast |                      |

なお，このとき ```standard_metadata.mcast_grp = 1``` とすることで全 IF にパケットが複製されます．複製先から入力パケットを受信した元々の受信ポートを除外するためには Egress 処理に下記のような処理を追加します．

```c:egress_with_broadcast.p4
control EgressName(inout headers hdr,
                   inout metadata meta,
                   inout standard_metadata_t standard_metadata) {
+   action drop() {
+       mark_to_drop(standard_metadata);
+   }
    
    apply {
+       if (standard_metadata.egress_port == standard_metadata.ingress_port) {
+           drop();
+       }
    }
}
```

```standard_metadata.ingress_port``` は（オリジナルの）入力パケットを受信したポート番号，```standard_metadata.egress_port``` は複製パケットを送出するポート番号となります．上記の実装では，元の入力パケットを受信したポートには出力しないよう，Egress で入出力ポートをチェックしています．

### タグ VLAN（802.1Q）の実装

上記で定義した broadcast action を VLAN に対応させましょう．重ねての説明となりますが ```mcast_grp``` の設定はコントロールプレーンと密接に関わる部分となるため，下記のような仮定を追加します．

- VLAN が設定される毎にコントロールプレーンが未使用の ```mcast_grp``` の値を発行し，当該 VLAN および同一の VLAN に属する IF 集合と紐付けて管理する．

例えば，インターフェースを４つ（それぞれ IF0, IF1, IF2, IF3）具備するスイッチを想定し，ユーザが IF0, IF2 に VLAN 100 を設定し，続いて IF0, IF1, IF3 に VLAN 200 を設定した場合 各 VLAN ID の ```mcast_grp``` と ```egress_port``` の組み合わせは下記のようになります．

| VLAN ID | mcast_grp  |     egress_port   |
| --------| ---------- | ----------------- |
|    0    |     1      | IF0, IF1, IF2, IF3|
|   100   |     2      |      IF0, IF2     |
|   200   |     3      |   IF0, IF1, IF3   |

上記の表をコントロールプレーンが管理するイメージです．なお ```mcast_grp == 1``` に対応する VLAN ID は 0 としています（デフォルト VLAN）．```mcast_grp == 2``` は VLAN 100 の Broadcast domain，```mcast_grp == 3``` は VLAN 200 の Broadcast domain に対応します．このように VLAN 毎に ```mcast_grp``` を設定することで Broadcast domain の分割が可能となります．

VLAN 毎に MAC テーブルを分割し，broadcast action で ```mcast_grp``` を VLAN 毎に設定するよう拡張すれば，簡易 L2 Switch にタグ VLAN を対応させることが出来ます．こちらはそれぞれ下記のように記述されます．

```c:broadcast_with_vlan.p4
action broadcast(bit<16> grp_id) {
  standard_metadata.mcast_grp = grp_id;
}
```

```c:mac_vlan_exact.p4
table mac_vlan_exact {
    key = {
+       hdr.vlan.id: exact;
        hdr.ethernet.dstAddr: exact;
    }
    action = {
        switching;
        broadcast;
        drop;
    }
}        
```

まず broadcast_with_vlan.p4 について説明します．VLAN を考慮しない通常の Broadcast の場合は事前に定義した ```mcast_grp == 1``` を ```standard_metadata.mcast_grp``` に設定するのみでしたが，VLAN 毎に異なる ```mcast_grp``` を設定できるよう，action が引数 ```grp_id``` をコントロールプレーンより受け取り，こちらを ```standard_metadata.mcast_grp``` に設定する実装としています．

また，MAC テーブルを VLAN 毎に分割するために ```key = { hdr.vlan.id: exact; }``` を追加し，宛先 MAC アドレスに加えて VLAN ID も考慮したテーブル走査を実装しています．なお，VLAN ID が異なる場合もエントリが登録されるテーブルは全て mac_vlan_exact table となるため，厳密には VLAN 毎の MAC テーブルを用意していませんが，VLAN ID が異なる場合は宛先 MAC アドレスが同一でもテーブル上のエントリとしては別々のものとして認識されることから，実質的に VLAN 毎に MAC テーブルを用意していると言えるかと思います．

上記で説明した VLAN 100，VLAN 200 に対応する Broadcast は， mac_vlan_exact table に下記のようなエントリを登録することで実現できます．

|        key             |   action  |   action parameter   |
| ---------------------- | --------- | -------------------- |
| 100, ff:ff:ff:ff:ff:ff | broadcast | 2 (0000000000000010) |
| 200, ff:ff:ff:ff:ff:ff | broadcast | 3 (0000000000000011) |

なお，broadcast action の定義を若干変更した（```grp_id``` を引数として受け取るようにした）ため，VLAN を考慮しない通常の Broadcast を行うためには mac_exact table に下記のようなエントリを登録する必要があります．

|        key             |   action  |   action parameter   |
| ---------------------- | --------- | -------------------- |
|    ff:ff:ff:ff:ff:ff   | broadcast | 1 (0000000000000001) |

先程は action parameter には何も設定しない実装でしたが，こちらでは ```grp_id = 1``` を登録します．データプレーンで静的に ```mcast_grp``` を設定していた部分を，コントロールプレーンから動的に登録するように変更したためです（どちらがいいかについてはここでは議論しませんが，データプレーンとコントロールプレーン間の機能配備については実装するアプリケーションによって工夫が必要そうです...）．

なお，上記で新たに定義した mac_vlan_exact table は下記のように Ingress 処理内で適用されます．

```c:apply_vlan.p4
apply{
    if ( !hdr.vlan.isValid() ) {
        mac_exact.apply();
    } else {
+       mac_vlan_exact.apply();
    }
}
```

前回は入力パケットにタグ VLAN がスタックされていた場合に drop() していましたが，今回は mac_vlan_exact table を適用するように変更しています．

### VLAN 毎のトラヒックカウンタの実装

最後に，VLAN 毎のトラヒックカウンタを実装します．トラヒックカウンタは BMv2 の "Extern" である Counter を用いて実装します． Extern とは端的に言うと **target 毎に実装される外部機能**のことです．P4 では，target 毎に独自に実装した機能を Extern という形で（自作の P4 プログラムより）呼び出すことが出来ます．主要な Extern については [PSA](https://p4.org/p4-spec/docs/PSA-v1.1.0.html) で規定されており，今回用いる Counter の他にレート制御を行う [Meter](https://tools.ietf.org/html/rfc2698) や stateful な記憶領域である Register 等があります．

architecture は target が実装した Extern を抽象化し，どのように利用するか，を定義しており，例えば [v1model.p4](https://github.com/p4lang/p4c/blob/master/p4include/v1model.p4) では Counter は下記のように定義されています．

```c:v1model_counter.p4

/* 中略 */

enum CounterType {
    packets,
    bytes,
    packets_and_bytes
}

/* 中略 */

extern counter
#if V1MODEL_VERSION >= 20200408
<I>
#endif
{
    /***
     * A counter object is created by calling its constructor.  This
     * creates an array of counter states, with the number of counter
     * states specified by the size parameter.  The array indices are
     * in the range [0, size-1].
     *
     * You must provide a choice of whether to maintain only a packet
     * count (CounterType.packets), only a byte count
     * (CounterType.bytes), or both (CounterType.packets_and_bytes).
     *
     * Counters can be updated from your P4 program, but can only be
     * read from the control plane.  If you need something that can be
     * both read and written from the P4 program, consider using a
     * register.
     */
    counter(bit<32> size, CounterType type);
    // FIXME -- size arg should be `int` but that breaks typechecking

    /***
     * count() causes the counter state with the specified index to be
     * read, modified, and written back, atomically relative to the
     * processing of other packets, updating the packet count, byte
     * count, or both, depending upon the CounterType of the counter
     * instance used when it was constructed.
     *
     * @param index The index of the counter state in the array to be
     *              updated, normally a value in the range [0,
     *              size-1].  If index >= size, no counter state will be
     *              updated.
     */
#if V1MODEL_VERSION >= 20200408
    void count(in I index);
#else
    void count(in bit<32> index);
#endif
}
```

最大 $2^{32}$ 個のカウンタを有する counter object を生成することが出来，かつ CounterType によって "何をカウントするか" を選ぶことが出来るようです．また，CounterType としてパケット単位（packets），バイト単位（bytes），その両方（packets_and_bytes）の３パターンが定義されています．実際にカウンタを実行（トラヒックをカウント）する際は count() method を呼び出し，引数として index を指定することで何番目のカウンタを実行するかを選べる仕様になっています．

このように，**P4 には Extern という "自由度" が与えられており，それを architecture が抽象化することで H/W そのものに対する柔軟な機能拡張を実現**しているのです．

では，上記を用いて VLAN 毎のトラヒックカウンタを実装します．まず，counter object を定義しましょう．今回は VLAN の上限数である 4096 個のカウンタを有する counter object を生成します．なお，トラヒックカウントはバイト単位で行うものとします．P4 では下記のように記述されます．

```c:counter_definition.p4
   const bit<32> CNT_SIZE = 4096;
   counter(CNT_SIZE, CounterType.bytes) traffic_cnt;
```

ここで ```const``` は定数を定義する記述で，今回の場合はカウンタ数 ```CNT_SIZE``` を 4096 として定義しています．

実際にカウンタを実行するためには index を指定する必要がありますが，今回は counter の index として VLAN ID がちょうどよさそうです．P4 では変数の代入時にビット幅を揃える必要があるため，12 bit の VLAN ID を 32 bit の index 値に変換する必要があります．この実装は色々と方法があるかもしれませんが，今回は metadata を活用して実装します．metadata は "独自に定義可能な構造体変数" で，同一の処理ブロック（今回の場合は Ingress 処理）内部では stateful な変数として使用可能です．例えば，今回は 32 bit 幅の index 値を使用するため，下記のように metadata を定義します．

```c:metadata_definition.p4
struct metadata_t {
    bit<32> idx;
}
```

今回はテーブル走査の直前に counter 用の index 値を取得し，スイッチング処理（broadcast/switching action）中に入力パケットが属する VLAN のカウンタを実行するような仕様にします．なお，VLAN タグがスタックされていない通常のトラヒックの場合は index を 0 に設定します．P4 では Ingress 処理の中で下記のように記述されます．

```c:traffic_counter.p4
control IngressName(inout headers hdr,
                    inout metadata_t meta,
                    inout standard_metadata_t standard_metadata) {

    /* Counter 定義部 */

	action broadcast(bit<16> grp_id) {
+		traffic_cnt.count(meta.cnt_idx);
		standard_metadata.mcast_grp = grp_id;
	}
    
    action switching(bit<9> port) {
+       traffic_cnt.count(meta.cnt_idx);
    	standard_metadata.egress_spec = port;
	}
  
	/* Table 定義部 */
    
    apply{
        if ( !hdr.vlan.isValid() ) {
+           meta.cnt_idx = 32w0;
            mac_exact.apply();
        } else {
+           meta.cnt_idx = (bit<32>) hdr.vlan.id;
        	mac_vlan_exact.apply();
        }
    }
}
```

ここで ```32w0``` は 32 bit 幅の 0 を表します．入力パケットの VLAN ID は ```(bit<32>) hdr.vlan.id``` で 32 bit 幅にキャストしています．```meta``` は metadata のインスタンスであり，各テーブルを適用する前に入力パケットの VLAN ID を ```meta.idx``` に格納して，この値を count() method の引数として与えることで VLAN 毎のトラヒックカウントを実行しています．なお，各カウンタの値はコントロールプレーンから取得可能です．こちらは「機能確認編（執筆中）」にて詳細を説明します．

## 機能追加した L2 Switch の実装

以上の P4 プログラム（の断片）を前回説明した簡易版 L2 Switch の P4 プログラムに追加すると，冒頭で述べたタグ VLAN，および VLAN 毎のトラヒックカウンタの機能追加が可能です．P4 での記述は下記のようになります．

```c:L2switch_advanced.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 {
+   bit<32> cnt_idx;
}

/** 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) {

+   const bit<32> CNT_SIZE = 4096;
+   counter(CNT_SIZE, CounterType.bytes) traffic_cnt;
    
    action drop() {
        mark_to_drop(standard_metadata);
    }

+	action broadcast(bit<16> grp_id) {
+		traffic_cnt.count(meta.cnt_idx);
+		standard_metadata.mcast_grp = grp_id;
+	}
    
    action switching(bit<9> port) {
+       traffic_cnt.count(meta.cnt_idx);
    	standard_metadata.egress_spec = port;
	}
    
    table mac_exact {
        key = { hdr.ethernet.dstAddr: exact; }
        actions = {
            switching;
+           broadcast;
            drop;
        }
    }

+	table mac_vlan_exact {
+    	key = {
+        	hdr.vlan.id: exact;
+        	hdr.ethernet.dstAddr: exact;
+    	}
+    	action = {
+        	switching;
+        	broadcast;
+        	drop;
+    	}
+	}
    
    apply{
        if ( !hdr.vlan.isValid() ) {
+           meta.cnt_idx = 32w0;
            mac_exact.apply();
        } else {
+           meta.cnt_idx = (bit<32>) hdr.vlan.id;
+           mac_vlan_exact.apply();
        }
    }
}

/** EGRESS **/
control EgressName(inout headers hdr,
                   inout metadata meta,
                   inout standard_metadata_t standard_metadata) {
+    action drop() {
+        mark_to_drop(standard_metadata);
+    }
    
    apply {
+        if (standard_metadata.egress_port == standard_metadata.ingress_port) {
+            drop();
+        }
+    }
}

/** 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;
```

上記をデプロイしたスイッチをホスト間に挟みこめば，ARP パケット（ブロードキャスト）の処理も含み，VLAN を設定した IF 間での ping 疎通が可能となります．

## おわりに

[「準備編」](https://qiita.com/13ryuse4/items/cb83abd80712616e0799)にて定義した簡易 L2 Switch に下記の機能追加を行いました．

- タグ VLAN（802.1Q）毎に MAC テーブル/Broadcast domain を分割
- VLAN 毎のトラヒックカウンタ