試行錯誤してProxmox VE上でvJunosEvolvedを動かしたりPyEZを動かしたり

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

adventar.org

はじめに

こんにちは、BBSakura Networksでバックエンド開発をしている秋山です。普段はOCXの開発をしており、OCXの機能追加やクラウド事業者とのAPI連携に尽力しています。最近はバックエンド開発の他にもネットワーク開発に興味を持っており、社内ラボ環境の整備を目的とした石狩プロジェクトなどにも顔を出したりしています。最近、石狩プロジェクトのメンバーのおかげでProxmox VEを通じて自由にVMを立てたり消したりできる環境ができたので、自分も便乗してたまに触ったりしています。今回は、その中で得た知見としてVMの立て方やconfig投入自動化の仕方などについて解説したいと思います。

blog.bbsakura.net

今回は

  • NW自動化の勉強がしたいと思い、NW機器上でPythonコードを走らせられるPyEZに興味があった
  • 今年公開された大手ベンダーの新しい仮想イメージを触って見たかった
  • アカウント登録が不要でハードルが低い

などの理由からvJunosEvolvedをProxmox VE上で動かしてみることにしました。

仮想イメージを取得

仮想イメージは Downloads から取得できます。DLした仮想イメージはノード内の任意ディレクトリに移動させます。 ノードの/home/akiyama配下にvJunosEvolved-23.2R1-S1.8-EVO.qcow2が存在している画像

仮想イメージをインポート

まずはvJunosEvolvedを動作させるための最小要件に合わせて空のVMを作成します。

ハードウェアおよびソフトウェアの最小要件 |vJunosEvolved |ジュニパーネットワークス

以下のコマンドを実行しましょう。(1行です)

# qm create 103 --cores 4 --memory 8192 --cpu host --net0 virtio,bridge=vmbr0 --scsihw virtio-scsi-pci --ostype l26

ノードのshellから以下のコマンドを実行し、仮想イメージを取り込みます。

# qm importdisk 103 <your_path>/vJunosEvolved-23.2R1-S1.8-EVO.qcow2 <your_storage_name>
# qm set 103 --virtio0 <your_storage_name>:vm-103-disk-0
# qm set 103 --boot order=virtio0
# qm set 103 --serial0 socket

起動にはSMBIOSの設定も必要になります。

KVMでのvJunosEvolvedの展開と管理 |vJunosEvolved |ジュニパーネットワークス

VMのOptionsからSMBIOS(type 1)の各欄に以下を入力します。

manufacturer: Bochs
product: Bochs
serial: chassis_no=0:slot=0:type=1:assembly_id=0x0D20:platform=251:master=0: channelized=yes

前述のパラメータがSMBIOS(type 1)の各欄に入力されている様子

その後、ノードのshellから/etc/pve/qemu-server/[vmid].confを編集し、argsを2行追加します。

# nano /etc/pve/qemu-server/103.conf
boot: order=scsi1;scsi0;ide2;net0
cores: 4
cpu: x86-64-v2-AES
ide2: none,media=cdrom
memory: 8192
meta: creation-qemu=8.1.2,ctime=1703206221
name: vJunosEvolved
net0: virtio=BC:24:11:90:EA:A2,bridge=vmbr0
numa: 0
ostype: l26
scsi0: local-lvm:vm-103-disk-0,iothread=1,size=32G
scsi1: local-lvm:vm-103-disk-1,iothread=1,size=40G
scsihw: virtio-scsi-single
smbios1: uuid=dca71814-b638-4735-8eae-9a62237a7e2c,manufacturer=Qm9jaHM=,product=Qm9jaHM=,serial=Y2hhc3N>
sockets: 1
vmgenid: bb9c4e40-e285-47f5-bb3c-0fffec77746b
args: -smbios type=0,vendor=Bochs,version=Bochs
args: -smbios type=3,manufacturer=Bochs

VMの起動

VMを起動して動作を確認します。

# qm start 103
# qm terminal 103

ログイン情報を求められた際は「root」と入力すれば入れるはずです。
ログインと同時にエラーログが流れたりして見にくいので、以下のコマンドを実行し、少し待つと止まります。

# cli
# configure
# delete chassis auto-image-upgrade
# set system root-authentication plain-text-password
# commit and-quit

以上でvJunosEvolvedの設定は終わりです。
環境構築は完了したので、これから自動化やアプリケーションの動作検証など頑張ってやっていきたいと思います。
実際にJuniperの機器が手元になくても、config投入を行うスクリプト等の動作検証ができるのは非常に便利だと感じました。
また、Proxmox VEもVMの設定が非常に楽で、慣れれば15~30分で好きな仮想イメージを積んだVMが立てれるようになりそうです。

PyEZでconfigを操作してみる

環境構築ができたということで、config投入の自動化に取り組んでみました。その第一歩としてインターフェースの有効化、無効化を行うconfigをPyEZを用いたPythonコードから投入できるようにします。
PyEZとはJunos OSに対するPythonライブラリであり、これを使用することで、Junos機器への設定や管理が可能になります。

以下のコード(interface_manip.py)をVM内の/var/db/scripts/op/配下にscpなりを使って置きます。

from jnpr.junos import Device
from jnpr.junos.utils.config import Config
from jnpr.junos.exception import *

def generate_interface_config(interface_name, disable=True):
    if disable:
        disable_tag = "<disable/>"
    else:
        disable_tag = "<disable operation=\"delete\"/>"
    return f"""
        <configuration>
            <interfaces>
                <interface>
                    <name>{interface_name}</name>
                    {disable_tag}
                </interface>
            </interfaces>
        </configuration>
    """


def apply_configuration_changes(config_utility, config_changes):
    try:
        config_utility.lock()
        config_utility.load(config_changes, format="xml", merge=True)
        config_utility.commit()
    except (LockError, ConfigLoadError, CommitError) as err:
        print(f"Error: {err}")
        return False
    finally:
        try:
            config_utility.unlock()
        except UnlockError:
            print("Error: Unable to unlock configuration")
            return False
    return True

def main():
    with Device() as dev:
        dev.bind(cu=Config)
        if apply_configuration_changes(dev.cu, generate_interface_config("et-0/0/1", True)):
            print("Configured successfully.")
        else:
            print("Configuration error")

if __name__ == "__main__":
    main()

自分は以下のコマンドでsshを有効化し、ユーザを作り、scpで送りました。
アドレスの設定なども適宜行なって下さい。

# configure
# set system services ssh
# set system login user akiyama authentication plain-text-password
# set system login user akiyama class super-user
# commit and-quit

Pythonをスクリプト言語として設定し、操作スクリプト(op script)の登録をするために、/var/db/scripts/op/interface_manip.pyがある状態で以下のコマンドを実行します。

# configure
# set system services netconf ssh
# set system scripts language python3
# set system scripts op file interface_manip.py
# commit and-quit

CLIモードで以下のコマンドを実行すると、Pythonコードが実行されます

# op interface_manip.py

interface_manip.pyの実行前後で特定のインターフェースが無効化されているのが分かります。 全てのインターフェース(et-0/0/0からet-0/0/3)がuplinkしている et-0/0/1だけがdownlinkしている

終わりに

今まで開発の中でちょくちょく仮想アプライアンスは出てきていたのですが、あまり理解しておらず、今回のProxmox VEの操作を通じて一気に理解が深まった気がします。ただ検証環境を整えたところで終わっては勿体無いので、ネットワーク×アプリケーションでどんなことができるかこれから色々試していきたいです。

百番煎じだけど、チームの Github Actions 活用方法とデプロイの流れを紹介してみる

はじめに

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

こんにちは、BBSakura Networks のシステム管理部に所属している蟹江(@kanix2929)です。普段は BBIX から委託されているシステムなどの開発・運用がメインで、ネットワークエンジニア / オペレーターを手助けするためのシステム構築・自動化に尽力しています。

最近、複数人で同時並行的に 1 つのコードを触る場合が増えてきたことから、あらためてチーム内で開発プロセス(GitHub の運用方法など)についてまとめたので紹介してみます。

環境について

  • 本番環境の構成
    • VM 上で Docker コンテナを起動させている
      • docker-compose.yml でコンテナ管理している
    • コンテナが動いているサーバーはプロキシ配下
  • ステージング環境の構成
    • 本番環境を模している(データだけ違う)
  • CI/CD ツールとして Github Actions を主に使っている
    • コード管理に GitHub を使用しているので運用が楽

開発の流れとルール

ざっと絵にするとこんな感じ

開発者がプルリクを出すことでGitHub Actionsが自動的にステージング用と本番環境用イメージを作成して開発していく様子を描いています
GitHub Actionsを使用した開発フローの図

流れを言語化すると

  1. コードを修正してプッシュし、main ブランチへのプルリクを作成
    • プルリク作成時にブランチ名のタグを打った Docker イメージが自動生成され、Docker Hub にプッシュされる(build-image.yml
    • 後述の Release Please のためにプルリクのタイトルは Conventional Commits に従う必要あり
      • タイトルをチェックするワークフローも作成済み(lint-PR-title.yml
  2. プルリク作成者が自身でステージング環境にて動作確認
    • ステージングのコンテナを入れ替えるときは Slack で一声かける
  3. 自身での動作確認後、チームメンバーにレビュー & 動作確認を依頼する
  4. レビュアーは該当プルリクが問題ないことを確認して、プルリクをマージする
    • 基本的にはレビュアーがマージする
    • プルリクのマージは Squash and merge
    • リポジトリの設定でも制限
  5. マージすると main タグの Docker イメージが生成される(build-image.yml
  6. マージ時に Release Please のワークフローも動いて Bot がプルリクを生成するので確認してマージする
    • GitHub 上で Semantic Versioning に従ってタグが打たれる
    • 上記 Version をタグ名とした Docker イメージが自動生成され、Docker Hub にプッシュされる(build-image.yml
  7. 本番へリリース
    • コンテナを立ち上げるのは基本人手
    • デプロイを自動化しても良いが、結局確認が必要なので
      • みんなが使っているステージング環境において、どのタイミングでコンテナ立ち上げ直していいのかちょっと決めづらいのでそちらも人手

もろもろの採用理由

  • Squash and merge の理由
    • 開発していくときのコミットがある程度適当でも他人に影響が無い
      • 開発する人が開発しやすい粒度でコミットしたい
      • Create a merge commit だと、コミット粒度についてチームで統一しておかないと逆に見づらくなると思っている
      • もしバグ含んだプルリクをマージしてしまった場合は、該当プルリクをまるっと Revert するか Fix のコミットを当てる
    • 「1 機能 1 プルリク」にすることで、「1 機能 1 コミット」となるので、コミット履歴がスッキリして見やすい
    • プルリクのタイトルだけ見れば良いので、release-please.yml でのバージョン管理が楽
  • プルリク作成時にイメージを作成する理由
    • 各人がステージング環境での動作確認をしやすくするため

おわりに

GitHub Actions をメインとした CI/CD についての紹介は今さら感ありますが、まだ GitHub の運用がざっくりしているチームの方たちの参考になればなあ、と思っています。 今回紹介した方法が完成形というわけではないので、これからも柔軟に運用をアップデートしていく予定です。 また、みなさんの開発体制で「もっと良い方法があるよ」ということがあれば、ぜひ教えていただきたいです!

[参考]

参考として yml ファイルをいくつか貼っておきます。

build-image.yml

単純に Docker イメージを生成するための yml ファイルです。

name: build-and-push-to-dockerhub

env:
  IMAGE_NAME: hogehoge

on:
  workflow_dispatch:
  push:
    branches:
      - "main"
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+*'
  pull_request:
    branches:
      - "main"

jobs:
  docker:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Set TAG
        run: |
          if [[ "${{ github.event_name }}" == "pull_request" ]]; then
            TAG=$(echo "${{ github.head_ref }}" | tr '/' '-')
          else
            TAG=$(echo "${{ github.ref_name }}" | tr '/' '-')
          fi
          echo "TAG=${TAG}" >> $GITHUB_ENV
      - name: Docker meta
        id: meta
        uses: docker/metadata-action@v4
        with:
          images: ${IMAGE_NAME}
      - name: Login to DockerHub
        uses: docker/login-action@v3
        with:
          username: fugafuga
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - name: Build and push
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: "${{ env.IMAGE_NAME }}:${{ env.TAG }}"
          labels: ${{ steps.meta.outputs.labels }}

ちなみに

次のように、本番環境とステージング環境でイメージビルド時に使う変数だけ変更したい場合には、スクリプトで無理やり埋め込んだりしています。

  • Docker でマルチステージビルドを使っているのでイメージのビルドとコンテナの起動のステージが違う
  • NEXT_PUBLIC_* のようなビルド時に展開される変数を使っている
    • 本番環境とステージングで別の変数を使いたい

参考として yml はここに折りたたんでおきます。

タグごとに変数を指定する Docker イメージを生成するための yml ファイルです。

name: build-image-and-push-to-dockerhub

env:
  IMAGE_NAME: hogehoge

on:
  workflow_dispatch:
  push:
    branches:
      - main
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+*'
  pull_request:
    branches:
      - main

jobs:
  build-image:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repo
        uses: actions/checkout@v4
      - name: Set TAG
        run: |
          if [[ "${{ github.event_name }}" == "pull_request" ]]; then
            TAG=$(echo "${{ github.head_ref }}" | tr '/' '-')
          else
            TAG=$(echo "${{ github.ref_name }}" | tr '/' '-')
          fi
          echo "TAG=${TAG}" >> $GITHUB_ENV
      - name: Docker meta
        id: meta
        uses: docker/metadata-action@v4
        with:
          images: ${IMAGE_NAME}
      - name: Login to DockerHub
        uses: docker/login-action@v3
        with:
          username:  fugafuga
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      # 以下で変数を設定している
      - name: SET ENV for Dockerfile
        run: |
          if [[ "${{ github.event_name }}" == "pull_request" ]]; then
            TAG=$(echo "${{ github.head_ref }}" | tr '/' '-')
            NEXT_PUBLIC_EXAMPLE_ENV=http://example-staging.com
          else
            TAG=$(echo "${{ github.ref_name }}" | tr '/' '-')
            NEXT_PUBLIC_EXAMPLE_ENV=http://example.com
          fi
          echo "TAG=${TAG}" >> $GITHUB_ENV
          echo "NEXT_PUBLIC_EXAMPLE_ENV=${NEXT_PUBLIC_EXAMPLE_ENV}" >> $GITHUB_ENV
      - name: Build and push
        uses: docker/build-push-action@v4
        with:
          context: hogehoge
          file: ./docker/Dockerfile
          push: true
          tags: "${{ env.IMAGE_NAME }}:${{ env.TAG }}"
          labels: ${{ steps.meta.outputs.labels }}
          build-args: |
            NEXT_PUBLIC_EXAMPLE_ENV=${{ env.NEXT_PUBLIC_EXAMPLE_ENV }}

DockerfileARG コマンドと ENV コマンドで環境変数を外から指定できるようにしておく必要があります。

ARG NEXT_PUBLIC_EXAMPLE_ENV=http://example.com
ENV NEXT_PUBLIC_EXAMPLE_ENV=${NEXT_PUBLIC_EXAMPLE_ENV}
...

release-please.yml

Release Please の yml ファイルです。Version タグを打った Docker イメージも生成するようにしています。

name: release-please

env:
  IMAGE_NAME: hogehoge

on:
  push:
    branches:
      - main

permissions:
  contents: write
  pull-requests: write

jobs:
  release-please:
    runs-on: ubuntu-latest
    steps:
      - name: Run release-please
        id: release
        uses: google-github-actions/release-please-action@v3
        with:
          release-type: go
          package-name: "hogehoge"
      - name: Set TAG from release-please
        if: ${{ steps.release.outputs.release_created }}
        run: |
          echo "Release Tag - ${{ steps.release.outputs.tag_name }}"
          if [ -n "${{ steps.release.outputs.tag_name }}" ]; then
            echo "TAG=${{ steps.release.outputs.tag_name }}" >> $GITHUB_ENV
          else
            echo "TAG=latest" >> $GITHUB_ENV
          fi
      - name: Checkout for tag created
        if: ${{ steps.release.outputs.release_created }}
        uses: actions/checkout@v4
      - name: Docker meta for tag created
        if: ${{ steps.release.outputs.release_created }}
        id: meta
        uses: docker/metadata-action@v4
        with:
          images: ${IMAGE_NAME}
      - name: Login to DockerHub for tag created
        if: ${{ steps.release.outputs.release_created }}
        uses: docker/login-action@v3
        with:
          username: fugafuga
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - name: Build and push for tag created
        if: ${{ steps.release.outputs.release_created }}
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: "${{ env.IMAGE_NAME }}:${{ env.TAG }}"
          labels: ${{ steps.meta.outputs.labels }}

lint-PR-title.yml

プルリクのタイトルが Conventional Commits に従っているかチェックするための yml ファイルです。 https://github.com/dreampulse/action-lint-pull-request-title をそのまま使っています。

github.com

name: "Lint PR Title"
on:
  pull_request_target:
    types:
      - opened
      - edited
      - synchronize

jobs:
  main:
    runs-on: ubuntu-latest
    steps:
      - uses: dreampulse/action-lint-pull-request-title@master
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

auto-test.yml

テストを実行するための yml ファイルです。このあたりは各プロジェクトでいい感じに。

name: Lint, Test & Build

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main

jobs:
  lint-test-build:
    runs-on: ubuntu-latest
    steps:
      - name: Setup Go
        uses: actions/setup-go@v4
        with:
          go-version: ^1.21
      - name: Checkout code
        uses: actions/checkout@v4
      - name: Lint
        uses: golangci/golangci-lint-action@v3
        with:
          version: latest
          args: --timeout=5m
      - name: Test
        run: make test # make コマンドを使用してテストしている場合
      - name: Build 
        run: go build .

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 の理解がすすみました。

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