プロダクション

BEAR.Sundayの既定のprod束縛をベースに、アプリケーション側で各デプロイ環境に応じたモジュールを追加・上書きしてカスタマイズします。

既定のProdModule

既定のprod束縛では、以下のインターフェイスが束縛されています。

  • エラーページ生成ファクトリー
  • PSRロガーインターフェース
  • ローカルキャッシュ
  • 分散キャッシュ

詳細はBEAR.PackageのProdModule.phpを参照してください。

アプリケーションのProdModule

既定のProdModuleに対してアプリケーションのProdModulesrc/Module/ProdModule.phpに設置してカスタマイズします。特にエラーページと分散キャッシュは重要です。

<?php
namespace MyVendor\Todo\Module;

use BEAR\Package\Context\ProdModule as PackageProdModule;
use BEAR\QueryRepository\CacheVersionModule;
use BEAR\Resource\Module\OptionsMethodModule;
use BEAR\Package\AbstractAppModule;

class ProdModule extends AbstractModule
{
    /**
     * {@inheritdoc}
     */
    protected function configure()
    {
        $this->install(new PackageProdModule);       // デフォルトのprod設定
        $this->override(new OptionsMethodModule);    // OPTIONSメソッドをプロダクションでも有効に
        $this->install(new CacheVersionModule('1')); // リソースキャッシュのバージョン指定
        
        // 独自のエラーページ
        $this->bind(ErrorPageFactoryInterface::class)->to(MyErrorPageFactory::class);
    }
}

キャッシュ

キャッシュには、ローカルキャッシュと複数のWebサーバー間で共有する分散キャッシュの2種類があります。どちらのキャッシュもデフォルトはPhpFileCacheです。

ローカルキャッシュ

ローカルキャッシュはデプロイ後に変更されないアノテーションなどのキャッシュに使われ、分散キャッシュはリソース状態の保存に使われます。

分散キャッシュ

2つ以上のWebサーバーでサービスを提供するには分散キャッシュの構成が必要です。代表的なキャッシュエンジンであるmemcachedRedis向けのモジュールが用意されています。

Memcached

<?php
namespace BEAR\HelloWorld\Module;

use BEAR\QueryRepository\StorageMemcachedModule;
use BEAR\Resource\Module\ProdLoggerModule;
use BEAR\Package\Context\ProdModule as PackageProdModule;
use BEAR\Package\AbstractAppModule;
use Ray\Di\Scope;

class ProdModule extends AbstractModule
{
    protected function configure()
    {
        // memcache
        // {host}:{port}:{weight},...
        $memcachedServers = 'mem1.domain.com:11211:33,mem2.domain.com:11211:67';
        $this->install(new StorageMemcachedModule($memcachedServers));
        
        // Prodロガーのインストール
        $this->install(new ProdLoggerModule);
        
        // デフォルトのProdModuleのインストール
        $this->install(new PackageProdModule);
    }
}

Redis

// redis
$redisServer = 'localhost:6379'; // {host}:{port}
$this->install(new StorageRedisModule($redisServer));

リソースの状態保存は単にTTLによる時間更新のキャッシュとの他に、TTL時間では消えない永続的なストレージとして(CQRS)の運用も可能です。その場合にはRedisで永続処理を行うか、Cassandraなどの他KVSのストレージアダプターを独自で用意する必要があります。

キャッシュ時間の指定

デフォルトのTTLを変更する場合StorageExpiryModuleをインストールします。

// Cache time
$short = 60;
$medium = 3600;
$long = 24 * 3600;
$this->install(new StorageExpiryModule($short, $medium, $long));

キャッシュバージョンの指定

リソースのスキーマが変わり、互換性が失われる時にはキャッシュバージョンを変更します。特にTTL時間で消えないCQRS運用の場合に重要です。

$this->install(new CacheVersionModule($cacheVersion));

ディプロイの度にリソースキャッシュを破棄するためには$cacheVersionに時刻や乱数の値を割り当てると変更が不要で便利です。

ログ

ProdLoggerModuleはプロダクション用のリソース実行ログモジュールです。インストールするとGET以外のリクエストをPsr\Log\LoggerInterfaceにバインドされているロガーでログします。

特定のリソースや特定の状態でログしたい場合は、カスタムのログをBEAR\Resource\LoggerInterfaceにバインドします。

use BEAR\Resource\LoggerInterface;
use Ray\Di\AbstractModule;

final class MyProdLoggerModule extends AbstractModule
{
    protected function configure(): void
    {
        $this->bind(LoggerInterface::class)->to(MyProdLogger::class);
    }
}

LoggerInterface__invokeメソッドでリソースのURIとリソース状態がResourceObjectオブジェクトとして渡されるのでその内容で必要な部分をログします。作成には既存の実装 ProdLoggerを参考にしてください。

デプロイ

⚠️ 上書き更新を避ける

サーバーにディプロイする場合

  • 駆動中のプロジェクトフォルダをrsyncなどで上書きするのはキャッシュやオンデマンドで生成されるファイルの不一致や、高負荷のサイトではキャパシティを超えるリスクがあります。安全のために別のディレクトリでセットアップを行い、そのセットアップが成功すれば切り替えるようにします。
  • DeployerBEAR.Sundayレシピを利用することができます。

クラウドにディプロイする時には

  • コンパイルが成功すると0、依存関係の問題を見つけるとコンパイラはexitコード1を出力します。それを利用してCIにコンパイルを組み込むことを推奨します。

コンパイル {: #compilation }

セットアップ時にプロジェクトをウォームアップできます。DI/AOP 用の動的ファイルやアノテーションなどの静的キャッシュを事前に作成し、最適化された autoload.phppreload.php を出力します。

原則: できればデプロイ先でコンパイルする(warmup / health check のタイミング)。パスや環境変数など実環境の実値がそのまま反映されるので、値は正しく焼き込まれ、書き込み先の変更も不要です。

例外: 事前コンパイルが要る環境(サーバーレス、read-only なアプリルート、/tmp などパス固定)。実サービスに触れない場所でコンパイルするため、書き込み先の変更・AOT スクリプトの再利用・(触れないサービスを通す).compile.php を併用し、ランタイムで変わる値(接続先・トークン等)は焼き込まずランタイム解決にします。

ビルドスクリプトはアプリケーションを名乗るだけで、起動はしません(BEAR.Package 1.22以降。スケルトンはbin/compile.php)。

<?php
// bin/compile.php
use BEAR\Package\Compiler;

require dirname(__DIR__) . '/vendor/autoload.php';

// Load build-time-only stubs (null objects / fake env) if present.
$dotCompile = dirname(__DIR__) . '/.compile.php';
is_file($dotCompile) && require $dotCompile;

$context = $argv[1] ?? 'prod-app';
$writeDir = $argv[2] ?? null;

exit((new Compiler('MyVendor\MyProject', $context, dirname(__DIR__), $writeDir))());

Compiler::fromInjector($injector, $context, $writeDir)は、すでにinjectorを持っている呼び出し元(動作中のアプリケーション内のコマンドなど)のためのものです。ビルドスクリプトでは使いません。

"scripts": {
    "compile": "php bin/compile.php prod-app"
}
  • コンパイルをすれば全てのクラスでインジェクションを行うのでランタイムでDIのエラーが出る可能性が極めて低くなります。
  • .envに含まれた内容はPHPファイルに取り込まれるのでコンパイル後に.envを消去可能です。コンテントネゴシエーションを行う場合など(例:api-app, html-app)1つのアプリケーションで複数コンテキストのコンパイルを行うときには、コンテキストごとに bin/compile.php を呼び、プロジェクト直下に出る autoload.php / preload.php を退避します(後続コンパイルで上書きされないようにします)。
php bin/compile.php prod-hal-api-app
mv autoload.php api.autoload.php
mv preload.php api.preload.php
php bin/compile.php prod-html-app

opcache.preload は PHP プロセス単位の設定です。複数コンテキストを preload する場合はそれぞれ別プロセス(php-fpm プール等)になり、プロセスごとに退避した preload を指します(例:api 用プールは opcache.preload=/path/to/api.preload.php)。上の例で html 側を既定名のままにしているのは、そのプロセスが既定の preload.php を指すからです。

DIスクリプトの出力先は{appDir}/var/tmp/{context}/diです。これはビルド成果物で、成果物に同梱されていれば実行時はコンパイルせず読むだけです。

vendor/bin/bear.compile は非推奨です。移行手順は BEAR.Package#482 を参照してください。

読み取り専用デプロイ(サーバーレス、イミュータブルコンテナ)

サーバーレスやイミュータブルコンテナでは書き込めるディレクトリが制限されることがあります。VercelやAWS Lambda、docker run --read-onlyreadOnlyRootFilesystem: trueで起動したコンテナでは、プロジェクトのディレクトリは読み取り専用で、書き込めるのは/tmpなど1つのディレクトリだけです。通常のVPSや共有ホストでは不要です。

この場合は書き込めるディレクトリをアプリケーションに渡します。ビルド時と起動時の両方に渡し、次の2つを守ります。

  • 絶対パスを渡します。相対パスを渡すとInvalidWriteDirExceptionが投げられます。
  • ビルドと起動で同じパスを渡します。パスはDIスクリプトに焼き込まれるため、渡されたinjectorとコンパイルの書き込み先が違う場合はWriteDirMismatchExceptionが投げられます。

$writeDirBootstrap::__invoke()Injector::getInstance()new Compiler()の末尾の省略可能な引数です。エントリポイントを次のように変更します。

 // public/index.php
-exit((new Bootstrap())('prod-app', $GLOBALS, $_SERVER));
+exit((new Bootstrap())('prod-app', $GLOBALS, $_SERVER, getenv('APP_WRITE_DIR') ?: null));

 // bin/compile.php               php bin/compile.php prod-app /tmp
-exit((new Compiler('MyVendor\MyProject', $context, dirname(__DIR__)))());
+$writeDir = $argv[2] ?? null;
+
+exit((new Compiler('MyVendor\MyProject', $context, dirname(__DIR__), $writeDir))());

 // src/Bootstrap.php
-    public function __invoke(string $context, array $globals, array $server): int
+    public function __invoke(
+        string $context, array $globals, array $server, string|null $writeDir = null
+    ): int
     {
-        $app = Injector::getInstance($context)->getInstance(AppInterface::class);
+        $app = Injector::getInstance($context, $writeDir)->getInstance(AppInterface::class);

 // src/Injector.php
-use BEAR\Package\Injector\PackageInjector;
+use BEAR\Package\Injector as PackageInjector;

-    public static function getInstance(
-        string $context, string|null $tmpDir = null, string|null $logDir = null
-    ): InjectorInterface
-    {
-        $meta = new Meta(__NAMESPACE__, $context, dirname(__DIR__), $tmpDir, $logDir);
-        $cacheNamespace = str_replace('/', '_', $meta->appDir) . $context;
-        $cache = (new LocalCacheProvider($meta->tmpDir . '/injector', $cacheNamespace))->get();
-
-        return PackageInjector::getInstance($meta, $context, $cache);
-    }
+    public static function getInstance(string $context, string|null $writeDir = null): InjectorInterface
+    {
+        return PackageInjector::getInstance(__NAMESPACE__, $context, dirname(__DIR__), null, $writeDir);
+    }

Metaとinjectorのキャッシュプールは書き込み先からBEAR\Package\Injectorが組むので、スケルトン側のMeta/LocalCacheProviderの行はなくなります。開発用のエントリは何も渡さず既定のパスを使います。環境変数を読むのはエントリの仕事で、フレームワークの仕事ではありません。

書き込み先はビルドには引数で、実行時には環境変数で渡します。

build     php bin/compile.php prod-app /tmp
runtime   APP_WRITE_DIR=/tmp
          php-fpm   env[APP_WRITE_DIR] = /tmp
          docker    --env APP_WRITE_DIR=/tmp

書き込み先を/tmpにした場合の配置は次のとおりです。

{appDir}/var/tmp/{context}/di                   コンパイル済みDIスクリプト(成果物内)
/tmp/MyVendor/MyProject/{context}/tmp           クエリリポジトリのキャッシュ、serializeしたinjector
/tmp/MyVendor/MyProject/{context}/log

パスにアプリケーション名とcontextが入るのは、ローカルキャッシュのキーがリソースURIだからです。区切りがないまま2つのアプリケーションや2つのcontextが同じディレクトリを共有すると、互いのキャッシュエントリで応答してしまいます。

コンパイル済みDIスクリプトはappDir配下に残り、デプロイ成果物に同梱されます。新しいインスタンスの/tmpは空なので、DIスクリプトまで書き込み先に移すとコールドスタートのたびに再コンパイルになります(リソース5個のアプリケーションで、再コンパイルが0.38秒、成果物からの読み込みが0.018秒)。

ビルドと違う書き込み先で起動した場合は、古いパスを使わずに再コンパイルされます。成果物が読み取り専用なら例外で止まり、書き込み可能ならCompiled DI scripts on demandのnoticeが出ます。

BEAR.Package 1.22以降が必要です。背景: BEAR.Package#491

autoload.php

{project_path}/autoload.php に最適化された autoload ファイルが出力されます。composer dump-autoload --optimizevendor/autoload.php より軽く、preload を使わない構成でリクエストごとの autoload コストを下げます。

注意:preload.php を使う場合は利用クラスの大半が起動時に読み込まれるので、この autoload.php はほぼ不要です(composer の vendor/autoload.php で十分)。つまり autoload.phppreload を使えない環境向けのフォールバックという位置づけです。

preload.php

{project_path}/preload.phpに最適化されたpreload.phpファイルが出力されます。preloadを有効にするためにはphp.iniでopcache.preloadopcache.preload_userを指定する必要があります。

PHP 7.4でサポートされた機能ですが、7.4初期のバージョンでは不安定です。7.4.4以上の最新版を使いましょう。

例)

opcache.preload=/path/to/project/preload.php
opcache.preload_user=www-data

Note: パフォーマンスベンチマークはbenchmarkを参考にしてください。(2020年)

.compile.php

実環境ではないと生成ができないクラス(例えば認証が成功しないとインジェクトが完了しないResourceObject)がある場合には、コンパイル時にのみ読み込まれるダミークラス読み込みをルートの.compile.phpに記述することによってコンパイルをすることができます。目的は「コンパイル時に構築を通す」ことなので、中身は Null オブジェクト(何もしない実装)が基本です。これは事前コンパイル(実サービスに触れない)だけでなく、認証などリクエスト時の状態が要るために、デプロイ先でコンパイルしても構築できないリソースにも当てはまります。値の偽装($_SERVER['X'] = 'fake' など)は最小限にとどめ、ランタイムで本物が要る値には使わないでください(焼き込まれます)。

注意(BEAR.Package 1.21+): Compiler::fromInjector() はルートの .compile.php を自動では読み込みません(非推奨の bear.compile は自動でした)。上の bin/compile.php のように、アプリ側で明示的に require してください。

.compile.php

例) 例えばコンストラクタで認証が得られない場合に例外を出してしまうAuthProviderがあったとしたら以下のように空のクラスを作っておいて、.compile.phpに読み込ませます。

/tests/Null/AuthProvider.php

<?php
class AuthProvider 
{  // newをするだけのdummyなので実装は不要
}

.compile.php

<?php
require __DIR__ . '/tests/Null/AuthProvider.php'; // 常に生成可能なNullオブジェクト
$_SERVER['YOUR_REQUIRED_ENV'] = 'fake'; // 特定の環境変数がないとエラーになる場合

こうする事で例外を避けてコンパイルを行うことができます。他にもSymfonyのキャッシュコンポーネントはコンストラクタでキャッシュエンジンに接続を行うので、コンパイル時にはこのようにダミーのアダプターを読み込むようにしておくと良いでしょう。

tests/Null/RedisAdapter.php

namespace Ray\PsrCacheModule;

use Ray\Di\ProviderInterface;
use Serializable;
use Symfony\Component\Cache\Adapter\RedisAdapter as OriginAdapter;
use Symfony\Component\Cache\Marshaller\MarshallerInterface;

class RedisAdapter extends OriginAdapter implements Serializable
{
    use SerializableTrait;

    public function __construct(ProviderInterface $redisProvider, string $namespace = '', int $defaultLifetime = 0, ?MarshallerInterface $marshaller = null)
    {
        // do nothing
    }
}

module.dot

コンパイルをすると”dotファイル”が出力されるのでgraphvizで画像ファイルに変換するか、GraphvizOnlineを利用すればオブジェクトグラフを表示することができます。スケルトンのオブジェクトグラフもご覧ください。

dot -T svg module.dot > module.svg

ブートストラップのパフォーマンスチューニング

immutable_cacheは、不変の値を共有メモリにキャッシュするためのPECLパッケージです。APCuをベースにしていますが、PHPのオブジェクトや配列などの不変の値を共有メモリに保存するため、APCuよりも高速です。また、APCuでもimmutable_cacheでも、PECLのIgbinaryをインストールすることでメモリ使用量が減り、さらなる高速化が期待できます。

現在、専用のキャッシュアダプターなどは用意されていません。ImmutableBootstrapを参考に、専用のBootstrapを作成し呼び出してください。初期化コストを最小限に抑え、最大のパフォーマンスを得ることができます。

php.ini

// エクステンション
extension="apcu.so"
extension="immutable_cache.so"
extension="igbinary.so"

// シリアライザーの指定
apc.serializer=igbinary
immutable_cache.serializer=igbinary