RFC 準拠のコントローラー&プロトコルライブラリ開発の進め方

イノベーションセンターの三島です。

本記事では、RFC や Internet-Draft に準拠したコントローラーやプロトコルライブラリの開発について、 NTT Com が公開中の Segment Routing (SR) 用のコントローラー、Pola PCE の開発経験を基にご紹介します。
商用機器と相互接続可能なコントローラー・プロトコルライブラリを開発してみたい方、SR をはじめとするネットワークを運用中で、機能拡張が可能なコントローラーを導入してみたい方は是非ご覧ください!

以降では、コントローラー開発手順の概要を Pola PCE の実装例を基に解説した後、プロトコルライブラリの作り方と機能追加の方法、相互接続試験と OSS へのマージまでの流れを解説します。

例として扱う Pola PCE 自体の詳細や活用例については解説しないため、詳細を知りたい方は下記の資料をご参照ください。

コントローラー・プロトコルライブラリを作ってみよう!

コントローラーはソフトウェアによりネットワークを集中管理する役割を持ち、主に運用やサービス提供の効率化などを目的として用いられます。 特に、大規模商用網のように複数のルーターが存在する環境においては、機器の一元管理による運用コストの低減や、スケーラビリティの高い運用が期待されます。 また、このようなネットワークは複数のベンダーの機器を用いて構成されることもあります。

これらの理由から、本記事では下記のポイントを満たすコントローラーを開発します。

  • 複数のクライアントを収容可能なサーバー
    • セッションごとに状態を管理し、並行処理可能な実装
  • 各ベンダーの商用機器と相互接続可能な、RFC 準拠なプロトコルライブラリ
    • プロトコルの構造や基本的なパケット操作の実装

以降の節では、要件を満たすようなサーバー機能の開発手順と、RFC 準拠なプロトコル実装について解説します。 コントローラーとプロトコルライブラリの開発方法をご紹介するにあたり、SR のコントローラーである Path Computation Element (PCE) を例として用います。
PCE は、SR を初めとする Traffic Engineering (TE) 技術で構成されたネットワークにおいて、TE を管理するためのコントローラーであり、TE により網全体の性能を考慮した QoS の向上や SFC 提供などを実現する役割を持ちます。

サーバー開発の概要

まずは、コントローラーのサーバー機能を開発する上で必要となるソケットプログラミングや並行処理、セッション管理の手法についての概要をご紹介します。

ソケットプログラミングによる TCP サーバー実装

PCEP は TCP ベースのプロトコルであり、TCP ソケットプログラミングが必要となります。 一般的なソケットプログラミングの概要を示します。

図の通り、一般的なソケットプログラミングにおけるサーバー実装では、ソケットを作成する socket、機器のアドレス・ポートとソケットを紐付ける bind、当該ポートで TCP の待ち受けを行う listen、クライアントからの接続を受け入れてセッションを構築する accept という流れで TCP セッションを構成します。

Pola PCE では Go の net パッケージを利用し、pkg/server/server.goServe メソッドとして実装を行なっています。 net.ListenTCP が socket・bind・listen を行い、ListenTCP の返り値である *TCPListenerAcceptTCP() メソッドにより accept を行います。

並行処理とセッション管理

PCE のようなコントローラーでは、複数のクライアントを管理することが求められます。 そのため、サーバー機能として 並列処理セッション管理 の機能が必要となります。 図に Pola PCE の並行処理とセッション管理の実装例を示します。

Pola PCE では、セッションは pkg/server/session.go に実装した Session 構造体を用いて管理を行なっています。 Session 構造体では、セッション管理に必要となるクライアントのアドレスやソケットの情報に加え、SRP-ID や Stateful PCE として管理するクライアントの SR Policy、Keepalive の間隔など、クライアントごとに固有のパラメータを全て管理させています。

並列処理は goroutine により実現しています。 TCP サーバーが AcceptTCP() を実行した後、そのセッションを管理するための Session 構造体を作成します。 その後 goroutine を作成して Session 構造体の Established() メソッドを呼び出し、PCE としてのメッセージ送受信や SR Policy の発行など、そのクライアントに対する PCE 機能を提供します。

この仕組みにより、複数のクライアントを区別しつつ複数収容するコントローラーを構成することが可能となります。

プロトコルライブラリ開発

RFC 準拠のプロトコルライブラリの開発について、Pola PCE への Close メッセージの追加 を例に解説します。

RFC の読み解きによるプロトコルの確認

Open/Close/Keepalive などのPCEP メッセージは RFC5440 で提案されています。 4.2.7 節に記載されている通り、Close メッセージは PCEP の通信において TCP セッションを終了させるために用いられます。
本記事ではプロトコルライブラリに Close を実装した後、Pola PCE から特定のピアに Close メッセージ送り、PCEP のセッションを切断する実装を追加します。

以降の説明のため、PCEP の基本構造を図に示します。

図の通り、PCEP は単一の common header と複数の object から構成されます。

common header にはそれが何の PCEP メッセージであるかを示す message type が格納されています。 RFC5440 の 6.1 節の通り、主なメッセージは 7 種類存在し、今回実装する Close の message type は 7 と定義されています。

各 object は common object header と object body から構成されています。 common object header には、その object を示す object class と object type が格納されています。

RFC5440 の 7.17 節に、object class は 15、object type は 1 と定義されています。 RFC5440 の 6.8 節には、Close メッセージは 1 つの CLOSE object を含むと書かれています。

message type・object class・object type など、プロトコルで決められたリソースは、IANA が管理しています。IANA は管理するリソースを https://www.iana.org にまとめて掲載しているため、プロトコル開発等の際はこちらを参照すると良いです。 PCEP の場合は Path Computation Element Protocol (PCEP) Numbers を参照してください。

プロトコルライブラリの実装 - Pola PCE への Close メッセージの追加

ここからは実際に Pola PCE へ Close メッセージを追加します。 message type は pkg/packet/pcep/message.go に、object class は pkg/packet/pcep/object.go に実装済みのため今回は追加不要です。

const (
    MT_CLOSE uint8 = 0x07 // RFC5440
)
const (
    OC_CLOSE uint8 = 0x0f // RFC5440
)

Close メッセージの構造体とパケット操作に関するメソッドを追加します。 Pola PCE では、広く用いられる BGP ライブラリである GoBGP と同様のパケット操作メソッドを採用しています。 パケット操作メソッドとしては、バイト列を構造体に格納する DecodeFromBytes()、構造体をバイト列に変換する Serialize()の 2 つのメソッドと、新たに構造体を作成する NewCloseMessage 関数が必要となります。

// Close Message
type CloseMessage struct {
    CloseObject *CloseObject
}

func (m *CloseMessage) DecodeFromBytes(messageBody []uint8) error {
    var commonObjectHeader CommonObjectHeader
    if err := commonObjectHeader.DecodeFromBytes(messageBody); err != nil {
        return err
    }
    closeObject := &CloseObject{}
    if err := closeObject.DecodeFromBytes(messageBody[COMMON_OBJECT_HEADER_LENGTH:commonObjectHeader.ObjectLength]); err != nil {
        return err
    }
    m.CloseObject = closeObject
    return nil
}

func (m *CloseMessage) Serialize() []uint8 {
    closeMessageLength := COMMON_HEADER_LENGTH + m.CloseObject.getByteLength()
    closeHeader := NewCommonHeader(MT_CLOSE, closeMessageLength)
    byteCloseHeader := closeHeader.Serialize()
    byteCloseObject := m.CloseObject.Serialize()
    byteCloseMessage := AppendByteSlices(byteCloseHeader, byteCloseObject)
    return byteCloseMessage
}

func NewCloseMessage(reason uint8) (*CloseMessage, error) {
    o, err := NewCloseObject(reason)
    if err != nil {
        return nil, err
    }
    m := &CloseMessage{
        CloseObject: o,
    }
    return m, nil
}

同様に、close object の object type と構造体とメソッド群も実装します。 こちらも message と同じメソッドに加え、Object 長の計測のため、Len() メソッドを実装します。

// Close Object (RFC5440 7.17)
const (
    OT_CLOSE_CLOSE uint8 = 0x01
)

const (
    R_NO_EXPLANATION_PROVIDED               uint8 = 0x01
    R_DEADTIMER_EXPIRED                     uint8 = 0x02
    R_RECEPTION_OF_A_MALFORMED_PCEP_MESSAGE uint8 = 0x03
)

type CloseObject struct {
    Reason uint8
}

func (o *CloseObject) DecodeFromBytes(objectBody []uint8) error {
    o.Reason = objectBody[3]
    return nil
}

func (o *CloseObject) Serialize() []uint8 {
    closeObjectHeader := NewCommonObjectHeader(OC_CLOSE, OT_CLOSE_CLOSE, o.getByteLength())
    byteCloseObjectHeader := closeObjectHeader.Serialize()

    buf := make([]uint8, 4)

    buf[3] = o.Reason
    byteCloseObject := AppendByteSlices(byteCloseObjectHeader, buf)
    return byteCloseObject
}

func (o *CloseObject) Len() uint16 {
    // CommonObjectHeader(4byte) + CloseObjectBody(4byte)
    return COMMON_OBJECT_HEADER_LENGTH + 4
}

func NewCloseObject(reason uint8) (*CloseObject, error) {
    o := &CloseObject{
        Reason: reason,
    }
    return o, nil
}

close object は RFC5440 の 7.17 節で下記のように定義されています。

    0                   1                   2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |          Reserved             |      Flags    |    Reason     |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                                                               |
   //                         Optional TLVs                       //
   |                                                               |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

また、7.17節では Reserved 領域と Flag 領域は 0 でパディングし、read 時には無視と規定されています。 そのため、close object の構造体である type CloseObject には、Reason 領域だけを uint8 で用意しておき、DecodeFromBytes()Serialize() は Reason のみを扱うように実装します。

次に、Close メッセージの受信処理を追加します。 pkg/server/session.gofunc (ss *Session) ReceivePcepMessage() に、message type が Close であるメッセージを受信した場合の処理を追加します。

case pcep.MT_CLOSE:
    byteCloseMessageBody := make([]uint8, commonHeader.MessageLength-pcep.COMMON_HEADER_LENGTH)
    if _, err := ss.tcpConn.Read(byteCloseMessageBody); err != nil {
        return err
    }
    closeMessage := &pcep.CloseMessage{}
    if err := closeMessage.DecodeFromBytes(byteCloseMessageBody); err != nil {
        return err
    }
    ss.logger.Info("Received Close",
        zap.String("session", ss.peerAddr.String()),
        zap.Uint8("reason", closeMessage.CloseObject.Reason),
        zap.String("detail", "See https://www.iana.org/assignments/pcep/pcep.xhtml#close-object-reason-field"))
    // Close session if get Close Message
    return nil

まず byteCloseMessageBody という byte 列を作成し、ss.tcpConn.Read() により read した object を格納します。その後 DecodeFromBytes() メソッドにより、closeMessage 構造体に受信した close message を格納しています。

Close を正しく受信・デコードした後、ログに close を受信した旨と受信した close の Reason を記録した後 ReceivePcepMessage() から return することで、Established() に戻りセッションの close 処理を行います。

以上で、RFC に準拠したパケットフォーマットの定義と TCP セッションからの read/write 処理、close の処理が全て実装できました。

サーバー機能の実装 - Pola PCE への Close メッセージ生成コマンドの追加

運用・検証時に任意のタイミングで特定のピアとの Close を行うため、gRPC API の追加と、コマンドを介した Close メッセージの送信機能を実装します。

func (ss *Session) SendClose(reason uint8) error {
    closeMessage, err := pcep.NewCloseMessage(reason)
    if err != nil {
        return err
    }
    byteCloseMessage := closeMessage.Serialize()

    ss.logger.Info("Send Close",
        zap.String("session", ss.peerAddr.String()),
        zap.Uint8("reason", closeMessage.CloseObject.Reason),
        zap.String("detail", "See https://www.iana.org/assignments/pcep/pcep.xhtml#close-object-reason-field"))
    if _, err := ss.tcpConn.Write(byteCloseMessage); err != nil {
        return err
    }
    return nil
}

今回必要となるのはあるセッションに対するメッセージの送信処理であるため、pkg/server/session.go 内に、Session 構造体の SendClose() メソッドとして実装します。
SendClose では、NewCloseMessage() 関数で close message を作成し、Serialize() メソッドにより byte 列に変換、ss.tcpConn.Write() によりソケットへと write します。

Pola PCE はマイクロサービスとしての活用を前提とし、gRPC API を有しています。また、標準コマンドとして、デーモンである polad に対する gRPC client となる pola コマンドを提供しています。
ここでは、polapola session del <Address> オプションを実装し、指定したピアへ SendClose を送信可能とします。

まず、api/grpc/pola.protoDeleteSession の RPC を作成します。

rpc DeleteSession (Session) returns (RequestStatus) {};

次に、polad 側の処理として pkg/server/grpc_server.goDeleteSession メソッドを作成します。

func (c *APIServer) DeleteSession(ctx context.Context, input *pb.Session) (*pb.RequestStatus, error) {
    ssAddr, _ := netip.AddrFromSlice(input.GetAddr())

    s := c.pce
    ss := s.SearchSession(ssAddr)
    if err := ss.SendClose(pcep.R_NO_EXPLANATION_PROVIDED); err != nil {
        return &pb.RequestStatus{IsSuccess: false}, err
    }
    // Remove session info from PCE server
    s.closeSession(ss)

    return &pb.RequestStatus{IsSuccess: true}, nil
}

このメソッドは、gRPC により DeleteSession が実行された際、当該セッションに対して SendClose 処理を実行し、成功/失敗のステータスを送信します。

次に、pola コマンドが用いる gRPC client 側の関数を cmd/pola/grpc_client.go に実装します。

func deleteSession(client pb.PceServiceClient, session *pb.Session) error {
    ctx, cancel := withTimeout()
    defer cancel()
    _, err := client.DeleteSession(ctx, session)
    if err != nil {
        return err
    }
    return nil
}

deleteSession() 関数が実行されると、gRPC client として DeleteSession メソッドが実行されます。

最後に、pola コマンドに session del オプションを追加します。 pola コマンドは Cobra を用いて実装しています。

まず、pola session コマンドに del オプションを追加します。cmd/pola/session.gonewSessionCmd() 関数に newSessionDelCmd() の呼び出しを追加します。

func newSessionCmd() *cobra.Command {
    cmd := &cobra.Command{
        Use: "session",
        RunE: func(cmd *cobra.Command, args []string) error {
            if err := showSession(jsonFmt); err != nil {
                return err
            }
            return nil
        },
    }

    cmd.AddCommand(newSessionDelCmd())
    return cmd
}

次に cmd/pola/session_del.go に del コマンドそのものである newSessionDelCmd() を実装します。

package main

import (
    "fmt"
    "net/netip"

    pb "github.com/nttcom/pola/api/grpc"
    "github.com/spf13/cobra"
)

func newSessionDelCmd() *cobra.Command {
    return &cobra.Command{
        Use:          "del",
        SilenceUsage: true,
        RunE: func(cmd *cobra.Command, args []string) error {
            if len(args) < 1 {
                return fmt.Errorf("requires session address\nUsage: pola session del [session address]")
            }
            ssAddr, err := netip.ParseAddr(args[0])
            if err != nil {
                return fmt.Errorf("invalid input\nUsage: pola session del [session address]")
            }
            if err := delSession(ssAddr, jsonFmt); err != nil {
                return err
            }
            return nil
        },
    }
}

func delSession(session netip.Addr, jsonFlag bool) error {
    ss := &pb.Session{
        Addr: session.AsSlice(),
    }
    err := deleteSession(client, ss)
    if err != nil {
        return err
    }
    if jsonFlag {
        fmt.Printf("{\"status\": \"success\"}\n")
    } else {
        fmt.Printf("success!\n")
    }
    return nil
}

newSessionDelCmd は、pola sessiondel オプションが指定された時に、func delSession() を実行します。
func delSession()cmd/pola/grpc_client.godeleteSession() を実行することで、gRPC client としてセッション削除の gRPC API を実行します。

これにより、pola session del <Address> オプションによる Close 機能を追加できました。

動作試験

機能を追加した Pola PCE とベンダー機器との相互接続試験を行います。

今回は WIDE Project の高田さん(@Enigamict)に作成していただいた example/containerlab/sr-mpls_pcep を使用し、下記のトポロジーを用いて検証します。 ここでは、PCEP の検証用に Cisco/Juniper/FRRouting の PE が 1 台ずつ存在する環境を作成しています。

このトポロジーをはじめ、SR-MPLS や SRv6 で TE の検証が可能な環境を example として公開しています。 機能を追加した Pola PCE を Docker コンテナとしてビルドすることで、これらの環境を使って検証できます。 Linux + Docker 環境があれば試せるので、是非手元で動かしてみてください。

Docker を用いたネットワークエミュレータツールである Containerlab のインストールや、各イメージの準備方法、ネットワークの起動などの流れは REAME.md にまとめています。

起動したネットワーク上で Pola PCE のコンテナに入り、polad コマンドで Pola PCE の起動&セッションの構築後、pola session コマンドで確認します。

# polad -f polad.yaml  > /dev/null 2>&1 & 
# pola session
sessionAddr(0): 10.0.255.1
sessionAddr(1): 10.0.255.3
sessionAddr(2): 10.0.255.2

次に、今回追加した pola session del コマンドを実行し、IOS XR/Junos/FRRouting Close メッセージを送信します。

以下では IOS XR に対する Close を実施します。

root@pola-pce:/# pola session del 10.0.255.1
success!

また、動作試験のため Wireshark を利用して PCEP パケットをキャプチャします。下記に、SSH 経由で Containerlab 環境からパケットキャプチャを実施する例を示します。

ssh $clab_host "sudo -S ip netns exec clab-srv6_te_l3vpn-pe01 tcpdump -U -nni eth1 -w -"  | wireshark -k -i -

キャプチャした結果、実装通りに Close メッセージが送られ、TCP も Close していることが確認できます。
これにより、実装した Close 機能が各ルーターと正しく相互接続可能なことを確かめられました。

Let's contribute!

最後に、今までに実装した機能を Pola PCE へ Pull Request として提出しましょう。

テンプレートに従って GitHub PR を作成します。

今回の例にあげた Close 機能は PR #66 で取り込まれています。 この PR では、同時に PCErr 機能も追加されています。もし興味があれば、是非そちらの読み解きにもチャレンジしてみてください。

また、今回の例にあげた Close 機能はPola PCE v1.2.1 でリリース済みのため、 最新の Docker イメージ や、それを用いた example でも試せます!

まとめと今後の予定

本記事では、Pola PCE の実装例を基に、RFC/I-D 準拠のコントローラーやプロトコルライブラリ開発の進め方についてご紹介しました。 記事を通じてプロトコルライブラリやコントローラーの実装に興味を持たれた方は、是非我々と共に Pola PCE を開発しましょう!

次のステップとして、 uSID や Flex-Algo を用いた Dynamic TE の実装を目指しています。実装予定の機能は issuemilestone として整備していますので是非ご確認ください。

本記事を読んでコントローラーやプロトコルライブラリの開発に興味をもった方や、拡張可能な PCE を求めている方がいましたら、Pola PCE にコントリビューションしてみませんか? もしご興味をお持ちの方がいれば、是非気軽にご連絡や PR/Issue の作成をお願いします!

© NTT Communications Corporation All Rights Reserved.