protovalidateにIPプリフィックスをバリデーションするためのカスタム関数を追加した話

この記事は BBSakura Networks Advent Calendar 2023 の 12/20 の記事です。

こんにちは。BBSakura Networksで全社のテックリードとモバイルコアの開発をするチームのリーダーを兼務している、日下部(@higebu)です。

はじめに

BBSakuraではシステム間通信にProtocol Buffersを多用しています。例えば、さくらのセキュアモバイルコネクトでは、さくらのクラウドのバックエンドとの連携やモバイルコアのコンポーネント間の通信に使用していて、OCXのバックエンドでも使用しています。

どちらのサービスもネットワークを制御するシステムで、入力されたパラメータのバリデーションや整合性のチェックに苦労しています。OCXでの苦労話はJANOG51での川畑の発表や下記の記事で紹介しています。

blog.bbsakura.net

今回、protovalidateにIPプリフィックスをバリデーションするための機能を追加したので、追加した機能の紹介、使い方、今後の話をしたいと思います。

protovalidateとは

protovalidateはBufが開発している、Protocol Buffersで定義されたメッセージのバリデーションのためのライブラリ群で、protoc-gen-validate(PGV)の後継とされています。 Common Expression Language(CEL)ベースになっているため、複雑なバリデーションルールを簡潔に記述することが可能です。 各言語用のライブラリはGo、C++、Python、Javaのベータ版がリリースされており、TypeScriptにも対応予定となっています。

詳しくは下記のBufの記事や公式リポジトリのREADMEを参照してください。 buf.build

CELはGoogleが作った式の評価のためのシンプルな言語で、Protocol Buffersを使って実装されています。 最近ではKubernetesのCRDのバリデーションAdmission Policiesのバリデーションでも使われているため、知っている方もいるかもしれません。

追加した機能の紹介

CELのカスタム関数として isIpPrefix() という関数を追加しています。ドキュメントにも同じことが書いてあるのですが下記のような仕様になっています。

  • string.isIpPrefix() -> bool
  • string.isIpPrefix(4) -> bool
  • string.isIpPrefix(6) -> bool
  • string.isIpPrefix(true) -> bool
  • string.isIpPrefix(4,true) -> bool
  • string.isIpPrefix(6,true) -> bool

引数には4/6に加えて、true/falseを取るようになっています。4/6はIPv4/IPv6のことで、trueの場合はネットワークアドレスかどうかをチェックしています。

これを使うと、下記のようにIPv4のプレフィックスかどうかをチェックするバリデーションルールを書くことができます。

message IPv4Route {
  string ipv4_prefix = 1 [(buf.validate.field).cel = {
    id: "ipv4_route.ipv4_prefix",
    message: "value must be a valid IPv4 prefix",
    expression: "this.isIpPrefix(4,true)"
  }];
}

ただ、毎回このように書くのはだるいのと、 isIp() と雰囲気を合わせるため、下記のように書けるようにもしています。

message IPv4Route {
  string ipv4_prefix = 1 [(buf.validate.field).string.ipv4_prefix = true];
}

先ほど説明した、isIpPrefix() の引数のパターンに合わせて、 string.ip_with_prefixlen string.ipv4_with_prefixlen string.ipv6_with_prefixlen string.ip_prefix string.ipv4_prefix string.ipv6_prefix を定義しています。

詳しくは buf/validate/validate.proto を見てください。

使い方

ここからはBufによるprotoファイルからのコード生成についてある程度知っているという前提で説明していきます。 あまり知らないという方は Bufのドキュメントを見に行っていただければと思います。

まず、 buf.yamldepsbuf.build/bufbuild/protovalidate を足す必要があります。例としては下記のような感じです。

version: v1
name: buf.build/higebu/example
deps:
  - buf.build/bufbuild/protovalidate
breaking:
  use:
    - FILE
lint:
  use:
    - DEFAULT

buf.gen.yaml は好きな言語のコード生成の設定をしていただければ良いです。ここではGoのコードを生成したいので下記のようにします。

version: v1
plugins:
  - plugin: buf.build/protocolbuffers/go:v1.31.0
    out: gen/go
    opt: paths=source_relative
  - plugin: buf.build/grpc/go:v1.3.0
    out: gen/go
    opt:
      - paths=source_relative

次に、protoファイルを作ります。今回は、雑に proto/example/v1/example.proto とします。例として、IPv4/IPv6アドレスの追加、IPv4/IPv6ルートの追加を行うAPIを持ったサービスを定義しています。 IPアドレスの追加は、インターフェースへのIPアドレスの追加、つまり ip addr add のイメージで、ルートの追加は ip route add のイメージでお願いします。

syntax = "proto3";

package higebu.example.v1;

import "buf/validate/validate.proto";

option go_package = "github.com/higebu/protovalidate-ip-prefix-example/gen/go/higebu/example/v1;examplev1";

service ExampleService {
  rpc AddIPv4Address(AddIPv4AddressRequest) returns (AddIPv4AddressResponse) {}
  rpc AddIPv6Address(AddIPv6AddressRequest) returns (AddIPv6AddressResponse) {}
  rpc AddIPv4Route(AddIPv4RouteRequest) returns (AddIPv4RouteResponse) {}
  rpc AddIPv6Route(AddIPv6RouteRequest) returns (AddIPv6RouteResponse) {}
}

message AddIPv4AddressRequest {
  string name = 1;
  string address = 2 [(buf.validate.field).string.ipv4_with_prefixlen = true];
}

message AddIPv4AddressResponse {
  string name = 1;
  string address = 2;
}

message AddIPv6AddressRequest {
  string name = 1;
  string address = 2 [(buf.validate.field).string.ipv6_with_prefixlen = true];
}

message AddIPv6AddressResponse {
  string name = 1;
  string address = 2;
}

message AddIPv4RouteRequest {
  string name = 1;
  string prefix = 2 [(buf.validate.field).string.ipv4_prefix = true];
  string nexthop = 3 [(buf.validate.field).string.ipv4 = true];
}

message AddIPv4RouteResponse {
  string name = 1;
  string prefix = 2;
  string nexthop = 3;
}

message AddIPv6RouteRequest {
  string name = 1;
  string prefix = 2 [(buf.validate.field).string.ipv6_prefix = true];
  string nexthop = 3 [(buf.validate.field).string.ipv6 = true];
}

message AddIPv6RouteResponse {
  string name = 1;
  string prefix = 2;
  string nexthop = 3;
}

AddIPv4AddressRequest では、 ipv4_with_prefixlen を使い、 192.168.100.5/24 のようにプレフィックス長が付いているIPアドレスのみ受け付けるようにしています。 また、 AddIPv4RouteRequest では prefixipv4_prefix を使い、 192.168.100.0/24 のようにアドレス部分がネットワークアドレスになっていて、プレフィックス長が付いている値のみ受け付けるようにしています。 IPv6についても同様です。

protoファイルができたら、buf generate でGoのコードを生成します。問題なければ gen/go ディレクトリ配下にコードが生成されるはずです。

次にサーバのrpcの実装ですが、特に特殊なことをする必要はありません。本当はインターフェースにIPアドレスを付けるなど何かすごい処理をするはずですが、例として、リクエストの中身を返すだけにしています。

func (*Server) AddIPv4Address(ctx context.Context, req *examplev1.AddIPv4AddressRequest) (*examplev1.AddIPv4AddressResponse, error) {
    return &examplev1.AddIPv4AddressResponse{
        Name:    req.GetName(),
        Address: req.GetAddress(),
    }, nil
}

バリデーションを行えるようにするにはサーバ起動時の処理を少し追加する必要があります。具体的には下記のようになります。

import (
    "log"
    "net"

    protovalidate_middleware "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/protovalidate"
    "google.golang.org/grpc"

    "github.com/bufbuild/protovalidate-go"
    examplev1 "github.com/higebu/protovalidate-ip-prefix-example/gen/go/proto/example/v1"
)


func main() {
    lis, err := net.Listen("tcp", "localhost:9000")
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
    validator, err := protovalidate.New()
    if err != nil {
        log.Fatal(err)
    }
    s := New()
    g := grpc.NewServer(grpc.UnaryInterceptor(protovalidate_middleware.UnaryServerInterceptor(validator)))
    examplev1.RegisterExampleServiceServer(g, s)
    g.Serve(lis)
}

普段と違うところは、 protovalidate.New() しているところと、生成したvalidatorを protovalidate_middleware.UnaryServerInterceptor() に渡しているところだけだと思います。 github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/protovalidateを使うことで、サーバの各メソッドでバリデーションに関する処理を書かなくても良くなるため、使うことをおすすめします。

紹介したコードは下記のリポジトリに置いています。

github.com

置いているコードにはmain関数がないため、サーバを起動することはできませんが、同様の処理をテストコードに書いているため、 go test -v ./server を実行すると雰囲気がわかると思います。

今後の話

今後ですが、IPプレフィックスに関する、より高度なバリデーションを行えるようにしたいと思っています。具体的にはIPアドレスが指定したプリフィクスの範囲に含まれるかどうかなどをバリデーションルールとして記述できるようにしたいです。 また、protovalidateはまだプロダクション環境に導入していないため、導入を進めていきたいと思っています。

最後に

機能追加自体にご興味がある方は下記のプルリクエストから、ソースコードを追っていただければ雰囲気がわかると思います。

github.com

protovalidate にはProtocol Buffersの定義、ドキュメント、Examples、テストツールがあり、まずは、その辺りにコードを追加することになります。

特に、テストツールとして、protovalidate-conformance という各言語の実装が仕様を満たしているかどうかチェックできるツールがあり、これに対してテストケースを足しておかないと、各言語の実装を行っても最終的なテストができないので注意が必要です。

その後、各言語のリポジトリ(protovalidate-goprotovalidate-pythonprotovalidate-javaprotovalidate-cc)に対して仕様を満たすようにコードを追加していけば良いです。

protovalidate-conformanceですが、JavaとPythonは修正中の自分のprotovalidateリポジトリをベースにテストが可能ですが、GoとC++については、protovalidateの修正がマージされた後でないとテストできません。これはメンテナの方にやっていただくしかないので、ある程度できたらメンテナの方にお願いしましょう。

GoとPythonにはIPプリフィックスをパース、操作するための標準ライブラリが存在するため比較的簡単でしたが、Java、C++にはないので大変でした。

ちなみにC++については弊社のpainaさんに実装していただきました。ありがとうございました!

実は、IPプリフィックスをバリデーションするための機能は過去にpainaさんがPGVに追加していました。プルリクエストも出しています。 しかし、PGVへの機能追加がフリーズされていたためか、Goのみに対応していたためかわかりませんが、反応がありませんでした。。。 そのような状況で、どうしようかなと思っていたところにprotovalidateの発表があったため、それに乗っていこうとしている話でした。

OCXはなぜ全国にアクセスポイントを作っているのか?

はじめに

こんにちは。BBSakura Networks株式会社の佐々木です。 この記事は BBSakura Networks Advent Calendar 2023 - Adventar の18日目の記事です。

以前、以下のポストでも書いておりますが、OCXでは全国のデータセンター内にアクセスポイントを設置し、全国カバーのネットワークを構築しております。

blog.bbsakura.net

本日は我々がなぜ全国にアクセスポイントを構築しているのか?を中心に紹介させてください。

OCXのネットワークトポロジー

OCXは親会社であるBBIXのIXネットワークトポロジーをベースに構築されております。 昨今のネットワークは、IPネットワーク上に仮想化したオーバーレイネットワークを構築するのが主流であり、IXもOCXも多分に漏れずこのような技術を採用しております。 詳細については、弊社川畑の以下のポストをご参照ください!

blog.bbsakura.net

なぜ全国にアクセスポイントを設置しているのか?

OCXでは、2023/12/18現在、20ヶ所のデータセンターでサービスを提供しており、毎月のように展開拠点を拡大しており、国内でも最大級のアクセスポイントを持ったNaaSサービスとなっております。

なぜ、全国にこんなにアクセスポイントが必要なのか?というところですが、

  • ネットワークの効率化(トラフィック処理の地産地消)によるコストや遅延の削減
  • 快適な NW インフラを整備することによりトラフィックの地産地消と DX を推進
  • インターネットと異なるレベルで品質を担保したNWの実現

などを実現していきたいと思ってます。

OCXを検討する上で出てきたデジタル田園都市国家構想には会社としても大変共感をしており、いろいろなインスピレーションを受けており、本構想の実現に向けて、弊社もさまざまな取り組みをしていきたいと考えており、特にインフラ整備の部分において民間企業の力を結集して寄与したいと考えたのが地域企業との協業によるアクセスポイントの全国整備でした。

デジタル田園都市国家構想 デジタル田園都市国家構想とは、「デジタル実装を通じて地方が抱える課題を解決し、誰一人取り残されずすべての人がデジタル化のメリットを享受できる心豊かな暮らしを実現する」という政府が推進している構想です。

デジタル田園都市国家構想を表した図。1. デジタル基盤の整備、2. デジタル人材の育成・確保、誰一人取り残されないための取り組み、3. 地域課題を解決するためのデジタル実装、の 3 項目が記載されている
デジタル田園都市国家構想サマリー

サービス、プラットフォームのクラウド化が進む中、柔軟な対応ができるネットワークインフラが必須になってきております。 OCXの構成要素はアンダーレイとなる全国網とVNFを動かすコンピューティング基盤、制御するオーケストレーター(API群)の3つとなります。 OCXの構成要素はアンダーレイNW、コンピューター、APIの3つとなる。

これらを分散配置することで、特定のエリアへのトラフィック集中の回避や、遅延の低減、また最適なトラフィック分散の結果としてコストの削減効果も付随的に現れてきます。遅延が短くなればさまざまな新しいアプリケーションのユースケースを作り出すこともでき、まずはインフラを作ることで、そういったユースケースを生み出す基盤にできればと考えてます。

最後に

Advent Calendarで登録したものの、何を書こうかギリギリまで決めておらず、先日routeviewsにあるbgpdataから事業者/国別のIPアドレス保有数を分析するツールを作っていたので、その紹介記事を書き掛けてたのですが、検算したらどこか計算おかしそうで、慌てて別の記事を書いてみました。 IPアドレスもどこかでゆっくり検算したら結果共有したいと思います。

BBSakuraが参加しているSAJについて

はじめに

この記事は BBSakura Networks Advent Calendar 2023の19日目の記事です。

adventar.org

こんにちは、BBSakura Networksで取締役を務めている山口と申します。2018年8月に会社を設立してから、早いもので5年目を迎えています。

当社では、OCX(Open Connectivity eXchange)というキーとなるプロダクトが立ち上がりました。これを強化する機能の構築が順調に進んでおり、今年は新しい分野の事業も手がけるなど、会社として新たなステージに入った感があります。これからもさらに研鑽を積んでいかなければと思っています。

今回は久しぶりにブログを書かせていただいて、当社が参加している一般社団法人ソフトウェア協会(SAJ)についてとそこでの活動についてお話しします。

SAJとは?

SAJとは、一般社団法人ソフトウェア協会といいまして、ソフトウェア製品に係わる企業が集まり、ソフトウェア産業の発展に係わる事業を通じて、我が国産業の健全な発展と国民生活の向上に寄与することを目的に、政策提言や人材育成、参加している会社同士の交流まで10の委員会(図の左側の青い色の部分)、更にその下部に多数の研究会を置いて会員企業が活発に活動をしつつ、社団法人としてもPSマークの認証、U22プログラミングコンテスト、CEATECなど数多くの事業をやっています。

U22プログラミングコンテストCEATECなどはご存じの方も多いのでは無いでしょうか? 全部のSAJのメールを受けていると、活動への参加お誘いや、新しく入られた企業さんの紹介、活動報告のメールがひっきりなしにやってくるのでもう大変なくらいです。

SAJの組織図を表している、大きく4つの組織を表しており、左半分に理事会など組織運営をする組織、協会事業に関わる組織、外部の委員会が表示されている。右半分にはSAJ内部にある委員会が表示されており、青い色で囲まれている。
SAJの組織図

この団体は、現在当社の親会社であるさくらインターネット株式会社の代表の田中が会長を務めており、当社のもう一つの親会社BBIX株式会社の親会社であるソフトバンク株式会社の創業者孫正義さんが1982年に「日本パソコンソフトウェア協会」として設立、その後3回の名称変更を経て、現在の「ソフトウェア協会」となった歴史ある団体で、現時点ではなんだかすごく縁(えにし)を感じているところです。

SAJでBBSakuraは何をしているのか?

当社は数ある委員会の中でも、技術委員会、地域デジタル推進委員会、アライアンスビジネス委員会、プロジェクトみらい、に参加させていただいております。 技術委員会では、参加各社の技術課題や興味のある技術分野について議論したり、共同で各分野の先端の企業の方をお呼びしたり、社内の技術や技術的な課題をシェアしたりしています。 アライアンスビジネス委員会では、自社ソリューションの紹介や仲間を集めたりしていて、この場で新しい商材などの情報を入手したりできたこともあります。 プロジェクトみらいは、少し毛色が違っていて次世代の経営リーダーが集まっている集団で、経営に関わるリーダーだからこその悩みだったりどうしていきたいかという決断だったりを話すことによって、ゆるくて強固なつながりを得られるようなイメージがあります。

最後に地域デジタル推進委員会ですが、ここではSAJの活動を全国に拡大するため、全国でセミナー、勉強会などの企画、運営を行う体制を整備し、地場産業界や各地域経産局、各地域団体と連携し、ビジネスマッチング商談会への参加や展示会の企画、運営なども行い、各地域の交流や連携をより強め、地域でのビジネス成功モデルを積極的に発信し、地方創生に対して貢献しようという目的の委員会です。

どこの団体もそうなのですが、団体の参加企業は関東近円にあることが多く、会合も常に関東のみで行われるということになりがちで、その地域のみの意見にまとまりがちです。そういうなかで、地域のビジネスにもしっかり目を向けることによって、日本全体の力につなげていこうという取り組みを多く実施しているのが、地域デジタル推進委員会です。

当社はこの委員会で、いわゆるNaaS≒ネットワーククラウド研究会というものに参加させていただいて活動をさせていただいております。

今後何をしようとしているのか?

「活動させていただいております」と記載しましたが、実はネットワーククラウド研究会は、本日2023/12/19がメンバーを集めて研究会活動をする第一回目のできたてほやほやの会になります。

www.saj.or.jp

当社BBSakuraは、OCXというNaaSサービスを提供させていただいており、その本質的な価値は当社がコアになって、地方のネットワーク事業者の仲間と一緒になってサービスを組み上げていくものなのですが、OCXだけではなく、NaaS的なビジネス活動に関する研究を全国各地に広がっているSAJ参加企業の方々と一緒になって業界全体を押し広げていきたいなと期待しているところです。

地方独自のビジネス活動を別の地方や関東近郊などでもできるようになったり出来るようにするお手伝いができたら良いなぁと心底思います。 今日はNaaS関係の取り組みということで、当社以外のネットワーククラウド提供事業者の方、とメガクラウドの方のお話も聞ける予定で、個人的にも楽しみにしておりました。

ちょっと大きなスコープで業界を眺められるかも?

ちなみになのですが、こういった一般社団法人の活動は、会社によっては特定の部局の方のみが参加するもので一般の社員の方にはあまり関係ないかも?っていう方もおられると思います。 もちろんそういった企業もあると思いますが、もし参加するチャンスがあったらぜひ参加してみてください。 現在の社会情勢や政治の世界で何を話しているのか、業界の中でビジネス的な自社の立ち位置はどの辺りかなとか、そんな産業が存在していて、そんなところに、そんなソフトウェアやサービスがあるなんて!?みたいな新鮮な驚きがたくさんありたくさんの刺激を受けることができますし、そこになにかのビジネスを作れるチャンスがあるかもしれません。

一緒に成長しませんか?

最後になりますが、SAJでは自分自身の考え方に新しい視点や視座を加えつつ、周りにも変化を与えられるような企業さんをいつでも募集しております。 実際にはそんなにハードルは高くないので、ちょっと気になるなぁくらいで構いませんのでぜひお声がけいただければと思います。

あ、BBSakura Networksでももちろんメンバー募集しています、カジュアル面談できるのでいつでも声かけてください〜! www.bbsakura.net

どちらもご連絡をお待ちしています!

趣味でXDPに入門してEtherIPを実装してみた

この記事は BBSakura Networks Advent Calendar 2023の12/14の記事になります。

まえおき

こんにちは。BBSakura Networksでアルバイトをしている 梅田です。

私は情報学部の大学1年生です。

私は今までBGPやRoutingに触れてきました。 しかし、今の時代はネットワークだけではなく、ソフトウェアの開発・利用ができることが求められていると感じています。

そのなかで、私が特に興味を持っているソフトウェアによる高速パケット処理に趣味で入門した話について今回書きたいと思います。

モチベーション

私はAS59105 Home NOC Operators' Group(以下HomeNOC)に加入しています。 HomeNOCとは、インターネットに接続する自律システム AS59105を運用している団体です。
また、AS59105を運用するだけではなく、自由なネットワーク環境を求める学生やエンジニアの方に当団体の持つIPアドレスによるインターネット接続を提供する活動を行っています。

活動の詳細はHomeNOCのWebサイトをご覧ください。

(BBSakura NetworksとHomeNOCは関係がなく、個人の趣味の話です。)

今回は、BGPやASの話ではなく拠点間通信に利用しているTunnel接続についてです。

www.homenoc.ad.jp

拠点間のEtherIP接続における問題

HomeNOCでは拠点間の接続にNTTが提供するフレッツ光を利用し、 各拠点間をNEC UNIVERGE IXシリーズ (以下 NEC IX)を利用してEtherIP(RFC3378) Protocolで接続しています。
この拠点間接続において、HomeNOCではNEC IXのEtherIP Tunnelによる接続で問題を抱えています。
NEC IXによるEtherIP TunnelはCPUでencap/decapの処理がされていると思われ、ショートパケットが多く流れるとCPUが高負荷になります。
HomeNOCでは、ショートパケットでNEC IXのCPUが高負荷になったことによるパケットロスで過去に大規模な障害を起こしており、このNEC IXによるEtherIP Tunnelの負荷問題に長期に渡って悩まされています。

考えられる解決策

HomeNOCでは、EtherIPプロトコルを利用しつつ、特定の問題を解決する必要があります。
EtherIPを選択する理由は、L2VPNを介してeBGPをルーティング情報なしで確立できることや、安価で入手しやすい中古のNEC IX機器が市場に多く存在することなどが挙げられます。
また、EtherIPが使われていることには歴史的経緯があり、プロトコル自体を変えてしまうことは接続している全てのユーザに影響があるため現実的ではありません。

問題解決のアプローチとしては以下の3つが挙げられます。

  • 上位機種への交換:
    • 利点: EtherIPに縛られずさまざまな機能が使える
  • 問題: 費用の面で制約がある。
    • 他社製品の検討:
      • 例: 「古河電気工業株式会社 FITELnet」
    • 利点: 既存の機器と同等サイズで、NEC IXと違う機能も利用可能です。
    • 問題: 中古市場での流通が少なく、価格が高い。
  • 自作XDP-EtherIP:
    • 利点: 学習になり、カスタマイズと自動化が可能。
    • 問題: メンテナンスコストが発生する。

HomeNOCは非営利で活動しており、高価な機材を複数台購入することは費用の問題があることを踏まえ、汎用的なPCやサーバ上で動作するEtherIPトンネルを開発することを選択しました。
さらに、私自身が高速なパケット処理技術に興味を持っており、このプロジェクトは好都合であると考えました。

XDPを選んだ背景

さて、高速なソフトウェアによるパケット処理でよく使われるものとしてDPDKやXDPまたKernel Moduleが挙げられます。

今回はタイトルの通りXDPを選びましたが、選定理由としては以下の二つを重視しました。

  • 消費電力が小さい
  • 小型PCでも問題なく動く

背景事情として、HomeNOCではデータセンターだけではなく個人の家にも機材をおいて運用しているため設置場所や消費電力の面で制約があることがあげられます。

HomeNOCでは常時大きな通信が流れているわけではないため、DPDKのような基本的にCPUを100%常時使用したパケットの処理では必要以上に多くの電気を使用してしまいます。 昨今の電気代の高騰を考えると消費電力を抑えたいところです。

そこで、XDPを選びました。

XDPはカーネル空間で動作するeBPFで記述可能なパケット処理系です。XDPではネットワークスタックに渡す前のドライバレベルで処理が可能で、また受信処理のリソース最適化はLinuxのNAPIに任せることができるので利用用途にあってると考えました。

成果物

以下のGitHub Repositoryで公開しています。

github.com

今回実装した機能は以下の二つです。

  • EtherIP over IPv6: ユースケースとしてフレッツ光のIPv6オプションを利用したVPNである場合が多いため
  • TCP MSS Clamping: エンドデバイスにMTU/MSSを個別に設定せずにTCP通信を行いたいため

成果物の相互接続性検証

今回は NEC IXと自作 XDP-EtherIPとの相互接続性の検証を行いました。

検証環境の写真
検証環境の写真

トポロジー

トポロジー図
トポロジー図

NEC IXとサーバ間はIPv6 LinkLocalアドレスを利用してEtherIP over IPv6を行っています。
エンドデバイスは192.168.3.0/24を割り当て、EtherIPを通して同じL2セグメントにいるようになります。  

動作方法

NEC IX

NEC IXのTunnelの設定は下記の設定を行っています。

!
interface Tunnel2.0
  tunnel mode ether-ip ipv6
  tunnel destination fe80::1
  tunnel source fe80::2%GigaEthernet2.0
  no ip address
  bridge-group 1
  bridge ip tcp adjust-mss 1404
  bridge ipv6 tcp adjust-mss 1384
  no shutdown
!

自作XDP-EtherIP

事前に READMEに記述した方法で自作EtherIPゲートウェイをビルドしておきます。
今回のトポロジーで示した環境のサーバーで以下のコマンドを実行します。
interfaceはNEC IX宛とクライアントPC宛のinterface名の二つを指定します。

./bin/goxdp --device eth0 --device eth1

検証結果

  • PC2からICMP echo を送信し、NEC IXから送られてきたパケット

    "NEC IXがencapしたEtherIP packetのcapture"
    NEC IXがencapしたpacketのcapture

  • PC2からのICMP echoに応答したパケットをXDP-Server(XDP-EtherIPプログラム)がencapしたもの

"XDP-EtherIPがencapしたEtherIP packetのcapture"
XDP-EtherIPがencapしたEtherIP packetのcapture

  • NEC IX側でパケットカウンタが増加している
  Encapsulation TUNNEL:
    Tunnel mode is ether-ip ipv6
    Tunnel is ready
    Destination address is fe80::1
    Source address is fe80::2%GigaEthernet2.0
    Nexthop address is fe80::1
    Outgoing interface is GigaEthernet2.0
    Interface MTU is 1500
    Path MTU is 1500
    Statistics:
      27127989 packets input, 38565259003 bytes, 0 errors
      6426917 packets output, 4685680696 bytes, 0 errors
    Received ICMP messages:
      0 errors

NEC IXとの間でEtherIPパケットのやり取りができました。

実装

EtherIPのパケット構造

EtherIPはRFC3378で標準化されているProtocolで、非常に単純な構造をしています。

        +-----------------------+-----------------------------+
        |      |                |                             |
        |  IP  | EtherIP Header | Encapsulated Ethernet Frame |
        |      |                |                             |
        +-----------------------+-----------------------------+

IP Headerの次のEtherIP Headerは16bitで、中身は固定値になっています。

  • VERSION: 3
  • RESERVED: 0
        0   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15
     +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
     |               |                                               |
     |    VERSION    |                   RESERVED                    |
     |               |                                               |
     +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+

実装したEncapのコード

実際のencap処理の流れについて説明します。

  1. input packetの先頭にEthernet Header + IPv6 Header + EtherIP Headerのサイズである56byteの領域を作る
  2. 1の各packet headerを作成し作った領域に書き込む

encapをする際のコードを示します。

  void *data_end = (void *)(long)ctx->data_end;
  void *data = (void *)(long)ctx->data;
  struct ethhdr *cpy_ether_header;
  struct ethhdr *ether_header;
  struct ipv6hdr *ip6_header;
  ether_header = data;

  if (data + sizeof(*ether_header) > data_end)
  {
    return XDP_ABORTED;
  }
    data += sizeof(*ether_header);

    uint16_t length = sizeof(ether_header);


    struct ethhdr *output_ethernet_header;
    struct ipv6hdr *etherip_tunnel_ip6_header;
    struct in6_addr etherip_tunnel_ip6_saddr;
    struct in6_addr etherip_tunnel_ip6_daddr;

    // 1. 先頭にEthernet Header, IPv6 Header, EtherIP Headerを付ける空間を作る
    if (bpf_xdp_adjust_head(ctx, 0 - (int)sizeof(struct ethhdr) - (int)sizeof(struct ipv6hdr) - (int)sizeof(struct etherip_hdr)))
    {
      return XDP_ABORTED;
    }


    data = (void *)(long)ctx->data;
    data_end = (void *)(long)ctx->data_end;

    // 2. ここから順番にpacket headerを作成し書き込む

    // Ethernet Headerの追加処理

    if (data + sizeof(struct ethhdr) > data_end)
    {
      return XDP_ABORTED;
    }

    output_ethernet_header = data;
    // EtherIPを処理しているマシンのmac アドレス(smac)と宛先macアドレスを設定
    uint8_t dmac[6] = {0x00, 0x60, 0xb9, 0xe6, 0x20, 0xfb};
    uint8_t smac[6] = {0xbe, 0xfd, 0x30, 0xae, 0x56, 0xb9};
    output_ethernet_header->h_proto = htons(ETH_P_IPV6);
    __builtin_memcpy(output_ethernet_header->h_dest, dmac, sizeof(dmac));
    __builtin_memcpy(output_ethernet_header->h_source, smac, sizeof(smac));

    // IPv6 Headerの作成
    data += sizeof(struct ethhdr);
    if (data + sizeof(struct ipv6hdr) > data_end)
    {
      return XDP_ABORTED;
    }

    etherip_tunnel_ip6_header = data;
    etherip_tunnel_ip6_header->version = 6;
    etherip_tunnel_ip6_header->priority = 0;
    etherip_tunnel_ip6_header->nexthdr = 97; // Next HeaderがEtherIP(97)であることを設定
    etherip_tunnel_ip6_header->hop_limit = 64;
    // fe80::1
    uint8_t saddr[16] = {0xfe, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
                         0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01};
    __builtin_memcpy(etherip_tunnel_ip6_saddr.s6_addr, saddr, sizeof(saddr));
    etherip_tunnel_ip6_header->saddr = etherip_tunnel_ip6_saddr;
    // fe80::2
    uint8_t daddr[16] = {0xfe, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
                         0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02};
    __builtin_memcpy(etherip_tunnel_ip6_daddr.s6_addr, daddr, sizeof(daddr));
    etherip_tunnel_ip6_header->daddr = etherip_tunnel_ip6_daddr;

    data += sizeof(struct ipv6hdr);
    
    struct etherip_hdr *etherip_header;
    etherip_header = data;
    if (data + sizeof(struct etherip_hdr) > data_end)
    {
      return XDP_ABORTED;
    }
    // EtherIP Headerの作成
    etherip_header->etherip_ver = 0x30;
    etherip_header->etherip_pad = 0x00;

    etherip_tunnel_ip6_header->payload_len = htons(data_end - data);

    data += sizeof(struct etherip_hdr);
    struct ethhdr *old_ether_header;
    old_ether_header = data;
    if (data + sizeof(struct ethhdr) > data_end)
    {
      return XDP_ABORTED;
    }
...

また、TCP MSSを調整する場合には上に加えて下記の処理を行います。

  1. 元のinput packetのpacket Headerを順番に読む。
    Ethernet Header -> IP Header ...
  2. IP Headerの次のパケットがTCPでSYN flagが立っている場合、元のTCPのoption fieldのMSSの値を読む。
  3. 読み取った元の値が設定したいMSS値より大きい場合のみ、MSSの値を更新する。
    IPv4のMTU1500の場合MSS値は1460であるため、encapした際のオーバーヘッド56byteを引いた1404をMSSに設定。
  4. TCPのchecksumの更新。
    // if IPv4
    if (old_ether_header->h_proto == htons(ETH_P_IP)) {
      data += sizeof(struct ethhdr);
      struct iphdr *old_ip_header;
      old_ip_header = data;
      if (data + sizeof(struct iphdr) > data_end) {
        return XDP_ABORTED;
      }

      // if TCP
      if (old_ip_header->protocol == 6) {
        data += sizeof(struct iphdr);
        struct tcphdr *old_tcp_header;
        old_tcp_header = data;
        if (data + sizeof(struct tcphdr) > data_end) {
          return XDP_ABORTED;
        }
        // 4 if SYN
        if (old_tcp_header->syn == 1) {
          data += sizeof(struct tcphdr);
          struct tcpopt *old_tcp_options;
          old_tcp_options = data;
          if (data + sizeof(struct tcpopt) > data_end) {
            return XDP_ABORTED;
          }
          // if MSS
          if (old_tcp_options->kind == 2 && old_tcp_options->len == 4) {
            data += sizeof(struct tcpopt);
            uint16_t *old_mss;
            old_mss = data;
            if (data + sizeof(uint16_t) > data_end) {
              return XDP_ABORTED;
            }
            uint16_t old_mss_value = *old_mss;
            // 5 if MSS > 1404
            if (ntohs(*old_mss) > 1404) {
              // set MSS 1404
              uint16_t new_mss = htons(1404);
              __builtin_memcpy(old_mss, &new_mss, sizeof(uint16_t));

              // TCP checksum の再計算と更新
              update_checksum(&old_tcp_header->check, old_mss_value,
                              htons(1404));
            }
          }
        }
      }
    }

実装したDecapのコード

decapはencapと異なり、encapしたときに先頭につけた56byteをカットするだけの処理です。

  1. IPv6 packetである
  2. IPv6 packetのNextHeaderがEtherIP(97)である
  3. 先頭56 Byteをカット
  4. 処理したパケットを別のinterfaceに送出
  void *data_end = (void *)(long)ctx->data_end;
  void *data = (void *)(long)ctx->data;
  struct ethhdr *ether_header;
  struct ipv6hdr *ip6_header;
  ether_header = data;

  if (data + sizeof(*ether_header) > data_end)
  {
    return XDP_ABORTED;
  }
  /*

  Decap !!

  */

  uint16_t h_proto = ether_header->h_proto;

  if (h_proto == htons(ETH_P_IPV6))
  { // 1 IPv6 Packetである

    data += sizeof(*ether_header);
    ip6_header = data;
    if (data + sizeof(*ip6_header) + 2 > data_end)
    {
      return XDP_ABORTED;
    }
    // 2 EtherIP Packetである
    if (ip6_header->nexthdr == 97)
    {
      data += sizeof(*ip6_header) + 2;
      if (data + sizeof(*ip6_header) + 2 > data_end)
      {
        return XDP_ABORTED;
      }
      struct ethhdr *etherip_ether_header;
      etherip_ether_header = data;
     // 3 先頭56 byte分のカット
      bpf_xdp_adjust_head(ctx, sizeof(*ether_header) + sizeof(*ip6_header) + 2);

      // 4 ifindex 3のinterfaceにパケットを送出
      // cat /sys/class/net/eth1/ifindex
      bpf_redirect(3, 0);
      return XDP_REDIRECT;
    }
  }

実装過程で苦労した点

decapは簡単ですが、encapではいくつか苦労しました。
そのひとつを書き留めたいと思います。

IPv6 Payload Lengthの設定ミス

encapするときは先頭にパケットヘッダーを新たに追加しますが、パケットの後方のことも考えなければなりません。
例えば、IPv6のパケットヘッダーを先頭につけた場合、その後に続くバイト列のサイズを示すpayload lengthを更新する必要があります。

etherip_tunnel_ip6_header->payload_len = htons(data_end - data);

この値が本来のサイズより小さい値が設定されている場合、Wiresharkやtcpdumpで見たときにpayload lengthを超えたバイト列は破棄されてしまい見ることができません。

テストコードの追加

今回のプログラムには処理されたパケットが正しく動いているか確認するためのテストコードを書いています。

https://github.com/x86taka/xdp-etherip/blob/dev/pkg/coreelf/xdp_test.go

テストでは、gopacketを利用してTCPのSYNパケット作成しXDPのプログラムがEtherIPパケットにencapしたものと想定される正しいencap後のパケットと比較しチェックしています。

github.com

このテストを行うことで、TCP MSSの書き換えとTCP checksumが正しいかについても検証ができました。

XDPに処理させるpacketの生成

   opts := gopacket.SerializeOptions{FixLengths: true, ComputeChecksums: true}
    iph := &layers.IPv4{
        Version: 4, Protocol: layers.IPProtocolTCP, Flags: layers.IPv4DontFragment, TTL: 64, IHL: 5, Id: 1160,
        SrcIP: net.IP{192, 168, 100, 200}, DstIP: net.IP{192, 168, 30, 1},
    }
    tcph := &layers.TCP{
        Seq:     0x00000000,
        SYN:     true,
        Ack:     0x00000000,
        SrcPort: 1234,
        DstPort: 80,
        Options: []layers.TCPOption{
            //TCP MSS Option (1460)
            {
                OptionType:   0x02,
                OptionLength: 4,
                OptionData:   []byte{0x05, 0xb4},
            },
            {
                OptionType:   0x04,
                OptionLength: 2,
            },
            {
                OptionType:   0x08,
                OptionLength: 10,
                OptionData:   []byte{0x00, 0x00, 0x00, 0x00, 0x00},
            },
            {
                OptionType:   0x01,
                OptionLength: 1,
            },
            {
                OptionType:   0x01,
                OptionLength: 1,
            },
        },
    }
    tcph.SetNetworkLayerForChecksum(iph)
    buf := gopacket.NewSerializeBuffer()
    err := gopacket.SerializeLayers(buf, opts,
        &layers.Ethernet{DstMAC: []byte{0x00, 0x00, 0x5e, 0x00, 0x11, 0x01}, SrcMAC: []byte{0x00, 0x00, 0x5e, 0x00, 0x11, 0x02}, EthernetType: layers.EthernetTypeIPv4},
        iph,
        tcph,
        gopacket.Payload(payload),
    )

XDPが処理して期待されるパケットの生成

    ip6h := &layers.IPv6{
        Version:    6,
        NextHeader: layers.IPProtocolEtherIP,
        HopLimit:   64,
        SrcIP:      net.ParseIP("fe80::1"),
        DstIP:      net.ParseIP("fe80::2"),
    }
    eiph := &layers.EtherIP{
        Version:  3,
        Reserved: 0,
    }
    iph := &layers.IPv4{
        Version: 4, Protocol: layers.IPProtocolTCP, Flags: layers.IPv4DontFragment, TTL: 64, IHL: 5, Id: 1160,
        SrcIP: net.IP{192, 168, 100, 200}, DstIP: net.IP{192, 168, 30, 1},
    }
    tcph := &layers.TCP{
        Seq:     0x00000000,
        SYN:     true,
        Ack:     0x00000000,
        SrcPort: 1234,
        DstPort: 80,
        Options: []layers.TCPOption{
            //TCP MSS Option (1460 => 1404)
            {
                OptionType:   0x02,
                OptionLength: 4,
                OptionData:   []byte{0x05, 0x7c},
            },
            {
                OptionType:   0x04,
                OptionLength: 2,
            },
            {
                OptionType:   0x08,
                OptionLength: 10,
                OptionData:   []byte{0x00, 0x00, 0x00, 0x00, 0x00},
            },
            {
                OptionType:   0x01,
                OptionLength: 1,
            },
            {
                OptionType:   0x01,
                OptionLength: 1,
            },
        },
    }

    tcph.SetNetworkLayerForChecksum(iph)
    err := gopacket.SerializeLayers(buf, opts,
        &layers.Ethernet{DstMAC: []byte{0x00, 0x60, 0xb9, 0xe6, 0x20, 0xfb}, SrcMAC: []byte{0xbe, 0xfd, 0x30, 0xae, 0x56, 0xb9}, EthernetType: layers.EthernetTypeIPv6},
        ip6h, eiph,
        &layers.Ethernet{DstMAC: []byte{0x00, 0x00, 0x5e, 0x00, 0x11, 0x01}, SrcMAC: []byte{0x00, 0x00, 0x5e, 0x00, 0x11, 0x02}, EthernetType: layers.EthernetTypeIPv4},
        iph,
        tcph,
        gopacket.Payload(payload),
    )

また、テストコードを実装する上でgopacketのEtherIPに関するコードにSerializeTo関数が不足していたため、実装を行いました。

github.com

まとめ

プログラミングにおいては、Hello World程度のC言語習得レベルで開発をスタートし、自力でEtherIPのencap/decapやTCP MSS Clampingまで実装したことは大きな学びになりました。
ネットワーク分野においては、それぞれのpacket headerのfieldが実際にどのように利用されているのか深く理解することができ、packetをparseするコンピュータの気持ちを考えさせられました。

今後の課題

Multi Coreに対応させたい

EtherIPのパケットは、5tupleのhashがTunnel終端で固定になってしまうためNICのRSSが効かず1 coreでしか処理ができません。
XDP上でRPSを実装し、マルチコアで処理できるようにしたいと考えています。

eBPF Mapsによる、動的なTunnelの作成

現状の実装では、macアドレスやTunnnel終端の宛先アドレスや送信元アドレスがハードコードされています。
利用するのにソースコードを書き換える必要があり、使いにくいのが現状です。
eBPF Mapsを利用して動的に値を渡すことで、再コンパイルせずに利活用可能にしたいと考えています。

高速化の工夫

今の実装では、XDP_REDIRECTを行って別のinterfaceからパケットを送出しています。
XDP_REDIRECTを行うコストは高いため、XDP_TXで同じinterfaceからパケットを送出したほうが高速です。
eBPF Mapsを利用し、vlanとtunnelのendpointを動的に設定できる仕組みを実装したいと考えています。

謝辞

今回の記事を書くにあたって、BBSakura Networksの早坂さんにアドバイスをいただき、テストコードの実装などを行うことができました。

また、最初に実装したコードについても公開しています。

github.com

次回の記事もお楽しみに!

Rego でマイグレーション SQL を分割して稼働中のシステムに適用する

この記事は BBSakura Networks アドベントカレンダー2023の16日目の記事です。 adventar.org

書いているのは BBSakura Networks 株式会社 金井(@masu_mi, @masu-mi.bsky.social)です。

今回は OPA/Rego(以後、 Rego) を自作ツール(ddl-planner) に組み込んで利用してみたので振り返りたいと思います。

この記事は Rego を試してみたいから始まっています。また一般にテーブルマイグレーションはアプリケーション実装やデータベースの機能を熟慮しないと危険です。そのため今回の方法は汎用的なアプローチと言えません。

daichirata/hammer(以後、hammer) というツールは Google Cloud Spanner(以後、 Spanner) のテーブルマイグレーション支援ツールです。マイグレーション前後のテーブル定義からマイグレーション SQL を生成してくれます。しかし単純に差を埋める SQL を生成するため稼働中のシステムには適用できません。

そこで自作ツール(ddl-planner)を使ってみました。与えられたマイグレーションを稼働中に適用できるように3ステップに分解しステップごとのマイグレーション SQL ファイルを生成します。

自作ツールではポリシー記述言語および処理系である Rego を利用します。

まず動かしてみる

hammer が生成するマイグレーション SQLddl-planner で分割してみます。

hammerSpanner 用テーブルマイグレーション支援ツールです。データベースからスキーマを取得したり、変更を適用したりと色々な機能があります。今回は新旧のテーブル定義を比較して変更に必要な SQL を生成する diff サブコマンドを使います。

hammer でマイグレーション SQL を生成する

次のような更新を実験してみます。

更新前 ER図

erDiagram

ExampleAlter {
    INT64 Id PK
    INT64 Col_1 "NOT NULL"
    INT64 Col_2
    STRING(MAX) Col_3
}

OutOfScope {
    INT64 IdA PK
    STRING(MAX) Meta
}

ExampleRecreated {
    INT64 IdA PK,FK
    INT64 IdB PK
    INT64 IdC PK
    STRING(MAX) Meta
}
OutOfScope ||--o{ ExampleRecreated : "INTEREAVE IN PARENT"

更新後 ER図

erDiagram

ExampleAlter {
    INT64 Id PK
    INT64 Col_1 ": NOT NULL が外れたよ"
    STRING(MAX) Col_3
}

OutOfScope {
    INT64 IdA PK
    STRING(MAX) Meta
}

ExampleRecreated {
    INT64 IdA PK,FK
    INT64 IdB PK
    STRING(MAX) Meta
}
OutOfScope ||--o{ ExampleRecreated : "INTEREAVE IN PARENT"

つぎのように新旧定義 DDL ファイルからマイグレーション SQL を生成します。

hammer diff ./example/old.ddl ./example/new.ddl > ./example/hammer-gen.ddl

生成ファイル (hammer-gen.sql) はつぎのようになっています。 DROP 文や CREATE 文が一つにまとめられていて稼働しているシステムで行うとデータが消えたり、アプリケーションが期待する列を見つけられなくなり失敗しそうだとわかります。

hammer-gen.sql

ALTER TABLE ExampleAlter ALTER COLUMN Col_1 INT64;
ALTER TABLE ExampleAlter DROP COLUMN Col_3;
ALTER TABLE ExampleAlter ADD COLUMN Col_3 INT64;
ALTER TABLE ExampleAlter DROP COLUMN Col_2;
DROP TABLE ExampleRecreated;
CREATE TABLE ExampleRecreated (
  IdA INT64 NOT NULL,
  IdB INT64 NOT NULL,
  Meta STRING(MAX),
) PRIMARY KEY(IdA, IdB),
  INTERLEAVE IN PARENT OutOfScope ON DELETE NO ACTION;

hammer は便利で変更前として spanner://projects/proj_name/instances/instance_name/databases/db_name のようにデータベースのリソースIDを使って直接指定できます。

ddl-planner で分割する

ddl-planner はマイグレーション SQL を3つのフェーズ(prepare, recreate, cleanup)に分割します。それぞれに対応した SQL ファイルは指定したディレクトリ配下に置かれます。

./ddl-planner -input ./example/hammer-gen.ddl -output ./dist

上のように呼び出すことでつぎのように分割されます。

ls ./dist
0_prepare.ddl  1_recreate.ddl  2_cleanup.ddl

0_prepare.ddl

ALTER TABLE ExampleAlter ALTER COLUMN Col_1 INT64;
CREATE TABLE Tmp_ExampleRecreated (IdA INT64 NOT NULL, IdB INT64 NOT NULL, Meta STRING(MAX)) PRIMARY KEY (IdA, IdB), INTERLEAVE IN PARENT OutOfScope ON DELETE NO ACTION;
ALTER TABLE ExampleAlter ADD COLUMN Tmp_Col_3 INT64;

1_recreate.ddl

ALTER TABLE ExampleAlter DROP COLUMN Col_2;
DROP TABLE ExampleRecreated;
ALTER TABLE ExampleAlter DROP COLUMN Col_3;
CREATE TABLE ExampleRecreated (IdA INT64 NOT NULL, IdB INT64 NOT NULL, Meta STRING(MAX)) PRIMARY KEY (IdA, IdB), INTERLEAVE IN PARENT OutOfScope ON DELETE NO ACTION;
ALTER TABLE ExampleAlter ADD COLUMN Col_3 INT64;

2_cleanup.ddl

DROP TABLE Tmp_ExampleRecreated;
ALTER TABLE ExampleAlter DROP COLUMN Tmp_Col_3;

それぞれのステップでの変更は追加かアプリケーション側で利用しなくなった要素の削除に限定されるため、ステップ適用前にアプリケーションのバージョンアップとデータマイグレーションを挟めばシステムを稼働しつつテーブル定義を更新できると考えています。

ddl-planner のなかのはなし

よくあるテキスト処理です。

  1. memefish を使い SQL をパースする
  2. 得られた AST に含まれる SQL 文を Rego をつかい分類する
  3. SQL 文の分類にもとづいて実行クエリをそれぞれのフェーズに登録する
  4. それぞれのフェーズの SQL ファイルを生成する

SQL パーサー(memefish)

Rego の評価する SQLAST について説明します。 memefish はGoで書かれた SpannerSQL パーサーです。ドキュメントはそれほど充実しているわけではないため公式サンプルコードを読むと早いです。ほかには ZetaSQL もありますが Go にバインドするのに苦労しそうです。

使うのには、クエリ文字列を Buffer に与えた *memefish.Lexer*memefish.Parser に渡して ().ParseDDLs() などを呼ぶだけです。(FilePath フィールドは構文エラーの表示などデバッグ用メタ情報なので test.sql などでも大丈夫です)

parser := &memefish.Parser{
        Lexer: &memefish.Lexer{File: &token.File{
                FilePath: "test.sql",
                Buffer:   "DROP TABLE foo; DROP TABLE bar;",
        }},
}
asts, _ := parser.ParseDDLs()
pp.Println(asts)

出力結果は下のようになります。 SQL 文ごとに型があります。ドキュメントはないですが複雑ではないので *memefish/ast/ast.go を読めばすぐに理解できます。 Rego は値を JSON として受け取って評価するので型名は使えません。 しかし DropTable 構造体は Drop フィールドを持つなどの特徴があるため、これを文の種類の判断材料にしようと考えました。

[]ast.DDL{
  &ast.DropTable{
    Drop:     0,
    IfExists: false,
    Name:     &ast.Ident{
      NamePos: 11,
      NameEnd: 14,
      Name:    "foo",
    },
  },
  &ast.DropTable{
    Drop:     16,
    IfExists: false,
    Name:     &ast.Ident{
      NamePos: 27,
      NameEnd: 30,
      Name:    "bar",
    },
  },
}

OPA/Rego について

SQL 文の分類や同一テーブルを対象とした更新をみつけるために Rego を使います。 OPA という汎用ポリシーエンジンで中核言語として使われています。 OPA を用いることで認証認可や k8s のリソース定義についてポリシーを定められるようです。ポリシーエンジンは OPA のほかに Hashicorp Sentinel, jsPolicy, Kyverno などがあります。

Hashicorp Sentinel はエンタープライズライセンスが必要ですが OPAApache-2.0 license で提供されていて無料で商用利用も可能です。 また jsPolicy, Kyverno は主に k8s のリソースに対するポリシー適用を目的にしていますが Rego は汎用的であることを目指しています。とくにランタイムをプログラムに埋め込んで使うこともできるため遊びやすいです。

RegoDatalog に影響を受けた論理型言語です。 再帰的な定義が許可されないためデータベースに例えやすいです。最初は、ビューと関数とテーブルとオブジェクトがある関係データベースみたいなものだと思っておくと入門しやすいと思います。

まずはインストールして opa runREPL を起動しましょう。そして the basics を読みながら実験してれば数分で感覚がつかめます。 遊んでいたときにだいたい下みたいな事を感じました。あくまでこれは実験して掴んだ認識なので正確さのためにドキュメントと実装を確認しましょう。

  • Rego ではポリシーを定義する
  • ポリシーはデータ(と仮想データ)の集まりに対する問い合わせ(クエリ)である
  • ポリシーに基づく判定は、クエリを評価(Eval)することで実現する
  • クエリは式であり、式はデータや仮想データで構成される
  • クエリの評価(Eval)では充足性判定が行われる
    • 充足性判定は、クエリの式に含まれるすべての変数について正しい束縛を探す(探索する)ことで実現する
      • 全ての正しい束縛を列挙できる
    • 探索空間はクエリ(問い合わせ)が起点となって決定される
  • 正しい束縛は変数を含むすべての比較を充たしている
    • 矛盾の生じる束縛は正しい束縛ではない
  • 変数に束縛される可能性のある値の一覧を束縛候補と呼ぶことにする
    • 束縛候補は勝手に作った造語
    • 変数に代入があれば、束縛候補は代入の右辺値となる
    • 変数が複合オブジェクトの参照に使われていると、インデックス全てが束縛候補に含まれる
    • 代入とオブジェクト参照を通じて、複数の束縛候補が提示されるときは積集合が束縛候補となる
      • そもそも束縛候補が造語なのだけど、コンパイル時に行われるのか評価時に判定されるのかは謎
    • グローバルを除き、同一変数に対する複数回の代入はコンパイル時に拒否される
    • グローバルでの複数回の代入がなされた変数をルール本文で利用するとコンパイルエラー
  • データは、明示的に定義されているものを指す
  • 仮想データは、事前に定義されたルールから評価時に導出される
    • ユーザーはポリシー定義でルールを記述する
  • 再帰定義は使えない
    • おそらく停止性を保証し探索範囲を定めるため
  • 全ての変数に有限の束縛候補がなければコンパイルは通らない
    • 関数の仮引数は評価時に実引数が代入される
  • Unification(=) は、右左辺の式の一致を意味する
    • オブジェクトはコンパイル時に内部を再帰的に比較される
      • 片方が値の場合は比較として扱われる
        • 矛盾があればコンパイル時にエラー
      • 束縛候補を持たない変数と束縛候補をもつ変数が両辺に並んだ場合は代入と解釈される
        • 束縛候補を持たない変数に束縛候補が設定される
      • 束縛候補のない変数が両辺に並んだ場合はコンパイルエラー

等価性はとても大事なので REPL で遊びながら公式資料 Equality に目を通してみてください。 REPL だけではわからないことも多いのでドキュメントは大事です。 事前に定義するデータやルール(に基づく仮想データ)はグローバル変数の data 配下に入ります。ただし評価(Eval)の時に渡されるデータは input に代入されます。 詳しくは Document Model を読んで下さい。

ここでは簡単な例を書いておきます。まずデータとルールの準備です。

# base data
servers := {
    "h1": {"site": "tokyo", "role": "db"},
    "h3": {"site": "tokyo", "role": "ntp"},
    "h5": {"site": "tokyo", "role": "web"},
    "h7": {"site": "osaka", "role": "step"},
}
## rule: 仮想オブジェクト(集合)を定義する
> ssh_disabled[host] { host := servers[_]; host.role != "step" }
## 導出される集合を確認する
> ssh_disabled
[
  {
    "role": "db",
    "site": "tokyo"
  },
  {
    "role": "ntp",
    "site": "tokyo"
  },
  {
    "role": "web",
    "site": "tokyo"
  }
]
## 関数を試す
> in_tokyo(host) := result { result := host.site == "tokyo" }
## これでも大丈夫
> in_tokyo(host) { host.site == "tokyo" }
> in_tokyo({"site": "tokyo", "role": "db"})
true

Rego ランタイムの呼び出し

Rego ランタイムは *rego.New() で生成します。そして PrepareForEval() メソッドで *rego.PreparedEvalQuery を準備します。さいごに input を引数にわたして評価(Eval())を呼び出します。クエリは PrepareForEval() より先にランタイムに設定する必要がありました。

// 評価結果の値が Go のネイティブ型ではないため変換が必要になる
int64Id := func (v interface{}) int64 {
    r, _ := v.(json.Number).Int64()
    return r
}

//go:embed module.rego
var module string
runtime := rego.New(
    rego.Query(`data.ddl.add[id]`),
    rego.Module("data.ddl", module),
)
prepared, _ := runtime.PrepareForEval(context.Background())

var inputData interface{}

results, _ := prepared.Eval(context.Background(), rego.EvalInput(inputData))
for _, r := range results {
    id := int64Id(r.Bindings["id"])
    // ...
}

inputData に与えられたインスタンスは RegoJSON エンコーディングされます。そのためアノテーションを使って Rego 内でのフィールド名を変更できます。 また結果(サンプルコード内 results)はネイティブ型ではないため利用には変換が必要となります。評価直前に渡されるデータは Rego 言語内の input に代入されます。この input は特別で Unsafe な変数を受け付けないルール定義本文に残っていてもエラーになりません。

ddl-planner でも同じ流れで利用しています。 inputData 相当の変数に memefish でパースした結果(AST のリスト)が含まれています。

ポリシー定義を抜粋(すこし訂正)しました。フィールドが存在するという条件は下のようにフィールド名を書くだけで表せます。

package ddl

# DROP TABLE 文の集合を定義: 条件 .Drop フィールドが存在する
drop_table[x] { input[x].Drop }

# CREATE TABLE 文の集合を定義: 条件 .Create フィールドが存在する
create_table[x] { input[x].Create }

# 同一テーブルを DROP して CREATE している組み合わせの集合
replace_table_pair[c_id][d_id] {
        create_table[c_id]
        drop_table[d_id]
        # テーブル名が Name.Name に含まれているのは memefish 由来
        input[c_id].Name.Name == input[d_id].Name.Name
}

このように複数オブジェクトの関係式を簡単に定義することで列挙に使えます。

ふりかえり

勢いに任せてアドベントカレンダー締め切り駆動で実験・執筆しました。制作物自体の問題やほかのアプローチとの比較、本筋から外れる感想や得たことの整理などやっておきます。

ddl-planner の課題

3つ挙げておきます。

1つめは実装が間に合っておらず hammer が生成する SQL 文すべてに対応できていないことです。 実はマイグレーション SQL として DDL のほかに UPDATE 文も含まれます。いまは UPDATE 文に対応できていません。これは実装すればほぼ解決します。 UPDATE 文は DDL ではないので ddl-planner という名前が不自然になってしまうところくらいです。

2つめは ddl-plannerhammer に依存していてオーナーが分かれていることです。そのため hammer の変更にすばやく追従できません。気づかずに危険なマイグレーション SQL を利用してしまう危険が残ります。

最後の問題は ddl-planner は衝突を避けるためのプレフィックスを引数として受け取ることです。 ddl-planner はマイグレーションを3ステップに分離します。その途中で使われる一時用途のカラム名が他のカラム名と衝突してはいけません。テーブル名も同様です。しかし hammer が生成するマイグレーション SQL には適用先のテーブルの情報が残っていません。そのため引数で渡しました。一方で hammer であればそもそも引数を受け取る必要はありません。新旧のテーブル定義を持っているからです。 どちらのツールも新旧のテーブル定義を比較してマイグレーション SQL を生成するという目的を共にしています。そのため今回の3ステップ分解は hammer のオプションとして提案した方がよかったかも知れません。

ただし hammer としては責任範囲を広げることになるので簡単に受け入れるとは限らないです。たとえば hammer オーナーは中間的なテーブルも更新後のテーブル定義として与えれば十分と考えているかも知れません。

マイグレーション SQL 分離のアプローチ

SQL を分割してもマイグレーションはできません。アプリケーションの更新も必要だからです。たとえばテーブル定義として互換性を維持できていても、アプリケーションが利用している ORM やクエリビルダーが * を利用していたりするとデータ不整合でアプリケーションが障害を起こしたりします。どうせ詳細な検討してからアプリケーションと合わせて作り直す可能性が高いのであれば、誤りを許容して参考資料を素早く作るだけで十分です。それなら ChatGPT など生成 AI に作ってもらえば解決します。

やった感想

Regoプラグイン機構も用意されていてビルトイン関数を追加したりもできるようです。 特にビルトイン関数内部では再帰処理が使えるので探索結果も提供できます。(ただ Go には引数がインタフェースとして渡ってきて型アサーションが頻繁に必要になるので相応に価値がないと実装する気にはなれません。) OPA として外部データの利用パターンも整理されているので設計の参考になりそうです。

言語ランタイムをプログラムに埋め込むというのは楽しく思います。何かにつけて入れたくなります。ほかの言語を利用できるというのは価値があります。カスタマイズの提供や責任分担があります。また部分問題を別の言語に任せられるのはアプローチや思考の幅を広げてくれます。たぶん。

ポリシーを簡単に定義・更新できるのでアプリケーション固有の問題に利用してもコストが膨らみすぎないということです。システム的にはメモリ消費が大きくならないものに利用できます。モデルとしては再起処理が不要で複数ドキュメントにまたがった条件の組み合わせを評価したい場面に適しています。用途はセキュリティや k8s に限らず広く探せそうです。とりあえず、ライセンスや規約、アプリケーションリソースの衝突回避、ガイドラインの強制、gopacket と組み合わせパケット列の取り出しなどに使えそうだと考えています。

今回の実装は、いいアプローチとは言い切れないですが、実用性もある試作品を作ると理解がすすむと思えました。

例えば *memefish.Lexer も数値周りで特殊な処理をしています。また *memefish.Parser は LL(1) の独自拡張した手書きのパーサーでした。あとから調べてたら作成者の方の解説記事がみつかりました。独自拡張の部分など苦労がわかる楽しい記事でした。

上の記事を読んでいて SQLiteLemon(LALR(1) のパーサージェネレータ) を使っていることを思い出しました。未検証ですが LALR(1) パーサージェネレータの nihei9/vartan が使えるかも知れません。

また *memefish.Parser を読んでいたら SELECT はキーワードなのに INSERT は識別子として扱っていて気になりました。もちろんこれはSpanner のキーワード定義を確認すれば正しいことがわかります。 Spanner の理解がすすみました。

課題についての理解も深まるしドキュメントも必死に読みます。締め切りに追い立てられて良かったです。

Next.js 未経験者が社内システムのフロントエンド開発をするまで

はじめに

この記事は BBSakura Networks Advent Calendar 2023 の 15 日目の記事です。

こんにちは、BBSakura Networks のシステム管理部に所属している澁谷です。普段は BBIX から委託されているシステムなどの開発・運用をメインに業務しています。

今回は新卒かつ Next.js 未経験者の私が、どのように Next.js に触れはじめてフロントエンド開発をしているのかお話ししようと思います。 私自身、学生時代は情報系に属してはいたものの、研究活動で先代が書いた C++ のコードを見よう見まねで真似したり他の言語も軽く触れる程度で入社前は浅く狭くの経験値でした。

はじめての Next.js

きっかけ

私は入社後の各部署を回る研修を終え、7 月に社内システムを開発する現在の部署へ配属になりました。配属後、フロントエンドかバックエンドのどちらから始めたいか問われフロントエンドの開発を選択しました。

学生時代の就活で行なった短期のインターンシップで HTML/CSS で軽い開発をした時に、コーディング後の完成物がわかりやすく目で見られることを魅力に感じていて、楽しく始められそうと思ったことが理由です。加えて、バックエンドの開発経験がなく不安に思っていたこともあります(そういえば情報系の学部を選択したのも、大学一年生の情報の授業で HTML/CSS を触って楽しかったことが理由の 1 つでした)。

ここではじめて Next.js と出会いました。

Next.js とは

ご存じだと思いますが、Next.js は React ベースの web フレームワークです。Next.js には以下のような機能と特徴があります。

  • Routing

    • A file-system based router built on top of Server Components that supports layouts, nested routing, loading states, error handling, and more.
  • Rendering

    • Client-side and Server-side Rendering with Client and Server Components. Further optimized with Static and Dynamic Rendering on the server with Next.js. Streaming on Edge and Node.js runtimes.
  • Data Fetching

    • Simplified data fetching with async/await in Server Components, and an extended fetch API for request memoization, data caching and revalidation.
  • Styling

    • Support for your preferred styling methods, including CSS Modules, Tailwind CSS, and CSS-in-JS
  • Optimizations

    • Image, Fonts, and Script Optimizations to improve your application's Core Web Vitals and User Experience.
  • TypeScript

    • Improved support for TypeScript, with better type checking and more efficient compilation, as well as custom TypeScript Plugin and type checker.

私が勉強を始めた 2023 年 7 月時点では、Next.js 13 が最新でした。後述 しますが、Next.js 13.4 からルーティングの種類に App Router が加わるなど大きく機能が変化する箇所がありました。利用して良いか不安だったので質問したところ、「新しい機能はぜひ使って!」という返答をいただきました。個人利用でなく会社という組織でも保守的にならず新しい機能をどんどん使っていくんだ!と感じたことをよく覚えています。

以降では、Next.js 13 の利用を前提に述べています。

勉強方法

私は、Next.js だけでなく React、TypeScript も未経験でした。そのため、実際に手を動かしながら勉強していくのが良いだろうと思い、先輩が教えてくださったチュートリアルを一から始めました。

各チュートリアル終了後時点でぼんやり理解し始めたので、実際に開発するフェーズに入っていきました。研修後の初仕事として、以前まで SQL や API を叩いて取得していた DB をブラウザ上で簡単に検索できるシステムの開発をすることになったので、以降はその開発を通してわからないことは調べつつ慣れていった形になります。

そのなかで、苦労した点や素直に感じた点などを初心者の目線からまとめていこうと思います。

Next.js 13 の壁

ルーティング

初心者にとってこれだけはすぐに抑えなければ!と感じたのはルーティングです。ルーティングにおいては、Next.js 13.4 から App Router が追加され既存の Page Router を加えた二種類からプロジェクトの作成時、設定できるようになりました。ディレクトリ構成と URL のパスの関係が変化したというのがわかりやすい説明かなと思います。

極論ドキュメントにすべて書いてあるのですが、英語で読むのに疲れてしまう&日本語でもすぐに理解できるほどの知識がないため、慣れるのに苦労しました。チュートリアルでも片方しか経験しておらず、調べながらコーディングしているとどちらのルーティングを利用しているのか混同してしまい、無駄な時間を要してしまうことが多々ありました。

Page Router では pages ディレクトリ配下にディレクトリやファイルを追加することでルーティングされます。App Router ではルーティングに利用するファイルは page というファイル名である必要があります(表 1)。

(表 1) URL とルーティングの関係
URL App Router Page Router
http://ドメイン/books /app/books/page.tsx /pages/books.tsx
http://ドメイン/books/title /app/books/title/page.tsx /pages/books/title.tsx
http://ドメイン/books/1 /app/books/[number]/page.tsx /pages/books/[number].tsx

モックサーバーの構築

モックサーバーという単語もこの開発ではじめて触れました。モックサーバーとは、ローカル環境から API が使用できない時に実際のデータに変わるモックデータを返すサーバーのことです。この仕組みもよくわからず、先輩に何度も説明させてしまいました。。。m( )m

MSW(Mock Service Worker) を利用すると良いだろうというアドバイス&口コミから、MSW の準備を始めました。しかし、Next.js 13 は MSW をサポートしていませんでした(少なくとも 2023 年 8 月ごろまでは)。どうにか利用できないかと、MSW のテストコードを動かそうと babel コンパイラを利用すると、Next.js のコンパイラと競合してしまい。。。調査を続けても同じように導入に苦労している記事しか見つけられず、MSW の公式 X で Next.js にサポートを呼びかけている のをようやく発見し、他の手段を取ることにしました。

現在調べ直すと、サポートしていない状況は続いているもののどうにか動かせている人はいるようです。しかし、私は以下の方法で解決できたのでご紹介します。

App Router の Route Handlers 機能を使用して、json-server でモックサーバーを建てテストデータを取得する実装です。この時、Next.js 本体は localhost:3000、モックサーバーは localhost:4000 など別々にローカルサーバーを立てる必要があります。返したいモックデータは単純なデータでしたし、軽く試した後に検証環境でもテストできるので機能としては十分でした。

社内システムの開発

上記の経験を通して、Next.js に多少は慣れたかなと思います。またシステムを動かすための VM を構築したりコーディングだけではない作業もはじめて経験し、どうにか初仕事のシステムがリリースできました。開発したシステムを利用した同期から「すごく便利!助かっている!」と言ってもらえてとっても嬉しかったです。ユーザーが身近な人でリアクション & FB をすぐにいただけるのは、社内システム開発の魅力なのかなと感じます。

また、社内外問わず開発時には、ユーザーの目線に立って機能や使い方を考えていくことが必要だと思います。開発前のリクエストとヒアリングはありますが、開発における技術的なことだけではなく、各部署の業務内容や業務実態を知っておく必要があるなと感じました(研修時に各部署を回る経験ができたのはとても良かったです)。さらに、社内にはネットワークエンジニアが多いので、ネットワークの知識も必要になってくるなと思っています。

現在は Go 言語を使用したバックエンド開発にも取り組んでいます。Next.js と同様に勉強+実際の業務でようやく慣れてきたと感じているところです。Go 言語の勉強時間はあまり取ることができず、すぐに既存プロジェクトにジョインする形でした。そのなかで効率よく勉強するために、すでに Go 言語の勉強をしていた新卒の同期にアドバイスをいただき以下の内容に取り組みました。

列挙すると多く見えますが、サッとドキュメントを読んで自分にとって難しそうに感じた範囲のみ手を動かした形になります。

配属から今までの期間でフロントエンド開発・バックエンド開発に取り組むことができており、何も知らなかった頃に比べるとシステムエンジニアとして成長できていると感じています。入社前ワクワクして想像していた通りの仕事ができていて嬉しいです(できない自分にもどかしさを感じることの方がまだ多くありますが)。

おわりに

このブログを書くために公式ドキュメントを見直していると、Next.js 14 がリリースされていました。高速化がメインで新しい API の追加はないそうですが、開発スピード早すぎませんか。。。すごい。このように新しい技術は絶えず生まれているので、良いものはすぐ吸収できるようにアンテナを張って勉強していきたいと思います。

以上、Next.js 未経験者の私が社内システムのフロントエンド開発をするまでのお話でした。 私の感想が多く含まれた記事になりますが、少しでも役に立てればと思います。読んでいただきありがとうございました!

疑似フレッツ光網内の通信を accel-ppp で再現できるか検証する(検討編)

はじめに

この記事は BBSakura Networks Advent Calendar 2023 の 12 日目の記事です。 adventar.org

こんにちは、BBSakura Networks 株式会社の佐藤です。

「インターネット」をテーマに色々と調べていたら、登 大遊さんが書いた資料「230610 講演 第1部 (登) - 配布資料その1 - 秘密の NTT 電話局、フレッツ光、インターネット入門.pdf」を見つけました(こちらからダウンロードできます)。

資料の中ではフレッツ光の内部構成が紹介がされており、宅内 PC・ルータが PPP セッションを経由して ISP に接続するまでの通信フローの解説はとても興味深いものでした。

この記事では検討編と称してフレッツ光網内で PPP セッションが生成されるまでのフローを OSS の組み合わせで再現できるかを検証するために検討したことを紹介します。 余力があれば今後「実装編」を書く予定です。

なお上記の資料は資料内でも述べられているとおり NTT 公式の資料ではないです。資料内に掲載されている内部構成図についても「観測結果だけから検証して作った図」と述べてます。 このため本記事で扱うネットワークは、フレッツ光網に似た「疑似フレッツ光網」となります。

疑似 NTT フレッツ光と PPP セッションの関係

疑似 NTT フレッツ光の概要

疑似 NTT フレッツ光は宅内の PC ・ルータと ISP を中継するネットワークです。上記資料の pp.44-60 でその詳細が説明されています。中継機能の根幹を担う機器が、フレッツ収容ルータと網終端装置です。フレッツ収容ルータは宅内ルータからの通信を L3 終端するゲートウェイとして機能し、網終端装置は ISP との接続点としての役割を果たします。

フレッツ収容ルータと網終端装置の関係を示した概略図。左から右に、宅内のネットワーク、最寄りの電話局のネットワーク、NTT フレッツのバックボーンネットワーク、ISPのネットワーク、の順でネットワークが並び、各ネットワーク内に配置された機器の図を線で結ぶことで、各ネットワークのどの機器同士が接続しているのかを示しています。
フレッツ収容ルータと網終端装置の関係を示した概略図

PPP セッション確立までの流れを資料から読み解く

上記の資料ではフレッツ収容ルータ・網終端装置の振る舞いが記述されています。その一部を下記に抜粋します。

1.ユーザ名(@ より後)で表を検索して接続先となる ISP を決定する(pp.58-59)

(a) 収容ルータは、ユーザーが PPPoE 接続を自宅側から開始したとき、それをどの ISP に接続するか、決定しなければならない。これは、PPPoE 接続におけるユーザー名 username@example.org のような文字列のうち @ よりも後の文字列をもとに、表を検索して決定する。

2.フレッツ収容ルータと網終端装置の間は L2TP を利用する(pp.57-58)

(a) と (b) との間は、PPPoE トンネル (PPPoE: Point-to-Point Protocol over Ethernet。詳しくは後述する。) が張られるが、より正確には、PPPoE の中の PPP というプロトコルを収容ルータで取り出して、これを L2TP (Layer-2 Tunneling Protocol) という UDP プロトコルの一種 (このあたりは、固有名詞なので、分からなくても差し支え無い。今後の続編で詳しく述べる。) に入れ、L2TP の UDP パケットとして飛ばす。

3.ユーザ認証は網終端装置を経由して ISP 毎に設置された RADIUS サーバに問い合わせる(pp.58)

PPPoE の接続は、ユーザー認証を必要とする。ISP は契約しているユーザーからの接続のみを受付けたいためである。そこで、網終端装置から ISP の RADIUSサーバー (ユーザー認証サーバーのことである。詳しくは続編で述べる予定である。) に認証のお伺いが届く。

このように NTT フレッツ光では、接続時のユーザ情報から経由する ISP を判断し、通信経路の生成・決定をしています。

PPP セッション確立までの流れを整理

上記の振る舞いを通信フローとしてまとめたのが下図になります。

PPP セッション確立までの流れを示した図。この図で示した処理の流れは、このあと文書で説明しています。
PPP セッション確立までの流れ

最初にフレッツ収容ルータは以下の処理を実行します。

1. 宅内ルータとの間に PPPoE セッションを生成
2. ユーザ名( @ の後ろ )をキーとして接続先となる網終端装置を問い合わせ
3. 網終端装置との間に L2TP セッションを生成
以降、網終端装置に PPP セッション処理を引き継ぎ、通信を中継に徹します。

処理を引き継いだ網終端装置はフレッツ収容ルータと連携して以下の処理を実行します。

4. ISP 内の RADIUS サーバにユーザ情報を送信 & 認証結果受け取り
5. 結果を宅内ルータに送信し、残りの PPP ネゴシエーションを実施

上記が完了後、宅内ルータと網終端装置の間に PPP セッションが確立して ISP を経由したインターネットへの通信が可能になります。

今回のテスト項目を検討

おおまかな通信の流れが整理できたので今回のテスト項目を整理します。また、OSS で動作検証するためのネットワークを検討します。

テスト項目

動作検証のためのネットワークで以下を実現することを本検証の目的とします。

  • 宅内ルータと網終端装置の間で PPP セッションを確立できること
  • ユーザ名に応じて接続先となる網終端装置・ISP が区別されること
  • PPP セッション確立後は ISP を経由した通信ができること

検証用ネットワーク

以下の図のネットワークを netns, bridge, veth を利用して Linux 上に構築します。

検証用ネットワークの構成図。検証用に用意したネットワークとそのネットワーク内に配置する機器の接続関係を示しています。この後に説明する構成要素間の接続関係を線としてつなげることで表しています。
検証用ネットワークの構成図

構成要素: netns

netns 名 疑似フレッツ上の役割 OSS 検証用ネットワーク上の役割
home-1,2 宅内ルータ rp-pppoe PPPoE クライアント
sse フレッツ収容ルータ accel-ppp PPPoE サーバ・L2TP クライアント・RADIUS クライアント
dispatch 振り分けサーバ free-radius RADIUS サーバ
nte-1,2 網終端装置 accel-ppp L2TP サーバ・RADIUS クライアント
isp-rt-1,2 ISP ルータ - 経路制御
isp-radius-1,2 ISP RADIUS free-radius RADIUS サーバ

構成要素: bridge

bridge 名 疑似フレッツ上の役割
onu-sw home-1,2 - sse 間のネットワーク
core-sw バックボーン内のネットワーク
isp-sw-1,2 ISP 内のネットワーク

成否の判断

まず、home-1 - nte-1 および home-2 - nte-2 でそれぞれ PPP セッションが確立することを確認します。その後、home-1 から home-2 に ping を実行して ISP 1, 2 経由で疎通が確認できたら検証成功と判断します。

選択した OSS と今回の要件の比較

今回のネットワーク構成の肝となるのは、フレッツ収容ルータ・網終端装置が連携して PPP セッションを確立する機能です。今回は accel-ppp の利用を検討しました。

accel-ppp

PPP, PPPoE, L2TP を実装した OSS はいくつかありますが、 accel-ppp は下記の点で優れていると判断しました。

  • PPP(PPPoE, L2TP , RADIUS) に関するサーバ・クライアントの機能を標準で多数有している
  • 各機能がモジューラブルに設計されており、今回のような PPP 機能をカスタムするケースに有利と判断
  • ルータ OS として人気がある VyOS で採用されている

参考にした資料

サーバを中継して PPP セッションを確立するためのフロー

Cisco が公開しているページを参考にしました。 こちらには L2TP セッションを利用して PPP ネゴシエーションを引き継ぐための通信フローが掲載されており大変参考になりました。

accel-ppp が提供する機能

公式サイトのドキュメント Github 上のソースコードを参考にしました。

比較結果

双方の資料を比較した結果、accel-ppp は PPPoE 、L2TP といったプロトコルのサーバ・クライアントを独立した機能として提供することは可能ですが、それらの各機能を連携させて動作させる仕組みを有していないことがわかりました。

不足している機能を示した図。上記で既に述べた「連携させる仕組みが有していない」ことを強調するための図です。
不足している機能

今回の検証を実現するために不足している機能を下記に列挙します。

  • PPP ネゴシエーションの過程で L2TP セッションを生成する機能(L2TP セッションの生成は CLI/Telnet からのコマンド実行でのみ可能)
  • ユーザ名をキーにして L2TP サーバを選択する機能
  • PPPoE セッションと L2TP セッションで生成されたネットワークインタフェース(例: /dev/ppp0 , /dev/l2tp0)間の通信を中継する機能
  • PPPoE セッションの生成過程でクライアントから取得したネゴシエーション情報( LCP、ユーザ情報 )を L2TP セッション側に渡す機能

accel-ppp のソースコードを改修して機能を追加する

フレッツ収容ルータ・網終端装置間の通信を実現するため以下の機能を accel-ppp に追加します。

追加機能の一覧

機能名 処理内容
LT2P セッション動的生成機能 宅内ルータからユーザ情報を受け取ったタイミングで、そのユーザ名から接続先となる網終端装置を決定、その網終端装置との間に L2TP セッションを生成する
PPP セッション間通信中継機能 PPPoE セッションと L2TP セッションの通信を中継する
PPP ネゴシエーション情報中継機能 宅内ルータから受信したネゴシエーション情報を網終端装置側に転送する

各機能の詳細

L2TP セッション動的生成機能

ユーザ名を利用して網終端装置を決定して、その網終端装置との間に L2TP セッションを生成するため、宅内ルータ側からユーザ名を届く Chap Response のタイミングで以下の処理を追加します。

  1. ユーザ名の @ 後ろの文字列を振り分けサーバに送信して接続先(網終端装置の IP アドレス)を取得
  2. 接続先に対して L2TP セッションの生成を開始する

L2TP セッション動的生成機能の図。上記で既に述べた処理によって、どのような変化が起きるのかを示した図です。
L2TP セッション動的生成機能

この機能を実装するときに accel-ppp のモジューラブルな構成が役立ちます。accel-ppp ではユーザ情報取得後の認証処理をハンドラとして組み替えることが可能です。この仕組みを利用してローカルファイルを参照する方法と RADIUS に問い合わせる方法を設定ファイルで選択可能にしています。今回はハンドラとして上記の機能を追加することで、独立性を高め疎結合を保ちます。

PPP セッション間通信中継機能

宅内ルータとフレッツ収容ルータ間で確立した PPPoE セッションと L2TP セッションを紐づけて双方の通信を中継する仕組みが必要です。 それぞれのセッションが確立したタイミングでネットワークインタフェース(例: /dev/ppp0 , /dev/l2tp0)が OS 上に生成されるため、L2TP セッションが生成されたタイミングでこの2つのネットワークインタフェースの通信を中継させるプロセスを動的に生成する機能を設けます。

PPP セッション間通信中継機能の図。上記で既に述べた処理によって、どのような変化が起きるのかを示した図です。
PPP セッション間通信中継機能

PPP ネゴシエーション情報中継機能

標準の機能で PPPoE セッションと L2TP のセッションを生成した場合、独立した2つの PPP セッションが生成されるだけとなります。この独立した2つのセッションを宅内ルータと網終端装置を両端とした1つの仮想的な PPP セッションとして扱うため、PPPoE セッション生成時に受け取ったネゴシエーション情報を網終端装置に渡します。 渡した情報をもとに網終端装置がネゴシエーションを引き継ぐことで仮想的な1つの PPP セッションが生成されます。

PPP ネゴシエーション情報中継機能の図。上記で既に述べた処理によって、どのような変化が起きるのかを示した図です
PPP ネゴシエーション情報中継機能

各機能の実行タイミング

上記機能の実行タイミングを整理します。

機能名 実行タイミング
L2TP セッション動的生成機能 宅内ルータから CHAP Response を受信した後
PPP セッション間通信中継機能 L2TP セッション確立時(ユーザ認証に失敗した場合は即削除)
PPP ネゴシエーション情報中継機能 網終端装置に PPP ネゴシエーション情報を渡す時

以下のフローは、Cisco のページにある通信フローをベースに今回発生する通信を付け加えたものです。

  sequenceDiagram
      participant HR as 宅内ルータ<br>(home)
      participant SR as フレッツ収容<br>(sse)
      participant DS as 振り分けサーバ<br>(dispatch)
      participant N as 網終端装置<br>(nte)
      participant RA as RADIUS<br>(isp-radius)
      HR->>SR: PADI
      SR->>HR: PADO
      HR->>SR: PADR
      SR->>HR: PADS
      Note over HR,SR: PPPoE ネゴシエーション完了
      HR->>SR: LCP Configuration-Request
      SR->>HR: LCP Configuration-Ack
      SR->>HR: LCP Configuration-Request
      HR->>SR: LCP Configuration-Ack
      Note over HR,SR: LCP ネゴシエーション完了
      SR->>HR: CHAP challenge
      HR->>SR: CHAP challenge Response
      Note over HR,SR: CHAP(ユーザ情報受信まで)
      rect rgb(191, 223, 255)
      Note over SR: 実行:LT2P セッション動的生成機能<br>(接続先の取得・L2TP セッション生成を依頼)
      SR->>DS: Access-Request
      DS->>SR: Access-Response 
      Note over SR,DS : RADIUS(接続先の取得)
      SR->>N: SCCRQ
      N->>SR: SCCRP
      SR->>N: SCCCN
      N->>SR: ZLB
      Note over SR,N: L2TP ネゴシエーション(トンネル)
      SR->>N: ICRQ
      N->>SR: ICRP
      SR->>N: ICCN
      N->>SR: ZLB
      Note over SR,N: L2TP ネゴシエーション(セッション)
      end

      rect rgb(168, 168, 255)
      Note over SR: PPP セッション間通信中継機能<br>(宅内ルータからのユーザ情報を転送)
      end

      N->>SR: LCP Configuration-Request
      SR->>N: LCP Configuration-Ack

      rect rgb(153, 255, 153)
      Note over SR : 実行:PPP ネゴシエーション情報中継機能<br>(宅内ルータからの LCP 情報を転送)
      SR->>N: LCP Configuration-Request
      N->>SR: LCP Configuration-Ack
      Note over N,SR: LCP ネゴシエーション完了
      N->>SR: CHAP challenge
      SR->>N: CHAP challenge Response
      Note over SR,N: CHAP(ユーザ情報転送)
      end

      N->>RA: Access-Request
      RA->>N: Access-Response
      Note over N,RA: RADIUS(ユーザ認証)

      rect rgb(168, 168, 255)
      Note over HR,N: PPP セッション間通信中継機能実行後<br>仮想的な PPP セッションとして宅内ルータ - 網終端装置 で通信
      N->>HR: CHAP success/failure 
      Note over N, HR: CHAP(認証結果の送信)
      Note over HR,N: IPCP Complete( IPv4 アドレスの取得)
      Note over HR,N: 仮想的な PPP セッションを利用したデータ通信
      end

おわりに

疑似フレッツ光網の通信を OSS で再現するために検討したことをここまで書きました。上記の検討により実装方針の大枠が定まりましたので、後は実装を進めていくだけとなります。

登 大遊さんの資料を初めて読んだときは OSS の標準的な機能で実現できるだろうと軽い気持ちで考え、netns の環境構築から着手しましたが、検討を進めていく過程で機能追加が必要なことを知り、当初の想定を超える作業が必要であることがわかりました。

普段の業務ではあまり馴染みの無いプロトコルを調査した今回の経験は、今後の業務において良いアイディアを生むきっかけとなりそうです。 せっかくここまで準備したので、機能実装とテストを実施して、その内容をまとめた記事を「実装編」として書きたいと思います(3ヶ月以内ぐらいを目標に)。