YUKI.LOG― 夜明けのログ ―― 真昼のログ ―― 夕暮れのログ ―― 夜更けのログ ―

Storage::fake では捕まらない S3 の不具合を、AWS Emulator で捕まえる

Laravel で S3 を使うコードのテストといえば Storage::fake() です。手軽で速いのですが、中身は local ドライバなので、S3 とのやり取りそのものは確かめられません。

実際に、本番の S3 では画像として扱われないのに、Storage::fake() のテストは通ってしまう、というケースがあります。AWS 公式のローカルエミュレータ「AWS Emulator」が出ていたので、これに向けて PHPUnit で S3 の結合テストを書き、そのケースを捕まえられるかを試しました。

題材:拡張子の無いファイル名で画像を置く

題材は、Laravel 公式ドキュメントの「Specifying a File Name」にある、ユーザー ID をそのままファイル名にする例です。

$path = $request->file('avatar')->storeAs(
    'avatars', $request->user()->id
);

ファイル名に拡張子が付かないので、画像の種類はオブジェクトの Content-Type に持たせます。これをクラスにすると次のようになります。

use Aws\S3\Exception\S3Exception;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use League\Flysystem\UnableToRetrieveMetadata;

final class AvatarStore
{
    public function put(string $userId, UploadedFile $image): void
    {
        // キーに拡張子が無いので、Content-Type を渡さないと種類が決まらない
        $image->storeAs('avatars', $userId, [
            'disk' => 'images',
            'ContentType' => $image->getMimeType(),
        ]);
    }

    public function contentType(string $userId): ?string
    {
        try {
            return Storage::disk('images')->mimeType("avatars/{$userId}") ?: null;
        } catch (UnableToRetrieveMetadata $e) {
            // 無いときだけ null にし、権限不足や通信断はそのまま上げる
            $previous = $e->getPrevious();
            if ($previous instanceof S3Exception && $previous->getStatusCode() === 404) {
                return null;
            }

            throw $e;
        }
    }
}

ContentType を渡し忘れても、fake のテストは通る

まずは Storage::fake() でテストを書きます。置いた画像の種類を読み返すだけです。

protected function setUp(): void
{
    parent::setUp();

    Storage::fake('images');
}

public function test置いた画像の種類を読み返せる(): void
{
    $image = UploadedFile::fake()->image('photo.png', 10, 10);

    (new AvatarStore())->put('123', $image);

    $this->assertSame('image/png', (new AvatarStore())->contentType('123'));
}

このテストは通ります。問題は、put() から 'ContentType' => $image->getMimeType() の行を消しても通ってしまうことです。

本番の S3 では、渡し忘れた画像は application/octet-stream として置かれ、画像として扱われません。それなのにテストは緑のままです。

なぜ fake では捕まらないのか

理由は、ストリームで送るときは、誰もファイルの中身を見ていないからです。

storeAs() は、ファイルをストリームで送ります。公式ドキュメントの「Automatic Streaming」にあるとおり、putFile / putFileAs(storeAs の中身)は自動でストリームを使います。

ContentType を渡し忘れると、S3 に送るまでの各段階で種類が次のように決まります。

段階 何を見るか 結果
Flysystem の S3 アダプタ ストリームなので中身は見ず、キー(avatars/123)の拡張子を見る 決まらない
AWS SDK for PHP 元のファイルのパスの拡張子を見る。アップロードされたファイルは拡張子の無い一時ファイル application/octet-stream

どちらも拡張子しか見ないので、拡張子の無いファイルは application/octet-stream になります。S3 は送られてきた Content-Type をそのまま持つので、読み返しても application/octet-stream です。

テストで画像を photo.png という名前で作っていても同じです。この名前はブラウザから届いた元のファイル名にあたり、storeAs() には使われません。キーは渡した '123' だけで決まります。

一方、Storage::fake() の中身である local ドライバの mimeType() は、渡した ContentType を見ずに、保存したファイルの中身から種類を判定します。中身は PNG なので image/png が返り、渡し忘れてもテストは通ります。

つまり、fake のテストは「S3 に何を送ったか」ではなく「ファイルの中身が何か」を確かめているだけです。

なお、これは拡張子の無いキーにストリームで送るときの話です。次のどれかなら種類は付きます。

  • storeAs('avatars', "{$id}.png") のように、キーに拡張子を付ける(Flysystem が拡張子から引く)
  • store() / putFile() を使う(中身から推測した拡張子付きの名前を作る)
  • 文字列で put() する(Flysystem が中身から推測する)

AWS Emulator で S3 に近い相手に向ける

これを捕まえるには、送った Content-Type をそのまま持ち、S3 と同じ形で返してくれる相手にテストを向ける必要があります。そこで AWS Emulator を使います。

AWS Emulator は、AWS の API を手元で再現するエミュレータです。テストに使ううえで、次の点が便利でした。

  • AWS アカウントが要らず、料金もかからない。 本物の S3 にテスト用のバケットを用意しなくて済みます。
  • SDK をそのまま使える。 接続先(endpoint)を差し替えるだけで、アプリのコードは変えません。
  • S3 API と同じ形で応答する。 送った Content-Type をそのまま返し、無いキーには S3 と同じく 404 の S3Exception を返します。
  • 状態はメモリにだけ持つ。 止めるとバケットも中身も消えるので、起動しなおせばまっさらな状態に戻せます。ただし、動かしている間はテストの実行をまたいで中身が残ります。後で出てくる setUp() でバケットがあるかを先に確かめているのは、このためです。

Docker で立てる

公開イメージは無いので、公式手順の Dockerfile を元に、CPU に合わせて rpm を選ぶようにしてビルドします。

FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal

RUN rpm -i "https://downloads.aws.com/aws-emulator/packages/aws-emulator/LTS/latest/aws-emulator-$(uname -m).rpm" \
    && mkdir -p /home/emulator && chown 1000 /home/emulator

USER 1000
ENV HOME=/home/emulator
EXPOSE 9297
ENTRYPOINT ["aws-emulator", "-b", "0.0.0.0"]

compose では、署名の検証とテレメトリを切って起動しました。オプションはコマンドリファレンスにあります。

services:
  aws-emulator:
    build: ./docker/aws-emulator
    # 署名キーは起動のたびに作り直されるので、手元のテストでは検証を切る
    command: ["-p", "9297", "--auth", "off", "--telemetry", "off"]
    ports:
      # 認証を切っているので、公開は 127.0.0.1 に限る
      - "127.0.0.1:9297:9297"
    volumes:
      # 起動のたびに書き出される ca.pem を、ホストから読めるようにする
      - ${HOME}/.aws/emulator/9297:/home/emulator/.aws/emulator/9297

マウント先のディレクトリは、起動する前に mkdir -p ~/.aws/emulator/9297 で作っておきます。無いと Docker が root の持ち物として作るので、Linux ではホストから読めなくなります。

また、Linux では、書き出されるファイルが自分の持ち物になるように、コンテナを自分の uid と gid で動かします(公式手順では、docker run に --user "$(id -u):$(id -g)" を付けています)。

PHPUnit からディスクを繋ぎ替える

テストの setUp() で、Storage::fake('images') の代わりに、images ディスクを Emulator の S3 に差し替えます。

protected function setUp(): void
{
    parent::setUp();

    $endpoint = env('AWS_EMULATOR_ENDPOINT');
    $caBundle = env('AWS_EMULATOR_CA_BUNDLE');
    if (!$endpoint || !$caBundle) {
        // CI で渡し忘れたときに、黙って skip されて緑になるのを防ぐ
        if (env('CI')) {
            $this->fail('CI では AWS_EMULATOR_ENDPOINT と AWS_EMULATOR_CA_BUNDLE を渡す');
        }
        $this->markTestSkipped('Emulator の接続先が無いので skip');
    }

    // throw などは本番の images ディスクの設定を引き継ぎ、接続先だけ差し替える
    $config = array_merge(config('filesystems.disks.images'), [
        'bucket' => 'test-images',
        'region' => 'us-east-1',
        'endpoint' => $endpoint,
        // --auth off なのでキーは何でもよい
        'key' => 'dummy',
        'secret' => 'dummy',
        // バケット名をホスト名に入れる形式は受け付けないので path-style にする
        'use_path_style_endpoint' => true,
        // 起動のたびに書き出される証明書を信頼させる(localhost と 127.0.0.1 宛て)
        'http' => ['verify' => $caBundle],
    ]);

    $disk = Storage::build($config);
    $client = $disk->getClient();
    if (!$client->doesBucketExistV2('test-images')) {
        $client->createBucket(['Bucket' => 'test-images']);
    }

    Storage::set('images', $disk);
}

path-style が要ることは、公式の「Host names that your SDK changes」に書かれています。

ほかの設定は、config('filesystems.disks.images') を元にして接続先だけを上書きしています。テスト側で throw などを書き直すと、本番のディスクと違う条件で確かめることになるからです。

TLS の検証は切らずに、Emulator が書き出す ca.pem を信頼させます。証明書は起動のたびに作り直されますが、置き場所(~/.aws/emulator/<ポート>/ca.pem)は変わらないので、パスを渡しておけば起動しなおしてもそのまま使えます。証明書は localhost と 127.0.0.1 宛てなので、PHP を compose の別コンテナで動かし、aws-emulator のようなサービス名で繋ぐと検証に通りません。

接続先と証明書は、環境変数で渡して実行します。

AWS_EMULATOR_ENDPOINT=https://127.0.0.1:9297 \
AWS_EMULATOR_CA_BUNDLE=$HOME/.aws/emulator/9297/ca.pem \
php artisan test

環境変数が無ければ skip するので、ふだんの php artisan test に混ざっていても落ちません。ただし CI(CI という環境変数がある環境)では、渡し忘れても skip されて緑のままになってしまうので、fail にしています。なお、この 2 つを .env に書くと、php artisan config:cache 済みの環境では .env が読まれず、env() が null を返して skip されます。シェルや CI の環境変数として渡してください。

fake で通ったテストが、Emulator では落ちた

テスト本体は、fake のときと 1 行も変えていません。変えたのは setUp() だけです。

Storage::fake AWS Emulator
正しいコード 🟢 通る 🟢 通る
ContentType を渡し忘れ 🟢 通ってしまう 🔴 落ちる

Emulator に向けると、渡し忘れたときは image/png ではなく application/octet-stream が返り、テストが落ちました。ContentType を戻すと、また通ります。fake では見えなかった「S3 に何を送ったか」を、同じテストで確かめられるようになりました。

試した環境は、AWS Emulator 1.0.385、Laravel 13.42.0、league/flysystem-aws-s3-v3 3.35.3、aws/aws-sdk-php 3.403.6、PHP 8.4 です。

もう 1 つ:「無ければ null」の分岐

contentType() には、S3 が 404 を返したときだけ null にする分岐があります。これも fake では確かめられませんでした。

public function test置いていない画像の種類はnullになる(): void
{
    $this->assertNull((new AvatarStore())->contentType('missing'));
}

次の表は、config/filesystems.php で images ディスクを 'throw' => true にしている場合です。Storage::fake() も Emulator 側の setUp() も元のディスクの throw 設定を引き継ぐので、同じ条件で比べています。

Storage::fake AWS Emulator
正しいコード 🔴 落ちる 🟢 通る
404 も例外にしてしまう 🔴 落ちる 🔴 落ちる

fake では、正しいコードなのに落ちます。 local ドライバは、ファイルが無いときに S3Exception の付かない例外(No such file exists.)を投げるからです。throw => true なので、この例外がそのまま上がります。正しいコードでも壊れたコードでも落ちるので、区別できません。

Emulator は本物の S3 と同じく S3Exception(404)を返したので、正しいコードは通り、404 も例外にしてしまったコードは落ちました。

なお、throw を設定していない(既定の false)場合は、fake でも Emulator でも例外は出ず、mimeType() が false を返して null になります。404 の S3Exception も Laravel が握りつぶすので、contentType() の分岐まで届きません。正しいコードも壊れたコードも通るので、どちらに向けても区別できません。

分かった限界

  • 権限(IAM)が絡む動きは確かめられない。 公式の制限事項に、IAM ポリシーやバケットポリシーを AWS と同じようには評価しないとあります。これは --auth の設定とは関係ありません。
    • たとえば S3 の HeadObject は、s3:ListBucket の権限が無いと、キーが無いときに 404 ではなく 403 を返します。「無ければ null」というコードは、権限しだいで例外になります。これは本物の AWS で確かめるしかありません。
  • すべての動きが本物と比べて確かめられているわけではない。 AWS Emulator は、AWS と同じ動きをするかを fidelity test で確かめ、サービスごとの合格数(fidelity score)をリリースごとに公開しています。ただ、公式の Fidelity のページによると、いま本物にいちばん近いのはバケットの作成などの control plane で、オブジェクトの読み書きのような data plane はサービスによって差があります。テストが緑でも「S3 と同じ」と言い切るには、使う操作が fidelity test で確かめられているかを見る必要があります。
    • たとえば、この記事の put() が送る PutObject には、ContentType のほかに ACL=private も付いています。ディスクに visibility を設定していないと、Flysystem が private を付けるからです。AWS Emulator 1.0.385 で確かめたところ、ContentType の読み返しと、無いキーへの 404 には、対応する fidelity test があり、合格していました。一方、ACL=private を付けた PutObject の結果を確かめる fidelity test はありませんでした。テストは緑でも、この呼び出しが S3 と同じだという根拠は、まだ無いことになります。
    • 使う操作が fidelity test で確かめられているかは、公式の「Checking whether your project works with AWS Emulator」の手順で調べられます。
  • 版は自分で管理する必要がある。 Dockerfile は LTS/latest の rpm を取り込むので、どの版が入るかはビルドした時点で決まります。Docker のキャッシュが効いている手元では古い版のまま残り、キャッシュの無い CI では最新版が入るので、手元と CI で版がずれることがあります。最新版にするときは docker build --pull --no-cache でビルドし直します。版を指定して入れることもできますが、セキュリティ修正は最新版にしか入らず、公式は最新版を勧めています。

まとめ

  • Storage::fake() は、ファイルの中身から種類を判定します。そのため、ストリームで送るときの ContentType の渡し忘れのように、「S3 に何を送ったか」で決まる不具合は捕まえられません。
  • AWS Emulator に向けると、同じテストのまま、fake で通ってしまった渡し忘れを捕まえられました。404 の分岐も、fake では正しいコードのほうが落ちましたが、Emulator では正しく確かめられました。
  • Storage::fake() は業務ロジックのテストに使い、S3 とのやり取りは結合テストで確かめる、という分け方がよさそうです。
  • 権限まわりは、AWS Emulator では確かめられません。そこは本物の環境で確かめる項目として、別に持っておくのがよさそうです。

参考文献