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?

ECS RunTaskをLaravelから使うときの環境差分とタイムアウト対策

0
Posted at

はじめに

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環境ではコンテナを都度起動できるようになっている。あわせて、起動しっぱなしで課金され続けることがないよう、タイムアウトも二重にかけてある。

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?