← Blog一覧へ

optimize:clearはoptimizeの逆操作ではない

#tech#Laravel#PHP

デプロイスクリプトによく置かれている php artisan optimize:clear && php artisan optimize は、打ち消し合っているように見えて釣り合っていません。前半だけが余分に消しているものがあります。公式ドキュメントも、そう書いています。

The optimize:clear method may be used to remove all of the cache files generated by the optimize command as well as all keys in the default cache driver.

(optimize:clear は、optimize が生成したキャッシュファイルの削除に加えて、デフォルトのキャッシュドライバの全キーを削除します)

— Laravel 13.x「Deployment — Optimization

「as well as」の後ろが、optimize の作っていないものです。レートリミッタのカウンタ、セッション、それにドライバによっては実行中のコマンドやジョブが握っている排他ロック。

原因は、Laravel で「キャッシュ」と呼ばれるものが、名前が同じだけで性質のまったく異なる2つの体系に分かれていることにあります。この記事ではその境界を引いたうえで、境界を跨ぐ唯一のコマンドが optimize:clear であることを実測で確かめます。

検証は素の Laravel で行いました。以降に貼る出力はすべてこの環境で実際に取ったものです。

Laravel 13.24.0
PHP 8.4.1
CACHE_STORE database
DB_CONNECTION sqlite

CACHE_STORE=database は素の Laravel の既定値で、観測にも都合のいい値です。アプリケーションキャッシュの中身が SQLite の cache / cache_locks テーブルにそのまま見えます。Redis だと redis-cli が要りますし、file だとハッシュ化されたファイル名しか見えません。

世界1と世界2

まず区別を先に置きます。

世界1 - ビルド成果物 世界2 - 状態
中身 ソースコードから決定論的に導ける 外部から得た結果、時間で変わる値
保存先 bootstrap/cache, storage/framework/views Redis / DB / file / memcached
ドライバの概念 ない(常にファイル) ある(config/cache.php)
消したときの帰結 再生成されるだけ 再取得コスト、または情報の消失
操作する手段 artisan コマンド Cache ファサード

世界1に入るのは、config:cache / route:cache / event:cache / view:cache の生成物と、パッケージ自動検出の結果である packages.php / services.php です。世界2に入るのは Cache::remember() で包んだデータ——だけではないのですが、それは後の節で見ます。

観測には、2つの世界を一度に覗くシェルスクリプトを使いました。artisan コマンドとして実装しなかったのには理由があります。artisan は起動しただけで bootstrap/cache/packages.phpservices.php を再生成するので、観測を artisan コマンドで書くと観測者自身が世界1を書き換えてしまい、「optimize:clear 直後は空」という状態を捉えられません。

その再生成は実際に見えます。

php artisan optimize:clear
ls bootstrap/cache          # → 空
php artisan inspire > /dev/null
ls bootstrap/cache
packages.php
services.php

clear-compiled が消したファイルが、次のブートで戻っています。世界1が「捨てても戻ってくるビルド成果物」であることは、この時点でもう見えています。

optimize を打つと、この2つに3ファイルが加わって5つになります。コンパイル済みビューは別に64枚です。

php artisan optimize
 config ......... 8.30ms DONE
 events ......... 0.90ms DONE
 routes ........ 10.82ms DONE
 views ......... 94.55ms DONE
── 世界1: bootstrap/cache
   config.php          ← optimize が作った
   events.php          ← optimize が作った
   packages.php
   routes-v7.php       ← optimize が作った
   services.php
── 世界1: compiled views
   64 枚
── 世界2: アプリケーションキャッシュ
   store: database / driver: database
   [cache] 0 件
   [cache_locks] 0 件

世界2は0件のままです。optimizeCache ファサードに一切触っていません。タスクの一覧はソースにそのまま書いてあります。

return [
    'config' => 'config:cache',
    'events' => 'event:cache',
    'routes' => 'route:cache',
    'views'  => 'view:cache',
    ...ServiceProvider::$optimizeCommands,
];

vendor/laravel/framework/src/Illuminate/Foundation/Console/OptimizeCommand.php

世界1はキャッシュドライバを知らない

世界1が CACHE_STORE と無関係であることは、存在しないストア名を渡すと確かめられます。

php artisan optimize:clear
CACHE_STORE=bogus php artisan config:cache; echo "exit=$?"
 INFO Configuration cached successfully.

exit=0

同じ環境変数を、世界2に触るコマンドに渡します。

php artisan config:clear
CACHE_STORE=bogus php artisan cache:clear; echo "exit=$?"
   InvalidArgumentException

  Cache store [bogus] is not defined.

  at vendor/laravel/framework/src/Illuminate/Cache/CacheManager.php:121

exit=1

同じ環境変数で、世界1のコマンドは exit=0、世界2のコマンドは exit=1。この非対称性が、2つが別物である何よりの証拠です。

理由もソースで確認できます。設定キャッシュを読む側はこうなっています。

if (is_file($cached = $app->getCachedConfigPath())) {
    $items = require $cached;
}

vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/LoadConfiguration.php

素の require で、Cache ファサードは登場しません。当然で、キャッシュドライバの設定そのものが config/cache.php の中にある以上、設定を読むためにキャッシュストアを使うと鶏と卵になります。だから世界1は常にファイルとして書かれます。

ファイルとして書けないときは容赦がありません。パッケージ自動検出の結果を書き出す PackageManifest は、ディレクトリが書き込み不可なら例外を投げます。

if (! is_writable($dirname = dirname($this->manifestPath))) {
    throw new Exception("The {$dirname} directory must be present and writable.");
}

vendor/laravel/framework/src/Illuminate/Foundation/PackageManifest.php

なお getCachedConfigPath() が返すのは bootstrap/cache/config.php であって、config/cache.php ではありません。単語を入れ替えただけの名前で、前者は世界1の生成物、後者は世界2の設定ファイルという正反対の位置にあります。実際に解決させると分かります。

php artisan tinker --execute 'echo app()->getCachedConfigPath().PHP_EOL;'
/path/to/project/bootstrap/cache/config.php

世界1のファイルはすべて同じ経路でパスが決まり、環境変数で差し替えられます。読み取り専用のファイルシステムで /tmp に逃がすときに使う口です。

メソッド 環境変数 既定のパス
getCachedConfigPath() APP_CONFIG_CACHE bootstrap/cache/config.php
getCachedRoutesPath() APP_ROUTES_CACHE bootstrap/cache/routes-v7.php
getCachedEventsPath() APP_EVENTS_CACHE bootstrap/cache/events.php
getCachedPackagesPath() APP_PACKAGES_CACHE bootstrap/cache/packages.php
getCachedServicesPath() APP_SERVICES_CACHE bootstrap/cache/services.php

ちなみに cache という語は3箇所で別の意味に使われています。bootstrap/cache/ は世界1の置き場、config/cache.php は世界2のドライバ設定、storage/framework/cache/ は世界2の file ドライバの実体です。混同すると、この記事の話は全部ぼやけます。

config:cacheするとenv()がnullになる

世界1の性質のうち、実務で先に踏むのはこれです。設定キャッシュがあると .env の読み込みがスキップされます。

Once the configuration has been cached, your application’s .env file will not be loaded by the framework during requests or Artisan commands; therefore, the env function will only return external, system level environment variables.

(設定がキャッシュされると、リクエスト中も artisan コマンド中も .env はフレームワークに読み込まれません。したがって env 関数は、外部のシステムレベルの環境変数しか返しません)

— Laravel 13.x「Configuration — Configuration Caching

php artisan config:cache
php artisan tinker --execute 'echo "env(APP_NAME)=".var_export(env("APP_NAME"), true).PHP_EOL; echo "config(app.name)=".config("app.name").PHP_EOL;'
env(APP_NAME)=NULL
config(app.name)=Laravel

config:clear すると 'Laravel' に戻ります。ローカルでは動くのに本番だけ壊れる典型がこれです。config(...) は常に生きているので、env()config/*.php の中でだけ使う、というルールがここから導かれます。

同じ性質は検証中にも刺さります。設定キャッシュが焼かれている状態では、CACHE_STORE=bogus のような環境変数の上書きが読まれもしません。前節の非対称性を確かめるとき config:clear を挟んだのはそのためで、順序を間違えると結果がひっくり返ります。

php artisan optimize:clear
php artisan config:cache                                  # database を焼く
CACHE_STORE=bogus php artisan cache:clear; echo "exit=$?"
 INFO Application cache cleared successfully.

exit=0

壊れたドライバ名を渡したのに成功しました。「環境変数を変えたのに反映されない」は、設定キャッシュが残っている症状です。

Bladeを書いていなくてもコンパイル結果は出る

optimize で64枚のビューが生成されました。どこのビューなのかは、コンパイル結果に埋め込まれた元パスで分かります。

php artisan optimize
grep -h -o 'PATH [^*]*' storage/framework/views/*.php \
  | sed 's|.*/resources/views/||; s|.*/framework/src/Illuminate/||' \
  | sort | head -20
Foundation/resources/exceptions/renderer/components/topbar.blade.php ENDPATH
Foundation/resources/exceptions/renderer/components/trace.blade.php ENDPATH
Foundation/resources/exceptions/renderer/show.blade.php ENDPATH
bootstrap-4.blade.php ENDPATH
bootstrap-5.blade.php ENDPATH
email.blade.php ENDPATH
mcp/authorize.blade.php ENDPATH
semantic-ui.blade.php ENDPATH
simple-tailwind.blade.php ENDPATH
tailwind.blade.php ENDPATH
welcome.blade.php ENDPATH
...

64枚のうち、アプリ自身のビューは welcome.blade.php の1枚だけです。残りは例外画面のレンダラ、ページネーション(Bootstrap 4/5、Tailwind、Semantic UI の全バリエーション)、Markdown メールのレイアウトです。view:cache は vendor が名前空間登録したビューも走査するので、API 専用アプリでも Inertia + React でも生成物は出ます。「Blade を使っていないから view:cache は不要」は成り立ちません。

しかも Blade は遅延コンパイルです。view:cache を打たなくても、レンダリングされた時点で勝手に書かれます。

php artisan serve --port=8123 &
sleep 3
php artisan view:clear
find storage/framework/views -name '*.php' | wc -l   # → 0
curl -s -o /dev/null http://127.0.0.1:8123/
find storage/framework/views -name '*.php' | wc -l   # → 1

書けない状況を作ると、実行時エラーになります。

php artisan view:clear
chmod 500 storage/framework/views
curl -s -o /dev/null -w "status=%{http_code}\n" http://127.0.0.1:8123/
chmod 755 storage/framework/views
grep -o "local.ERROR: .\{0,80\}" storage/logs/laravel.log | tail -1
status=500                                                            ← curl の出力
local.ERROR: tempnam(): file created in the system's temporary directory   ← laravel.log の最終行

読み取り専用コンテナやスケール後の新ノードで「特定のページだけ500」が出るのはこれです。エラーページ自体も Blade なので、例外画面のレンダリングでさらに詰むことがあります。対策はイメージのビルド時に view:cache を済ませておくことです。

世界2にはデータ以外も住んでいる

世界2に入るのは、Cache::remember() で包んだデータだけではありません。フレームワークの機能が同じ場所に同居しています。

php artisan tinker --execute '
Cache::put("demo.value", "hello", 600);
RateLimiter::hit("login:1.2.3.4", 60);
Cache::lock("import-job", 300)->get();
'
── 世界2: アプリケーションキャッシュ
   store: database / driver: database
   [cache] 3 件
   - laravel-cache-demo.value
   - laravel-cache-login:1.2.3.4
   - laravel-cache-login:1.2.3.4:timer
   [cache_locks] 1 件
   - laravel-cache-import-job

アプリのデータとフレームワークの内部状態が同じテーブルに並びました。laravel-cache-config/cache.phpprefix で、既定は Str::slug(env('APP_NAME')).'-cache-' です。ロックだけが別テーブル cache_locks に入っている点は、後の節で効いてきます。

同居しているものを並べると、レートリミッタ、セッション(SESSION_DRIVER がキャッシュ系の場合)、ShouldBeUnique ジョブや WithoutOverlapping のロック、スケジューラの withoutOverlapping() / onOneServer() のミューテックスです。

ここが要点で、世界2の中にも段階があります。

消えるもの 帰結
remember() したデータ 性能劣化(再取得するだけ)
レートリミッタ 制限が実質リセット
セッション 全員ログアウト
ロック / ミューテックス 二重実行

ロックは「キャッシュ」と呼ばれていますが、失われても再生成されません。データが消えるのは性能の問題ですが、ロックが消えるのは実害です。

世界を跨ぐのはoptimize:clearだけ

コマンドと世界の対応は、ほぼ名前どおりです。世界1を作るのが optimize(= config:cache + event:cache + route:cache + view:cache)、世界1を消すのが config:clear / route:clear / event:clear / view:clear / clear-compiled、世界2を消すのが cache:clear / cache:forget

例外は optimize:clear の1つだけで、世界1を消したうえで cache:clear も実行します。

レートリミッタで確かめます。routes/web.php に1行足して、1分あたり3回に絞ります。

Route::get('/limited', fn () => 'ok')->middleware('throttle:3,1');
php artisan route:clear && php artisan config:clear   # 前節の optimize が残っていると新しいルートが効かない
for i in 1 2 3 4; do printf "req%s -> " $i; curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8123/limited; done
req1 -> 200
req2 -> 200
req3 -> 200
req4 -> 429

制限がかかった状態でカウンタを覗くと、キーはハッシュでした。

laravel-cache-5c785c036466adea360111aa28563bfd556b5fba
laravel-cache-5c785c036466adea360111aa28563bfd556b5fba:timer

元になっているのは、ログイン済みならユーザーID、未ログインならドメインと IP を | で繋いだ文字列です。

protected function resolveRequestSignature($request)
{
    if ($user = $request->user()) {
        return $this->formatIdentifier($user->getAuthIdentifier());
    } elseif ($route = $request->route()) {
        return $this->formatIdentifier($route->getDomain().'|'.$request->ip());
    }

vendor/laravel/framework/src/Illuminate/Routing/Middleware/ThrottleRequests.php

その formatIdentifier()sha1() をかけるので、cache:forget でピンポイントに消すのは、自分でハッシュを計算しない限り事実上できません。この消せなさが、次の実測の重みになります。429 が出たままの状態で --except=cache 付きで実行します。

php artisan optimize:clear --except=cache
curl -s -o /dev/null -w "after --except=cache: %{http_code}\n" http://127.0.0.1:8123/limited
 INFO Clearing cached bootstrap files.
 config .......... 0.92ms DONE
 compiled ........ 0.98ms DONE
 events .......... 0.41ms DONE
 routes .......... 0.28ms DONE
 views ........... 8.10ms DONE

after --except=cache: 429

cache の行が消えていて、制限は維持されたままです。除外せずに世界2を消すと、こうなります。

php artisan cache:clear
curl -s -o /dev/null -w "after cache:clear: %{http_code}\n" http://127.0.0.1:8123/limited
after cache:clear: 200

制限を食らっていたクライアントが解放されました。対応表はソースにそのまま書いてあります。

return [
    'config'   => 'config:clear',
    'cache'    => 'cache:clear',     // ← これだけ世界2
    'compiled' => 'clear-compiled',
    'events'   => 'event:clear',
    'routes'   => 'route:clear',
    'views'    => 'view:clear',
    ...ServiceProvider::$optimizeClearCommands,
];

vendor/laravel/framework/src/Illuminate/Foundation/Console/OptimizeClearCommand.php

--except はキー名(cache)でもコマンド名(cache:clear)でも通り、カンマ区切りで複数指定できます。除外の判定が hasAny([$command, $key]) になっているからです。

命名も混乱に加担しています。:cache サフィックスは「作る」、:clear は「消す」。ところが cache:clear だけは cache が名詞(アプリケーションキャッシュ)で clear が動詞なので、意味の構造が逆転しています。

ロックが消えると二重実行になる

レートリミッタのリセットはまだ復旧できる部類です。ロックはそうはいきません。Isolatable を実装したコマンドを用意します。--isolated を付けると、内部で Cache::lock() を取ります。

class DemoSlowCommand extends Command implements Isolatable
{
    protected $signature = 'demo:slow';

    public function handle(): int
    {
        $this->info('start '.now()->toTimeString());
        sleep(10);
        $this->info('end '.now()->toTimeString());

        return self::SUCCESS;
    }
}

まずロックが効くことを確認します。ここでは file ドライバを使います(理由は次の節)。

CACHE_STORE=file php artisan cache:clear
CACHE_STORE=file php artisan demo:slow --isolated > /tmp/s1.log 2>&1 &
sleep 2
CACHE_STORE=file php artisan demo:slow --isolated
The [demo:slow] command is already running.

期待どおりブロックされました。ここに cache:clear を挟みます。

CACHE_STORE=file php artisan cache:clear
CACHE_STORE=file php artisan demo:slow --isolated
cat /tmp/s1.log
 INFO Application cache cleared successfully.

start 12:27:21     ← 3つ目のプロセス
end   12:27:31

start 12:27:18     ← 1つ目のプロセス
end   12:27:28

1つ目が 12:27:18-28、3つ目が 12:27:21-31。重なっているのは 21 から 28 までの7秒で、10秒のコマンドの7割が二重に走ったことになります。目の前で重なるのを見ると、さすがに肝が冷えます。

cache:clear は「キャッシュを消した」だけのつもりで、排他制御を壊しました。これがキュージョブ(ShouldBeUnique / WithoutOverlapping)やスケジューラの onOneServer() で起きると、二重課金・二重送信・データ破損になります。optimize:clear はこれを内包しています。

ロックが死ぬかはドライバで変わる

前節でわざわざ CACHE_STORE=file を指定したのは、database ドライバだと結果が変わるからです。

php artisan tinker --execute 'Cache::lock("import-job", 300)->get();'
php artisan optimize:clear
php artisan tinker --execute 'echo "cache rows: ".DB::table("cache")->count().PHP_EOL; echo "lock rows: ".DB::table("cache_locks")->count().PHP_EOL;'
cache rows: 0
lock rows: 1

ロックが生き残りました。理由は flush() の実装と、ロックの置き場所の既定値にあります。

public function flush()
{
    $this->table()->delete();

    return true;
}

vendor/laravel/framework/src/Illuminate/Cache/DatabaseStore.php

ドライバ flush() の実体 ロックの置き場所(既定) cache:clear でロックは
database cache テーブルを delete 別テーブル cache_locks 生き残る
file framework/cache/data/ 配下を削除 lock_path が同じ data/ 死ぬ
redis flushdb() lock_connection が既定で default 構成次第

file ドライバのロックが死ぬのは、素の config/cache.phppathlock_path がどちらも storage_path('framework/cache/data') を指しているからです。Redis がとくに厄介で、flushdb() はプレフィックスを見ません。同じ Redis DB を他用途と共有していると、無関係なキーまで消えます。

ロックだけを狙って消す手段もあります。

You may clear all atomic locks in the cache using the flushLocks method

(flushLocks メソッドで、キャッシュ内のアトミックロックをすべて消せます)

— Laravel 13.x「Cache — Removing Items From the Cache

artisan からは cache:clear --locks です。ただし、ロックストアがキャッシュストアと物理的に分かれていないと失敗します。

CACHE_STORE=file php artisan cache:clear --locks
   RuntimeException

  Flushing locks is only supported when the lock store is separate from the cache store.
public function flushLocks(): bool
{
    if (! $this->hasSeparateLockStore()) {
        throw new RuntimeException('Flushing locks is only supported when the lock store is separate from the cache store.');
    }

vendor/laravel/framework/src/Illuminate/Cache/FileStore.php

つまり「cache:clear でロックが飛ぶか」はドライバとロック設定(lock_connection / lock_table / lock_path)に依存します。一般論では断定できないので、自分の本番構成で1回確かめておく項目です。

prefixを分けても削除の境界にはならない

ロックが飛ぶ構成だと分かった場合、次に考えるのは「消す範囲を絞る」ことです。「全消しが必要なら専用ストアを切って cache:clear tenant に限定すればいい」という対策があります。素直に書くと効きません。config/cache.phpstores にプレフィックス違いを足した場合です。

'tenant' => [
    'driver' => 'database',
    'table' => env('DB_CACHE_TABLE', 'cache'),   // ← デフォルトと同じテーブル
    'prefix' => 'tenant_',
],
php artisan tinker --execute '
Cache::store("tenant")->put("t.a", "x", 600);
Cache::put("global.b", "y", 600);
echo "before: ".DB::table("cache")->count().PHP_EOL;'
php artisan cache:clear tenant
php artisan tinker --execute 'echo "after: ".DB::table("cache")->count().PHP_EOL;'
before: 2
after: 0

tenant を消したつもりで、デフォルトストアの分まで消えました。DatabaseStore::flush() が前節のとおりテーブル全消しで、プレフィックスを見ていないからです。ドキュメントにも警告があります。

Flushing the cache does not respect your configured cache “prefix” and will remove all entries from the cache.

(キャッシュの全消しは設定した prefix を尊重せず、キャッシュから全エントリを削除します)

— Laravel 13.x「Cache — Removing Items From the Cache

効かせるには、テーブルごと物理的に分ける必要があります。

'tenant' => [
    'driver' => 'database',
    'table' => 'tenant_cache',
    'lock_table' => 'tenant_cache_locks',
    'prefix' => '',
],
tenant:1 default:1     ← cache:clear tenant の前
tenant:0 default:1     ← 後

prefix は衝突回避のための名前空間であって、削除の境界ではありません。flush() の粒度は、ストアの物理的な実体(テーブル / ディレクトリ / Redis DB)で決まります。

デプロイに書くのはoptimizeの1行でいい

事前クリアが要らないのは、各 :cache コマンドが自分で先に clear しているからです。

public function handle()
{
    $this->callSilent('config:clear');

    $config = $this->getFreshConfiguration();

vendor/laravel/framework/src/Illuminate/Foundation/Console/ConfigCacheCommand.php

route:cache / event:cache / view:cache も同じで、handle() の1行目がそれぞれの callSilent('...:clear') です。つまり optimize:clear && optimize の前半は、世界1に関しては何も足していません。足しているのは世界2の巻き添えだけです。

php artisan optimize

どうしても消してから作りたいなら php artisan optimize:clear --except=cache にします。世界1はノードローカルなので、全ノードで実行するのを忘れないようにします。

まとめ

ローカルで挙動がおかしいときの optimize:clear はそのままで構いません。巻き添えの中身を知ったうえで打つのと、逆操作のつもりで打つのとでは、本番に持ち込んだときの結果が変わります。