Help us understand the problem. What is going on with this article?

ext4のDisk Quotaあれこれ

ext4のDisk quotaについて調べる機会がありましたので、備忘のためにまとめておきます。
何かお気づきのことがありましたらお気軽にコメント等いただければ幸いです。

Disk quotaとは?

Disk quotaとは、ファイルシステムの使用制限の仕組みのことです。
user, group, projectごとに、容量もしくはinode数の上限を設定することができます。
Androidでも8.1までは、アプリの暴走予防に使われていました。(参考)

ここで、projectとは「ディレクトリ群」のことを指します。
用途ごとにディレクトリをわける運用はよく行われると思いますが、
その用途ごとに対して容量制限をかけることができます。

上限にはsoftlimitとhardlimitの2種類があります。
softlimitを超えるとシステムから警告されますが、まだ書き込めます。
softlimitを超えてしばらく時間が経過するか、hardlimitを超えると書き込みがEDQUOTで失敗します。

Linux kernelにおいて、disk quotaの機能に対応しているファイルシステムは、
fs/quota/Kconfigによるとext2, ext3, ext4, jfs, ocfs2, reiserfs, gfs2, xfsがあります。(5.4.13時点)

本記事はext4を対象にしています。
また、調査したOSSのバージョンは以下のとおりです。

  • Linux kernel: 5.4.13
  • e2fsprogs: 1.45.5 (07-Jan-2020)
  • linuxquota: 4.05

Disk quotaを体感してみる

まずはDisk quotaによる書込制限を実験してみます。
ここでは一番お手軽なUser IDに対する制限を実験します。

1. quota機能を有効にしたext4ファイルシステムを作成する

テスト用のext4ファイルシステムを作成します。
サイズは適当に40MiB (4KiB * 10,000)にしています。
このとき、mkfs.ext4はデフォルトではDisk quota機能が無効なので、明示的に有効にする必要があります。

$ truncate -s 40M test_ext4.img
$ mkfs.ext4 -q -O quota test_ext4.img

ちゃんとquota機能が有効になっているかはdumpe2fsコマンドで確認できます。

$ dumpe2fs -h test_ext4.img | grep '^Filesystem features'
dumpe2fs 1.45.5 (07-Jan-2020)
Filesystem features:      has_journal ext_attr resize_inode dir_index filetype extent 64bit flex_bg sparse_super large_file huge_file dir_nlink extra_isize quota metadata_csum

ちなみにtune2fsコマンドを使うことで、既存のext4 imageに対してquota機能を有効にすることもできます。

$ truncate -s 40M test_ext4_2.img
$ mkfs.ext4 -q test_ext4_2.img
$ dumpe2fs -h test_ext4_2.img | grep '^Filesystem features'
dumpe2fs 1.45.5 (07-Jan-2020)
Filesystem features:      has_journal ext_attr resize_inode dir_index filetype extent 64bit flex_bg sparse_super large_file huge_file dir_nlink extra_isize metadata_csum
$ tune2fs -Q usrquota test_ext4_2.img
tune2fs 1.45.5 (07-Jan-2020)
$ dumpe2fs -h test_ext4_2.img | grep '^Filesystem features'
dumpe2fs 1.45.5 (07-Jan-2020)
Filesystem features:      has_journal ext_attr resize_inode dir_index filetype extent 64bit flex_bg sparse_super large_file huge_file dir_nlink extra_isize quota metadata_csum

2. user quotaのhardlimitをかけてみる

quotaを設定するコマンドは、edquotasetquotaがあります。
edquotaは、エディタを起動してquotaを設定するという半GUIのような動作です。
記事には向かないので、ここでは引数で設定値を指定できるsetquotaを使います。
edquotasetquotaともに、内部ではブロックデバイスに対してquotactlシステムコールを呼び出します。
quotactlシステムコールは、ブロックデバイスがmountされた状態で呼び出す必要があります。

$ mkdir -p mnt
$ sudo mount -o usrquota test_ext4.img mnt
$ sudo chmod a+w mnt/

これで準備が整いましたので、まずは今のquotaの設定を確認しましょう。
ここではquotaコマンドを使います。

$ quota --verbose --human-readable --filesystem-list mnt/
Disk quotas for user #1000 (uid 1000): 
     Filesystem   space   quota   limit   grace   files   quota   limit   grace
    /dev/loop19      0K      0K      0K               0       0       0        

まだなにも設定していないしデータを書いてもいないので、全て0となっています。
それではsetquotaコマンドを使って、UID 1000に対して容量のquota制限を設定します。
ここではsoftlimitを2MiB, hardlimitを4MiBにしてみます。

$ sudo setquota --user 1000 2048 4096 0 0 mnt
$ quota --verbose --human-readable --filesystem-list mnt/
Disk quotas for user #1000 (uid 1000): 
     Filesystem   space   quota   limit   grace   files   quota   limit   grace
    /dev/loop19      0K   2048K   4096K               0       0       0        

使用容量(space)は変わらず0Kですが、quotaが2048K、limitが4096Kになりました。

次に、softlimitを超える3MiBのデータを書き込んでみます。

$ dd if=/dev/zero of=mnt/1000 bs=1K count=3K
3072+0 レコード入力
3072+0 レコード出力
3145728 bytes (3.1 MB, 3.0 MiB) copied, 0.00996037 s, 316 MB/s
$ quota --verbose --human-readable --filesystem-list mnt/
Disk quotas for user #1000 (uid 1000): 
     Filesystem   space   quota   limit   grace   files   quota   limit   grace
    /dev/loop19   3072K*  2048K   4096K   7days       1       0       0        

書き込みは成功していますが、使用容量(space)に*がつきました。

いよいよ、hardlimitを超える8MiBのデータを書き込んでみます。

$ dd if=/dev/zero of=mnt/1000 bs=1K count=8K
dd: 'mnt/1000' の書き込みエラー: ディスク使用量制限を超過しました
4097+0 レコード入力
4096+0 レコード出力
4194304 bytes (4.2 MB, 4.0 MiB) copied, 0.0121523 s, 345 MB/s
$ quota --verbose --human-readable --filesystem-list mnt/
Disk quotas for user #1000 (uid 1000): 
     Filesystem   space   quota   limit   grace   files   quota   limit   grace
    /dev/loop19   4096K*  2048K   4096K   7days       1       0       0        
$ df -h mnt
Filesystem      Size  Used Avail Use% Mounted on
/dev/loop19      35M  4.8M   28M  15% mnt

filesystemとしてはまだ28Mの空きがあるのに、
hardlimitとして設定した4MiBで書き込みが中断されることが確認できました。

ちなみに、quotaコマンドではカレントユーザの設定値が出力されます。
-uオプションを指定することでUIDを指定することはできますが、
すべてのUIDに対して出力することはできません。
これはquotactlシステムコールのI/Fから来る制約で、
一覧が欲しい場合は有効な可能性のあるUIDに対して手当たりしだい呼び出す必要があります。

似たようなコマンドとして、repquotaコマンドがあります。
これは/etc/passwdからUIDのリストを生成します。
つまり、今回の実験やAndroidのように/etc/passwdがない環境では機能しないので注意が必要です。

3. Disk imageからquotaの状況を確認する

先程はquotaコマンドを使って、block device経由でquotaの状況を確認しました。
せっかくなので直接disk imageからもquotaの状況を確認してみます。

$ sudo umount mnt/
$ debugfs -R 'list_quota user' test_ext4.img
debugfs 1.45.5 (07-Jan-2020)
   user id     blocks    quota    limit      inodes    quota    limit
         0      13312        0        0           2        0        0
      1000    4194304     2048     4096           1        0        0

単位が不揃いなので若干読みづらいですが、
UID 1000のlimitが4,096KiBに対して使用容量(blocks)が4,194,304byteとなっているので上限に達していることがわかります。

この方法の場合、disk imageを直接参照しているため、
quota fileに記録されているすべてのUIDの状況が参照できます。

ちなみにlist_quotaは、lqという省略形を使うこともできます。

$ debugfs -R 'lq user' test_ext4.img
debugfs 1.45.5 (07-Jan-2020)
   user id     blocks    quota    limit      inodes    quota    limit
         0      13312        0        0           2        0        0
      1000    4194304     2048     4096           1        0        0

netlinkによるquotaの監視

続いては、quotaのsoftlimit/hardlimitを超えたことを検出するための仕組みについて紹介します。
書き込んでいるプロセス自身は、hardlimitを超えたときwrite(2)の結果としてEDQUOTが返ります。
ただそれだとシステムの監視目的では不便です。
昔はコンソールにメッセージが出力されてたらしいのですが、
今はnetlink(7)という仕組みを使って監視できるようになっています。

netlink(7)とは、socketを介してuser processとkernelとで双方向通信する仕組みです。
有名所では、ip monitorコマンドでrouting情報等の変化監視に使われていたり、
monitコマンドでプロセスの死活監視に使われていたりします。

サンプルコード

ということで、実際にnetlinkを使ってquotaの監視を見ます。
エラー処理は省略しています。

#include <asm/types.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <linux/netlink.h>
#include <linux/genetlink.h>
#include <linux/quota.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int
main(void)
{
  struct sockaddr_nl src_addr;

  // 1. PF_NETLINKのsocketを作成
  int sock = socket (PF_NETLINK, SOCK_RAW, NETLINK_GENERIC);

  memset (&src_addr, 0, sizeof(src_addr));
  src_addr.nl_family = AF_NETLINK;

  // 2. AF_NETLINKでbind
  bind (sock, (struct sockaddr*)&src_addr, sizeof(src_addr));

  // 3. 受信対象のgroup IDをsetsockoptで設定
  int group = GENL_ID_VFS_DQUOT;
  setsockopt (sock, SOL_NETLINK, NETLINK_ADD_MEMBERSHIP,
                    &group, sizeof(group));

  // 4. 受信用バッファを準備
  struct sockaddr_nl nla;
  char buf[8192];
  struct iovec iov = {buf, sizeof(buf)};
  struct msghdr msg = {
    .msg_name = (void *) &nla,
    .msg_namelen = sizeof(nla),
    .msg_iov = &iov,
    .msg_iovlen = 1,
  };

  do
    {
      // 5. kernelからのメッセージを受信
      int len = recvmsg (sock, &msg, 0);

      // 6. kernelからのメッセージをparse
      for (struct nlmsghdr *nh = (struct nlmsghdr *)buf;
           NLMSG_OK(nh, len);
           nh = NLMSG_NEXT(nh, len))
        {
          struct genlmsghdr *gh = NLMSG_DATA(nh);
          char * genlmsg_data = (char *) gh + GENL_HDRLEN;
          struct nlattr * attr[QUOTA_NL_A_MAX+1];
          attr[1] = (struct nlattr *)genlmsg_data;
          for (int i = 1; i < QUOTA_NL_A_MAX; i++)
            attr[i+1] = (struct nlattr *)((char *)attr[i] + attr[i]->nla_len);


          printf ("--------------------------------\n");

          u_int32_t qtype     = *(u_int32_t*)((char *)attr[QUOTA_NL_A_QTYPE]     + NLA_HDRLEN);
          u_int64_t excess_id = *(u_int64_t*)((char *)attr[QUOTA_NL_A_EXCESS_ID] + NLA_HDRLEN);
          u_int32_t warning   = *(u_int32_t*)((char *)attr[QUOTA_NL_A_WARNING]   + NLA_HDRLEN);
          u_int32_t dev_major = *(u_int32_t*)((char *)attr[QUOTA_NL_A_DEV_MAJOR] + NLA_HDRLEN);
          u_int32_t dev_minor = *(u_int32_t*)((char *)attr[QUOTA_NL_A_DEV_MINOR] + NLA_HDRLEN);
          u_int64_t caused_id = *(u_int64_t*)((char *)attr[QUOTA_NL_A_CAUSED_ID] + NLA_HDRLEN);

          printf ("QUOTA_NL_A_QTYPE = %u (%s)\n", qtype, qtype_to_str(qtype));
          printf ("QUOTA_NL_A_EXCESS_ID = %lu\n", excess_id);
          printf ("QUOTA_NL_A_WARNING = %u (%s)\n", warning, warning_to_str(warning));
          printf ("QUOTA_NL_A_DEV_MAJOR = %u\n", dev_major);
          printf ("QUOTA_NL_A_DEV_MINOR = %u\n", dev_minor);
          printf ("QUOTA_NL_A_CAUSED_ID = %lu\n", caused_id);
        }
    }
  while (1);

  close (sock);
}

コメントでも記載していますが、下記の流れで受信できます。

  1. PF_NETLINKのsocketを作成
  2. AF_NETLINKでbind
  3. 受信対象のgroup IDをsetsockoptで設定
  4. 受信用バッファを準備
  5. kernelからのメッセージを受信
  6. kernelからのメッセージをparse

6でkernelから来るメッセージの形式は下記のとおりです。

型名 サイズ[byte] 中身
struct nlmsghdr NLMSG_HDRLEN (16) nlmsg_len: messageの長さ(76)
nlmsg_type: message種別(GENL_ID_VFS_DQUOT)
nlmsg_flags: フラグ(0)
nlmsg_seq: シーケンス番号
nlmsg_pid: 送信元pid (quota通知の場合kernelなので0)
struct genlmsghdr GENNL_HDRLEN (4) cmd: command種別(QUOTA_NL_C_WARNING)
version: GENL_ID_VFS_DQUOTのversion(5.4.13時点では1)
struct nlattr NLA_HDRLEN(4) nla_len: attributeの長さ(8)
nla_type: attribute種別(QUOTA_NL_A_QTYPE)
u32 4 quotaの種別(USRQUOTA or GRPQUOTA or PRJQUOTA)
struct nlattr NLA_HDRLEN(4) nla_len: attributeの長さ(12)
nla_type: attribute種別(QUOTA_NL_A_EXCESS_ID)
u64 8 通知の要因となったUID or GID or ProjectID (QTYPEに対応したID)
struct nlattr NLA_HDRLEN(4) nla_len: attributeの長さ(8)
nla_type: attribute種別(QUOTA_NL_A_WARNING)
u32 4 通知のイベント種別。以下のいずれか。
#define QUOTA_NL_IHARDWARN 1 /* Inode hardlimit reached /
#define QUOTA_NL_ISOFTLONGWARN 2 /
Inode grace time expired /
#define QUOTA_NL_ISOFTWARN 3 /
Inode softlimit reached /
#define QUOTA_NL_BHARDWARN 4 /
Block hardlimit reached /
#define QUOTA_NL_BSOFTLONGWARN 5 /
Block grace time expired /
#define QUOTA_NL_BSOFTWARN 6 /
Block softlimit reached /
#define QUOTA_NL_IHARDBELOW 7 /
Usage got below inode hardlimit /
#define QUOTA_NL_ISOFTBELOW 8 /
Usage got below inode softlimit /
#define QUOTA_NL_BHARDBELOW 9 /
Usage got below block hardlimit /
#define QUOTA_NL_BSOFTBELOW 10 /
Usage got below block softlimit */
struct nlattr NLA_HDRLEN(4) nla_len: attributeの長さ(8)
nla_type: attribute種別(QUOTA_NL_A_DEV_MAJOR)
u32 4 通知の要因となったブロックデバイスのメジャー番号
struct nlattr NLA_HDRLEN(4) nla_len: attributeの長さ(8)
nla_type: attribute種別(QUOTA_NL_A_DEV_MINOR)
u32 4 通知の要因となったブロックデバイスのマイナー番号
struct nlattr NLA_HDRLEN(4) nla_len: attributeの長さ(12)
nla_type: attribute種別(QUOTA_NL_A_CAUSED_ID)
u64 8 通知の要因となったファイル操作を行ったUID

もうちょっと楽できないの?

netlinkを使ってquotaの監視をするquota_nldというコマンドがあります。
quotaの制限を超えたら、D-busにメッセージを送ってくれます。
D-busを使っているシステムなら便利ですね。

D-bus使ってない and/or 使いたくないんだけど?

libnlというライブラリがあります。
quota_nldもこのライブラリを使って実装しています。
ただこのライブラリ、あまり抽象度は高くないです。
というかほぼそのままです。
メッセージ解析時のキャストなどをきれいに書くためのユーティリティ関数群ぐらいで捉えた方がいいです。
このライブラリを使う場合でも、メッセージのフォーマットと前述のサンプルコードの流れは把握しておく必要があります。

quota fileのフォーマット

filesystemにquotaの上限が設定できることは確認できました。
それではその上限値と、今使っているサイズはどこに保存されているのでしょうか?

答えはもちろんfilesystemの中、です。

metaデータとしてsuperblockに保存されてそうですが、
ext4の場合usrquota、grpquota、prjquotaがそれぞれ普通にinodeを持つファイルとして、存在します。

ただし、ファイルツリーとしては見えない、隠しinodeという扱いです。
inode番号はusrquotaには3が、grpquotaには4が予約されています(Special inodes
)。

prjquotaはというと、superblockにinode番号が保持されており、そこから辿れます。
prjquotaは後から追加されたため、このような構成になっているようです。
なお、usrquota/grpquotaのinode番号もsuperblockには保持されています(The Super Block)。

quotaファイルを取り出してみる

まずはquotaファイルのinode番号を確認してみましょう。
superblockの情報を見るには、dumpe2fsコマンドが便利です。

$ dumpe2fs -h test_ext4.img | grep 'quota inode'
dumpe2fs 1.45.5 (07-Jan-2020)
User quota inode:         3
Group quota inode:        4

前述の通り、予約されたinode番号が使われていることがわかります。

次に、inode番号3のusrquotaファイルの中身を取り出します。
普通のファイルはmountすることで参照できますが、
quotaファイルは隠しinodeなので参照できません。
ということで、debugfsコマンドを使って取り出してみます。

$ debugfs -R 'dump <3> userquota.dump' test_ext4.img
debugfs 1.45.5 (07-Jan-2020)
$ ls -l userquota.dump
-rw-r--r-- 1 test test 7168  2月  1 00:24 userquota.dump
$ hexdump userquota.dump
0000000 1f11 d9c0 0001 0000 3a80 0009 3a80 0009
0000010 0000 0000 0007 0000 0000 0000 0005 0000
0000020 0000 0000 0000 0000 0000 0000 0000 0000
*
0000400 0002 0000 0000 0000 0000 0000 0000 0000
0000410 0000 0000 0000 0000 0000 0000 0000 0000
*
0000800 0003 0000 0000 0000 0000 0000 0000 0000
0000810 0000 0000 0000 0000 0000 0000 0000 0000
*
0000c00 0004 0000 0000 0000 0000 0000 0006 0000
0000c10 0000 0000 0000 0000 0000 0000 0000 0000
*
0001000 0005 0000 0000 0000 0000 0000 0000 0000
0001010 0000 0000 0000 0000 0000 0000 0000 0000
*
0001400 0000 0000 0000 0000 0002 0000 0000 0000
0001410 0000 0000 0000 0000 0000 0000 0000 0000
0001420 0000 0000 0000 0000 0002 0000 0000 0000
0001430 0000 0000 0000 0000 0000 0000 0000 0000
0001440 3400 0000 0000 0000 0000 0000 0000 0000
0001450 0000 0000 0000 0000 03e8 0000 9069 c947
0001460 0000 0000 0000 0000 0000 0000 0000 0000
0001470 0001 0000 0000 0000 1000 0000 0000 0000
0001480 0800 0000 0000 0000 0000 0040 0000 0000
0001490 811d 5e3d 0000 0000 0000 0000 0000 0000
00014a0 0000 0000 0000 0000 0000 0000 0000 0000
*
0001ba0 0005 0000 0000 0000 0000 0000 0000 0000
0001bb0 0000 0000 0000 0000 0000 0000 0000 0000
*
0001c00

7,168 byteのバイナリデータが取得できました。
そこはかとなく0x400 (=1,024)単位の構造が見えますね。

quotaファイルの構造

quotaファイルは頻繁に更新・参照され、かつファイルシステムの容量を消費するという厳しい制約があるので、
参照の計算量を抑えつつ空間消費量も減らすために結構複雑な構造をしています。

一言で言えば4階層の可変長連想配列とでも言うのでしょうか。。。

なおquotaファイルの構造は仕様とか見つからなかったので、
ここで述べていることはdebugfsのコードから読みとったものになります。

  • 3種類のblock(1KiB)の集合からなる
  • header block
    • 必ず先頭に配置されるblock
    • quotaファイルを示すマジックナンバーや構造のバージョン、blockの個数などの情報が含まれる
  • inner block
    • 全部で4階層ある
    • 次の階層のblock番号(4byte)が256個格納される
    • どの要素を参照するかは、IDによって一意に決まる。
    • 例えば、IDが0x12345678の場合、1階層目では0x12番目が、2階層目では0x34番目が参照される。
  • leaf block
    • 最初にblockの使用状況などを保存するヘッダ(16byte)がある
    • その後、IDごとのquota設定状況を保存するエントリ(72byte or 48byte)が繰り返される
    • QFMT_VFS_V1の場合、容量やinode数などが64bitなので72byte x 14個
    • QFMT_VFS_V0の場合、容量やinode数などが32bitなので48byte x 21個
    • この配列の中で目的のIDのエントリを見つけるのは線形探索(たかだか21個)
  • little endian

quota file format.png

終わりに

いつもにも輪をかけて誰得感の強い記事になってしまいました。。。
ただあまり情報がないところなので、いつかまた調べないといけなくなった未来の自分に向けて公開しておきます。

参考

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away