Linux
LinuxDay 18

Linux 4.13で導入されたULPを使ってTCPのペイロードを透過的に圧縮する

はじめに

このエントリは Linux Advent Calendar 2017 の18日分の記事です。

Linux 4.13で カーネル内TLS (KTLS) がマージされました。カーネルでTLSを行なうことのモチベーションは、例えばsendfileを使ってデータを送信するときに、ユーザランドにデータをコピーすることなく暗号化することなどがあります。詳細は LWNの記事netdev 1.2のプレゼンテーション を参照してもらうとして、この記事ではKTLSがマージされるときに一緒にマージされた ULP (Upper Layer Protocol)について扱います。

ULPとは?

ULPとは、ソケットとレイヤ4(現在はTCPのみ)の間にプロトコルを追加するためのフレームワークです。KTLSの場合は、ソケットからTCPにデータを渡す前にTLSの暗号化を行なっています。

カーネルモジュールとして実装可能なので、わりと簡単に簡単にプロトコルを追加することができます。(実装が簡単かどうかはプロトコル次第ですが。)

ユーザプログラムにはほとんど修正は必要なく、追加されたプロトコルを使うかどうかをsetsockoptを指定するだけです。

KTLSの場合は大体以下のような手順で通信を行ないます(Documentation/networking/tls.txtから抜粋)。

sample-tcp_ulp-tls.c
    sock = socket(AF_INET, SOCK_STREAM, 0);
    // Tell to use ktls
    setsockopt(sock, SOL_TCP, TCP_ULP, "tls", sizeof("tls"));
    // Set parameters through crypto_info
    setsockopt(sock, SOL_TLS, TLS_TX, &crypto_info, sizeof(crypto_info));
    const char *msg = "hello world\n";
    // Send a message
    send(sock, msg, strlen(msg));
    // Send a file using sendfile
    file = open(filename, O_RDONLY);
    fstat(file, &stat);
    sendfile(sock, file, &offset, stat.st_size);

1つ目のsetsockoptで"tls"という名前のプロトコルを使うことを指定して、2つ目のsetsockoptでTLSに必要なパラメタを設定してます。(crypto_infoの初期化は省略しています。) かなり簡単に使えることがわかると思います。

ULPの初期化

通信の処理は個々のプロトコルに依存して、あまりULPは関係ないため、ULPを使うための初期化のコードだけを見てみます。以下はKTLSのコードの一部です。

net/tls/tls_main.c
static int __init tls_register(void)
{
        tls_base_prot                   = tcp_prot;
        tls_base_prot.setsockopt        = tls_setsockopt;
        tls_base_prot.getsockopt        = tls_getsockopt;

        tls_sw_prot                     = tls_base_prot;
        tls_sw_prot.sendmsg             = tls_sw_sendmsg;
        tls_sw_prot.sendpage            = tls_sw_sendpage;
        tls_sw_prot.close               = tls_sk_proto_close;

        tcp_register_ulp(&tcp_tls_ulp_ops);

        return 0;
}

static void __exit tls_unregister(void)
{
        tcp_unregister_ulp(&tcp_tls_ulp_ops);
}

module_init(tls_register);
module_exit(tls_unregister);

モジュールがロードされるとtls_registerが呼ばれます。ここではKTLSで使う関数を用意します。

tls_setsockoptは先程の例でいう2つ目のsetsockoptで呼ばれることになります。sendmsgsendpageは名前から推測できる通りソケットにsendsendfileしたときに呼ばれる関数を指定しています。

net/tls/tls_main.c
static int tls_init(struct sock *sk)
{
        struct inet_connection_sock *icsk = inet_csk(sk);
        struct tls_context *ctx;
        int rc = 0;

        /* allocate tls context */
        ctx = kzalloc(sizeof(*ctx), GFP_KERNEL);
        if (!ctx) {
                rc = -ENOMEM;
                goto out;
        }
        icsk->icsk_ulp_data = ctx;
        ctx->setsockopt = sk->sk_prot->setsockopt;
        ctx->getsockopt = sk->sk_prot->getsockopt;
        sk->sk_prot = &tls_base_prot;
out:
        return rc;
}

static struct tcp_ulp_ops tcp_tls_ulp_ops __read_mostly = {
        .name                   = "tls",
        .owner                  = THIS_MODULE,
        .init                   = tls_init,
};

tls_initは先程の例の1つ目のsetsockoptで呼ばれます。

キモはsk->sk_prot = &tls_base_prot;で、ここでレイヤ4の関数(TCPの場合はtcp_sendmsgなどをKTLSのもので置き換えています。これ以降、ソケットで通信を行なうとまずKTLSの関数が呼ばれることになります。

setsockoptgetsockoptを保存しているのは、オリジナルの挙動にフォールバックさせるときに使うためです。

ULPを使ってTCPペイロード透過的圧縮機能(プロトコル)を実装してみる

せっかくなのでULPを使って独自プロトコルを実装してみました。プロトコルといっても複雑なものではなく、単にソケットから渡されたデータを勝手に圧縮してヘッダを付加して送信するだけです。

作成したソースコードはgithubに置いておきました → linux-playground/ulp-comp.c

送信関数

ULPはTCPの送信関数を横取りする形になるので、ソケットから渡される引数を以下のように元の送信関数(tcp_sendmsg)にそのまま渡してやれば、とりあえず(単なるTCPとして)データを送信できます。

int comp_sendmsg(struct sock *sk, struct msghdr *msg, size_t size)
{
        struct comp_context *ctx = comp_get_ctx(sk);
        return ctx->sendmsg(sk, msg, total);
}

送信バッファ

ユーザのデータはstruct msghdrの先にあるstruct iovecのリストになって渡されます。

struct iovecが持つバッファのまま圧縮はできないため、圧縮後のデータを保存するためのバッファを用意します。データ構造はstruct iovecのカーネル版struct kvecです。バッファはヘッダの分を余計に確保します。

struct kvec *comp_clone_iov(struct iov_iter *iter, int *nvecs)
{       
        struct kvec *kvec;
        struct iov_iter _iter;
        struct iovec iov;
        int i;

        kvec = kcalloc(iter->count, sizeof(*kvec), GFP_KERNEL);

        i = 0;
        iov_for_each(iov, _iter, *iter) {
                unsigned long len = iov.iov_len + COMP_HDRSIZE;

                kvec[i].iov_base = kmalloc(len, GFP_KERNEL);
                kvec[i].iov_len = len;
                i++;
        }
        *nvecs = i;

        return kvec;
}

圧縮

データの圧縮にはlib/zlib_deflate/にあるzlibライブラリを使います。
時間がなかったため、コードはほぼnvram_compressのコピーになっています。

圧縮元のバッファはiovecで圧縮先のバッファはkvecにします。

データフォーマット

圧縮したデータだけを送信しても、受信側でデータ境界がわからなかったり、伸長後のバッファサイズをどれぐらい確保すれば良いかわからない等の問題があるので、圧縮したデータの前に元のデータサイズと圧縮後のデータサイズをつけてTCPに渡します。

データサイズが小さいときに圧縮してもあまり意味がないので、64バイト未満のデータは圧縮せずにそのまま渡します。その場合もヘッダは付けます。

圧縮しつつkvecを作成する辺りのコードは以下のようになります。圧縮が必要な場合はcomp_compressを使いデータを圧縮し、そうでなければcopy_from_userでそのままコピーしています。

    i = 0;
    iov_for_each(iov, iter, msg->msg_iter) {
        unsigned long len = iov.iov_len;
        struct comp_hdr *hdr;

        hdr = (struct comp_hdr *) kvec[i].iov_base;
        hdr->olen = htonl(len);

        /* Compress the payload only if it's big enough */
        if (len >= COMP_MINIMUM_SIZE) {
            int clen;
            clen = comp_compress(iov.iov_base, hdr->payload, len, len);
            if (clen < 0) {
                ret = clen;
                goto error;
            }
            hdr->clen = htonl(clen);
            kvec[i].iov_len = clen + COMP_HDRSIZE;
        } else {
            hdr->clen = htonl(len);
            if (copy_from_user(hdr->payload, iov.iov_base, len)) {
                ret = -EFAULT;
                goto error;
            }
            kvec[i].iov_len = len + COMP_HDRSIZE;
        }
        total += kvec[i].iov_len;

        i++;
    }

送信

先程述べたように、データはstruct msghdrでTCPに渡されます。ソケットから渡されるmsghdrを複製しても良いですが、iovecの部分だけを先程用意したkvecに置き換えます。

        msg->msg_iter.kvec = kvec;
        msg->msg_iter.type = ITER_KVEC;
        msg->msg_iter.count = total;

この方法が良いかどうかはわからないですが、とりあえず動きます。

受信

送信と同じようにmsghdrioveckvecに置き換えたものをTCPに渡してデータを受信します。

現在の実装では複数のデータがくっついたり分断されているケースは考慮されていません。普通はまずヘッダだけ受信して圧縮後のサイズを把握した後、その分のデータがくるまで何度から受信を繰り返すといった処理が必要になるはずです。今回は時間がなかったため、データが1つだけ完全な形で送られてくるケースのみ対応しています。

安直に上記のような処理を書くのも良いですが、こういうときこそ KCM の出番だと思います。KCMを使うと下位層で指定した形のデータが完成した後、上位層にデータを渡してくれます。そのうちやるかもしれません。

プログラムを試してみる

上記レポジトリをcloneして以下のように実行すると動作を試すことができます。

$ make
$ sudo insmod ulp-comp.ko
$ python3 recv.py
# 別の端末上で実行する
$ python3 send.py 64  # 送りたいデータサイズを指定する

send.pyを読むとわかりますが、実装したプロトコルを使う時は以下のように"comp"というプロトコル名を指定しています。

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
s.setsockopt(socket.SOL_TCP, 31, "comp".encode())

おわりに

今回は新しく導入されたULPを紹介し、それを使ってオレオレプロトコルを実装してみました。みなさんも面白プロトコルを実装してみてはいかがでしょうか。