August 6, 2021 • ☕️☕️ 9 min read
【 環境 】
Laravel のバージョン: 8.16.1
PHP のバージョン: 7.4.7
MySQL のバージョン: 5.7
前回の続き。
「ジョブがキューに溜まった時、どんな順番で処理されるの?」
というのをトレースしてみたところ、
キューに溜まったジョブ1を開始 → ジョブ1終了 → キューに溜まったジョブ2を開始 → ジョブ2終了 → …
という挙動になっていた。
ジョブの終了を待たずに、キューに溜まったジョブをガシガシ捌いていくような挙動(非同期でジョブを捌いていく)には、どうすれば?
と実験してみたところ、一筋縄ではいかなかった(実現はできたけど、柔軟性は高くなさそうだと思った)ので、前回の時点では以下の結論となっていました。
『 Laravel において、ジョブとキューを使って並列処理を実現するのは、かなり限定的な用途になるのでは? 』
前回、キューに溜まったジョブを並列で処理させたい時、以下のように名称の異なるキューのワーカーを起動していました。
具体的は、こんな感じ。
php artisan queue:work --queue=my-queue01
php artisan queue:work --queue=my-queue02
php artisan queue:work --queue=my-queue03
しかし、『同一のワーカーを複数起動させていると、同一キューのジョブを捌いてくれる』という話を聞きました。
ワーカーの起動コマンドとしては、こんな感じ。
php artisan queue:work
php artisan queue:work
php artisan queue:work
という事で、検証してみる。
前回、ジョブのディスパッチは以下のようにしていました。
// 「default」という名前のキューを使用する
\App\Jobs\MyJob01::dispatch();
「default」の場合のキューの内容は、こんな感じです。
| id | queue | payload | attempts | reserved_at | available_at | created_at |
|:------|:--------|:-----------------------------------------------|:-----------|:--------------|:---------------|:-------------|
| 7 | default | {"uuid":"bb74","displayName":"MyJob01","(以下略)| 1 | 1627804941 | 1627804939 | 1627804939 |
| 8 | default | {"uuid":"8316","displayName":"MyJob02","(以下略)| 0 | « NULL » | 1627804944 | 1627804944 |
| 9 | default | {"uuid":"6e4d","displayName":"MyJob03","(以下略)| 0 | « NULL » | 1627804949 | 1627804949 |
この場合、キューに溜まったジョブを順番にしか捌いてくれない。
そのため、キューを分けてみました。
具体的には、onQueue メソッドに queue を指定しました。
\App\Jobs\MyJob01::dispatch()->onQueue('my-queue01');
\App\Jobs\MyJob02::dispatch()->onQueue('my-queue02');
\App\Jobs\MyJob03::dispatch()->onQueue('my-queue03');
この場合、キューの中身はこんな感じになります。
| id | queue | payload | attempts | reserved_at | available_at | created_at |
|:----|:-----------|:-----------------------------------------------|:-----------|:--------------|:---------------|:-------------|
| 16 | my-queue01 | {"uuid":"1ef0","displayName":"MyJob01",(以下略) | 0 | « NULL » | 1627807062 | 1627807062 |
| 17 | my-queue02 | {"uuid":"b0fa","displayName":"MyJob02",(以下略) | 0 | « NULL » | 1627807067 | 1627807067 |
| 18 | my-queue03 | {"uuid":"b988","displayName":"MyJob03",(以下略) | 0 | « NULL » | 1627807072 | 1627807072 |
ワーカーは以下のように3つ起動させました。
php artisan queue:work --queue=my-queue01
php artisan queue:work --queue=my-queue02
php artisan queue:work --queue=my-queue03
こうすると、ジョブを並列で処理してくれます。
詳しくは前回の記事を参照。
処理内容は前回と変わらず。
処理に 60秒かかり、20秒おきにログに出力するジョブ(MyJob01, MyJob02, MyJob03)を用意。
それを順番に呼び出す。
MyJob01, MyJob02, MyJob03 の handle メソッドの内容は全部同じです。
public function handle()
{
\Log::info(__METHOD__);
// 20 秒待機させた後、ログを出力
sleep(20);
\Log::info(__METHOD__. ': 20 seconds later');
// さらに 20 待機させた後、ログを出力
sleep(20);
\Log::info(__METHOD__. ': 40 seconds later');
// さらに 20 待機させた後、ログを出力
sleep(20);
\Log::info(__METHOD__. ': 60 seconds later');
}
}
ジョブの起動は、ブラウザから特定の URLを叩いてキックします。
ジョブをディスパッチする時、onQueue にてキュー名を指定しません。
// http://localhost:8000/api/my-job01-03
Route::get('my-job01-03', function(){
\App\Jobs\MyJob01::dispatch();
sleep(5);
echo 'my-job01';
\App\Jobs\MyJob02::dispatch();
sleep(5);
echo 'my-job02';
\App\Jobs\MyJob03::dispatch();
sleep(5);
echo 'my-job03';
return 'my-job01-03';
});
そして、ワーカーを起動。
※それぞれ、別のターミナルで起動させています。
php artisan queue:work
php artisan queue:work
php artisan queue:work
その時の jobs テーブル、こんな感じ。
全レコードに対し、attempts が 1 になりました。
| id | queue | payload | attempts | reserved_at | available_at | created_at |
|:---|:--------|:---------------------------------------------------|:-----------|:--------------|:---------------|:-------------|
| 10 | default | {"uuid":"bf49","displayName":"MyJob01","job":(以下略) | 1 | 1627953113 | 1627953113 | 1627953113 |
| 11 | default | {"uuid":"9aec","displayName":"MyJob02","job":(以下略) | 1 | 1627953119 | 1627953118 | 1627953118 |
| 12 | default | {"uuid":"0d02","displayName":"MyJob03","job":(以下略) | 1 | 1627953124 | 1627953123 | 1627953123 |
ログは、こうなりました。
[2021-08-03 01:11:53] MyJob01::handle
[2021-08-03 01:11:59] MyJob02::handle
[2021-08-03 01:12:04] MyJob03::handle
[2021-08-03 01:12:13] MyJob01::handle: 20 seconds later
[2021-08-03 01:12:19] MyJob02::handle: 20 seconds later
[2021-08-03 01:12:24] MyJob03::handle: 20 seconds later
[2021-08-03 01:12:33] MyJob01::handle: 40 seconds later
[2021-08-03 01:12:39] MyJob02::handle: 40 seconds later
[2021-08-03 01:12:44] MyJob03::handle: 40 seconds later
[2021-08-03 01:12:53] MyJob01::handle: 60 seconds later
[2021-08-03 01:12:59] MyJob02::handle: 60 seconds later
[2021-08-03 01:13:04] MyJob03::handle: 60 seconds later
意図通り、並列に処理してくれているようです。
上記では、動きを分かりやすくトレースするために、コンソールを3つ用意してそれぞれのウィンドウでワーカーを起動させましたが、
以下のように1つのウィンドウで複数のワーカーを起動させても正常に動作しました。
php artisan queue:work & php artisan queue:work & php artisan queue:work
『キューに溜まったジョブを並列で処理させたい場合、同一のワーカーを複数起動させる』
『キューが複数存在していても、ワーカーが複数立ち上がっていなければ、ジョブを並列で捌ける訳ではない』
ポイントとしては、キューに SQS等の外部リソースを使っていたとしても、並列処理をするかどうかには一切関係なく、ワーカーを複数立ち上げる必要がある、という点でしょうか。
(ワーカーさえ立ち上がっていればいいので、それを捌くマシンは1つでも行ける。)
ワーカーを複数起動させる状況としては、ECS や EKS で複数のコンテナを使用したりとか、
「コンテナが複数立ち上がってるなら、ワーカーも複数立ち上がっているので、キューを捌くコンテナも多くなっている」
という感じで。
並列処理はロジックが複雑になりがちなうえ、バグっても何が原因か特定が難しいケースが多いので、
「ローカルでは動作確認できません! AWSにアップすると、自動でそうなるはずなので(インフラチームに任せてあるので)、そこで確認が取れるはずです!
ロジックは机上何度も検証したので、行けるはずです!(キリッ)」
と言ってしまうと、動作検証が疎かになってしまうので、きちっとローカルで同じように動かせるような状況を整えておく事は重要なんじゃないかと思います。
途中、「->onQueue(‘my-queue01’)」と、キューを指定するコードを書いていますが、これは AWS SQS のリソース1つ分に相当するようです。
キューの設定を database にすると、複数のキューが存在する状態を仮想的に作っているのだそうな。
なので、「->onQueue(‘my-queue01’)」「->onQueue(‘my-queue02’)」 … と、複数のキューを使用する事で初めて実現できる機能を作り、それを AWS 上で実現する場合、
作成した分の AWS SQS が必要となります。
キューに溜まったジョブを並列で複数同時に処理するには、かならずしもコンテナが複数必要なわけではなく、
1つのマシンに複数のワーカーを動かしておけば、それだけで複数のジョブを並列で処理できます。
なので、マシン1つあたりの CPUやメモリの使用状況に余裕があるなら、
「コンテナを増やす」ではなく、
「1つのコンテナで起動するワーカーの数を増やす」
というアプローチもアリかも。
(追記)
インフラに詳しい人に聞いてみたところ、そんなの超常識らしい。
結局、書き手の知識が足りてなかっただけというオチ。