Relayer
Docs運用deployment

デプロイ(Dockerfile)#

Relayer アプリをコンテナで本番に出すための Dockerfile の組み方をまとめます。ポイントは「本番では PSX を事前コンパイルする」「ビルドと実行で同じ絶対パス」「public/ をドキュメントルートにしたフロントコントローラ」の 3 点です。

本番で押さえること#

  • PHP 8.5+。composer install --no-dev --optimize-autoloader。
  • APP_ENV は dev 以外(dev だとリクエスト毎にオンザフライコンパイル+プロファイラが走る。本番は無効化)。
  • PSX を事前コンパイル(後述)。dev のオンザフライは本番では使わない。
  • ルートを事前コンパイル(後述)。既定はリクエスト毎に src/Pages/ を走査するので、デプロイ時に 1 ファイルへ畳む。
  • DI コンテナを事前コンパイル(後述)。既定は毎リクエストの ContainerBuilder::compile() が回避可能な最大コストなので、PHP クラスにダンプして本番は require だけにする。
  • public/rosanna-defer.js を配置(composer install の post-install で自動。手動なら vendor/bin/rosanna-defer publish)。
  • ドキュメントルートは public/、実ファイル以外は public/index.php へ流すフロントコントローラ。

PSX 事前コンパイルの要点(ここが肝)#

APP_ENV が dev 以外だと .psx は自動コンパイルされないため、ビルド時に コンポーネントとページ/レイアウトの両方をコンパイルします。relayer compile が両方を <projectRoot>/var/cache/psx に出力します(無いディレクトリは飛ばします)。

php vendor/bin/relayer compile

rosanna-defer compile を直接使う場合は、存在するディレクトリだけを 1 回のコマンドにまとめて渡します(存在しないパスは exit 1)。manifest は実行のたびに書き直されるので、ディレクトリごとに分けて実行すると先に渡したコンポーネントが manifest から消えます。vendor/bin/rosanna compile は Relayer が使う deferred manifest を書き出さないので使いません。

php vendor/bin/rosanna-defer compile src/Components src/Pages --cache="$PWD/var/cache/psx"

なぜこうなるか:

  • キャッシュのファイル名は sha1(realpath(ソース))。だから ビルド時とランタイムで同じ絶対パスである必要があります(単一ステージで、ランタイムと同じ WORKDIR のままコンパイルするのが安全)。
  • manifest には成果物の絶対パスが入る(--cache の相対パスは cwd から解決)。ここでもビルドと実行で場所が違うと "Compiled PSX file not found" になる。
  • キャッシュは <root>/var/cache/psx の 1 か所。Relayer::boot() がコンポーネントとページ/レイアウトの両方をここに固定する。
  • public/index.php は Relayer::boot(\dirname(__DIR__), …) のように 正規化した絶対 projectRoot を渡す(__DIR__ . '/..' の /.. を残さない)。キャッシュキーがブレない。
  • ページが <Shell> などのコンポーネントを使う場合、そのページ先頭で // @psx-runtime App\Components\Shell を宣言する。バッチコンパイラに「実行時 manifest で解決する」と伝えるためで、無いと rosanna-defer compile src/Pages が exit 1(ファイル未生成)になる。

実行時にファイル書き込みが無い設計(外部 DB / 静的 ETag / プロファイラ無効)なら、コンテナ FS は読み取り専用で問題ありません。セッションや一時ファイルは /tmp に向けます。

ルートの事前コンパイル#

既定ではルーターはリクエストごとに src/Pages/ を走査します。デプロイ時に vendor/bin/relayer routes:compile を実行すると、可読でポータブルなルートのスナップショットを var/cache/routes/routes.php に 1 つ書き出し、本番はツリーを歩く代わりにこの OPcache 済みファイルだけを読みます。

php vendor/bin/relayer routes:compile
  • 存在するかどうかだけがゲートです。ファイルがあればそれを使い、無ければライブ走査に戻ります。dev はコンパイルしないので常に最新のツリーを反映し、stale になりません。
  • 走査時の曖昧さはデプロイ時に失敗します。page と route.php の衝突やルートグループによる URL 衝突は、本番初回リクエストではなくこの routes:compile で検出されます。
  • PSX 事前コンパイルとは独立した別ステップです(rosanna-defer compile は .psx を、routes:compile はルート表を畳む)。両方ビルド時に実行します。--cache のような絶対パス指定は不要で、出力は常に <projectRoot>/var/cache/routes/routes.php。生成物は自動生成のスナップショットなので手で編集しません(再生成は同コマンド)。
  • 終了コード: 0 成功 / 1 src/Pages が無い・走査不能・書き込み失敗。

詳細は CLI コマンド を参照。

DI コンテナの事前コンパイル#

既定では Relayer::boot() はリクエスト毎に Symfony の ContainerBuilder をビルドして compile() まで回します — 通常これが回避可能なリクエストコストの最大要因です。デプロイ時に vendor/bin/relayer container:compile を実行すると、素の PHP クラスを var/cache/container/CompiledContainer.php にダンプし、本番は ContainerBuilder を作らずそれを require します。

php vendor/bin/relayer container:compile
  • 存在するかどうかだけがゲート。ファイルがあれば本番はそれを使い、無ければライブビルドに戻ります。dev はコンパイルしないので常に最新のサービス設定を反映し stale になりません。
  • 不正なサービス定義はデプロイ時に失敗します(config/services.yaml / App\AppConfigurator の構成エラー)。本番初回リクエストではなくこの container:compile で検出されるので、配布物が壊れません。
  • PSX 事前コンパイル・routes:compile とは独立した別ステップです(3 つそれぞれ別のキャッシュを生成)。出力は常に上記パスで、自動生成・手で編集しません。サービス構成を変えたら再生成します(同コマンド)。
  • 終了コード: 0 成功 / 1 ビルド / ダンプ / 書き込み失敗。

ビルド手順まとめ#

composer install 後に 3 つのアーティファクトを生成して、OPcache 済み・走査ゼロのイメージを作ります:

composer install --no-dev --classmap-authoritative
vendor/bin/relayer compile               # .psx、ルート表、DI コンテナ -> PHP

route.go があれば、Go を入れたビルドステージで relayer native:build も実行し、 var/cache/native/bin を最終イメージへコピーします(雛形の Dockerfile には Go がありません。→ ネイティブルート)。

コミットする .env には APP_ENV を書かず、未設定は本番扱いです(雛形の Dockerfile の prod ターゲットは APP_ENV=prod の .env.local を書き込みます)。開発用に APP_ENV=dev を書いた .env.local をサーバに持ち込むと /_profiler が公開されるので注意してください。OPcache の validate_timestamps=0 で起動します。各ステップは有無ゲートなので、アーティファクトが無くてもライブ経路に縮退するだけで壊れません。

読み取り専用ファイルシステム。 スキャフォルドされる Dockerfile の prod ターゲットは上の 3 ステップをすべて実行するので、イメージは実行時に何も生成せず、読み取り専用ファイルシステムで動かせます。 compose.yaml にはコメントアウト済みの app-prod サービスとしてこの形が入っています: read_only: true に加え、まだ書き込みが発生するパスにだけ小さな tmpfs を割り当てます — ETag ストア(var/cache/etags)、PHP のセッションと upload temp(どちらも prod イメージが var/cache/ 配下へ移します)、Caddy 自身の /data と /config です。var/cache/ 全体をマウントしてはいけません。そこにはコンパイル済みのルート・コンテナ・.psx が入っており、空の tmpfs を重ねると隠れてしまい、毎リクエストの走査と再ビルドに戻ります。

サーバー構成#

FrankenPHP(単一バイナリ・推奨)#

FrankenPHP は Caddy に PHP を埋め込んだ単一バイナリ。nginx も php-fpm も不要で、php_server ディレクティブがフロントコントローラ(public/ を root に、実ファイル以外は index.php)を最初から行います。PHP に環境変数もそのまま渡る(clear_env 問題なし)。

  • 待受アドレスは環境変数 SERVER_NAME。TLS を上流(Fly.io / ロードバランサ)で終端するなら SERVER_NAME=":8080" のようにポートのみ 指定(= 平 HTTP、Caddy の自動 HTTPS を使わない)。$PORT 注入型の PaaS では起動時に SERVER_NAME=":$PORT" を組み立てる。
  • Caddy はデータ/設定を XDG ディレクトリに書くため、読み取り専用 FS では XDG_DATA_HOME / XDG_CONFIG_HOME を /tmp 配下に向ける。

worker モード(FrankenPHP)#

雛形の Caddyfile は php_server の index worker.php と worker ブロックでリクエストを public/worker.php に送ります。アプリを 1 度だけ起動してメモリに置いたまま処理する本番用の構成で、単一バイナリ(static-build.Dockerfile)か frankenphp run --config Caddyfile で使います。index と worker の行を消すと、 public/index.php がリクエストごとに処理するクラシックモードになります。

  • 開発には使いません。worker は読み込んだページやコンポーネントを持ち続けるので、編集が再起動まで反映されません。開発は php -S 127.0.0.1:8000 -t public か docker compose up --build で行います(どちらもこのファイルを読みません)。
  • コンテナのサービスはリクエストをまたいで生き続けます。サービスのプロパティにリクエストごとの状態を持たせないでください。持たせるものはリクエストの後に RequestLifecycle::onReset() で消します(名前ごとに 1 つで、同じ名前なら置き換わります)。
  • public/worker.php は各リクエストの後に Relayer::endRequest() を呼び、フレームワークと Rosanna が static に持つリクエスト単位の状態を捨てます。 worker のループを自分で書く場合も同じく呼びます。MAX_REQUESTS を指定するとその回数ごとに worker を起動し直します。
use Polidog\Rosanna\Runtime\RequestLifecycle;

RequestLifecycle::onReset(self::class, function (): void {
    $this->items = [];
});

nginx + php-fpm#

public/ を root にし、実ファイル以外を index.php に流します。 php-fpm は clear_env = no でコンテナの環境変数を PHP に渡します(環境変数 参照)。

server {
    listen __PORT__;
    root /var/www/html/public;
    location / { try_files $uri /index.php$is_args$args; }
    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_pass 127.0.0.1:9000;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}
; php-fpm.d/zz-app.conf
[www]
clear_env = no             ; APP_ENV / DB 認証情報などを PHP に渡す
catch_workers_output = yes ; PHP のエラーをコンテナログ(stderr)へ

$PORT を注入する PaaS では設定が環境変数を直接読めないので、起動スクリプトで置換してから両プロセスを起動します。

#!/bin/sh
set -e
: "${PORT:=8080}"
sed -i "s/__PORT__/${PORT}/g" /etc/nginx/nginx.conf
php-fpm --nodaemonize &
exec nginx -g 'daemon off;'

参考 Dockerfile#

FrankenPHP#

FROM dunglas/frankenphp:php8.5

# FrankenPHP イメージは install-php-extensions を同梱
RUN install-php-extensions mbstring opcache

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

# 本番 ini(php.ini-production をベースに上書きを重ねる)
RUN cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

ENV APP_ENV=production \
    COMPOSER_ALLOW_SUPERUSER=1 \
    SERVER_NAME=":8080" \
    XDG_DATA_HOME=/tmp/caddy \
    XDG_CONFIG_HOME=/tmp/caddy

# FrankenPHP の既定ドキュメントルートは /app/public(php_server が
# フロントコントローラを担う)。ランタイムと同じ /app でコンパイルする。
WORKDIR /app
COPY . .
RUN set -eux; \
    composer install --no-dev --optimize-autoloader --no-interaction; \
    php vendor/bin/relayer compile
COPY docker/php.ini "$PHP_INI_DIR/conf.d/zz-app.ini"

EXPOSE 8080
# 既定 CMD(frankenphp run)が SERVER_NAME の Caddyfile で起動する

$PORT を注入する PaaS なら SERVER_NAME を起動時に組み立てるエントリポイントを足します。

#!/bin/sh
set -e
export SERVER_NAME=":${PORT:-8080}"
exec frankenphp run --config /etc/frankenphp/Caddyfile

nginx + php-fpm#

FROM php:8.5-fpm
# php:8.5-fpm は mbstring/opcache/ctype/curl/openssl/json/tokenizer 同梱
RUN set -eux; apt-get update; \
    apt-get install -y --no-install-recommends nginx unzip; \
    rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
ENV APP_ENV=production COMPOSER_ALLOW_SUPERUSER=1 PORT=8080
WORKDIR /var/www/html
COPY . .
RUN set -eux; \
    composer install --no-dev --optimize-autoloader --no-interaction; \
    php vendor/bin/relayer compile; \
    chown -R www-data:www-data /var/www/html
COPY docker/nginx.conf      /etc/nginx/nginx.conf
COPY docker/php-fpm.conf    /usr/local/etc/php-fpm.d/zz-app.conf
COPY docker/entrypoint.sh   /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

php:8.5-apache でも可(mod_php + FallbackResource /index.php でフロントコントローラにする)。

確認#

  • composer install --no-dev 後、vendor/bin/relayer routes でルートが想定どおり出るか(ルーティングとページ)。
  • ビルドしたイメージをローカル起動し、/・動的セグメント・route.php・ 404 が正しく返るか、ログに "Compiled PSX not found" が出ないか。

このサイトの場合#

本サイトは FrankenPHP イメージ(上記の形)を Fly.io にデプロイし、記事ストアは Turso(libSQL/HTTP、未設定ならローカル SQLite フォールバック)です。ストア接続は環境変数で自動切替で、サーバーは読むだけ・記事更新はローカル CLI bin/docs(環境変数 / データベース 参照)。CI は GitHub Actions から flyctl deploy --remote-only で Fly のリモートビルダーがこの Dockerfile をビルドします。

最終更新 — 2026-11-04
改訂履歴 (7)
  • v1.1.0: usePHP→Rosanna(rosanna-defer)に合わせて更新
  • v0.31.0: relayer compile に統一、キャッシュ先を 1 か所に修正
  • v0.26.0 の read-only prod コンテナ説明を反映
  • v0.19.0 の DI コンテナ事前コンパイル(container:compile)と3アーティファクトのビルド手順まとめを追加
  • サイドバー再構成: 開発カテゴリ追加に伴う order 調整
  • ルート事前コンパイル(relayer routes:compile)のセクションとDockerfile手順を追加
  • 新規作成