ファイルシステムサイズの拡張時にデータベースアクセスがスローダウンする問題の解決

はじめに

こんにちは、技術顧問のsatです。

サイボウズでは、ファイルシステムサイズ拡張時にデータベースアクセスがスローダウンするという問題に長年悩まされてきました。本記事では運用本部の藤田と深谷がこの問題を解決した流れについて報告いたします。問題を解決するために2人はLinuxカーネルを修正しました。修正は社内に閉じたものではなく、執筆当時の最新 Linuxカーネルであるv4.17にマージされています。

問題

以下の操作の後にデータベースへのアクセスが一時的にスローダウンする

  1. ブロックデバイスのサイズを拡張する
  2. 上記デバイス上にあるファイルシステムのサイズを拡張する

原因

linuxカーネルはブロックデバイスのサイズ変更(縮小および拡張)時に、当該デバイス上にあるファイルシステムのページキャッシュ(後述)を無効化する*1

解決方法

ブロックデバイスのサイズ拡張時にはページキャッシュを無効化しないようにlinuxカーネルを変更する

ページキャッシュ

ページキャッシュについて既にご存知のかたはこの節は飛ばしてください。

ページキャッシュとはストレージデバイス上のデータへのアクセス速度を高速化するためのカーネルの機能です。

最近のコンピュータシステムにおいてメモリ上のデータをCPUに転送する速度はナノ秒オーダーです。これに対してストレージデバイス上のデータをメモリに転送する速度はSSDではマイクロ秒オーダー、HDDに至ってはミリ秒オーダーです。つまりストレージデバイス上のデータへのアクセス速度はメモリ上のデータへのそれに比べて数桁遅いと言えます。この問題を解決するのがページキャッシュです。

Linuxカーネルはプロセスがファイルシステム上のファイルにアクセスした場合、ファイルのデータを保持するストレージデバイスに毎回アクセスしません。読み出しにおいては初回読み出し時にメモリ上のページキャッシュと呼ばれる領域にストレージデバイス上のデータをコピーした上で、プロセスにはページキャッシュのデータをコピーして渡します。二回目以降の読み出しはストレージデバイスにはアクセスせず、ページキャッシュ上のデータを渡します。書き込みについてもストレージデバイスには直接アクセスせず、ページキャッシュにデータを書き込んだ段階でプロセスには書き込みが終わったと報告します。その後所定のタイミングでページキャッシュからストレージデバイスにデータを書き戻します。これによってストレージデバイス上のデータへのアクセスが実質的にメモリアクセスと同等の速度で実現できるわけです。

調査ログ

問題の検出

弊社のシステムではデータの一部をSQLite上に保存しています。データベースのサイズが増加してファイルシステムの空き領域が足りなくなってくると、次のような手順でファイルシステムのサイズを拡張します。

  1. ファイルシステムが存在するブロックデバイス*2のサイズを拡張する
  2. 上記デバイス上にあるファイルシステムのサイズを拡張する

ここで手順2の後にデータベースへのアクセスが一時的にスローダウンするという問題が発生していました。この問題はお客様が使用するアプリケーションの応答速度劣化として顕在化していました。

統計情報の調査

freeコマンドやsarコマンドによってLinuxの統計情報を確認した結果、次のようなことがわかりました。

  • 手順1の後にシステムに存在するページキャッシュの量が大幅に減少していた
  • 手順2の後にデータベースアクセス性能の劣化に連動してストレージデバイスへのアクセスが発生していた
  • 上記性能劣化の解消に連動してストレージデバイスへのアクセスは減少していた

この事実から、ブロックデバイスのサイズ拡張において、その上にあるファイルシステムのページキャッシュが無効化されているであろうことがわかりました。

カーネルソースの調査

ページキャッシュはカーネルの機能なので、深谷がカーネルソースの調査にとりかかりました。その結果、ブロックデバイスのサイズ変更処理の延長で呼ばれるcheck_disk_size_change()関数の中に次のような箇所を見つけました。

/**
 * check_disk_size_change - checks for disk size change and adjusts bdev size.
 * @disk: struct gendisk to check
 * @bdev: struct bdev to adjust.
 *
 * This routine checks to see if the bdev size does not match the disk size
 * and adjusts it if it differs.
 */
void check_disk_size_change(struct gendisk *disk, struct block_device *bdev)
{
    loff_t disk_size, bdev_size;

    disk_size = (loff_t)get_capacity(disk) << 9;
    bdev_size = i_size_read(bdev->bd_inode);
    if (disk_size != bdev_size) {                                              # ... (1)
        printk(KERN_INFO
               "%s: detected capacity change from %lld to %lld\n",
               disk->disk_name, bdev_size, disk_size);
        i_size_write(bdev->bd_inode, disk_size);
        flush_disk(bdev, false);                                           # ... (2)
    }
}
EXPORT_SYMBOL(check_disk_size_change);

変更前のサイズ(bdev_size)と変更後のサイズ(disk_size)が異なれば(1)が真と評価されて、(2)において当該デバイス上のファイルシステムのページキャッシュが無効化されていることがわかりました。

解決方法の調査

この問題を回避する何らかの設定項目がカーネルに存在しないかと更にソースを調査しましたが、残念ながら見つかりませんでした。このためカーネルの修正によって問題を解決できないかという検討を始めました。

まずは、そもそもブロックデバイスのサイズを変更する際にページキャッシュを無効化する理由が何かということを調べました。これにはflush_disk()関数を呼ぶようになったcommitをgit blameコマンドによって見つけた上で、そのdescriptionを読むという方法を使いました。コードが書かれた意図が分からない場合はコメントあるいは変更時のdescripionを見るのが王道です。

github.com

上記commitの言わんとすることを要約すると、次のようになります。

  • 縮小時に縮小対象領域のデータを読み書きしていると、読み書き中のデータが行先を失うためにI/Oエラーが発生する
  • 拡張については、「縮小直後に拡張」という特殊な場合に縮小時との問題が発生しうる

深谷はこの後、関連ソースの全調査によって、サイズ縮小直後に拡張したとしてもカーネル内の排他制御により上記の問題は起こりえないことを突き止めました。つまり、サイズ拡張時のページキャッシュの無効化は実は不要だったというわけです。

このアイデアを形にしたものが次のパッチです。

fs/block_dev.c | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)

diff --git a/fs/block_dev.c b/fs/block_dev.c
index 44d4a1e..d17603c 100644
--- a/fs/block_dev.c
+++ b/fs/block_dev.c
@@ -1078,7 +1078,14 @@ void check_disk_size_change(struct gendisk *disk, struct block_device *bdev)
                "%s: detected capacity change from %lld to %lld\n",
                name, bdev_size, disk_size);
         i_size_write(bdev->bd_inode, disk_size);
-        flush_disk(bdev, false);
+        if (bdev_size > disk_size) {
+            flush_disk(bdev, false);
+        } else {
+            if (!bdev->bd_disk)
+                return;
+            if (disk_part_scan_enabled(bdev->bd_disk))
+                bdev->bd_invalidated = 1;
+        }
     }
 }
EXPORT_SYMBOL(check_disk_size_change);

このパッチを当てたカーネルを開発環境においてテストをした後に本番環境に適用しました。これによって社内の問題は解決しました。

upstream化

開発環境におけるパッチのテストと並行して、upstream(公式)のlinuxカーネルへのパッチの取り込み作業に藤田がとりかかりました。社内の問題は既に解決しているのにこのようなことをする理由は次の通りです。

  • 弊社には、弊社のシステムを支えるOSSの改善に貢献するという方針がある
  • 将来のOSアップグレード時に逐次修正パッチをバックポートしないで済む

最初に投稿したときのパッチの文面は次のようなものでした。

linux-kernel - [RFC PATCH] fs: don't flush pagecahce when expanding block device

このメールは、linuxコミュニティの誰からも返事がもらえませんでした*3。しかし、この後めげずにパッチおよびその投稿文面をシンプルにしながら何度も再投稿した結果、ようやく数か月にlinuxの開発版とも言えるnext treeに、最終的にv4.17にパッチが取り込まれました。

github.com

これにて、この問題に関するすべての作業が完了しました。今後v4.17以降の公式カーネル、およびその派生版である各種linuxディストリビューションのカーネルではこの問題が発生しなくなりました。

おわりに

この問題の修正パッチは二千万行におよぶlinuxカーネルのソースコードの中のたった4行の追加、2行の削除に過ぎませんが、ここにたどり着くまでに上記のような様々な試行錯誤がありました。これはソース修正の手間は変更の行数に比例しないといういい例ではないでしょうか。

本記事が読者のみなさまの今後のトラブルシューティングに役立てば幸いです。

*1:正確にはページキャッシュだけではなくファイルシステム内のパス解決などに用いるデータのキャッシュなども無効化しているのですが、ここでは割愛します。

*2:リモートサーバ上のLVMボリュームに対応するiSCSIデバイスを2つ束ねてRAID1構成にしたmultiple device

*3:パッチ投稿においては無反応が一番やっかいです。なぜかというと、改善案を提示されたり反対されたりするのは善かれ悪しかれ誰かに注目されているので改善なり議論なりをすればよいのですが、反応が無い場合は次の一手が打ちにくいためです。

あわせて読みたい