brd: メモリ上に作成するブロックデバイス

はじめに

本記事はlinuxにおいてメモリ上に作成するブロックデバイスであるbrdというカーネルの機能について説明します。

用途

ブロックデバイスを直接しようする各種ファイルシステムやDB、partedなどのパーティショニングツールのテストが主な用途でしょう。とくに次のような場合には便利でしょう。

  • バックエンドがストレージデバイスであるときには時間がかかる加速試験の実行を高速化したい
  • 巨大なブロックデバイスが扱えるかを確認したい(後述するようにbrdにはブロックデバイスのサイズ制限が事実上無い)

使い方

機能が使えるかどうかの確認

まずはお使いのカーネルでbrdがサポートされているかを確認する必要があります。以下コマンドを実行した結果、"CONFIG_BLK_DEV_RAM=[ym]"という行が出てくれば使えます。そうでなければ当該機能が有効になっているカーネルを用意する必要があります(カーネルビルドについては本記事の対象外です)。

$ grep "^CONFIG_BLK_DEV_RAM=" /boot/config-$(uname -r)

例えばUbuntu 16.04のカーネルにおいてはモジュールとして提供されています(CONFIG_BLK_DEV_RAM=m)。

これ以降はbrdがモジュールとして提供されているとして話をします。

初期化方法

次のコマンド発行によってbrdを初期化します。

$ sudo modprobe brd

成功した場合は次のように/dev/ram*というブロックデバイスができます。

$ ls /dev/ram*
/dev/ram0  /dev/ram1  /dev/ram10  /dev/ram11  /dev/ram12  /dev/ram13  /dev/ram14  /dev/ram15  /dev/ram2  /dev/ram3  /dev/ram4  /dev/ram5  /dev/ram6  /dev/ram7  /dev/ram8  /dev/ram9

使用

/dev/ram*は全て通常のブロックデバイスと同様に使えます。たとえば以下は/dev/ram0上にBtrfsを作成する例です。

$ sudo mkfs.btrfs /dev/ram0
btrfs-progs v4.13.1
See http://btrfs.wiki.kernel.org for more information.

Performing full device TRIM /dev/ram0 (1.00GiB) ...
Label:              (null)
UUID:               638d68c2-ff24-44f2-9789-37309ba8cce7
Node size:          16384
Sector size:        4096
Filesystem size:    1.00GiB
Block group profiles:
  Data:             single            8.00MiB
  Metadata:         DUP              51.19MiB
  System:           DUP               8.00MiB
SSD detected:       no
Incompat features:  extref, skinny-metadata
Number of devices:  1
Devices:
   ID        SIZE  PATH
    1     1.00GiB  /dev/ram0

$ 

設定変更方法

brdが作成するデバイスの数、およびサイズはカーネルビルド時の設定によって異なります。たとえばUbuntu 16.04の場合はデバイスの数は16個、デバイスのサイズは64MBです。それぞれrd_nr, rd_rd_sizeというモジュールパラメタによって変更可能です。rd_sizeはKB単位ですデバイスの数を4個、デバイスのサイズを1GBにする例を示します。

$ sudo modprobe brd rd_nr=4 rd_size=$((1024*1024))
$ ls /dev/ram*
/dev/ram0  /dev/ram1  /dev/ram2  /dev/ram3       # デバイス数は4
$ LANG=C sudo parted /dev/ram0 p
Error: /dev/ram0: unrecognised disk label
Model: Unknown (unknown)
Disk /dev/ram0: 1074MB                           # サイズは1GB
Sector size (logical/physical): 512B/4096B
Partition Table: unknown
Disk Flags:

rd_sizeはシステムに搭載したものよりも大きいものを設定できます。brdは初期化時にデバイスに必要なメモリをすべて確保するのではなく、オンデマンドにメモリを獲得します。具体的には、内部的にはデバイスの領域をページ(x86_64の場合は4KB)単位に区切って管理しており、各ページに対して最初にアクセスしたときにページごとにメモリを獲得します。

終了方法

brdが作ったデバイスが不要になったら次のコマンドによってモジュールを削除します。このときbrdが使用していたメモリを解放します。

$ sudo modprobe -r brd

他の類似機能との違い

tmpfsとの違い

brdとtmpfsはメモリベースであることは同じなのですが、tmpfsはユーザにファイルシステムを提供するのに対してbrdはブロックデバイスを提供するというところが違います。つまりレイヤが異なります。

ファイルのループバックマウントとの違い

何らかのファイルシステム上に作成したファイル上にファイルシステムを作成してループバックマウントすることによって、ファイルシステムのテストはできます。sparseファイルを作れば大容量ファイルシステムも作成できます。しかしループバックマウントにおいてはファイルシステムにアクセスするごとにストレージの容量を消費してしまうという点が異なります。

おわりに

開発状況については、brdはかなり枯れた機能であり、2,3か月に一度のlinuxのバージョンアップごとに数個パッチが入る程度です。

機能がシンプルなせいもあり、brdのソース(drivers/block/brd.c)はたったの600程度なので、ブロックデバイス層の最も単純な実装を理解したいというかたは見てみると面白いでしょう。

Goでdirect I/O

はじめに

本記事はGoでlinuxのdirect I/Oを使う方法について書いたものです。A Tour of Goを全部終えた人なら読めると思います。

ソフトウェアバージョン

  • OS: Ubuntu 16.04
  • kernel: 4.4.0-83-generic
  • go: 1.9

きっかけ

Linuxのdirect I/Oは、read/write時用に使うバッファの先頭アドレスおよびサイズを、ストレージのブロックサイズの倍数(通常512バイトないし4096バイト)にアライメントする必要があります。Goの標準ライブラリ内には次のようにO_DIRECTが定義されていますが、この条件に合致するバッファをどうやってとるのかが気になったので調べました。

const (
...
        O_DIRECT                         = 0x4000
...

結論

仕様上direct I/Oの要求を満たすバッファを獲得できないため、自前でunsafeパッケージを使ってバッファをアライメントしたり、後述のdirectioパッケージなどを使うなりする必要がありそうです。

仕様の確認

仕様上は次のように、配列のアライメントは、配列の各要素のアライメント以上のものが保証されていませんでした。

The following minimal alignment properties are guaranteed:

* For a variable x of any type: unsafe.Alignof(x) is at least 1.
* For a variable x of struct type: unsafe.Alignof(x) is the largest of all the values unsafe.Alignof(x.f) for each field f of x, but at least 1.
* For a variable x of array type: unsafe.Alignof(x) is the same as the alignment of a variable of the array's element type.

それに加えて、アライメントを考慮したメモリアロケーションの仕組みが標準ライブラリにあるかどうかを確認しましたが、こちらも該当するものはなかったです。

directioライブラリ

前節で述べた通り標準ではdirect I/Oに必要なバッファをとる確実な方法が無いのですが、上述したdirect I/O用のライブラリ、directioをweb上で見つけました。

directioライブラリはunsafeライブラリを活用して、バッファの獲得時に内部的に余分な領域をとることによって、direct I/O用バッファの先頭アドレスをアライメントしています。

// alignment returns alignment of the block in memory
// with reference to AlignSize
//
// Can't check alignment of a zero sized block as &block[0] is invalid
func alignment(block []byte, AlignSize int) int {
    return int(uintptr(unsafe.Pointer(&block[0])) & uintptr(AlignSize-1))
}

// AlignedBlock returns []byte of size BlockSize aligned to a multiple
// of AlignSize in memory (must be power of two)
func AlignedBlock(BlockSize int) []byte {
    block := make([]byte, BlockSize+AlignSize)
    if AlignSize == 0 {
        return block
    }
    a := alignment(block, AlignSize)
    offset := 0
    if a != 0 {
        offset = AlignSize - a
    }
    block = block[offset : offset+BlockSize]
...

glibcposix_memalign()も内部的には似たようなこと(だいぶ複雑ですが)をしています。興味のあるかたはglibcのソースを見てください。

おわりに

unsafeライブラリやdirectioライブラリを使わない、もっといい方法を知っているかたがいれば教えてほしいです。

Windows Subsystem for Linuxとguest/native Ubuntuの性能をざっくりと比較

(2017年12月25日注) 本記事より新しいバージョンのWindowsについての類似記事を書きましたので、本記事のかわりにそちらをごらんください。

はじめに

わたしの手持ちのノートPCにはWindows10が入っています。もっぱらWindows Subsystem for Linux(WSL, いわゆるbash on Ubuntu on Windows)上で作業をしています。次のような小規模なプログラムの実行については全く問題無く使えており、WSLが無かったころに比べると、Windows端末を使った開発が非常に楽になりました。

  • ちょっとしたプログラムの動作確認
  • sshでリモートの開発マシンにログイン
  • 小規模ソースの読解(タグジャンプ、grep検索、git操作など)

その一方で、次のような操作においては、そこそこのスペックのマシンのはずなのに、やや「もっさり感」を感じることがあります。

  • 長いshell script。./configureのようにプロセス実行ごとにログが出力される場合は、ログを目で追えるほどになることも
  • 大規模ソース(具体的には数GBの大きさになるlinuxなど)の読解(タグジャンプ、grep検索、git操作など)。全てのデータがページキャッシュに乗る程度の大きさなのに、やけに遅く感じる

そこで、次の3つの環境の性能測比較によって、上記のような場合に何が性能ネックになっているかをざっくりと確かめました。

  • Windows10(バージョンは1703, OSビルドは15063.540)上のWSL
  • 上記マシン上のVirtual Box guestのUbuntu16.04(以下guest Ubuntuと記載)
  • 別のnativeマシン上のUbuntu 16.04(以下native Ubuntuと記載)1

それぞれのマシンにおいて性能測定時に性能測定プログラム以外の負荷はほぼかかっておらず、かつ、I/Oに関する測定においては、データは全てページキャッシュに乗るほどの空きメモリがあります。

マシンスペック

名前 WSL guest Ubuntu native Ubuntu
カーネル 4.4.0-43-Microsoft 4.10.0-33-generic 4.10.0-27-generic
CPU core m3-6Y30 1VCPU切り出し Ryzen 1800X
RAM 8GB 2GB 32GB
storage SanDisk X300S SD7TB3Q-256G-1006(256GB: read 520MB/s, write 470MB/s) Crucial CT275MX300SSD1(256GB: read 530MB/s write 500MB/s)
filesystem lxfs btrfs btrfs

WSLとnative Ubuntuの比較においては両者のスペックがかなり異なること、および後述の性能測定方法の精度が粗いことより、両者の性能値を厳密に比較してもあまり意味がありませんので、ざくっとした比較しかしていません(それしかできません)。どの程度の精度での比較が妥当かというのは本文中で適宜説明をします。

プロセス/スレッド生成コスト

次のようなプログラムを使ってプロセス/スレッド生成時のコストを採取しました。

#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>
#include <pthread.h>
#include <time.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <err.h>

char *progname;
static int concurrency;

static void show_usage(void)
{
        fprintf(stderr, "usage: %s <p|t> <n>\n"
                "\tmeasure process/thread creation time.\n"
                "\t<p|t>: 'p' for process and set 't' for thread\n"
                "\t<n>: concurrency\n",
                progname);

        exit(EXIT_FAILURE);
}

static void measure_process_creation_time(void)
{
        pid_t *pids;
        pids = malloc(sizeof(pid_t) * concurrency);
        if (pids == NULL)
                err(EXIT_FAILURE, "malloc() failed");

        struct timespec begin, end;

        if (clock_gettime(CLOCK_MONOTONIC_RAW, &begin) == -1)
                err(EXIT_FAILURE, "clock_gettime() failed");

        int i;
        for (i = 0; i < concurrency; i++) {
                pid_t pid;
                pid = fork();
                if (pid == -1) {
                        warn("fork() failed\n");
                        int j;
                        for (j = 0; j < i; j++) {
                                if (kill(pids[j], SIGINT) == -1)
                                        warn("kill() failed");
                        }

                        free(pids);
                        exit(EXIT_FAILURE);
                } else if (pid == 0) {
                        pause();
                } else {
                        pids[i] = pid;
                }
        }
        if (clock_gettime(CLOCK_MONOTONIC_RAW, &end) == -1) {
                warn("clock_gettime() failed");

                int j;
                for (j = 0; j < concurrency; j++)
                        if (kill(pids[j], SIGINT) == -1)
                                warn("kill() failed");
                free(pids);
                exit(EXIT_FAILURE);
        }
        if (system("free") == -1) {
                for (i = 0; i < concurrency; i++)
                        if (kill(pids[i], SIGINT) == -1)
                                warn("kill() failed");
                free(pids);
                err(EXIT_FAILURE, "system() failed");
        }

        long diff = (end.tv_sec - begin.tv_sec) * 1000000000 + (end.tv_nsec - begin.tv_nsec);
        printf("%ld\n", diff);
        for (i = 0; i < concurrency; i++) {
                if (kill(pids[i], SIGINT) == -1)
                        warn("kill() failed");
        }
        free(pids);
}

static void *thread_fn(void *arg)
{
        pause();
        /* shouldn't reach here */
        abort();
}

static void measure_thread_creation_time(void)
{
        pthread_t *tids;
        tids = malloc(sizeof(pthread_t) * concurrency);
        if (tids == NULL)
                err(EXIT_FAILURE, "malloc() failed");

        struct timespec begin, end;
        if (clock_gettime(CLOCK_MONOTONIC_RAW, &begin) == -1)
                err(EXIT_FAILURE, "clock_gettime() failed");

        int i;
        for (i = 0; i < concurrency; i++) {
                int ret;
                ret = pthread_create(&(tids[i]), NULL, thread_fn, NULL);
                if (ret) {
                        errno = ret;
                        free(tids);
                        err(EXIT_FAILURE, "pthread_create() failed\n");
                }
        }
        if (clock_gettime(CLOCK_MONOTONIC_RAW, &end) == -1) {
                free(tids);
                err(EXIT_FAILURE, "clock_gettime() failed");
        }
        if (system("free") == -1) {
                free(tids);
                err(EXIT_FAILURE, "system() failed");
        }

        long diff = (end.tv_sec - begin.tv_sec) * 1000000000 + (end.tv_nsec - begin.tv_nsec);
        printf("%ld\n", diff);
        free(tids);
}

int main(int argc, char *argv[])
{
        progname = argv[0];

        if (argc < 3)
                show_usage();

        char op = argv[1][0];
        if (op != 'p' && op != 't') {
                fprintf(stderr, "operation should be 'p' or 't': %c\n", op);
                show_usage();
        }
        concurrency = atoi(argv[2]);
        if (concurrency <= 0) {
                fprintf(stderr, "concurrency should be >= 1: %d\n", concurrency);
                show_usage();
        }

        switch (op) {
        case 'p':
                measure_process_creation_time();
                break;
                ;;
        case 't':
                measure_thread_creation_time();
                break;
                ;;
        default:
                show_usage();
        }

        exit(EXIT_SUCCESS);
}

プロセス生成の場合

上記プログラムを用いて1000個プロセスを生成した際の1プロセスあたりの生成時間と同メモリ使用量を採取しました。1プロセス当たりのメモリ使用量を厳密に求めるのは難しいので、(1000プロセス生成時のメモリ総使用量 - プログラム実行前のシステムメモリ総使用量)/1000で代替しました。このような性能を5回測定をした際の平均値を次に示します。

名前 WSL guest Ubuntu native Ubuntu
生成時間[ms] 4.53 0.082 0.0448
メモリ使用量[MB] 2.19 0.104 0.105

guest Ubuntuとの比較においては生成時間は50倍程度の差がついています。プロセス生成時間についてはguest Ubuntuのほうが優れているといっていいでしょう。

native Ubuntuとの比較においては、生成時間は100倍以上の差がついています。どちらの場合も同じデータに繰り返しアクセスするような処理はしていないので、native Ubuntuのデータはすべてキャッシュメモリに乗っているもののWSLのそれは乗っていないためにこの性能差が出たとは考えづらいです。他にもハード的に両者のプロセッサ性能、メモリアクセス性能による性能の違いが考えられますが、それだけではこれだけ大きな差は説明つきません。プロセス生成コストについてはWSLのほうがnative Ubuntuに比べて遅いと言っていいでしょう。これだと、プロセスをたくさん生成するshell scriptが遅く感じるのも説明がつきます。

メモリ使用量についてもguest Ubuntuおよびnative Linuxと20倍程度の差がついています。この値はnativeか否か、およびハードスペックの差は関係無く、かつ、プロセス生成時には生成した子プロセスのユーザメモリはほぼ割り当てられていない(子プロセスのメモリにwriteしたときにCopy-on-Writeによって子プロセス用の新規メモリが割り当てられる)であろうことを考えると、WSLはプロセスごとに消費するカーネルメモリの量がnative Ubuntuに比べて非常に多いと考えられます。

余談ですが、私のノートPCでは2000と少々のプロセスを生成した時点でswapが始まりました。あまりやる人はいないと思いますが、WSL上でプロセスを大量に動かすような処理を動かすのはnative Ubuntuの場合に比べてかなりのハンデを背負うでしょう。

ある人から、WSLの場合はfork()時にCopy-on-Writeせずにメモリを全コピーしているのではないかと指摘されたことより、WSLにおいて次の2パターンについてデータを採取してみました。

  • プロセス生成前に256MBの追加メモリ領域を獲得する(アクセスはしない)。
  • プロセス生成前に256MBの追加メモリを獲得した上で、対応する全ページにwriteアクセス

上記2つの場合どちらについても、追加メモリ領域獲得およびそこへのアクセスにかかる時間は生成時間に含んでいません。

5回計測した平均値は次のようになりました。

名前 WSL(追加メモリ領域なし。参考値) WSL(追加メモリ領域あり。writeアクセスなし) WSL(追加メモリ領域あり。writeアクセスあり)
生成時間[ms] 4.53 4.18 23.7
メモリ使用量[MB] 2.19 1.96 2.16

メモリ使用量については結果はどれもほぼ同じでした。このため、上記の仮説に反してCopy-on-Writeのしくみは働いていると考えられます。

その一方で生成時間は追加メモリ領域ありの場合と追加メモリ領域ありでwriteアクセス無しの場合はほぼ同じ値であるのに対して、追加メモリ領域ありでwriteアクセスなしの場合はそれらより5~6倍程度遅かったです。メモリ使用量は変化していないことより、追加メモリ領域及びそこへのwriteアクセスありの場合、追加メモリ領域の量に比例した何らかのreadアクセスがfork()時に発生しているものと考えられます。

スレッド生成の場合

上記プログラムを使ってスレッド生成時の生成時間および1スレッドあたりのメモリ使用量も求めました。5回測定した平均値を次に示します。

名前 WSL guest Ubuntu native Ubuntu
生成時間[ms] 0.248 0.0167 0.0108
メモリ使用量[MB] 0.0508 0.0360 0.0366

生成時間もメモリ使用量もプロセスの場合と同じくWSLの性能が一番悪いですが、差は明らかに縮まっています。前者についてはそれぞれ50倍と100倍の差があったものが15倍と20倍程度の差になっています。後者についてはguest Ubuntu, native Ubuntuどちらについても20倍の差があったものが2倍弱ほどの差になりました。WSLの場合は他の2つに比べてプロセス生成コストに比べたスレッド生成コストの低さが顕著であると言えるでしょう。 1

I/O性能

read性能

1GBのファイルを1MBごとに1024回に分けて読み出すことによってread I/O性能を採取しました。ファイルがページキャッシュに乗っている場合と乗っていない場合について採取しました。ページキャッシュの削除には、下記ddの前にecho 3 >/proc/sys/vm/drop_cachesを実行するという方法を使いました。採取方法は次の通り。

dd if=testfile of=/dev/zero bs=1M count=1024

10回測定した際の平均値を次に示します。

名前 WSL guest Ubuntu native Ubuntu
with page cache[MB/s] 2170 6360 12100
without page cache[MB/s] --- 677 489

WSLのページキャッシュありの性能guest Ubuntuに比べると3倍程度遅いです。native Ubuntuに比べた性能は6倍程度遅いです。この二つの環境におけるハードの性能差だけでこれだけの差が出るとは思えないので、WSLはnative Ubuntuに比べて読み出し性能が劣ると言えるでしょう。大規模ソースを読むときの速度差は、このページキャッシュありの場合の性能差が大きく関係していそうです。

ページキャッシュなしの場合についてはWSLにおいてページキャッシュを削除する簡単な方法が思いつかなかったので、比較はしませんでした2。guest Ubuntuにおけるページキャッシュなしの性能がハード性能限界を超えているのは、詳細は説明しませんがこれがVMだからです。

write性能

1MBのデータを1024回、合計1GBのファイルを新規ファイルに書き込むことによってwrite I/O性能を計測しました。データはbuffered I/O, direct I/Oそれぞれについて次のように採取しました。

dd if=/dev/zero of=testfile bs=1M count=1024; rm testfile
dd if=/dev/zero of=testfile oflag=direct bs=1M count=1024; rm testfile

10回測定した際の平均値を次に示します。

名前 WSL guest Ubuntu native Ubuntu
buffered I/O[MB/s] 324 596 3420
direct I/O[MB/s] 328 423 151

buffered I/OについてはWSLはguest OSに比べると2倍弱遅いことがわかります。native Ubuntuに比べて一桁程度遅いことがわかります。ストレージのwrite性能はそれほど違わないことを考えると、ファイルシステム(lxfs) and/or その他WSLのOS関連オーバーヘッドが非常に大きいと考えられます。direct I/OについてはWSLのほうが速いです。理由は不明ですが、ストレージ性能の差から来ているのかもしれません。

さらにWSLはbuffered I/Oよりもdirect I/Oのほうが速いという意味不明な結果になっています。これについても理由は不明です。WSLはbuffered I/Oとdirect I/Oの性能がほとんど変わらないという面白いデータがとれました。write時はread時とは異なりページキャッシュを使わないのかもしれない…などという想像が膨らむのですが、実装を知らないので本当のところは不明です。

おわりに

かつて、何かの記事でWSLのCPU性能はnative Ubuntuに近いするようなことが書いていましたが、それ以外の面については、少なくとも本記事で触れたような部分についてはnative Ubuntuに比べて、性能が悪い場合があると明らかになりました。今後の改善に期待です。

今後はできれば同じマシンでデータを採取したいですし、今回測らなかったようなデータもとってみたいです。また、ストレージアクセス性能測定についての、いくつかの直感に反する結果についても原因を明らかにしたいです(誰か詳しいかたがいれば教えてほしいです)。


  1. 当然ながら同じマシン上のnative Linuxの性能を測るのがベストなのですが、諸般の事情により今回はあきらめました

  2. 起動直後に上記ddを実行すればページキャッシュ無しの場合のデータを採取できると思いますが、時間の都合で、そこまではやりませんでした。

ASLRとKASLRの概要

はじめに

本記事はlinuxに存在する1Address Space Location Randomization(ASLR)、およびKASLR(Kernel ASLR)という、やたら長い名前のセキュリティ機能を紹介するものです。メモリアドレスの概念やC言語ポインタが理解できれば読める内容だと思います。

ASLR

概要

あるシステムをクラッカーが攻撃する方法はいろいろありますが、そのうちの一つが不正な方法によってプログラムに特定の命令を実行させる、不正なデータを操作させるというものです。攻撃には、(当然ながら)攻撃に使う命令、あるいはデータのアドレスが必要になります。ASLRが無い環境においてはプログラムのコードやデータは2固定されたアドレスにロードされるので、動かしているプログラムのバイナリがどんなものかわかっていれば、攻撃者が攻撃に使うコードやデータのアドレスを知るのは簡単です。

この類の攻撃を防ぐために考案されたのがASLRです。ASLRを使うと、プログラムの実行ごとに、そのコードやデータがランダムな位置に配置されます。そのため、クラッカーが攻撃に使うコードやデータのアドレスを割り出すのが困難になります3

ASLRは難しいハードウェア機構を使っているわけでなく、コードやデータを参照するときにアドレスを"0x10000000"などと絶対的に決め打ちで指定するのではなく、"所定のベースアドレス+0x10000000"などと、相対的に指定するだけです。

ここで一点注意が必要なのですが、ALSRによってスタック領域やヒープの領域のアドレスはどんなプロセスにおいてもランダム化されるのですが、コード領域や静的に配置されたグローバル変数やstatic変数などのデータ領域についてはそうではありません。以下に例を示します。

$ sleep 10000 &
[1] 7951
$ sleep 10000 &
[2] 7952
$ cat /proc/7951/maps       # sleep(pid=7951)のメモリマップ
00400000-00407000 r-xp 00000000 00:16 207201                             /bin/sleep # コード
00606000-00607000 r--p 00006000 00:16 207201                             /bin/sleep # 読み取り専用データ
00607000-00608000 rw-p 00007000 00:16 207201                             /bin/sleep # 読み書きデータ
009aa000-009cb000 rw-p 00000000 00:00 0                                  [heap]     # ヒープ
...
7ffc60378000-7ffc60399000 rw-p 00000000 00:00 0                          [stack]    # スタック
$ cat /proc/7952/maps       # sleep(pid=7952のメモリマップ)
00400000-00407000 r-xp 00000000 00:16 207201                             /bin/sleep
00606000-00607000 r--p 00006000 00:16 207201                             /bin/sleep
00607000-00608000 rw-p 00007000 00:16 207201                             /bin/sleep
00cf1000-00d12000 rw-p 00000000 00:00 0                                  [heap]
...
7ffd1a406000-7ffd1a427000 rw-p 00000000 00:00 0                          [stack]
...

というのも、上記のようにコード領域と静的に配置されたデータ領域のアドレスを相対的に指定するにはプログラムをPosition Independent Code (PIC) としてビルドされたPosition Independent Executable(PIE)である必要があるからです。gccにおいてはコンパイル時に-fpicオプションを付けると実現できます。こうなっていれば当該バイナリから生成されたプロセスは、任意のコード、データがランダム配置されます。以下に、私の環境ではPIEとして存在するsshを2つ起動した場合の、それぞれのプロセスのアドレスマップを示します。

$ cat /proc/7981/maps
5626d3d9c000-5626d3e45000 r-xp 00000000 00:16 867533                     /usr/bin/ssh
5626d4045000-5626d4048000 r--p 000a9000 00:16 867533                     /usr/bin/ssh
5626d4048000-5626d4049000 rw-p 000ac000 00:16 867533                     /usr/bin/ssh
5626d4049000-5626d404c000 rw-p 00000000 00:00 0
5626d5505000-5626d5544000 rw-p 00000000 00:00 0                          [heap]
...
7ffe2bd73000-7ffe2bd94000 rw-p 00000000 00:00 0                          [stack]
...
$ cat /proc/7985/maps
5616589f2000-561658a9b000 r-xp 00000000 00:16 867533                     /usr/bin/ssh
561658c9b000-561658c9e000 r--p 000a9000 00:16 867533                     /usr/bin/ssh
561658c9e000-561658c9f000 rw-p 000ac000 00:16 867533                     /usr/bin/ssh
561658c9f000-561658ca2000 rw-p 00000000 00:00 0
561658d81000-561658dc0000 rw-p 00000000 00:00 0                          [heap]
...
7ffc75060000-7ffc75081000 rw-p 00000000 00:00 0                          [stack]
...

PIEは通常のバイナリに比べてアドレッシングに余計な手間がかかるため、性能面では劣ります。このため、各種distroによって程度の差はありますが、セキュリティ的に重要なsshなどのバイナリはPIE、そうでないものは非PIEとしてビルドされる傾向にありました。ただし最近では可能な限りPIEをデフォルトにする動きが活発です。例えばUbuntuでは16.10からPIEがデフォルトになりました

あるプログラムがPIEかそうでないかは、たとえばfileコマンドによって確認できます。

$ file /usr/bin/ssh
/usr/bin/ssh: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=ecf7433a7d26461fc1bc7\
a6b6a4eba868e685839, stripped                # "...share object"となってる実行ファイルはPIE
$ file /bin/bash
/bin/bash: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=04eca96c5bf3e9a300952a29ef3\218f00487d37b, stripped            # "... executable"となってる実行ファイルは非PIE

有効/無効

ASLRはデフォルトで有効です。ASLRを無効化するにはsysctlのkernel.randomize_va_spaceに0を指定します(有効なときは2です。1についてはあんまり気にしなくていいです)。この設定はシステム全体に対して効果があります。

KASLR (KASLR)

概要

KASLRはASLRの考え方をそのままカーネルに持ってきたものです。KASLRはカーネルをPIEとしてビルドして、ブートのたびに自身を毎回異なるアドレスにロードします。非常に簡素な説明で恐縮ですが、ASLRさえわかっていればKASLRの機能については大して説明することがありません。

有効/無効

KASLRはlinux 3.14で導入されました。設定ファイル(.config)のCONFIG_RANDOMIZE_BASEがyになっていれば有効です。長らくデフォルト設定(defconfigと呼ばれるもの)ではこの機能は無効でした。しかし、本記事執筆時点での最新mainline kernelの4.12からはデフォルトで有効化されました。ただし、リンク先の記事にもある通り、Ubuntuなどのdistroカーネルでは以前から有効化されている例が多かったことより、これはカーネル開発者以外にはあまり影響無い話かもしれません。

KASLRには一つ罠があって、4.7以前のカーネルではhibernationと共存できないという制限があります。CONFIG_HIBERNATION=yの環境下においては、kaslrというカーネルブートオプションを明に指定した場合のみKASLRが有効になります(そのかわりhibernationは無効になります)。それ以外の場合は無効化されます。例えばUbuntuにおいて4.4系のカーネルを使ってシステムを構築している場合はこれに該当するので、デフォルトではKASLRは無効化されているため、注意が必要です。

...
unsigned char *choose_kernel_location(struct boot_params *boot_params,                                                                                                                                                             unsigned char *input,                                                                                                                                                                        unsigned long input_size,                                                                                                                                                                    unsigned char *output,                                                                                                                                                                       unsigned long output_size)                                                                                                                             {                                                                                                                                                                                                    unsigned long choice = (unsigned long)output;                                                                                                                                                unsigned long random;                                                                                                                                                                
#ifdef CONFIG_HIBERNATION
        if (!cmdline_find_option_bool("kaslr")) {
                debug_putstr("KASLR disabled by default...\n");
                goto out;
        }
#else
        if (cmdline_find_option_bool("nokaslr")) {
                debug_putstr("KASLR disabled by cmdline...\n");
                goto out;
        }
#endif
...

CONFIG_RANDOMIZE_BASE=yなカーネルで明にKASLRを無効化したい場合はカーネルのブートオプションにnokaslrを指定します。

困ったことにKASLRはdmesgに有効/無効のログを残しません。KASLRが有効かどうかを実行時に確かめるには、次のように適当なシンボルについて、お使いのカーネルのSystem.mapファイル内のアドレスと/proc/kallsymsから読み取れる実行時のアドレスをroot権限の下で4比較する必要があります。これらが異なっていればKASLRは有効、そうでなければ無効です。以下に例を示します。

$ sudo grep "T do_page_fault" /boot/System.map-$(uname -r)
ffffffff8106b780 T do_page_fault
$ sudo grep "T do_page_fault" /proc/kallsyms
ffffffffb626b780 T do_page_fault               # 上のアドレスと異なる。つまりKASLR有効

おわりに

この記事を書いた理由は、とある調べものをしているときに、「KASLRを有効にしたカーネルを動かしたはずなのにアドレスが固定だぞ!?」と気づいたのがきっかけです。私の使ってるUbuntuのドキュメントにもしっかり書いてたのでドキュメントちゃんと見てればわかってた話ですね。情けない。


  1. linux有機能というわけではなく、他のOSにも同等機能はあります

  2. プログラムが動的にロードするライブラリはまた事情が違います

  3. 後述のKASLRも含め、困難ではありますが、不可能ではありません。クラッカーと防御側の攻防はいつでもいたちごっこなのです

  4. 通常System.mapファイルはrootでないと読めないようになっていますし、/proc/kallsymsのアドレス欄はrootでないと正しい値が出ないように細工されています

カーネルの観点から見たLinuxKit

はじめに

Docker社はDockerCon2017において、Dockerコンテナの実行にフォーカスした軽量Linuxである、LinuxKitをリリースしました。

以下関連URLです。

本記事はLinuxKitについて網羅的な説明をすることではなくて、LinuxKitに同梱されているカーネルがどのようなものかをざっと眺めることです。なぜカーネルだけなのかというと、単に私がカーネル屋さんなのでカーネルに興味があっただけです。

本記事のLinuxKitの対象バージョンは、本記事執筆時点(2017年4月19日)で最新のcommit:f2d6752751318477ec86e4677514d5a4890249c1です。

カーネルは独自実装かどうか

LinuxKitに同梱されているカーネルは、独自実装したようなものではなく、みなさんがお使いのlinux kernelと同じものいくつかのパッチを当てたものです。カーネルバージョンは自分で選択もできるようですが、本書執筆現在で最新のv4.10 stable kernelもサポートしているようです。

カーネルに組み込まれている、モジュール化されている機能の数

通常のlinux distributionはなるべく多くの機能、デバイスをサポートするために、多くの機能を組み込んでいる、あるいはモジュール化していますが、LinuxKitに同梱されているカーネルはかなり思い切った作りになっており、ほとんどの機能を無効化しています。Ubuntu 16.04のカーネルの設定ファイルと、LinuxKitのデフォルトの設定ファイルのそれを比較してみます。

Ubuntu 16.04のカーネル LinuxKitのカーネル
有効化 2177 1540
モジュール化 4592 1

UbuntuカーネルよりもLinuxKitのそれのほうがはるかに軽量であることがわかります。モジュールに至っては1つしかありません。これがLinuxKitの軽量化、起動の高速化に一役買っていると考えられます。

どのような設定が組み込まれている、あるいはモジュール化されているのか。

CPU関連

CONFIG_NR_CPUS=128
...
# CONFIG_SCHED_SMT is not set
CONFIG_SCHED_MC=y

最多で128のコアまでサポートしているようです。この数が多ければ多いほど、CPUごとに存在するデータの数が増えますので、無暗に多くのCPUをサポートしない方針だと考えられます。Ubuntuの場合は512です。

マルチコアはサポートしているものの、ハイパースレッドはサポートしていないようです。

ファイルシステム関連

#
# File systems
#
CONFIG_DCACHE_WORD_ACCESS=y
# CONFIG_EXT2_FS is not set
# CONFIG_EXT3_FS is not set
CONFIG_EXT4_FS=y
CONFIG_EXT4_USE_FOR_EXT2=y
CONFIG_EXT4_FS_POSIX_ACL=y
CONFIG_EXT4_FS_SECURITY=y
# CONFIG_EXT4_ENCRYPTION is not set
# CONFIG_EXT4_DEBUG is not set
CONFIG_JBD2=y
# CONFIG_JBD2_DEBUG is not set
CONFIG_FS_MBCACHE=y
# CONFIG_REISERFS_FS is not set
# CONFIG_JFS_FS is not set
# CONFIG_XFS_FS is not set
# CONFIG_GFS2_FS is not set
# CONFIG_BTRFS_FS is not set
# CONFIG_NILFS2_FS is not set
# CONFIG_F2FS_FS is not set
# CONFIG_FS_DAX is not set
CONFIG_FS_POSIX_ACL=y
CONFIG_EXPORTFS=y
...
# CONFIG_QUOTA is not set
# CONFIG_QUOTACTL is not set
# CONFIG_AUTOFS4_FS is not set
CONFIG_FUSE_FS=y
CONFIG_CUSE=y
CONFIG_OVERLAY_FS=y
...

ローカルファイルシステムext4だけが有効化されています。XFSやBtrfsなどの、その他よく使われるファイルシステムは使わないようです。

Dockerのストレージドライバとしてよく使われるoverlayfsは有効化されています。

セキュリティ

#
# Security options
#
CONFIG_KEYS=y
CONFIG_PERSISTENT_KEYRINGS=y
CONFIG_BIG_KEYS=y
CONFIG_ENCRYPTED_KEYS=y
CONFIG_KEY_DH_OPERATIONS=y
CONFIG_SECURITY_DMESG_RESTRICT=y
CONFIG_SECURITY=y
CONFIG_SECURITYFS=y
CONFIG_SECURITY_NETWORK=y
CONFIG_SECURITY_NETWORK_XFRM=y
CONFIG_SECURITY_PATH=y
CONFIG_HAVE_HARDENED_USERCOPY_ALLOCATOR=y
CONFIG_HAVE_ARCH_HARDENED_USERCOPY=y
CONFIG_HARDENED_USERCOPY=y
# CONFIG_HARDENED_USERCOPY_PAGESPAN is not set
# CONFIG_SECURITY_SELINUX is not set
# CONFIG_SECURITY_SMACK is not set
# CONFIG_SECURITY_TOMOYO is not set
# CONFIG_SECURITY_APPARMOR is not set
# CONFIG_SECURITY_LOADPIN is not set
CONFIG_SECURITY_YAMA=y
CONFIG_INTEGRITY=y
# CONFIG_INTEGRITY_SIGNATURE is not set
CONFIG_INTEGRITY_AUDIT=y
# CONFIG_IMA is not set
# CONFIG_EVM is not set
CONFIG_DEFAULT_SECURITY_DAC=y
CONFIG_DEFAULT_SECURITY=""
CONFIG_CRYPTO=y

セキュリティモジュールは、なんと一番使われているSELinuxが無効化されています。そのかわり(?)、なぜかマイナーなYAMAが有効化されています。SELinuxのサポートについては今後の課題のようです。

namespaceとcgroup

コンテナ実装の肝であるnamespaceについてはひととおり有効化されているようです。

CONFIG_CGROUPS=y
CONFIG_PAGE_COUNTER=y
CONFIG_MEMCG=y
CONFIG_MEMCG_SWAP=y
CONFIG_MEMCG_SWAP_ENABLED=y
CONFIG_BLK_CGROUP=y
# CONFIG_DEBUG_BLK_CGROUP is not set
CONFIG_CGROUP_WRITEBACK=y
CONFIG_CGROUP_SCHED=y
CONFIG_FAIR_GROUP_SCHED=y
CONFIG_CFS_BANDWIDTH=y
CONFIG_RT_GROUP_SCHED=y
CONFIG_CGROUP_PIDS=y
CONFIG_CGROUP_FREEZER=y
CONFIG_CGROUP_HUGETLB=y
CONFIG_CPUSETS=y
CONFIG_PROC_PID_CPUSET=y
CONFIG_CGROUP_DEVICE=y
CONFIG_CGROUP_CPUACCT=y
CONFIG_CGROUP_PERF=y

コンテナのリソース制御をするcgroupについても多くが有効化されています。

CONFIG_NAMESPACES=y
CONFIG_UTS_NS=y
CONFIG_IPC_NS=y
CONFIG_USER_NS=y
CONFIG_PID_NS=y

仮想化

CONFIG_XEN=y
...
CONFIG_KVM_GUEST=y
...
CONFIG_HYPERV=y
...

Xen, KVM, Hyper-Vなどの主要なハイパーバイザのguestとして動くように設定されています。Widnows上や各種IaaS上で動かしたりする場合を考えれば当然必要でしょう。VIRTIOデバイスも用意されています。

おわりに

興味のある設定項目だけを適当に書き散らしたので、重要なところが漏れてことが多々あるかと思いますが、ご容赦ください。

壊れたパッチと、その対処方法

はじめに

本記事は、何らかの理由によってパッチの内容が壊れてしまう理由と、その対処方法について述べます。IT系に限っても「パッチ」という言葉には様々な意味がありますが、本記事で言うパッチとは、diffコマンドやgit format-patchなどによって作成した、2つのテキストファイルの差分を表現するテキストファイルのことを指します。「パッチの内容が壊れる」とは、後述する様々な理由によって、パッチが、作成時とは異なる正しく適用できない状態になってしまっていることを言います。具体的にはタブが1つまたは複数のスペースに誤変換されることを指します。これ以降、単にこのような誤変換のことを「壊れる」と記載します。

本記事は、どちらかというとメーリングリスト(以下ML)ベースで開発をしている(せざるを得ない)旧来型の開発者向けです。「開発は全部githubとgitを使ってやるからパッチを直接扱うことなんて無いよ」とか「patchコマンドとかgit {apply,am}ってそもそも何?」という次世代開発者は本記事を見なくても問題無いです。

パッチが壊れる理由

「パッチが壊れる理由なんて知っとるわ、どう対処すればいいかだけ教えろ」というかたは、この節を飛ばして下さい。

ある人がパッチを作ったとします。それを誰か別の人と共有するには

  • パッチファイルを直接コピーして渡す
  • パッチの内容をwebページ(ブログなど)に貼り付ける
  • パッチを貼り付けたメールを個人ないしMLに送信する

などの方法があります。直接コピーなら大丈夫なのですが、それ以外の場合は、例えば次のような時にパッチが壊れます。

  • Webページの表示時: htmlソースには正しいパッチが挿入されていても、webブラウザ上では任意長のタブないしスペースから成る列(POSIX正規表現で言うと/:space:+/)は全て1つのスペースに変換されてしまう
  • メールの送信時: 送信時にタブをスペースに自動変換する(パッチという観点から見ると)邪悪なメールクライアント(参考)がある
  • 端末からwebページへのコピペ時: タブが、その文字幅に応じたスペースの列に変換されることがある

こうなると、誰かがせっかく書いて公開してくれた便利なパッチをみなさんが使いたくても使えないという問題が発生します(逆のパターンもあり)。パッチを書いた人に連絡して正しいパッチを貰えればいいのですが、連絡するのに気が引けたり、連絡が取れなかったり、はたまた投稿者本人もそのパッチをもう持っていないなどということもありえます。

壊れたパッチの具体例

linuxの開発MLに投稿されたカーネルパッチを題材として壊れたパッチの具体例を示しましょう。これは、ML投稿時には正しかったものの、MLアーカイブサイトの誤整形によって壊れてしまったパッチです。

linuxカーネルソースのコーディングスタイルでは、インデントは8文字幅のタブです。しかし、この壊れたパッチ内のインデントはすべてタブではなくスペースになってしまっています。これではpatchコマンドやgit {apply,am}コマンドなどにとっては、パッチ内の各断片をソース中のどこに適用してよいのかわからないため、適用に失敗します。

対処方法

本節では壊れたパッチに遭遇した場合の様々な対処方法について述べます。

別サイトを探す

あるMLアーカイブサイト上にあるパッチが壊れているからといって、別のサイトでもそうであるとは限りません。問題が発生していない別のサイトがあれば、そちらを見ればいいのです。例えばこれはさきほどのパッチ(が挿入されたメール)を別のサイト上で見たものです。こちらは、タブはタブとして正しく整形されています。

linux開発について言えば、主な開発MLに投稿されたパッチがpatchwork.kernel.orgに正しく適用可能な形式でまとめられています。例に挙げた述べたパッチについてはここにあります。

直接 html をダウンロードする

パッチの著者が何も考えずにパッチをそのままhtmlにベタコピーしているような場合は、curlなどを使ってhtmlファイルを直接ダウンロードして、そこからパッチ部分を抽出することによって正しく適用可能なパッチが得られます。

パッチ適用時にマッチングのルールを緩くする

patchコマンドやgit {am,apply-patch}コマンドの--ignore-whitespaceオプションを使えば(patchコマンドの場合は-lでも可)、パッチ適用時にタブとスペースに関するマッチングのルールを緩めてくれるため、壊れたパッチも適用可能になります。

ただしこれには注意が必要です。パッチ適用後には"+"で始まる行のコンテンツがそのまま元のテキストに追加されます。このため、タブやスペースに特別な意味を持たせている場合に問題が発生します。例えばタブがスペースに変換されているパッチを、タブにが特別な意味を持つMakefileに対して適用すると、その後正しく動作しないことがあります。また、このような問題が無いケースでも、パッチ適用後のコードがプロジェクトのコーディングスタイルに違反してしまうこともあるので、パッチ適用後にフォーマッティングツールを適用するとよいかもしれません。

壊れたパッチを手元で修復する

次のようなスクリプトを適用すると、ある程度修復作業が自動化できます。

#!/bin/bash                                                                                                                                                                                                                                

if [ $# -ne 1 ] ; then
    echo "usage: $0 <broken-patch>" >&2
    exit 1
fi

PATCH=$1

unexpand -a $PATCH | sed -e 's/^\t/ \t/' -e 's/^$/ /'

単にunexpandコマンドを使うだけだと、patchファイルにおいて特別な意味を持つ行頭の1文字目をうまく扱えないため、sedで行頭を追加整形しています。このスクリプトは次のように壊れたパッチを引数として受け取り、修正後のそれを標準出力に出力します。

$ ./patch-unexpand bad.patch >good.patch

タブ幅が8ではない場合はman 1 unexpandを参照の上、スクリプトのソースを改変してください(-tオプションを使うことになると思います)。

タブからスペースへの誤変換については、これでだいたいのものは修正できます。このスクリプによって作成した新たなパッチが依然適用できない場合は、元から意図的に連続スペースだったものをスクリプトがタブに誤変換してしまっていることが考えられます。しかし、それを考慮しても手作業で全て修正するよりは、事前にこのスクリプトを適用しておくほうが遥かに作業効率が高いです。

さらに、ここまでは触れませんでしたが、1行に所定の文字数(例えば80文字)以上書くと自動的に改行する(パッチという観点から見ると)極悪非道なサイト、メールクライアントがあります。たとえば前記の例に挙げたパッチの中の"buf_start,"で始まる行が該当します。この文字列は、本来この直前の行の末尾に配置されているべきものなのですが、サイトの設定によって、このように表示されてしまっています。このような整形をされた場合は、上記スクリプトでは全く救えません。

後は(残念ながら)修正不完全なパッチを、適用前のソースと見比べながら、手動でなんとか適用可能な状態にします。辛く苦しい作業ですが、しょうがないです。

おわりに

上記のスクリプトに加えて、パッチ内の修正箇所の前後数行と対応する元ソースとを比較して、より高精度な復元をするスクリプトの実装も可能かと思いますが、まずはここまで。

なお、「何でこんなダサいことしてるの?このコマンド使えば簡単じゃん」というような便利コマンドをご存知のかたがいれば教えてください。なお、{patch,{git {apply,am}}の--ignore-whitespaceオプションについては優しい人に教えていただいたので、後から追記しました。

linux kernelのコードネームは無視していい

Linux Kernelにはバージョン名(本記事執筆時点では4.x)に加えてコードネームが存在します。Linux 4.10-rc6 Released, Now Codenamed The "Fearless Coyote"のように、記事のタイトルにコードネームを持ってきたり、コードネームが記事の中に記載されたりすることがあります。

実はこのコードネームにはほとんど意味がありません。少なくともlinuxの主要開発メーリングリストでもこれまでほとんど見たことがありません。2,3ヶ月に一度新バージョンがリリースされる現在ではコードネームがついていても覚えきれるわけがないので、使われないのは無理からぬことです。

カーネルのバイナリであるvmlinuxにもこの情報は含まれません。

$ strings vmlinux | grep "Fearless Coyote" 
$ 

そもそもLinuxのコードネームはソースディレクトリ直下のMakefileName = <codename>という形で定義されているだけで、他の用途に使われることは一切ありません。

$ head Makefile 
VERSION = 4
PATCHLEVEL = 10
SUBLEVEL = 0
EXTRAVERSION = -rc6
NAME = Fearless Coyote         # ←コードネーム
...
$ find | xargs grep -rnH '\$(NAME)'
$ 

バージョン名がいたるところで使われており、かつ、vmlinuxにも情報が含まれていることに比べると大きな違いです。

ついでに言うと、過去のコードネームの一覧を見ればわかる通り、この名前はリリース毎に更新されているわけではなく、数バージョンにわたって同じものが使いまわされたりしています*1

このためOpenStackのバージョン名"Mitaka"や"Newton"のような感覚で「君、昨日リリースされたFearless Coyoteをもう動かしてみたかね?」とかいう奴がいたら、そいつは似非カーネル通だと思います。

筆者はLinuxを使い始めて高々十数年なこともあって、過去のLinuxの歴史については詳しくありません。このため、過去にこのコードネームがいつから何のためにつけられたのかは知りません。しかし、少なくとも今現在は大した意味を持っていないことは確かです。このあたりの事情に詳しいかたがおられましたら、教えていただけると嬉しいです。

*1:Linus氏はコードネームについては更新をルーチンワークにしておらず、思い出したときに適当に更新しているのではないかと私は推測しています。