なにがあったか
Laravel のセッション保存先を DynamoDB にしていたところ、アクセス数が多い429 が発生した。
リクエスト数によるLaravelのThrottleの429ではなかった。
構成は次のようなものでした。
- Laravel 10
- PHP-FPM
- ECS
- セッション保存先に DynamoDB を利用
- AWS 認証は ECS task role
原因
原因は DynamoDB そのものではなく、PHP-FPM のリクエストごとに AWS SDK の DynamoDbClient が作られ、そのたびに ECS task role の一時認証情報を取得しに行く構成になっていたことでした。
対策として、AWS SDK for PHP の credentials provider cache を APCu 経由で使い、ECS task role の一時認証情報をプロセス内共有キャッシュに保存するようにしました。
Laravel の DynamoDB cache driver は、内部で Aws\DynamoDb\DynamoDbClient を作成します。
ECS task role を使っている場合、AWS SDK は ECS の credentials endpoint から一時認証情報を取得します。通常のアプリケーションではこれで問題ありませんが、PHP-FPM ではリクエストごとに Laravel アプリケーションが boot され、SDK client も作られ直しやすいです。
そのため、セッションアクセスが多いと次のような流れが頻繁に発生します。
HTTP request
-> Laravel boot
-> DynamoDB session/cache store resolve
-> DynamoDbClient create
-> ECS credentials endpoint access
-> DynamoDB access
アクセス数が増えると、DynamoDB ではなく ECS の credentials endpoint 側で 429 が出る
最初に疑ったこと
最初は DynamoDB の読み書きが多すぎるのではないかと考えました。
しかし、今回の問題は DynamoDB の throughput や API rate ではなく、AWS SDK が DynamoDB にアクセスする前段で必要になる「認証情報の取得」が多すぎることでした。
ここを取り違えると、DynamoDB のキャパシティやセッション TTL を調整しても根本解決になりません。
AWS SDK for PHP の認証情報キャッシュ
AWS SDK for PHP v3 には、credentials provider を PSR-16 cache で包む仕組みがあります。
関連するクラスは次のあたりです。
Aws\Credentials\CredentialProviderAws\Psr16CacheAdapter
SDK の default provider chain に cache を渡すと、ECS task role などから取得した一時認証情報が aws_cached_ecs_credentials のようなキーで保存されます。
Laravel には APCu cache store があるため、これを PSR-16 adapter 経由で SDK に渡します。
use Aws\Credentials\CredentialProvider;
use Aws\Psr16CacheAdapter;
use Illuminate\Support\Facades\Cache;
$credentials = CredentialProvider::defaultProvider([
'credentials' => new Psr16CacheAdapter(Cache::store('apc')),
]);
APCu は PHP-FPM worker プロセス上の共有メモリなので、同じ worker 内のリクエストを跨いで credentials を再利用できます。
Laravel 側の問題
Laravel 10.24 の標準 DynamoDB cache driver は、CacheManager の中で DynamoDbClient を直接生成します。
そのため、アプリケーション側から credentials provider cache を注入する設定口がありません。
そこで、Cache::extend('dynamodb', ...) で DynamoDB cache driver を上書きし、credentials cache 付きの DynamoDbClient を作るようにしました。
実装例
今回追加した Service Provider は次のような形です。
<?php
namespace App\Providers;
use Aws\Credentials\CredentialProvider;
use Aws\DynamoDb\DynamoDbClient;
use Aws\Psr16CacheAdapter;
use Illuminate\Cache\DynamoDbStore;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\ServiceProvider;
class SessionServiceProvider extends ServiceProvider
{
public function register(): void
{
$createDynamoDbClient = fn (array $config): DynamoDbClient => $this->createDynamoDbClient($config);
$this->app->booting(function () use ($createDynamoDbClient) {
Cache::extend('dynamodb', function ($app, $config) use ($createDynamoDbClient) {
$store = new DynamoDbStore(
$createDynamoDbClient($config),
$config['table'],
$config['attributes']['key'] ?? 'key',
$config['attributes']['value'] ?? 'value',
$config['attributes']['expiration'] ?? 'expires_at',
$config['prefix'] ?? config('cache.prefix'),
);
return Cache::repository($store);
});
});
}
public function boot(): void
{
}
private function createDynamoDbClient(array $config): DynamoDbClient
{
$dynamoConfig = [
'region' => $config['region'],
'version' => 'latest',
'http' => [
'timeout' => 5,
'connect_timeout' => 5,
],
];
if (! empty($config['endpoint'])) {
$dynamoConfig['endpoint'] = $config['endpoint'];
}
if (! empty($config['key']) && ! empty($config['secret'])) {
$dynamoConfig['credentials'] = Arr::only($config, ['key', 'secret']);
if (! empty($config['token'])) {
$dynamoConfig['credentials']['token'] = $config['token'];
}
} else {
$dynamoConfig['credentials'] = CredentialProvider::defaultProvider([
'credentials' => new Psr16CacheAdapter(Cache::store('apc')),
]);
}
return new DynamoDbClient($dynamoConfig);
}
}
Provider は config/app.php に登録します。
'providers' => [
// ...
App\Providers\SessionServiceProvider::class,
],
実装時の注意点
CredentialProvider::cache() ではなく defaultProvider(['credentials' => ...]) を使う
CredentialProvider::cache() で provider 全体を包む方法もあります。
ただし、AWS SDK の default provider は ECS credentials provider などの内部 provider にも cache 設定を渡す作りになっています。今回は SDK の想定に寄せて、defaultProvider() の credentials オプションに PSR-16 cache adapter を渡しました。
Laravel 標準実装との差分を小さくする
Laravel の DynamoDB cache driver は、次のような設定を扱います。
regionkeysecrettokenendpointtableattributesprefix
独自 driver にするときは、標準実装にある token や endpoint を落とさないようにします。
Cache::extend() の Closure の $this に注意する
Laravel の Cache::extend() に渡した Closure は、実行時に CacheManager 側へ bind されます。
そのため、Closure 内で Service Provider の private method を直接 $this->createDynamoDbClient() のように呼ぶと壊れる可能性があります。
今回は外側で factory Closure を作って capture しました。
$createDynamoDbClient = fn (array $config): DynamoDbClient => $this->createDynamoDbClient($config);
Cache::extend('dynamodb', function ($app, $config) use ($createDynamoDbClient) {
// ...
});
CLI で APCu を検証するときは apc.enable_cli=1
ローカルで php artisan tinker などから検証する場合、CLI では APCu が無効になっていることがあります。
その場合、次のように apc.enable_cli=1 を付けないと、APCu cache store に保存した値を読めません。
php -d apc.enable_cli=1 artisan tinker
PHP-FPM 上の本番挙動とは設定が異なる点に注意します。
テスト
この Provider が意図通りに動くか、次の観点で確認しました。
-
Cache::store('dynamodb')がIlluminate\Cache\DynamoDbStoreとして解決されること - 作られた
DynamoDbClientが cache 済み credentials を使うこと - LocalStack の DynamoDB に対して
put/getできること
テスト例です。
public function DynamoDBクライアントはAPCu互換キャッシュ済みECS認証情報を使用すること(): void
{
$this->disableStaticAwsCredentials();
$cache = Cache::store('apc');
$cache->set(
'aws_cached_ecs_credentials',
new Credentials('cached-key', 'cached-secret', 'cached-token', time() + 3600),
3600,
);
$store = Cache::store('dynamodb')->getStore();
$this->assertInstanceOf(DynamoDbStore::class, $store);
$this->assertSame(
'cached-key',
$store->getClient()->getCredentials()->wait()->getAccessKeyId(),
);
}
実行した確認は次の通りです。
php -l app/Providers/SessionServiceProvider.php
php -l tests/Unit/Providers/SessionServiceProviderTest.php
./vendor/bin/php-cs-fixer fix --config=./.php-cs-fixer.php --dry-run --diff app/Providers/SessionServiceProvider.php
./vendor/bin/phpunit tests/Unit/Providers/SessionServiceProviderTest.php
ほかの選択肢
環境変数で固定 credentials を渡す
AWS_ACCESS_KEY_ID と AWS_SECRET_ACCESS_KEY を環境変数で渡せば、ECS credentials endpoint には行かなくなります。
ただし、ECS task role を使うメリットが薄れます。長期 credentials の管理も必要になるため、基本的には避けたい方法です。
DynamoDB session をやめる
Redis や database session に戻せば、この問題は起きません。
ただし、今回は DynamoDB session の利用には理由があったため、保存先を変えずに SDK credentials の取得回数を減らす方針にしました。
SDK client を Laravel singleton にする
同一リクエスト内の client 再生成は抑制できます。
ただし、PHP-FPM のリクエスト跨ぎでは効きません。429 対策としては、SDK client の singleton より credentials の永続キャッシュが重要です。
まとめ
Laravel の DynamoDB session で ECS credentials endpoint の 429 が疑われる場合、見るべきなのは DynamoDB の負荷だけではありません。
PHP-FPM 環境では、AWS SDK client がリクエストごとに作られ、ECS task role credentials の取得も増えやすくなります。
対策としては、AWS SDK for PHP の credentials provider cache を APCu などの PSR-16 cache と組み合わせるのが有効でした。
参考
- AWS SDK for PHP v3 Credentials: https://docs.aws.amazon.com/sdk-for-php/v3/developer-guide/guide_credentials.html
- AWS SDK for PHP
CredentialProvider: https://github.com/aws/aws-sdk-php/blob/3.279.5/src/Credentials/CredentialProvider.php - Laravel 10
CacheManager: https://github.com/laravel/framework/blob/v10.24.0/src/Illuminate/Cache/CacheManager.php - Laravel 10
SessionManager: https://github.com/laravel/framework/blob/v10.24.0/src/Illuminate/Session/SessionManager.php