【FuelPHP】\Fuel\Core\Session_File::gc()について

投稿日:

例えば、\Fuel\Core\Session_File::gc()を避ける。

疑問

ある程度アクセスのあるサイトにおいて、FuelPHPでセッションを扱う際にセッションドライバで file を指定すると \Fuel\Core\Session_File::gc() の処理はかなりボトルネックになるケースがあるのではないか。という疑問。

バージョンは 1.8 で試していますが、1.7.3 でも同様かと思います。

理由

Fuel\Core\Session_File::gc() の処理を見るとこうなっています。

class Session_File extends \Session_Driver
{
    // ~ 略 ~

    /**
     * Garbage Collector
     *
     * @access	public
     * @return	bool
     */
    public function gc()
    {
        // do some garbage collection
        if (mt_rand(0, 100) < $this->config['gc_probability'])
        {
            if ($handle = opendir($this->config['path']))
            {
                $expire = $this->time->get_timestamp() - $this->config['expiration_time'];

                while (($file = readdir($handle)) !== false)
                {
                    if (filetype($this->config['path'] . $file) == 'file' and
                        strpos($file, $this->config['cookie_name'].'_') === 0 and
                        filemtime($this->config['path'] . $file) < $expire)
                    {
                        @unlink($this->config['path'] . $file);
                    }
                }

                closedir($handle);
            }
        }

        return true;
    }

    // ~ 略 ~
}

これを見ると、

  • $this->config['gc_probability'] で定義した値が、mt_rand(0, 100)より大きい数値であれば実行される
    • 5 であれば約4%の確率で実行される
    • 100 にしたからといって 100% 実行されるわけではない
    • $this->config['gc_probability']0 であれば実行されない
  • $this->config['path'] 以下のファイルをすべて見て TTL(有効期限) をチェックして期限が切れているファイルを削除

という処理が行われているのが分かります。
ということは、セッションファイルが大量にある場合、この gc() が発動すると全ファイルを舐める処理が走るので遅くなるのではないのかという疑問が湧くのです。

今回はこのへんを調べてみたいと思います。
答えを知りたい人は一番最後に飛んでみてください。

gc() はどこで呼ばれるのか

とりあえず、gc() はどこで呼ばれるのかというと Fuel\Core\Session_File::write() の中で呼ばれています。

class Session_File extends \Session_Driver
{
    // ~ 略 ~

    /**
     * write the session
     *
     * @return	\Session_File
     */
    public function write()
    {
        // do we have something to write?
        if ( ! empty($this->keys) or ! empty($this->data) or ! empty($this->flash))
        {
            parent::write();

            // ~ 略 ~

            // Run garbage collector
            $this->gc();
        }

        return $this;
    }

    // ~ 略 ~
}

write() はどこで呼ばれるのか

では、write() はどこで呼ばれるのかというと、、ちょっとややこしいので、おもむろに

<?php
\Session::set('test', time());

を呼んだらどうなるかというところからはじめてみます。

\Session::set() を呼ぶとどうなるか

まず単純に、Fuel\Core\Session::set() が呼ばれるので Fuel\Core\Session.php を一部抜粋して見てみます。

<?php
class Session
{
    // ~ 略 ~

    public static function forge($custom = array())
    {
        $config = \Config::get('session', array());

        // ~ 略 ~

        // $config['driver'] が 'file' であれば \\Session_File がクラス名となる
        $class = '\\Session_'.ucfirst($config['driver']);

        $driver = new $class($config);

        // get the driver's cookie name
        $cookie = $driver->get_config('cookie_name');

        // do we already have a driver instance for this cookie?
        if (isset(static::$_instances[$cookie]))
        {
            // if so, they must be using the same driver class!
            $class_instance = 'Fuel\\Core\\'.$class;
            if (static::$_instances[$cookie] instanceof $class_instance)
            {
                // 同じCookie名を使用して2つの異なるセッションをインスタンス化することはできません
                throw new \FuelException('You can not instantiate two different sessions using the same cookie name "'.$cookie.'"');
            }
        }
        else
        {
            // register a shutdown event to update the session
            \Event::register('fuel-shutdown', array($driver, 'write'));

            // init the session
            $driver->init();
            $driver->read();

            // store this instance
            static::$_instances[$cookie] =& $driver;
        }

        return static::$_instances[$cookie];
    }

    // ~ 略 ~

    public static function instance($instance = null)
    {
        if ($instance !== null)
        {
            if ( ! array_key_exists($instance, static::$_instances))
            {
                return false;
            }

            return static::$_instances[$instance];
        }

        if (static::$_instance === null)
        {
            static::$_instance = static::forge(); // まだ生成していなければここで生成される
        }

        return static::$_instance;
    }

    public static function set($name, $value = null)
    {
        return static::instance()->set($name, $value);
    }

    // ~ 略 ~
}

読み解いてみると、

  • set() の中で static::instance() が呼ばれます
  • Session_File が生成されていなければ static::forge() にて生成し返却します
    • これで透過的に Session_File とやり取りできるってわけですね

こんな感じかと思います。

そしてここでの肝が、\Event::register('fuel-shutdown', array($driver, 'write')); で、こいつでシャットダウンイベント時に \Session_File::write() を登録しているのがわかります。
そしてそして、\Event::register() しているということは、\Event::trigger() している場所があるということもわかります。

Event(‘fuel-shutdown’) の引き金を引いているのはどこか

trigger() しているのはどこかというと、core\bootstrap.php です。
app\bootstrap.php で require されます

ここでもソースを抜粋して見てみます。

<?php
// ~ 略 ~

/**
 * Register all the error/shutdown handlers
 */
register_shutdown_function(function ()
{
    // ~ 略 ~

    try
    {
        // fire any app shutdown events
        \Event::instance()->trigger('shutdown', '', 'none', true);

        // fire any framework shutdown events
        // ここやで!!
        \Event::instance()->trigger('fuel-shutdown', '', 'none', true);
    }
    catch (\Exception $e)
    {
        // ~ 略 ~
    }
    return \Errorhandler::shutdown_handler();
});

// ~ 略 ~

このとおり、register_shutdown_function で登録されていますね。
register_shutdown_function はスクリプト処理が完了したとき、あるいは exit() がコールされたときに実行されるコールバック関数です
PHP: register_shutdown_function – Manual

【答え】write() はどこで呼ばれるのか

上記のとおり、スクリプト処理が完了したときに呼ばれます。

ざっくり流れを書くと、

\Session::set('test', time()); を呼ぶと、一旦セッションに保存する変数などが作られて、スクリプト処理が完了したときに \Session_File::write(); が呼ばれセッションファイルに書き込まれる。
で、その時に gc() でのセッションファイル削除がたまに行われる。

という感じかと思います(違っていたらすいません)。
※ \Session::create()とか, set()とかして明示的に write() も呼べますが、よっぽどじゃないと直接 write() は呼ばない気がします

疑問点の確認

と、なんとなくセッションファイルが作られる流れがわかったところで(前置き長い)、冒頭に挙げた疑問点の確認を行ってみたいと思います。

まずは大量にセッションファイルがある状態を作ってみます。

設定ファイル

app/config/development/session.php が以下内容であると仮定します。

<?php
return array(
    // if no session type is requested, use the default(cookie)
    'driver'            => 'file',

    // session expire time (Default:7200)
    'expiration_time'   => 7200,

    // session close when browser is closed (Defult:true)
    'expire_on_close'   => true,

    // session ID rotation time  (optional, default = 300) Set to false to disable rotation
    'rotation_time'     => 300,

    // specific configuration settings for file based sessions
    'file'              => array(
        'cookie_name'      => 'fuelfid',      // name of the session cookie for file based sessions
        'path'             => APPPATH.'/tmp', // path where the session files should be stored
        'gc_probability'   => 5,              // probability % (between 0 and 100) for garbage collection
    ),
);

この状態でめっちゃセッションファイルを作ってみる

いったん適当なコントローラを用意して、Apache Bench なり Apache JMeter なりでめっちゃセッションファイルを作成します。

例) 簡単のためデフォルトの app\classes\controller\welcome.php を弄ります

class Controller_Welcome extends Controller
{
    /**
    * The basic welcome message
    *
    * @access  public
    * @return  Response
    */
    public function action_index()
    {
        Session::set('test', time());
        return Response::forge(View::forge('welcome/index'));
    }

適当に、http://localhost(適宜読み替えてください) にアクセスさせます。
こうすると、app/tmp/ 以下にズラーーーっとセッションファイルが吐かれることがわかります。

とりあえず、1万ファイルくらい用意しておきます。

$ ls -F | grep -v / | wc -l
10836

この状態で負荷検証

前提条件

app/config/development/session.phpgc_probability50 にしておきます。
こうすると約50%の確率で gc() が行われるはずです。

実行

http://localhost に手動でアクセスしてみます。

どうでしょうか、たまにレスポンスが遅くなるときがありませんか?
(画面の表示は終わっているけどタブのローディングはぐるぐるしっぱなし)
ローカルだとそんなに違いは無いかもしれませんが、NAS などでセッション格納用ディレクトリをマウントしていたりすると如実に遅さが実感出来るかと思います。

これを見るに、トラフィックが多いサイトの場合けっこうつらたんだということが想像できます。
※ たとえ gc_probability が低い値でも、あたりを引くユーザーは多いと思います

また、アクセスが集中している際の gc()TTL を考えるとほぼ無駄であるとも言えます。
(だってその時は新鮮なキャッシュが多数を占めているだろうし)

回避方法

じゃあどうするかというと、コンシューマでは gc() は行わずにバッチとかで gc() 相当の処理をすればいいんじゃね?という発想(別プロセスで実行)が自分には思い浮かびました。
※ もちろん他にも方法はあると思います

というわけで、そんな感じに修正してみたいと思います。

\Fuel\Core\Session_File を継承したファイルを作成

app/classes 直下に以下ファイルを作成します。

app/classes/session/file.php

<?php
/**
* Override \Fuel\Core\Session_File
*/
class Session_File extends \Fuel\Core\Session_File
{
    /**
     * Garbage Collector (override)
     *
     * @see \Fuel\Core\Session_File::gc()
     * @access public
     * @return bool
     */
    public function gc()
    {
        return true; // コンシューマ側では何もしない
    }
}

gc() をオーバーライドして何もしないようにします。

余談

今回とは関係ないですが、セッションファイルの保存先ディレクトリを階層化して保存したい場合などでも\Fuel\Core\Session_File を継承したクラスを使えば事足ります。

例えば、_write_file() でセッションファイルが作成されるのでここを弄って保存パスを変更したら、_read_file(), destroy() はそれに倣って修正するだけです。

bootstrap.php の修正

app/bootstrap.php を以下のように修正します。

<?php
// Bootstrap the framework DO NOT edit this
require COREPATH.'bootstrap.php';

\Autoloader::add_classes(array(
    // Add classes you want to override here
    // Example: 'View' => APPPATH.'classes/view.php',
    'Session_File' => APPPATH.'classes/session/file.php', // 追加
));

// 略

こうするとコンシューマ側ではgc() の処理は何も行われません。

バッチの作成

普通にバッチを作ってもいいのですが、せっかくなのでFuelPHPのTasksを使ってみます。

Taskの作成

app/tasks/deletesessionfile.php を作成します。
※ 階層分けできないの辛い。。(2.0で改善予定だそう)

<?php

namespace Fuel\Tasks;

use Fuel\Core\Arr;
use Fuel\Core\Cli;
use Fuel\Core\Config;

class DeleteSessionFile
{
    /**
     * php oil r deletesessionfile
     *
     * @return void
     */
    public static function run()
    {
        $time_start = microtime(true);

        // app/config/xxx/session.php の値を活用
        $config = Config::load('session');

        $path            = Arr::get($config, 'file.path');
        $expiration_time = Arr::get($config, 'expiration_time');
        $cookie_name     = Arr::get($config, 'file.cookie_name');

        // ドットファイル(. および ..)をスキップしたい
        // readdir(opendir($path)) だとfiletype():Lstat failed で死ぬ
        $files = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($path,
                    \FilesystemIterator::CURRENT_AS_FILEINFO |
                    \FilesystemIterator::KEY_AS_PATHNAME |
                    \FilesystemIterator::SKIP_DOTS
            )
        );

        $expire = (new \DateTime)->getTimestamp() - $expiration_time;

        foreach($files as $file_path => $file_info) {
            if (!$file_info->isFile()) {
                continue;
            }

            if (
                strpos($file_info->getFilename(), $cookie_name.'_') === 0
                && filemtime($file_path) <= $expire // バッチでの実行も考えてちょうどでも拾えるように
            ) {
                @unlink($file_path);
            }
        }

        $total_time = microtime(true) - $time_start;

        Cli::write('done!!');
        Cli::write('total_time(ms): ' . $total_time);
    }
}
  • \RecursiveDirectoryIterator を使っているのは readdir($handle) でドット(. および ..) があると filetype():Lstat failed で死んだので
  • filemtime($file_path) <= $expire としているのはcronでの実行を考えた際にちょうど期限が切れたセッションも消えるようにするため

実行

$ php oil r deletesessionfile で実行してみます。

$ php oil r deletesessionfile
done!!
total_time(ms): 12.701269865036

はい。手元の環境だと約13秒かかりました。
(裏を返すと、コンシューマ側で運の悪いユーザーはこれと近い時間待たされるケースがあるということが容易に想像できます)

なお、この間サイトに何度かアクセスしてみましたがレスポンスはすぐ返ってきました。
結果だけを見ると(この辺はシステムの構成によりけりなのでなんとも言えないですが、)このように別プロセスで実行したほうが影響は少ないと言える気がします。

cron

ここまで来たならあとはcronなりなんなりで定期実行させるだけです。

expiration_time が7200(2時間)であるならば以下のような感じになるかと思います。

$ crontab -e
# セッションファイル削除 0時から23時までの2時間おきに,0分(0:00, 2:00, 4:00, , , )ごとに実行する
0 */2 * * * php /your/fuel/path/oil r deletesessionfile > /dev/null 2>&1

少し考えたほうがいいかもしれないこと

cronで実行することにより、2時間ごとにセッションファイルが削除されるようになりました。
が、今まではgc()により定期的(大げさに言えば都度)にセッションファイルの削除が行われていたはずで、下手するとセッションファイルの生存時間が今までより長くなってしまうケースが考えられます。

例)

  • 12:00:01 にセッションファイルが作成された場合、14:00 のバッチでは消えない
  • 16:00のバッチで消えるので、最大3時間59分59秒ファイルが存在することになる(計算あってるかな)

有効期限が過ぎている状態(またはrotation_timeが過ぎている状態)でアクセスすると、セッションIDが新たに発行されて別のセッションファイルが作られることになるので古いセッションファイルが残っているのが問題になることは無い気がします(このへん自信ない)が、セッションファイルが今までより多くなることが予想されます。

そこまで神経質になるところでは無いかもしれませんが、気になる場合cronの実行周期を短くするのも手かと思います。
※ ファイルI/Oが気になるところではありますが、、
※ そもそもデフォルトの2時間(expiration_time)よりは短くすると思うけど

他のセッションドライバは?

FuelPHPは file だけでなく、他に cookie, db, memcached, redis 用のドライバがあるのでそれらを少し覗いてみます。

Fuel\Core\Session_Db

public function gc()
{
    if (mt_rand(0, 100) < $this->config['gc_probability'])
    {
        $expired = $this->time->get_timestamp() - $this->config['expiration_time'];
        $result = \DB::delete($this->config['table'])->where('updated', '<', $expired)->execute($this->config['database']);
    }

    return true;
}

上記を想像するに、一発のクエリで消せるのでfileより処理は遅くなさそう??だけど、大量にレコードがあったら遅そうといえば遅そうです。
それと、アクセス過多の状態だと上記と同じように無駄なクエリの実行だと思います。

Fuel\Core\Session_Cookie, Fuel\Core\Session_Memcached, Fuel\Core\Session_Redis には gc() は存在しませんでした。
(expire があるから当然っちゃあ当然か)

調査した結果

アクセスがある程度あるサイトで、Fuel\Core\Session_File::gc() をコンシューマ側で実行するのは避けたほうがいい。

  • どうしてもというなら、gc() は別プロセスで実行させる
  • ただし、実行周期に気をつけたほうがいいかも

可能であれば、セッションドライバは Fuel\Core\Session_Redis, Fuel\Core\Session_Memcached を選択したほうがいいのでは?と思われる。

  • 別途ミドルウェアが必要だし、簡単な実証しかしていないし、自分は実際そこまで経験が無いのでなんともいえないですが
  • 複数台/ノードでセッションを共有する場合はめんどくさそうだけど

簡単な実証しかしていませんが、調査結果は以上となります。

セキュリティの面で危ういという箇所があれば教えてください。

オチ

と、ここまで書いておいてなんですが、Session 使い方 – クラス – FuelPHP ドキュメント にこう記されています。

ガベージコレクション
セッションドライバーは不要なエントリーを削除するためのガベージコレクション機構を持ちます。 APC、Memcached、Redis等のexpireをビルトインでサポートするストレージの場合、その機能を使用し、 自動で不要なキャッシュエントリーを期限切れとしてます。

データベースやファイルシステムのようにそれを行わないストレージの場合、 gc_probability の設定によりガベージコレクションが必要か判断します。 必要な場合、ユーザーにページが送られた後、シャットダウンイベントにて実行されます。 この方法のよくない点は、処理に時間がかかる場合、GCが完了するまでページが “loading…” となることです。

ドキュメント嫁。
でも、まぁ実際に体験できたのでよしとします(謎)。

作成者: shimabox

Web系のプログラマをやっています。 なるべく楽しく生きていきたい。

2件のコメント

  1. gcのオーバーヘッドが問題になってこのページにたどり着きました。
    Taskでセッションドライバのgc()を走らせたい場合には以下の1行で済むみたいです。
    \Session::instance()->set_config(“gc_probability”, 101);

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください