AWS SDK for PHP S3アクセス時にinstance profile利用する場合の一工夫 Laravel編

# AWS SDK for PHP S3アクセス時にinstance profile利用する場合の一工夫 Laravel編
結論は、LaravelではServiceProviderを駆使して
インスタンスプロファイル認証プロバイダと認証情報のメモ化を使いましょう！

# 解説
## デフォルトの認証プロバイダ
### Aws\Credentials\CredentialProvider::defaultProvider()
デフォルトの認証情報プロバイダで、クライアントの作成時に credentials オプションを指定しなかった場合に使用されます。

#### どんな仕様か？
このプロバイダは、環境変数、.ini ファイル (.aws/credentials ファイルから .aws/config ファイルの順)、インスタンスプロファイル (EcsCredentials から Ec2 メタデータの順) の順で認証情報のロードを試行します。

つまり、EC2インスタンスIAMロールまたはECSタスクIAMロールを使用する場合、AWS php sdkは自動的にec2メタデータAPI（169.254.169.254）に対してルックアップを実行して、クレデンシャルを取得しようと試みるのです。

ただし、デフォルトではキャッシュされておらず、ec2メタデータAPIが遅い/応答しない場合、問題や速度低下の原因となることがあります。

具体的には、「Error retrieving credentials from the instance profile metadata service」というエラーが起きます。

## インスタンスプロファイルを使用する場合の認証プロバイダ
### Aws\Credentials\CredentialProvider::instanceProfile()
インスタンスプロファイルを使用する場合に指定すべき認証プロバイダなのですが、キャッシュされていません。

そこで「Memoizing Credentials」（認証情報のメモ化）を行うことで、ec2メタデータAPIへの要求頻度を下げる事ができます。

## 前回の戻り値を記憶している認証情報プロバイダの作成
### Aws\Credentials\CredentialProvider::memoize(callable $provider)
前回の戻り値を記憶している認証情報プロバイダを作成できます。

メモ化された認証情報の有効期限が切れると、memoize ラッパーはラップされたプロバイダ(今回は、インスタンスプロファイルを使用する場合の認証プロバイダ)を呼び出して認証情報の更新を試行します。

結果的に、ec2メタデータAPIへの要求頻度を下げる事ができます。

# Laravelのファイルシステムで、メモ化したい！

## 普通に、sdk使うのは簡単です。
https://docs.aws.amazon.com/ja_jp/sdk-for-php/v3/developer-guide/guide_credentials_provider.html#memoizing-credentials

## Laravelのファイルシステムの場合は？
私の場合は、Laravelのファイルシステムを使っている時に、この問題に出会ってしまったのです。

### Laravelのファイルシステムを利用する場合、S3ドライバーの設定情報
Laravelのファイルシステムを利用する場合、S3ドライバーの設定情報は、config/filesystems.php設定ファイルになります。ファイルには、S3ドライバーの設定配列の例が含まれています。

```config/filesystems.php
    'disks' => [

        'local' => [
            'driver' => 'local',
            'root' => storage_path('app'),
            'throw' => false,
        ],

        'public' => [
            'driver' => 'local',
            'root' => storage_path('app/public'),
            'url' => env('APP_URL').'/storage',
            'visibility' => 'public',
            'throw' => false,
        ],

        's3' => [
            'driver' => 's3',
            'key' => env('AWS_ACCESS_KEY_ID'),
            'secret' => env('AWS_SECRET_ACCESS_KEY'),
            'region' => env('AWS_DEFAULT_REGION'),
            'bucket' => env('AWS_BUCKET'),
            'url' => env('AWS_URL'),
            'endpoint' => env('AWS_ENDPOINT'),
            'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
            'throw' => false,
        ],

    ],
```

上記の「s3」の配列がAwsClientに渡される$configになるのですが

aws-sdk-phpのAwsClientは、$config['credentials']から、使用するプロバイダを取得するようになっています。

https://github.com/aws/aws-sdk-php/blob/master/src/AwsClient.php#L203

しかし、config/filesystems.phpには設定配列しか定義できず、例えば次のようにしても
うまく行きません

```
<?php
use Aws\Credentials\CredentialProvider;

$provider = CredentialProvider::instanceProfile();
$memoizedProvider = CredentialProvider::memoize($provider);

return [
　　　　'disks' => [
        's3' => [
            'driver' => 's3',
            'credentials' => $memoizedProvider,
            'region' => env('AWS_REGION'),
            'bucket' => env('AWS_BUCKET'),
        ],
   ],
];
```

php artisan optimizeする際に、エラーになります。
「Your configuration files are not serializable」

# 解決策！　ServiceProviderを使いましょう！
サービスプロバイダを使えば、Laravelアプリケーション全体の初期起動処理に、サービスコンテナの結合や、イベントリスナ、フィルター、それにルートなどを登録することができます。

この機能を利用して、Illuminate\Support\Facades\Storageに、オリジナルのs3ドライバーを登録することにしました。

## 新たにサービスプロバイダを作成し、's3cached' ドライバーを登録
```app/Providers/AwsS3CredentialsServiceProvider.php
<?php

namespace App\Providers;

use Aws\Credentials\CredentialProvider;
use Aws\S3\S3Client;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\ServiceProvider;
use League\Flysystem\AwsS3v3\AwsS3Adapter;
use League\Flysystem\Filesystem;

class AwsS3CredentialsServiceProvider extends ServiceProvider
{
    /**
     * Register services.
     *
     * @return void
     */
    public function register()
    {
        //
    }

    /**
     * Bootstrap services.
     *
     * @return void
     */
    public function boot()
    {
        Storage::extend('s3cached', function ($app, $config) {

            $s3Config = $config;
            $s3Config['version'] = 'latest';

            if (! empty($config['key']) && ! empty($config['secret'])) {
                $s3Config['credentials'] = Arr::only($config, ['key', 'secret', 'token']);
            } else {
                $provider = CredentialProvider::instanceProfile();
                $s3Config['credentials'] = CredentialProvider::memoize($provider);
            }

            $root = $s3Config['root'] ?? null;
            $options = $config['options'] ?? [];
            $streamReads = $config['stream_reads'] ?? false;

            return new Filesystem(new AwsS3Adapter(new S3Client($s3Config), $s3Config['bucket'], $root, $options, $streamReads), $config);
        });
    }
}
```

## サービスプロバイダを登録

```config/app.php
'providers' => [
    // Other Service Providers
    App\Providers\AwsS3CredentialsServiceProvider::class,
],
```

## Laravelのファイルシステムでは、's3cached' ドライバーを指定する

```config/filesystems.php
        's3' => [
            'driver' => 's3cached',
            'region' => env('AWS_REGION'),
            'bucket' => env('AWS_BUCKET'),
        ],
```

# Laravel9 & "league/flysystem-aws-s3-v3": "^3.0"の場合
[flysystemのページ](https://flysystem.thephpleague.com/docs/what-is-new/#:~:text=The%20put%20method%20was%20exposed%20to%20prevent%20having%20to%20choose%20between%20write%20and%20update.%20Needless%20to%20say%2C%20this%20method%20now%20has%20no%20value%20and%20has%20been%20removed)では、putメソッドは消したと書かれています...

The put method was exposed to prevent having to choose between write and update. Needless to say, this method now has no value and has been removed.

おいおい・・・という事で、修正版は以下

```
<?php

namespace App\Providers;

use Aws\Credentials\CredentialProvider;
use Aws\S3\S3Client;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\ServiceProvider;
use League\Flysystem\Filesystem as Flysystem;
use League\Flysystem\FilesystemAdapter as FlysystemAdapter;
use Illuminate\Filesystem\AwsS3V3Adapter;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter as S3Adapter;

class AwsS3CredentialsServiceProvider extends ServiceProvider
{
    /**
     * Register services.
     *
     * @return void
     */
    public function register()
    {
        //
    }

    /**
     * Bootstrap services.
     *
     * @return void
     */
    public function boot()
    {
        Storage::extend('s3cached', function ($app, $config) {
            $s3Config = $config;
            $s3Config['version'] = 'latest';

            if (! empty($config['key']) && ! empty($config['secret'])) {
                $s3Config['credentials'] = Arr::only($config, ['key', 'secret', 'token']);
            } else {
                $provider = CredentialProvider::instanceProfile();
                $s3Config['credentials'] = CredentialProvider::memoize($provider);
            }

            $root = $s3Config['root'] ?? '';
            $visibility = null;
            $mimeTypeDetector = null;
            $options = $config['options'] ?? [];
            $streamReads = $config['stream_reads'] ?? false;

            $client = new S3Client($s3Config);

            $adapter = new S3Adapter($client, $s3Config['bucket'], $root, $visibility, $mimeTypeDetector, $options, $streamReads);

            return new AwsS3V3Adapter(
                $this->createFlysystem($adapter, $config),
                $adapter,
                $s3Config,
                $client
            );

        });
    }

    /**
     * Create a Flysystem instance with the given adapter.
     *
     * @param  \League\Flysystem\FilesystemAdapter  $adapter
     * @param  array  $config
     * @return \League\Flysystem\FilesystemOperator
     */
    protected function createFlysystem(FlysystemAdapter $adapter, array $config)
    {
        return new Flysystem($adapter, Arr::only($config, [
            'directory_visibility',
            'disable_asserts',
            'temporary_url',
            'url',
            'visibility',
        ]));
    }
}
```


# さいごに
キャッシュの有効期限が切れたタイミングと、ec2メタデータAPIが遅い/応答しないタイミングが不運にも重なってしまうと、再発する可能性も考えられます。
ご紹介した方法で、しばらく運用していますが、問題だったエラー
「Error retrieving credentials from the instance profile metadata service」の発生はなく頻度としてはかなり抑えられているようです。

## デメリット？
キャッシュの有効期限内だと、インスタンスプロファイルからS3へのアクセス権を剥奪しても利用できてしまう時間が発生してしまいそうです。
（ごめんなさい、ここは検証できておりません）

皆様の参考になれば幸いです。