KLabGames Tech Blog

KLabは、多くのスマートフォン向けゲームを開発・提供しています。 スマートフォン向けゲームの開発と運用は、Webソーシャルゲームと比べて格段に複雑で、またコンシューマゲーム開発とも異なったノウハウが求められる領域もあります。 このブログでは、KLabのゲーム開発・運用の中で培われた様々な技術や挑戦とそのノウハウについて、広く紹介していきます。

はじめに

こんにちは、@tsukimiyaです。シンボリックリンク切り換えによるホットデプロイ、したいですか?シンボリックリンク切り換えによるデプロイはアトミックなデプロイを低コストで実現する手段です。最近はDeployerやCapistranoなどシンボリックリンク切り換えによるデプロイ作業を簡単に行うためのツールも充実し、自分で頑張ってシェルスクリプトを書かずとも低コストでシンボリックリンク切り換えデプロイを行う事が可能です。

ただ、PHPでのシンボリックリンクの切り換えによるデプロイについてネット上を見ていると「nginx+php-fpm環境でOPcacheを有効にしているとシンボリックリンクを切り換えてもキャッシュされている切り換える前のコードが実行され続ける」という情報がいくつか見つかります。OPcacheが原因となるならApache+mod_php環境下でも同様の問題は発生しそうですが、トラブルに遭遇しているのはnginx-php-fpm環境の人ばかりです。実際、自分は普段Apache + mod_php環境をよく使っているのですが実行するコードがいつまでも更新されない、というトラブルには遭遇していないように思います。

今回はnginx + php-fpm, Apache + mod_phpそれぞれの環境に対し検証コードを実行し、問題の確認・原因の切り分けをしてみました。

準備

実験環境は以下のような構成で準備しました。

ディレクトリ構成

.
|-- 1
|   |-- index.php
|   `-- lib
|       `-- user.php
|-- 2
|   |-- index.php
|   `-- lib
|       `-- user.php
`-- docroot -> 1

プログラム

  • index.php

<?php
require_once(dirname(__FILE__).'/lib/user.php');
echo "index(1):" . __FILE__ . "<br>";
foo();
  • lib/user.php

<?php
function foo()
{
	echo "lib(1):" . __FILE__ . "<br>";
}

ディレクトリ1, ディレクトリ2がソースコードが存在するディレクトリの実体で、docrootがドキュメントルートに設定するディレクトリ1, またはディレクトリ2へのシンボリックリンクになります。
このコードをnginx + php-fpm環境とapache+mod_php環境でシンボリックリンクを書き換えながらそれぞれ実行してみます。

PHPにはOPcacheの他にもrealpath cacheという仕組みがあり、開いたファイルの実パスを一定時間(php.iniのrealpath_cache_ttlに設定した秒数の間。標準は120秒。)キャッシュするという仕組みがあります。コード中に存在する__FILE__は実行しているスクリプト自身のパスが入るPHPが自動的に定義する定数ですが、これがRealpath cacheの影響を受ける事を考え、念のためディレクトリ1のコードには(1)、ディレクトリ2のコードには(2)と直接記述しました。
realpath cacheとシンボリックリンク切り換えリリース時にrealpath cacheが引き起こす問題について興味のある方は「PHPにおけるシンボリックリンクを使ったデプロイの危険性について(「realpath_cache」和訳)」で詳しく説明されているので、興味のある方はこちらもご覧ください。

検証

検証はphp-fpm, mod_phpそれぞれの環境に対して以下の手順で進めました。

1. ディレクトリ1に対してシンボリックリンクを張りindex.phpを実行しOPcacheにキャッシュさせる

まずはindex.phpを実行しOPcacheにキャッシュさせます。この時点ではディレクトリ1に対してシンボリックリンクを張り、そのまま実行しているだけなので出力は以下のようになり結果に差違は見られません。

nginx+php-fpm Apache + mod_php
出力 index(1):/var/www/1/index.php
lib(1):/var/www/1/lib/user.php
index(1):/var/www/1/index.php
lib(1):/var/www/1/lib/user.php

2. docrootへのシンボリックリンクを2に張り替えindex.phpを実行する

シンボリックリンクを張り替え、すぐにindex.phpを実行した結果です。

nginx+php-fpm Apache + mod_php
出力 index(1):/var/www/1/index.php
lib(1):/var/www/1/lib/user.php
index(1):/var/www/1/index.php
lib(1):/var/www/1/lib/user.php

この時点ではどちらの環境でも 1/index.php が実行されています。これはrealpath cacheが「docroot/index.php => 1/index.php」という情報をキャッシュしているから、と考えられます。

3. 120秒経過した後、もう一度index.phpを実行する

今回調べたいのはOPcacheがシンボリックリンク切り換えに与える影響なので、PHP本体が作成したrealpath cacheの有効期限が切れるのを待ちます。今回はphp.iniを編集せずに検証しているので、realpath_cache_ttlの標準設定である120秒が経過するのを待ちもう一度実行します。

nginx+php-fpm Apache + mod_php
出力 index(1):/var/www/1/index.php
lib(1):/var/www/1/lib/user.php
index(2):/var/www/2/index.php
lib(2):/var/www/2/lib/user.php

Apache + mod_php環境では 2/index.php が実行されました。対して、nginx + php-fpm環境は 1/index.php のままです。念のために時間をあけてもう一度実行したりもしましたが、nginx + php-fpm環境では 1/index.php が実行され続けました。
一体何が起きているのでしょうか?

古いコードが実行され続ける原因

OPcacheがキャッシュしているのはPHPのopcodeだけではありません。realpath cacheの情報も一部OPcacheが管理する共有メモリ上に保存します。この「realpath cacheのキャッシュ」はphp-fpm, mod_phpどちらの環境でも作られるものですが、このキャッシュが存在している状態でrealpath cacheの有効期限が切れた場合の挙動がphp-fpmとmod_phpで違うのです。Apache + mod_phpの場合、realpath cacheの有効期限が切れるとOPcacheが保持しているrealpath cacheのキャッシュも作り直されるのですが、nginx + php-fpm環境の場合realpath cacheの有効期限が切れてもOPcacheは新しく作られたrealpath cacheを無視して古いrealpath cacheの情報を保持し続けます。実際、3)を行った際にrealpath_cache_get()関数を使いrealpath cacheが保持している内容を見ると
「docroot/index.php => 1/index.phpというキャッシュはexpireされ、docroot/index.php => 2/index.php というキャッシュが作られているにも関わらず 1/index.php が実行され続けている」
という状態を確認することが出来ます。

OPcacheのキャッシュをクリアする

今回の検証でOPcacheがシンボリックリンク張り替え前のrealpath cacheをOPcacheがキャッシュし、nginx + php-fpm環境の場合古いキャッシュを保持し続ける事でシンボリックリンクを切り換えても古いコードが実行され続ける可能性があることがわかりました。原因はOPcacheがキャッシュしているrealpath cacheなのでそのキャッシュを適切にクリアする事が出来ればシンボリックリンクを張り替えた後のコードが実行できるはずです。

OPcacheのキャッシュは「プロセスを再起動する」か「opcache_reset()関数を呼ぶ」ことでクリアすることが出来ます。このうち、プロセスの再起動はダウンタイムが発生する代わりにopcache, realpath cache共に消えるためダウンタイムの発生が許容出来るなら最も確実な方法です。
では、opcache_reset()関数はどうでしょうか。

実は、PHP7.0以降なら一定の解決策になりえます。PHP7.0以降のopcache_reset()はOPcacheのキャッシュ内容をクリアするのとあわせて、全てのプロセスPHP本体側のrealpath cacheをクリアするので、不整合が発生することはありません。

一方でPHP5.6までのopcache_reset()関数はPHP本体側のrealpath cacheにはノータッチでした。よって以下のような問題が引き起こされます。

  1. 新しいコードをデプロイしシンボリックリンクを切り換え
  2. opcache_reset()実行
  3. realpath cacheがexpireする前にプログラムが実行される
  4. OPcacheが古いrealpath cacheをキャッシュしてしまう
  5. nginx + php-fpm環境だと以前のコードが実行され続ける

この問題を解決するには「realpath cacheがexpireされてからopcache_reset()関数を実行する」事になり、現実的ではありません。

opcache_reset()を実行するまでは以前のコードが実行される可能性があり完全な解決策とは言えませんがロードバランサー切り換えほど大げさな仕組みを用意しづらい環境で、かつPHP7以降を使っているならopcache_reset()を使う事を考えても良いかもしれません。
ただ、この方法だとopcache_reset()を叩くためのPHPコードを用意しなければならず、本番環境に適応する場合opcache_reset()を叩くためのPHPコードはローカルアクセスしか出来なくする、と一手間付け加える事が必要になります。そこで検討したいのが次の方法です。

Webサーバにシンボリックリンクからrealpathへの解決を任せる

nginxを使用している場合、nginxにrealpathの解決を任せる事が可能です。
nginxの設定で

fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

としている箇所を

fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;

に変更します。こうすることでシンボリックリンクからrealpathへの解決がnginxで行われ、PHPは常にrealpathで動作する事になりシンボリックリンクを切り換えた際に発生するOPcacheやrealpath cacheによる問題は起こらなくなります。この方法ならopcache_reset()を使用する方法とは違い管理用のプログラムを別に用意することもないですし、アトミックなデプロイも完全な形で行えます。

おわりに

OPcacheを有効にしているnginx + php-fpm環境でシンボリックリンク切り換えによるデプロイを行うとopcodeが更新されず古いコードが実行され続けるらしい、という漠然とした情報から興味を持ち調べてみたのですが、実際にnginx + php-fpm環境では古いコードが実行され続ける事が確認出来ました。自分としては実行している環境がOPcacheの挙動に影響を与えるとは考えていなかったため、実際に挙動が変わる事が確認出来たのは面白い発見でした。
また、opcache_reset()によってキャッシュをクリアすれば解決する、という記事も何件か見たのですがopcache_reset()はPHPのバージョンにより挙動が違いPHP7.0以降なら一定の解決策になり得ると言う事がわかったのも自分としては新しい発見でした。

  • OPcacheを有効にしているnginx + php-fpm環境ではOPcacheが原因で古いコードが実行され続ける可能性があること
  • その環境でもnginxにrealpathの解決を任せたりPHP7.0以降のopcache_reset()関数を使えば便利さを損なわずデプロイツールの恩恵を得られること

が伝われば幸いです。

はじめに

オンラインゲームでは、金貨やコインといった ゲーム内で通貨のように利用可能なアイテム が必ずといって良いほど登場しますよね。
先日もこのTech Blogにて、ゲーム内で発行する仮想通貨のデータ分析業務についての記事が掲載されましたが、ゲーム内通貨の運用は各種法令等も絡むことからKLabでも特に注意して取り扱いを行っている業務の一つです。

そこで今日はゲーム内通貨の運用に関する業務やKLabとしての考え方を、会計上の観点からもう少し掘り下げてご紹介します。

法令におけるゲーム内の金貨等の位置づけ

オンラインゲーム内でアイテムの購入に利用できる金貨やコインといったトークン、上記ではゲーム内通貨と言いましたが、これは資金決済に関する法律(以下資金決済法、とします)で各種の規定がされています。そして上記のようなゲーム内の概念は、この法律の中では前払式支払手段という名前で定義付けがされています。

前払式支払手段の定義について条文にはちょっと難しく書いてあるのですが、できるだけウソにならない範囲で簡単に書くと以下のようになります。

  1. 発行の際にその金額・数量等が紙の証票(例としてチケットなど)またはデジタルデータとして記録されていること
  2. 1が対価と引き替えに利用者に対して発行されていること、つまり有償であること
  3. 1の内容と紐付く、IDや番号などが発行されていること
  4. 物品の購入やサービスを受ける際などに利用できるものであること

※ただし上記を満たす場合でも使用期限が6ヶ月以内のものは原則として対象外

上記はゲーム内の金貨やコインだけでなく、デパートなどで発行される商品券や交通機関などで使える回数券やプリペイドカード、英会話教室の前売りチケットなども上記の特徴を持つことが多く、つまりこれらは法律上同じ立て付けのものということになります。

ところで今年5月に改正資金決済法が成立し、bitcoinといった近年出てきた新しい決済手段に関する条文が追加されました。そこで このbitcoinのような新しい概念は、条文の中では 「仮想通貨」 という名称で定義づけされました。
「仮想通貨」というとどこかで聞いたような名前ですが、ここでいう仮想通貨とは前述の特定のサービスやゲーム内でのみ利用可能な前払式支払手段とは法律上別物でして、ここでは不特定の相手に対して利用可能なものを指します(正確にはそのほかにも仮想通貨を構成する要件があります)。従って法令改正後も前述の前払式支払手段に関する条文はほぼそのまま残っています。

なお冒頭でご紹介した記事のように、我々KLab内部ではゲーム内の金貨やコイン=前払式支払手段のことは 仮想通貨 と呼んでいるのですが、上記の法令改正の絡みもあり誤解を招くためこの記事の中では、法令に則り正確に 前払式支払手段 と呼ぶことにします。

オンラインゲームでの前払式支払手段の会計処理

当たり前の話ですが、会計において一番大事なことは売上を正確に勘定することです。
会計上、売上を計上するタイミングは物販業においては引渡基準と言って 原則的には役務提供(顧客に対してサービス提供や商品の引き渡しを行うこと)を完了した時点とすること になっています。
例えば自動車の販売を行った場合、売上を立てるのは契約日や入金日ではなく、この引渡基準に基づいて登録日あるいは実際に顧客に対して車の引き渡しを行った日とする運用にするのが一般的です。

この点でオンラインゲームにおける前払式支払手段は次のように若干特殊な性格を持ちます。
※ゲームにおける金貨やコインなどは無償でも配布することもありますが、ここでは有償で販売された物で無償配布のものは含まない(本来の意味での前払式支払手段)前提で説明します

オンラインゲームにおける前払式支払手段の流れ

上記で示すようにオンラインゲームにおける前払式支払手段とは、iOSのApp StoreやAndroidのGoogle Playストアのようなスマートフォンでの「決済プラットフォーム」によるアプリ内課金や、その他クレジットカード決済などで販売される「商品」です。しかし、顧客がその「商品」(例えばコインや金貨)を購入する動機は、これそのものにあるわけではなく、これと引き替えに何かアイテムを購入したい、とかサービスを受けたい、というさらに別の目的にあるわけです。
逆にコンテンツ提供者=”前払式支払手段の提供者”の視点からすると、前払式支払手段を販売しただけの段階では商品を引き渡す義務がまだ残っている状態だと考えることもできます。
つまり前払式支払手段は債権を表章した有価証券のようなものと考えると、前払式支払手段販売時点ではゲーム内における役務提供は完了していないため、この時点では売上計上は まだ できない、と考えるのがもっとも合理的なのです。最初にプリペイドカードや英会話前売りチケットのことを書きましたが、これと同じと考えればわかりやすいと思います。

これを会計的に整理して言うと、顧客が「決済プラットフォーム」で代金を支払った時点でまもなくコンテンツ提供者への入金は発生するもののこの時点では役務提供の終了(=売上発生)とは見做さず、 前受金処理(負債として計上すること)を行う 、ということになります。つまり顧客が実際に前払式支払手段を利用してアイテムを引き替えた時が前受金を取り崩すタイミング=すなわち売上として計上を行うタイミングになるわけです。

帳簿イメージ

この項の最初に書いた通り、会計において一番重要なことは売上を正確に計上することです。従って前払式支払手段を利用した業務においては、販売・発行時の情報と同じくらい、 消費した際の情報が大事 だということがおわかり頂けると思います。

前払式支払手段の残高管理と売上計上

多くのゲームにおいて前払式支払手段はまとめ売りの際に値段を下げることがあります。
例えば、金貨100枚だと120円(@1.2円)だが1,200枚まとめて買うと1,000円(@1.0円)になってお得!……といった販促方法は一般的にもよく見られるものですよね。

どの価格で購入した前払式支払手段であっても同様に利用できるという前提であれば、アイテム引き替えの実装だけを考えた場合、その前払式支払手段の総残高さえ分かればよいことになります。つまりそれぞれの単価を気にする必要は無いわけです。
一方で、上記で述べたように「売上を正確に計上する」という観点ではその実装だけでは問題が発生します。

  • それぞれ「単価いくらの前払式支払手段が消費されたか?」を記録しておかないと売上が正確に計上できない
  • 単一アプリにおける同一の前払式支払手段でも、Google PlayストアとKindleアプリのように購入元の「決済プラットフォーム」が混在することがある(売上は「決済プラットフォーム」毎にそれぞれ勘定するのが妥当であるため)

……ということがあるためです。そのため単に総残高を記録するだけでなく、前払式支払手段の種類別の 預入・払出のルールが厳密に運用 されていなければなりません。
このような要件は実は一般的な商品の在庫管理の考え方そのものでして、これに倣って前払式支払手段の残高を管理していくことになります。

在庫管理の基本的な方式は先入先出法、後入先出法、移動平均法といったものがありますが、「古いものから消費」というルールが顧客から見て最も自然でわかりやすいこと、預入・払出記録の1:1の突き合わせができるため詳細なトレース・分析が行いやすいなどの理由からKLabでは現状ほとんどのケースで先入先出法を採用しています。

さて、会計上一般的に在庫の管理を行う場合には 商品有高帳 という帳簿を作成します。前払式支払手段の内部管理においてもこれとほぼ同様のことを行うことになります。
例として、とあるゲームの金貨(=前払式支払手段)の預入・払出のサンプルを以下に示します。

金貨預入・払出サンプル

上記の例では、顧客であるユーザAは日を分けて金貨をそれぞれ100枚(単価 @1.2円)、1,200枚(単価 @1.0円)と購入し、その次の日にアイテムショップで500枚消費しています。ということで残高は (100+1,200)-500=800枚になる計算です。
肝心の内訳としては、先入先出で古いものから消費していくルールですから、単価1.2円の金貨全部と単価1.0円の金貨一部が取り崩され、後者が800枚だけ残ることになります。

このデータさえあれば売上を導出するのは簡単ですね。上記の「払出」に当たるログを全顧客分サマリーすれば良いわけです。
さらにここから前払式支払手段の残高も明らかなので、これをエビデンスとして資金決済法で定められた供託(後述します)を行うことができます。

その他資金決済法で定められた業務

これまで前払式支払手段の会計業務についてご説明してきましたが、KLabは資金決済法で定められる自家型の前払式支払手段発行者に該当しますので、上記の他にも法で定められた業務が存在します。
以下に簡単にご紹介します。

前払式支払手段の発行届出

資金決済法では基準日(毎年3月末・9月末)未使用残高が1,000万円を越える自家型発行者は、管轄の財務局長に前払式支払手段に関する情報(前払式支払手段の名称やその単価等)を書面で届出することが定められています。
KLabもこれに当たるため、関東財務局に前払式支払手段に関する届出を行っています。

発行保証金の供託

資金決済法では、保証金として発行済みの前払式支払手段の1/2以上の金銭を供託することが定められています。上述の発行届出と同様に、基準日残高でこれを計上します。
これは利用者保護のための運用で、万が一発行者が破綻してしまったような場合にはここから優先的に配当を行うことで、支払い済みの代金を丸損しないようにしているというわけです。

前払式支払手段の払戻し

銀行法や出資法という法律では、免許なしに事業として他人のお金を預かったり、その払戻しを行ったり、または送金をしたりすることを禁止しています(これらは社会的影響が大きな業務だからです)。
もし前払式支払手段がいつでも自由に払戻しができてしまうと、一旦それを購入後、好きなときに現金化ができるということになります。これは上述したお金の預入と実質同じことができることになってしまうため、発行済みの前払式支払手段を払戻しすることは資金決済法で禁止されているのです。この規定があるため、前払式支払手段の発行業者は滅多なことでは払戻しに応じることはありません。

一方で発行者が前払式支払手段を廃止した場合には、上記の例外として利用者に対して購入済みの前払式支払手段について払戻しの対応を行わなければならないことになっています。
利用者は発行者が設定した申し出期間(法令上最低60日間)内に申し出を行うことで払戻しが受けられることになります。
ちなみに、払戻しの期間を超えた場合は除斥(当該利用者を通常の払戻し手続きから除外して、相当する額の前払式支払手段残高を控除すること)することが可能になります。除斥された場合でも民法上の債権が消滅するわけではありませんが、この期間を過ぎてしまうとお金の回収が面倒になってしまいますので、未使用分の前払式支払手段があるサービスが終了してしまった場合は、事業者の告知を見たらぜひ早めに手続きをしてくださいね。

おわりに

アイテム課金型オンラインゲームというジャンルは、比較的新しい業態であるため日々利用者を保護するための新たな法令やガイドラインが検討されています。一方で、バックエンドの業務は通常の商品の在庫管理や売上管理等と考え方に大きな差はありません。
みなさんが前払式支払手段に関する業務を行うことがありましたら、この記事を何かの参考にしていただければ幸いです。


Shimanuki

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

↑このページのトップヘ