0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

共有レンタルサーバーで派生画像が全部500になる|AVIF交渉・エンコーダ不在

0
Last updated at Posted at 2026-07-21

共有レンタルサーバーの罠 #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())。Acceptimage/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 つです。

  1. ?fm=avif の明示指定でも、出せなければ落とさずフォールバックする(クエリで壊せない)。
  2. Accept の選好は、エンコーダが実在するときだけ採用する。
  3. 最終フォールバックとして、元画像形式も出せなければ 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.phpsupportsOutput() の追加
  • src/Media/GdImageProcessor.phpfunction_exists() によるエンコーダ能力判定・encode()
  • src/Media/ServeDerivativeHandler.php — 能力込みの negotiateFormat()
  • tests/Media/MediaHttpTest.php / tests/Media/GdImageProcessorTest.php — AVIF-less 環境の回帰テスト

関連記事


── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?