ファイルオープンと新フラグ

jirou senju
2014/09/03 11:33
/community/blog/images/union/pg_high_logo.png

多人数により日々改善が加えられるLinuxカーネルですが、その中にはまったく新しい機能もあれば、既存機能を拡張したものもあります。本記事ではopen(2)に加えられた新フラグについて取り上げます。

O_TMPFILEフラグ ---- linux-3.11

2014年9月にリリースされたlinux-3.11では、ファイルオープン時に指定可能なO_TMPFILEフラグが追加されました。目的は従来のmkstemp(3)tmpfile(3)と同様ですが、カーネルレベルで対応するため、効率とアトミック性が強化されます。glibcでは2014年2月にリリースされたv2.19でO_TMPFILEに対応しました。

従来のmkstemp(3)ファミリ、tmpfile(3)を用いる場合では、

  1. 一意な(と期待できる)ファイル名の生成
  2. そのファイル名でファイルを作成/オープン

という手順を踏みますが、一意性を保証するにはO_EXCLを用いたopen(2)を実行する必要があり、open(2)がエラーを返せば再度ファイル名の生成からやり直さなければなりません。

一意なファイル名は(一般的に)乱数を基に生成されます。重複する恐れは多くないかもしれませんが、この動作は効率的ではありません。ファイル名をユーザ空間で生成するため、アトミック性はほぼ皆無と言えます

O_TMPFILEはそもそもファイル名を与える必要がないという点に特長があり、ディレクトリ名しか指定せず、このディレクトリ下にファイルが作成されます。ファイル名は、Linuxカーネルが自動的に一意な名前を生成しますが、作成完了時には既に削除された状態となります。すなわち、リンクカウントはゼロであり、ファイルディスクリプタを用いた操作は可能でもファイル名による操作は不可能です。

ファイル名とリンクカウント

ここで簡単にファイルシステム上のinode、ファイル名、データブロックの基本的な事柄をおさらいしておきましょう*1。図1も併せて眺めてみてください。

[*1] ここで述べる内容はあくまでもUNIX系ファイルシステムでの古典的、基本的な構造です。Linuxの最新ファイルシステムや非UNIX系ファイルシステムにはあてはまらないものもあるでしょう。

  • ファイルシステム上で一ファイルを構成する基本要素は、inodeとファイル名、必要に応じデータブロックの三者。
  • ファイルの中身(ファイルデータ)を保持するのがデータブロック。データサイズが小さければデータブロックを割り当てずファイルデータをinode内に埋め込む場合もある。
  • ファイルに関する情報(オーナやタイムスタンプなど、メタデータ)を保持するのはinode。ここにファイル名は含まれない。ファイルデータを保持するデータブロック番号を保持するのもinode。
  • ファイル名とinode番号のペアをリンクと言い、これを保存するのがディレクトリ。ディレクトリはリンクだけを保存するという形式こそ特殊だが、ディスク上の存在としては通常ファイルと変わらない(一般的に)。古いUNIXではディレクトリをread(2)もできたが、現在ではreaddir(3)しか許可されない。もちろんread(2)できても形式を把握していなければ意味はない。形式はファイルシステムにより差異がある。
  • ハードリンク(lnコマンド、link(2))はひとつのinodeが複数のファイル名とペアリングされた状態。ペアリングの数をリンクカウントと言う。
  • シンボリックリンク(ソフトリンク、ln -sコマンド、symlink(2))はリンクカウントには関係せず、inodeが他のファイル名を参照する状態。ここで「他のファイル名」とは単なる文字列に過ぎず、存在しないファイル名を指すシンボリックリンクも作成可能。
  • rmコマンド(unlink(2))はinode番号とファイル名のペアリングを解消する。即ち、ファイル名が削除され、inodeが持つリンクカウントをデクリメントする。ファイル名が見えなくなるため、このファイル名を用いた操作は以降不可能になる。
  • リンクカウントがゼロになっても、いずれかのプロセスがそのファイルを使用中であれば(オープンしたままであれば)、カーネルはinodeとデータブロックを解放しない。クローズした時点(そのファイルの使用が終了した時点)でリンクカウントがゼロであれば、inodeとデータブロックが解放される。
inode、ファイル名、データブロックの関係

図1: inode、ファイル名、データブロックの関係

例えば図1のディレクトリを表示するということは、ブロック番号202の内容を取得、出力することであり、次のような結果が得られます。

-rw-r--r-- 1 jro jro 18 Aug 17 02:15 fileA
lrwxrwxrwx 1 jro jro 13 Aug 17 02:15 symlinkB -> /another/file

また、cat fileAするということは、

  1. パス名を解決する。
  2. fileAに対応するinode番号を得る。
  3. inode11を調べ、ファイルデータはブロック番号101にあることが分かる、
  4. ブロック番号101の内容を取得、出力する。

ということです。

古典的なlnmvcpコマンドからinode、ファイル名、データブロックの関係を見ると次のようになります。

$ ln fileA fileB
fileAのinodeとファイル名fileBのペアを作成する。fileAのinode、ファイル名、データブロックは存在し続ける。
図1で言えば、ブロック番号202に{ino=11,name="fileB"}を追加し、inode11のnlinkを2にする。
$ mv fileA fileB
fileAのinodeとファイル名fileBのペアを作成し、ファイル名にfileAを持つペアを削除する。inode、データブロックは存在し続ける。図1で言えば、ブロック番号202から{ino=11,name="fileA"}を削除し、{ino=11,name="fileB"}を追加する。inode11のリンクカウントは変化しない。
$ cp fileA fileB
inode、ファイル名、データブロックを三つとも新たに作成する。作成したinodeとfileBのペアを作成する。fileAのinode、ファイル名、データブロックはそのまま存在し続ける。図1で言えば、

  • ブロック番号404にブロック番号101をコピーする。
  • inode44にinode11をコピーする。同時にblock=101をblock=404に変更する。
  • ブロック番号202に{ino=44,name="fileB"}を追加する。

ファイルを新規作成した直後のリンクカウントは1です。unlink(2)を実行しリンクカウントがゼロになっても、いずれかのプロセスがそのファイルをオープンしたままであれば、ファイル名は見えなくなり、ファイル名を用いた操作は不可能になります。しかし、inodeもデータブロックも存在し続けており、ファイルディスクリプタを用いた操作は可能です。

たびたび誤解されることですが、ファイルパーミションから書き込み許可を落しても、そのファイルを削除する操作を禁止したことにはなりません。ファイルパーミションの書き込み許可とはファイルデータの変更許可を意味するに過ぎず、ファイル名(リンク)を保持している親ディレクトリには影響しません。ファイル削除を禁止する場合は、その親ディレクトリの書き込み許可を落す必要があります。

ファイル削除とは親ディレクトリ内のファイル名を削除する動作であり、例えば図1のfileAを削除するということは、ディレクトリの内容であるブロック番号202から{ino=11,name="fileA"}というエントリを削除するという意味です。環境によってはimmutableフラグ、POSIX ACL、LSMなどでも対応できると思いますが、話を古典的なファイルパーミションに限定すれば上記の通りです。

O_TMPFILE指定時のリンクカウント

話をO_TMPFILEに戻しましょう。O_TMPFILEは指定されたディレクトリ下に一意な名前のファイルを、初めからリンクカウントがゼロの状態で作成します。ファイルデータはまだ存在せず、またファイル名(リンク)も存在せず、inodeだけが存在する状態です。

lsなどを実行してもファイル名は見えませんが、inodeは存在するため、ファイルディスクリプタを用いた操作が可能です。ここでLinuxカーネル内部では、一意な名前として乱数などを基にした「ありそうにない名前」を生成するのではなく、ファイルシステムが作成するファイルのため新規に割り当てたinode番号をそのまま文字列化して使用します。つまりファイルシステムの通常の新規inode割り当て処理によって一意性が確保され、アトミック性と効率が向上します。

初めからリンクカウントがゼロというのはこれまでになかった状態のため、ファイルシステム側でも新たな対応が必要になり、O_TMPFILEが導入されたlinux-3.11時点ではext2、minix、tmpfsでしか使用できませんでした。

現代ではあまり使用されていないminixなどになぜ最初に実装したのだろうかと首を傾げたくなりますが、O_TMPFILEをmainlineにcommitしたのはVFSのメンテナであることを踏まえると、恐らくは、

  • VFSの部分は自分でやるが、個々のファイルシステムにも対応は必要。
  • 一人で個々のファイルシステムすべてにO_TMPFILEを実装するのは大変。
  • お手本となるファイルシステムコードを書くのはやぶさかではないが、できれば簡単に済ませたい。
  • 単純なファイルシステムを一つ選ぼう。

のような考えだったのかもしれません(邪推かもしれませんが)。ext2を選んだのは多くのファイルシステムメンテナが参考にするだろうという考えから、またtmpfsを選んだのは需要が多そうだという考えからだろうと思います。実際にはO_TMPFILE後に後述するlinkat(2)をする場合も多く予想できるため、tmpfsの需要はそれほどでもないかもしれません。

linux-3.16現在ではext3、ext4、xfs、btrfs、f2fsでも対応している模様です。

O_TMPFILEで作成したファイルに名前を与える

さて、O_TMPFILEで作成したファイルにI/Oしたとして、そのファイルに名前を与え、永続的にファイルシステム上に保存するにはどうすれば良いでしょうか。

Linuxカーネルが内部で生成したファイル名は使用できません。作成時に一時的に与えられただけで、すでに削除されています。従来のmkstemp(3)ならば、rename(2)などを使って生成された一意なファイル名を、より一般的なファイル名へと変更できますが、初めからファイル名が存在しない状態のO_TMPFILEでは不可能です。ここでprocファイルシステムとlinkat(2)の出番です。

procファイルシステム

procファイルシステムにはプロセスIDを名前とするサブディレクトリがあり、そこからプロセスに関するさまざまな情報が得られます。プロセスがオープンしているファイルに関する情報も得られます。

$ ls -l /proc/$PID/fd
total 0
lrwx------ 1 jro jro 64 Aug 16 13:40 0 -> /dev/ttyS0
lrwx------ 1 jro jro 64 Aug 16 13:40 1 -> /dev/ttyS0
lrwx------ 1 jro jro 64 Aug 16 13:40 2 -> /dev/ttyS0
lrwx------ 1 jro jro 64 Aug 16 13:40 3 -> /tmp/#11 (deleted)

fdの0、1、2はそれぞれstdinstdoutstderrという通常のファイルディスクリプタなので、今さら注目に値しませんが、3の#11は目を引きます。ファイル名の末尾にはdeletedという文字列が追加されており、削除されたことを表しています。これがO_TMPFILEで作成したファイルで、ファイル名の数字はこのファイルシステム上でのinode番号です。

話はそれますが、/proc/PID下に限らず、シンボリックリンクはセキュリティ上有害になり得るという考えから(実はハードリンクも)、シンボリックリンクをたどる処理を一部制限する設定を可能とする/proc/protected_symlinksがlinux-3.6(2012年9月)で導入されました(ハードリンク用には/proc/protected_hardlinks)。導入当初ではデフォルトで有効とされていましたが、動作しなくなる既存アプリケーションが確認されたため、linux-3.7(2012年12月)で機能は残すけれどデフォルトでは無効と設定するよう変更されました。

linkat(2)とAT_SYMLINK_FOLLOWフラグ

linkat(2)はlinux-2.6の時代から実装されているシステムコールです。本来の目的は、従来のファイル名だけを与えるlink(2)に既にパス名解決済みのディレクトリも与えることでパス名解決処理を節約し、またディレクトリ移動の機会を削減することにあります。linkat(2)に指定可能なフラグAT_SYMLINK_FOLLOWも少し遅れて実装され、このフラグがO_TMPFILEで作成したファイルに対して威力を発揮します。

/proc/PID/fd下のファイルは、ファイルディスクリプタの値を名前とするシンボリックリンクであり、シンボリックリンクが指すファイル名が本来オープンしたファイル名です。ファイルが削除されている場合は末尾にdeletedという文字列が追加され、ファイル名は得られますが削除されていれば名前からファイルを使用することはできません。

linkat(AT_SYMLINK_FOLLOW)/proc/PID/fdを組み合わせると、既に削除されリンクカウントがゼロになったファイルに対しても、新たな名前を与えリンクカウントをインクリメントできます。

ここまでの内容をサンプルコードとして付属ソースファイル一式のo_tmpfile.cにまとめました。

リスト1: o_tmpfile.c(要約)

    tmpfile = open(argv[1], O_RDWR | O_TMPFILE, S_IRUSR | S_IWUSR);
    :::
    pid = getpid();
    len = snprintf(proc, sizeof(proc), "/proc/%d/fd", pid);
    len = snprintf(fdpath, sizeof(fdpath), "%s/%d", proc, tmpfile);
    :::
    dirfd = open(argv[1], O_PATH);
    err = linkat(AT_FDCWD, fdpath, dirfd, argv[2], AT_SYMLINK_FOLLOW);

fdから新たなリンクを作成する処理は独立させ汎用的なlinkfd(3)とでもすると、他の場面でも有効活用できるかもしれません。

参考のため、より汎用的にlinkat(AT_SYMLINK_FOLLOW)をコマンド化したprocfd.cを付属ソースファイル一式に含めておきます。

誤って削除してしまったけれど自ユーザが所有するプロセスがまだオープンしていることという条件がありますが、以前からファイル復活の呪文として知られた方法です。従来はコピーするしか方法がありませんでしたが、O_TMPFILE導入以降ではリンクカウントがゼロのファイルでもlink(2)可能になったため、効率良く復活させられます。procfd.cではlinkat(AT_SYMLINK_FOLLOW)に失敗した場合、従来のコピー方式でファイルを復活します。ここで、ファイルコピーの効率化を狙い、splice(2)を使用しています(効率化と言ってもsparseファイルの場合には有効ではありません)。また、現代のマルチコア対応、複数ディスク対応のつもりでマルチスレッドでsplice(2)を実行するようにしてみました(あくまでもサンプルで、それほど厳密なものではありませんが)。

先に、inode、ファイル名、データブロックの関係という観点から、簡単にlnについて触れましたが、ハードリンクがファイルシステムを跨ぐことはできません。inodeを共有するためです。linkat(2)も二つのファイル名が別ファイルシステムを指す場合はエラーとなります。上記で元ファイル名は/proc下、新ファイル名はコマンドライン引数に指定されたディレクトリ下のように異なるファイルシステムに見えますが、フラグにAT_SYMLINK_FOLLOWを指定することで、/proc下のシンボリックリンクを辿り、コマンドライン引数に指定されたファイルシステムへ辿り着けます。すなわち、同一ファイルシステム内でのハードリンク作成となり、linkat(2)は成功します。

O_PATHフラグ ―― linux-2.6.39

先に挙げたサンプルコードo_tmpfile.cではO_PATHを使用しましたが、この部分は次のようにも記述できます。

len = snprintf(newfile, sizeof(newfile), "%s/%s", argv[1], argv[2]);
err = linkat(AT_FDCWD, fdpath, AT_FDCWD, newfile, AT_SYMLINK_FOLLOW);

O_PATHも比較的新しく、linux-2.6.39(2011年5月)で導入された機能なのでここで取り上げます。glibcでは同じく2011年5月にリリースされたv2.14でO_PATHに対応しました。

O_PATHopen(2)に与えるフラグですが、実際にはファイルオープンとは言い難い動作になります。Linuxカーネル内の処理としては、通常のファイルオープンに比べ半分程度の内容しか実行しません(実ファイルシステムでの処理はなく、VFSレイヤに閉じる)。オープンしたファイルを表すファイルディスクリプタは得られますが、このファイルディスクリプタを用いた多くの処理はエラーとなります。例えば、I/Oやfstat(2)などは実行できません。O_PATHはパス名解決の結果を保持することを目的としたもので、I/Oを目的としていないためです。このファイルディスクリプタを使用できるシステムコールは、close(2)dup(2)fcntl(2)linkat(2)などの...atファミリ、fchdir(2)など、ごく一部に限定されます。

先に挙げたo_tmpfile.cでは、linkat(2)に与えるパラメータ(dirfd)を得るためにO_PATHを用いています。このためパラメータによりリンク先ファイル名(argv[2])のパス名解決処理、およびリンク先ファイルフルパス名生成処理を削減できます(サンプルのo_tmpfile.cでは単純なためあまり効果はありませんが)。linkat(2)時点ではargv[2]のディレクトリのパス名解決処理を行わないため、万が一上位ディレクトリの一部で名前が変更された場合でも、linkat(2)はエラーになりません。まぁ、この点は一概に利点とは言えないかもしれません。

file handleシステムコール ―― linux-2.6.39

linux-2.6.39ではO_PATHに少し似たところがあるfile handleシステムコールも追加されました。やはりパス名解決の結果を保持しますが、結果をファイルディスクリプタではなく、ファイルハンドルとして表現したものです。

リスト2: file handleシステムコール

int name_to_handle_at(int dfd, const char *name, struct file_handle *handle,
                      int *mnt_id, int flags);
int open_by_handle_at(int mountdirfd, struct file_handle *handle, int flags);

O_PATH同様に、glibcではv2.14で上記二システムコールに対応しました。

この二つのシステムコールは内容的にopenat(2)を二分割したものと言えます。name_to_handle_at(2)flagsには...atファミリと同じAT_EMPTY_PATHAT_SYMLINK_FOLLOWを指定でき、open_by_handle_at(2)flagsにはオープンフラグを指定できます。name_to_handle_at(2)はパス名を解決し、その結果をstruct file_handlemnt_idに返します。得られたfile handleからファイルをオープンするには、まずmnt_id/proc/self/mountinfoを照合し、マウントポイントを特定し、マウントポイントをオープンします。このオープンにO_PATHを使用することはあまりないと思われます。

O_PATHを使用する場合は、得られたファイルディスクリプタにfchdir(2)してから、open_by_handle_at(2)の引数dfdAT_FDCWDを指定します。O_PATHによりLinuxカーネル内の処理は削減されますが、fchdir(2)を追加することになるので相殺されてしまうと思われます。厳密な所は測定、比較しなければ分かりません。

open_by_handle_at(2)はマウントポイントのファイルディスクリプタとstruct file_handleを基にファイルを特定、オープンし、ファイルディスクリプタを返します。ファイルハンドルという用語はNFSでも使用されていますが、file handleシステムコールが言うファイルハンドルは意味的にも実装的にもNFSのファイルハンドルと同じもので、システムコール内部でNFSが使用するファイルハンドル作成/解析ルーチンをそのまま用いています。そのため、NFSを使用できないファイルシステムにはfile handleシステムコールを使用できません。また、パス名を解決したプロセスとは別プロセスで実行されることが想定されるopen_by_handle_at(2)には、NFSサーバ同様に、CAP_DAC_READ_SEARCHケーパビリティ(権限)が必要です。

file handleシステムコールを使用するにはCONFIG_EXPORTFSCONFIG_FHANDLEを有効にする必要があります。

open_by_handle_at(2)では、NFSで発生するESTALE("Stale NFS file handle"、「実効性のないNFSファイルハンドルです」)というエラーも発生し得ます。原因もNFSと同様で、name_to_handle_at(2)でfile handleを作成後open_by_handle_at(2)で実際にオープンするまでの間に、対象のファイルが削除された場合などに発生します。NFSでクライアントとは無関係にサーバー上でファイルが削除された場合に相当します。

この二つのシステムコールは主にユーザ空間でのファイルサーバ実装での使用を想定しており、file handle作成とその使用の間に大きな時差があっても構わず、また作成プロセスと使用プロセスが異なっていても構いません。別プロセスへファイルディスクリプタを渡すにはソケットとcmsg(3)を用いた方法しかなく、これは手間がかかります。

file handleを渡すのは容易です。共有メモリ、パイプ、ソケット、ディスク上のファイルなど、なんでも使えます。ファイルディスクリプタを保持するために、長時間ファイルをオープンし続けなくとも良いと言うのも長所です。しかし、別プロセスである以上、file handle使用プロセスがファイルをオープンする適切な権限を備えているかを確認しなければならず、このためopen_by_handle_at(2)ではCAP_DAC_READ_SEARCHケーパビリティを必須としています。通常のアプリケーションで使用することはあまりないと思われるシステムコールですが、一応サンプルコード(fhandle.c)を付属ソースファイル一式に含めておきます。ここではname_to_handle_at(2)open_by_handle_at(2)を別プロセスで実行し、pipe(2)によりfile handleを渡しています。

リスト3: fhandle.c(要約)

struct pipedata {
    int mnt_id;
    struct file_handle fh;
};

int child_handle(char *file, int outfd)
{
    struct file_handle *fh;
    struct pipedata pd;

    fh = malloc(sizeof(*fh) + MAX_HANDLE_SZ);
    fh->handle_bytes = MAX_HANDLE_SZ;
    /* file handle の作成 */
    err = name_to_handle_at(AT_FDCWD, file, fh, &pd.mnt_id, 0);
    :::
    /* file handle をパイプへ書き込む */
    pd.fh = *fh;
    ssz = write(outfd, &pd, sizeof(pd));
    ssz = write(outfd, fh->f_handle, fh->handle_bytes);
}
/*---------------------------------------------------------------------- */
/* mnt_id からマウントポイントの特定 */
int mntfd(int mnt_id)
{
    fp = fopen("/proc/self/mountinfo", "r");
    do {
        p = fgets(line, sizeof(line), fp);
        n = sscanf(line, "%d %*d %*s %*s %as\n", &id, &mntpnt);
    } while (id != mnt_id);
    fclose(fp);
    :::
    dirfd = open(mntpnt, O_RDONLY | /* O_PATH */);

    return dirfd;
}

int child_open(int infd)
{
    /* パイプから file handle を読み取る */
    ssz = read(infd, &pd, sizeof(pd));
    ssz = read(infd, pd.fh.f_handle, pd.fh.handle_bytes);
    :::
    /* file handle によるファイルオープン */
    dirfd = mntfd(pd.mnt_id);
    fd = open_by_handle_at(dirfd, &pd.fh, O_RDONLY);
    :::
    ssz = read(fd, filedata, st.st_size);
    :::
}

/*---------------------------------------------------------------------- */

int main(int argc, char *argv[])
{
    /*
    * プロセス A で file handle を作成し、
    * プロセス A でその file handle を基にファイルをオープンする
    */
    err = pipe(pfd);
    pid = fork();
    if (!pid) {
        err = child_handle(argv[1], pfd[1]);
        exit(err ? EXIT_SUCCESS : EXIT_FAILURE);
    }
    pid = fork();
    if (!pid) {
        err = child_open(pfd[0]);
        exit(err ? EXIT_SUCCESS : EXIT_FAILURE);
    }
    :::
}

本記事ではファイルオープンに関する、Linuxカーネルに追加された比較的新しい機能とその使い方を紹介しました。今後も折にふれLinuxカーネルの新機能を紹介したいと思います。

(初出: H26/8)

Copyright © 2014 SENJU Jiro

本記事のサンプルコードは、以下のリンクよりダウンロードすることができます。記事と合わせてご参照ください。
[サンプルコード]
また、本稿で取り上げた内容は、記事の執筆時の情報に基づいており、その後の実装の進展、変更等により、現在の情報とは異なる場合があります。
本連載「プログラマーズ・ハイ」では読者からのご意見、ご感想を切望しています。今後取り上げるテーマなどについてもご意見をお寄せください。

Bookfair

O'Reilly Japanのセレクションフェア、全国の書店さんで開催
[ブックフェアのページへ]

Feedback

皆さんのご意見をお聞かせください。ご購入いただいた書籍やオライリー・ジャパンへのご感想やご意見、ご提案などをお聞かせください。より良い書籍づくりやサービス改良のための参考にさせていただきます。
[feedbackページへ]