RACCOON TECH BLOG

株式会社ラクーンホールディングス
技術戦略部より、
tipsやノウハウなど
技術的な話題を発信いたします。

一緒にラクーンのサービスをつくりませんか?採用情報はこちら

しくじり先生のように学ぶ「NFS+sqliteで苦労した話から学ぶ、問題解決の考え方」という勉強会をやってみました

こんにちは、羽山です。
今回は社内で実施した勉強会をほぼそのままブログ記事にしています。

ITエンジニアたるものドキュメントを残したり勉強会を実施したり分かりやすい設計にリファクタリングしたりなど、日々 知見の伝達 を意識していると思います。主要な知見はたいていそれらの方法で満たせるのですが、しかし残念ながら中には画一的な方法では伝えにくいものもあったりします。

ところで伝統芸能や工芸の世界では技術の継承において 背中を見て学ぶ という方法が用いられることがあるようです。
これと似た手法はITエンジニアの世界にもあって、先輩エンジニアとペアプログラミングをすることで文書では伝わりにくい技術や考え方・テクニックを学ぶ、みたいなことをやったりします。
ただし知見の伝達を目的としたペアプログラミングには難点があって、1度の実施で先輩エンジニアから学ぶことができる対象者が1名に限られたり、実施毎に学習できる内容にムラがあったりで画一的な知見の伝達には向いていません。

そこでペアプログラミングのような 背中を見て学ぶ類いの知見 を画一的に伝えることができないか?と考えたのが 「しくじり先生」のように実際に起きた問題に対処するプロセスを細かくまとめた勉強会の実施 という方法でした。
思考のプロセスを共有することには一定の価値があるのでは?という思いからその勉強会の内容を記事として公開してみました。

しくじり先生 - NFS+sqliteで苦労した話から学ぶ、問題解決の考え方 (SpeakerDeck)

以降は発表スライドの生成元 Markdown からブログ用に多少手直ししています。

はじめに

なぜ「しくじり先生」のように学ぶのか?

問題解決の過程はエンジニアごとに個性が表れやすかったり、ノウハウの塊だったりと少なからず参考になる知見があるはずです。

一方で他のエンジニアがどのように問題解決しているのかは共有される機会が少ない状況にあります。

そこで今回は 仮想サーバ上で動作していたマイクロサービスを Docker化する案件で発生した問題解決していく過程を一緒になぞる ことで問題解決のプロセスを追体験してもらえたらと思っています。

マイクロサービスのシステム構成

良い悪いは別として今までは 各サーバのローカルストレージを永続化可能なストレージとして利用可能 でしたが、Docker化するとコンテナ内にデータを永続化するわけにはいきません。
そこで Docker化のためにはデータストアを複数の Dockerコンテナから共有可能な状態にする必要がありました。

今回発生した問題(これを解決します)

詳細は後述しますが結論として Docker化において以下のようなシステム構成を選択しました。

開発から単体テストまではローカルファイルシステムを利用して問題なく進行しましたが、ステージング環境でのテストフェーズで 複数コンテナから同時に sqlite DBファイルを更新をするとデータ構造が壊れる という問題が発生しました。

ちなみに sqlite公式サイトのFAQに書かれたNFSに対するスタンスは以下の通りです。

Locking mechanism might not work correctly if the database file is kept on an NFS filesystem. This is because fcntl() file locking is broken on many NFS implementations. You should avoid putting SQLite database files on NFS if multiple processes might try to access the file at the same time.

(NFSではロック機構が正しく動かない可能性があります。これは fcntl() のファイルロックが多くのNFSの実装で壊れているからです。もし複数のプロセスから同時にデータベースファイルへアクセスするなら、NFSにデータベースファイルを配置するのは避けるべきです。)

なぜ sqlite を採用していたのか?(Docker化の以前の話)

冒頭に簡単な説明は記載しましたが改めてまとめます。

保持するデータがシンプルで更新頻度が低くパフォーマンス要件も緩かった

シンプルなデータ構造ならテキストファイル等に保存すれば良かったのでは?

1カラムに20MB超のテキストをいくつか保持する要件があった

これらの理由から 今後の選択肢に対して最も動きやすい一歩として sqlite を採用 しました。必要に応じて交換しやすい部品というイメージです。

データストアの共有

Docker化によって データストアの共有 が必要になったため、次の選択肢から選択することとしました。

この選定を行った背景を理解するために、まずは sqlite と他の RDBMS の違いを考えてみましょう。

sqlite と他の RDBMS(MySQL, PostgreSQL等)の違いとは?

両者とも SQL でデータを操作する RDBMS で一見すると似ていますが動作原理は大きく異なります。
システムアーキテクトとしてシステム構成を考えるためには、そういったアーキテクチャ面の違いを理解する必要があります。

イモリとヤモリみたいなもので一見すると似ているけど両生類と爬虫類など、そもそもの成り立ちに違いがあります。

MySQL, PostgreSQL などはデーモンが集中管理する

MySQL などはサーバ上でデーモンとして起動して、利用するアプリはTCP/IPなどの通信でDBデーモンとやりとりをします。DBとアプリのプロセスは別になるし、多くの場合には起動するサーバも別です。
アプリ側がデータ操作をしたい場合はSQLという形でDBデーモンに依頼するだけで実際にデータの読み込み・更新を実行するのはDBデーモンです。

単一のデーモンがデータファイルの読み込み・更新を集中管理するのでデータ整合性の保証が容易 であるという特徴があります。例えば複数のアプリが同時に接続して更新処理をしても、実施の処理を担当するのは単一デーモンなので内部で排他制御ができます。

一方で欠点もあって、維持管理コストは高くなりがちです。サーバの用意からデーモンの死活監視やデータのバックアップなど一連のコストが必要となります。またシステム設計において、 DBだけ停止している状態 を考慮する必要があります。

sqlite にはデーモンがない

では sqlite はどうかというと、sqlite にはデーモンがありません。

sqlite の実態は単なるライブラリとCUIツールにすぎません。
プログラムからどのように利用するかというと、例えば Java では sqlite用の JDBCドライバ経由で普通のデータベースと同じように利用できます。
JDBCドライバは本来DBサーバとの通信を中継する役割ですが、sqlite の場合は JDBCドライバ内に sqlite ライブラリを含んで、JDBCドライバ自体が sqlite DBファイルを直接参照・更新するという構造になっています。

つまりアプリのプロセス自体が sqlite ファイルを直接開いて参照・更新することになります。そのためDBだけが死ぬという可能性を考慮する必要が無くなります。(ただしDBファイル破損などは発生します)

しかし sqlite にはデーモンがないのに複数のアプリからの同時更新をどのように排他制御しているのでしょう?

sqlite DBファイルを操作してみる

では実際に sqlite DBファイルを python や CUIツールから操作して、複数のプロセスから同時に更新した場合の動作を確かめてみます。

まずは sqlite DBファイルを準備してテーブルを作成します。

sqlite3 test.db "create table t ( val varchar )"

今回は各プロセスの状況を常時確認したいので、以下の少々長い watch コマンドを利用します。
これを実行すると python と sqlite3 プロセスが開いているファイルディスクリプタと保持するロックがリアルタイムで表示されます。

watch -tn 1 'bash -c '"'"'while read f; do echo -e "PID:$f $(ps -p $f -o comm=)\n$(ls -l /proc/$f/fd)\n$(ls /proc/$f/fdinfo |tail \
  -n+4 |while read fd; do echo "fd: $fd\n$(tail -n+4 /proc/$f/fdinfo/$fd)"; done)\n\n"; done < <(pgrep python; pgrep sqlite3)'"'"

では Python と sqlite CUIツールから先ほど作成したDBファイルを開いてみます。

# Python
import sqlite3
conn = sqlite3.connect('test.db')
# sqlite CUI
sqlite3 test.db

watch を実行中のコンソールが以下のような表示となり、各プロセスが 3番のファイルディスクリプタで test.db を開いている様子を確認できます。

PID:22764 python
total 0
lrwx------ 1 jun jun 64 Oct  4 11:34 0 -> /dev/pts/25
lrwx------ 1 jun jun 64 Oct  4 11:34 1 -> /dev/pts/25
lrwx------ 1 jun jun 64 Oct  4 11:34 2 -> /dev/pts/25
lrwx------ 1 jun jun 64 Oct  4 11:35 3 -> /home/jun/works/sqlite/test.db
fd: 3

PID:17833 sqlite3
total 0
lrwx------ 1 jun jun 64 Oct  4 14:00 0 -> /dev/pts/19
lrwx------ 1 jun jun 64 Oct  4 14:00 1 -> /dev/pts/19
lrwx------ 1 jun jun 64 Oct  4 14:00 2 -> /dev/pts/19
lrwx------ 1 jun jun 64 Oct  4 14:00 3 -> /home/jun/works/sqlite/test.db
fd: 3

排他制御の確認

次は sqlite がどのように排他制御を行っているのか確認してみます。
現在は python と sqlite3 という独立したプロセスが同じDBファイルを同時に開いているので、それぞれのプロセスが好きなタイミングで書き込むと当然ファイルは壊れてしまいます。

MySQL のようなデーモン型ならば排他処理に様々な方法を利用できますが、sqlite は集中管理できるデーモンがいないので 排他制御をOS側の仕組みに頼っています

実際に試してみましょう。
sqlite3 コマンド側で以下のSQLを実行してトランザクションの開始とレコードの挿入をします。

begin;
insert into t values ('a');

sqlite は排他制御をOS側の仕組みに頼っているので、/proc からその情報を取得することができます。
watch のコンソールはその /proc から情報を取得しているので、以下のような内容でファイルロックを取得した様子を確認できます。

$ cat /proc/<pid>/fdinfo/<fd>
lock:   1: POSIX  ADVISORY  WRITE 17833 08:10:16995 1073741825 1073741825
lock:   2: POSIX  ADVISORY  READ  17833 08:10:16995 1073741826 1073742335

次に Python 側からレコードを挿入してみると、データベースがロックされているのでエラーになります。

# python
cur = conn.cursor()
cur.execute('insert into t values (?)', ('b',))

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
sqlite3.OperationalError: database is locked

sqlite は排他制御をOS側に頼っていることもあって他のRDBMSのような複雑なロック機構は用意されておらず、あらゆる更新に対してデータベース全体の排他ロックを取得します。
更新が多いデータベースではパフォーマンス的な問題になりえますが、一方で プログラム言語を問わずロックが相互に正しく動作する というメリットがあります。

実際に sqlite のCUIツールと Python から同時に更新してみたら正しく排他制御されています。

sqlite の利用するOS側の排他制御とは?

sqlite の利用する排他制御の仕組みは fcntl() と呼ばれています。当然ですが sqlite 専用の仕組みではなく 汎用的な排他制御機構 なのでプログラムから直接利用することもできます。

新しいコンソールを開いて python プロセスから以下を実行してみます。

# python
import fcntl

fp = open('test.db', 'r+b')
fcntl.lockf(fp, fcntl.LOCK_EX)

先ほどまでのような sqlite3 モジュール経由ではなく、open() で直接開いてからロックを取得しています。
しかしこのコードを実行しても応答は返ってきません。なぜなら先ほどの sqlite CUIのプロセスがそのファイルに対して排他ロックをまだ保持しているからです。
この時点で正常にロックが機能していることを確認できました。

では sqlite CUIツールで未解決のままにしていたトランザクションをコミットしてロックを解放してみます。

commit;

その瞬間に fcntl.lockf() の応答が返ってきてロックを取得できました。
さらにそのまま sqlite CUIツールから新規レコードを挿入してみると失敗することも確認できます。

insert into t values ('c');
Error: database is locked

python プロセスが fcntl を直接利用してロックを取得しているので、sqlite CUIツールがロックを取得できなかったことが分かります。

OSの仕組みを利用するとはどういうことか?

OSの仕組みを利用する ことは一般的に システムコール と呼ばれて以下のような特徴を持っています。

システム設計において高レイヤは問題領域に近いので自然と考慮されます。
今回の例では「SQLが使えるデータストアが欲しい」「複数ノードで共有できるデータストアが欲しい」などが高レイヤにあたります。

これらの要件を満たすのは当然ですが、システムアーキテクトの役割にはそこからさらにレイヤを下って考慮する所にもあります。低レイヤまで考慮された技術選定はプロダクトの完成度を高くしてくれます。
(※とはいえ、過度に低レイヤをチューニングしすぎると互換性の問題も起きるのでバランスも大切です)

様々な材料から設計したシステム構成は、その選定理由を言語化しましょう。

システムアーキテクトとは全知全能の神ではないのですべてを考慮することは不可能で必ず抜けはあります。その上で考慮されたポイントや理由が言語化されていれば、あとからアーキテクチャを変更したい場合の参考になります。

話題を戻して、問題発生からの経緯を振り返り

結果として今回は sqlite を選定しました。
選定において NFS で sqlite DBファイルを共有する点について懸念を感じつつも NFS側の進化もあるはずで、ロックは設定すれば実現できると考えました。(これ自体は間違っていない)

その結果として発生した DBファイル破損問題を振り返ってみます。

DBファイル破損について分解して考える

sqlite は複数プロセスからの更新に対応しているので、sqlite 開発者の想定する使い方をすれば壊れないはずです。
もちろんドキュメントが誤っている可能性もありますが、低い可能性に対する検証は後回しにするべきなので検証の優先度を下げました。

この二つの前提条件から必然的に

「sqliteは同時更新に対応しているけどなんらかの理由でそれが正しく動いていない」

という状況が導き出されます。

さらに冒頭で引用した NFS のドキュメントには NFS における排他制御の問題がまさに指摘されていました。
元々想定していた問題が発生したという状況なので、「ファイルの排他制御」にあたりを付けて検証をすすめます。

NFSのマウントオプションを確認

マウントされたファイルシステムはマウントオプション次第で動きが変わるので、まずは /proc/mounts からマウント状況を確認します。

$ cat /proc/mounts
...
:<mount_from> <mount_to> nfs rw,sync,noatime,vers=3,rsize=32768,wsize=32768,namlen=255,acregmin=0,acregmax=0,acdirmin=0,acdirmax=0,soft,nolock,
proto=tcp,timeo=30,retrans=6,sec=null,mountaddr=10.100.5.212,mountvers=3,mountproto=tcp,local_lock=all,addr=<ip> 0 0
...

vers=3nolock が指定されていました。

では nolock について man nfs で確認します。

$ man nfs
lock / nolock  Selects  whether  to use the NLM sideband protocol to lock files on the server.  If neither op‐
               tion is specified (or if lock is specified), NLM locking is used for this  mount  point.   When
               using  the  nolock  option,  applications can lock files, but such locks provide exclusion only
               against other applications running on the same client.  Remote applications are not affected by
               these locks.

「nolock でもロック可能だけど同じNFSクライアント上で動作するアプリケーションにのみ作用する」 とのことです。
これで問題に対する直接の原因が分かりました。

sqlite が NFS 上のファイルを fcntl() でロックすることは可能だけど同一のNFSクライアント(同サーバ)のみに作用して、別サーバからは同時にロック取得できてしまうことが分かりました。
これでは複数のサーバから書き込み要求が競合するとファイルが破損するのは必然です。

知識の精度を高める

現在のマウント情報を取得したい場合に /etc/fstab を参照するのは正しいでしょうか?
大抵はそれでも正しいけどサーバの状況によってはマウント状態が fstab に反映されていない可能性もあります。

誤った情報を元に判断するとすれ違いが累積して、最終的に大きな問題になることがあります。
そうならないためにも、 持っている知識の精度を高めましょう。

「fstab はマウント状態を表す」は精度が低い状態です。
一方で「fstab はOS起動時にマウントするための設定ファイル」と認識していれば、必ずしもリアルタイムな情報を表していないことに気づくことができます。

現在のマウント状況をより正しく得るなら /proc/mounts を参照するべきです。
こういったわずかな精度の差は徐々に積み重なって大きな差となって表れます。

マウントオプションを lock に変更できるか検討

man nfs をさらに確認すると NFSv3 では NFS とは別に NLM(Network Lock Manager) というプロトコルでロックを管理していて、ロックをする場合はその設定も必要になるとのことが分かりました。
それらを踏まえてインフラチームと協議の結果、マウントオプションを lock に変更する案は採用しないことになりました。

該当マイクロサービスのためだけに NLM用のデーモンを管理した上でシステムの健全性を担保するくらいならなら、ノウハウのある MySQL を利用した方が管理上楽だと考えると「ですよね」としか言えない状況です。
またロック処理が改善されている NFSv4 はというと、利用している機器に既知の致命的な不具合が報告されていたため採用不可能でした。

対応方針の決定

方針決定の理由は以下でした。

しかしこの判断において1つ考慮漏れをしていました。

それは、 本番環境は書き込み頻度が低くてもテスト時は過度な同時書き込みテストを実施する ということです。

アプリ側で簡易的なロック機構を用意した上で複数並行で負荷をかけたところ、残念ながら sqlite DBファイルが壊れる現象が再発してしまいました。
書き込みタイミングさえ保護できれば安全だと考えていたので問題が再発したのは想定外でした。個別のモジュール単位では正常動作を確認できているので問題が潜む部分は必然的に現在の問題領域よりも低レイヤであることを意味します。

障害対応では問題の再現条件を重視する

発生する問題によってはその再現が難しい場合もあります。
問題に対処する場合に最も工数をかけるべき部分は 問題の再現 です。

問題への対処フロー

  1. 問題の発生(報告を受ける、目視で確認など)
  2. 問題の再現
  3. 問題を修正
  4. 問題の修正の確認(再現)

問題の報告を受けると つい再現方法の確認をスキップして修正に入ってしまいがち です。
そして修正後に確認してクローズしたら、実は実施したテストが不適切だったというケースはよくあります。

問題へ対処する際には必ず再現方法を確認してから行うようにしましょう。

NFS についてさらに調査

疑いの目を向けたのは NFSサーバでした。
Linux NFS-HOWTO のドキュメントを確認して以下の記述から NFS の書き込みが同期化されていないことが原因だと考えました。書き込み完了後にロックを解放しても、NFSサーバ側へはまだ反映されていないのかもと。

しかし後にこの推測が誤っていたことが発覚します。よく読むと意味が違うことを事実と取り違えていました。(後述)

Linux NFS-HOWTO - 5.9. NFS の同期動作と非同期動作

NFSversion3プロトコルのリクエストでは、ファイルをクローズするときやfsync()の時にNFSクライアントから「後出し」のcommitリクエストが発行されますが、これによってサーバは以前書き込みを完了していなかったデータ・メタデータをディスクに書き込むよう強制されます。そしてサーバは、sync動作に従うのであれば、この書き込みが終了するまでクライアントに応答しません。一方 もしasyncが用いられている場合は、commitは基本的にno-op(何も行なわない動作)です。なぜならサーバは再びクライアントに対して、データは既に永続的なストレージに送られた、と嘘をつくからです。 するとクライアントはサーバがデータを永続的なストレージに保存したと信じて自分のキャッシュを捨ててしまうので、これはやはりクライアントとサーバをデータ破壊の危険に晒すことになります。

誤った方向のまま同期書き込みへの変更をするオプションを調べました。

man nfs / man mount

ac / noac      Selects whether the client may cache file attributes. If neither option is specified (or if  ac
               is specified), the client caches file attributes.

               To  improve  performance,  NFS  clients cache file attributes. Every few seconds, an NFS client
               checks the server's version of each file's attributes for updates.  Changes that occur  on  the
               server in those small intervals remain undetected until the client checks the server again. The
               noac option prevents clients from caching file attributes so that applications can more quickly
               detect file changes on the server.

               In  addition  to preventing the client from caching file attributes, the noac option forces ap‐
               plication writes to become synchronous so that local changes to a file become  visible  on  the
               server  immediately.   That way, other clients can quickly detect recent writes when they check
               the file's attributes.

               Using the noac option provides greater cache coherence among NFS  clients  accessing  the  same
               files, but it extracts a significant performance penalty.  As such, judicious use of file lock‐
               ing is encouraged instead.  The DATA AND METADATA COHERENCE section contains a detailed discus‐
               sion of these trade-offs.
The noac option is a combination of the generic option sync, and the NFS-specific option actimeo=0.
acregmin=n     The  minimum  time (in seconds) that the NFS client caches attributes of a regular file before it requests fresh
               attribute information from a server.  If this option is not specified, the NFS client uses a  3-second  minimum.
               See the DATA AND METADATA COHERENCE section for a full discussion of attribute caching.

acregmax=n     The  maximum  time (in seconds) that the NFS client caches attributes of a regular file before it requests fresh
               attribute information from a server.  If this option is not specified, the NFS client uses a 60-second  maximum.
               See the DATA AND METADATA COHERENCE section for a full discussion of attribute caching.

acdirmin=n     The  minimum time (in seconds) that the NFS client caches attributes of a directory before it requests fresh at‐
               tribute information from a server.  If this option is not specified, the NFS client uses  a  30-second  minimum.
               See the DATA AND METADATA COHERENCE section for a full discussion of attribute caching.

acdirmax=n     The  maximum time (in seconds) that the NFS client caches attributes of a directory before it requests fresh at‐
               tribute information from a server.  If this option is not specified, the NFS client uses  a  60-second  maximum.
               See the DATA AND METADATA COHERENCE section for a full discussion of attribute caching.

actimeo=n      Using  actimeo  sets all of acregmin, acregmax, acdirmin, and acdirmax to the same value.  If this option is not
               specified, the NFS client uses the defaults for each of these options listed above.
async  All I/O to the filesystem should be done asynchronously.  (See also the sync option.)

sync   All  I/O  to  the  filesystem  should be done synchronously.  In the case of media with a limited number of write cycles
       (e.g. some flash drives), sync may cause life-cycle shortening.

ドキュメントによると noacsyncactimeo=0 を指定した効果をもつようなので同期書き込みする上にキャッシュも無効になるので問題の解決になるはずと考えました。
しかしこの時点ではドキュメントの読み込みと理解が甘く、各オプションの動作原理をしっかり把握せずに利用していました。

しかし、

マウントオプションの変更後に再度テストするとファイル破損は発生しなくなりました。

認識違いのまま偶然解決してしまうケースは少なからずあるのですが後からより大きな問題となることもあるので要注意です。ここに至るまでに本当の問題に気づけるチャンスはあったので反省点です。

この時点では問題が起きなくなったので 本人の認識としてはクローズした案件 となりました。

インフラチームから不穏な情報共有あり

「tcpdump で分析したところマウント時の sync, async で動作に多少の違いはあるものの、どちらでも妥当なタイミングで FILE_SYNC フラグ付きのパケットがNFSサーバに送信されているようです」

RFC 1813 - NFS Version 3 Protocol

If stable is FILE_SYNC, the server must commit the data written plus all file system metadata to stable storage before returning results.

NFSv3 の仕様を規定した RFC1813 には FILE_SYNC フラグが付いていると応答前にディスクへの永続化を保証することが記載されています。

RFC は策定された仕様というだけなので各プラットフォームで RFC 通り実装されている保証はないものの、仕様を調べる中立的な情報源としてとても役に立ちます。

この情報で大きな見落としに気づく

tcpdump の結果から今までうっすら感じていたモヤモヤが何だったのかようやく分かりました。
NFS にはサーバとクライアントがいますが、サーバ側の考慮が抜け落ちていたことに気づいてしまったのです。正確に言うと意識のどこかにはありましたが、あえて考えないようにしていました。
しかし実際のパケットレベルでのやりとり内容が出てくると否応なしにNFSサーバの存在を認識せざるを得ません。

この時点で分かっていることは以下です。

つまり、 偶然問題が解消しただけ だと分かってしまったので追加調査をすることになりました。

サーバ側の同期書き込みと今回の問題には関係がなかった

よく考えれば当たり前の事ですが、NFSサーバ側は 単一のデーモンがデータを集中管理するので書き込みが同期か非同期かに関わらず最新の情報を返却することができる はずです。仮にまだストレージに書き込んでないファイルへの読み込み要求があったら、メモリ上のバッファから返却したり書き込みを待ってから返却することなどができます。

ではマウント時に指定した同期・非同期の設定は何のためかというとサーバやデーモンの意図しないシャットダウンが発生した際に書き込まれていない情報が失われるというバックアップとリカバリの話でした。

問題が発生しうる場所を改めて整理

システムは様々な仕組みの上で動いているので、その仕組みがそれぞれレイヤとなって表れます。

今回の問題において、開発者から近い順に挙げると以下のレイヤに分けて考えることができます。

  1. 開発したプログラムの問題
  2. (開発したプログラムを動作させる)Pythonインタプリタの問題
  3. (Pythonインタプリタが内部で利用する)sqliteライブラリの問題
  4. (sqliteライブラリがシステムコール経由で呼び出す)Linuxカーネルの問題
  5. (Linuxカーネルがシステムコール内で利用する)NFSクライアントの問題
  6. (NFSクライアントがネットワーク経由でコールする)NFSサーバの問題(※ネットワークの問題も含む)

ここで重要なのは問題を確認するレイヤがどこなのかを意識しながら対応することです。
1~4 はこれまでの情報から問題の原因である可能性が低いことが分かっています。なぜならばローカルファイルシステムでは正常に動いていたからです。
6 は状況を整理した結果、単一デーモンでデータの不整合が発生する論理的な理由がないので可能性が低くなりました。
ネットワークの問題も有線のローカルネットワークなので低く見積もることができます。

となると次に検証すべきポイントは 5 のレイヤです。

対象レイヤに限定して検証をする

調査対象を Linux の NFSクライアントに絞れたのでまずは情報収集をします。

クライアント側の問題ということは、 サーバ側では更新されたデータがあるのに依然として古い情報を使ってしまう 状況が考えられます。
つまりクライアント側のキャッシュがどのように使われているかを調べることで欲しい情報を得ることができそうです。

キャッシュの影響で最新情報を取得できない事象はネットワーク経由のやりとりでは当たり前にあることですが、NFSが普通のストレージ然として振る舞うのでつい忘れてしまっていたことに気づかされました。

NFSクライアントの情報収集 (man nfs)

DATA AND METADATA COHERENCE
       Some modern cluster file systems provide perfect cache coherence among their clients.  Perfect cache coherence
       among  disparate  NFS clients is expensive to achieve, especially on wide area networks.  As such, NFS settles
       for weaker cache coherence that satisfies the requirements of most file sharing types.

   Close-to-open cache consistency
       Typically file sharing is completely sequential.  First client A opens a file, writes something  to  it,  then
       closes it.  Then client B opens the same file, and reads the changes.

       When an application opens a file stored on an NFS version 3 server, the NFS client checks that the file exists
       on the server and is permitted to the opener by sending a GETATTR or ACCESS request.   The  NFS  client  sends
       these requests regardless of the freshness of the file's cached attributes.

       When  the  application closes the file, the NFS client writes back any pending changes to the file so that the
       next opener can view the changes.  This also gives the NFS client an opportunity to report write errors to the
       application via the return code from close(2).

       The behavior of checking at open time and flushing at close time is referred to as close-to-open cache consis‐
       tency, or CTO.  It can be disabled for an entire mount point using the nocto mount option.

   Weak cache consistency
       There are still opportunities for a client's data cache to contain stale data.  The NFS version 3 protocol in‐
       troduced  "weak  cache  consistency" (also known as WCC) which provides a way of efficiently checking a file's
       attributes before and after a single request.  This allows a client to help identify changes that  could  have
       been made by other clients.

       When  a  client  is  using many concurrent operations that update the same file at the same time (for example,
       during asynchronous write behind), it is still difficult to tell whether it was that client's updates or  some
       other client's updates that altered the file.

   Attribute caching
       Use the noac mount option to achieve attribute cache coherence among multiple clients.  Almost every file sys‐
       tem operation checks file attribute information.  The client keeps this information cached  for  a  period  of
       time  to reduce network and server load.  When noac is in effect, a client's file attribute cache is disabled,
       so each operation that needs to check a file's attributes is forced to go back to the server.  This permits  a
       client to see changes to a file very quickly, at the cost of many extra network operations.

       Be  careful  not to confuse the noac option with "no data caching."  The noac mount option prevents the client
       from caching file metadata, but there are still races that may result in data cache incoherence between client
       and server.

       The  NFS protocol is not designed to support true cluster file system cache coherence without some type of ap‐
       plication serialization.  If absolute cache coherence among clients is required, applications should use  file
       locking.  Alternatively, applications can also open their files with the O_DIRECT flag to disable data caching
       entirely.

ようやく問題の全貌が分かる

DATA AND METADATA COHERENCE
Some modern cluster file systems provide perfect cache coherence among their clients. Perfect cache coherence among disparate NFS clients is expensive to achieve, especially on wide area networks. As such, NFS settles for weaker cache coherence that satisfies the requirements of most file sharing types.

ドキュメントから NFS は 大抵の用途に適合する弱いキャッシュの一貫性 を採用していることが分かります。

DATA AND METADATA COHERENCE の DATA は「ファイル内容のキャッシュ一貫性」で METADATA は「ファイルやディレクトリの属性情報のキャッシュ一貫性」を示します。
キャッシュの一貫性を担保する目的で Close-to-open cache consistencyWeak cache consistency の2種類の方法があるようです。

Close-to-open cache consistency

A8. What is close-to-open cache consistency? の解説も合わせて参照しました。

Linux implements close-to-open cache consistency by comparing the results of a GETATTR operation done just after the file is closed to the results of a GETATTR operation done when the file is next opened. If the results are the same, the client will assume its data cache is still valid; otherwise, the cache is purged.

とのことでした。逆に言うとファイルを開きっぱなしの状態で読み込むと、この機能は動作しないので古いキャッシュを利用してしまうということです。

ちなみにドキュメントに出てくる GETATTR は RFC で定義される NFS サーバからファイル・ディレクトリの情報を取得するプロシージャです。

Weak cache consistency

There are still opportunities for a client's data cache to contain stale data. The NFS version 3 protocol introduced "weak cache consistency" (also known as WCC) which provides a way of efficiently checking a file's attributes before and after a single request. This allows a client to help identify changes that could have been made by other clients.

When a client is using many concurrent operations that update the same file at the same time (for example, during asynchronous write behind), it is still difficult to tell whether it was that client's updates or some other client's updates that altered the file.

Close-to-open だけではファイルを開いた後に更新されたデータを取得できないので、それを補完する目的で Weak cache consistency が用意されています。
各オペレーションの前にファイル属性を取得(GETATTR)して認識していない属性情報の更新があれば他のクライアントから更新されたと判断してキャッシュを破棄するという動作のようです。

問題のアプリは ファイルを開くタイミングをロックに含めていません でした。

  1. sqlite DBファイルを開く
  2. ロックをする
  3. 書き込む
  4. ロックを解放する
  5. sqlite DBファイルを閉じる

これではファイルを開いた後でロックを取得する前に他のクライアントから書き込みがあるとキャッシュが破棄されず古いデータを使ってしまいます。
さらに sqlite はPOSIXのロック機構の問題からファイルを閉じる動作に癖があるので、利用する側が厳密にファイルの開閉を制御することが難しい状況もあります。

Close-to-open では検出できませんでしたが Weak cache consistency は各オペレーションの前にファイル属性を取得して更新を検出してくれるので、それならば問題の発生を防ぐことができそうだと思えます。
しかしここで問題になるのがファイル属性のキャッシュです。

ファイル属性の取得にはキャッシュが効くので一定時間内の再取得は古い属性情報のままとなり、結果として「ファイルは更新されていない」と判断されてしまいます。

ここまで調べると noac で問題が解決した理由がようやく分かりました。
noac には actimeo=0 を暗黙で含むため属性キャッシュが無効化されます。

都度サーバーから GETATTR で属性情報を取得することで最新の更新を検出可能となり、キャッシュの破棄ができるようになったようです。

仮説を検証する

問題の原因だと思われる仮説ができたので次は実際にそれが合っているか検証する必要があります。

改めて先ほどのレイヤを考えてみると検証したいのは 5 レイヤです。可能な限り対象レイアのみ切り出して検証する方法を考えます。

sqlite がファイルの読み書きに利用するのはシステムコールなので、sqlite が使うのと 同じシステムコールを利用すれば sqlite を利用せずに 5 レイヤだけを検証可能 なはずです。

sqlite の動作を確認する

システムコールは「OS側へのお願い」なので、OS側から観測する方法がいくつも用意されています。
例えば現在プログラムが呼び出しているシステムコールをリアルタイムで観測する strace コマンドがあります。

strace -p <pid>

strace を利用して、Python 経由で sqlite の操作を一通りやってみたところ、DBファイルのオープンから読み書きクローズまで想定通りの動作を確認できました。

詳細は省略しますが、例えば sqlite DBファイルのオープンをしたら以下のようなシステムコールが実行されます。

# Pythonコード
conn = sqlite3.connect('test.db')
# straceの出力
openat(AT_FDCWD, "/home/jun/works/sqlite/test.db", O_RDWR|O_CREAT|O_CLOEXEC, 0644) = 3

利用しているシステムコールを洗い出せたので、あとはこれらのシステムコールを sqlite ではない方法で再現して問題の所在を確認します。
この確認に sqlite を利用してしまうと sqlite 依存の問題なのか、システムコール以降の問題なのかを判断しづらくなりますが、sqlite 以外からも同じ問題を再現できれば、システムコール以降の問題だと断定することができます。

2台のPCを用意して同じNFS領域をマウントします。問題を再現するためにマウントオプションは noac を含めない状態としています。

クライアントAの処理内容

  1. ファイルAをオープン
  2. ファイルAを読み込んでsha256ハッシュ取得
  3. ファイルAを先頭にシーク
  4. 2に戻る

クライアントA はファイルを開いたまま、先頭までシークと読み込みとハッシュ計算を繰り返します。

クライアントBの処理内容

クライアントAが常時ハッシュを出力しつづけている間にクライアントBが書き込みます。
クライアントBの書き込み完了からクライアントA側のハッシュが変化するまでにどれくらいのタイムラグが生じるかを検証します。

検証結果

想定通り クライアントBが書き込み完了してからクライアントAの結果へ反映されるまでにはたっぷり数秒間かかる ケースが散見されました。
sqlite DBファイル破損は最新ではないデータを読み込んだ上で書き込むことで発生していました。検証によって古い情報を数秒間読み込んでしまうことを確認できたことでファイル破損の原因としては十分検証できたと判断しました。

...
0040: a6c283ae87881fa14788baaadc8df1854daacd140eb71274ca8c997d8ddaf8e4 ← このあたりでクライアントBがファイル更新
0041: a6c283ae87881fa14788baaadc8df1854daacd140eb71274ca8c997d8ddaf8e4
0042: a6c283ae87881fa14788baaadc8df1854daacd140eb71274ca8c997d8ddaf8e4
0043: a6c283ae87881fa14788baaadc8df1854daacd140eb71274ca8c997d8ddaf8e4
0044: a6c283ae87881fa14788baaadc8df1854daacd140eb71274ca8c997d8ddaf8e4
0045: a6c283ae87881fa14788baaadc8df1854daacd140eb71274ca8c997d8ddaf8e4
0046: a6c283ae87881fa14788baaadc8df1854daacd140eb71274ca8c997d8ddaf8e4
0047: a6c283ae87881fa14788baaadc8df1854daacd140eb71274ca8c997d8ddaf8e4
0048: a6c283ae87881fa14788baaadc8df1854daacd140eb71274ca8c997d8ddaf8e4
0049: a6c283ae87881fa14788baaadc8df1854daacd140eb71274ca8c997d8ddaf8e4
0050: a6c283ae87881fa14788baaadc8df1854daacd140eb71274ca8c997d8ddaf8e4
0051: a6c283ae87881fa14788baaadc8df1854daacd140eb71274ca8c997d8ddaf8e4
0052: a6c283ae87881fa14788baaadc8df1854daacd140eb71274ca8c997d8ddaf8e4
0053: b05dcdb7adb24011b3fcb776eb74df7e90507c5a6527b2fdd00fc2edd0993e49 ← 更新してから数秒後にハッシュが変化
0054: b05dcdb7adb24011b3fcb776eb74df7e90507c5a6527b2fdd00fc2edd0993e49

また派生パターンとして以下の2通りでもそれぞれ実施しました。

結果としてこれらも想定通り、問題が発生しなくなることを確認しました。
前者は Close-to-open cache consistency 、後者は Weak cache consistency の機能でそれぞれキャッシュが破棄されたことになります。

詳しい説明は省略しますが検証に利用したコードを掲載します。

クライアントA(ファイルを開いたままsha256ハッシュを計算し続ける)

import hashlib
import time

fp = open('test.db', 'rb')

def sha256hash1(fp):
  fp.seek(0)
  return hashlib.sha256(fp.read()).hexdigest()

for i in range(1000):
  print('{:04}: {}'.format(i, sha256hash1(fp)))
  time.sleep(0.1)

fp.close()

クライアントB(任意タイミングでランダムデータを書き込む)

import random
def random_write():
  fp = open('test.db', 'w')
  fp.write(str(random.random()))
  fp.close()

random_write()

クライアントA'(都度ファイルを開いて閉じながらハッシュ計算)

import hashlib
import time

def sha256hash2():
  fp = open('test.db', 'rb')
  return hashlib.sha256(fp.read()).hexdigest()
  fp.close()

for i in range(1000):
  print('{:04}: {}'.format(i, sha256hash2()))
  time.sleep(0.1)

まとめ

いくつかの考慮漏れと想定ミスから寄り道をしつつ途中からなんとか仕切り直してゴールを迎えることができた、という経緯でした。
ちなみに今回のプロジェクトでは開発初期にローカルストレージを利用していたので、はじめから NFS を利用できていたらもう少し理想的な対処ができたはずでした。しかし新規 NFS 環境の準備が必要な案件だったのでどうしても後手に回ってしまう状況でした。

こういった思考のプロセスが表に出るケースは多くないので、他のエンジニアがどう考えてどう決断したのかという情報の一つになればいいなと思います。

さて、ラクーングループは一緒に働く仲間を絶賛大募集中です!
高レイヤから低レイヤまで幅広く経験できる環境です。もしご興味を持っていただけましたら、こちらからエントリーお待ちしています!

一緒にラクーンのサービスを作りませんか? 採用情報を詳しく見る

関連記事

運営会社:株式会社ラクーンホールディングス(c)2000 RACCOON HOLDINGS, Inc