php-fpm + capistrano環境で 'Cannot redeclare class' が発生する謎を追え!

JUGEMテーマ:PHP
 

Webサービスがcgiからphp-fpmに変わったときに突然クラス再定義エラーが出てきて、
それの原因をまとめてみた、そんなお話。

php-fpmにしたらエラーが出るようになった

今関わっているWebサービスがcgiからphp-fpmに変わって、突然以下のエラーが発生するようになりました。

Fatal error: Cannot redeclare class MyClassName....

ファッ!?
phpは同じ名前のクラスを2回定義するとFatalErrorを起こしますが、上記のクラスを定義しているファイルは1ファイルしかなく、しかも  require_once で呼ばれているので2回は呼ばれないはず。

これはきっとphp-fpmの挙動が関わっているはず。
 

今回の前提

とりあえず今回のシステムの状態をまとめるとこんな感じ。


├─/home/deploy_user
│ ├─current ←releases/AAAAAへのsymlink
│ └─releases
│    ├─AAAAA ←現在動いているコード
│    │   └─...
│    └─(BBBBB) ←今からデプロイされようとしているコード
│        └─...
└─/var/www
  └─MyApp ←/home/deploy_user/currentへのsymlink

これを読んでいる方の状況は分かりませんが、うちはレガシーなので phpのautoloaderは使ってません。 クラスの再定義回避はrequire_onceによってのみ担保されています。ここ、ポイントです。
 

symlinkベースのデプロイをしても、php-fpmは古い方のコードを参照し続ける

標題の問題が発生している原因の一端担っているのが、php-fpmのこの挙動です。
いわゆるcapistrano的なsymlinkベースのデプロイをしても、
php-fpmは古い方のコードを参照し続けることが分かりました。

php-fpmのドキュメントを読んだわけではなく実際の挙動からの推測ですが、
php-fpmはドキュメントルートをsymlink解決後のフルパスで保持してるようで、

capistranoがcurrentの向け先を変更(図のBBBBB)しても古い方のコード(図のAAAAA)を参照し続けます。
この手の話は詳しくはないですが、事前にプロセスを常駐させておくFastCGIとしては普通な動き方なのかもしれません。

この挙動への対策については、デプロイ直後にphp-fpmをリロードさせる形で対応したのですが、
標題の問題に関しては 現在実行されているドキュメントルートの場所と、currentの向け先が違う状況が短時間ながらありうる というのがポイントで、今回の根本的な原因。
(短時間というのはcurrentが切り替わってから、php-fpmがリロードされるまでのわずかな時間のこと
 

phpのrequire_onceのパスの書き方次第で、古いのと新しいコード両方から読み込みうる

もう上に答えを書いちゃっているんですが、
require_onceで呼ぶにしてもパスに書き方次第でreleasesの古い方と新しい方の両方からファイルが呼ばれうるんです。
これがエラー発生の直接の原因でした。

たとえば  require_once '/var/www/MyApp/classes/MyClassName.php'  とか書くとこれはsymlinkを経由するパスなので、currentのファイルを読み込もうとします。
この時のphp-fpmのドキュメントルート(AAAAA)とcurrentの向け先(BBBBB)が違うと、MyClassName.phpが読み込み済みであるにも関わらず、別のファイルだと判定され読み込まれてしまいます。
そして同じクラスの再定義となり、標題のエラーが発生します。

つまりphp-fpmはAAAAAがドキュメントルートのつもりでphpを実行しているんですが、
php側はsymlinkを再び解釈しなおすようなrequireをする場合があるので、
そのために発生している問題だったんですね。

 

これはrequire_onceの仕組みで避けられないのか?

get_included_fileメソッドを使うと、require済みのファイルの一覧が取得できるんですが、それを見る限りphpはrequire済みファイルをsymlink解決後のフルパスで保持しているみたいです。
なのでAAAAA配下のMyClassName.phpとBBBBB配下の同ファイルは別ファイルだと判定され、読み込まれてしまいます。


require_once '/classes/MyClassName.php';
var_dump(get_included_files());
/*
array(1) {
[0]=>
string(56) "/home/deploy_user/releases/AAAAA/classes/MyClassName.php"
}
*/


解決方法

このエントリを書いている時点では実はまだ解決してませんが、解決方法はいくつか考えられます。

  1. ブルーグリーンデプロイメントする

    • 最高ですね。古い環境と新しい環境がぶつからないだろうし。php-fpmをreloadしなくて済むからアプリケーションの瞬断にも悩まないです。
      ただうちの環境でやるには敷居がとても高い・・。

  2. phpのautoloaderを使う

    • require対象が読み込み済みかをファイルパスではなく、クラスが定義済みかで判定するようになります。これならクラスが再定義される心配はなくなります。
      同じくうちの環境では敷居が高い・・。

  3. require_onceのパスを全部見直す

    • symlinkを経由するようなパスを全部直して経由しないようにします。require_onceを全部見直す?ははッ、そんなまさか!と思ったら割とできちゃうかも・・。


require_onceのパスを全部見直す

全部とはいえ、うちの場合はrequire_onceの書き方って、だいたい2パターンなんですよね。

  1. dirname(__FILE__)  をつかって現在のファイルからの相対位置から読み込む

  2. /../dir/file.php  的にパスを書いてinclude_pathベースに読み込む

  3. /var/www/MyApp/classes/MyClassName.php  みたいにrequire_onceにsymlink経由の絶対パスを書いちゃってる。このパターンはほぼ無いのであまり考えない。(ちゃんと対応はしますけど

1.の方は問題が起きません。現在のreleasesディレクトリから外のディレクトリへは行かないでしょう。
問題が起こりうるのは2.の方で、その時のinclude_pathが  /var/www/MyApp/  とかだとsymlinkを経由するでしょう。
ここで気づくのは、直すべきはrequire_onceの方ではなく、include_pathの方でした。

うちの場合はinclude_pathの設定を、phpファイル内のini_setにて実現しています。なのですべてのini_setを見直してinclude_pathを  dirname(__FILE__) な書き方しちゃえばいいのでは?
ini_setの数は大して多くはないので、現実的な話な気がしました。

まあミドルウェアの問題をアプリケーションコード側で解決するってのがどうなんだろうって気がしますね。
どうなんでしょう。

スポンサーサイト

コメント