ローファイ日記

出てくるコード片、ぼくが書いたものは断りがない場合 MIT License としています http://udzura.mit-license.org/

PyRoute2 + PyCall.rb でネットワークインタフェースの操作をする

前回、BCCPythonバインディングで、TCにBPFを引っ掛けてegressのIPを表示した。

udzura.hatenablog.jp

ところで私はRubyBCCを開発しているわけだが、上記プログラムは一部iproute2的な操作をPythonで行うPyroute2に依存している。RubyにはPyroute2に相当するgemが開発されていないので動かせない。RbBCCの対象範囲はBPFプログラム作成側だけなので。

...と思っていたが、PyCall.rbというものの存在を思い出した(以下単にPyCallというときはRuby版)。PyCallは主にPython数値計算機械学習関連のライブラリを呼び出すときに使われている印象があるが、Pyroute2を呼び出してみる。

続きを読む

BCCでTCにアタッチし、egressのIPを表示する

サーバではいろいろな通信があるけれど、実際どんな通信が行われているか全貌を確認するのはなかなか難しい。

blog.ssrf.in

上記のソリューションはかっこいいが、KRSIなんで、「うちの環境のカーネルじゃちょっと...」となりがち。BPFあるあるだと思う。

一方でBPF自体は、カーネルでサポートされて随分時間が経ってきており、徐々に使えるカーネルが普及してきた。

とりあえず通信のログを残したい、というだけであればTCにBPFをアタッチする方法も使えて、これはUbuntu 20.04のカーネル(5.4 〜 5.8)なら十分使える。

// よく考えたらTCなのでブロックもできるのかな?

まあ今度調べるとして、egressの通信をキャプチャしてdst IP(今回はv4だけです)を集計するサンプルを残す。

動作環境(主にカーネルバージョン)について

今回は別の環境カーネル5.4でも動作確認しま。もう少し低くても行けるかもしれない。

しかしこのブログの環境は以下。

ubuntu@udzura.local:~$ uname -a
Linux kondo-ke-focal 5.8.0-63-generic #71~20.04.1-Ubuntu SMP Thu Jul 15 17:46:44 UTC 2021 aarch64 aarch64 aarch64 GNU/Linux

BCC の環境を作る

BCCといえば自作ビルド... という時代もあったが、今回はパッケージマネージャー経由のもので十分。

$ apt install libbpfcc python3-bpfcc python3-pyroute2

Ubuntu 20.04 では libbcc 0.12.0 が入ってくる。

自分の環境は全然違うディストリです!という場合はいっそ --host=netdebianを立ち上げてもいいと思う。

$ docker run -ti --rm -v /lib/modules:/lib/modules -v /usr/src:/usr/src --privileged --net=host debian:11-slim
~# apt update && apt install libbpfcc python3-bpfcc python3-pyroute2

多分動く。debian 11 では libbcc 0.18.0 が入ってくる。

Pythonスクリプト

いろいろな実装のつぎはぎだが、これを動かす。

from bcc import BPF, libbcc
from struct import unpack
import pyroute2
import time
import sys

code = """
// SPDX-License-Identifier: GPL-2.0+
#define BPF_LICENSE GPL

#include <linux/pkt_cls.h>
#include <uapi/linux/bpf.h>
#include <bcc/proto.h>

BPF_HASH(ip_counter, u32, u64);

int counter(struct __sk_buff *skb) {
    u32 key = 0;
    u8 *cursor = 0;

    struct ethernet_t *ethernet = cursor_advance(cursor, sizeof(*ethernet));
    // 0x0800 = IPv4
    if (ethernet->type != 0x0800)
        return TC_ACT_OK;

    struct ip_t *ip = cursor_advance(cursor, sizeof(*ip));
    key = ip->dst;

    if (key == 0)
        return TC_ACT_OK;

    // 192.168.0.0 ~ 192.168.255.255 のログは取らない
    // 他のプライベートIPレンジは宿題とします。
    if (3232235520 <= key && key <= 3232301055)
        return TC_ACT_OK;

    u64 zero = 0;
    u64 *value = ip_counter.lookup_or_try_init(&key, &zero);
    if (value)
        *value += 1;

    return TC_ACT_OK;
}
"""

# ライブラリを知らないので頑張って自作した
def int_to_ip(nr_):
    nr = unpack('I', nr_)[0]
    return '{}.{}.{}.{}'.format(
                (nr >> 24 & 0b11111111),
                (nr >> 16 & 0b11111111),
                (nr >> 8  & 0b11111111),
                (nr       & 0b11111111)
            )

def count(nr_):
    return unpack('L', nr_)[0]

def count_packet(iface):
    bpf = BPF(text=code, cflags=["-w"])
    func = bpf.load_func("counter", BPF.SCHED_CLS)
    counter = bpf.get_table("ip_counter")
    
    print("map fd: {}".format(counter.map_fd))
    ip = pyroute2.IPRoute()
    ipdb = pyroute2.IPDB(nl=ip)
    idx = ipdb.interfaces[iface].index
    ip.tc("add", "clsact", idx)

    # ffff:fff3 というのが、 egress のことらしい。
    # ingress なら ffff:fff2 らしい。
    ip.tc("add-filter", "bpf", idx, ":1", fd=func.fd, name=func.name,
          parent="ffff:fff3", classid=1, direct_action=True)

    print("Hit CTRL+C to stop")
    while True:
        try:
            print("outgoing dest ips in 1s:")
            for k, v in counter.items():
                print(f'k: {int_to_ip(k)}, v: {count(v)}')
            counter.clear()
            time.sleep(1)
        except KeyboardInterrupt:
            print("Removing filter from device")
            break
    ip.tc("del", "clsact", idx)
    ipdb.release()

if __name__ == '__main__':
    iface = sys.argv[1]
    count_packet(iface)

※ ところで拙作 RbBCC ではこれと同じことができない。libbccのFFIなので本当はできるはずだが、pyroute2のRuby版がないのでBPFプログラムを作れてもアタッチできない。pyroute2は巨大なので流石に僕は移植する時間が...。

pyroute2がRubyにあると嬉しいなあという願いだけ残そう。

tcにアタッチする

上記をsudoで実行するとデバイスにアタッチできる。

$ sudo python3 filter.py enp0s1
map fd: 4
Hit CTRL+C to stop
outgoing dest ips in 1s:
outgoing dest ips in 1s:
outgoing dest ips in 1s:
outgoing dest ips in 1s:

この状態で、 enp0s1 を通って外に行くような通信をすると、ちゃんと記録されることがわかる。

$ ping hatena.ne.jp
PING hatena.ne.jp (54.65.114.10) 56(84) bytes of data.
64 bytes from ec2-54-65-114-10.ap-northeast-1.compute.amazonaws.com (54.65.114.10): icmp_seq=1 ttl=232 time=36.1 ms
64 bytes from ec2-54-65-114-10.ap-northeast-1.compute.amazonaws.com (54.65.114.10): icmp_seq=2 ttl=232 time=32.1 ms

...

outgoing dest ips in 1s:
k: 54.65.114.10, v: 1
outgoing dest ips in 1s:
k: 54.65.114.10, v: 1
...

今回は雑然とMapに保存しているが、定期的に取り出して監査ログにするなり、怪しい通信を確認するなりやろうと思えばできそうかな?

トラブルシュート

tc にアタッチする前に、以下のコマンドで現在何もクラスがないことが確認できる。

$ tc qdisc show dev enp0s1 clsact
qdisc fq_codel 0: root refcnt 2 limit 10240p flows 1024 quantum 1514 target 5.0ms interval 100.0ms memory_limit 32Mb ecn

上のスクリプトの実行が失敗すると、中途半端なクラスやフィルターが残る。

$ tc qdisc show dev enp0s1 clsact
qdisc fq_codel 0: root refcnt 2 limit 10240p flows 1024 quantum 1514 target 5.0ms interval 100.0ms memory_limit 32Mb ecn 
qdisc clsact ffff: parent ffff:fff1 <- これ

削除は以下のコマンドで。

$ sudo tc qdisc del dev enp0s1 clsact

(実はこの記事の主題こと)宣伝

RubyKaigi 2022 で BPF の話をします。と言いつつこの記事でしているネットワーク関係の機能の話はしなくて、Observability中心です。

rubykaigi.org

思えば2年以上前にとりあえず完成させたものですが、RbBCCの話中心に色々やっていくような気がします。正直、現場で運用にも携わる人としては、CO-REが普通に使えるカーネルに全部置き換えよう! ってすぐにはいかないので、全然BCC使っていこうと思っていますから...。

udzura.hatenablog.jp

今年はハイブリッド開催だそうです。できる形で是非ご参加くださいませ。赤福

(今気づいたけど1行もRubyのコードがない記事で宣伝してるw)

参考にしたページ

atmarkit.itmedia.co.jp

yunazuno.hatenablog.com

github.com

RustによるRubyアプリケーションサーバー Flamboyant: version 0.1.0.rc

「なんとか動かせた」という世界の話なんですが、途中経過をメモしておきます。

先日Rustでgemをなるべくイマい形で書く方法をメモっていたんですが、一つは実用的なものを作ろうと思ってサーバを書いています。

udzura.hatenablog.jp

Flamboyant という名前をつけました。

github.com

RubyGems 3.4.0.dev (今のgithub masterにあるやつ)を使っていればRuby自体は3系であれば動くようです。インストールは:

gem install flamboyant --pre

あるいはGemfileに:

# 2022/06/11 現在
gem "flamboyant", "0.1.0.rc2"
続きを読む

Fukuoka.rb #256 LTの補足: RustでRubyGemを書く話

fukuokarb.connpass.com

docs.google.com

キリがいい回で話しました。およそLTで収まる話ではなかった(5分、一番難しい)ため、最も肝心の「どうやってコードを書くか」の部分を全部端折ってこのブログで補足することにしました。


サンプルプロジェクト

github.com

ここを読み込めば大体書けるようになるのでは、という感じなのですが、書く際の留意点をつらつら残します。

(5月初旬現在)事前にそもそも最新のRubyGemsをインストールする必要がある

色々方法があるんですが、10年前の方法を思い出すために ruby setup.rb を叩く方法を使いました。逆にRubyGemsさえ最新ならRubyを3.2まで上げなくて良い? と思う。

rbenv shell 3.2.0-preview1
git clone --depth=1 https://github.com/rubygems/rubygems.git
cd rubygems
ruby setup.rb

プロジェクトの初期化

gemとしてもlib crateとしてもvalidなディレクトリ構成にすればOKです。あと、拡張gemの名前で - を含めると難儀します(自動で読み込むC関数名に - を含もうとするため)ので気をつけましょう。

bundle gem oye_rust
cd oye_rust
cargo init --lib

ちなみにこれで生成されたプロジェクトは Cargo.lock がgitignoreされていますが、gemとしてはこのファイルは必要なのでgitの管理対象にしましょう。

このあと Cargo.toml を更新する必要があり、パッチ作者が作ったプロジェクトが参考になりますが、罠があって rb-sys を使うときは features = ["link-ruby"] を指定していないと私の環境(Apple Silicon の Macです)では読み込み時に関数が見つからないなどと言われました。この辺は怪しい(これだとlibrubyを二重にリンクしてね?とか)感じもしますが、きっと誰かが精査してくれるということで...。

crate-type = ["cdylib"] になるというのもポイントです。

[package]
name = "oye_rust"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
libc = "0.2.125" # どうせ後で使うので宣言しています
rb-sys = { git = "https://github.com/ianks/rb-sys", branch = "main", features = ["link-ruby"] }

一度 cargo build して Cargo.lock を更新しましょう。

Rust側実装

rb-sys は基本的にruby.hからbindgenで生成した関数群なので、ほぼ全部unsafeです。頑張りましょう。逆にいうとRustでただのFFIのコードを書くだけとも言えます(補完も効きますし)。

コードを見てあとはコンパイラ耳をすませば書けると思うんですが、渋いかもポイント*1をいくつか書いておくと、

マクロが基本使えないので、 Qnil/Qtrue/QfalseRSTRING_LEN() やとにかく色々使えない

ここはbindgenの制限だと思います。抽出されている定数もあるのでbindgenでの運用がどうだったかもうちょい調べようとは思いますが...。

Qnil について言えばは実態はenumなのでそっちを直接使えば大丈夫です。

#[repr(u32)]
#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)]
pub enum ruby_special_consts {
    RUBY_Qfalse = 0,
    RUBY_Qtrue = 20,
    RUBY_Qnil = 8,
    RUBY_Qundef = 52,
    RUBY_IMMEDIATE_MASK = 7,
    RUBY_FIXNUM_FLAG = 1,
    RUBY_FLONUM_MASK = 3,
    RUBY_FLONUM_FLAG = 2,
    RUBY_SYMBOL_FLAG = 12,
}

しかしこれはこれで別の渋いポイントが。

型のサイズに厳しい

上記のように Qnil/Qtrue/Qfalse の大きさは本来 32bit ですが、筆者の環境(M-1 Mac)では VALUE は64bitです。なので以下のコードはコンパイルできません。

use rb_sys::ruby_special_consts;
let qnil = ruby_special_consts::RUBY_Qnil;
std::mem::transmute::<ruby_special_consts, VALUE>(qnil);

ひとまずこういう構造体を挟んで回避しました。これは as u64 でもいいのかな、試していませんが、エンディアンのことを考えると言い切れない(試そうね)...。

#[repr(C)]
struct WrappedValue {
    value: u32,
    padding: u32,
}

let qnil = WrappedValue {
    value: ruby_special_consts::RUBY_Qnil as u32,
    padding: 0,
};
std::mem::transmute::<WrappedValue, VALUE>(qnil)

VALUEruby_special_constsのサイズは当然フラグや環境で変わるということでしょうので*2、厳密にはRust側でも #[cfg] などで切り替えるべきでしょう。

メソッドに対応する関数は、引数を持つ場合基本的に std::mem::transmute() で置き換えることになる

Rustにおける関数ポインタ型は、引数と戻り値の型がセットでそれぞれ別の型として認識されます。

しかしCRubyでは引数に何個VALUE型を取っても問題なくメソッド定義として利用できる関数ポインタ型として扱われます。

Rustの表現で言うと、 fn() -> VALUE 型でも fn(VALUE) -> VALUE 型でも fn(VALUE, VALUE) -> VALUE 型でもvalidであり、 rb_define_method() の引数に使えます。

従ってC言語のノリで関数を書いていた場合、Rustではこれもtransmute()が必要になります。

#[no_mangle]
unsafe extern "C" fn test_meth(rbself: VALUE) -> VALUE {
    ....
}

#[no_mangle]
pub extern "C" fn Init_test() {
    // ...
    let function_name = CString::new("test").unwrap();
    let callback = unsafe {
        std::mem::transmute::<unsafe extern "C" fn(VALUE) -> VALUE, unsafe extern "C" fn() -> VALUE>(
            test_meth,
        )
    };

    unsafe { rb_define_method(klass, function_name.as_ptr(), Some(callback), 0) }
}

引数は原則 rb_get_args() のような仕組みで取り扱うのも手かと思います*3

雑感

Rustで拡張gemを書くにしても libruby の関数を使う必要があるわけで、それを現状提供されている rb-sys crate 経由で利用しようとすると unsafe 祭りになるのが現状です。Rustの安全性とは...という気持ちになる。

しかしそれでも静的検査してくれるところは強力だし、他ツールやライブラリ(ことパッケージマネージャ)が整っているなど生産性はかなり上がるのですが。

一方でストレスなくRustでgemを書きたいなら、 nix crate のようなノリでRubyGemを書くための薄いラッパーcrateがあるといいと思います。OSSチャンスだ!

まだほぼ中身はないですが名前を思いついたので押さえておくテスト。一応最低限は書きました。

このcoffretを使っていないとこういう感じで、

#[allow(non_snake_case)]
#[no_mangle]
pub extern "C" fn Init_oye_rust() {
    let name = CString::new("Rust").unwrap();
    let object = unsafe { rb_cObject };
    let klass = unsafe { rb_define_class(name.as_ptr(), object) };

    let function_name = CString::new("mostrarme").unwrap();
    let callback = unsafe {
        std::mem::transmute::<unsafe extern "C" fn(VALUE) -> VALUE, unsafe extern "C" fn() -> VALUE>(
            test_show_self,
        )
    };

    unsafe { rb_define_method(klass, function_name.as_ptr(), Some(callback), 0) }
}

coffretを使うととりあえずこういう感じにできました。もっと綺麗にできると思います。

use coffret::class;
use coffret::exception;

fn init_oye_rust_internal() -> Result<(), Box<dyn Error>> {
    let object = class::object_class();
    let klass = class::define_class("Rust", object);

    let callback = class::make_callback(&test_show_self);

    class::define_method(klass, "mostrarme", callback, 0);
    Ok(())
}

#[allow(non_snake_case)]
#[no_mangle]
pub extern "C" fn Init_oye_rust() {
    match init_oye_rust_internal() {
        // これはRustのErrorをRubyの例外として上げるというかっこいい関数
        Err(e) => exception::rustly_raise(e.as_ref()),
        Ok(_) => {}
    }
}

雑感

個人的にはRubyGemsが公式対応したことで、妙なハックをせずにgemとしてもcrateとしてもvalidなプロジェクトを作ればそれが使えるようになるので、楽になったなあと思いました。色々アイデアが思い浮かびます。

*1:LTでも話すかもですが

*2:その辺のルールは流石に把握していないんですが

*3:c.f. mrubyは完全にそっちに寄せてる

archof: リモートのままイメージのarchitectureを確認するコマンド

背景

Apple SiliconのMacが販売されてそれなりの時間も経ち、エコシステムもできてきてお仕事の開発に使っている人も増えてきたように思う。

ところで、colimaのようなツールを使い、Docker環境をMacに作成した時、Apple Silicon上ではデフォルトで arm64 版のDocker*1がインストールされる。

$ docker info | grep Archi
 Architecture: aarch64

この上で何も考えずにイメージをビルドしたら arm64 のイメージができる。

この時、このイメージをリモートのレジストリGCRGHCRなどを想像してほしい)にpushにしたら、もちろん arm64 のイメージがpushされる。

ここで、このイメージをx86_64の本番サーバにpullして走らせるような運用だったら、困ったことになるだろう...。

このような違うアーキテクチャのイメージをうっかりプッシュしてしまう事故は、意外と防ぐ仕組みが簡単ではない*2

さらに言うと、pushされたイメージのアーキテクチャがなんであるかを確認するのも意外と骨が折れる。pullしてしまえば docker inspect なりで確認すればすぐわかるが、何度もプッシュしていてそのうちいくつかに混ざってしまったような場合とか、そもそもイメージが大きい場合とかは難しいこともありそう。

と言うことで Docker Registory API を叩いてリモートから確認するコマンドを作った。

github.com

インストール

$ go install github.com/udzura/archof

Go 1.17 で動作確認しています。多分1.18などでも問題なさそう。

様子

$ archof docker.io/amd64/ubuntu:latest
amd64

$ archof docker.io/arm64v8/ubuntu:latest 
arm64

認証が必要なやつはBearer認証に対応しているので、たとえばGCRだとこう言う感じで使えます。

$ archof gcr.io/udzura-dev/sample:latest --bearer "$(gcloud auth print-access-token)"                                                                     
amd64

内部

マニフェストを取得して、そこからConfigのDigestを取得してBlobを取得すればいいんですが、今回利用した go-containerregisrty はBlobの方のAPIをどう叩けばいいかよくわからず、URLを自分で構築して直接叩くことになった。いい方法があるんだろうか。

ref, _ := name.ParseReference(target)
desc, _ := remote.Get(ref, remote.WithAuth(&authn.Bearer{Token: token}))

reg := ref.Context()
url := fmt.Sprintf("%s://%s/v2/%s/blobs/%s",
    reg.Scheme(),
    reg.RegistryStr(),
    reg.RepositoryStr(),
    sha,
)
res, _ := desc.Client.Get(url)

type BlobResponse struct {
    Architecture string `json:"architecture"`
}
b := BlobResponse{}

if err := json.NewDecoder(res.Body).Decode(&b); err != nil {
    panic(err)
}

fmt.Println(b.Architecture)

雑感

Go の思い出しも兼ねて作ったが、特に変わったことをしていないので思い出せたのかどうか。

あとコマンドの名前の主語が大きいかも?(対象はコンテナイメージだけなのに) と思ったが、覚えやすいしえいやと付けました。ご了承ください。

とりあえず不安になった時にお使いください。というかpush防ぐ方法ってないんですかね...。

*1:正確にはLinuxVMイメージ

*2:いい方法があれば知りたい、というのもこのブログを書いたモチベーションの一つ