共有レンタルサーバーの罠 #4/全5本。症状→原因→対策の索引は 🔗 罠ハブ から。
はじめに
共有レンタルサーバー(GD が --with-avif 無しでビルドされている環境)で、オンデマンドの派生画像を配信している方へ。
Docker 本番(--with-avif ビルド)では全部 200 で返り、テストも緑でした。ところが共有ホスティングへの設置リハーサルで php:8.4-apache 公式イメージ相当の環境に置いた瞬間、ページ内の画像が軒並み壊れました。派生画像のリクエストが全部 500 です。実設置の前にこのリハーサルで踏めたのが不幸中の幸いでした。
犯人は「ブラウザが AVIF を要求している」ことと「サーバがその AVIF を生成できる」ことを暗黙に同一視していたこと。imageavif() が存在しない環境で Error(未定義関数)が飛び、それが RuntimeException の catch を抜けて 500 になっていました。この記事では、なぜ AVIF 交渉で派生画像が全滅したのか、エンコーダの実在まで条件に入れた形式交渉でどう直したかを実コードで追います(解決済み)。
この記事で分かること:
- 受信側の
Acceptだけで content negotiation を組むと、生成側の能力欠如で 500 が出る仕組み -
imageavif()/imagewebp()はビルドフラグ依存。function_exists()で能力を確かめてから選ぶ実装 -
Error(未定義関数)がRuntimeExceptionの catch を抜ける理由と、正しい防ぎ方
前提を共有します。自己ホスト型の OSS NeNe Records(PHP 8.4 + SPA)は、GET /media/{preset}/… で元画像を thumb(160px)等へリサイズし、出力形式を Accept によるコンテンツネゴシエーションで決めます。ローカル(--with-avif の GD)では Chrome に AVIF が返り、テストも緑でした。
何が起きたか — Accept: image/avif で 500
現象はシンプルでした。Chrome や Edge は画像リクエストの Accept ヘッダに 常に image/avif を含めて送ってきます。
Accept: image/avif,image/webp,image/apng,image/*,*/*;q=0.8
派生画像ハンドラの形式交渉は、修正前はこうなっていました(ServeDerivativeHandler::negotiateFormat())。Accept に image/avif の文字列が含まれていれば、無条件で AVIF を選ぶ。
// 修正前(抜粋・イメージ)
if (str_contains($accept, 'image/avif')) {
return ImageProcessorInterface::FORMAT_AVIF;
}
if (str_contains($accept, 'image/webp')) {
return ImageProcessorInterface::FORMAT_WEBP;
}
選ばれた形式は、そのまま GD のエンコード関数に流れます(GdImageProcessor::encode())。
$ok = match ($format) {
self::FORMAT_WEBP => imagewebp($image, null, $this->quality),
self::FORMAT_AVIF => imageavif($image, null, $this->quality),
self::FORMAT_JPEG => imagejpeg($image, null, $this->quality),
self::FORMAT_PNG => imagepng($image),
default => throw new RuntimeException('Unsupported output format: ' . $format),
};
ここで、共有ホスティングの GD は --with-avif 無しでビルドされていることが普通にあります(php:8.4-apache の公式イメージも、この記事を書いた時点では該当しました)。AVIF 無しビルドの環境では imageavif() という関数がそもそも存在しません。
つまり imageavif($image, ...) の行に到達した瞬間、PHP は
Error: Call to undefined function imageavif()
を投げます。厄介なのは、これが RuntimeException ではなく Error(未定義関数呼び出し) だという点です。ハンドラ側には生成失敗のガードとして catch (\RuntimeException) がありましたが、Error はこの網に一切かからず、そのままアプリの外まで抜けて 500 になります。
そして Chrome/Edge は毎回 Accept: image/avif を送るので、その環境ではすべての派生画像リクエストが 500 = 画像全滅、というわけです。ローカルと Docker 本番(AVIF あり GD)では 200 が返りテストも通るので、AVIF 無しの環境に置く設置リハーサルで初めて発覚しました。
なぜ壊れたか — 受信側の Accept だけを見て、生成側の能力を無視していた
Accept の文字列だけで AVIF を選ぶと、エンコーダ不在の環境では下図のとおり 500 まで抜けます。
content negotiation は本来「サーバが提供できる表現(representation)の中から、クライアントの選好に最も合うものを選ぶ」仕組みです。ポイントは**「サーバが提供できる」集合が先にある**ことです。
修正前のコードは、この「サーバが提供できる集合」を暗黙に固定していました。「AVIF・WebP・JPEG・PNG は全部出せる」という前提で、クライアントの Accept だけを見て決めていたわけです。
しかし GD のエンコーダはビルドフラグ依存です。imageavif() / imagewebp() が使えるかどうかは、実行環境ごとに違います。
- Docker 本番(
--with-avifビルド)→ AVIF 出せる - 共有ホスティングの GD → AVIF 出せない(
imageavif()が無い)
「ブラウザが AVIF を要求している」は「サーバが AVIF を生成できる」を保証しません。この 2 つを取り違えたのが根本原因です。交渉のロジックに、生成側(エンコーダ)の実在という条件が欠けていました。
対策:エンコーダの能力込みで交渉する
方針は素直で、「その形式を本当にエンコードできるか」を交渉の各分岐で必ず問い合わせるようにしました。
まずインターフェースに、出力形式ごとの能力を返す supportsOutput() を足します(ImageProcessorInterface)。
/**
* Whether this processor can encode the given output format (one of the
* FORMAT_* constants). Encoder availability depends on how the underlying
* library was built (e.g. GD without --with-avif has no imageavif()), so
* format negotiation must consult this before selecting a format.
*/
public function supportsOutput(string $format): bool;
GD 実装では function_exists() でエンコーダの実在を判定します(GdImageProcessor::supportsOutput())。ここが今回の肝です。
public function supportsOutput(string $format): bool
{
// GD の encoder はビルドフラグ依存(共有ホスティングでは AVIF 無しが普通にある)。
return match ($format) {
self::FORMAT_WEBP => function_exists('imagewebp'),
self::FORMAT_AVIF => function_exists('imageavif'),
self::FORMAT_JPEG => function_exists('imagejpeg'),
self::FORMAT_PNG => function_exists('imagepng'),
default => false,
};
}
そのうえで交渉ロジックを、各分岐で supportsOutput() を必ず通すよう書き換えます(ServeDerivativeHandler::negotiateFormat())。?fm= の明示指定・Accept による選好・元画像形式へのフォールバック、そのすべてに「出せるか」の条件を掛けます。
// encoder はビルドフラグ依存(GD の AVIF 無しビルド等)。processor が出力
// できない形式を選ぶと encode 時に fatal になるため、交渉は常に能力込みで行う。
if ($fm !== null && in_array($fm, $allowed, true) && $this->processor->supportsOutput($fm)) {
return $fm;
}
if (str_contains($accept, 'image/avif') && $this->processor->supportsOutput(ImageProcessorInterface::FORMAT_AVIF)) {
return ImageProcessorInterface::FORMAT_AVIF;
}
if (str_contains($accept, 'image/webp') && $this->processor->supportsOutput(ImageProcessorInterface::FORMAT_WEBP)) {
return ImageProcessorInterface::FORMAT_WEBP;
}
$sourceFormat = match ($sourceMime) {
'image/png' => ImageProcessorInterface::FORMAT_PNG,
'image/webp' => ImageProcessorInterface::FORMAT_WEBP,
'image/avif' => ImageProcessorInterface::FORMAT_AVIF,
default => ImageProcessorInterface::FORMAT_JPEG,
};
if ($this->processor->supportsOutput($sourceFormat)) {
return $sourceFormat;
}
return $this->processor->supportsOutput(ImageProcessorInterface::FORMAT_PNG)
? ImageProcessorInterface::FORMAT_PNG
: ImageProcessorInterface::FORMAT_JPEG;
これで、Accept: image/avif が来ても、AVIF を出せない環境では次の候補(WebP → 元画像形式 → PNG)へフォールバックします。効かせどころは 3 つです。
-
?fm=avifの明示指定でも、出せなければ落とさずフォールバックする(クエリで壊せない)。 -
Acceptの選好は、エンコーダが実在するときだけ採用する。 - 最終フォールバックとして、元画像形式も出せなければ PNG(それも無ければ JPEG)へ落とす。GD で PNG/JPEG が両方欠けることは実質ないので、必ずどれかは返せる。
回帰テストは「AVIF エンコーダを持たない環境」を無名クラスで模して書きました(tests/Media/MediaHttpTest.php)。supportsOutput(FORMAT_AVIF) は false を返し、万一 resize() に AVIF が来たら実環境と同じ \Error('Call to undefined function imageavif()') を投げる、という意地の悪いダブルです。
public function supportsOutput(string $format): bool
{
return $format !== self::FORMAT_AVIF && $this->inner->supportsOutput($format);
}
public function resize(string $sourceBytes, int $maxWidth, string $format): string
{
if ($format === self::FORMAT_AVIF) {
throw new \Error('Call to undefined function imageavif()');
}
return $this->inner->resize($sourceBytes, $maxWidth, $format);
}
これに対して、Accept: image/avif,... を送ると 200 で image/webp が返ること、?fm=avif を送っても 500 にならずソース形式(PNG)へ落ちることを検証しています。
$response = $this->avifLessDerivativeHandler()->handle(
$this->derivativeRequest('thumb', 'pic.png')
->withHeader('Accept', 'image/avif,image/webp,image/*'),
);
self::assertSame(200, $response->getStatusCode());
self::assertSame('image/webp', $response->getHeaderLine('Content-Type'));
実機でも、共有ホスティング相当のハーネス(php:8.4-apache 公式イメージ)で 500 → 200 (image/webp) を確認しました。--with-avif でビルドされた Docker 本番の挙動は変わりません(従来どおり AVIF を返す)。
学び
-
content negotiation は「受信側の
Accept」だけでは閉じない。「生成側が本当に出せるか」まで条件に入れて初めて成立する。 サーバが提供できる representation の集合を、環境に応じて動的に確定させるのが正しい姿。 -
imageavif()/imagewebp()の有無はビルドフラグ依存。function_exists()で実在を確かめてから使う。「PHP に GD が入っている」は「AVIF/WebP をエンコードできる」を意味しない。 -
Error(未定義関数呼び出し)はRuntimeExceptionの catch では止まらない。 生成系のガードをRuntimeExceptionだけに絞っていると、能力不足がそのまま 500 まで抜ける。防ぐべきは catch を広げることではなく、そもそも出せない形式を選ばないこと。 - ローカルと本番でエンジンの構成が違うと、テスト緑・本番全滅が起きる。 「能力の欠けた環境」を偽装するテストダブルを 1 つ持っておくと、この手の環境差はコードで固定できる。
「ブラウザが対応形式を要求している」は、サーバがそれを作れることの証明ではありません。交渉する側は、相手の選好と自分の能力の両方を、毎回突き合わせる必要があります。
一次資料
-
nene-records#737 / #738(fix: AVIF エンコーダ不在の GD で派生画像が 500 になるのを修正する) -
src/Media/ImageProcessorInterface.php—supportsOutput()の追加 -
src/Media/GdImageProcessor.php—function_exists()によるエンコーダ能力判定・encode() -
src/Media/ServeDerivativeHandler.php— 能力込みのnegotiateFormat() -
tests/Media/MediaHttpTest.php/tests/Media/GdImageProcessorTest.php— AVIF-less 環境の回帰テスト
関連記事
- 🔗 共有レンタルサーバーの罠ハブ(症状→原因→対策の索引)(全5本の入口・随時更新)
- 同シリーズ:① InMemory レート制限が発動しない、③ 作成1秒の org が即消える(JST×UTC)、⑤ phpdotenv v5 は putenv しない
- 本記事の「生成側の能力を条件に入れる」話は、より一般的なエラーの content negotiation(API には JSON/ブラウザには HTML)の設計論と近縁です。
── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp