はじめに

こんにちは、@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()関数を使えば便利さを損なわずデプロイツールの恩恵を得られること

が伝われば幸いです。