はじめに
Laravelで、必要なときだけ重い処理を裏側で走らせたいことがある。頻度的にQueueワーカーを常駐させるほどではなく、かといってWebリクエストの中で同期実行するとPHP-FPMやALBのタイムアウトに引っかかる。
そこでECSのRunTaskで都度コンテナを起動し、終わったら(課金も含めて)消える「OneShot」という仕組みをLaravelアプリに作った。ただ、ECS環境でしか動かないとローカルでの動作確認がそのたびにECS起動待ちになってつらいので、環境ごとに実装を切り替えられるようにした。
この記事では、その切り替えの設計と、あわせて必要になったタイムアウトの二重化について書く。
全体像: 「都度起動する」をインターフェースで抽象化する
全体の構造はこんな感じ。呼び出し側はOneShotTaskDispatcherというインターフェースにしか依存せず、実際にECSで起動するかローカルでその場実行するかはAppServiceProviderが環境で振り分ける。
以降の章で、この図の各パーツを順番に説明していく。まずはインターフェース自体から。
// OneShotTaskDispatcher.php
<?php
interface OneShotTaskDispatcher
{
/**
* 渡されたコマンドを1回限りのタスクとして実行する。
*
* ECS環境: RunTaskでコンテナを起動したら、処理の終了を待たずにすぐ返る。
* ローカル環境: プロセスとしてその場で実行し、終わるまで待ってから返る。
*
* 呼び出し側が戻り値を見て分岐しなくて済むよう、戻り値は持たせていない。
*
* @param list<string> $command
*/
public function dispatch(array $command, int $timeoutSeconds = 1800): void;
}
呼び出し側は$dispatcher->dispatch(['php', 'artisan', 'command:hoge'])と叩くだけで、裏側がECSなのかローカルのサブプロセス実行なのか、同期か非同期かを一切気にしなくてよい。
ECS環境の実装: RunTaskでコンテナを都度起動する
ECS環境用の実装はAWS SDKのEcsClient::runTask()を叩くだけ。ポイントは、起動リクエストが通った時点ですぐ処理を返し、完了を待たないようにしていること。起動したタスクのARNは、成功したかどうかの判定には使うものの、呼び出し元には返さずログに出すだけにとどめている。
// EcsOneShotTaskDispatcher.php
<?php
use Aws\Ecs\EcsClient;
use Illuminate\Support\Facades\Log;
use RuntimeException;
final class EcsOneShotTaskDispatcher implements OneShotTaskDispatcher
{
public function __construct(
private readonly EcsClient $ecsClient,
private readonly OneShotRunTaskParams $params,
) {}
public function dispatch(array $command, int $timeoutSeconds = 1800): void
{
$guardedCommand = ['timeout', '-k', '20', (string) $timeoutSeconds, ...$command];
$result = $this->ecsClient->runTask([
'cluster' => $this->params->clusterName,
'taskDefinition' => $this->params->taskDefinitionFamily,
'launchType' => 'FARGATE',
'networkConfiguration' => [
'awsvpcConfiguration' => [
'subnets' => $this->params->subnetIds,
'securityGroups' => [$this->params->securityGroupId],
'assignPublicIp' => 'DISABLED',
],
],
'overrides' => [
'containerOverrides' => [
[
'name' => $this->params->containerName,
'command' => $guardedCommand,
],
],
],
]);
$taskArn = $result['tasks'][0]['taskArn'] ?? null;
if (! is_string($taskArn)) {
$reason = $result['failures'][0]['reason'] ?? '詳細不明';
throw new RuntimeException("ECSタスクの起動リクエストが失敗しました({$reason})");
}
Log::info('OneShotタスクを起動しました', ['taskArn' => $taskArn]);
}
}
起動先クラスターやサブネットなどはOneShotRunTaskParamsというパラメータオブジェクトにまとめて注入している(envから組み立てる部分は割愛)。taskArnを戻り値にせずログだけに出すことで、「ECS環境ではタスクARNが手に入る」という実装都合をインターフェースの外に漏らさないようにしている。$guardedCommandで元のコマンドをtimeoutコマンドで包んでいる部分は次の章で説明する。
ローカル環境の実装: その場でサブプロセス実行する
ローカル開発やテストのたびにECSを起動するわけにはいかないので、ローカル用の実装ではSymfonyのProcessコンポーネントでその場実行する。インターフェースが同じなので、呼び出し側のコードはECS環境と共通のまま動作確認できる。
あえて同期的に最後まで待つ実装にしているのは、フィーチャーテストの都合が大きい。ECS環境と同じように非同期にしてしまうと、テストでは「コマンドを投げた」ところまでしか検証できず、処理が最後まで走った後の状態をアサートできない。ローカル/テスト環境だけは同期実行にすることで、dispatch()を呼んだ直後に結果を普通に検証できるようにしている。
// LocalOneShotTaskDispatcher.php
<?php
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Symfony\Component\Process\Process;
final class LocalOneShotTaskDispatcher implements OneShotTaskDispatcher
{
public function dispatch(array $command, int $timeoutSeconds = 1800): void
{
$process = new Process($command, base_path());
$process->setTimeout($timeoutSeconds);
try {
$process->run(function (string $type, string $buffer): void {
Log::debug('[LocalOneShotTaskDispatcher] '.trim($buffer));
});
} catch (ProcessTimedOutException) {
throw new RuntimeException("制限時間({$timeoutSeconds}秒)を超えたためプロセスを強制終了しました。");
}
if (! $process->isSuccessful()) {
throw new RuntimeException('コマンドが異常終了しました: '.$process->getErrorOutput());
}
}
}
AppServiceProviderで環境ごとに実装を切り替える
あとは、AppServiceProviderでどちらの実装を使うかを環境で振り分けるだけ。
// app/Providers/AppServiceProvider.php
public function register(): void
{
// local/testingだけローカル実行、それ以外はECS RunTaskを使う
$this->app->bind(OneShotTaskDispatcher::class, fn ($app) => $app->environment('local', 'testing')
? $app->make(LocalOneShotTaskDispatcher::class)
: $app->make(EcsOneShotTaskDispatcher::class));
}
この振り分けをAppServiceProvider1箇所に閉じ込めておくことで、コントローラーやコマンドの呼び出しコードは常にOneShotTaskDispatcherインターフェースにだけ依存すればよくなる。テストではモックに差し替えることも、LocalOneShotTaskDispatcherをそのまま使って実プロセスを実行させることもできる。
タイムアウトを二重にかけて「起動しっぱなし」を防ぐ
RunTaskは起動している時間で課金されるので、コマンドがバグって終了しなかった場合に**起動し続けてしまう(課金され続ける)**のがいちばん怖い。
コマンド側でset_time_limitのようなPHPのタイムアウトを仕込む手もあるが、外部コマンド呼び出しやブロッキングI/O待ちの間は効かないことがあり、確実に止まる保証がない。そこで、PHPの外側からOSレベルのtimeoutコマンドでプロセスごと包み、何が起きても確実に強制終了できるようにしている。
$guardedCommand = ['timeout', '-k', '20', (string) $timeoutSeconds, ...$command];
timeout <秒> <コマンド>で指定秒数後にTERMシグナルを送り、-k 20で「TERMを送ってから20秒経っても終了しなければKILLする」を追加している。コマンド自体がTERMを無視するようなバグを抱えていても、最終的には確実にコンテナが終了するようにしている。
ローカル用のLocalOneShotTaskDispatcher側はProcess::setTimeout()を使っており、こちらもタイムアウト時はProcessTimedOutExceptionを捕捉して例外に変換している。ECS環境・ローカルどちらの実装でも「指定時間を超えたら強制終了する」という挙動を揃えているので、ローカルで動作確認したときの体感がそのままECS環境での挙動の目安になる。
呼び出し側の例
実際の呼び出し側はこんな感じになる。OneShotTaskDispatcherにartisanコマンドを渡すだけ。
// ReportController.php
class ReportController extends Controller
{
public function runAggregate(
RunAggregateRequest $request,
OneShotTaskDispatcher $dispatcher,
): JsonResponse {
$dispatcher->dispatch([
'php', 'artisan', 'report:aggregate', $request->string('month')->value(),
]);
return response()->json(['status' => 'accepted'], JsonResponse::HTTP_ACCEPTED);
}
}
dispatch()がvoidなので、コントローラー側は戻り値を見て分岐する必要がなく、常に「受け付けました」の202を返すだけでいい。ECS環境かローカルかで裏側が非同期か同期かは変わるが、それはOneShotTaskDispatcherの実装に閉じた話であり、呼び出し側には一切影響しない。
さいごに
「単発で重い処理を都度実行したいけど、常駐サーバーは持ちたくない」という思いつきを、ECS RunTaskをLaravelアプリから直接叩く形にすることで実現してみた。インターフェースで抽象化して環境ごとに実装を切り替えることで、ローカルではECSを意識せず動作確認でき、ECS環境ではコンテナを都度起動できるようになっている。あわせて、起動しっぱなしで課金され続けることがないよう、タイムアウトも二重にかけてある。