PHPにおけるシンボリックリンクを使ったデプロイの危険性について(「realpath_cache」和訳)

この文書は@julienPauliさんによる記事「realpath_cache」の日本語翻訳です。元々は@gilbiteさんがKLab社内向けに翻訳したものでしたが、日本語では見たことがない指摘を含んでおり今でも有用だと考えたため、@julienPauliさんの了解を取った上で@hnwが修正・追記して公開するものです。

はじめに

PHP に realpath_cache_get(), realpath_cache_size() という関数があることをご存じでしょうか? また、php.ini に realpath_cache から始まる設定項目があることは?

realpath cache は知っておきたい極めて重要な概念です。 特に、コードのデプロイ時にシンボリックリンクを取り扱う場合は知っておくべきでしょう。 この仕組みはサーバの性能向上およびIOの削減を実現するもので、PHP 5.1 で導入されました。 ちょうど PHP 界隈にフレームワークが現れ始めた時期ですね。

stat システムコールの復習

あなたのシステムがどうやって動いているのか、重々ご承知とは思いますが、いったん整理しましょう。 特定のパス が指定された場合に、カーネルやファイルシステムは実際のところ何が指定されたのかを知る必要があります。 (Unixでいうところの)ファイルにアクセスしようとしてパスを指定するときは、必ずあなた自身なり、ライブラリなり、カーネルなりがそのパスを解決する必要があります。 ここでいうパスの解決とは、要はそれがファイルなのか、ディレクトリなのか、はたまたリンクなのか、という情報を得る事です。

パスの解決は、OSにそのファイルの種類を問い合わせることで行われます。 ファイルがシンボリックリンクだった場合は、さらにリンク先のファイルの種類を問い合わせることになります。 "../hey/./you/../foobar" のような相対パスの場合には、まずフルパスに直し、そのフルパスのパス解決を行います(Unixでいうファイルは、すべての種類の実ファイル、ディレクトリ、リンクのことを指します)。

通常、相対パスについては、C の realpath() 関数が呼ばれます。 そして、この関数は、stat() システムコールを呼びます

stat() は重い処理です。 システムコールですからカーネルトラップやコンテキストスイッチを発生させますし、ほぼ確実にディスクに対してメタデータを問い合わせます。 カーネルの stat() のソース http://lxr.free-electrons.com/source/fs/stat.c#L190 を追ってみると、予想通りファイルシステムへの問い合わせ (inode->getattr()) につながっていますね。 通常、カーネルは buffer cache を使用するためディスクへの問い合わせの影響はかなり小さいのですが、高負荷なサーバでは欲しい情報がbuffer cacheに乗っていないかもしれません。 その結果、できれば発生させたくないはずの IO が発生してしまうことになります。

PHP がやっていること

PHP のプロジェクトでは大量のファイルを使用しますよね。 昨今は大量のクラスを使用するので、大量のファイルが存在することになります(1ファイルに1クラスだと仮定しています)。 ですから、autoload していようとなかろうと、大量のファイルを include し、読み込み、カーネルに stat 情報を問い合わせる必要があります。 そんなわけで、PHPからファイルアクセスするたびにパスの解決、リンクの解決、またファイルの情報の取得が行われます。 これらは全て stat() システムコールを使用します。 そこで、stat() システムコールの結果は realpath cache と呼ばれる領域にキャッシュされています。 実はPHPの他にも多くのソフトウェアがstatをキャッシュしています。コードを眺めてみれば気づきますよ ;-)

システムコールの結果をキャッシュするといっても、PHP がキャッシュするのはパス解決された結果であるところのrealpathだけです。(オーナーや、パーミション、各種日時といった)その他の情報はrealpath cacheではキャッシュされません。ただし、PHPには最後のstatシステムコール1件をキャッシュする仕組みがあり、そちらではその他の情報もキャッシュされます。

例によって、ソースコードを確認してみると良さそうですね。 PHP でファイルへのアクセスが発生すると、php_resolve_path() が使用されます。 この関数はすぐに tsrm_realpath() を呼びますが、 これも virtual_file_ex() を呼び、 最終的に tsrm_realpath_r() が呼ばれます。

ここからが面白いところです。realpath_cache_find() などの関数が呼ばれると、指定されたパスに対してstat 情報が過去に問い合わせされて既にキャッシュされているかどうか、realpath cacheの連想配列を検索します。

この連想配列の要素には構造体 realpath_cache_bucket が使用されており、多くの情報がカプセル化されています:

typedef struct _realpath_cache_bucket {
    zend_ulong                    key;
    char                          *path;
    char                          *realpath;
    struct _realpath_cache_bucket *next;
    time_t                         expires;
    int                            path_len;
    int                            realpath_len;
    int                            is_dir;
#ifdef ZEND_WIN32
    unsigned char                  is_rvalid;
    unsigned char                  is_readable;
    unsigned char                  is_wvalid;
    unsigned char                  is_writable;
#endif
} realpath_cache_bucket;

この bucket が見つからなかった場合は、lstat() のプロキシである php_sys_lstat() が呼ばれ、ファイルシステムへの問い合わせを行います。 そして、問い合わせ結果を含むbucketが realpath cache へと保存されます。

PHP 設定とそのカスタマイズ

さて、PHP の realpath cache で抑えておくべきことがいくつかあります。まずは INI の設定から。:

マニュアルが警告している通り、そう頻繁にファイル変更が起こらないようなサーバ(プロダクション環境)の場合は、TTL を大きくしておくべきでしょう。 また、デフォルトの size はとんでもなく小さいですね。Symfony2 のようなフレームワークを使っているのであれば、16K は1リクエストで埋まってしまいます。 realpath_cache_get() を監視すれば、16K の制限にすぐ引っかかってしまう様子が見て取れるでしょう。 512K とか、なんなら1Mにした方が良いでしょう。 realpath cache が埋まるということは、他のエントリのための余地が無くなっている状態ですから、キャッシュミスが原因でstat()が乱発され、カーネルにストレスを与え続けてしまいます。 この size を理論的に導出するのは難しいのですが、ソースのここを見る限り、1エントリにつき、sizeof(realpath_cache_bucket) + 解決されたパスの総文字数 + 1 となっています。 自分の環境(LP64)では、sizeof(realpath_cache_bucket) = 56 バイトでした。

もう一つ落とし穴があります。PHP は出会った全てのパスを解決しようとしますが、その際に細かくパーツ分けして、1つずつパス解決していきます。 "/home/julien/www/fooproject/app/web/entry.php" にアクセスしたとしますね。 そうすると、まず、最少単位に分解して "/home" の解決をして 1 エントリとしてキャッシュに放り込む、続いて "/home/julien"、"/home/julien/www"とやっていきます。 どういうことでしょうか?理由の一つは、ディレクトリの全レベルのアクセスを確認するのに使うためです。 次に、多くの PHP ユーザはパスを組み立てるのに文字列連結する傾向があるので、あるパスの一部をPHPが確認済みかもしれません。 その場合、realpath cache に問い合わせればユーザーがアクセス可能かどうか判断することができます。 キャッシュへの問い合わせは非常に軽いですからね。 詳細の処理はtsrm_realpath_r() のソースコードに記述されています。 これは全てのサブパスについて呼び出される再帰関数です。

これまで見てきた通り、キャッシュはあった方がいいですよね!

新規にデプロイしてウェブサイトを公開する前に、URL をいくつか叩いてキャッシュを準備しておくのも重要です。 これはOPcode キャッシュだけではなくて、realpath cache、ひいてはカーネルのページキャッシュを準備するという事にもなります。

さて、realpath cacheはどうクリアするのでしょうか?この関数はPHP上で隠されています。realpath_cache_clear() だと思いましたか?残念ながらそんな関数は存在しません :-) clearstatcache(true) なのです。この true が非常に大事です。このパラメータ名は$clear_realpath_cacheで、言うまでもなく我々が探していたものです。

では、例を。

<?php
$f = @file_get_contents('/tmp/bar.php');

echo "hello";

var_dump(realpath_cache_get());

この場合、次のような結果が得られます。:

hello
array(5) {
  ["/home/julien.pauli/www/realpath_example.php"]=>
  array(4) {
    ["key"]=>
    float(1.7251638834424E+19)
    ["is_dir"]=>
    bool(false)
    ["realpath"]=>
    string(43) "/home/julien.pauli/www/realpath_example.php"
    ["expires"]=>
    int(1404137986)
  }
  ["/home"]=>
  array(4) {
    ["key"]=>
    int(4353355791257440477)
    ["is_dir"]=>
    bool(true)
    ["realpath"]=>
    string(5) "/home"
    ["expires"]=>
    int(1404137986)
  }
  ["/home/julien.pauli"]=>
  array(4) {
    ["key"]=>
    int(159282770203332178)
    ["is_dir"]=>
    bool(true)
    ["realpath"]=>
    string(18) "/home/julien.pauli"
    ["expires"]=>
    int(1404137986)
  }
  ["/tmp"]=>
  array(4) {
    ["key"]=>
    float(1.6709564980243E+19)
    ["is_dir"]=>
    bool(true)
    ["realpath"]=>
    string(4) "/tmp"
    ["expires"]=>
    int(1404137986)
  }
  ["/home/julien.pauli/www"]=>
  array(4) {
    ["key"]=>
    int(5178407966190555102)
    ["is_dir"]=>
    bool(true)
    ["realpath"]=>
    string(22) "/home/julien.pauli/www"
    ["expires"]=>
    int(1404137986)

例示のPHPファイルのフルパスがパーツごとに解決されているのがわかると思います。 実は/tmp/bar.phpは存在していないので、当然ながらこのエントリーはキャッシュにはありません。 しかし、/tmpまでは解決されていることがわかります。つまり /tmp へはアクセス可能であることがわかるため、 これ以後の/tmp以下のパス解決は、最初の1回より安上がりになります。

realpath_cache_get() が返す連想配列には、キャッシュ失効時刻("expires")など重要な情報が入っていますね。 これはrealpath_cache_ttl の設定値とファイルへの最終アクセス日時から計算されています。 key は、解決されたパスのハッシュ値で、FNV hash の一種が使われています。 ですが、これは内部的な情報であって必要な情報ではありません(この値はシステムの整数のサイズに応じてinteger もしくは float になります)。

ここでclearstatcache(true) を呼ぶとrealpath cacheの連想配列がリセットされ、以前にキャッシュされていたファイルであっても新しいファイルアクセスとして PHP に stat() を強制させることになります。

OPcode cache の場合

さらに別の落とし穴を紹介しましょう。

realpath cacheは、プロセスごとに存在するものであって、共有メモリで共有されるものではない

ですから、キャッシュの失効や変更、手動での消去などについては、プロセスプール内の全てのプロセス で行われる必要があるのです(訳注:全プロセス横断でのrealpath cache消去機能は提供されていないため、非現実的だと考えるべきでしょう)。 opcode キャッシュ拡張を使っている場合にコードデプロイが失敗するのはこれが主な理由です。 デプロイ時にはシンボリックリンクの切り替えをすると思います。例えば /www/deploy-a から /www/deploy-b のように。 ここで忘れがちなのが、opcode キャッシュ拡張は(少なくとも OPCache と APC については)、PHPの内部的な realpath cache を信頼しているということです。 つまり、opcode キャッシュ拡張はシンボリックリンク先の変更があったことに気づきません。 さらに悪いことに、realpath cacheの全エントリが徐々に失効していくにつれ、徐々にリンク先の変更に気づいていくわけです。 どうなるかは言わんでも分かりますな?

デプロイ時にこの残念なメカニズムの発生を防ぐ一番の解決策は、完全に新しいPHPワーカープールを準備しておくことです。 fastCGI handler でその新しいプールにロードバランスさせ、古いプールはワーカーが全部仕事をやり終えたら放棄してしまえばよいのです。

この方法だと良い点がたくさんあります。 デプロイA が メモリプールA で動いて、デプロイB が メモリプールB で動く。 以上。 2つのデプロイ間で完全にメモリイメージの分離を行い、なんのメモリ共有もさせない。 realpath cacheも、opcode cache も、何もかもが新しくなるわけです。 FastCGI プールのロードバランスは、少なくとも Lighthttpd と Nginx では使えます :) 私もプロダクション環境で実践していますが、手堅いですよ!

終わりに

realpath cacheについて何か書いてくれと依頼されたのは、多分、皆がツラい経験をしたことがあるからだと思います(それも、おそらくコードのデプロイ時に)。 ということで、その仕組と、存在意義や設定の変更の必要性とその仕方を書きました。他に何かあるかな?

翻訳者による補足

PHPプロジェクトでシンボリックリンク切り替えによるデプロイ(特にホットデプロイ)を行っている場合に、OPcacheとの組み合わせで何故か新しいファイルに切り替わらない、という事件があちこちで起きているのではないでしょうか。その原因の一つと、解決策を示した文章を紹介しました。

本稿で指摘されている、シンボリックリンクによるデプロイとrealpath cacheとの相性の悪さは非常に悩ましい問題です。元の記事ではOPcacheとの組み合わせで問題が起きるように読めるかもしれませんが、実はOPcache無しでもrealpath cacheが古いシンボリックリンク先を返してしまうことで同様の事故が起こる可能性があります。

多くのPHPプロジェクトではrealpath_cache_sizeの値が小さすぎて本稿の問題そのものは起きないかもしれません。その場合でも、OPcacheが提供している全プロセス共有のrealpath cacheという、本稿で触れられていない落とし穴があります。実はこちらの方がトラブル事例としては多い可能性もありそうです。

本稿で提案されているプロセスプールを新旧2個用意するという解決策は完璧ではあるものの、小規模案件では非現実的なこともあるでしょう。 元記事の筆者の@julienPauliさんはSensioLabsの社員でもあります。同社はSymfonyの開発元として有名で、大規模案件に取り組んでいることで知られていますから、この解決策で特に問題はないということだと思われます。

(11/1 08:45 追記)一方で、プロセスプールを2個用意するという提案は安全側に倒しすぎだという見方もできます。というのも、シンボリックリンクを利用したデプロイを行っていても本稿で指摘した問題が露呈しない状況もあるのです。詳細はまた別エントリでお伝えすることになると思いますが、昨今のフレームワークを使った開発であれば問題が起きない環境の方が多数派だろうと予想しています。ですから、今うまく動いているプロジェクトのデプロイプロセスをわざわざ変更する必要は無いでしょう。

このブログについて

KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。

おすすめ

合わせて読みたい

このブログについて

KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。