シンボリックリンクを用いたアトミックデプロイと opcache と realpath cache

これまで PHP のアプリケーションのデプロイは rsync でどべーとコードを撒いていました。が、それだと新旧のコードが混在するし Capistrano とかはデフォでシンボリックリンク切り替えでアトミックなデプロイになっているし、周回遅れな感じもしますが今後は似たような方法でデプロイしたいと思います。

releases/ ディレクトリの中にリリースタグでディレクトリを掘ってコードを配置して current を最新のリリースのディレクトリへのシンボリックリンクにします。そして Apache や Nginx でドキュメントルートを current の中の公開用のディレクトリに設定します(/path/to/app/current/public とか)。

/path/to/app/
  releases/
    20161213/
    20161224/
    20170101/
  current -> releases/20170101

なお、世間では(Capistrano では)リリース日時をディレクトリにしているようですけど、本番環境とか検証環境とかで同じバージョンなのに異なるディレクトリ名になるのもわかりにくいかな、と思うので、リリースタグ(Git のタグ)を使います。

新しいバージョンをデプロイする時は、新しいリリースタグでディレクトリを掘ってコードを rsync で撒いて、

/path/to/app/
  releases/
    20161213/
    20161224/
    20170101/
    20170112/ # new release
  current -> releases/20170101

current のシンボリックリンクの宛先を変更します。

/path/to/app/
  releases/
    20161213/
    20161224/
    20170101/
    20170112/
  current -> releases/20170112 # change symlink destination

シンボリックリンクのアトミックな入れ替えの ln -sfn vs mv -Tf で書いたように、アトミックにシンボリックリンクを切り替えれば新旧のコードが混在することなくデプロイが可能です。

が、しかし、PHP ではこのデプロイ方法の問題もよく知られています・・・

opcache と realpath cache が絡み合っていて、実際に試すと非常に不可解な動作になったりします。

いくつかの解決方法はあるのですが・・・とりあえず以下の選択肢は辛そうなので却下。

  • Blue Green Deployment
    • 構成からして考え直さなければならない
    • AWS の案件とかなら考えてもいいかも
  • mod_realdoc を使う (apache)
    • なんか辛そう

Apache を graceful restart

デプロイでシンボリックリンクを切り替えたあとに Apache を graceful restart します。

これなら opcache も realpath cache も間違いなくクリアされます。一番無難な方法です。

Nginx で $realpath_root

Nginx+PHP-FPM なら、Nginx の方で realpath に解決済のパスを渡すことで簡単に解決出来るらしいです。

fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;

これなら、シンボリックリンクを切り替えると PHP-FPM には PHP のコードは別のパスとして渡されるので、キャッシュが云々の問題はなくなります。

Apache でも mod_realdoc を使えば出来ますがね・・・

なお、この方法には後述の「キャッシュクリアしない場合の別の問題」の問題もあります。

opcache をクリアするページを呼ぶ

次のような opcache をクリアするページを作成しておいて、デプロイ後に curl でこのページを呼ぶ、という方法が考えられます。

<?php
opcache_reset();

なお、php の cli で opcache_reset() 実行しても意味がありません、opcache は sapi ごとの共有メモリにあるので cli と mod_php では別管理です。

で、一見この方法でうまくいきそうな気がしますが・・実際に試したところ realpath キャッシュが有効だとなんとも形容し難い動きになりました。

下記のように realpath キャッシュもクリアするようにしてもダメです。

<?php
opcache_reset();
clearstatcache(true);

opcache は共有メモリを用いて apache のプロセス間で共有されているのに対して、realpath キャッシュはプロセス単位なので、これだけだと複数の apache のワーカープロセスの realpath キャッシュをすべてクリアすることができないから・・なのかもしれない。

なので、この方法を用いる場合は realpath キャッシュは無効にしておくのが無難なようです。

realpath_cache_size = 0

opcache のタイムスタンプを毎回チェック

realpath キャッシュを無効にすれば、この問題は opcache だけの問題になります。その場合(mod_php なら)、opcache を明示的にクリアしなくても opcache によってファイルのタイムスタンプのチェックが行われる際に新しいファイルに置き換わります。

opcache のタイムスタンプのチェックは opcache.revalidate_freq に設定した秒数ごとに行われます。

この数秒の間、新旧のコードが混在することが妥協できるなら明示的にキャッシュをクリアする必要は無いし、妥協できないのであれば、0 を指定すればリクエストの都度チェックされるようになるので、下記のように設定すれば opcache をクリアしなくてもシンボリックリンクを切り替えたタイミングでアトミックにコードが差し替わります。

realpath_cache_size = 0
opcache.revalidate_freq = 0

ただし、この設定だとリクエストの都度 require/include で読まれるすべてのファイルの realpath が解決されて、かつ、タイムスタンプのチェックが行われるようになります。

それが問題にならない場合は、この設定が一番なにも考えなくても良い無難な設定となるでしょう。

ただ、この方法には後述の「キャッシュクリアしない場合の別の問題」の問題もあります。

キャッシュクリアしない場合の別の問題

opcache にしても realpath cache にしても、キャッシュのサイズには上限があります。

realpath_cache_size = 16K
opcache.memory_consumption = 64M
opcache.max_accelerated_files = 2000

この上限に達したとき、古いキャッシュをパージして空き容量を確保する・・・なんてことはなく、単純に新しくキャッシュされなくなる、だけです。

つまり、上限に達するとそれ以降のファイルはキャッシュされなくなります。

realpath cache のデフォルトは現代のフレームワークだと少なすぎる気もするので適当に増やしておくと良いかもしれません。

一方で opcache はデフォルト値で十分なような気もします。

が、シンボリックリンク切り替えによるアトミックなデプロイしていると、新旧のコードが両方ともキャッシュとして残るので、キャッシュを明示的にクリアしない限り、キャッシュがどんどん肥大化します。

いずれは共有メモリのサイズかファイル数のどちらかの上限に達して、新しくデプロイしたコードが一切キャッシュされなくなってしまうことがあります。

そのため、opcache_get_status() を監視して一定値を超えたら手動でキャッシュをクリアするように運用しているところもあるそうです。

もっとも、Apache なら大抵の場合は logrotate のために毎日 restart なり graceful restart なりしていると思うので、あんまり関係ないと思いますが。

まとめ

Apache を graceful restart するのが一番簡単で問題も少ないと思います。

この場合は graceful restart したときにコードが切り替われば良いので opcache.revalidate_freq とか realpath_cache_ttl とかはかなり大きめでも良いと思います。

むしろ opcache.validate_timestamps = 0 にして、opcache によるタイムスタンプのチェックを行わないようにしても良いかもしれません。その場合、Apache を graceful restart しない限り、いつまでたってもコードは新しいものに反映されなくなります。

また、php ファイルの require/include に限って言えば opcache は realpath cache の前段のキャッシュのように振る舞います。つまり、require/include したときに opcache にヒットすれば realpath の解決は行われません。

realpath cache が利くのはほとんど php コードの require/include だと思うので(PHPUnit は死ぬほど realpath() を呼んでた気がするけど)、opcache が有効なら realpath cache を無効にしてもあんまり性能は劣化しません(むしろ性能がよくなることもある?という話も聞いた)。


昨年の終わりぐらいに社内用に書いていたメモからのコピペ。

今のところ、Apache をおもむろに graceful restart しても大丈夫な感じのシステムなので、opcache.revalidate_freqrealpath_cache_ttl は大きめに設定して、デプロイ後は graceful restart してます。