プログラマブルデータプレーンのためのプログラミング言語P4について勉強する

はじめに

SIGCOMMの論文を読んでいてP4というプログラミング言語を知りました。
面白そうだったので勉強しようと思ったのですが、どこまで環境構築すれば動くようになるのかいまいちやり方が分からず、思い立ってはやめを繰り返していました。
2021年は心機一転,そろそろ書き方は知っておきたいと思い本腰を入れて勉強することにしました。
そんな折、日本P4ユーザ会様がP4のハンズオン勉強会をオンラインで開催されていたため、そちらにも参加させていただき、理解を深めることができました。
connpass.com

本記事の内容は以下のチュートリアルを進めていくにあたって,理解した内容を自分なりにまとめたものになります。
誤りがございましたらご指摘いただけますと幸いです。可及的速やかに修正いたします。
github.com

[追記 2021.08.19 細かなtypoや言い回しは特に断らずちょこちょこ直していますが、明らかに誤解を生みそうな文章、認識が完全に誤っていた文章に関しては追記を付して変更・編集しています]

[追記 2021.08.20 本記事はP4の言語仕様ver1.2.1時点のものを参照して作成しました。現在はver1.2.2が策定されており、一部異なる可能性がございます]
https://p4.org/p4-spec/docs/P4-16-v1.2.2.pdf

SDN (Software Defined Network)

データプレーンとは何かを説明するにあたり、まずは簡単にSDNについて紹介します。

SDNとは、Software Defined Networkの略で、ネットワークの設定・構成等を柔軟に動的に制御するための技術です。

SDNでは、従来のネットワークスイッチのルーティングを、ルーティングのルールを決定する機能と、パケット転送などのパケットを処理する機能に分離します。
このそれぞれのモジュールを、コントロールプレーン、データプレーンと呼びます。コントロールプレーンは中央集権的にデータプレーンとなるネットワーク機器を管理することができます。

従来技術では、ネットワーク機器ごとに異なるルーティングルールが設定されているため、統括的な管理には手間がかかります。

データプレーンは各々フローテーブルを持っており,到着したパケットの処理をどうするべきか、フローテーブルを参照して決定します。このフローテーブルはコントロールプレーンが制御しています。
例えば、OpenFlowでは、フローテーブルはヘッダ情報、カウンタ、アクションが定義されています。

SDNでは、コントロールプレーンを介してフローテーブルをデータプレーンに伝えることにより、複数のネットワーク機器に設定を反映することができます。

このようにして、まるでソフトウェア的にネットワークの構成・設定をプログラミングできるように扱えます。

P4について

P4とは、データプレーンがどのようにパケットを処理するかを記述するために開発されたプログラミング言語です。P4という名前は、論文のタイトルにもなっているProgramming Protocol-independent Packet Processorsの略語になっています。

Bosshart, Pat, Dan Daly, Glen Gibb, Martin Izzard, Nick McKeown, Jennifer Rexford, Cole Schlesinger, et al. 2014. “P4: Programming Protocol-Independent Packet Processors.” SIGCOMM Comput. Commun. Rev. 44 (3): 87–95.
https://dl.acm.org/doi/10.1145/2656877.2656890

従来のネットワーク機器のパケット処理に関する機能は、ASICの設計に依存しており、ASICの持つ機能しか利用できないという制約がありました。
この機能を変更しようとなると、ASICから再設計する必要があり、多大なコストがかかります。

P4では、P4に対応している機器であれば、機器の特性に依らずデータプレーンの機能をプログラミングすることができます。

P4の言語仕様にはP4-14とP4-16が存在します。これはオリジナルのP4に改良を加えていったものがP4-16となっており、旧来のP4をP4-14と呼ぶようになったようです。現在はP4-16での開発が主流です。

PISA (Protocol Independent Switching Architecture)

P4では以下のようなパイプライン処理を持つアーキテクチャを想定し、パイプラインの各ブロックの処理をプログラミングしていくことになります。

「Parser」→「Match-Action Unit」→「Deparser」

このアーキテクチャPISA(Protocol Independent Switching Architecture)と呼ばれています。

ブロックの意味は、それぞれ以下の通りです。

  • Parser: ヘッダ情報を解析し、その情報をメタデータとしてパイプラインの各ブロックに渡す。
  • Match-Action Unit: 得られたメタデータを用いてフローテーブルを参照し、対応する処理(アクション)を実行する
  • Deparser: ヘッダを再構成し、パケットを送出する

ターゲットとアーキテクチャモデル

P4でプログラミングするデータプレーン機器のことをターゲットと呼びます。
ターゲットとなるハードウェアに関しては、FPGAやASICなどがあります。また、P4の仕様に完全準拠するBehavioral-Model (BMv2)というP4対応の簡易的なソフトウェアスイッチもあります。これはP4開発の練習やテストのためのターゲットであり、性能は控えめになっています。

P4でPISAをプログラミングするにあたって,「プログラミングが可能なブロック」「各ステージの持つインターフェイス」「各ステージの機能」を定義したものをアーキテクチャと呼びます。

Specifically, arch.p4 defines what P4-programmable blocks are available, the interface for each stage, and the capability for each stage. Who is responsible for writing such an architecture program? The P4 Consortium is one source of such a definition, but different switch vendors have created their own architecture specifications to closely describe the capabilities of their switching chips.

Chapter 4: Bare-Metal Switches — Software-Defined Networks: A Systems Approach Version 2.1-dev documentation

アーキテクチャは基本的にプログラミング対象となるデータプレーン機器のベンダが提供するファイルです。P4でデータプレーンの機能をプログラミングする際は、ターゲットがサポートするアーキテクチャを利用する必要があります。

チュートリアルではBMv2をターゲットとして利用しています。
ちなみにBMv2にはいくつかのバリエーションがあるのですが、チュートリアルなどで利用されているsimple_switch、simple_switch_grpcは、v1model.p4というアーキテクチャを利用できます。
github.com

アーキテクチャモデルは他にPSA (Portable Switch Architecture)があります。
これは、汎用的に利用できることを目的にしたモデルで、現在開発が進められています。
[追記 2021.08.20 曖昧かつ誤解のあった記述であったため文章を再考しました]
ベンダごとにアーキテクチャが異なるとプログラムの書き方が変わってしまうため、汎用性がありません。
PSAでは異なるデバイスに対しても共通の方法でプログラミングできるように、P4 Architecture Working Groupで標準化が進められているものです。
2021年8月現在はver 1.1が策定されています。


https://p4lang.github.io/p4-spec/docs/PSA.pdf

P4言語を書いてみる

定義ファイルのインクルード

P4言語は、コアとなるプログラムをインクルードするところから始まります。
最初の部分を見てみると、以下のようになっています。C言語に似た書き方です。

#include <core.p4>
#include <v1model.p4>

core.p4はコアとなるヘッダファイルであることは推察されますが、v1model.p4は何でしょうか。
これは、アーキテクチャの定義ファイルです。
今回はBMv2をターゲットとするので、対応しているアーキテクチャの1つであるv1model.p4を利用します。
実際には、ネットワーク機器のアーキテクチャに合わせた定義ファイルをインクルードしてくることになります。

変数の定義

変数は一般的なプログラミングと同様の方法で定義することができます。
型もいくつかありますが、そのうちよく利用されるものを以下に列挙します。
また、typedefを用いて変数の型に別名をつけることも可能です。

  • bit<n>: n bitで表現される符号なしinteger
  • int<n>: n bitで表現される符号つきinteger
  • varbit<n>: n bitで表現されるbitstring
  • bool: bool型
  • header: 順序付きのデータコレクション。ヘッダ情報を表現するときに用いる。isValid()というメソッドで検証することができる。
  • struct: 順序なしのデータコレクション。

上の情報を使い、イーサネットヘッダを変数として定義すると、以下のようになります。

typedef bit<32> macAddr 
header ethernet_t {
   macAddr dst;
   macAddr src;
   bit<16> type;
};

パイプラインの実装方法

各ブロックをどのように定義し実装するべきかに関しては、アーキテクチャの定義ファイル(ここではv1model.py)に記載されています。
tutorials/p4-cheat-sheet.pdf at master · p4lang/tutorials · GitHub

v1model.p4ではパイプライン処理は以下のように定義されています。したがって、v1modelアーキテクチャのデータプレーンをプログラミングする際には、以下の構造にしたがってパイプラインの処理を実装していくことになります。

// v1model pipeline elements
parser Parser<H, M>(
    packet_in pkt,
    out H hdr,
    inout M meta,
    inout standard_metadata_t std_meta
);

control VerifyChecksum<H, M>(
    inout H hdr,
    inout M meta
);

control Ingress<H, M>(
    inout H hdr,
    inout M meta,
    inout standard_metadata_t std_meta
);

control Egress<H, M>(
    inout H hdr,
    inout M meta,
    inout standard_metadata_t std_meta
);

control ComputeChecksum<H, M>(
    inout H hdr,
    inout M meta
);

control Deparser<H>(
    packet_out b, in H hdr
);

// v1model switch
package V1Switch<H, M>(
    Parser<H, M> p,
    VerifyChecksum<H, M> vr,
    Ingress<H, M> ig,
    Egress<H, M> eg,
    ComputeChecksum<H, M> ck,
    Deparser<H> d
);

各ブロックの実装方法

それぞれの機能をどのようにP4で実装していくかを見ていきます。
なお、タイトルに*が付いているものは少し細かい話になるので、まずは大まかな流れを知りたいという人は飛ばしていただいて問題ないという意味で付けています。

Parser

Parserは,届いたパケットのヘッダ情報を解析して,のちのMatch-Action Pipelineで利用するための情報を取得します.例えば,MACアドレスIPアドレスなどです.
これらのデータは,パイプラインで利用できるような形式(メタデータ)として抽出され、Match-Action Pipelineに渡されていきます。

parse

Parserをプログラミングする箇所はparserブロックです。

parseブロックでは、startという初期状態と、acceptrejectの2種類の終了状態が定義されたステートマシンを想定しています。
acceptは「パースに成功した」、rejectは「(パケットにエラーがあるなどして)パースに失敗した」を指します*1

parseブロックは、必ずstartから始まってからどちらかの終了状態に到達しなければなりません。ただし、初期状態と終了状態以外の状態は、stateステートメントを用いて自分で定義することができます。

一般的に、パケットの各種ヘッダ(イーサネットヘッダ、IPヘッダなど)を抽出するための状態をそれぞれ定義して、start→各種ヘッダの解析状態→acceptと状態を遷移させていくことで、ヘッダを順番に抽出していきます。

P4では、transitionステートメントで状態を遷移させていきます。以下はtransitionの使い方を示したものです。ここでは単純にstartから直接acceptへと状態を遷移します。
特に処理を記述していないので何も実行されません。

parser MyParser(packet_in packet,
                out headers hdr,
                inout metadata meta,
                inout standard_metadata_t standard_metadata) {

    state start {
        transition accept
    }
}

v1model.p4の定義と比較してみます。

parser Parser<H, M>(
    packet_in pkt,
    out H hdr,
    inout M meta,
    inout standard_metadata_t std_meta
);
select

一般的には、selectステートメントを併用して状態遷移を制御し、処理を記述していくことになります。
selectステートメントは一般的なプログラミング言語のswitch-case文の使い方に非常に似ています。

実際に、TutorialsのBasicForwardingのsolutionよりソースコードを引用させていただき、状態遷移制御のやり方を確認します。
(コメントにつきましては著者が追加しております。)
github.com

parser MyParser(packet_in packet,
                out headers hdr,
                inout metadata meta,
                inout standard_metadata_t standard_metadata) {

    state start {
        transition parse_ethernet;  //1. 
    }

    state parse_ethernet {
        packet.extract(hdr.ethernet); //2. 
        transition select(hdr.ethernet.etherType) { //3. 
            TYPE_IPV4: parse_ipv4;  //4. 
            default: accept; //5. 
        }
    }

    state parse_ipv4 { 
        packet.extract(hdr.ipv4); //6. 
        transition accept; //7. 
    }

}
  1. stateステートメントで定義した「parse_ethernet」に状態を遷移させる
  2. parse_ethernet状態に遷移し、まずイーサネットヘッダを抽出する
  3. selectステートメントイーサネットヘッダのイーサネットタイプを確認する
  4. もしもイーサネットタイプがTYPE_IPV4(プログラム上部で0x800と定義されています)であれば、parse_ipv4に遷移
  5. TYPE_IPv4でなければ、これ以上抽出したいヘッダ情報はないので、accept状態に遷移してパース終了
  6. parse_ipv4状態に遷移し、IPヘッダを抽出する。
  7. これ以上抽出したいヘッダ情報は無いので、accept状態に遷移してパース終了

ちなみにこちらはIPヘッダのみを利用するという前提に立っています。それ以外のヘッダを抽出したい場合は適宜状態を定義して遷移させていくことになります。

Match-Action Unit

Parserでヘッダ情報を取り出しました。このヘッダ情報にはフローエントリのkeyとなりうる情報が含まれています。keyというのは送信元IPアドレスなどのことです。

次にやりたいことは、そのkeyに対応するactionを実行することです。すなわち、送信元IPアドレスがこうだったら転送、違っていれば廃棄、というようなことをしたいわけです。

P4では、keyとactionを定義していくことになります。

control

Match-Action Unitはマルチステージ型で構成されており、各ステージに固有のメモリとALUが搭載されているのですが、各ステージはcontrolブロックを用いて定義することができます。parserブロックで抽出されたメタデータは、controlブロックに渡されています。

control MyIngress(inout headers hdr,
                  inout metadata meta,
                  inout standard_metadata_t standard_metadata) {
 ...
}

v1model.p4の定義と比較してみます。

control Ingress<H, M>(
    inout H hdr,
    inout M meta,
    inout standard_metadata_t std_meta
);
方向*

controlに渡されている引数には、inoutというものが付けられています。これはデータの方向を示す型です。

controlや後述のactionでは、DirectionalなものとDirectionlessなものの2種類の変数を利用可能です。Directionalなものはデータプレーンから(すなわちパイプライン上から)送られて来るデータ、Directionlessなものはコントロールプレーンから来るデータです。

Directionlessなものは特に問題無いのですが、Directionalなデータに関しては別途in, out, inoutの3種類の方向を付与されます。
inはread-onlyで変更不可能なデータ、outは変更される予定のあるデータ*2につけられます。
すなわち基本的には、inとなっているデータは代入式の右側、outとなっているデータは代入式の左側で出てくることになります。

inoutはinputとしてもoutputとしても利用されるデータであり、上の例と照らし合わせれば、代入式の右側にも左側にも来ることができます。例えば、値を入れ替えるswap処理や、インクリメント、デクリメント処理をする時などに利用されます。

Directionlessなデータに関しては後述のactionにて説明します。

action

acitionの処理を記述するには、actionステートメントを利用します。

構文としては、以下のとおりです(Parserと同じくbasic.p4より引用)。一般的なプログラミング言語で言う関数定義と類似しています。

control MyIngress(inout headers hdr,
                  inout metadata meta,
                  inout standard_metadata_t standard_metadata) {

    action drop() { //1.
        ...
    }
    
    action ipv4_forward(macAddr_t dstAddr, egressSpec_t port) {  //2.
        standard_metadata.egress_spec = port;
        hdr.ethernet.srcAddr = hdr.ethernet.dstAddr;
        hdr.ethernet.dstAddr = dstAddr;
        hdr.ipv4.ttl = hdr.ipv4.ttl - 1;
    }
 ...
}

1. drop()というactionを定義します。
2. ipv4_forward()というactionを定義します。データプレーンのどのポートからパケットを送出するかを決定したあと、送信元MACアドレスhdr.ethernet.dstAddr(データプレーン自身)に、宛先MACアドレスをフローテーブルに従って決定された転送先dstAddr、そしてttlを1つ減らします(つまりL2での通常のルーティングの実装です)。

standard_metadata_tは、アーキテクチャ(v1model.p4)で定義されているデータで、以下があります。
egressSpec_tは、データプレーンの持つポート(ネットワークスイッチの文脈でのポート)です。

struct standard_metadata_t {
    bit<9> ingress_port;
    bit<9> egress_spec;
    bit<9> egress_port;
    bit<32> clone_spec;
    bit<32> instance_type;
    bit<1> drop;
    bit<16> recirculate_port;
    bit<32> packet_length;
    bit<32> enq_timestamp;
    bit<19> enq_qdepth;
    bit<32> deq_timedelta;
    bit<19> deq_qdepth;
    bit<48> ingress_global_timestamp;
    bit<48> egress_global_timestamp;
    bit<32> lf_field_list;
    bit<16> mcast_grp;
    bit<32> resubmit_flag;
    bit<16> egress_rid;
    bit<1> checksum_error;
    bit<32> recirculate_flag;
}
Directionlessな変数*

actionに渡している引数には今回は方向型が付けられていません。

方向型が付けられない、Directionlessなデータとしては次のような場合があります。

  1. コンパイル時に決定するデータ
  2. actionに渡されるパラメータのうち、コントロールプレーンからのみセットできるデータ
  3. actionに渡されるパラメータのうち、他のアクションによってセットされるデータ(inとして扱われる)

今回のmacAddr_t dstAddr、egressSpec_t portに渡される具体的な値は、pod-topo/s*-runtime.jsonで指定されています。
つまりこれは2.の「コントロールプレーンからセットされるデータ」であるため方向型が付けられていないということになります。
コントロールプレーンに関しては後述のP4Runtimeにて説明させていただきます。

table

keyとactionを定義するために、tableステートメントを利用します。

以下が、Parserと同じbasic.p4より引用したソースコードです。

control MyIngress(inout headers hdr,
                  inout metadata meta,
                  inout standard_metadata_t standard_metadata) {

    action drop() {
        ...
    }
    
    action ipv4_forward(macAddr_t dstAddr, egressSpec_t port) {
        ...
    }
    
    table ipv4_lpm {  //3.
        key = { //4.
            hdr.ipv4.dstAddr: lpm;
        }
        actions = {  //5. 
            ipv4_forward;
            drop;
            NoAction;
        }
        size = 1024;
        default_action = drop();
    }
 ...
}

3. tableステートメントを用いてテーブル情報を定義します。
4. keyとして、IPv4の宛先IPアドレスを利用します。lpmとは、logest prefix matchの略であり、マッチングのルールとして、IPアドレスのビット列の一致する長さが最長の経路を選択するという意味です。core.p4で定義されています。*3
5. actionsとして、ipv4_forward, dropを登録します。

applyブロック

tableで定義した情報を実際に適用するため、applyブロックを利用します。
controlブロックには、applyブロックがなければいけません。*4
[追記 2021.08.19 まるでその根拠があると断定しているような書き方になっていたので、大幅に編集しました]

tableで定義した情報を実際に適用するには、tableが持つapply()メソッドを呼び出します。
tableの持つapply()メソッドを呼び出すと、そのtableに格納されているフローテーブルエントリから、キーにヒットするものを検索し、対応するactionを実行します(言語仕様の13.2.3参照)。

control MyIngress(inout headers hdr,
                  inout metadata meta,
                  inout standard_metadata_t standard_metadata) {

    action drop() {
        ...
    }
    
    action ipv4_forward(macAddr_t dstAddr, egressSpec_t port) {
        ...
    }
    
    table ipv4_lpm {
        ...
    }
    
    apply {
        if (hdr.ipv4.isValid()) { //6
            ipv4_lpm.apply();
        }
    }
}

6. applyブロック内でテーブル情報を適用します。isValid()によってヘッダを検証し、もし有効なものであればテーブルの持つapply()メソッドで実際にテーブル情報を適用します。

apply()メソッドを呼び出しているのは、applyブロック内です。このapplyブロックは何者でしょうか。

controlブロックには、applyブロックがなければいけないと書かれている文献もありましたが、言語仕様にはそのような内容は明示的に書かれていません。

そのため、もう少し突っ込んで調べました。

言語仕様のApplendix Fを見ると、

Actions may be called directly from a control apply block.

とあります。
また、その下の表を見ると、control apply block内では、「control」、「extern」、「table」、「action」、「function」を呼び出すことが可能と書いており、さらに「table」を呼び出せるのは、control内のapplyブロックのみということが書かれています。
そして、その少し前に、

Calling a parser, control, or table means invoking its apply() method.

とあります。
上記を踏まえると、定義したtableをapply()メソッドで呼び出すためには、controlブロック内でapplyブロックを用意してそこに記述しなくてはならない、という認識のほうが正しいかもしれません。
(controlブロックにapplyブロックがなければならない、というのは、tableを用意してわざわざそれを利用しないことはない、というような意味かもしれません。もう少し調査します。)


ところで、宛先IPアドレスがどうだったらipv4_forwardを実行し、dropを実行するのかが分かりません。実際の対応付けはどこでやっているのでしょうか。

対応付けに関しては、コントロールプレーンが定義しており、データプレーンは単にそのコントロールプレーンから渡された情報をもとに処理を行います。
コントロールプレーンがgRPCを介してデータプレーンの持つフローテーブルのエントリを更新している形です(詳しくは実験・P4Runtimeの章)。

P4で記述するのは、あくまでデータプレーンで実行される処理であり、その他のことに関してはタッチしません*5

Deparser

Deparserではヘッダを再構成してパケットを作成し、それを宛先に対して送信します。
もしもヘッダデータを書き換えているのであれば、その内容が反映されます。

以下がDeparserの例です。

control MyDeparser(packet_out packet, in headers hdr) {
    apply { //1.
        packet.emit(hdr.ethernet); //2. 
        packet.emit(hdr.ipv4); //3. 
    }
}

1. applyステートメントで実行する内容を記述します。
2. イーサネットヘッダをセットします。
3. IPヘッダをセットします。

packet_out packetはcore.p4に記述されています。packet.emtiによりヘッダ情報をシリアライズします。これでパケットのヘッダを、順番にセットしていっていることになります。

PISAブロックの適用

PISAの主要ブロックを適切に定義したあとは、これを実際に適用します。構文は下記のとおりです。*6

V1Switch(
    MyParser(),
    MyVerifyChecksum(),
    MyIngress(),
    MyEgress(),
    MyComputeChecksum(),
    MyDeparser()
) main;

これでデータプレーンのプログラミングが完成しました。

P4を実行する

実行環境の構築

P4ファイルを書き終えたので、今度はコンパイルのための実行環境を準備します。
まずは、P4をコンパイルするp4lang-p4cを以下のページにしたがってインストールします。
github.com

なお、今回は以下の環境で実験をします。
カーネルのバージョン [追記 2021.08.19 誤りがあったため修正しました。NetFliterの記事のものをコピーしたまま修正するのを忘れていました。]

<s>5.4.0-47-generic</s>
5.11.0-25-generic

OSのバージョン

Ubuntu 20.04 LTS

嬉しいことに、Ubuntu20.04であれば、リポジトリが用意されているのでここからインストールしてきます。

$ sudo add-apt-repository ppa:dreibh/ppa
$ sudo apt update
$ sudo apt install p4lang-p4c

自分の場合、元々の環境が悪かったのか、依存関係の問題でclangのインストールができず、p4lang-p4cがインストールできませんでした。
aptitudeコマンドを使うといくつか解決方法を教えてくれるのですが、libc6のバージョンを2.31-0ubuntu9.3から2.31-0ubuntu9.2にダウングレード(3を選択)したところ、うまくインストールできました。
unix.stackexchange.com

$ sudo aptitude install clang
...
 libc6-i386 : 依存: libc6 (= 2.31-0ubuntu9.2) 2.31-0ubuntu9.3 がインストール済みです
...

以下のアクションでこれらの依存関係の問題は解決されます:

     以下のパッケージを削除する:                                            
1)     libc-dev-bin [2.31-0ubuntu9.3 (now)]                                 

     以下のパッケージをインストールする:                                    
2)     libc-dev-bin:i386 [2.31-0ubuntu9.2 (focal-updates)]                  

     以下のパッケージをダウングレードする:                                  
3)     libc6 [2.31-0ubuntu9.3 (now) -> 2.31-0ubuntu9.2 (focal-updates)]     
4)     libc6:i386 [2.31-0ubuntu9.3 (now) -> 2.31-0ubuntu9.2 (focal-updates)]
5)     libc6-dbg [2.31-0ubuntu9.3 (now) -> 2.31-0ubuntu9.2 (focal-updates)] 
6)     libc6-dev [2.31-0ubuntu9.3 (now) -> 2.31-0ubuntu9.2 (focal-updates)] 



この解決方法を受け入れますか? [Y/n/q/?]y

さて、次はターゲットとなるBMv2をインストールします。
github.com

依存するパッケージが、すでにp4lang-p4cを入れた時に入っているような?と思いそのまま作業を進めたところうまく./configure -> makeができました。
パスを通すほどでもないなと思ったら、make installは行わずとも大丈夫です。

$ git clone https://github.com/p4lang/behavioral-model.git
$ cd behavioral-model

$  ./autogen.sh
$ ./configure
$ make
$ sudo make install

実際に実行する

実験用ネットワークの構築

コンパイラとターゲットをインストールできたので、実際に実験用のネットワーク環境を構築していきます。
なお、この構築方法については、下記記事を参考に書かせていただきました。
opennetworking.org

www.slideshare.net

P4環境の整ったVMなどを用意しても良いのですが、簡単に試す際にはNetwork Namespaceが利用されています。Network Namespaceとは、カーネル名前空間機能の一種であり、ネットワーク関連の機能を分離させて動作させることのできる技術です。コンテナ技術にも利用されています。

ホストマシンにBmv2をインストールしているので、今回は以下のような環境を想定しました。

まずは、host0とhost1というネットワーク名前空間を作ります。

$ sudo ip netns add host0
$ sudo ip netns add host1

続いて、ホストマシンとhost0、ホストマシンとhost1をつなぐvethペア(それぞれveth0とveth0-host、veth1とveth1-hostという名前にします)を作ります。

$ sudo ip link add veth0 type veth peer name veth0-host
$ sudo ip link add veth1 type veth peer name veth1-host

今度は、作ったvethをそれぞれのネットワーク名前空間に移します。veth0をhost0に、veth1をhost1に移します。

$ sudo ip link set veth0 netns host0
$ sudo ip link set veth1 netns host1

ここまでくれば、あとは必要なインターフェースにIPアドレスを付与して、up状態にします。

$ sudo ip netns exec host0 ip addr add 10.0.0.1/24 dev veth0
$ sudo ip netns exec host1 ip addr add 10.0.1.1/24 dev veth1

$ sudo ip netns exec host0 ip link set veth0 up
$ sudo ip netns exec host1 ip link set veth1 up

$ sudo ip link set dev veth0-host up
$ sudo ip link set dev veth1-host up
P4プログラムのコンパイル

次に、P4プログラムをコンパイルします。
コンパイルするプログラムは以下です。
github.com

p4cコマンドでコンパイルし、コンパイルに成功すると、basic.p4iファイルと、basic.jsonファイルが生成されます。

p4c basic.p4
ソフトウェアスイッチの起動とテーブル情報の書き込み

次に、P4プログラムの書き込みのターゲットとなるソフトウェアスイッチを起動します。
これは、simple_switchプログラムを使います(場所はbehavioral-model/targets/simple_switch)
iオプションで、スイッチのポートがどのネットワークインタフェースに紐づくかを指定できます。
ホストとhost0の通信路であるveth0-hostをポート0に、ホストとhost1の通信路であるveth1-hostをポート1に設定します。
log-consoleオプションをつけることで、ログが端末に表示されるようになります。

$ sudo ./simple_switch -i 0@veth0-host -i 1@veth1-host --log-console <p4cで生成されたjsonファイルのパス>

今度は、CLIプログラムでソフトウェアスイッチにテーブル情報を書き込みます。
プログラムは/behavioral-model/targets/simple_switchにあります。

コマンド名はhelpで確認でき、またコマンドの使い方は help <コマンド名> で確認できます。

./runtimeCLI

     :
     :

RuntimeCmd: help

Documented commands (type help <topic>):
========================================
act_prof_add_member_to_group       set_crc16_parameters
     :
     :

RuntimeCmd: show_tables
MyIngress.ipv4_lpm             [implementation=None, mk=ipv4.dstAddr(lpm, 32)]

RuntimeCmd: show_actions
MyIngress.drop                 []
MyIngress.ipv4_forward         [dstAddr(48),	port(9)]
NoAction                       []

早速、ソフトウェアスイッチにテーブル情報を書き込みます。
スイッチに「宛先IPアドレスが10.0.0.1宛てのパケットが来たら、host0のMACアドレスに転送」、
「10.0.1.1宛のパケットが来たらhost1のMACアドレスに転送」というルールを書き込むことにします。

このような場合にはtable_addコマンドを使います。
help table_addとすることで、コマンドの書式を確認することが出来ます。

table_add <table name> <action name> <match fields> => <action parameters> [priority]

今回の例では、どのように書けば良いでしょうか。P4プログラムのMyIngressの箇所を見てみます。
ここでは、コメントに書いたとおりの対応関係になります。

  • table name : ipv4_lpm
  • action name : ipv4_forward
  • match fileds : ホスト0 or ホスト1のIPアドレス(宛先として)
  • action parameters : ホスト0 or ホスト1のMACアドレス(宛先として)、ソフトウェアの出力ポート
control MyIngress(inout headers hdr,
                  inout metadata meta,
                  inout standard_metadata_t standard_metadata) {

    action drop() {
        ...
    }
    
    action ipv4_forward(macAddr_t dstAddr, egressSpec_t port) { // action parameters (dstAddrとport)
        ...
    }
    
    table ipv4_lpm {  // table name
        key = {
            hdr.ipv4.dstAddr: lpm;  // match fields(host0あるいはhost1のIPアドレス/プレフィックス)
        }
        actions = {
            ipv4_forward;  // action name
            drop;
            NoAction;
        }
        size = 1024;
        default_action = drop();
    }
 ...
}

それでは早速、ルールを追加していきます。
10.0.0.1のときはパケットの宛先MACアドレスをhost0のものにし、その出力ポートをveth0-hostのポートである0(スイッチ起動時に指定したもの)にします。
10.0.1.1のときはパケットの宛先MACアドレスをhost1のものにし、その出力ポートをveth1-hostのポートである1にします。

RuntimeCmd: table_add MyIngress.ipv4_lpm MyIngress.ipv4_forward 10.0.0.1/24 => host0のMACアドレス 0
Adding entry to lpm match table MyIngress.ipv4_lpm
match key:           LPM-0a:00:00:01/24
action:              MyIngress.ipv4_forward
runtime data:        host0のMACアドレス	00:00
Entry has been added with handle 0
RuntimeCmd: table_add MyIngress.ipv4_lpm MyIngress.ipv4_forward 10.0.1.1/24 => host1のMACアドレス 1
Adding entry to lpm match table MyIngress.ipv4_lpm
match key:           LPM-0a:00:01:01/24
action:              MyIngress.ipv4_forward
runtime data:        3a:e3:c5:6e:85:19	00:01
Entry has been added with handle 1

table_dumpコマンドでテーブル内容を確認してみると、以下のようになっています。

RuntimeCmd: table_dump MyIngress.ipv4_lpm
==========
TABLE ENTRIES
**********
Dumping entry 0x0
Match key:
* ipv4.dstAddr        : LPM       0a000001/24
Action entry: MyIngress.ipv4_forward - a230e2b741d3, 00
**********
Dumping entry 0x1
Match key:
* ipv4.dstAddr        : LPM       0a000101/24
Action entry: MyIngress.ipv4_forward - 3ae3c56e8519, 01
==========
Dumping default entry
Action entry: MyIngress.drop -
パケットを実際に送ってみる

さて、ここから実験を行います。
簡単にScapyで下記のようなプログラムを書きました。
host0からhost1に対して、あるいはhost1からhost0に対してパケットを1つ送信します。

from scapy.all import *
import sys

switch_veth0_mac = "52:0b:93:d7:52:51"
switch_veth1_mac = "36:86:0a:a1:c7:e5"

host0_mac = "a2:30:e2:b7:41:d3"
host1_mac = "3a:e3:c5:6e:85:19"

host0_ip = "10.0.0.1"
host1_ip = "10.0.1.1"

if len(sys.argv) < 2:
    print(f"Usage {sys.argv[0]} <mode | 0=host0, 1=host1>")
    exit()

mode = sys.argv[1]
print(mode)

if mode == "0":
    ether = Ether(dst=switch_veth0_mac, type=0x0800)
    ip = IP(dst=host1_ip)
    request = (ether/ip)
    sendp(request, iface="veth0")
else:
    ether = Ether(dst=switch_veth1_mac, type=0x0800)
    ip = IP(dst=host0_ip)
    request = (ether/ip)
    sendp(request, iface="veth1")

では、host0からhost1に向かってパケットを送信します。

$ sudo ip netns exec host0 sudo python3 test.py 0

simple_switchを起動した画面を確認すると、キー(宛先IPアドレス)がテーブルエントリにマッチしていることがわかります。

Match key:
* hdr.ipv4.dstAddr    : LPM       0a000101/24
Action entry: MyIngress.ipv4_forward - host1のMACアドレス,1,

host1上でtcpdumpを使って確認しておくと、パケットが届いていることがわかります。

$ sudo ip netns exec host1 sudo tcpdump -i veth1
IP 0.0.0.0 > 10.0.1.1:  hopopt 0

host1からhost0に向かってパケットを送信してみても同様です。

$ sudo ip netns exec host1 sudo python3 test.py 1
Match key:
* hdr.ipv4.dstAddr    : LPM       0a000001/24
Action entry: MyIngress.ipv4_forward - host0のMACアドレス,0,
$ sudo ip netns exec host0 sudo tcpdump -i veth0
IP 0.0.0.0 > 10.0.0.1:  hopopt 0

P4Runtime

P4言語のコンパイルから実行まで確認してみました。

流れをまとめると、次のようになります。

  1. P4でプログラムを記述
  2. P4プログラムをコンパイルしてデータプレーンのターゲットに合わせたバイナリデータを生成(ベンダーの提供しているアーキテクチャモデルの定義ファイルも含まれる)
  3. バイナリデータをデータプレーンにロード
  4. データプレーンのテーブル情報を作成・更新・削除

肝となるのは、4番目の操作です。これはどのようにすれば良いでしょうか。例えば、P4コンパイラコンパイルすると、データプレーンにアクセスするためのAPIが自動生成されます。このAPIを使ってコントロールプレーンの機能を実装すれば実行可能です。しかし、このAPIは、作成したP4プログラムに依存しています。そのため、P4プログラムを書き換えてしまうとAPIが変わってしまい、再度コントロールプレーンを実装し直さなければなりません。

またターゲットとなるハードウェアによってはCLIが用意されており、これを用いてアクセスすることも可能です。先程の例では、BMv2で提供されている「runtimeCLI」というプログラムを利用したと思います。これでP4プログラムに依存せずにアクセスが可能となりますが、これはターゲットとなるハードウェアに依存してしまいます。

汎用的に制御できるように、P4ではP4Runtimeと呼ばれるフレームワークでP4の実行基盤を制御することができるようになっています。P4Runtimeでは、コントロールプレーンとデータプレーンの間をgRPCでやり取りさせています。これにより、プログラムやハードウェアに依存しないAPIの提供を実現しています。

ワークフロー

P4プログラムをコンパイルすると、P4Infoと呼ばれる形式のファイルが生成されます。
これには、P4プログラムで定義したテーブルやアクション、パラメータなどのIDや、その構造に関する情報が含まれています。
このP4infoファイルはgRPCで使えるようにProtobufという書式で書かれています。
P4infoファイルがコントロールプレーンとデータプレーンでロードされることで、両者でP4に関する各種情報を参照することができるようになります。

今回のチュートリアルでは、P4Runtimeというページにて演習することが可能です。
github.com


ここでは、mycontroller.pyというPythonプログラムによってコントロールプレーンが実装されています。
github.com


なお、このコントロールプレーンのプログラムは、今まで見てきたbasic.p4ではなく、下記のbasic_tunnel.p4に対応するものであるため、一部違う点があります。
(basic_tunnel.p4に関しては、https://github.com/p4lang/tutorials/blob/master/exercises/basic_tunnel/basic_tunnel.p4を参照)

(以下既存のコメントを削除、新規のコメントに関しては著者が追加)

mycontroller.py
...
import p4runtime_lib.helper #1.
...
def main(p4info_file_path, bmv2_file_path):
    p4info_helper = p4runtime_lib.helper.P4InfoHelper(p4info_file_path) #2. 

    try:
        #3. 
        s1 = p4runtime_lib.bmv2.Bmv2SwitchConnection(
            name='s1',
            address='127.0.0.1:50051',
            device_id=0,
            proto_dump_file='logs/s1-p4runtime-requests.txt')
        s2 = p4runtime_lib.bmv2.Bmv2SwitchConnection(
            name='s2',
            address='127.0.0.1:50052',
            device_id=1,
            proto_dump_file='logs/s2-p4runtime-requests.txt')

        ...

        # 4. 
        s1.SetForwardingPipelineConfig(p4info=p4info_helper.p4info,
                                       bmv2_json_file_path=bmv2_file_path)
        print "Installed P4 Program using SetForwardingPipelineConfig on s1"

        s2.SetForwardingPipelineConfig(p4info=p4info_helper.p4info,
                                       bmv2_json_file_path=bmv2_file_path)

        print "Installed P4 Program using SetForwardingPipelineConfig on s2"

        # 5. 
        writeTunnelRules(p4info_helper, ingress_sw=s1, egress_sw=s2, tunnel_id=100,
                         dst_eth_addr="08:00:00:00:02:22", dst_ip_addr="10.0.2.2")
  1. P4Runtimeを利用するためのライブラリをインポートします。
  2. P4Infoに関するヘルパーを定義(コンパイラに生成されたP4Infoファイルをロードし、その設定に基づいてデータがセットされる)
  3. Bmv2SwitchConnection()により、データプレーンとの接続試行(gRPCコネクション)
  4. SetForwardingPipelineConfig()によってパイプラインの設定をデータプレーンにインストール
  5. データプレーンに対して新規のルールを書き込み

SetForwardingPipelineConfig()は、後述のWriteTableEntry()と同様、P4RuntimeのProtobuf定義に基づくものです。

writeTunnelRulesは上部に定義されています。

def writeTunnelRules(p4info_helper, ingress_sw, egress_sw, tunnel_id,
                     dst_eth_addr, dst_ip_addr):
    ...
    # 1) Tunnel Ingress Rule
    table_entry = p4info_helper.buildTableEntry(
        table_name="MyIngress.ipv4_lpm",  # 1.
        match_fields={
            "hdr.ipv4.dstAddr": (dst_ip_addr, 32)   # 2.
        },
        action_name="MyIngress.myTunnel_ingress", # 3.
        action_params={
            "dst_id": tunnel_id,   # 4.
        })
    ingress_sw.WriteTableEntry(table_entry) # 5.
    print "Installed ingress tunnel rule on %s" % ingress_sw.name
    ...
  1. テーブル名として、P4プログラムで定義したMyIngressのipv4_lpmテーブルを指定
  2. マッチフィールド(key)となる箇所に"10.0.2.2/32"を指定
  3. 対応するアクションとして、MyIngressのmyTunnel_ingressを指定
  4. アクションの持つパラメータ"dst_id"として、tunnel_idの持つ値を指定
  5. WriteTableEntry()によってデータプレーンにルールをインストール

なお、これは今回のチュートリアルで用意されているソースコードにしたがったものであり、これが汎用的な書き方というわけではありません(WriteTableEntry()など、P4RuntimeのProtobuf定義に基づくものを除く)。

gRPCの使い方がいまいちわかっていないので今後調査しようと思います。

[2022.07.03 追記]
調査した結果について新しく書きました。
madomadox.hatenablog.com

まとめ

P4はSDN環境においてデータプレーンの処理を記述するためのプログラミング言語です。
P4ではPISAと呼ばれるパイプライン処理が想定されており、この各種ブロックの処理を実装していくことになります。
また、P4Runtimeと呼ばれるP4のランタイムフレームワークにより、コントロールプレーンとデータプレーンの汎用的な制御を行うことができます。

最後に、簡単な実験によって、P4プログラムが動作することを確認しました。
今後はgRPCの調査を深め、P4Runtimeに関する記事を書いていきます。

*1: acceptとrejectに到達した場合の処理はアーキテクチャ側(BMv2)で定義されます。rejectになった場合は色々と方策を取れます。例えば、これ以上処理をしないようにしたり、エラーログだけ吐いてパースに成功した分だけ後続に渡したりなどです。言語仕様の12.3の"An architecture must specify the behavior when the accept and reject states are reached. "以降参照。

*2:P4ではL値と呼ばれます

*3:match_kind型。他にexact, ternaryがあります。

*4:言語仕様には明示的に書かれていないのですが、リンク先にはそう書かれています。、下記リンクでは "Every control block must contain an 'apply' block." とあります。https://github.com/jafingerhut/p4-guide/blob/master/demo1/demo1-heavily-commented.p4_16.p4

*5:ここがずっと不可解で、勉強会に参加してようやく腑に落ちました

*6:チェックサムの計算や検証など、今回定義していないものもあります。