Pentaprogram
(更新:

Kanauの公開ページをAstro + VercelからPHP + CORESERVERに移行してページの表示(TTFB)を速くしてみた

背景:5分放置するとページが遅い

私の個人開発アプリ、Kanauには、ユーザーが作成したプロジェクトを外部に共有できる「ページの公開」機能があります。

公開ページの例:

技術スタックは Astro SSR + Vercel Serverless で、ISR(Incremental Static Regeneration)によるキャッシュも設定していました。

しかし問題が一つ。Kanauは現状、アクセス頻度がそれほど高くなく、5分以上リクエストがない状態が続くことのほうが多い状況です。

Vercel Serverless Functions はアイドル状態が続くとコンテナが停止し、次のリクエストでコールドスタートが発生します。その結果、TTFB(Time to First Byte)が 1〜3秒 かかるケースが頻発していました。ISRのキャッシュは「アクセスがあった上での再生成」を最適化する仕組みなので、そもそもアクセスが少ない状況では本来の効果を発揮できませんでした。

加えて、ユーザーのサブスクリプション状態(isVisible フラグ)に応じた動的な出し分けも必要で、完全な静的生成には移行できませんでした。

技術選定:Slim Framework 4 + CORESERVER

コールドスタートの問題は、アーキテクチャに起因するもので、常にプロセスが待機しているような従来型のサーバー(いわゆるレンタルサーバー)なら、この問題は発生しません。

どのように改善するか検討した結果、以下の構成に決めました。

  • PHP(Slim Framework 4)
    • 軽量なPHPフレームワーク。共用サーバーとの相性が良い
  • CORESERVER
    • 既に契約済みのレンタルサーバー。PHP 8.3 対応、APIも提供されている
  • Firestore REST API
    • 共用サーバーではgRPC拡張が使えないため、REST APIとGuzzle HTTPクライアントで代替
  • ファイルベースキャッシュ
    • stale-while-revalidate パターンで、キャッシュが古くなっても即座にレスポンスを返す

ローカル開発環境はDockerで構築し、デプロイはGitHub Actions + rsyncという構成です。

Docker環境の構築

ローカル開発用の Dockerfile はシンプルです。

FROM php:8.3-apache

RUN a2enmod rewrite
RUN sed -i 's|/var/www/html|/var/www/html/public|g' /etc/apache2/sites-available/000-default.conf
RUN sed -i 's/AllowOverride None/AllowOverride All/g' /etc/apache2/apache2.conf
RUN apt-get update && apt-get install -y unzip && rm -rf /var/lib/apt/lists/*

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

WORKDIR /var/www/html

ポイントは4つあります。

  • a2enmod rewrite.htaccessRewriteRule を有効化
  • ドキュメントルートを public/ サブディレクトリに変更(後述のセキュリティ構成に対応)
  • AllowOverride All.htaccess を許可
  • マルチステージビルドで Composer を軽量にコピー

docker-compose.yml も最小構成です。

services:
  app:
    build: .
    ports:
      - "8080:80"
    volumes:
      - .:/var/www/html

docker compose up -d だけで http://localhost:8080 で動きます。

共用サーバーでのプロジェクト構成

共用サーバーでのPHPプロジェクトでは、vendor/.env がウェブからアクセス可能な位置に置かれがちです。
今回は public/ サブディレクトリをドキュメントルートとして分離し、機密ファイルを物理的にウェブルート外に配置しました。

~/kanau-public/              ← ウェブルート外(アクセス不可)
├── vendor/
├── src/
├── templates/
├── var/cache/
├── .env
└── public/                  ← ドキュメントルート
    ├── index.php
    ├── .htaccess
    ├── css/
    ├── favicon.png
    └── default-image.jpg

~/public_html/public.kanau.app → ~/kanau-public/public/  (シンボリックリンク)

CORESERVERでは SymLinksIfOwnerMatch が有効なので、シンボリックリンクでドキュメントルートを public/ に向けられます。
この構成なら .htaccess はフロントコントローラーへのルーティングだけで済みます。

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

vendor/.env はドキュメントルートの外にあるため、.htaccess の設定に関係なく外部からアクセスできません。
念のため、実際にcurl https://public.kanau.app/.env が 404エラーを返すことも確認しました。

共用サーバーからFirestoreを使う

Firestore公式のPHP SDK(google/cloud-firestore)はgRPC拡張が必要で、共用サーバーでは使えません。
その代わりに REST API を Guzzle で直接叩いています。

$this->baseUrl = "https://firestore.googleapis.com/v1/projects/{$projectId}/databases/(default)/documents";

$url = "{$this->baseUrl}/{$collection}/{$docId}";
$res = $this->http->get($url, ['query' => ['key' => $this->apiKey]]);

Firestoreの公開APIキーを使って、セキュリティルールで読み取りが許可されたドキュメントにアクセスします。
書き込みは行わないので、公開キーで十分です。

キャッシュには stale-while-revalidate パターンを採用しました。

public function getStale(string $key, int $freshTtl = 300): ?array
{
    $file = $this->path($key);
    if (!file_exists($file)) return null;

    $age = time() - filemtime($file);
    if ($age > 86400) { unlink($file); return null; }

    $data = file_get_contents($file);
    if ($data === false) return null;

    return [
        'data' => json_decode($data, true),
        &apos;fresh&apos; => $age <= $freshTtl,
    ];
}

5分以内なら fresh: true でキャッシュをそのまま返し、5分〜24時間なら fresh: false(古いデータを即座に返しつつ裏で更新)。
24時間を超えたら破棄して再取得します。

GitHub Actions + rsync でデプロイ

デプロイは GitHub Actions で main ブランチへのpush時に自動実行されます。

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: &apos;8.3&apos;

      - name: Install Composer dependencies
        run: composer install --no-dev --optimize-autoloader --no-interaction

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: &apos;20&apos;

      - name: Build CSS
        run: |
          npm install
          npm run build:css

      - name: Setup SSH
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key

      - name: Allow SSH and wait for propagation
        run: |
          RUNNER_IP=$(curl -s https://ifconfig.me)
          curl -sk "https://api.coreserver.jp/v1/tool/ssh_ip_allow" \
            -d "account=${{ secrets.SSH_USER }}" \
            -d "server_name=${{ secrets.SSH_HOST }}" \
            -d "api_secret_key=${{ secrets.CORESERVER_API_KEY }}" \
            -d "param[addr]=$RUNNER_IP"
          for i in $(seq 1 20); do
            if ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
              -o ConnectTimeout=5 -o IdentitiesOnly=yes \
              -p ${{ secrets.SSH_PORT }} \
              ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} \
              "echo ok" 2>/dev/null; then
              echo "SSH ready after attempt $i"
              exit 0
            fi
            echo "Attempt $i/20, retrying in 15s..."
            sleep 15
          done
          exit 1

      - name: Deploy via rsync
        run: |
          rsync -avz --delete \
            --exclude=&apos;.env&apos; --exclude=&apos;var/cache/&apos; \
            --exclude=&apos;.git&apos; --exclude=&apos;node_modules&apos; \
            -e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
              -o IdentitiesOnly=yes -p ${{ secrets.SSH_PORT }}" \
            ./ ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:~/kanau-public/

      - name: Setup symlink
        run: |
          ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
            -o IdentitiesOnly=yes -p ${{ secrets.SSH_PORT }} \
            ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} "
            if [ ! -L ~/public_html/public.kanau.app ]; then
              rm -rf ~/public_html/public.kanau.app
              ln -s ~/kanau-public/public ~/public_html/public.kanau.app
            fi
          "

特徴的なのは「Allow SSH and wait for propagation」ステップです。
CORESERVERではSSH接続にIP許可が必要で、その反映に数分かかりました。
GitHub Actionsのランナーは毎回IPが変わるため、デプロイのたびにAPIでIP許可を行い、リトライループで接続を待ちます。

最後の「Setup symlink」ステップでは、初回デプロイ時にドキュメントルートのシンボリックリンクを作成します。
if [ ! -L ... ] で既にシンボリックリンクが存在する場合はスキップします。

Claude Codeによる作業の自動化と手動対応の切り分け

今回の移行作業は、大部分をClaude Codeと対話しながら進めました。

Claude Codeが行った作業

  • Astroのページ、コンポーネント、型定義、i18nファイルを読み取って、PHPテンプレートに一括変換
  • Slim Framework のルーティング、テンプレートエンジン、ヘルパー関数の設計と実装
  • Firestore REST APIクライアント + ファイルキャッシュ(stale-while-revalidate)の実装
  • Docker環境の構築、Tailwind CSS v4の設定
  • CORESERVER APIの呼び出し(サイト登録、SSH IP許可)と、エラーレスポンスからのパラメータ形式の特定
  • GitHub Actionsワークフローの作成
  • 本番サーバーへの rsync デプロイ、.env 作成、動作確認

手動で行った作業

  • CORESERVERへのパスワードでの初回SSHログインと公開鍵登録(Claude Codeはパスワード入力不可)
  • Cloudflare管理画面でのDNS Aレコード変更
  • GitHubでのActions Secrets設定
  • CORESERVERのAPIキー発行

「認証情報の入力」と「外部管理画面での設定変更」は手動、それ以外はClaude Codeという分担でした。

CORESERVER APIのハマりポイント

CORESERVER APIは便利ですが、いくつかハマったポイントがあります。

1. パラメータのネスト形式が独特

ドキュメント上は domain, phpver のようにフラットに見えましたが、実際にはネスト形式が必要です。
しかもエンドポイントによって形式が異なります。

# サイト登録 — param[インデックス][フィールド] 形式
curl -d "param[0][domain]=example.com" -d "param[0][phpver]=83" ...

# SSH IP許可 — param[フィールド] 形式
curl -d "param[addr]=192.168.1.1" ...

2. ベースURLとパラメータ名

  • ベースURLは https://api.coreserver.jp/api.{サーバー名} ではない)
  • 認証パラメータは api_secret_keyapi_key ではない)

3. SSH IP許可の反映に数分かかる

APIが成功を返しても、すぐにはSSH接続できません。前述のリトライループで対応しました。

結果

私の環境では、下記の結果になりました。

指標

Before (Vercel)

After (CORESERVER)

TTFB(5分以上アイドル後)

1〜3秒

41ms

TTFBの安定性

アクセス頻度に依存

常時一定

デプロイ

git push → 約1分

git push → 約2分

まとめ

サーバーレスは便利ですが、低トラフィックなサイトでは、コールドスタートが最大のボトルネックになり得ます
「レンタルサーバー + PHP」という一見レガシーな構成が、この場合では最適解だったと思っています。

今回紹介した構成(Docker + GitHub Actions rsync + CORESERVER API連携)は、PHP以外の言語やフレームワークでも応用できるはずです。
共用サーバーへのCI/CDパイプラインを構築する際の参考になれば幸いです。

シェア